From b32ecb24521952b3784c54394fa4c17f95389dfd Mon Sep 17 00:00:00 2001 From: Makisuo Date: Wed, 19 Aug 2026 23:55:02 +0200 Subject: [PATCH 1/5] fix(ios): make the organization explicit everywhere the app enters a screen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The app treated "which organization" as ambient state. It lived only in the Clerk session token's active-organization claim, and nothing else carried it, so every entry point resolved against whichever organization happened to be selected. The reported symptom was notifications. `MobilePushService` has always sent `maple_org_id`; nothing on the device read it, so tapping an alert for org B while org A was active pushed the incident straight onto the Alerts stack, where the request went out under org A's token and came back 404 — the app said the incident did not exist. Home Screen widgets had the same shape: one snapshot per surface, re-pointed silently on every switch. Push, widget and Live Activity taps now go through one entry point, `DestinationOpener`, which switches organization *before* navigating. Order matters: `select` bumps `dataGeneration` and every detail screen keys its load on it, so pushing first would build the screen under the old generation, fire the 404, and only then re-run — the user would watch the bug go past. A short toast acknowledges the switch; an organization the user has left is refused rather than opened onto an error screen. The subtle case is cold start. A tap launches the app and fires `didReceive` before `RootView` has loaded memberships, so a membership-first ordering would tell every cold cross-organization tap that the user is not a member. `DestinationResolver` parks instead and `RootView` re-asks once the session settles; it is a pure function in `MapleWidgetData` with a test per rule, because the app target has no test bundle. Widgets can now be pinned to an organization. A Clerk token carries exactly one, and `setActive` is global session state the foreground is using, so the API had to accept an explicit one: `x-maple-org-id`, verified against the caller's Clerk memberships. Two invariants hold it together. Naming the organization you already have is free — no verifier call — which is what lets a client send the header unconditionally instead of branching. And everywhere membership cannot be proven (self-hosted, `MAPLE_ORG_ID_OVERRIDE`, API keys, no verifier wired) the header is a 403, never a silent ignore: ignoring it is precisely the failure where a widget renders one organization's incidents under another's name and nobody notices. Membership lookups are cached per *user*, never per (user, organization). The organization arrives in a request header, so a per-pair key would turn that header into a Clerk-request amplifier; caching the whole set gives negative answers for free and makes header rotation cost nothing. The shared TTL is the revocation lag — five minutes, documented on the constant, with a Clerk webhook the proper fix. On the device, snapshots move to per-organization keys, `IssuesWidget` becomes an `AppIntentConfiguration` (the `kind` string is untouched, so placed widgets migrate rather than disappear), and the publish set is driven by which organizations actually have a widget placed, capped at three. Publishing every membership would be 48 requests a round for an account in twelve, most of them for organizations nobody pinned — and iOS answers that appetite with less background time, so the widgets would end up less current, not more. `IncidentActivityAttributes` gains an optional `organization_id`, and optional is load-bearing: attributes are the static half of a Live Activity, so one already running can never gain the field, and a required one would make iOS silently drop every start push from a server that has not deployed yet. Both sides tolerate its absence, so they can ship in either order. --- apps/api/src/routes/v2/api-keys.http.test.ts | 48 ++++ apps/api/src/runtime/http-graph.ts | 11 + .../services/auth/ApiAuthorizationV2Layer.ts | 40 ++- apps/api/src/services/auth/AuthService.ts | 36 ++- .../auth/OrgMembershipService.test.ts | 72 ++++++ .../src/services/auth/OrgMembershipService.ts | 238 +++++++++++++++++ .../services/push/MobilePushService.test.ts | 1 + .../src/services/push/MobilePushService.ts | 5 + apps/electric-sync/src/auth/TenantResolver.ts | 16 +- apps/ios/Maple/App/DestinationOpener.swift | 164 ++++++++++++ apps/ios/Maple/App/MapleApp.swift | 17 +- .../Maple/App/OrganizationNoticeView.swift | 54 ++++ apps/ios/Maple/App/RootView.swift | 35 ++- apps/ios/Maple/App/Route.swift | 47 +--- apps/ios/Maple/Auth/SessionController.swift | 33 +++ apps/ios/Maple/Push/PushRegistrar.swift | 18 +- apps/ios/Maple/Telemetry/Telemetry.swift | 20 +- apps/ios/Maple/Widgets/WidgetPublisher.swift | 239 +++++++++++++++--- .../Sources/MapleAPI/MapleClient.swift | 62 ++++- .../Sources/MapleAPI/Middleware.swift | 42 ++- .../MapleWidgetData/DestinationResolver.swift | 65 +++++ .../IncidentActivityAttributes.swift | 23 +- .../PublishedOrganizationIndex.swift | 120 +++++++++ .../SelectOrganizationIntent.swift | 79 ++++++ .../MapleWidgetData/SelectServiceIntent.swift | 101 ++++++++ .../MapleWidgetData/WidgetDeepLink.swift | 140 ++++++++++ .../Sources/MapleWidgetData/WidgetKinds.swift | 30 ++- .../MapleWidgetData/WidgetSnapshotStore.swift | 29 ++- .../OrganizationMiddlewareTests.swift | 105 ++++++++ .../DestinationResolverTests.swift | 72 ++++++ .../IncidentActivityAttributesTests.swift | 42 ++- .../PublishedOrganizationIndexTests.swift | 148 +++++++++++ .../WidgetDeepLinkTests.swift | 89 +++++++ apps/ios/Widgets/IssuesWidget.swift | 100 +++++++- apps/ios/Widgets/IssuesWidgetView.swift | 124 ++++++++- apps/ios/Widgets/SelectServiceIntent.swift | 72 ------ apps/ios/Widgets/ThroughputWidget.swift | 70 ++++- apps/ios/Widgets/ThroughputWidgetView.swift | 76 +++++- packages/auth/src/auth.test.ts | 214 +++++++++++++++- packages/auth/src/index.ts | 183 +++++++++++++- packages/domain/src/http/current-tenant.ts | 37 ++- packages/domain/src/http/index.ts | 4 + packages/domain/src/http/v2/auth.ts | 5 +- packages/domain/src/http/v2/errors.ts | 19 ++ packages/domain/src/http/v2/openapi.test.ts | 12 + 45 files changed, 2897 insertions(+), 260 deletions(-) create mode 100644 apps/api/src/services/auth/OrgMembershipService.test.ts create mode 100644 apps/api/src/services/auth/OrgMembershipService.ts create mode 100644 apps/ios/Maple/App/DestinationOpener.swift create mode 100644 apps/ios/Maple/App/OrganizationNoticeView.swift create mode 100644 apps/ios/Packages/MapleAPI/Sources/MapleWidgetData/DestinationResolver.swift create mode 100644 apps/ios/Packages/MapleAPI/Sources/MapleWidgetData/PublishedOrganizationIndex.swift create mode 100644 apps/ios/Packages/MapleAPI/Sources/MapleWidgetData/SelectOrganizationIntent.swift create mode 100644 apps/ios/Packages/MapleAPI/Sources/MapleWidgetData/SelectServiceIntent.swift create mode 100644 apps/ios/Packages/MapleAPI/Sources/MapleWidgetData/WidgetDeepLink.swift create mode 100644 apps/ios/Packages/MapleAPI/Tests/MapleAPITests/OrganizationMiddlewareTests.swift create mode 100644 apps/ios/Packages/MapleAPI/Tests/MapleWidgetDataTests/DestinationResolverTests.swift create mode 100644 apps/ios/Packages/MapleAPI/Tests/MapleWidgetDataTests/PublishedOrganizationIndexTests.swift create mode 100644 apps/ios/Packages/MapleAPI/Tests/MapleWidgetDataTests/WidgetDeepLinkTests.swift delete mode 100644 apps/ios/Widgets/SelectServiceIntent.swift diff --git a/apps/api/src/routes/v2/api-keys.http.test.ts b/apps/api/src/routes/v2/api-keys.http.test.ts index bcf2ccb96..b73d59b7c 100644 --- a/apps/api/src/routes/v2/api-keys.http.test.ts +++ b/apps/api/src/routes/v2/api-keys.http.test.ts @@ -288,6 +288,54 @@ describe("v2 api_keys over HTTP", () => { await harness.dispose() }) + // `x-maple-org-id` names an organization explicitly instead of relying on the + // credential's own. This deployment is self-hosted, which has no membership + // directory to check a selection against — so the header is REJECTED rather + // than ignored. Silently ignoring it is the failure that would render one + // organization's data under another's name. + it("rejects an organization selection it cannot verify, in the v2 envelope", async () => { + const harness = makeHarness() + const sessionToken = await harness.bootstrapSession() + + const { status, body } = await harness.request("GET", "/v2/api_keys", { + token: sessionToken, + headers: { "x-maple-org-id": "org_other" }, + }) + + // 403, not the 401 a missing organization produces: the credential is fine. + expect(status).toBe(403) + expect((body as { error?: { code?: string } }).error?.code).toBe("organization_access_denied") + await harness.dispose() + }) + + it("serves the credential's own organization when the header names it", async () => { + const harness = makeHarness() + const sessionToken = await harness.bootstrapSession() + + const { status } = await harness.request("GET", "/v2/api_keys", { + token: sessionToken, + // The free no-op, which is what lets a client send the header always. + headers: { "x-maple-org-id": "default" }, + }) + + expect(status).toBe(200) + await harness.dispose() + }) + + it("rejects an organization selection made with an API key", async () => { + const harness = makeHarness() + const key = await harness.bootstrapKey() + + const { status, body } = await harness.request("GET", "/v2/api_keys", { + token: key.secret, + headers: { "x-maple-org-id": "org_other" }, + }) + + expect(status).toBe(403) + expect((body as { error?: { code?: string } }).error?.code).toBe("organization_access_denied") + await harness.dispose() + }) + it("allows requests when the limiter fails open", async () => { const harness = makeHarness(() => Effect.succeed("failed_open")) const key = await harness.bootstrapKey() diff --git a/apps/api/src/runtime/http-graph.ts b/apps/api/src/runtime/http-graph.ts index 86c29ec83..269caf101 100644 --- a/apps/api/src/runtime/http-graph.ts +++ b/apps/api/src/runtime/http-graph.ts @@ -60,6 +60,9 @@ import { ApiAuthorizationLayer } from "@/services/auth/ApiAuthorizationLayer" import { ApiAuthorizationV2Layer } from "@/services/auth/ApiAuthorizationV2Layer" import { SessionAuthorizationLayer } from "@/services/auth/SessionAuthorizationLayer" import { ApiV2RateLimiter } from "@/services/auth/ApiV2RateLimiter" +import { EdgeCacheService } from "@maple/cache" +import { CacheBackendLive } from "@/platform/CacheBackendLive" +import { OrgMembershipService } from "@/services/auth/OrgMembershipService" import { ApiKeysService } from "@/services/org/ApiKeysService" const HealthRouter = HttpRouter.use((router) => router.add("GET", "/health", HttpServerResponse.text("OK"))) @@ -166,6 +169,14 @@ export const ApiAuthLive = Layer.mergeAll( ).pipe( Layer.provideMerge(ApiV2RateLimiter.layer), Layer.provideMerge(ApiKeysService.layer), + // Membership verification for `x-maple-org-id`. Only the v2 layer asks for + // it; without it that layer cannot build, which is deliberate — the header + // must never end up silently ignored in a runtime that forgot to wire this. + Layer.provideMerge( + OrgMembershipService.layer.pipe( + Layer.provide(EdgeCacheService.layer.pipe(Layer.provide(CacheBackendLive))), + ), + ), Layer.provideMerge(Env.layer), ) diff --git a/apps/api/src/services/auth/ApiAuthorizationV2Layer.ts b/apps/api/src/services/auth/ApiAuthorizationV2Layer.ts index 0c4c63d80..929eec054 100644 --- a/apps/api/src/services/auth/ApiAuthorizationV2Layer.ts +++ b/apps/api/src/services/auth/ApiAuthorizationV2Layer.ts @@ -6,11 +6,14 @@ import { scopeAllows, V2InsufficientScope, V2InvalidCredentials, + V2OrganizationAccessDenied, V2RateLimited, } from "@maple/domain/http/v2" import { Effect, Layer, Option, Schema } from "effect" import { ApiKeysService } from "@/services/org/ApiKeysService" +import { ORG_SELECTION_HEADER } from "@maple/auth" import { makeResolveTenant } from "./AuthService" +import { OrgMembershipService } from "@/services/auth/OrgMembershipService" import { annotateAuthSpan } from "@/services/auth/auth-span" import { Env } from "@/platform/Env" import { @@ -31,6 +34,9 @@ const getBearerToken = (headers: Record): string | u return token } +const getOrgSelectionHeader = (headers: Record): string | undefined => + headers[ORG_SELECTION_HEADER] ?? headers[ORG_SELECTION_HEADER.toUpperCase()] + const requestPath = (url: string): string => { const queryStart = url.indexOf("?") return queryStart === -1 ? url : url.slice(0, queryStart) @@ -49,7 +55,20 @@ export const ApiAuthorizationV2Layer = Layer.effect( const env = yield* Env const apiKeys = yield* ApiKeysService const rateLimiter = yield* ApiV2RateLimiter - const resolveTenant = makeResolveTenant(env) + // The one resolver wired for organization selection: `x-maple-org-id` is + // a v2-client affordance (the iOS app publishing a widget snapshot per + // organization), and every other resolver rejects the header instead. + // + // Optional so a route test can build this layer without a membership + // directory. Absent, the header is *rejected* rather than ignored — a + // runtime that forgot to wire it serves 403s, never another org's data. + const membership = yield* Effect.serviceOption(OrgMembershipService) + const resolveTenant = makeResolveTenant( + env, + undefined, + undefined, + Option.match(membership, { onNone: () => undefined, onSome: (service) => service.verify }), + ) return AuthorizationV2.of({ bearer: (httpEffect) => @@ -99,6 +118,19 @@ export const ApiAuthorizationV2Layer = Layer.effect( ) } + // An API key is already organization-bound, so a selection could + // only ever widen it. This path returns before `resolveTenant` + // runs, so the guard inside the resolver never sees it — the + // check has to be here too. + const requestedOrg = getOrgSelectionHeader(request.headers) + if (requestedOrg !== undefined && requestedOrg !== resolved.orgId) { + return yield* Effect.fail( + V2OrganizationAccessDenied.make( + "An API key cannot select a different organization.", + ), + ) + } + const tenant = new CurrentTenant.TenantSchema({ orgId: resolved.orgId, userId: resolved.userId, @@ -109,7 +141,11 @@ export const ApiAuthorizationV2Layer = Layer.effect( return yield* Effect.provideService(httpEffect, CurrentTenant.Context, tenant) } - const tenant = yield* resolveTenant(request.headers) + const tenant = yield* resolveTenant(request.headers).pipe( + Effect.catchTag("@maple/http/errors/OrganizationAccessDeniedError", (error) => + Effect.fail(V2OrganizationAccessDenied.make(error.message)), + ), + ) yield* annotateAuthSpan("session", { orgId: tenant.orgId, userId: tenant.userId }) return yield* Effect.provideService( httpEffect, diff --git a/apps/api/src/services/auth/AuthService.ts b/apps/api/src/services/auth/AuthService.ts index 6826332a8..cfbd42a34 100644 --- a/apps/api/src/services/auth/AuthService.ts +++ b/apps/api/src/services/auth/AuthService.ts @@ -1,4 +1,6 @@ import { + AuthorizationUnavailableError, + OrganizationAccessDeniedError, SelfHostedAuthDisabledError, SelfHostedInvalidPasswordError, SelfHostedLoginResponse, @@ -13,8 +15,9 @@ import { makeResolveTenant, type TenantContext, } from "@maple/auth" -import { Context, Effect, Layer } from "effect" +import { Context, Effect, Layer, Option } from "effect" import { Env } from "@/platform/Env" +import { OrgMembershipService } from "@/services/auth/OrgMembershipService" // The pure tenant-resolution + self-hosted login primitives live in the shared // `@maple/auth` package (consumed by apps/api AND the standalone @@ -27,8 +30,24 @@ export { makeResolveTenant, type TenantContext } type HeaderRecord = Record export interface AuthServiceApi { - readonly resolveTenant: (headers: HeaderRecord) => Effect.Effect - readonly resolveMcpTenant: (headers: HeaderRecord) => Effect.Effect + readonly resolveTenant: ( + headers: HeaderRecord, + ) => Effect.Effect< + TenantContext, + UnauthorizedError | OrganizationAccessDeniedError | AuthorizationUnavailableError + > + /** + * Same resolver, wider credentials. It can reject an organization selection + * too: `makeResolveMcpTenant` passes no membership verifier, so + * `x-maple-org-id` naming anything other than the credential's own + * organization is a 403 rather than a silent ignore. + */ + readonly resolveMcpTenant: ( + headers: HeaderRecord, + ) => Effect.Effect< + TenantContext, + UnauthorizedError | OrganizationAccessDeniedError | AuthorizationUnavailableError + > readonly loginSelfHosted: ( password: string, ) => Effect.Effect @@ -46,7 +65,16 @@ export class AuthService extends Context.Service()( { make: Effect.gen(function* () { const env = yield* Env - const resolveTenant = makeResolveTenant(env) + // Optional on purpose. Where it is absent — tests, and any runtime that + // has not wired it — `makeResolveTenant` rejects `x-maple-org-id` + // outright rather than ignoring it, which is the safe half of the + // no-silent-ignore rule. + const membership = yield* Effect.serviceOption(OrgMembershipService) + const verifyOrgMembership = Option.match(membership, { + onNone: () => undefined, + onSome: (service) => service.verify, + }) + const resolveTenant = makeResolveTenant(env, undefined, undefined, verifyOrgMembership) const resolveMcpTenant = makeResolveMcpTenant(env) const loginSelfHosted = makeLoginSelfHosted(env) const refreshSelfHostedSession = makeRefreshSelfHostedSession(env) diff --git a/apps/api/src/services/auth/OrgMembershipService.test.ts b/apps/api/src/services/auth/OrgMembershipService.test.ts new file mode 100644 index 000000000..f43ac9f30 --- /dev/null +++ b/apps/api/src/services/auth/OrgMembershipService.test.ts @@ -0,0 +1,72 @@ +import { assert, describe, it } from "@effect/vitest" +import { AuthorizationUnavailableError } from "@maple/domain/http" +import { Effect } from "effect" +import { type ClerkMembershipRow, collectMemberships } from "./OrgMembershipService" + +const row = (id: string, role = "org:member"): ClerkMembershipRow => ({ + organization: { id }, + role, +}) + +/** A full page is what tells the pager to ask for another one. */ +const fullPage = (prefix: string) => Array.from({ length: 100 }, (_, index) => row(`${prefix}_${index}`)) + +describe("collectMemberships", () => { + it.effect("stops on the first short page", () => + Effect.gen(function* () { + const offsets: Array = [] + const result = yield* collectMemberships((offset) => { + offsets.push(offset) + return Effect.succeed(offset === 0 ? fullPage("org_a") : [row("org_last")]) + }) + + assert.deepStrictEqual(offsets, [0, 100]) + assert.strictEqual(result.memberships.length, 101) + assert.isFalse(result.truncated) + }), + ) + + /** + * The flag that sends a miss to the precise pair lookup. Without it, a user in + * more organizations than we page through would be told they are not a member + * of one they are in. + */ + it.effect("marks a user with more organizations than we page through as truncated", () => + Effect.gen(function* () { + let pages = 0 + const result = yield* collectMemberships(() => { + pages += 1 + return Effect.succeed(fullPage("org")) + }) + + assert.strictEqual(pages, 5) + assert.strictEqual(result.memberships.length, 500) + assert.isTrue(result.truncated) + }), + ) + + // `OrgId`/`RoleName` are non-empty, trimmed strings — the ids Clerk cannot + // give us are blank or padded ones. + it.effect("drops memberships it cannot model rather than failing the request", () => + Effect.gen(function* () { + const result = yield* collectMemberships(() => + Effect.succeed([row("org_ok"), row("org_padded", " org:member "), row("")]), + ) + + assert.deepStrictEqual( + result.memberships.map((membership) => membership.orgId), + ["org_ok"], + ) + }), + ) + + it.effect("a page failure is a failure, never a shorter answer", () => + Effect.gen(function* () { + const result = yield* collectMemberships(() => + Effect.fail(new AuthorizationUnavailableError({ message: "Clerk unreachable" })), + ).pipe(Effect.result) + + assert.strictEqual(result._tag, "Failure") + }), + ) +}) diff --git a/apps/api/src/services/auth/OrgMembershipService.ts b/apps/api/src/services/auth/OrgMembershipService.ts new file mode 100644 index 000000000..aacc51e1b --- /dev/null +++ b/apps/api/src/services/auth/OrgMembershipService.ts @@ -0,0 +1,238 @@ +import { createClerkClient } from "@clerk/backend" +import { EdgeCacheService } from "@maple/cache" +import type { VerifiedOrgMembership } from "@maple/auth" +import { AuthorizationUnavailableError, OrgId, RoleName, type UserId } from "@maple/domain/http" +import { Context, Effect, Layer, Option, Redacted, Schema } from "effect" +import { Env } from "@/platform/Env" +import { clerkRequest } from "@/services/auth/clerk-request" + +export interface OrgMembershipServiceApi { + /** + * Is `userId` a member of `orgId`, and with what role? + * + * `Option.none()` is a definite no. A failure is "could not find out" — the + * caller must reject the request, never fall back to the credential's own + * organization: a Clerk blip would otherwise render one org's incidents under + * another org's name. + */ + readonly verify: ( + userId: UserId, + orgId: OrgId, + ) => Effect.Effect, AuthorizationUnavailableError> +} + +/** + * Membership lookups are keyed by **user**, never by (user, org). + * + * The org in the key would be attacker-controlled — it arrives in a request + * header — so a per-pair cache turns the header into a Clerk-request amplifier: + * rotate the value and every request misses and dials out. Caching the whole + * membership set per user means header rotation costs zero extra outbound + * calls, and negative answers come for free. + */ +export const ORG_MEMBERSHIP_CACHE_BUCKET = "org-membership" + +/** + * How long a membership answer is trusted, in two tiers. + * + * **The shared TTL is the revocation lag**: for up to five minutes after being + * removed from an organization, a user can still select it with the header. + * That is a deliberate, tunable number and it is strictly tighter than nothing — + * but it is looser than the token path, where Clerk session tokens last ~60s. + * If it ever needs to be tighter, lower it; the traffic here is widget refreshes + * and the extra Clerk volume is small. Do not "fix" it by removing the cache: + * this read sits in auth, in front of every request that carries the header. + * + * A Clerk webhook on `organizationMembership.deleted`/`.updated` calling + * `edgeCache.invalidate({ bucket, key: userId })` would close the window + * properly, and is the follow-up worth doing. + */ +const MEMBERSHIP_MEMO_TTL_MS = 60_000 +const MEMBERSHIP_CACHE_TTL_SECONDS = 300 + +/** + * Well above the service default (40ms). That deadline's premise is that + * abandoning a read is cheap because `compute` was going to open a connection + * anyway — here `compute` is a round-trip to Clerk, which is exactly the case + * `readTimeoutMs` exists for. + */ +const MEMBERSHIP_CACHE_READ_TIMEOUT_MS = 200 + +const MEMBERSHIP_PAGE_SIZE = 100 +/** 500 organizations. Past that, `truncated` sends the miss to a pair lookup. */ +const MEMBERSHIP_MAX_PAGES = 5 + +const CachedMemberships = Schema.Struct({ + memberships: Schema.Array(Schema.Struct({ orgId: OrgId, role: RoleName })), + truncated: Schema.Boolean, +}) +type CachedMemberships = Schema.Schema.Type + +interface MemoEntry { + readonly value: CachedMemberships + readonly freshUntil: number +} + +// Per-isolate tier. A warm isolate answers with zero network; the shared tier +// behind it is what keeps Clerk out of the path across isolates. +const membershipMemo = new Map() + +const decodeOrgIdOption = Schema.decodeUnknownOption(OrgId) +const decodeRoleNameOption = Schema.decodeUnknownOption(RoleName) + +const unavailable = (cause: unknown) => + new AuthorizationUnavailableError({ + message: `Could not verify organization membership: ${cause instanceof Error ? cause.message : String(cause)}`, + }) + +/** One page of memberships, as much of it as Maple can model. */ +export interface ClerkMembershipRow { + readonly organization: { readonly id: string } + readonly role: string +} + +/** + * Pages a user's memberships and decodes them, with the fetch injected so the + * paging, the truncation flag and the drop-what-we-cannot-model rule are + * testable without a Clerk client. + */ +export const collectMemberships = Effect.fnUntraced(function* ( + listPage: ( + offset: number, + ) => Effect.Effect, AuthorizationUnavailableError>, +) { + const memberships: Array<{ orgId: OrgId; role: RoleName }> = [] + let page = 0 + let truncated = false + let undecodable = 0 + + while (true) { + const rows = yield* listPage(page * MEMBERSHIP_PAGE_SIZE) + + for (const row of rows) { + const orgId = decodeOrgIdOption(row.organization.id) + const role = decodeRoleNameOption(row.role) + // A Clerk role Maple does not model must not 500 an unrelated request — + // it simply is not a membership we can act on. Counted so the span says + // it happened rather than the count quietly disagreeing with Clerk. + if (Option.isNone(orgId) || Option.isNone(role)) { + undecodable += 1 + continue + } + memberships.push({ orgId: orgId.value, role: role.value }) + } + + page += 1 + if (rows.length < MEMBERSHIP_PAGE_SIZE) break + if (page >= MEMBERSHIP_MAX_PAGES) { + truncated = true + break + } + } + + yield* Effect.annotateCurrentSpan({ + "maple.auth.membership.count": memberships.length, + "maple.auth.membership.truncated": truncated, + "maple.auth.membership.undecodable": undecodable, + }) + return { memberships, truncated } satisfies CachedMemberships +}) + +const make = Effect.gen(function* () { + const env = yield* Env + const edgeCache = yield* EdgeCacheService + + const clerk = + env.MAPLE_AUTH_MODE.toLowerCase() === "clerk" && Option.isSome(env.CLERK_SECRET_KEY) + ? createClerkClient({ secretKey: Redacted.value(env.CLERK_SECRET_KEY.value) }) + : null + + const listFromClerk = (userId: UserId) => + collectMemberships((offset) => { + if (clerk === null) return Effect.fail(unavailable("Clerk is not configured")) + return clerkRequest( + "Clerk.users.getOrganizationMembershipList", + { "tenant.userId": userId }, + () => + clerk.users.getOrganizationMembershipList({ + userId, + limit: MEMBERSHIP_PAGE_SIZE, + offset, + }), + ).pipe( + Effect.map((response) => response.data), + Effect.mapError(unavailable), + ) + }) + + const readShared = (userId: UserId) => + edgeCache + .getOrCompute( + { + bucket: ORG_MEMBERSHIP_CACHE_BUCKET, + key: userId, + ttlSeconds: MEMBERSHIP_CACHE_TTL_SECONDS, + schema: CachedMemberships, + readTimeoutMs: MEMBERSHIP_CACHE_READ_TIMEOUT_MS, + }, + listFromClerk(userId), + ) + .pipe(Effect.map((result) => result.value)) + + const load = Effect.fn("OrgMembershipService.load")(function* (userId: UserId) { + const now = Date.now() + const memo = membershipMemo.get(userId) + if (memo && now < memo.freshUntil) { + yield* Effect.annotateCurrentSpan("cache.status", "memo") + return memo.value + } + + // Only successes are memoized. A Clerk failure is not a membership answer, + // and caching it would turn one outage into a fixed window of wrong 403s. + const value = yield* readShared(userId) + membershipMemo.set(userId, { value, freshUntil: now + MEMBERSHIP_MEMO_TTL_MS }) + return value + }) + + /** + * The precise question, for the one case the per-user set cannot answer: a + * user in more organizations than we page through. Without it, a pathological + * account would be told it is not a member of an org it is in. + */ + const verifyPair = Effect.fn("OrgMembershipService.verifyPair")(function* (userId: UserId, orgId: OrgId) { + if (clerk === null) return yield* Effect.fail(unavailable("Clerk is not configured")) + const response = yield* clerkRequest( + "Clerk.organizations.getOrganizationMembershipList", + { "tenant.userId": userId, orgId }, + () => + clerk.organizations.getOrganizationMembershipList({ + organizationId: orgId, + userId: [userId], + limit: 1, + }), + ).pipe(Effect.mapError(unavailable)) + + const membership = response.data[0] + if (!membership) return Option.none() + const role = decodeRoleNameOption(membership.role) + return Option.map(role, (value): VerifiedOrgMembership => ({ orgId, role: value })) + }) + + const verify = Effect.fn("OrgMembershipService.verify")(function* (userId: UserId, orgId: OrgId) { + yield* Effect.annotateCurrentSpan({ "tenant.userId": userId, "tenant.requested_org_id": orgId }) + const { memberships, truncated } = yield* load(userId) + const found = memberships.find((membership) => membership.orgId === orgId) + if (found) return Option.some(found) + if (!truncated) return Option.none() + return yield* verifyPair(userId, orgId) + }) + + return { verify } satisfies OrgMembershipServiceApi +}) + +export class OrgMembershipService extends Context.Service()( + "@maple/api/services/auth/OrgMembershipService", + { make }, +) { + static readonly layer = Layer.effect(this, this.make) +} diff --git a/apps/api/src/services/push/MobilePushService.test.ts b/apps/api/src/services/push/MobilePushService.test.ts index d774234b6..747524b50 100644 --- a/apps/api/src/services/push/MobilePushService.test.ts +++ b/apps/api/src/services/push/MobilePushService.test.ts @@ -291,6 +291,7 @@ describe("MobilePushService live activities", () => { // Epoch seconds, never an ISO string — ActivityKit decodes this // dictionary with a plain JSONDecoder. started_at: first!.attributes!.started_at, + organization_id: ORG, }) assert.strictEqual(typeof first!.attributes!.started_at, "number") assert.deepStrictEqual( diff --git a/apps/api/src/services/push/MobilePushService.ts b/apps/api/src/services/push/MobilePushService.ts index 5aee80d52..5d7ece4d5 100644 --- a/apps/api/src/services/push/MobilePushService.ts +++ b/apps/api/src/services/push/MobilePushService.ts @@ -208,6 +208,11 @@ export const renderLiveActivityAttributes = ( : null), signal_label: event.signalDisplay.label, started_at: Math.floor((nowMs - (event.openForMs ?? 0)) / 1000), + // Which organization the incident belongs to, so a tap on the Lock Screen + // opens it in that org rather than in whichever one the app happens to be + // showing. Optional on the client (`IncidentActivityAttributes`): an + // activity started before this field existed can never gain one. + organization_id: event.orgId, }) /** diff --git a/apps/electric-sync/src/auth/TenantResolver.ts b/apps/electric-sync/src/auth/TenantResolver.ts index da645828d..3d621c4fd 100644 --- a/apps/electric-sync/src/auth/TenantResolver.ts +++ b/apps/electric-sync/src/auth/TenantResolver.ts @@ -1,12 +1,19 @@ import { makeResolveTenant, type TenantContext } from "@maple/auth" -import type { UnauthorizedError } from "@maple/domain/http" +import type { + AuthorizationUnavailableError, + OrganizationAccessDeniedError, + UnauthorizedError, +} from "@maple/domain/http" import { Context, Effect, Layer } from "effect" import { SyncConfig } from "../config" export interface TenantResolverApi { readonly resolve: ( headers: Record, - ) => Effect.Effect + ) => Effect.Effect< + TenantContext, + UnauthorizedError | OrganizationAccessDeniedError | AuthorizationUnavailableError + > } /** @@ -22,6 +29,11 @@ export interface TenantResolverApi { * Clerk and self-hosted are both covered by `makeResolveTenant`; there is no * API-key path here, because this worker has no database and the browser's * shape-fetch only ever sends the session bearer. + * + * No membership verifier is passed, and that is deliberate: this worker has no + * membership directory, so `x-maple-org-id` is REJECTED here rather than + * ignored. A silently ignored selection would serve the token's own rows under + * another organization's name. */ export class TenantResolver extends Context.Service()( "@maple/electric-sync/TenantResolver", diff --git a/apps/ios/Maple/App/DestinationOpener.swift b/apps/ios/Maple/App/DestinationOpener.swift new file mode 100644 index 000000000..eb525b09c --- /dev/null +++ b/apps/ios/Maple/App/DestinationOpener.swift @@ -0,0 +1,164 @@ +import MapleWidgetData +import Observation +import SwiftUI + +/// The one way anything outside the view tree opens a screen: a tapped +/// notification, a tapped widget, a tapped Live Activity. +/// +/// It exists because all three can name an organization that is not the active +/// one, and the app has exactly one active organization at a time. Before this, +/// a tap on an alert for another org pushed the incident straight onto the +/// Alerts stack, where the request went out under the current org's token and +/// came back 404 — the app said the incident did not exist. +/// +/// `AppNavigation` is left as what it was always good at: putting a route on +/// screen. The decision of *which organization that route belongs in* is here, +/// and the decision itself is `DestinationResolver`, in a package that has +/// tests. +@MainActor +@Observable +final class DestinationOpener { + enum Source: String { + case push + case widget + case liveActivity + } + + /// The one line of feedback a switch is allowed. Silently changing which + /// organization the whole app is showing is not acceptable; a modal question + /// in front of someone who tapped an alert to read it is not either. + struct Notice: Equatable, Identifiable { + enum Kind: Equatable { + case switched(organizationId: String, name: String?) + case notAMember + } + + let id = UUID() + var kind: Kind + } + + private(set) var notice: Notice? + + private let navigation: AppNavigation + /// Assigned at launch. Weak because the session outlives nothing here and + /// this object is reachable from the app delegate. + weak var session: SessionController? + + /// A destination waiting for the session to be able to answer. One slot: a + /// newer tap replaces an older one, the same way `Telemetry.PushOpen` treats + /// a second tap. + private var parked: (link: WidgetDeepLink, source: Source)? + private var parkExpiry: Task? + /// Matches `Telemetry.PushOpen`'s own abandon window, so a parked + /// destination and the open span measuring it cannot outlive each other. + private static let parkTimeout: Duration = .seconds(30) + + init(navigation: AppNavigation) { + self.navigation = navigation + } + + func open(_ url: URL, source: Source) async { + guard let link = WidgetDeepLink(url: url) else { return } + await open(link, source: source) + } + + func open(_ link: WidgetDeepLink, source: Source) async { + switch DestinationResolver.decide(organizationId: link.organizationId, session: snapshot) { + case .navigate: + navigation.go(link.target) + + case .switchThenNavigate(let organizationId): + await switchThenNavigate(to: organizationId, link: link) + + case .park: + park(link, source: source) + + case .refuseNotAMember: + show(.notAMember) + // The tap will never reach its screen, so close the span with a + // reason rather than letting it time out anonymously. + Telemetry.PushOpen.abandon(reason: "not_a_member") + } + } + + /// The session can now answer a question it could not before — called from + /// `RootView` right after `SessionController.refresh()`. + func sessionDidSettle() async { + guard let parked else { return } + self.parked = nil + parkExpiry?.cancel() + parkExpiry = nil + await open(parked.link, source: parked.source) + } + + func dismissNotice() { + notice = nil + } + + // MARK: Private + + private var snapshot: SessionSnapshot { + guard let session else { return .loading } + switch session.phase { + case .loading: + return .loading + case .signedOut: + return .signedOut + case .needsOrganization: + // Signed in with nothing active. There is no organization to compare + // against, and switching into the one the link names is exactly what + // the picker would otherwise ask the user to do by hand — so treat it + // as "active organization: none" and let the resolver switch. + return .ready( + activeOrganizationId: "", + memberIds: session.memberIds, + membershipsLoaded: session.membershipsLoaded + ) + case .ready(let organizationId): + return .ready( + activeOrganizationId: organizationId, + memberIds: session.memberIds, + membershipsLoaded: session.membershipsLoaded + ) + } + } + + /// Switch **then** navigate, never the other way round. + /// + /// `select` bumps `dataGeneration`, and every detail screen keys its load on + /// it. Pushing the route first would build the screen under the old + /// generation, fire the request with the old organization's token, take the + /// 404 — the exact failure this type exists to remove — and only then + /// re-run. + private func switchThenNavigate(to organizationId: String, link: WidgetDeepLink) async { + guard let session else { return park(link, source: .push) } + + Telemetry.PushOpen.recordOrganizationSwitch() + await session.select(organizationId: organizationId) + + guard session.currentOrganizationId == organizationId else { + // Clerk refused the switch — revoked membership, or an expired + // session. Landing on the incident anyway reproduces the 404. + show(.notAMember) + Telemetry.PushOpen.abandon(reason: "switch_failed") + return + } + + show(.switched(organizationId: organizationId, name: session.name(of: organizationId))) + navigation.go(link.target) + } + + private func park(_ link: WidgetDeepLink, source: Source) { + parked = (link, source) + parkExpiry?.cancel() + parkExpiry = Task { [weak self] in + try? await Task.sleep(for: Self.parkTimeout) + guard !Task.isCancelled else { return } + self?.parked = nil + } + } + + private func show(_ kind: Notice.Kind) { + notice = Notice(kind: kind) + } +} diff --git a/apps/ios/Maple/App/MapleApp.swift b/apps/ios/Maple/App/MapleApp.swift index 107a5039f..477dde03f 100644 --- a/apps/ios/Maple/App/MapleApp.swift +++ b/apps/ios/Maple/App/MapleApp.swift @@ -9,6 +9,7 @@ struct MapleApp: App { @State private var clerk: Clerk @State private var session: SessionController @State private var navigation: AppNavigation + @State private var opener: DestinationOpener init() { // `Clerk.shared` traps until `configure` has run, and Swift evaluates @@ -22,8 +23,10 @@ struct MapleApp: App { let tokens = ClerkTokenProvider() let navigation = AppNavigation() _navigation = State(initialValue: navigation) + let opener = DestinationOpener(navigation: navigation) + _opener = State(initialValue: opener) // Notification taps arrive on the app delegate, outside the view tree. - PushRegistrar.shared.navigation = navigation + PushRegistrar.shared.opener = opener // Before Clerk and the API client, so a cold launch is inside a session. Self.startTelemetry() // And before anything it should measure. Ends at the first frame, in @@ -31,7 +34,9 @@ struct MapleApp: App { Telemetry.Launch.begin() if FixtureAPI.isEnabled { _clerk = State(initialValue: Clerk.configure(publishableKey: FixtureSession.publishableKey)) - _session = State(initialValue: SessionController.fixture(api: FixtureAPI(), tokens: tokens)) + let session = SessionController.fixture(api: FixtureAPI(), tokens: tokens) + _session = State(initialValue: session) + opener.session = session return } @@ -52,7 +57,12 @@ struct MapleApp: App { } catch { fatalError("Invalid API base URL: \(error)") } - _session = State(initialValue: SessionController(api: api, tokens: tokens)) + let session = SessionController(api: api, tokens: tokens) + _session = State(initialValue: session) + // Assigned here rather than in `body`: a tap that launched the app can + // reach the delegate before the first frame, and an opener with no + // session parks every destination it is handed. + opener.session = session } /// Session replay and tracing, configured entirely from Info.plist — see the @@ -88,6 +98,7 @@ struct MapleApp: App { .environment(clerk) .environment(session) .environment(navigation) + .environment(opener) } } } diff --git a/apps/ios/Maple/App/OrganizationNoticeView.swift b/apps/ios/Maple/App/OrganizationNoticeView.swift new file mode 100644 index 000000000..bccd4422a --- /dev/null +++ b/apps/ios/Maple/App/OrganizationNoticeView.swift @@ -0,0 +1,54 @@ +import SwiftUI + +/// The one line of feedback shown when opening a destination moved the user to +/// another organization — or refused to. +/// +/// An overlay on the tab view rather than anything inside a `NavigationStack`: +/// answering a cross-organization tap changes the tab *and* replaces the stack, +/// so anything living in a stack would be torn down in the same frame it +/// appeared. And not an alert — someone who tapped an alert to read it should +/// not have to dismiss a question first. +struct OrganizationNoticeView: View { + let notice: DestinationOpener.Notice + let onDismiss: () -> Void + + /// Long enough to read six words, short enough that it is gone before the + /// incident has finished loading. + private static let duration: Duration = .seconds(3) + + var body: some View { + HStack(spacing: 8) { + switch notice.kind { + case .switched(let organizationId, let name): + // The same categorical colour the switcher and the picker rows + // use, so the toast and the toolbar are recognisably one thing. + ServiceDot(serviceName: organizationId, size: 7) + Text("Switched to \(name ?? "another organization")") + .font(Typo.smallMedium) + .foregroundStyle(Token.foreground) + case .notAMember: + Circle() + .fill(Token.destructive) + .frame(width: 7, height: 7) + Text("You're not a member of that organization") + .font(Typo.smallMedium) + .foregroundStyle(Token.foreground) + } + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background( + RoundedRectangle(cornerRadius: Token.Radius.xl, style: .continuous) + .fill(Token.card) + .stroke(Token.border, lineWidth: 1) + ) + .shadow(color: .black.opacity(0.12), radius: 12, y: 4) + .padding(.top, 8) + .accessibilityElement(children: .combine) + .task(id: notice.id) { + try? await Task.sleep(for: Self.duration) + guard !Task.isCancelled else { return } + onDismiss() + } + } +} diff --git a/apps/ios/Maple/App/RootView.swift b/apps/ios/Maple/App/RootView.swift index c5188096b..4c663d325 100644 --- a/apps/ios/Maple/App/RootView.swift +++ b/apps/ios/Maple/App/RootView.swift @@ -11,6 +11,7 @@ struct RootView: View { @Environment(Clerk.self) private var clerk @Environment(SessionController.self) private var session @Environment(AppNavigation.self) private var navigation + @Environment(DestinationOpener.self) private var opener var body: some View { Group { @@ -36,6 +37,10 @@ struct RootView: View { // no manual subscription needed. .task(id: clerkStateKey) { await session.refresh() + // A destination that arrived before the session could place it — a + // cold launch from a notification tap — is answered here, now that + // the memberships are known. + await opener.sessionDidSettle() } .background(Token.background) .tint(Token.primary) @@ -45,12 +50,19 @@ struct RootView: View { .animation(.default, value: session.phase) // A widget tap arrives here whatever the phase is; the tabs may not // exist yet, and `AppNavigation` holds the destination until they do. - .onOpenURL { navigation.open($0) } + .onOpenURL { url in + Task { await opener.open(url, source: .widget) } + } .onAppear { // The first frame — the end of `app.launch`. The phase rides along // because "slow launch" means something different when it ended on // the sign-in screen than when it ended on a loaded Home. Telemetry.Launch.firstFrame(phase: session.phase.telemetryName) + // Widgets migrated by an update resolve their organization on the + // next timeline build; this makes that build happen now. + WidgetPublisher.shared.reloadIfNewBuild( + version: Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "unknown" + ) Typo.assertAvailable() // After the font check, so a missing face is reported as a missing // face rather than as a silently system-font navigation bar. @@ -73,6 +85,7 @@ struct RootView: View { struct MainTabView: View { @Environment(AppNavigation.self) private var navigation @Environment(SessionController.self) private var session + @Environment(DestinationOpener.self) private var opener @Environment(\.scenePhase) private var scenePhase private let push = PushRegistrar.shared private let widgets = WidgetPublisher.shared @@ -109,15 +122,31 @@ struct MainTabView: View { guard let orgId = session.currentOrganizationId else { return } liveActivities.configure(api: session.api, organizationId: orgId) } - .task(id: session.currentOrganizationId) { + .task(id: session.widgetPublishKey) { guard let orgId = session.currentOrganizationId else { return } widgets.configure( api: session.api, organizationId: orgId, - organizationName: session.activeOrganization?.name + organizationName: session.activeOrganization?.name, + memberships: session.publishableOrganizations ) + // Only a verified list may prune: `membershipsLoaded` is false when + // Clerk's client payload was the source, and that list can be partial — + // pruning against it would wipe live organizations' snapshots. + if session.membershipsLoaded { + widgets.prune(to: session.memberIds) + } await widgets.refresh(trigger: .organization) } + // Above the tabs, because answering a cross-organization tap changes the + // tab and the stack in the same frame. + .overlay(alignment: .top) { + if let notice = opener.notice { + OrganizationNoticeView(notice: notice) { opener.dismissNotice() } + .transition(.move(edge: .top).combined(with: .opacity)) + } + } + .animation(.snappy, value: opener.notice) .onChange(of: scenePhase) { _, phase in switch phase { case .active: diff --git a/apps/ios/Maple/App/Route.swift b/apps/ios/Maple/App/Route.swift index 076d22a96..3eef1f2c0 100644 --- a/apps/ios/Maple/App/Route.swift +++ b/apps/ios/Maple/App/Route.swift @@ -97,45 +97,18 @@ final class AppNavigation { tab = .alerts } - /// The Home Screen widget's deep links — `maple://issues` and - /// `maple://issue/`; see `IssuesWidgetKind`. - /// - /// Anything else is ignored rather than guessed at: a URL this app does not - /// recognise landing the user on a random tab is worse than it doing - /// nothing, and the scheme is ours alone. - func open(_ url: URL) { - guard url.scheme == IssuesWidgetKind.urlScheme else { return } - // The host alone: a path can carry an issue id or a service name, and - // neither belongs in an event property. - Telemetry.track(Telemetry.Event.widgetOpened, ["target": url.host() ?? "unknown"]) - switch url.host() { - case "incident": - // `maple://incident/` — a tapped Live Activity. The id is the - // public `inc_…` form the activity was started with. - let id = url.pathComponents.first { $0 != "/" } - if let id, !id.isEmpty { openIncident(id: id) } else { open(.incidents) } - case "issues": - open(.errors) - case "issue": - // `maple://issue/` — the id is the first path component, and an - // empty one means the widget's row lost its issue. - let id = url.pathComponents.first { $0 != "/" } - if let id, !id.isEmpty { openIssue(id: id) } else { open(.errors) } - case "services": + /// Put a destination on screen. Which organization it belongs to has already + /// been settled by `DestinationOpener`; this is only routing. + func go(_ target: WidgetDeepLink.Target) { + switch target { + case .incident(let id): openIncident(id: id) + case .issue(let id): openIssue(id: id) + case .service(let name): openService(name: name) + case .incidentsList: open(.incidents) + case .issuesList: open(.errors) + case .servicesList: servicesPath = [] tab = .services - case "service": - // `maple://service/`; service names can contain characters - // the widget percent-encoded, so decode before matching. - let name = url.pathComponents.first { $0 != "/" }?.removingPercentEncoding - if let name, !name.isEmpty { - openService(name: name) - } else { - servicesPath = [] - tab = .services - } - default: - break } } } diff --git a/apps/ios/Maple/Auth/SessionController.swift b/apps/ios/Maple/Auth/SessionController.swift index 75cc03fb4..cfc0581b9 100644 --- a/apps/ios/Maple/Auth/SessionController.swift +++ b/apps/ios/Maple/Auth/SessionController.swift @@ -2,6 +2,7 @@ import ClerkKit import Foundation import Maple import MapleAPI +import MapleWidgetData import Observation /// Owns "who is signed in, to which organization, and is the API usable yet". @@ -90,6 +91,38 @@ final class SessionController { memberships.count > 1 } + /// The organizations a destination is allowed to switch into. Paired with + /// `membershipsLoaded`, which says whether this set is trustworthy — an + /// unverified list must never be used to *refuse* anything. + var memberIds: Set { + Set(memberships.map(\.organization.id)) + } + + /// Every membership, in the shape the widget publisher and the widget + /// extension's organization picker use. + var publishableOrganizations: [PublishedOrganization] { + memberships.map { + PublishedOrganization( + id: $0.organization.id, + name: $0.organization.name, + lastPublishedAt: .distantPast + ) + } + } + + /// Re-runs the widget publish when the active organization *or* the set the + /// user belongs to changes — an organization joined after launch should get + /// a snapshot without waiting for a switch. + var widgetPublishKey: String { + ([currentOrganizationId ?? "none"] + memberIds.sorted()).joined(separator: "|") + } + + /// The display name for an organization the user belongs to, for the line + /// shown after a switch. Nil when only the id is known. + func name(of organizationId: String) -> String? { + memberships.first { $0.organization.id == organizationId }?.organization.name + } + /// Recompute the phase from Clerk's current state. /// /// Called on launch and whenever Clerk's observable state changes — `Clerk` diff --git a/apps/ios/Maple/Push/PushRegistrar.swift b/apps/ios/Maple/Push/PushRegistrar.swift index f0c45cd1e..d3c820e7c 100644 --- a/apps/ios/Maple/Push/PushRegistrar.swift +++ b/apps/ios/Maple/Push/PushRegistrar.swift @@ -1,6 +1,7 @@ import Foundation import Maple import MapleAPI +import MapleWidgetData import Observation import UIKit import UserNotifications @@ -35,8 +36,9 @@ final class PushRegistrar: NSObject { /// True while a sync is in flight; the settings sheet dims its toggles. private(set) var isSyncing = false - /// Set by the app at launch so a notification tap can navigate. - var navigation: AppNavigation? + /// Set by the app at launch so a notification tap can be routed — including + /// into the organization the alert actually fired in. + var opener: DestinationOpener? private let defaults = UserDefaults.standard private let center = UNUserNotificationCenter.current() @@ -221,7 +223,7 @@ enum PushEnvironmentDetector { } /// APNs and notification callbacks arrive on UIKit's app delegate; this -/// forwards them to the registrar and to navigation. +/// forwards them to the registrar and to `DestinationOpener`. final class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDelegate { func application( _ application: UIApplication, @@ -282,6 +284,11 @@ final class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCent let userInfo = response.notification.request.content.userInfo let kind = userInfo["maple_kind"] as? String let incidentId = userInfo["maple_incident_id"] as? String + // The organization the alert fired in. Every push has carried it since + // `MobilePushService` was written; until `DestinationOpener` existed + // nothing on the device read it, so a tap on another org's alert opened + // an incident id the active token could not fetch. + let organizationId = userInfo["maple_org_id"] as? String Task { @MainActor in switch kind { case "alert_incident": @@ -296,7 +303,10 @@ final class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCent coldStart: Telemetry.Launch.isColdStart ) Telemetry.track(Telemetry.Event.pushOpened, ["kind": "alert_incident"]) - PushRegistrar.shared.navigation?.openIncident(id: incidentId) + await PushRegistrar.shared.opener?.open( + WidgetDeepLink(target: .incident(id: incidentId), organizationId: organizationId), + source: .push + ) default: break } diff --git a/apps/ios/Maple/Telemetry/Telemetry.swift b/apps/ios/Maple/Telemetry/Telemetry.swift index 2f2df4f52..98ce211e6 100644 --- a/apps/ios/Maple/Telemetry/Telemetry.swift +++ b/apps/ios/Maple/Telemetry/Telemetry.swift @@ -70,6 +70,9 @@ enum Telemetry { static let pushKind = "maple.app.push.kind" static let pushColdStart = "maple.app.push.cold_start" static let widgetTrigger = "maple.app.widget.trigger" + static let widgetOrganizationCount = "maple.app.widget.organization_count" + static let pushAbandonReason = "maple.app.push.abandon_reason" + static let pushOrganizationSwitched = "maple.app.push.org_switched" static let widgetSurface = "maple.app.widget.surface" static let liveActivityAction = "maple.app.live_activity.action" } @@ -247,7 +250,7 @@ extension Telemetry { static func begin(kind: String, screen: String, coldStart: Bool) { // A second tap before the first landed: the older one is abandoned, // not left open beside it. - expire() + abandon(reason: "superseded") let span = MapleTracing.shared.startSpan( Name.pushOpen, attributes: [ @@ -264,7 +267,7 @@ extension Telemetry { let expiry = Task { try? await Task.sleep(for: .seconds(30)) guard !Task.isCancelled else { return } - expire() + abandon(reason: "expired") } pending = Pending(span: span, screen: screen, expiry: expiry) } @@ -283,15 +286,26 @@ extension Telemetry { self.pending = nil } - private static func expire() { + /// The tap will never reach its screen. `reason` distinguishes the ways + /// that happens — a refused organization is a product problem worth + /// counting; a superseded tap is not. + static func abandon(reason: String) { guard let pending else { return } pending.expiry.cancel() // Not an error: an abandoned open is a user changing their mind, and // marking it `Error` would put it in the error dashboards. pending.span.setAttribute("maple.app.push.abandoned", true) + pending.span.setAttribute(Key.pushAbandonReason, reason) pending.span.end() self.pending = nil } + + /// The tap landed in a different organization than the one on screen, so + /// answering it cost a `setActive` plus a forced token round-trip. That is + /// real latency on the alert-to-eyes number and is invisible otherwise. + static func recordOrganizationSwitch() { + pending?.span.setAttribute(Key.pushOrganizationSwitched, true) + } } } diff --git a/apps/ios/Maple/Widgets/WidgetPublisher.swift b/apps/ios/Maple/Widgets/WidgetPublisher.swift index fb096c448..b2ebca38c 100644 --- a/apps/ios/Maple/Widgets/WidgetPublisher.swift +++ b/apps/ios/Maple/Widgets/WidgetPublisher.swift @@ -44,17 +44,33 @@ final class WidgetPublisher { /// every fifteen. private static let minimumInterval: TimeInterval = 60 - private let issuesStore: WidgetSnapshotStore - private let throughputStore: WidgetSnapshotStore + /// How many organizations one round may publish. + /// + /// One organization costs four requests. Publishing every membership would + /// be 48 for an account in twelve — most of them for organizations nobody + /// put on a Home Screen — and iOS answers that kind of appetite with less + /// background time, so the widgets would end up *less* current. The set is + /// driven by what is actually placed instead; see `organizationsToPublish`. + private static let maximumOrganizations = 3 + /// Organizations in flight at once: three organizations means six sockets + /// open, not twelve. Pairwise, so changing this means changing the loop in + /// `refresh` too. + private static let maximumConcurrentOrganizations = 2 + + private let index: PublishedOrganizationIndex private var lastRefreshedAt: Date? /// Set once the app knows who is signed in, so the background task — which /// runs with no view tree — has something to fetch with. private var context: Context? struct Context { + /// Unscoped. Each organization fetches through `api.scoped(to:)`; the + /// client itself stays on the token's own claim. var api: any MapleAPI - var organizationId: String - var organizationName: String? + var active: PublishedOrganization + /// Every organization the user belongs to, for widgets pinned to one + /// that is not active. + var memberships: [PublishedOrganization] } /// What asked for this refresh. Recorded on every `widget.refresh` span, @@ -68,23 +84,64 @@ final class WidgetPublisher { case background } - init( - issuesStore: WidgetSnapshotStore = .issues, - throughputStore: WidgetSnapshotStore = .throughput - ) { - self.issuesStore = issuesStore - self.throughputStore = throughputStore + init(index: PublishedOrganizationIndex = PublishedOrganizationIndex()) { + self.index = index } - /// Called whenever the signed-in organization is known or changes. - func configure(api: any MapleAPI, organizationId: String, organizationName: String?) { - let isNewOrganization = context?.organizationId != organizationId - context = Context(api: api, organizationId: organizationId, organizationName: organizationName) - // A switch invalidates both the throttle and whatever is on the Home - // Screen: those numbers belong to the org the user just left. + /// Called whenever the signed-in organization, or the set the user belongs + /// to, is known or changes. + func configure( + api: any MapleAPI, + organizationId: String, + organizationName: String?, + memberships: [PublishedOrganization] = [] + ) { + let isNewOrganization = context?.active.id != organizationId + let active = PublishedOrganization( + id: organizationId, + name: organizationName, + lastPublishedAt: .distantPast + ) + context = Context( + api: api, + active: active, + memberships: memberships.isEmpty ? [active] : memberships + ) + // A switch invalidates the throttle: the numbers on the Home Screen + // belong to the organization the user just left. if isNewOrganization { lastRefreshedAt = nil } } + /// One `reloadAllTimelines` after an update that changed how widgets resolve + /// their organization. + /// + /// A widget migrated from the pre-picker build keeps rendering its last + /// cached view until iOS decides to rebuild the timeline, which can be an + /// hour. This makes it pick up an organization at once. Keyed on the build + /// version so it happens once per install of a new build, not once per + /// launch. + func reloadIfNewBuild(version: String) { + let defaults = UserDefaults.standard + let key = "widgets.reloadedForBuild" + guard defaults.string(forKey: key) != version else { return } + defaults.set(version, forKey: key) + WidgetCenter.shared.reloadAllTimelines() + } + + /// Drop every organization the user is no longer a member of, snapshots and + /// all. + /// + /// **Verified lists only.** `SessionController.membershipsLoaded` is false + /// when the list came from Clerk's client payload, which can be partial — + /// pruning against that would wipe live organizations. + func prune(to memberIds: Set) { + for organizationId in index.prune(to: memberIds) { + WidgetSnapshotStore.issues(organizationId: organizationId).clear() + WidgetSnapshotStore.throughput(organizationId: organizationId).clear() + } + WidgetCenter.shared.reloadAllTimelines() + } + /// Fetch and publish both snapshots. /// /// Silent by design — the widgets are a side effect of using the app, and a @@ -97,19 +154,120 @@ final class WidgetPublisher { if !force, let lastRefreshedAt, Date().timeIntervalSince(lastRefreshedAt) < Self.minimumInterval { return } lastRefreshedAt = Date() + let organizations = await organizationsToPublish(context, trigger: trigger) + await Telemetry.span( Telemetry.Name.widgetRefresh, attributes: [ Telemetry.Key.widgetTrigger: .string(trigger.rawValue), - Telemetry.Key.organizationId: .string(context.organizationId), + Telemetry.Key.organizationId: .string(context.active.id), + Telemetry.Key.widgetOrganizationCount: .int(Int64(organizations.count)), ] ) { _ in - async let issues: Void = self.refreshIssues(context) - async let throughput: Void = self.refreshThroughput(context) - _ = await (issues, throughput) + let rounds = organizations.map { organization in + PublishRound( + organization: organization, + api: context.api.scoped(to: organization.id), + isActive: organization.id == context.active.id + ) + } + + // Two organizations in flight, in pairs. Everything here is already + // on the main actor and the concurrency that matters is the awaits + // inside `publish`, so this is `async let` rather than a task group — + // which also keeps the whole round on one actor rather than making + // `Context` `Sendable` for no gain. + var index = rounds.startIndex + while index < rounds.endIndex { + let first = rounds[index] + let second = rounds.indices.contains(index + 1) ? rounds[index + 1] : nil + index += Self.maximumConcurrentOrganizations + + async let firstDone: Void = self.publish(first) + if let second { + async let secondDone: Void = self.publish(second) + _ = await (firstDone, secondDone) + } else { + await firstDone + } + } } } + /// Everything one organization's round needs, and nothing that is not + /// `Sendable` — `Context` holds the unscoped client and stays on the main + /// actor. + private struct PublishRound: Sendable { + let organization: PublishedOrganization + let api: any MapleAPI + let isActive: Bool + } + + /// One organization's round: both surfaces, then record it in the index the + /// widget extension reads. + private func publish(_ round: PublishRound) async { + async let issues: Void = refreshIssues(round.organization, api: round.api) + async let throughput: Void = refreshThroughput(round.organization, api: round.api) + _ = await (issues, throughput) + + index.record( + PublishedOrganization( + id: round.organization.id, + name: round.organization.name, + lastPublishedAt: Date() + ), + isActive: round.isActive + ) + } + + /// Which organizations this round covers. + /// + /// Driven by what is actually on a Home Screen, not by the membership list: + /// fetching for an organization nobody pinned is battery spent to make iOS + /// trust the app less. The active organization is always first and always + /// included — `getCurrentConfigurations` returns nothing at all right after + /// boot, and that must never be able to *shrink* the set below the + /// organization the user is looking at. + private func organizationsToPublish( + _ context: Context, + trigger: Trigger + ) async -> [PublishedOrganization] { + let pinned = await pinnedOrganizationIds() + let others = context.memberships + .filter { $0.id != context.active.id && pinned.contains($0.id) } + // Oldest first, so a background round that can only afford one + // extra organization round-robins rather than starving one. + .sorted { lastPublished(of: $0) < lastPublished(of: $1) } + + // A `BGAppRefreshTask` gets tens of seconds; twelve requests inside one + // is how the whole chain gets deprioritized. + let budget = trigger == .background ? 1 : Self.maximumOrganizations - 1 + return [context.active] + others.prefix(budget) + } + + private func lastPublished(of organization: PublishedOrganization) -> Date { + index.load().first { $0.id == organization.id }?.lastPublishedAt ?? .distantPast + } + + /// The organizations the user actually pinned a widget to. + private func pinnedOrganizationIds() async -> Set { + guard let configurations = try? await WidgetCenter.shared.currentConfigurations() else { return [] } + var ids: Set = [] + for info in configurations { + if let intent = info.widgetConfigurationIntent(of: SelectOrganizationIntent.self), + let id = intent.organization?.id + { + ids.insert(id) + } + if let intent = info.widgetConfigurationIntent(of: SelectServiceIntent.self), + let id = intent.organization?.id + { + ids.insert(id) + } + } + return ids + } + /// One surface's fetch-and-publish, as a child of the refresh. /// /// Both halves are silent by design — a widget must never surface an error @@ -131,20 +289,24 @@ final class WidgetPublisher { func clear() { context = nil lastRefreshedAt = nil - issuesStore.clear() - throughputStore.clear() + // Every organization, not just the active one: anything left behind + // stays readable on the Home Screen of a phone that has been signed out. + for organizationId in index.clear() { + WidgetSnapshotStore.issues(organizationId: organizationId).clear() + WidgetSnapshotStore.throughput(organizationId: organizationId).clear() + } WidgetCenter.shared.reloadAllTimelines() } // MARK: Issues - private func refreshIssues(_ context: Context) async { - await snapshot("issues") { await self.publishIssues(context) } + private func refreshIssues(_ organization: PublishedOrganization, api: any MapleAPI) async { + await snapshot("issues") { await self.publishIssues(organization, api: api) } } - private func publishIssues(_ context: Context) async -> Bool { + private func publishIssues(_ organization: PublishedOrganization, api: any MapleAPI) async -> Bool { guard - let page = try? await context.api.issues( + let page = try? await api.issues( query: IssueQuery(actionableOnly: true, sort: .severity), window: Self.issuesWindow.resolve(), limit: Self.issueFetchLimit, @@ -153,13 +315,14 @@ final class WidgetPublisher { else { return false } let snapshot = IssuesSnapshot.make( - organizationId: context.organizationId, - organizationName: context.organizationName, + organizationId: organization.id, + organizationName: organization.name, generatedAt: Date(), issues: page.items.map(WidgetIssue.init(issue:)), hasMore: page.hasMore ) - guard issuesStore.save(snapshot) else { return false } + guard WidgetSnapshotStore.issues(organizationId: organization.id).save(snapshot) + else { return false } // Reload rather than wait for the next timeline entry: the whole point // of publishing from the app is that the Home Screen updates the moment // the app learns something. @@ -169,19 +332,19 @@ final class WidgetPublisher { // MARK: Throughput - private func refreshThroughput(_ context: Context) async { - await snapshot("throughput") { await self.publishThroughput(context) } + private func refreshThroughput(_ organization: PublishedOrganization, api: any MapleAPI) async { + await snapshot("throughput") { await self.publishThroughput(organization, api: api) } } - private func publishThroughput(_ context: Context) async -> Bool { + private func publishThroughput(_ organization: PublishedOrganization, api: any MapleAPI) async -> Bool { let window = Self.throughputWindow.resolve() // Three requests, not one per service: `group_by: service` returns // every service's shape at once, and the ungrouped total covers the // traffic of services past the series limit — summing only the grouped // series would quietly under-report a big org's throughput. - async let servicesTask = context.api.services(window: window, limit: Self.serviceFetchLimit) - async let groupedTask = context.api.traceTimeseries( + async let servicesTask = api.services(window: window, limit: Self.serviceFetchLimit) + async let groupedTask = api.traceTimeseries( TraceTimeseriesRequest( aggregation: .count, window: window, @@ -189,7 +352,7 @@ final class WidgetPublisher { seriesLimit: ThroughputSnapshot.maximumServices ) ) - async let totalTask = context.api.traceTimeseries( + async let totalTask = api.traceTimeseries( TraceTimeseriesRequest(aggregation: .count, window: window) ) @@ -216,13 +379,15 @@ final class WidgetPublisher { } let snapshot = ThroughputSnapshot.make( - organizationId: context.organizationId, + organizationId: organization.id, generatedAt: Date(), windowMinutes: Int(Self.throughputWindow.duration / 60), services: rows, overall: overall ) - guard throughputStore.save(snapshot) else { return false } + guard + WidgetSnapshotStore.throughput(organizationId: organization.id).save(snapshot) + else { return false } WidgetCenter.shared.reloadTimelines(ofKind: ThroughputWidgetKind.identifier) return true } diff --git a/apps/ios/Packages/MapleAPI/Sources/MapleAPI/MapleClient.swift b/apps/ios/Packages/MapleAPI/Sources/MapleAPI/MapleClient.swift index 125ca8b92..e13673560 100644 --- a/apps/ios/Packages/MapleAPI/Sources/MapleAPI/MapleClient.swift +++ b/apps/ios/Packages/MapleAPI/Sources/MapleAPI/MapleClient.swift @@ -108,6 +108,16 @@ public struct IssueQuery: Hashable, Sendable { /// A protocol so screens can be driven by a stub in tests and previews without /// a network, a token, or a signed-in user. public protocol MapleAPI: Sendable { + /// A view of this client that names `organizationId` explicitly instead of + /// relying on the session token's active-organization claim. + /// + /// A scoped *instance* rather than a per-call argument: the alternative is a + /// parameter on all twenty methods below and every stub that implements + /// them. A task-local would read better still, but its failure mode — + /// "forgot to wrap, request silently went to the active organization" — is + /// the exact bug this exists to prevent. + func scoped(to organizationId: String) -> any MapleAPI + func services(window: ResolvedTimeWindow, limit: Int) async throws -> Page func service(named name: String, window: ResolvedTimeWindow) async throws -> Service func issues(query: IssueQuery, window: ResolvedTimeWindow?, limit: Int, cursor: String?) async throws @@ -143,10 +153,18 @@ public protocol MapleAPI: Sendable { func traceBreakdown(_ request: TraceBreakdownRequest) async throws -> TraceBreakdownResult } +extension MapleAPI { + /// Stubs and fixtures serve one organization and ignore the scope. + public func scoped(to organizationId: String) -> any MapleAPI { self } +} + /// The live client: generated operations, wrapped so call sites see plain /// values and one error type. public struct MapleClient: MapleAPI { let client: Client + private let tokens: any MapleTokenProvider + private let serverURL: URL + private let transport: any ClientTransport /// - Parameters: /// - tokens: supplies the Clerk session JWT. @@ -154,12 +172,48 @@ public struct MapleClient: MapleAPI { /// (`https://api.maple.dev`), so the production URL is never hardcoded /// in Swift. Override for a locally-run API. public init(tokens: any MapleTokenProvider, baseURL: URL? = nil) throws { - self.client = Client( + self.init( + tokens: tokens, serverURL: try baseURL ?? Servers.Server1.url(), transport: URLSessionTransport(), - // Order matters: auth runs outermost so the error mapper sees the - // response to a request that actually carried a token. - middlewares: [BearerAuthMiddleware(tokens: tokens), ErrorMappingMiddleware()] + organizationId: nil + ) + } + + private init( + tokens: any MapleTokenProvider, + serverURL: URL, + transport: any ClientTransport, + organizationId: String? + ) { + self.tokens = tokens + self.serverURL = serverURL + self.transport = transport + + // Order matters: auth runs outermost so the error mapper sees the + // response to a request that actually carried a token. + var middlewares: [any ClientMiddleware] = [BearerAuthMiddleware(tokens: tokens)] + if let organizationId { + middlewares.append(OrganizationMiddleware(organizationId: organizationId)) + } + middlewares.append(ErrorMappingMiddleware()) + + self.client = Client(serverURL: serverURL, transport: transport, middlewares: middlewares) + } + + /// The transport is shared rather than rebuilt, so scoping to three + /// organizations does not mean three `URLSession`s and three connection + /// pools. + /// + /// A scoped call must never invalidate the token: it does not depend on the + /// active-organization claim, so a background fetch for one organization + /// must not perturb the token the foreground is using for another. + public func scoped(to organizationId: String) -> any MapleAPI { + MapleClient( + tokens: tokens, + serverURL: serverURL, + transport: transport, + organizationId: organizationId ) } diff --git a/apps/ios/Packages/MapleAPI/Sources/MapleAPI/Middleware.swift b/apps/ios/Packages/MapleAPI/Sources/MapleAPI/Middleware.swift index a596f0809..166435e3b 100644 --- a/apps/ios/Packages/MapleAPI/Sources/MapleAPI/Middleware.swift +++ b/apps/ios/Packages/MapleAPI/Sources/MapleAPI/Middleware.swift @@ -22,10 +22,10 @@ extension MapleTokenProvider { /// Attaches `Authorization: Bearer ` to every request. /// -/// Note there is deliberately **no org header**: v2 resolves the organization -/// from the token's own active-organization claim, so switching orgs means -/// re-minting the token, not changing a header. Adding one here would do -/// nothing. +/// The token carries the organization: v2 reads the active-organization claim, +/// so switching organizations means re-minting the token rather than changing a +/// header. `OrganizationMiddleware` is the one exception, and it is deliberately +/// not applied to the app's own client — see `MapleAPI.scoped(to:)`. public struct BearerAuthMiddleware: ClientMiddleware { private let tokens: any MapleTokenProvider @@ -49,6 +49,40 @@ public struct BearerAuthMiddleware: ClientMiddleware { } } +/// Names the organization explicitly, for the one caller that cannot use the +/// token's own claim. +/// +/// That caller is the widget publisher, fetching for an organization the user +/// belongs to but has not made active. `Clerk.setActive` is global session +/// state the foreground is using, so the only way to read another organization +/// without disturbing the user is to name it per request. The server verifies +/// the name against the caller's memberships (`packages/auth`, +/// `ORG_SELECTION_HEADER`), so this header can never widen what the token +/// already authorizes — and an organization it cannot verify is a 403, never a +/// silent fallback to the active one. +public struct OrganizationMiddleware: ClientMiddleware { + /// Must match `ORG_SELECTION_HEADER` in `packages/auth/src/index.ts`. + public static let headerName = HTTPField.Name("x-maple-org-id")! + + private let organizationId: String + + public init(organizationId: String) { + self.organizationId = organizationId + } + + public func intercept( + _ request: HTTPRequest, + body: HTTPBody?, + baseURL: URL, + operationID: String, + next: (HTTPRequest, HTTPBody?, URL) async throws -> (HTTPResponse, HTTPBody?) + ) async throws -> (HTTPResponse, HTTPBody?) { + var request = request + request.headerFields[Self.headerName] = organizationId + return try await next(request, body, baseURL) + } +} + /// Turns every non-2xx response into a typed `MapleAPIError` before the /// generated code sees it. /// diff --git a/apps/ios/Packages/MapleAPI/Sources/MapleWidgetData/DestinationResolver.swift b/apps/ios/Packages/MapleAPI/Sources/MapleWidgetData/DestinationResolver.swift new file mode 100644 index 000000000..f4355b987 --- /dev/null +++ b/apps/ios/Packages/MapleAPI/Sources/MapleWidgetData/DestinationResolver.swift @@ -0,0 +1,65 @@ +import Foundation + +/// What the app knows about the session at the moment a destination arrives. +/// +/// A snapshot rather than the `SessionController` itself, so the decision below +/// is a pure function the package can test — the app target has no test bundle. +public enum SessionSnapshot: Sendable, Equatable { + /// Clerk is still restoring. Memberships are not merely empty, they are + /// **unknown**, which is a different thing from "you are not a member". + case loading + case signedOut + case ready(activeOrganizationId: String, memberIds: Set, membershipsLoaded: Bool) +} + +public enum DestinationDecision: Sendable, Equatable { + /// Go straight there: either the link named no organization, or it named the + /// one already active. + case navigate + /// Switch first, then go. Never the other way round. + case switchThenNavigate(organizationId: String) + /// The session cannot answer yet. Hold the destination and ask again once it + /// settles. + case park + /// The user is not in that organization. Say so; do not navigate. + case refuseNotAMember(organizationId: String) +} + +/// Decides what to do with a destination that names an organization. +/// +/// The ordering of the rules is the whole content of this type. In particular +/// **the "still loading" checks come before the membership check**: a tap on a +/// notification launches the app cold, and `didReceive` fires before +/// `RootView`'s task has run `SessionController.refresh()`. At that moment the +/// membership set is empty, so a membership-first ordering would tell every +/// cold-start cross-organization tap that the user is not a member — which is a +/// worse bug than the one being fixed. +public enum DestinationResolver { + public static func decide( + organizationId: String?, + session: SessionSnapshot + ) -> DestinationDecision { + // No organization named. This is every link built before multi-org, and + // `maple://issues` still means "the issues of whichever org I am in". + // Switching here would move the user for a link that never asked. + guard let organizationId, !organizationId.isEmpty else { return .navigate } + + switch session { + case .loading: + return .park + case .signedOut: + // The user may be about to sign in — `sessionDidSettle()` re-asks. + return .park + case .ready(let activeOrganizationId, let memberIds, let membershipsLoaded): + if activeOrganizationId == organizationId { return .navigate } + // `membershipsLoaded == false` means the list came from Clerk's client + // payload, which `SessionController` documents as possibly partial. + // Refusing on a partial list would lock a user out of their own org. + guard membershipsLoaded else { return .park } + guard memberIds.contains(organizationId) else { + return .refuseNotAMember(organizationId: organizationId) + } + return .switchThenNavigate(organizationId: organizationId) + } + } +} diff --git a/apps/ios/Packages/MapleAPI/Sources/MapleWidgetData/IncidentActivityAttributes.swift b/apps/ios/Packages/MapleAPI/Sources/MapleWidgetData/IncidentActivityAttributes.swift index cd67b1eff..ad6f1af85 100644 --- a/apps/ios/Packages/MapleAPI/Sources/MapleWidgetData/IncidentActivityAttributes.swift +++ b/apps/ios/Packages/MapleAPI/Sources/MapleWidgetData/IncidentActivityAttributes.swift @@ -97,19 +97,30 @@ public struct IncidentActivityAttributes: Codable, Hashable, Sendable { /// What is being measured ("Error Rate", "p95 Latency"). public var signalLabel: String public var startedAt: Date + /// Which organization's incident this is, so a tap lands in the right one. + /// + /// **Optional, and it has to stay optional.** Attributes are the static half + /// of an activity: an activity already running when this shipped has no such + /// field and no later push can add one. Making it required would also mean + /// iOS silently dropping every start push from a server that has not yet + /// shipped the matching change — and, per the notes at the top of this file, + /// a failed decode here is silence, not an error. + public var organizationId: String? public init( incidentId: String, ruleName: String, service: String?, signalLabel: String, - startedAt: Date + startedAt: Date, + organizationId: String? = nil ) { self.incidentId = incidentId self.ruleName = ruleName self.service = service self.signalLabel = signalLabel self.startedAt = startedAt + self.organizationId = organizationId } private enum CodingKeys: String, CodingKey { @@ -118,6 +129,7 @@ public struct IncidentActivityAttributes: Codable, Hashable, Sendable { case service case signalLabel = "signal_label" case startedAt = "started_at" + case organizationId = "organization_id" } public init(from decoder: any Decoder) throws { @@ -127,6 +139,7 @@ public struct IncidentActivityAttributes: Codable, Hashable, Sendable { service = try container.decodeIfPresent(String.self, forKey: .service) signalLabel = try container.decode(String.self, forKey: .signalLabel) startedAt = Date(timeIntervalSince1970: try container.decode(Double.self, forKey: .startedAt)) + organizationId = try container.decodeIfPresent(String.self, forKey: .organizationId) } public func encode(to encoder: any Encoder) throws { @@ -136,6 +149,7 @@ public struct IncidentActivityAttributes: Codable, Hashable, Sendable { try container.encodeIfPresent(service, forKey: .service) try container.encode(signalLabel, forKey: .signalLabel) try container.encode(startedAt.timeIntervalSince1970, forKey: .startedAt) + try container.encodeIfPresent(organizationId, forKey: .organizationId) } } @@ -148,7 +162,9 @@ public enum IncidentActivityStatus: String, Codable, Hashable, Sendable { extension IncidentActivityAttributes { /// Where a tap on the activity lands: the incident, on the Alerts tab. public var deepLinkURL: URL? { - URL(string: "\(IssuesWidgetKind.urlScheme)://incident/\(incidentId)") + // No `?org=` for a legacy activity, which keeps its pre-multi-org meaning: + // open in whichever organization is active. + WidgetDeepLink(target: .incident(id: incidentId), organizationId: organizationId).url } /// Previews and the widget gallery. @@ -158,7 +174,8 @@ extension IncidentActivityAttributes { ruleName: "Checkout error rate", service: "checkout-api", signalLabel: "Error Rate", - startedAt: Date().addingTimeInterval(-14 * 60) + startedAt: Date().addingTimeInterval(-14 * 60), + organizationId: "org_sample" ) } } diff --git a/apps/ios/Packages/MapleAPI/Sources/MapleWidgetData/PublishedOrganizationIndex.swift b/apps/ios/Packages/MapleAPI/Sources/MapleWidgetData/PublishedOrganizationIndex.swift new file mode 100644 index 000000000..2a0c6b64a --- /dev/null +++ b/apps/ios/Packages/MapleAPI/Sources/MapleWidgetData/PublishedOrganizationIndex.swift @@ -0,0 +1,120 @@ +import Foundation + +/// An organization the app has published a snapshot for. +public struct PublishedOrganization: Codable, Hashable, Sendable, Identifiable { + public var id: String + /// Display name, when the app knew one. The id is the stable identity — + /// an organization can be renamed, and a widget pinned to it must survive + /// that. + public var name: String? + public var lastPublishedAt: Date + + public init(id: String, name: String?, lastPublishedAt: Date) { + self.id = id + self.name = name + self.lastPublishedAt = lastPublishedAt + } +} + +/// Which organizations the widget extension may show, and which one is active. +/// +/// The extension has no session and cannot ask Clerk anything, so this is its +/// only source of truth for the organization picker and for resolving a widget +/// that was never configured. The app writes it whenever it publishes. +public struct PublishedOrganizationIndex: Sendable { + private let appGroupIdentifier: String + + private enum Key { + static let organizations = "widgets.organizations.v1" + static let active = "widgets.activeOrganization.v1" + } + + public init(appGroupIdentifier: String = WidgetAppGroup.identifier) { + self.appGroupIdentifier = appGroupIdentifier + } + + private var defaults: UserDefaults? { UserDefaults(suiteName: appGroupIdentifier) } + + /// The organization a widget with no configuration resolves to — including + /// every widget migrated from before the picker existed. + public var activeOrganizationId: String? { + defaults?.string(forKey: Key.active) + } + + /// Published organizations, **active first**, then by most recently + /// published. That ordering is what the picker shows and what + /// `defaultResult()` picks, so a newly placed widget lands where the user + /// already is. + public func load() -> [PublishedOrganization] { + guard let data = defaults?.data(forKey: Key.organizations), + let decoded = try? Self.decoder.decode([PublishedOrganization].self, from: data) + else { return [] } + + let active = activeOrganizationId + return decoded.sorted { first, second in + if (first.id == active) != (second.id == active) { return first.id == active } + return first.lastPublishedAt > second.lastPublishedAt + } + } + + /// Record a publish. Replaces the existing entry rather than appending, so + /// republishing does not grow the list. + public func record(_ organization: PublishedOrganization, isActive: Bool) { + guard let defaults else { return } + var organizations = load().filter { $0.id != organization.id } + organizations.append(organization) + write(organizations, to: defaults) + if isActive { defaults.set(organization.id, forKey: Key.active) } + } + + /// Drop every organization the user is no longer a member of, and return + /// their ids so the caller can wipe their snapshots too. + /// + /// **Only ever call this with a verified membership list.** Pruning against + /// Clerk's partial client payload would delete live organizations' snapshots + /// and leave those widgets empty until the next publish. + @discardableResult + public func prune(to memberIds: Set) -> [String] { + guard let defaults else { return [] } + let organizations = load() + let evicted = organizations.map(\.id).filter { !memberIds.contains($0) } + guard !evicted.isEmpty else { return [] } + + write(organizations.filter { memberIds.contains($0.id) }, to: defaults) + if let active = activeOrganizationId, !memberIds.contains(active) { + defaults.removeObject(forKey: Key.active) + } + return evicted + } + + /// Sign-out. Returns every id that was published, because the caller has a + /// per-organization snapshot to remove for each — leaving those behind would + /// keep one account's incidents readable to whoever holds the phone next. + @discardableResult + public func clear() -> [String] { + guard let defaults else { return [] } + let ids = load().map(\.id) + defaults.removeObject(forKey: Key.organizations) + defaults.removeObject(forKey: Key.active) + return ids + } + + private func write(_ organizations: [PublishedOrganization], to defaults: UserDefaults) { + guard let data = try? Self.encoder.encode(organizations) else { return } + defaults.set(data, forKey: Key.organizations) + } + + // ISO-8601, matching `WidgetSnapshotStore`: read by another process, from + // possibly another build. + private static var encoder: JSONEncoder { + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + return encoder + } + + private static var decoder: JSONDecoder { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + return decoder + } +} diff --git a/apps/ios/Packages/MapleAPI/Sources/MapleWidgetData/SelectOrganizationIntent.swift b/apps/ios/Packages/MapleAPI/Sources/MapleWidgetData/SelectOrganizationIntent.swift new file mode 100644 index 000000000..0b7762a56 --- /dev/null +++ b/apps/ios/Packages/MapleAPI/Sources/MapleWidgetData/SelectOrganizationIntent.swift @@ -0,0 +1,79 @@ +import AppIntents +import Foundation + +/// The issues widget's configuration: which organization it shows. +/// +/// Before this, every widget followed whichever organization happened to be +/// active in the app, so switching organizations silently re-pointed the Home +/// Screen. Pinning is the whole point: two widgets, two organizations, both +/// correct at once. +public struct SelectOrganizationIntent: WidgetConfigurationIntent { + public static let title: LocalizedStringResource = "Select organization" + public static let description = IntentDescription("Show one organization's ongoing issues.") + + @Parameter(title: "Organization") + public var organization: OrganizationEntity? + + public init() {} + + public init(organization: OrganizationEntity?) { + self.organization = organization + } +} + +/// One row of the organization picker. +public struct OrganizationEntity: AppEntity { + /// The `org_…` id, not the name: an organization can be renamed, and a + /// widget pinned to it has to survive that. + public var id: String + public var name: String? + + public init(id: String, name: String?) { + self.id = id + self.name = name + } + + public static let typeDisplayRepresentation = TypeDisplayRepresentation(name: "Organization") + public static let defaultQuery = OrganizationEntityQuery() + + public var displayRepresentation: DisplayRepresentation { + DisplayRepresentation(title: "\(name ?? id)") + } +} + +/// The options come from what the app has published. The extension has no +/// session, so this index is the only thing here that knows an organization +/// exists. +public struct OrganizationEntityQuery: EntityQuery { + public init() {} + + private var published: [PublishedOrganization] { PublishedOrganizationIndex().load() } + + /// Resolving what a configured widget already holds. An organization the + /// user has since left still resolves, by id — dropping it would silently + /// re-point the widget at the active organization, which is the exact + /// failure this configuration exists to prevent. The widget renders it as + /// unavailable instead. + public func entities(for identifiers: [String]) async throws -> [OrganizationEntity] { + let known = published + return identifiers.map { identifier in + OrganizationEntity(id: identifier, name: known.first { $0.id == identifier }?.name) + } + } + + public func suggestedEntities() async throws -> [OrganizationEntity] { + published.map { OrganizationEntity(id: $0.id, name: $0.name) } + } + + /// A newly placed widget lands on the organization the user is already in, + /// rather than on an empty picker they have to answer before the widget says + /// anything. It is also what a widget migrated from the pre-configuration + /// build resolves to. + public func defaultResult() async -> OrganizationEntity? { + let known = published + guard let activeId = PublishedOrganizationIndex().activeOrganizationId else { + return known.first.map { OrganizationEntity(id: $0.id, name: $0.name) } + } + return OrganizationEntity(id: activeId, name: known.first { $0.id == activeId }?.name) + } +} diff --git a/apps/ios/Packages/MapleAPI/Sources/MapleWidgetData/SelectServiceIntent.swift b/apps/ios/Packages/MapleAPI/Sources/MapleWidgetData/SelectServiceIntent.swift new file mode 100644 index 000000000..e2ab8e9b4 --- /dev/null +++ b/apps/ios/Packages/MapleAPI/Sources/MapleWidgetData/SelectServiceIntent.swift @@ -0,0 +1,101 @@ +import AppIntents +import Foundation + +/// The widget's own configuration: which service it shows. +/// +/// Long-press → Edit Widget → Service. Leaving it unset means the whole +/// organization, which is the useful default — someone adding a throughput +/// widget without a service in mind wants "is traffic normal", not a picker +/// they have to answer before the widget says anything. +public struct SelectServiceIntent: WidgetConfigurationIntent { + public static let title: LocalizedStringResource = "Select service" + public static let description = IntentDescription("Show one service's throughput, or the whole organization's.") + + @Parameter(title: "Service") + public var service: ServiceEntity? + + /// Added rather than split into a second intent: iOS persists the intent + /// *type name* for every configured widget, so renaming or replacing this + /// type unconfigures them all. A new optional parameter is safe — existing + /// instances decode with it nil, which resolves to the active organization. + @Parameter(title: "Organization") + public var organization: OrganizationEntity? + + public init() {} + + public init(service: ServiceEntity?, organization: OrganizationEntity? = nil) { + self.service = service + self.organization = organization + } +} + +/// One row of the picker. +/// +/// The options come from the snapshot the app published — the extension has no +/// session to list services with, and the app's own list is the right one +/// anyway: it is scoped to the signed-in organization and to services that +/// actually reported in the last hour. +public struct ServiceEntity: AppEntity { + /// The service name is the identifier. Names are unique per organization, + /// and using them means a configured widget survives a republish that + /// reordered the list. + public var id: String + + public var throughputPerSecond: Double? + + public init(id: String, throughputPerSecond: Double?) { + self.id = id + self.throughputPerSecond = throughputPerSecond + } + + public static let typeDisplayRepresentation = TypeDisplayRepresentation(name: "Service") + public static let defaultQuery = ServiceEntityQuery() + + public var displayRepresentation: DisplayRepresentation { + guard let throughputPerSecond else { return DisplayRepresentation(title: "\(id)") } + // The rate as a subtitle: with a dozen services, "which one is busy" + // is most of what the choice depends on. + return DisplayRepresentation(title: "\(id)", subtitle: "\(WidgetFormat.rate(throughputPerSecond))") + } +} + +public struct ServiceEntityQuery: EntityQuery { + public init() {} + + /// Reads the organization parameter of the very intent being configured, so + /// the service list belongs to the organization the user just picked. + @IntentParameterDependency(\.$organization) + public var configuration + + /// The dependency is nil until the organization parameter resolves, and an + /// empty service picker on first open reads as broken — so fall back to the + /// organization the app is in. + private var organizationId: String? { + configuration?.organization.id ?? PublishedOrganizationIndex().activeOrganizationId + } + + private var snapshot: ThroughputSnapshot? { + organizationId.flatMap { WidgetSnapshotStore.throughput(organizationId: $0).load() } + } + + /// Resolving what a configured widget already holds. A service that has + /// since gone quiet still resolves — dropping it here would silently + /// re-point the widget at the organization total, which reads as "your + /// service is fine" rather than "your service stopped reporting". + public func entities(for identifiers: [String]) async throws -> [ServiceEntity] { + let services = snapshot?.services ?? [] + return identifiers.map { identifier in + ServiceEntity( + id: identifier, + throughputPerSecond: services.first { $0.name == identifier }?.throughputPerSecond + ) + } + } + + /// The list iOS shows in the picker: busiest first, as published. + public func suggestedEntities() async throws -> [ServiceEntity] { + (snapshot?.services ?? []).compactMap { service in + service.name.map { ServiceEntity(id: $0, throughputPerSecond: service.throughputPerSecond) } + } + } +} diff --git a/apps/ios/Packages/MapleAPI/Sources/MapleWidgetData/WidgetDeepLink.swift b/apps/ios/Packages/MapleAPI/Sources/MapleWidgetData/WidgetDeepLink.swift new file mode 100644 index 000000000..f26eabb7b --- /dev/null +++ b/apps/ios/Packages/MapleAPI/Sources/MapleWidgetData/WidgetDeepLink.swift @@ -0,0 +1,140 @@ +import Foundation + +/// Every `maple://` destination, as one value that can be built and parsed. +/// +/// Building used to live in `WidgetKinds` and parsing in `AppNavigation`, in the +/// app target — which has no test bundle, so the two halves could drift with +/// nothing to catch it. They are one type here, in the module the app, the +/// widget extension and the Live Activity all link, and `swift test` covers the +/// round trip. +/// +/// **The organization travels as a query item, never as a path segment.** Every +/// notification already sitting in Notification Center and every activity +/// already on a Lock Screen was built without one, and an absent `org` still +/// means exactly what it has always meant: whichever organization is active. +/// A path form (`maple://org//incident/`) would have made all of those +/// unparseable. +public struct WidgetDeepLink: Hashable, Sendable { + public enum Target: Hashable, Sendable { + case incident(id: String) + case issue(id: String) + case service(name: String) + case incidentsList + case issuesList + case servicesList + } + + public var target: Target + /// The organization the destination belongs to, or nil for "the active one". + /// + /// Nil is not a missing value to be filled in later — it is the pre-multi-org + /// meaning, and `DestinationResolver` deliberately never switches organization + /// for it. + public var organizationId: String? + + public init(target: Target, organizationId: String? = nil) { + self.target = target + self.organizationId = organizationId + } + + public static let scheme = "maple" + /// The query item carrying the organization. Short because it is user-visible + /// in a copied link. + public static let organizationQueryItem = "org" + + /// Parses a `maple://` URL, or nil for anything else. + /// + /// Anything unrecognised is nil rather than a guess: a URL this app does not + /// know landing the user on an arbitrary tab is worse than it doing nothing, + /// and the scheme is ours alone. + public init?(url: URL) { + guard url.scheme == Self.scheme else { return nil } + guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { return nil } + + // The first real path component. `pathComponents` includes "/" for an + // absolute path, and a host-only URL has none at all. + let identifier = components.percentEncodedPath + .split(separator: "/") + .first + .map(String.init)? + .removingPercentEncoding + + // A host that takes an identifier falls back to its list when the + // identifier is missing — a widget row whose issue disappeared should still + // open the Errors list rather than do nothing. + func resolve(_ make: (String) -> Target, orElse fallback: Target) -> Target { + guard let identifier, !identifier.isEmpty else { return fallback } + return make(identifier) + } + + let target: Target + switch components.host { + case "incident": + target = resolve({ .incident(id: $0) }, orElse: .incidentsList) + case "incidents": + target = .incidentsList + case "issue": + target = resolve({ .issue(id: $0) }, orElse: .issuesList) + case "issues": + target = .issuesList + case "service": + target = resolve({ .service(name: $0) }, orElse: .servicesList) + case "services": + target = .servicesList + default: + return nil + } + + self.target = target + let organizationId = components.queryItems? + .first { $0.name == Self.organizationQueryItem }? + .value + self.organizationId = organizationId.flatMap { $0.isEmpty ? nil : $0 } + } + + /// The URL form. Built with `URLComponents` rather than interpolation: + /// service names carry characters — spaces, `/`, `#`, `?` — that have to be + /// encoded differently in a path than in a query, and hand-rolling that once + /// a query item exists is how a link silently stops resolving. + public var url: URL? { + var components = URLComponents() + components.scheme = Self.scheme + + switch target { + case .incident(let id): + components.host = "incident" + guard let path = Self.encodedSegment(id) else { return nil } + components.percentEncodedPath = path + case .issue(let id): + components.host = "issue" + guard let path = Self.encodedSegment(id) else { return nil } + components.percentEncodedPath = path + case .service(let name): + components.host = "service" + guard let path = Self.encodedSegment(name) else { return nil } + components.percentEncodedPath = path + case .incidentsList: + components.host = "incidents" + case .issuesList: + components.host = "issues" + case .servicesList: + components.host = "services" + } + + if let organizationId, !organizationId.isEmpty { + components.queryItems = [URLQueryItem(name: Self.organizationQueryItem, value: organizationId)] + } + return components.url + } + + /// One path segment, with `/` encoded rather than treated as a separator. + /// + /// `.urlPathAllowed` permits `/`, so a service named `orders/v2` built through + /// it produces a two-segment path that parses back as `orders` — the kind of + /// break that only shows up for the one customer who names services that way. + private static func encodedSegment(_ value: String) -> String? { + var allowed = CharacterSet.urlPathAllowed + allowed.remove(charactersIn: "/") + return value.addingPercentEncoding(withAllowedCharacters: allowed).map { "/\($0)" } + } +} diff --git a/apps/ios/Packages/MapleAPI/Sources/MapleWidgetData/WidgetKinds.swift b/apps/ios/Packages/MapleAPI/Sources/MapleWidgetData/WidgetKinds.swift index bfab3bcea..7093fa717 100644 --- a/apps/ios/Packages/MapleAPI/Sources/MapleWidgetData/WidgetKinds.swift +++ b/apps/ios/Packages/MapleAPI/Sources/MapleWidgetData/WidgetKinds.swift @@ -11,15 +11,20 @@ public enum IssuesWidgetKind { public static let identifier = "MapleIssuesWidget" /// Tapping a row opens the app on that issue; tapping anything else opens - /// the Errors list. Handled by `AppNavigation.open(_:)`. - public static let urlScheme = "maple" + /// the Errors list. Built and parsed by `WidgetDeepLink`, routed by + /// `DestinationOpener`. + public static let urlScheme = WidgetDeepLink.scheme - public static func issueURL(id: String) -> URL? { - URL(string: "\(urlScheme)://issue/\(id)") + /// `organizationId` is required rather than defaulted: a widget pinned to one + /// organization that emits an organization-less link sends the tap to + /// whichever org happens to be active, which is the bug these links exist to + /// avoid. A compile error at the call site is the point. + public static func issueURL(id: String, organizationId: String?) -> URL? { + WidgetDeepLink(target: .issue(id: id), organizationId: organizationId).url } - public static var issuesListURL: URL? { - URL(string: "\(urlScheme)://issues") + public static func issuesListURL(organizationId: String?) -> URL? { + WidgetDeepLink(target: .issuesList, organizationId: organizationId).url } } @@ -30,15 +35,12 @@ public enum ThroughputWidgetKind { /// A configured widget opens its service; the unconfigured one opens the /// Services tab. - public static func serviceURL(name: String?) -> URL? { - guard let name, !name.isEmpty else { return servicesListURL } - guard let encoded = name.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) else { - return servicesListURL - } - return URL(string: "\(IssuesWidgetKind.urlScheme)://service/\(encoded)") + public static func serviceURL(name: String?, organizationId: String?) -> URL? { + guard let name, !name.isEmpty else { return servicesListURL(organizationId: organizationId) } + return WidgetDeepLink(target: .service(name: name), organizationId: organizationId).url } - public static var servicesListURL: URL? { - URL(string: "\(IssuesWidgetKind.urlScheme)://services") + public static func servicesListURL(organizationId: String?) -> URL? { + WidgetDeepLink(target: .servicesList, organizationId: organizationId).url } } diff --git a/apps/ios/Packages/MapleAPI/Sources/MapleWidgetData/WidgetSnapshotStore.swift b/apps/ios/Packages/MapleAPI/Sources/MapleWidgetData/WidgetSnapshotStore.swift index bea1dacdd..4c577ea74 100644 --- a/apps/ios/Packages/MapleAPI/Sources/MapleWidgetData/WidgetSnapshotStore.swift +++ b/apps/ios/Packages/MapleAPI/Sources/MapleWidgetData/WidgetSnapshotStore.swift @@ -78,10 +78,35 @@ public struct WidgetSnapshotStore: Sendable { } } +// Keys are per organization, because a widget can be pinned to one. The `v1` +// keys below are read-only leftovers: a widget placed before this shipped has a +// snapshot under the old key and would otherwise render "Open Maple" until the +// next publish. Delete them, and the fallbacks that read them, one release on. + extension WidgetSnapshotStore where Value == IssuesSnapshot { - public static var issues: WidgetSnapshotStore { .init(key: "issues.snapshot.v1") } + public static func issues( + organizationId: String, + appGroupIdentifier: String = WidgetAppGroup.identifier + ) -> WidgetSnapshotStore { + .init(key: "issues.snapshot.v2.\(organizationId)", appGroupIdentifier: appGroupIdentifier) + } + + /// Read-only fallback for widgets placed before per-organization snapshots. + /// Delete one release after that shipped, along with its readers. + public static var legacyIssues: WidgetSnapshotStore { .init(key: "issues.snapshot.v1") } } extension WidgetSnapshotStore where Value == ThroughputSnapshot { - public static var throughput: WidgetSnapshotStore { .init(key: "throughput.snapshot.v1") } + public static func throughput( + organizationId: String, + appGroupIdentifier: String = WidgetAppGroup.identifier + ) -> WidgetSnapshotStore { + .init(key: "throughput.snapshot.v2.\(organizationId)", appGroupIdentifier: appGroupIdentifier) + } + + /// Read-only fallback for widgets placed before per-organization snapshots. + /// Delete one release after that shipped, along with its readers. + public static var legacyThroughput: WidgetSnapshotStore { + .init(key: "throughput.snapshot.v1") + } } diff --git a/apps/ios/Packages/MapleAPI/Tests/MapleAPITests/OrganizationMiddlewareTests.swift b/apps/ios/Packages/MapleAPI/Tests/MapleAPITests/OrganizationMiddlewareTests.swift new file mode 100644 index 000000000..5c31a1230 --- /dev/null +++ b/apps/ios/Packages/MapleAPI/Tests/MapleAPITests/OrganizationMiddlewareTests.swift @@ -0,0 +1,105 @@ +import Foundation +import HTTPTypes +import OpenAPIRuntime +import Testing + +@testable import MapleAPI + +/// Captures the request a middleware chain produced, and answers with an empty +/// 200 so the chain completes. +private final class RecordingTransport: ClientTransport, @unchecked Sendable { + private(set) var lastRequest: HTTPRequest? + + func send( + _ request: HTTPRequest, + body: HTTPBody?, + baseURL: URL, + operationID: String + ) async throws -> (HTTPResponse, HTTPBody?) { + lastRequest = request + return (HTTPResponse(status: .ok), nil) + } +} + +private actor CountingTokens: MapleTokenProvider { + private(set) var forcedRefreshes = 0 + + func token(forceRefresh: Bool) async throws -> String? { + if forceRefresh { forcedRefreshes += 1 } + return "test-token" + } + + func forcedRefreshCount() -> Int { forcedRefreshes } +} + +@Suite("Organization scoping") +struct OrganizationMiddlewareTests { + private func send(through middlewares: [any ClientMiddleware], transport: RecordingTransport) async throws { + var next: @Sendable (HTTPRequest, HTTPBody?, URL) async throws -> (HTTPResponse, HTTPBody?) = { + try await transport.send($0, body: $1, baseURL: $2, operationID: "test") + } + for middleware in middlewares.reversed() { + let inner = next + next = { @Sendable request, body, baseURL in + try await middleware.intercept( + request, + body: body, + baseURL: baseURL, + operationID: "test", + next: inner + ) + } + } + _ = try await next( + HTTPRequest(method: .get, scheme: "https", authority: "api.maple.test", path: "/v2/services"), + nil, + URL(string: "https://api.maple.test")! + ) + } + + @Test("A scoped client names the organization; an unscoped one does not") + func headerPresenceFollowsScope() async throws { + let tokens = CountingTokens() + + let scoped = RecordingTransport() + try await send( + through: [ + BearerAuthMiddleware(tokens: tokens), + OrganizationMiddleware(organizationId: "org_2abc"), + ], + transport: scoped + ) + #expect(scoped.lastRequest?.headerFields[OrganizationMiddleware.headerName] == "org_2abc") + #expect(scoped.lastRequest?.headerFields[.authorization] == "Bearer test-token") + + let unscoped = RecordingTransport() + try await send(through: [BearerAuthMiddleware(tokens: tokens)], transport: unscoped) + // Absent, not empty: the app's own requests must keep resolving the + // organization from the token's claim. + #expect(unscoped.lastRequest?.headerFields[OrganizationMiddleware.headerName] == nil) + } + + /// A background fetch for one organization must not invalidate the token the + /// foreground is using for another. + @Test("Scoped requests never force a token refresh") + func scopedRequestsDoNotRefreshTheToken() async throws { + let tokens = CountingTokens() + let transport = RecordingTransport() + + try await send( + through: [ + BearerAuthMiddleware(tokens: tokens), + OrganizationMiddleware(organizationId: "org_2abc"), + ], + transport: transport + ) + + #expect(await tokens.forcedRefreshCount() == 0) + } + + /// The header name is a wire contract with `packages/auth`. + @Test("The header name matches the server's") + func headerNameIsPinned() { + #expect(OrganizationMiddleware.headerName.canonicalName == "x-maple-org-id") + } +} diff --git a/apps/ios/Packages/MapleAPI/Tests/MapleWidgetDataTests/DestinationResolverTests.swift b/apps/ios/Packages/MapleAPI/Tests/MapleWidgetDataTests/DestinationResolverTests.swift new file mode 100644 index 000000000..5ef47d5df --- /dev/null +++ b/apps/ios/Packages/MapleAPI/Tests/MapleWidgetDataTests/DestinationResolverTests.swift @@ -0,0 +1,72 @@ +import Foundation +import Testing + +@testable import MapleWidgetData + +/// One test per rule, because the *order* of the rules is the whole content of +/// `DestinationResolver` and every wrong order produces a plausible-looking app +/// that fails on exactly one path. +@Suite("DestinationResolver") +struct DestinationResolverTests { + private let ready = SessionSnapshot.ready( + activeOrganizationId: "org_a", + memberIds: ["org_a", "org_b"], + membershipsLoaded: true + ) + + @Test("A link with no organization never moves the user") + func organizationLessLinkNavigates() { + #expect(DestinationResolver.decide(organizationId: nil, session: ready) == .navigate) + #expect(DestinationResolver.decide(organizationId: "", session: ready) == .navigate) + } + + @Test("The organization already active is a plain navigation") + func sameOrganizationNavigates() { + #expect(DestinationResolver.decide(organizationId: "org_a", session: ready) == .navigate) + } + + @Test("Another organization the user belongs to switches first") + func memberOrganizationSwitches() { + #expect( + DestinationResolver.decide(organizationId: "org_b", session: ready) + == .switchThenNavigate(organizationId: "org_b") + ) + } + + @Test("An organization the user has left is refused, not opened") + func nonMemberIsRefused() { + #expect( + DestinationResolver.decide(organizationId: "org_z", session: ready) + == .refuseNotAMember(organizationId: "org_z") + ) + } + + /// The cold-start case. A notification tap launches the app and fires + /// `didReceive` before `RootView`'s task has run `session.refresh()`, so the + /// membership set is empty — *unknown*, not "you are not a member". Checking + /// membership before this would refuse every cold cross-org tap. + @Test("A session still loading parks rather than refusing") + func loadingParks() { + #expect(DestinationResolver.decide(organizationId: "org_b", session: .loading) == .park) + } + + @Test("A signed-out session parks — the user may be about to sign in") + func signedOutParks() { + #expect(DestinationResolver.decide(organizationId: "org_b", session: .signedOut) == .park) + } + + /// `membershipsLoaded == false` means the list came from Clerk's client + /// payload, which `SessionController` documents as possibly partial. Refusing + /// on that would lock a user out of an org they are in. + @Test("An unverified membership list parks rather than refusing") + func partialMembershipsPark() { + let partial = SessionSnapshot.ready( + activeOrganizationId: "org_a", + memberIds: ["org_a"], + membershipsLoaded: false + ) + #expect(DestinationResolver.decide(organizationId: "org_b", session: partial) == .park) + // …but the active org still resolves, because that needs no list at all. + #expect(DestinationResolver.decide(organizationId: "org_a", session: partial) == .navigate) + } +} diff --git a/apps/ios/Packages/MapleAPI/Tests/MapleWidgetDataTests/IncidentActivityAttributesTests.swift b/apps/ios/Packages/MapleAPI/Tests/MapleWidgetDataTests/IncidentActivityAttributesTests.swift index ee3a45f6f..5a8e9dbb0 100644 --- a/apps/ios/Packages/MapleAPI/Tests/MapleWidgetDataTests/IncidentActivityAttributesTests.swift +++ b/apps/ios/Packages/MapleAPI/Tests/MapleWidgetDataTests/IncidentActivityAttributesTests.swift @@ -110,7 +110,8 @@ struct IncidentActivityAttributesTests { let encoded = try JSONEncoder().encode(IncidentActivityAttributes.sample) let json = try object(String(decoding: encoded, as: UTF8.self)) #expect( - Set(json.keys) == ["incident_id", "rule_name", "service", "signal_label", "started_at"] + Set(json.keys) + == ["incident_id", "rule_name", "service", "signal_label", "started_at", "organization_id"] ) #expect(json["started_at"] is NSNumber) @@ -121,11 +122,46 @@ struct IncidentActivityAttributesTests { ) } - @Test("A tap opens the incident") + @Test("A tap opens the incident, in its own organization") func deepLink() { #expect( IncidentActivityAttributes.sample.deepLinkURL?.absoluteString - == "maple://incident/inc_YofPTrK9782DWwcnXhpcCw" + == "maple://incident/inc_YofPTrK9782DWwcnXhpcCw?org=org_sample" ) } + + @Test("Decodes the organization when the server sends it") + func decodesOrganization() throws { + let json = """ + { + "incident_id": "inc_1", + "rule_name": "Latency", + "signal_label": "p95 Latency", + "started_at": 1800000000, + "organization_id": "org_2abc" + } + """ + let decoded = try JSONDecoder().decode(IncidentActivityAttributes.self, from: Data(json.utf8)) + #expect(decoded.organizationId == "org_2abc") + #expect(decoded.deepLinkURL?.absoluteString == "maple://incident/inc_1?org=org_2abc") + } + + /// The case that must never regress: an activity started before the + /// organization id existed, or by a server that has not deployed it yet. + /// Attributes are the static half of an activity — a required field here + /// would make iOS drop the start push in silence. + @Test("Decodes attributes with no organization at all, and links without one") + func decodesWithoutOrganization() throws { + let json = """ + { + "incident_id": "inc_1", + "rule_name": "Latency", + "signal_label": "p95 Latency", + "started_at": 1800000000 + } + """ + let decoded = try JSONDecoder().decode(IncidentActivityAttributes.self, from: Data(json.utf8)) + #expect(decoded.organizationId == nil) + #expect(decoded.deepLinkURL?.absoluteString == "maple://incident/inc_1") + } } diff --git a/apps/ios/Packages/MapleAPI/Tests/MapleWidgetDataTests/PublishedOrganizationIndexTests.swift b/apps/ios/Packages/MapleAPI/Tests/MapleWidgetDataTests/PublishedOrganizationIndexTests.swift new file mode 100644 index 000000000..cbc7d5d09 --- /dev/null +++ b/apps/ios/Packages/MapleAPI/Tests/MapleWidgetDataTests/PublishedOrganizationIndexTests.swift @@ -0,0 +1,148 @@ +import Foundation +import Testing + +@testable import MapleWidgetData + +@Suite("Published organizations") +struct PublishedOrganizationIndexTests { + /// A suite **per test**, not per type: swift-testing runs these in parallel + /// and `UserDefaults` is process-wide, so a shared name means one test wiping + /// another's writes mid-assertion. + private func makeIndex(_ name: String = #function) -> PublishedOrganizationIndex { + let suiteName = "com.maple.tests.organizations.\(name)" + UserDefaults(suiteName: suiteName)?.removePersistentDomain(forName: suiteName) + return PublishedOrganizationIndex(appGroupIdentifier: suiteName) + } + + private func organization(_ id: String, name: String? = nil, minutesAgo: Double = 0) -> PublishedOrganization { + PublishedOrganization( + id: id, + name: name, + lastPublishedAt: Date(timeIntervalSince1970: 1_800_000_000 - minutesAgo * 60) + ) + } + + @Test("Reads nothing before the app has ever published") + func startsEmpty() { + let index = makeIndex() + #expect(index.load().isEmpty) + #expect(index.activeOrganizationId == nil) + } + + @Test("The active organization sorts first, then the most recently published") + func ordersActiveFirst() { + let index = makeIndex() + index.record(organization("org_a", name: "Acme", minutesAgo: 30), isActive: false) + index.record(organization("org_b", name: "Globex", minutesAgo: 5), isActive: true) + index.record(organization("org_c", name: "Initech", minutesAgo: 1), isActive: false) + + #expect(index.load().map(\.id) == ["org_b", "org_c", "org_a"]) + #expect(index.activeOrganizationId == "org_b") + } + + @Test("Republishing replaces rather than duplicates") + func recordReplaces() { + let index = makeIndex() + index.record(organization("org_a", name: "Acme", minutesAgo: 30), isActive: true) + index.record(organization("org_a", name: "Acme Renamed", minutesAgo: 0), isActive: true) + + #expect(index.load().count == 1) + #expect(index.load().first?.name == "Acme Renamed") + } + + /// The "removed from the organization" case: the caller uses these ids to + /// wipe the matching snapshots, so returning the wrong set leaves another + /// account's data on the Home Screen. + @Test("Pruning returns exactly the evicted ids and forgets a dropped active") + func pruneEvicts() { + let index = makeIndex() + index.record(organization("org_a"), isActive: true) + index.record(organization("org_b"), isActive: false) + index.record(organization("org_c"), isActive: false) + + #expect(index.prune(to: ["org_b", "org_c"]) == ["org_a"]) + #expect(index.load().map(\.id).sorted() == ["org_b", "org_c"]) + #expect(index.activeOrganizationId == nil) + } + + @Test("Pruning to the same membership set changes nothing") + func pruneIsANoOpWhenNothingChanged() { + let index = makeIndex() + index.record(organization("org_a"), isActive: true) + + #expect(index.prune(to: ["org_a"]).isEmpty) + #expect(index.activeOrganizationId == "org_a") + } + + @Test("Signing out reports every id so their snapshots can go too") + func clearReportsEveryId() { + let index = makeIndex() + index.record(organization("org_a"), isActive: true) + index.record(organization("org_b"), isActive: false) + + #expect(index.clear().sorted() == ["org_a", "org_b"]) + #expect(index.load().isEmpty) + #expect(index.activeOrganizationId == nil) + } +} + +@Suite("Per-organization snapshot storage") +struct PerOrganizationSnapshotStoreTests { + /// Per test, for the same reason as above. + private func suiteName(_ name: String = #function) -> String { + let suiteName = "com.maple.tests.perOrgSnapshots.\(name)" + UserDefaults(suiteName: suiteName)?.removePersistentDomain(forName: suiteName) + return suiteName + } + + private func snapshot(organizationId: String) -> IssuesSnapshot { + IssuesSnapshot.make( + organizationId: organizationId, + organizationName: nil, + generatedAt: Date(timeIntervalSince1970: 1_800_000_000), + issues: [], + hasMore: false + ) + } + + /// The property the whole per-widget picker rests on: two organizations + /// never read each other's numbers. + @Test("Organizations keep separate keys") + func organizationsAreIsolated() { + let suite = suiteName() + let acme = WidgetSnapshotStore.issues( + organizationId: "org_a", + appGroupIdentifier: suite + ) + let globex = WidgetSnapshotStore.issues( + organizationId: "org_b", + appGroupIdentifier: suite + ) + + acme.save(snapshot(organizationId: "org_a")) + + #expect(acme.load()?.organizationId == "org_a") + #expect(globex.load() == nil) + + acme.clear() + globex.save(snapshot(organizationId: "org_b")) + #expect(acme.load() == nil) + #expect(globex.load()?.organizationId == "org_b") + } + + @Test("Issues and throughput keep separate keys within one organization") + func surfacesAreIsolated() { + let suite = suiteName() + let issues = WidgetSnapshotStore.issues( + organizationId: "org_a", + appGroupIdentifier: suite + ) + let throughput = WidgetSnapshotStore.throughput( + organizationId: "org_a", + appGroupIdentifier: suite + ) + + issues.save(snapshot(organizationId: "org_a")) + #expect(throughput.load() == nil) + } +} diff --git a/apps/ios/Packages/MapleAPI/Tests/MapleWidgetDataTests/WidgetDeepLinkTests.swift b/apps/ios/Packages/MapleAPI/Tests/MapleWidgetDataTests/WidgetDeepLinkTests.swift new file mode 100644 index 000000000..1d6eb7729 --- /dev/null +++ b/apps/ios/Packages/MapleAPI/Tests/MapleWidgetDataTests/WidgetDeepLinkTests.swift @@ -0,0 +1,89 @@ +import Foundation +import Testing + +@testable import MapleWidgetData + +@Suite("WidgetDeepLink") +struct WidgetDeepLinkTests { + private func roundTrip(_ link: WidgetDeepLink) -> WidgetDeepLink? { + link.url.flatMap(WidgetDeepLink.init(url:)) + } + + @Test("every target survives build → parse") + func roundTripsEveryTarget() { + let targets: [WidgetDeepLink.Target] = [ + .incident(id: "inc_YofPTrK9782DWwcnXhpcCw"), + .issue(id: "iss_123"), + .service(name: "checkout-api"), + .incidentsList, + .issuesList, + .servicesList, + ] + for target in targets { + let link = WidgetDeepLink(target: target, organizationId: "org_2abc") + #expect(roundTrip(link) == link, "\(target) did not survive the round trip") + } + } + + @Test("an absent org stays absent — the pre-multi-org meaning") + func absentOrganizationStaysNil() throws { + let url = try #require(URL(string: "maple://incident/inc_1")) + let link = try #require(WidgetDeepLink(url: url)) + #expect(link.organizationId == nil) + #expect(link.target == .incident(id: "inc_1")) + } + + @Test("an empty org query item reads as absent, not as an org named \"\"") + func emptyOrganizationReadsAsNil() throws { + let url = try #require(URL(string: "maple://incident/inc_1?org=")) + #expect(WidgetDeepLink(url: url)?.organizationId == nil) + } + + /// Service names are user-authored and reach the URL verbatim. + @Test( + "awkward service names survive", + arguments: ["orders/v2", "checkout?live", "api#edge", "billing service", "café-api", "a&b=c"] + ) + func awkwardServiceNames(name: String) { + let link = WidgetDeepLink(target: .service(name: name), organizationId: "org_2abc") + #expect(roundTrip(link) == link) + } + + @Test("the URL shape is pinned") + func urlShapeIsPinned() { + #expect( + WidgetDeepLink(target: .incident(id: "inc_1"), organizationId: "org_2abc").url?.absoluteString + == "maple://incident/inc_1?org=org_2abc" + ) + #expect( + WidgetDeepLink(target: .issuesList).url?.absoluteString == "maple://issues" + ) + } + + @Test("an id-less host falls back to its list rather than to nothing") + func missingIdentifierFallsBackToList() throws { + let url = try #require(URL(string: "maple://issue?org=org_2abc")) + let link = try #require(WidgetDeepLink(url: url)) + #expect(link.target == .issuesList) + #expect(link.organizationId == "org_2abc") + } + + @Test("unknown hosts and foreign schemes are refused, not guessed at") + func refusesUnknownURLs() throws { + #expect(WidgetDeepLink(url: try #require(URL(string: "maple://dashboard/1"))) == nil) + #expect(WidgetDeepLink(url: try #require(URL(string: "https://maple.dev/issues"))) == nil) + #expect(WidgetDeepLink(url: try #require(URL(string: "mapleX://issues"))) == nil) + } + + @Test("WidgetKinds builds the same links") + func widgetKindsAgree() { + #expect( + IssuesWidgetKind.issueURL(id: "iss_1", organizationId: "org_2abc")?.absoluteString + == "maple://issue/iss_1?org=org_2abc" + ) + #expect( + ThroughputWidgetKind.serviceURL(name: nil, organizationId: "org_2abc")?.absoluteString + == "maple://services?org=org_2abc" + ) + } +} diff --git a/apps/ios/Widgets/IssuesWidget.swift b/apps/ios/Widgets/IssuesWidget.swift index 6cf247bd4..6d911fdcc 100644 --- a/apps/ios/Widgets/IssuesWidget.swift +++ b/apps/ios/Widgets/IssuesWidget.swift @@ -9,7 +9,16 @@ import WidgetKit /// for why, and `WidgetPublisher` for who writes it. struct IssuesWidget: Widget { var body: some WidgetConfiguration { - StaticConfiguration(kind: IssuesWidgetKind.identifier, provider: IssuesProvider()) { entry in + // Configurable since the organization picker shipped. Widgets placed + // before that are migrated by iOS rather than removed — the `kind` string + // is the identity, so it must never change — and arrive with a + // default-initialized intent, whose `defaultResult()` is the active + // organization. + AppIntentConfiguration( + kind: IssuesWidgetKind.identifier, + intent: SelectOrganizationIntent.self, + provider: IssuesProvider() + ) { entry in IssuesWidgetView(entry: entry) .containerBackground(Token.background, for: .widget) } @@ -36,10 +45,29 @@ struct IssuesEntry: TimelineEntry { var snapshot: IssuesSnapshot? var isPlaceholder = false + + /// The organization whose numbers are on screen. Deep links carry it so a tap + /// resolves in that organization rather than in whichever one is active. + var organizationId: String? + var organizationName: String? + /// Shown only when the account publishes more than one organization — a + /// single-organization Home Screen does not need its name repeated. Decided + /// at timeline-build time so the view does no I/O. + var showsOrganization = false + /// The configured organization is not one the app publishes any more: the + /// user left it, or was removed. A terminal state, not a loading one. + var isOrganizationUnavailable = false + + /// The organization to name in the header, or nil when naming it would be + /// noise. + var headerOrganization: (name: String, id: String)? { + guard showsOrganization, let organizationId else { return nil } + return (organizationName ?? organizationId, organizationId) + } } -struct IssuesProvider: TimelineProvider { - private let store = WidgetSnapshotStore.issues +struct IssuesProvider: AppIntentTimelineProvider { + private let index = PublishedOrganizationIndex() /// The redacted skeleton iOS shows while placing a widget. Real-shaped /// sample data, so the outline is the widget's own layout rather than a @@ -50,9 +78,57 @@ struct IssuesProvider: TimelineProvider { /// The widget gallery. Never the empty state: a user browsing the gallery /// should see what the widget looks like when it has something to say. - func getSnapshot(in context: Context, completion: @escaping (IssuesEntry) -> Void) { - let stored = store.load() - completion(IssuesEntry(date: Date(), snapshot: context.isPreview ? (stored ?? .sample) : stored)) + func snapshot(for configuration: SelectOrganizationIntent, in context: Context) async -> IssuesEntry { + var entry = makeEntry(for: configuration, at: Date()) + if context.isPreview, entry.snapshot == nil { + entry.snapshot = .sample + entry.isOrganizationUnavailable = false + } + return entry + } + + /// Resolves the configured organization to a snapshot. + /// + /// A widget with no configuration — every instance migrated from before the + /// picker — follows the active organization, which is also what + /// `OrganizationEntityQuery.defaultResult()` gives a fresh one. + private func makeEntry(for configuration: SelectOrganizationIntent, at date: Date) -> IssuesEntry { + let published = index.load() + let configuredId = configuration.organization?.id + let organizationId = configuredId ?? index.activeOrganizationId + + guard let organizationId else { + // Nothing published at all: a fresh install, or a widget added + // before signing in. + return IssuesEntry(date: date, snapshot: legacySnapshot()) + } + + let stored = WidgetSnapshotStore.issues(organizationId: organizationId).load() + // Deliberately no fallback to another organization's snapshot. Rendering + // one organization's counts under another's name is the same class of + // error as opening the wrong organization from a notification. + let snapshot = stored ?? (configuredId == nil ? legacySnapshot() : nil) + let name = published.first { $0.id == organizationId }?.name ?? snapshot?.organizationName + + return IssuesEntry( + date: date, + snapshot: snapshot, + organizationId: organizationId, + organizationName: name, + showsOrganization: published.count > 1, + // Configured, published nothing, and not in the index either: the + // user is no longer a member. + isOrganizationUnavailable: snapshot == nil + && configuredId != nil + && !published.contains { $0.id == organizationId } + ) + } + + /// The pre-per-organization key. Keeps a widget placed before this shipped + /// showing numbers until the next publish writes the new key; delete with + /// the deprecated store accessors one release on. + private func legacySnapshot() -> IssuesSnapshot? { + WidgetSnapshotStore.legacyIssues.load() } /// Entries every quarter hour for the next two, from a single read. @@ -62,13 +138,15 @@ struct IssuesProvider: TimelineProvider { /// claim "2m" an hour later. WidgetKit is told to come back after the last /// one; the app's own `reloadTimelines` is what actually keeps it current /// when something happens. - func getTimeline(in context: Context, completion: @escaping (Timeline) -> Void) { - let snapshot = store.load() + func timeline(for configuration: SelectOrganizationIntent, in context: Context) async -> Timeline { let now = Date() let step: TimeInterval = 15 * 60 - let entries = (0..<8).map { index in - IssuesEntry(date: now.addingTimeInterval(Double(index) * step), snapshot: snapshot) + let base = makeEntry(for: configuration, at: now) + let entries = (0..<8).map { offset -> IssuesEntry in + var entry = base + entry.date = now.addingTimeInterval(Double(offset) * step) + return entry } - completion(Timeline(entries: entries, policy: .after(now.addingTimeInterval(8 * step)))) + return Timeline(entries: entries, policy: .after(now.addingTimeInterval(8 * step))) } } diff --git a/apps/ios/Widgets/IssuesWidgetView.swift b/apps/ios/Widgets/IssuesWidgetView.swift index c65b3acde..5b9693ae1 100644 --- a/apps/ios/Widgets/IssuesWidgetView.swift +++ b/apps/ios/Widgets/IssuesWidgetView.swift @@ -32,7 +32,7 @@ private struct SmallView: View { var body: some View { WidgetFrame(entry: entry) { snapshot in VStack(alignment: .leading, spacing: 0) { - SectionHeader(snapshot: snapshot) + SectionHeader(snapshot: snapshot, organization: entry.headerOrganization) CountLine(snapshot: snapshot) SeverityLine(snapshot: snapshot) @@ -47,7 +47,7 @@ private struct SmallView: View { StalenessFooter(snapshot: snapshot, now: entry.date) } } - .widgetURL(IssuesWidgetKind.issuesListURL) + .widgetURL(IssuesWidgetKind.issuesListURL(organizationId: entry.organizationId)) } } @@ -60,7 +60,7 @@ private struct ListView: View { WidgetFrame(entry: entry) { snapshot in VStack(alignment: .leading, spacing: 0) { HStack(alignment: .firstTextBaseline) { - SectionHeader(snapshot: snapshot) + SectionHeader(snapshot: snapshot, organization: entry.headerOrganization) Spacer() CountLine(snapshot: snapshot, isCompact: true) } @@ -82,7 +82,7 @@ private struct ListView: View { } // Per-row deep link: the point of showing the rows is // that one of them is the reason to open the app. - Link(destination: IssuesWidgetKind.issueURL(id: issue.id) ?? fallbackURL) { + Link(destination: IssuesWidgetKind.issueURL(id: issue.id, organizationId: entry.organizationId) ?? fallbackURL) { IssueRowView(issue: issue, now: entry.date, showsCount: true) } } @@ -91,7 +91,7 @@ private struct ListView: View { Spacer(minLength: 0) } } - .widgetURL(IssuesWidgetKind.issuesListURL) + .widgetURL(IssuesWidgetKind.issuesListURL(organizationId: entry.organizationId)) } /// Only reachable if the scheme itself failed to parse, which it cannot. @@ -106,7 +106,7 @@ private struct RectangularView: View { var body: some View { WidgetFrame(entry: entry, isAccessory: true) { snapshot in VStack(alignment: .leading, spacing: 2) { - Text(snapshot.isEmpty ? "No ongoing issues" : "\(snapshot.countLabel) ongoing") + Text(rectangularHeadline(snapshot: snapshot)) .font(.headline) .widgetAccentable() if let top = snapshot.issues.first { @@ -120,7 +120,16 @@ private struct RectangularView: View { } .frame(maxWidth: .infinity, alignment: .leading) } - .widgetURL(IssuesWidgetKind.issuesListURL) + .widgetURL(IssuesWidgetKind.issuesListURL(organizationId: entry.organizationId)) + } + + /// Rectangular is the only accessory family with room for the organization; + /// truncating a name to two glyphs on circular or inline is worse than + /// leaving it out. + private func rectangularHeadline(snapshot: IssuesSnapshot) -> String { + let base = snapshot.isEmpty ? "No ongoing issues" : "\(snapshot.countLabel) ongoing" + guard let organization = entry.headerOrganization else { return base } + return "\(base) · \(organization.name)" } } @@ -139,7 +148,7 @@ private struct CircularView: View { .foregroundStyle(.secondary) } } - .widgetURL(IssuesWidgetKind.issuesListURL) + .widgetURL(IssuesWidgetKind.issuesListURL(organizationId: entry.organizationId)) } } @@ -163,9 +172,10 @@ private struct InlineView: View { // MARK: - Shared chrome -/// The three states every family shares: never published, nothing ongoing, and -/// content. Written once so a signed-out phone cannot show "0 issues" — which -/// would read as "all clear" when the truth is "Maple has no idea". +/// The states every family shares: never published, no longer a member, waiting +/// on an organization, nothing ongoing, and content. Written once so a +/// signed-out phone cannot show "0 issues" — which would read as "all clear" +/// when the truth is "Maple has no idea". private struct WidgetFrame: View { let entry: IssuesEntry var isAccessory = false @@ -182,6 +192,18 @@ private struct WidgetFrame: View { // truth we had — but stops looking like live data. .opacity(snapshot.isStale(at: entry.date) ? 0.55 : 1) } + } else if entry.isOrganizationUnavailable { + // Terminal, not a loading state: no amount of opening the app + // will fill this in. + UnavailableOrganizationView( + organizationName: entry.organizationName, + isAccessory: isAccessory + ) + } else if let organizationName = entry.organizationName { + // Pinned to an organization this round did not publish — outside + // the refresh budget, or added since. Names the action that fixes + // it rather than looking broken. + WaitingOrganizationView(organizationName: organizationName, isAccessory: isAccessory) } else { DisconnectedView(isAccessory: isAccessory) } @@ -193,11 +215,85 @@ private struct WidgetFrame: View { private struct SectionHeader: View { let snapshot: IssuesSnapshot + /// Only when the account has more than one organization published — a + /// single-organization Home Screen does not need its own name repeated back. + var organization: (name: String, id: String)? var body: some View { - Text("Ongoing issues") - .sectionLabelStyle() - .lineLimit(1) + VStack(alignment: .leading, spacing: 1) { + Text("Ongoing issues") + .sectionLabelStyle() + .lineLimit(1) + if let organization { + HStack(spacing: 4) { + // The same categorical colour the app's organization + // switcher uses, so the two read as one thing. + OrganizationDot(organizationId: organization.id) + Text(organization.name) + .font(Typo.micro) + .foregroundStyle(Token.mutedForeground) + .lineLimit(1) + } + } + } + } +} + +/// The organization's categorical colour, from the same `ServiceColor` the app +/// uses for `OrganizationRow` and the switcher — so the Home Screen and the +/// toolbar agree on what an organization looks like. +private struct OrganizationDot: View { + let organizationId: String + + var body: some View { + ServiceDot(serviceName: organizationId, size: 7) + } +} + +/// The organization a widget is pinned to no longer publishes anything: the +/// user left it, or was removed. +private struct UnavailableOrganizationView: View { + let organizationName: String? + let isAccessory: Bool + + var body: some View { + if isAccessory { + Text(organizationName ?? "Unavailable").font(.headline).widgetAccentable() + } else { + VStack(alignment: .leading, spacing: 4) { + Text(organizationName ?? "Organization").sectionLabelStyle() + Text("Unavailable") + .font(Typo.heading) + .foregroundStyle(Token.foreground) + Text("You're no longer a member. Edit this widget to pick another organization.") + .font(Typo.tiny) + .foregroundStyle(Token.mutedForeground) + .fixedSize(horizontal: false, vertical: true) + } + } + } +} + +/// Pinned to a real organization that has not been published yet. +private struct WaitingOrganizationView: View { + let organizationName: String + let isAccessory: Bool + + var body: some View { + if isAccessory { + Text("Open Maple").font(.headline).widgetAccentable() + } else { + VStack(alignment: .leading, spacing: 4) { + Text(organizationName).sectionLabelStyle() + Text("Open Maple") + .font(Typo.heading) + .foregroundStyle(Token.foreground) + Text("Open the app once to load this organization's issues.") + .font(Typo.tiny) + .foregroundStyle(Token.mutedForeground) + .fixedSize(horizontal: false, vertical: true) + } + } } } diff --git a/apps/ios/Widgets/SelectServiceIntent.swift b/apps/ios/Widgets/SelectServiceIntent.swift deleted file mode 100644 index 951395218..000000000 --- a/apps/ios/Widgets/SelectServiceIntent.swift +++ /dev/null @@ -1,72 +0,0 @@ -import AppIntents -import MapleWidgetData - -/// The widget's own configuration: which service it shows. -/// -/// Long-press → Edit Widget → Service. Leaving it unset means the whole -/// organization, which is the useful default — someone adding a throughput -/// widget without a service in mind wants "is traffic normal", not a picker -/// they have to answer before the widget says anything. -struct SelectServiceIntent: WidgetConfigurationIntent { - static let title: LocalizedStringResource = "Select service" - static let description = IntentDescription("Show one service's throughput, or the whole organization's.") - - @Parameter(title: "Service") - var service: ServiceEntity? - - init() {} - - init(service: ServiceEntity?) { - self.service = service - } -} - -/// One row of the picker. -/// -/// The options come from the snapshot the app published — the extension has no -/// session to list services with, and the app's own list is the right one -/// anyway: it is scoped to the signed-in organization and to services that -/// actually reported in the last hour. -struct ServiceEntity: AppEntity { - /// The service name is the identifier. Names are unique per organization, - /// and using them means a configured widget survives a republish that - /// reordered the list. - var id: String - - var throughputPerSecond: Double? - - static let typeDisplayRepresentation = TypeDisplayRepresentation(name: "Service") - static let defaultQuery = ServiceEntityQuery() - - var displayRepresentation: DisplayRepresentation { - guard let throughputPerSecond else { return DisplayRepresentation(title: "\(id)") } - // The rate as a subtitle: with a dozen services, "which one is busy" - // is most of what the choice depends on. - return DisplayRepresentation(title: "\(id)", subtitle: "\(WidgetFormat.rate(throughputPerSecond))") - } -} - -struct ServiceEntityQuery: EntityQuery { - private var snapshot: ThroughputSnapshot? { WidgetSnapshotStore.throughput.load() } - - /// Resolving what a configured widget already holds. A service that has - /// since gone quiet still resolves — dropping it here would silently - /// re-point the widget at the organization total, which reads as "your - /// service is fine" rather than "your service stopped reporting". - func entities(for identifiers: [String]) async throws -> [ServiceEntity] { - let services = snapshot?.services ?? [] - return identifiers.map { identifier in - ServiceEntity( - id: identifier, - throughputPerSecond: services.first { $0.name == identifier }?.throughputPerSecond - ) - } - } - - /// The list iOS shows in the picker: busiest first, as published. - func suggestedEntities() async throws -> [ServiceEntity] { - (snapshot?.services ?? []).compactMap { service in - service.name.map { ServiceEntity(id: $0, throughputPerSecond: service.throughputPerSecond) } - } - } -} diff --git a/apps/ios/Widgets/ThroughputWidget.swift b/apps/ios/Widgets/ThroughputWidget.swift index 91a35df03..c57518adb 100644 --- a/apps/ios/Widgets/ThroughputWidget.swift +++ b/apps/ios/Widgets/ThroughputWidget.swift @@ -40,10 +40,24 @@ struct ThroughputEntry: TimelineEntry { /// The row to draw, or nil when the configured service is not in the /// snapshot — it went quiet, or the org changed under the widget. var service: ServiceThroughput? { snapshot?.service(named: serviceName) } + + /// The organization whose numbers are on screen, carried into deep links. + var organizationId: String? + var organizationName: String? + /// Only worth showing when the account has more than one. + var showsOrganization = false + /// Pinned to an organization the app no longer publishes. + var isOrganizationUnavailable = false + + /// The organization to name in the header, or nil when naming it is noise. + var headerOrganizationName: String? { + guard showsOrganization else { return nil } + return organizationName ?? organizationId + } } struct ThroughputProvider: AppIntentTimelineProvider { - private let store = WidgetSnapshotStore.throughput + private let index = PublishedOrganizationIndex() func placeholder(in context: Context) -> ThroughputEntry { ThroughputEntry(date: Date(), snapshot: .sample, serviceName: nil, isPlaceholder: true) @@ -52,27 +66,59 @@ struct ThroughputProvider: AppIntentTimelineProvider { /// The gallery. Never the empty state: someone browsing widgets should see /// what this looks like with traffic in it. func snapshot(for configuration: SelectServiceIntent, in context: Context) async -> ThroughputEntry { - let stored = store.load() + var entry = makeEntry(for: configuration, at: Date()) + if context.isPreview, entry.snapshot == nil { + entry.snapshot = .sample + entry.isOrganizationUnavailable = false + } + return entry + } + + /// Resolves the configured organization — nil meaning the active one, which + /// is every widget configured before the organization parameter existed. + private func makeEntry(for configuration: SelectServiceIntent, at date: Date) -> ThroughputEntry { + let published = index.load() + let configuredId = configuration.organization?.id + let organizationId = configuredId ?? index.activeOrganizationId + let serviceName = configuration.service?.id + + guard let organizationId else { + return ThroughputEntry(date: date, snapshot: legacySnapshot(), serviceName: serviceName) + } + + let stored = WidgetSnapshotStore.throughput(organizationId: organizationId).load() + // No fallback to another organization's snapshot — see `IssuesProvider`. + let snapshot = stored ?? (configuredId == nil ? legacySnapshot() : nil) + return ThroughputEntry( - date: Date(), - snapshot: context.isPreview ? (stored ?? .sample) : stored, - serviceName: configuration.service?.id + date: date, + snapshot: snapshot, + serviceName: serviceName, + organizationId: organizationId, + organizationName: published.first { $0.id == organizationId }?.name, + showsOrganization: published.count > 1, + isOrganizationUnavailable: snapshot == nil + && configuredId != nil + && !published.contains { $0.id == organizationId } ) } + /// The pre-per-organization key; see `IssuesProvider.legacySnapshot`. + private func legacySnapshot() -> ThroughputSnapshot? { + WidgetSnapshotStore.legacyThroughput.load() + } + /// One read, several entries — the numbers do not change between them, /// only how old they are. The app's `reloadTimelines` is what actually /// keeps this current; the entries are the floor. func timeline(for configuration: SelectServiceIntent, in context: Context) async -> Timeline { - let snapshot = store.load() let now = Date() let step: TimeInterval = 15 * 60 - let entries = (0..<8).map { index in - ThroughputEntry( - date: now.addingTimeInterval(Double(index) * step), - snapshot: snapshot, - serviceName: configuration.service?.id - ) + let base = makeEntry(for: configuration, at: now) + let entries = (0..<8).map { offset -> ThroughputEntry in + var entry = base + entry.date = now.addingTimeInterval(Double(offset) * step) + return entry } return Timeline(entries: entries, policy: .after(now.addingTimeInterval(8 * step))) } diff --git a/apps/ios/Widgets/ThroughputWidgetView.swift b/apps/ios/Widgets/ThroughputWidgetView.swift index 716a328a3..ba986332e 100644 --- a/apps/ios/Widgets/ThroughputWidgetView.swift +++ b/apps/ios/Widgets/ThroughputWidgetView.swift @@ -17,7 +17,7 @@ struct ThroughputWidgetView: View { content // A configured widget opens its service; the org-wide one opens the // Services tab. `AppNavigation.open(_:)` handles both. - .widgetURL(ThroughputWidgetKind.serviceURL(name: entry.serviceName)) + .widgetURL(ThroughputWidgetKind.serviceURL(name: entry.serviceName, organizationId: entry.organizationId)) } @ViewBuilder @@ -40,7 +40,7 @@ private struct SmallThroughputView: View { var body: some View { ThroughputFrame(entry: entry) { service, snapshot in VStack(alignment: .leading, spacing: 0) { - ThroughputHeader(service: service, snapshot: snapshot) + ThroughputHeader(service: service, snapshot: snapshot, organizationName: entry.headerOrganizationName) RateLine(service: service) TrendLine(service: service, snapshot: snapshot) @@ -63,7 +63,7 @@ private struct MediumThroughputView: View { ThroughputFrame(entry: entry) { service, snapshot in HStack(alignment: .top, spacing: 14) { VStack(alignment: .leading, spacing: 0) { - ThroughputHeader(service: service, snapshot: snapshot) + ThroughputHeader(service: service, snapshot: snapshot, organizationName: entry.headerOrganizationName) RateLine(service: service) TrendLine(service: service, snapshot: snapshot) Spacer(minLength: 4) @@ -86,7 +86,7 @@ private struct LargeThroughputView: View { var body: some View { ThroughputFrame(entry: entry) { service, snapshot in VStack(alignment: .leading, spacing: 0) { - ThroughputHeader(service: service, snapshot: snapshot) + ThroughputHeader(service: service, snapshot: snapshot, organizationName: entry.headerOrganizationName) RateLine(service: service) TrendLine(service: service, snapshot: snapshot) @@ -209,6 +209,15 @@ private struct ThroughputFrame: View { // so is the whole point: falling back to the org total here // would read as "your service is fine". MissingServiceView(name: entry.serviceName, isAccessory: isAccessory) + } else if entry.isOrganizationUnavailable { + MissingOrganizationView( + name: entry.organizationName, + isMember: false, + isAccessory: isAccessory + ) + } else if let organizationName = entry.organizationName { + // Pinned to an organization this round did not publish. + MissingOrganizationView(name: organizationName, isMember: true, isAccessory: isAccessory) } else { DisconnectedThroughputView(isAccessory: isAccessory) } @@ -221,15 +230,62 @@ private struct ThroughputFrame: View { private struct ThroughputHeader: View { let service: ServiceThroughput let snapshot: ThroughputSnapshot + /// Only when the account publishes more than one organization. + var organizationName: String? var body: some View { - HStack(spacing: 5) { - if service.name != nil { - ServiceDot(serviceName: service.displayName, size: 6) + VStack(alignment: .leading, spacing: 1) { + HStack(spacing: 5) { + if service.name != nil { + ServiceDot(serviceName: service.displayName, size: 6) + } + Text(service.displayName) + .sectionLabelStyle() + .lineLimit(1) + } + if let organizationName { + HStack(spacing: 4) { + ServiceDot(serviceName: snapshot.organizationId, size: 6) + Text(organizationName) + .font(Typo.micro) + .foregroundStyle(Token.mutedForeground) + .lineLimit(1) + } + } + } + } +} + +/// The widget is pinned to an organization that has nothing published — either +/// because the app has not covered it yet, or because the user is no longer in +/// it. Never falls back to another organization's numbers: one organization's +/// traffic under another's name is the same error as opening the wrong +/// organization from a notification. +private struct MissingOrganizationView: View { + let name: String? + let isMember: Bool + let isAccessory: Bool + + var body: some View { + if isAccessory { + Text(isMember ? "Open Maple" : (name ?? "Unavailable")) + .font(.headline) + .widgetAccentable() + } else { + VStack(alignment: .leading, spacing: 4) { + Text(name ?? "Organization").sectionLabelStyle() + Text(isMember ? "Open Maple" : "Unavailable") + .font(Typo.heading) + .foregroundStyle(Token.foreground) + Text( + isMember + ? "Open the app once to load this organization's traffic." + : "You're no longer a member. Edit this widget to pick another organization." + ) + .font(Typo.tiny) + .foregroundStyle(Token.mutedForeground) + .fixedSize(horizontal: false, vertical: true) } - Text(service.displayName) - .sectionLabelStyle() - .lineLimit(1) } } } diff --git a/packages/auth/src/auth.test.ts b/packages/auth/src/auth.test.ts index 44dba9b7b..325b7b727 100644 --- a/packages/auth/src/auth.test.ts +++ b/packages/auth/src/auth.test.ts @@ -2,13 +2,14 @@ import { assert, describe, it } from "@effect/vitest" import { createHmac } from "node:crypto" import { Effect, Exit, Option, Redacted, Schema } from "effect" import { TestClock } from "effect/testing" -import { OrgId, RoleName, UserId } from "@maple/domain/http" +import { AuthorizationUnavailableError, OrgId, RoleName, UserId } from "@maple/domain/http" import { makeGetCustomerData, makeLoginSelfHosted, makeRefreshSelfHostedSession, makeResolveMcpTenant, makeResolveTenant, + ORG_SELECTION_HEADER, SELF_HOSTED_SESSION_MAX_LIFETIME_SECONDS, SELF_HOSTED_SESSION_TTL_SECONDS, } from "./index" @@ -995,3 +996,214 @@ describe("self-hosted JWT algorithm pinning", () => { }), ) }) + +describe(`${ORG_SELECTION_HEADER} (organization selection)`, () => { + const clerkEnv = { + ...baseEnv, + MAPLE_AUTH_MODE: "clerk", + CLERK_SECRET_KEY: Option.some(Redacted.make("sk_test_123")), + CLERK_JWT_KEY: Option.some(Redacted.make("jwt_test_123")), + } as const + + const clerkAuth = + (overrides: { orgId?: string | null; tokenType?: string } = {}) => + async () => ({ + isAuthenticated: true, + message: null, + toAuth: () => ({ + isAuthenticated: true, + tokenType: overrides.tokenType ?? "session_token", + userId: "user_123", + orgId: overrides.orgId === undefined ? "org_123" : overrides.orgId, + orgRole: "org:admin", + }), + }) + + /** Counts calls, because "never asked" is the assertion for the no-op path. */ + const verifier = (memberships: ReadonlyArray<{ orgId: string; role: string }>) => { + let calls = 0 + const verify = (_userId: UserId, orgId: OrgId) => { + calls += 1 + const found = memberships.find((membership) => membership.orgId === orgId) + return Effect.succeed( + found + ? Option.some({ orgId: asOrgId(found.orgId), role: asRoleName(found.role) }) + : Option.none<{ orgId: OrgId; role: RoleName }>(), + ) + } + return { verify, calls: () => calls } + } + + const assertDenied = (exit: Exit.Exit) => { + const failure = getFailure(exit) as { _tag?: string } | undefined + assert.isTrue(Exit.isFailure(exit)) + assert.strictEqual(failure?._tag, "@maple/http/errors/OrganizationAccessDeniedError") + } + + it.effect("naming the organization you already have costs nothing", () => + Effect.gen(function* () { + const membership = verifier([{ orgId: "org_123", role: "org:admin" }]) + const resolveTenant = makeResolveTenant(clerkEnv, clerkAuth(), undefined, membership.verify) + + const tenant = yield* resolveTenant({ + authorization: "Bearer test-token", + [ORG_SELECTION_HEADER]: "org_123", + }) + + assert.strictEqual(tenant.orgId, asOrgId("org_123")) + // The invariant that keeps a client free to send the header always. + assert.strictEqual(membership.calls(), 0) + }), + ) + + it.effect("adopts a verified organization AND its role, not the token's", () => + Effect.gen(function* () { + const membership = verifier([{ orgId: "org_other", role: "org:member" }]) + const resolveTenant = makeResolveTenant(clerkEnv, clerkAuth(), undefined, membership.verify) + + const tenant = yield* resolveTenant({ + authorization: "Bearer test-token", + [ORG_SELECTION_HEADER]: "org_other", + }) + + assert.deepStrictEqual(tenant, { + orgId: asOrgId("org_other"), + // Carrying `org:admin` across would make an admin of one org an + // admin of every org they belong to. + roles: [asRoleName("org:member")], + userId: asUserId("user_123"), + authMode: "clerk", + }) + }), + ) + + // The widget's cold case: a token whose session has no active organization + // at all still resolves when the request names one it can prove. + it.effect("works with no active organization in the token", () => + Effect.gen(function* () { + const membership = verifier([{ orgId: "org_other", role: "org:member" }]) + const resolveTenant = makeResolveTenant( + clerkEnv, + clerkAuth({ orgId: null }), + undefined, + membership.verify, + ) + + const tenant = yield* resolveTenant({ + authorization: "Bearer test-token", + [ORG_SELECTION_HEADER]: "org_other", + }) + + assert.strictEqual(tenant.orgId, asOrgId("org_other")) + }), + ) + + it.effect("refuses an organization the user is not in", () => + Effect.gen(function* () { + const membership = verifier([]) + const resolveTenant = makeResolveTenant(clerkEnv, clerkAuth(), undefined, membership.verify) + + const exit = yield* Effect.exit( + resolveTenant({ + authorization: "Bearer test-token", + [ORG_SELECTION_HEADER]: "org_other", + }), + ) + + // 403, and deliberately not the 401 that a missing active organization + // produces — a client must be able to tell "stop asking for that org" + // from "sign in again". + assertDenied(exit) + }), + ) + + it.effect("a lookup failure rejects rather than falling back to the token's org", () => + Effect.gen(function* () { + const resolveTenant = makeResolveTenant(clerkEnv, clerkAuth(), undefined, () => + Effect.fail(new AuthorizationUnavailableError({ message: "Clerk unreachable" })), + ) + + const exit = yield* Effect.exit( + resolveTenant({ + authorization: "Bearer test-token", + [ORG_SELECTION_HEADER]: "org_other", + }), + ) + const failure = getFailure(exit) as { _tag?: string } | undefined + + assert.isTrue(Exit.isFailure(exit)) + assert.strictEqual(failure?._tag, "@maple/http/errors/AuthorizationUnavailableError") + }), + ) + + it.effect("rejects the header when no verifier is wired", () => + Effect.gen(function* () { + const resolveTenant = makeResolveTenant(clerkEnv, clerkAuth()) + + const exit = yield* Effect.exit( + resolveTenant({ + authorization: "Bearer test-token", + [ORG_SELECTION_HEADER]: "org_other", + }), + ) + + assertDenied(exit) + }), + ) + + it.effect("rejects the header for an API-key credential", () => + Effect.gen(function* () { + const membership = verifier([{ orgId: "org_other", role: "org:admin" }]) + const resolveTenant = makeResolveMcpTenant(clerkEnv, clerkAuth({ tokenType: "api_key" })) + + const exit = yield* Effect.exit( + resolveTenant({ + authorization: "Bearer test-token", + [ORG_SELECTION_HEADER]: "org_other", + }), + ) + + assertDenied(exit) + assert.strictEqual(membership.calls(), 0) + }), + ) + + it.effect("rejects the header when MAPLE_ORG_ID_OVERRIDE pins the deployment", () => + Effect.gen(function* () { + const membership = verifier([{ orgId: "org_other", role: "org:admin" }]) + const resolveTenant = makeResolveTenant( + { ...clerkEnv, MAPLE_ORG_ID_OVERRIDE: Option.some("org_pinned") }, + clerkAuth(), + undefined, + membership.verify, + ) + + const exit = yield* Effect.exit( + resolveTenant({ + authorization: "Bearer test-token", + [ORG_SELECTION_HEADER]: "org_other", + }), + ) + + assertDenied(exit) + assert.strictEqual(membership.calls(), 0) + }), + ) + + // Self-hosted mode has no membership directory, so an honoured header would + // be unconditional cross-tenant access. + it.effect("rejects the header in self-hosted mode", () => + Effect.gen(function* () { + const exit = yield* atFixedTime( + Effect.exit( + makeResolveTenant(baseEnv)({ + authorization: `Bearer ${signClaims(validClaims)}`, + [ORG_SELECTION_HEADER]: "org_other", + }), + ), + ) + + assertDenied(exit) + }), + ) +}) diff --git a/packages/auth/src/index.ts b/packages/auth/src/index.ts index 27b08c82e..faa41eb0f 100644 --- a/packages/auth/src/index.ts +++ b/packages/auth/src/index.ts @@ -10,6 +10,8 @@ import { createHmac, timingSafeEqual } from "node:crypto" import { createClerkClient } from "@clerk/backend" import { AuthMode, + AuthorizationUnavailableError, + OrganizationAccessDeniedError, OrgId, RoleName, SelfHostedAuthDisabledError, @@ -494,14 +496,141 @@ export const makeRefreshSelfHostedSession = (env: Pick Effect.Effect, AuthorizationUnavailableError> + +const ORGANIZATION_ACCESS_DENIED_MESSAGE = "You are not a member of the requested organization." + +const organizationAccessDenied = (requestedOrgId?: OrgId) => { + // The id is included only when it decoded as an `OrgId`; an unparseable + // header value is never cast into the brand just to appear in an error. + if (requestedOrgId === undefined) { + return new OrganizationAccessDeniedError({ message: ORGANIZATION_ACCESS_DENIED_MESSAGE }) + } + return new OrganizationAccessDeniedError({ + message: ORGANIZATION_ACCESS_DENIED_MESSAGE, + requestedOrgId, + }) +} + +/** + * Resolves {@link ORG_SELECTION_HEADER} into a verified membership, or + * `Option.none()` when the request names no organization. + * + * Two invariants, and both are load-bearing: + * + * 1. **The header only ever selects among organizations the caller can already + * be proven a member of.** Wherever that proof is unavailable — self-hosted + * mode, `MAPLE_ORG_ID_OVERRIDE`, API keys, no verifier wired — the request is + * rejected, never silently served under the credential's own organization. + * Silently ignoring it is the failure mode where a widget renders one + * organization's incidents under another's name and nobody notices. + * 2. **Naming the organization you already have is free** — see + * {@link applyRequestedOrg}, which short-circuits before reaching here. That + * is what lets a client send the header unconditionally instead of branching. + */ +const selectRequestedOrg = Effect.fnUntraced(function* ( + userId: UserId, + headers: HeaderRecord, + verify: VerifyOrgMembership | undefined, +): Effect.fn.Return< + Option.Option, + UnauthorizedError | OrganizationAccessDeniedError | AuthorizationUnavailableError +> { + const requested = getHeader(headers, ORG_SELECTION_HEADER) + if (!requested) return Option.none() + + const requestedOrgId = yield* decodeOrgId(requested, "Invalid organization selection") + if (!verify) return yield* Effect.fail(organizationAccessDenied(requestedOrgId)) + + // Stamped only when the header actually decides the organization, so + // `maple.auth.org_source` answers "how much traffic selects an organization + // explicitly" rather than "how many clients send the header unconditionally". + yield* Effect.annotateCurrentSpan({ + "maple.auth.org_source": "header", + "tenant.requested_org_id": requestedOrgId, + }) + + const membership = yield* verify(userId, requestedOrgId) + if (Option.isNone(membership)) { + return yield* Effect.fail(organizationAccessDenied(requestedOrgId)) + } + return membership +}) + +/** {@link selectRequestedOrg}, applied to a tenant that already has an organization. */ +const applyRequestedOrg = Effect.fnUntraced(function* ( + tenant: TenantContext, + headers: HeaderRecord, + verify: VerifyOrgMembership | undefined, +): Effect.fn.Return< + TenantContext, + UnauthorizedError | OrganizationAccessDeniedError | AuthorizationUnavailableError +> { + const requested = getHeader(headers, ORG_SELECTION_HEADER) + if (!requested) return tenant + // The free no-op. Decoded first so an unparseable value is still rejected. + const requestedOrgId = yield* decodeOrgId(requested, "Invalid organization selection") + if (requestedOrgId === tenant.orgId) return tenant + + const membership = yield* selectRequestedOrg(tenant.userId, headers, verify) + if (Option.isNone(membership)) return tenant + + // The role travels with the organization. Carrying the token's `orgRole` + // across would grant an admin of org A admin of org B. + return { ...tenant, orgId: membership.value.orgId, roles: [membership.value.role] } +}) + export const makeResolveTenant = ( env: AuthEnv, authenticateClerkRequest = makeClerkAuthenticateRequest(env), acceptsToken: string | string[] = "session_token", + /** + * Omit to disable {@link ORG_SELECTION_HEADER} entirely — the header is then + * rejected rather than ignored. Callers that have no membership directory to + * check against (electric-sync) and callers where the header has no meaning + * (`makeResolveMcpTenant`, whose credentials include org-bound API keys) + * deliberately pass nothing. + */ + verifyOrgMembership?: VerifyOrgMembership, ) => Effect.fn("AuthService.resolveTenant")(function* ( headers: HeaderRecord, - ): Effect.fn.Return { + ): Effect.fn.Return< + TenantContext, + UnauthorizedError | OrganizationAccessDeniedError | AuthorizationUnavailableError + > { const authMode = getAuthMode(env.MAPLE_AUTH_MODE) if (authMode === "clerk") { @@ -539,9 +668,36 @@ export const makeResolveTenant = ( } const orgIdOverride = getOptionalString(env.MAPLE_ORG_ID_OVERRIDE) + const userId = yield* decodeUserId(auth.userId, "Invalid user in Clerk session token") + + // Two credentials must not be allowed to select an organization, and in + // both cases the header is a rejection rather than a silent ignore: + // + // - an API key is already organization-bound, so a selection could only + // ever widen it (`acceptsToken` is what admits keys here — the MCP + // resolver — and that path passes no verifier anyway); + // - `MAPLE_ORG_ID_OVERRIDE` pins a deployment to one organization, and + // honouring a selection would defeat the pin. + const selectable = + auth.tokenType === "session_token" && orgIdOverride === undefined + ? verifyOrgMembership + : undefined if (!auth.orgId && !orgIdOverride) { - return yield* unauthorized("Active organization is required") + // No active organization in the session. A request that names one it + // can prove membership of is still serviceable — this is the widget + // publishing path, whose whole point is not to disturb whatever the + // foreground has active. + const selected = yield* selectRequestedOrg(userId, headers, selectable) + if (Option.isNone(selected)) { + return yield* unauthorized("Active organization is required") + } + return { + orgId: selected.value.orgId, + userId, + roles: [selected.value.role], + authMode: "clerk", + } } const clerkTenant: TenantContext = { @@ -549,7 +705,7 @@ export const makeResolveTenant = ( orgIdOverride ?? auth.orgId!, "Invalid organization in Clerk session token", ), - userId: yield* decodeUserId(auth.userId, "Invalid user in Clerk session token"), + userId, roles: typeof auth.orgRole === "string" ? yield* Effect.map( @@ -560,7 +716,7 @@ export const makeResolveTenant = ( authMode: "clerk", } - return clerkTenant + return yield* applyRequestedOrg(clerkTenant, headers, selectable) } const token = getBearerToken(headers) @@ -581,14 +737,17 @@ export const makeResolveTenant = ( } const orgIdOverride = getOptionalString(env.MAPLE_ORG_ID_OVERRIDE) - if (orgIdOverride) { - return { - ...tenant, - orgId: yield* decodeOrgId(orgIdOverride, "Invalid MAPLE_ORG_ID_OVERRIDE value"), - } - } - - return tenant + const resolved = orgIdOverride + ? { + ...tenant, + orgId: yield* decodeOrgId(orgIdOverride, "Invalid MAPLE_ORG_ID_OVERRIDE value"), + } + : tenant + + // Self-hosted mode has no membership directory to check a selection + // against, so an honoured header here would be unconditional cross-tenant + // access. Passing no verifier makes it a rejection. + return yield* applyRequestedOrg(resolved, headers, undefined) }) export const makeResolveMcpTenant = ( diff --git a/packages/domain/src/http/current-tenant.ts b/packages/domain/src/http/current-tenant.ts index f409ff482..6a1826917 100644 --- a/packages/domain/src/http/current-tenant.ts +++ b/packages/domain/src/http/current-tenant.ts @@ -34,6 +34,34 @@ export class AuthorizationUnavailableError extends HttpTaggedError()( + "@maple/http/errors/OrganizationAccessDeniedError", + { + message: Schema.String, + // Set only when the requested value decoded as an OrgId. An undecodable + // header is never cast into the brand just to put it in an error. + requestedOrgId: Schema.optionalKey(OrgId), + }, + { + status: 403, + code: "organization_access_denied", + title: "Organization not available", + message: "You are not a member of the requested organization.", + retry: "never", + recovery: "request_access", + exposure: "public_message", + }, +) {} + export class TenantSchema extends Schema.Class("TenantSchema")({ orgId: OrgId, userId: UserId, @@ -54,7 +82,7 @@ export class Authorization extends HttpApiMiddleware.Service< provides: Context } >()("Authorization", { - error: [UnauthorizedError, AuthorizationUnavailableError], + error: [UnauthorizedError, AuthorizationUnavailableError, OrganizationAccessDeniedError], security: { bearer: HttpApiSecurity.bearer, }, @@ -97,7 +125,12 @@ export class SessionAuthorization extends HttpApiMiddleware.Service< provides: Context } >()("SessionAuthorization", { - error: [UnauthorizedError, AuthorizationUnavailableError, ApiKeyNotAcceptedError], + error: [ + UnauthorizedError, + AuthorizationUnavailableError, + ApiKeyNotAcceptedError, + OrganizationAccessDeniedError, + ], security: { bearer: HttpApiSecurity.bearer, }, diff --git a/packages/domain/src/http/index.ts b/packages/domain/src/http/index.ts index 1a4c263ad..b96a198fc 100644 --- a/packages/domain/src/http/index.ts +++ b/packages/domain/src/http/index.ts @@ -11,6 +11,10 @@ export * from "./auth" export * from "./billing" export * from "./chat" export * as CurrentTenant from "./current-tenant" +// The tenant-resolution failures themselves, flat: `@maple/auth` raises all +// three and has no use for the namespace. (`UnauthorizedError` already reaches +// the barrel through `./warehouse`.) +export { AuthorizationUnavailableError, OrganizationAccessDeniedError } from "./current-tenant" export * from "./dashboard-sections" export * from "./dashboards" export * from "./demo" diff --git a/packages/domain/src/http/v2/auth.ts b/packages/domain/src/http/v2/auth.ts index 40d0875fd..354d45631 100644 --- a/packages/domain/src/http/v2/auth.ts +++ b/packages/domain/src/http/v2/auth.ts @@ -1,10 +1,11 @@ import { HttpApiMiddleware, HttpApiSecurity, OpenApi } from "effect/unstable/httpapi" import { Schema } from "effect" import { ApiKeyLookupPersistenceError } from "../api-keys" -import { Context, UnauthorizedError } from "../current-tenant" +import { AuthorizationUnavailableError, Context, UnauthorizedError } from "../current-tenant" import { V2InsufficientScope, V2InvalidCredentials, + V2OrganizationAccessDenied, V2InvalidRequest, V2RateLimited, V2ResponseSchemaFailure, @@ -31,8 +32,10 @@ export class AuthorizationV2 extends HttpApiMiddleware.Service< V2InvalidCredentials.schema, V2InsufficientScope.schema, V2RateLimited.schema, + V2OrganizationAccessDenied.schema, publicError(ApiKeyLookupPersistenceError), publicError(UnauthorizedError), + publicError(AuthorizationUnavailableError), ], security: { bearer: HttpApiSecurity.bearer.pipe( diff --git a/packages/domain/src/http/v2/errors.ts b/packages/domain/src/http/v2/errors.ts index 94d4e879c..6a4ef2770 100644 --- a/packages/domain/src/http/v2/errors.ts +++ b/packages/domain/src/http/v2/errors.ts @@ -147,6 +147,25 @@ export const V2InsufficientScope = defineV2Error({ identifier: "InsufficientScopeError", }) +/** + * The caller named an organization (`x-maple-org-id`) it cannot prove + * membership of. + * + * Not `V2InsufficientPermissions`: that one says "only organization + * administrators can perform this operation", which would send a widget owner + * looking for an admin instead of unpinning the organization. + */ +export const V2OrganizationAccessDenied = defineV2Error({ + tag: "@maple/http/v2/OrganizationAccessDeniedError", + status: 403, + code: "organization_access_denied", + title: "Organization not available", + message: "You are not a member of the requested organization.", + retry: "never", + recovery: "request_access", + identifier: "OrganizationAccessDeniedError", +}) + export const V2InsufficientPermissions = defineV2Error({ tag: "@maple/http/v2/InsufficientPermissionsError", status: 403, diff --git a/packages/domain/src/http/v2/openapi.test.ts b/packages/domain/src/http/v2/openapi.test.ts index 752258b2b..49d1f7665 100644 --- a/packages/domain/src/http/v2/openapi.test.ts +++ b/packages/domain/src/http/v2/openapi.test.ts @@ -510,6 +510,9 @@ describe("MapleApiV2 OpenAPI", () => { const adminTags = [ "@maple/http/v2/InsufficientPermissionsError", "@maple/http/v2/InsufficientScopeError", + // From `AuthorizationV2` itself, so every v2 operation carries it: a request + // can always name an organization (`x-maple-org-id`) the caller is not in. + "@maple/http/v2/OrganizationAccessDeniedError", ] expect([...responseErrorTags("post", "/v2/integrations/slack/install", "403")].sort()).toEqual( adminTags, @@ -524,6 +527,9 @@ describe("MapleApiV2 OpenAPI", () => { // for every member, so its 403 comes from the scope middleware alone. expect(responseErrorTags("get", "/v2/integrations/slack", "403")).toEqual([ "@maple/http/v2/InsufficientScopeError", + // From `AuthorizationV2` itself, so every v2 operation carries it: a request + // can always name an organization (`x-maple-org-id`) the caller is not in. + "@maple/http/v2/OrganizationAccessDeniedError", ]) }) @@ -628,10 +634,16 @@ describe("MapleApiV2 OpenAPI", () => { expect(responseErrorTags("post", "/v2/api_keys", "403")).toEqual([ "@maple/http/v2/InsufficientPermissionsError", "@maple/http/v2/InsufficientScopeError", + // From `AuthorizationV2` itself, so every v2 operation carries it: a request + // can always name an organization (`x-maple-org-id`) the caller is not in. + "@maple/http/v2/OrganizationAccessDeniedError", ]) expect(responseErrorTags("get", "/v2/ingest_keys", "403")).toEqual([ "@maple/http/v2/InsufficientPermissionsError", "@maple/http/v2/InsufficientScopeError", + // From `AuthorizationV2` itself, so every v2 operation carries it: a request + // can always name an organization (`x-maple-org-id`) the caller is not in. + "@maple/http/v2/OrganizationAccessDeniedError", ]) }) From 7767e4f5d9ddcaf3864d5599343583dc6a4d1d6f Mon Sep 17 00:00:00 2001 From: Makisuo Date: Thu, 20 Aug 2026 00:03:26 +0200 Subject: [PATCH 2/5] fix(ios): regenerate the client OpenAPI spec for the new auth failures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ios:openapi:check` regenerates the pruned spec and compares, so the two failures `AuthorizationV2` gained — the 403 for an unverifiable organization selection and the 503 when the membership directory cannot be reached — have to appear on every v2 operation's documented responses. Description text only; no schema or operation changed, so the generated Swift client is unaffected. --- .../MapleAPI/Sources/MapleAPI/openapi.json | 84 +++++++++---------- 1 file changed, 42 insertions(+), 42 deletions(-) diff --git a/apps/ios/Packages/MapleAPI/Sources/MapleAPI/openapi.json b/apps/ios/Packages/MapleAPI/Sources/MapleAPI/openapi.json index 1e16c5d63..0bbea14a1 100644 --- a/apps/ios/Packages/MapleAPI/Sources/MapleAPI/openapi.json +++ b/apps/ios/Packages/MapleAPI/Sources/MapleAPI/openapi.json @@ -3163,7 +3163,7 @@ } } }, - "description": "The @maple/http/v2/InsufficientScopeError failure. HTTP 403." + "description": "The @maple/http/v2/InsufficientScopeError failure. HTTP 403. | The @maple/http/v2/OrganizationAccessDeniedError failure. HTTP 403." }, "429": { "content": { @@ -3210,7 +3210,7 @@ } } }, - "description": "The @maple/http/errors/AlertPersistenceError failure. HTTP 503. | The @maple/http/errors/ApiKeyLookupPersistenceError failure. HTTP 503." + "description": "The @maple/http/errors/AlertPersistenceError failure. HTTP 503. | The @maple/http/errors/ApiKeyLookupPersistenceError failure. HTTP 503. | The @maple/http/errors/AuthorizationUnavailableError failure. HTTP 503." }, "504": { "content": { @@ -3316,7 +3316,7 @@ } } }, - "description": "The @maple/http/v2/InsufficientScopeError failure. HTTP 403." + "description": "The @maple/http/v2/InsufficientScopeError failure. HTTP 403. | The @maple/http/v2/OrganizationAccessDeniedError failure. HTTP 403." }, "429": { "content": { @@ -3363,7 +3363,7 @@ } } }, - "description": "The @maple/http/errors/AlertPersistenceError failure. HTTP 503. | The @maple/http/errors/ApiKeyLookupPersistenceError failure. HTTP 503." + "description": "The @maple/http/errors/AlertPersistenceError failure. HTTP 503. | The @maple/http/errors/ApiKeyLookupPersistenceError failure. HTTP 503. | The @maple/http/errors/AuthorizationUnavailableError failure. HTTP 503." }, "504": { "content": { @@ -3440,7 +3440,7 @@ } } }, - "description": "The @maple/http/v2/InsufficientScopeError failure. HTTP 403." + "description": "The @maple/http/v2/InsufficientScopeError failure. HTTP 403. | The @maple/http/v2/OrganizationAccessDeniedError failure. HTTP 403." }, "404": { "content": { @@ -3497,7 +3497,7 @@ } } }, - "description": "The @maple/http/errors/AlertPersistenceError failure. HTTP 503. | The @maple/http/errors/ApiKeyLookupPersistenceError failure. HTTP 503." + "description": "The @maple/http/errors/AlertPersistenceError failure. HTTP 503. | The @maple/http/errors/ApiKeyLookupPersistenceError failure. HTTP 503. | The @maple/http/errors/AuthorizationUnavailableError failure. HTTP 503." }, "504": { "content": { @@ -3587,7 +3587,7 @@ } } }, - "description": "The @maple/http/v2/InsufficientScopeError failure. HTTP 403." + "description": "The @maple/http/v2/InsufficientScopeError failure. HTTP 403. | The @maple/http/v2/OrganizationAccessDeniedError failure. HTTP 403." }, "429": { "content": { @@ -3634,7 +3634,7 @@ } } }, - "description": "The @maple/http/errors/AlertPersistenceError failure. HTTP 503. | The @maple/http/errors/ApiKeyLookupPersistenceError failure. HTTP 503." + "description": "The @maple/http/errors/AlertPersistenceError failure. HTTP 503. | The @maple/http/errors/ApiKeyLookupPersistenceError failure. HTTP 503. | The @maple/http/errors/AuthorizationUnavailableError failure. HTTP 503." }, "504": { "content": { @@ -3711,7 +3711,7 @@ } } }, - "description": "The @maple/http/v2/InsufficientScopeError failure. HTTP 403." + "description": "The @maple/http/v2/InsufficientScopeError failure. HTTP 403. | The @maple/http/v2/OrganizationAccessDeniedError failure. HTTP 403." }, "404": { "content": { @@ -3768,7 +3768,7 @@ } } }, - "description": "The @maple/http/errors/AlertPersistenceError failure. HTTP 503. | The @maple/http/errors/ApiKeyLookupPersistenceError failure. HTTP 503." + "description": "The @maple/http/errors/AlertPersistenceError failure. HTTP 503. | The @maple/http/errors/ApiKeyLookupPersistenceError failure. HTTP 503. | The @maple/http/errors/AuthorizationUnavailableError failure. HTTP 503." }, "504": { "content": { @@ -3902,7 +3902,7 @@ } } }, - "description": "The @maple/http/v2/InsufficientScopeError failure. HTTP 403." + "description": "The @maple/http/v2/InsufficientScopeError failure. HTTP 403. | The @maple/http/v2/OrganizationAccessDeniedError failure. HTTP 403." }, "404": { "content": { @@ -3969,7 +3969,7 @@ } } }, - "description": "The @maple/http/errors/AlertPersistenceError failure. HTTP 503. | The @maple/http/errors/WarehouseUpstreamError failure. HTTP 503. | The @maple/http/errors/ApiKeyLookupPersistenceError failure. HTTP 503." + "description": "The @maple/http/errors/AlertPersistenceError failure. HTTP 503. | The @maple/http/errors/WarehouseUpstreamError failure. HTTP 503. | The @maple/http/errors/ApiKeyLookupPersistenceError failure. HTTP 503. | The @maple/http/errors/AuthorizationUnavailableError failure. HTTP 503." }, "504": { "content": { @@ -4117,7 +4117,7 @@ } } }, - "description": "The @maple/http/v2/InsufficientScopeError failure. HTTP 403." + "description": "The @maple/http/v2/InsufficientScopeError failure. HTTP 403. | The @maple/http/v2/OrganizationAccessDeniedError failure. HTTP 403." }, "429": { "content": { @@ -4164,7 +4164,7 @@ } } }, - "description": "The @maple/http/anomalies/AnomalyPersistenceError failure. HTTP 503. | The @maple/http/errors/ApiKeyLookupPersistenceError failure. HTTP 503." + "description": "The @maple/http/anomalies/AnomalyPersistenceError failure. HTTP 503. | The @maple/http/errors/ApiKeyLookupPersistenceError failure. HTTP 503. | The @maple/http/errors/AuthorizationUnavailableError failure. HTTP 503." }, "504": { "content": { @@ -4241,7 +4241,7 @@ } } }, - "description": "The @maple/http/v2/InsufficientScopeError failure. HTTP 403." + "description": "The @maple/http/v2/InsufficientScopeError failure. HTTP 403. | The @maple/http/v2/OrganizationAccessDeniedError failure. HTTP 403." }, "404": { "content": { @@ -4298,7 +4298,7 @@ } } }, - "description": "The @maple/http/anomalies/AnomalyPersistenceError failure. HTTP 503. | The @maple/http/errors/ApiKeyLookupPersistenceError failure. HTTP 503." + "description": "The @maple/http/anomalies/AnomalyPersistenceError failure. HTTP 503. | The @maple/http/errors/ApiKeyLookupPersistenceError failure. HTTP 503. | The @maple/http/errors/AuthorizationUnavailableError failure. HTTP 503." }, "504": { "content": { @@ -4391,7 +4391,7 @@ } } }, - "description": "The @maple/http/v2/InsufficientScopeError failure. HTTP 403." + "description": "The @maple/http/v2/InsufficientScopeError failure. HTTP 403. | The @maple/http/v2/OrganizationAccessDeniedError failure. HTTP 403." }, "404": { "content": { @@ -4458,7 +4458,7 @@ } } }, - "description": "The @maple/http/anomalies/AnomalyPersistenceError failure. HTTP 503. | The @maple/http/errors/WarehouseUpstreamError failure. HTTP 503. | The @maple/http/errors/OrgClickHouseSettingsPersistenceError failure. HTTP 503. | The @maple/http/errors/ApiKeyLookupPersistenceError failure. HTTP 503." + "description": "The @maple/http/anomalies/AnomalyPersistenceError failure. HTTP 503. | The @maple/http/errors/WarehouseUpstreamError failure. HTTP 503. | The @maple/http/errors/OrgClickHouseSettingsPersistenceError failure. HTTP 503. | The @maple/http/errors/ApiKeyLookupPersistenceError failure. HTTP 503. | The @maple/http/errors/AuthorizationUnavailableError failure. HTTP 503." }, "504": { "content": { @@ -4642,7 +4642,7 @@ } } }, - "description": "The @maple/http/v2/InsufficientScopeError failure. HTTP 403." + "description": "The @maple/http/v2/InsufficientScopeError failure. HTTP 403. | The @maple/http/v2/OrganizationAccessDeniedError failure. HTTP 403." }, "429": { "content": { @@ -4699,7 +4699,7 @@ } } }, - "description": "The @maple/http/errors/ErrorPersistenceError failure. HTTP 503. | The @maple/http/errors/WarehouseUpstreamError failure. HTTP 503. | The @maple/http/errors/OrgClickHouseSettingsPersistenceError failure. HTTP 503. | The @maple/http/errors/ApiKeyLookupPersistenceError failure. HTTP 503." + "description": "The @maple/http/errors/ErrorPersistenceError failure. HTTP 503. | The @maple/http/errors/WarehouseUpstreamError failure. HTTP 503. | The @maple/http/errors/OrgClickHouseSettingsPersistenceError failure. HTTP 503. | The @maple/http/errors/ApiKeyLookupPersistenceError failure. HTTP 503. | The @maple/http/errors/AuthorizationUnavailableError failure. HTTP 503." }, "504": { "content": { @@ -4767,7 +4767,7 @@ } } }, - "description": "The @maple/http/v2/InsufficientScopeError failure. HTTP 403." + "description": "The @maple/http/v2/InsufficientScopeError failure. HTTP 403. | The @maple/http/v2/OrganizationAccessDeniedError failure. HTTP 403." }, "429": { "content": { @@ -4814,7 +4814,7 @@ } } }, - "description": "The @maple/http/errors/ErrorPersistenceError failure. HTTP 503. | The @maple/http/errors/ApiKeyLookupPersistenceError failure. HTTP 503." + "description": "The @maple/http/errors/ErrorPersistenceError failure. HTTP 503. | The @maple/http/errors/ApiKeyLookupPersistenceError failure. HTTP 503. | The @maple/http/errors/AuthorizationUnavailableError failure. HTTP 503." }, "504": { "content": { @@ -4923,7 +4923,7 @@ } } }, - "description": "The @maple/http/v2/InsufficientScopeError failure. HTTP 403." + "description": "The @maple/http/v2/InsufficientScopeError failure. HTTP 403. | The @maple/http/v2/OrganizationAccessDeniedError failure. HTTP 403." }, "404": { "content": { @@ -4990,7 +4990,7 @@ } } }, - "description": "The @maple/http/errors/ErrorPersistenceError failure. HTTP 503. | The @maple/http/errors/WarehouseUpstreamError failure. HTTP 503. | The @maple/http/errors/OrgClickHouseSettingsPersistenceError failure. HTTP 503. | The @maple/http/errors/ApiKeyLookupPersistenceError failure. HTTP 503." + "description": "The @maple/http/errors/ErrorPersistenceError failure. HTTP 503. | The @maple/http/errors/WarehouseUpstreamError failure. HTTP 503. | The @maple/http/errors/OrgClickHouseSettingsPersistenceError failure. HTTP 503. | The @maple/http/errors/ApiKeyLookupPersistenceError failure. HTTP 503. | The @maple/http/errors/AuthorizationUnavailableError failure. HTTP 503." }, "504": { "content": { @@ -5058,7 +5058,7 @@ } } }, - "description": "The @maple/http/v2/InsufficientScopeError failure. HTTP 403." + "description": "The @maple/http/v2/InsufficientScopeError failure. HTTP 403. | The @maple/http/v2/OrganizationAccessDeniedError failure. HTTP 403." }, "429": { "content": { @@ -5105,7 +5105,7 @@ } } }, - "description": "The @maple/http/errors/MobileDevicePersistenceError failure. HTTP 503. | The @maple/http/errors/ApiKeyLookupPersistenceError failure. HTTP 503." + "description": "The @maple/http/errors/MobileDevicePersistenceError failure. HTTP 503. | The @maple/http/errors/ApiKeyLookupPersistenceError failure. HTTP 503. | The @maple/http/errors/AuthorizationUnavailableError failure. HTTP 503." }, "504": { "content": { @@ -5189,7 +5189,7 @@ } } }, - "description": "The @maple/http/v2/InsufficientScopeError failure. HTTP 403." + "description": "The @maple/http/v2/InsufficientScopeError failure. HTTP 403. | The @maple/http/v2/OrganizationAccessDeniedError failure. HTTP 403." }, "404": { "content": { @@ -5246,7 +5246,7 @@ } } }, - "description": "The @maple/http/errors/MobileDevicePersistenceError failure. HTTP 503. | The @maple/http/errors/ApiKeyLookupPersistenceError failure. HTTP 503." + "description": "The @maple/http/errors/MobileDevicePersistenceError failure. HTTP 503. | The @maple/http/errors/ApiKeyLookupPersistenceError failure. HTTP 503. | The @maple/http/errors/AuthorizationUnavailableError failure. HTTP 503." }, "504": { "content": { @@ -5338,7 +5338,7 @@ } } }, - "description": "The @maple/http/v2/InsufficientScopeError failure. HTTP 403." + "description": "The @maple/http/v2/InsufficientScopeError failure. HTTP 403. | The @maple/http/v2/OrganizationAccessDeniedError failure. HTTP 403." }, "429": { "content": { @@ -5385,7 +5385,7 @@ } } }, - "description": "The @maple/http/errors/MobileDevicePersistenceError failure. HTTP 503. | The @maple/http/errors/ApiKeyLookupPersistenceError failure. HTTP 503." + "description": "The @maple/http/errors/MobileDevicePersistenceError failure. HTTP 503. | The @maple/http/errors/ApiKeyLookupPersistenceError failure. HTTP 503. | The @maple/http/errors/AuthorizationUnavailableError failure. HTTP 503." }, "504": { "content": { @@ -5477,7 +5477,7 @@ } } }, - "description": "The @maple/http/v2/InsufficientScopeError failure. HTTP 403." + "description": "The @maple/http/v2/InsufficientScopeError failure. HTTP 403. | The @maple/http/v2/OrganizationAccessDeniedError failure. HTTP 403." }, "404": { "content": { @@ -5534,7 +5534,7 @@ } } }, - "description": "The @maple/http/errors/MobileDevicePersistenceError failure. HTTP 503. | The @maple/http/errors/ApiKeyLookupPersistenceError failure. HTTP 503." + "description": "The @maple/http/errors/MobileDevicePersistenceError failure. HTTP 503. | The @maple/http/errors/ApiKeyLookupPersistenceError failure. HTTP 503. | The @maple/http/errors/AuthorizationUnavailableError failure. HTTP 503." }, "504": { "content": { @@ -5634,7 +5634,7 @@ } } }, - "description": "The @maple/http/v2/InsufficientScopeError failure. HTTP 403." + "description": "The @maple/http/v2/InsufficientScopeError failure. HTTP 403. | The @maple/http/v2/OrganizationAccessDeniedError failure. HTTP 403." }, "404": { "content": { @@ -5691,7 +5691,7 @@ } } }, - "description": "The @maple/http/errors/MobileDevicePersistenceError failure. HTTP 503. | The @maple/http/errors/ApiKeyLookupPersistenceError failure. HTTP 503." + "description": "The @maple/http/errors/MobileDevicePersistenceError failure. HTTP 503. | The @maple/http/errors/ApiKeyLookupPersistenceError failure. HTTP 503. | The @maple/http/errors/AuthorizationUnavailableError failure. HTTP 503." }, "504": { "content": { @@ -5813,7 +5813,7 @@ } } }, - "description": "The @maple/http/v2/InsufficientScopeError failure. HTTP 403." + "description": "The @maple/http/v2/InsufficientScopeError failure. HTTP 403. | The @maple/http/v2/OrganizationAccessDeniedError failure. HTTP 403." }, "429": { "content": { @@ -5870,7 +5870,7 @@ } } }, - "description": "The @maple/http/errors/WarehouseUpstreamError failure. HTTP 503. | The @maple/http/errors/OrgClickHouseSettingsPersistenceError failure. HTTP 503. | The @maple/http/errors/ApiKeyLookupPersistenceError failure. HTTP 503." + "description": "The @maple/http/errors/WarehouseUpstreamError failure. HTTP 503. | The @maple/http/errors/OrgClickHouseSettingsPersistenceError failure. HTTP 503. | The @maple/http/errors/ApiKeyLookupPersistenceError failure. HTTP 503. | The @maple/http/errors/AuthorizationUnavailableError failure. HTTP 503." }, "504": { "content": { @@ -5963,7 +5963,7 @@ } } }, - "description": "The @maple/http/v2/InsufficientScopeError failure. HTTP 403." + "description": "The @maple/http/v2/InsufficientScopeError failure. HTTP 403. | The @maple/http/v2/OrganizationAccessDeniedError failure. HTTP 403." }, "404": { "content": { @@ -6030,7 +6030,7 @@ } } }, - "description": "The @maple/http/errors/WarehouseUpstreamError failure. HTTP 503. | The @maple/http/errors/OrgClickHouseSettingsPersistenceError failure. HTTP 503. | The @maple/http/errors/ApiKeyLookupPersistenceError failure. HTTP 503." + "description": "The @maple/http/errors/WarehouseUpstreamError failure. HTTP 503. | The @maple/http/errors/OrgClickHouseSettingsPersistenceError failure. HTTP 503. | The @maple/http/errors/ApiKeyLookupPersistenceError failure. HTTP 503. | The @maple/http/errors/AuthorizationUnavailableError failure. HTTP 503." }, "504": { "content": { @@ -6108,7 +6108,7 @@ } } }, - "description": "The @maple/http/v2/InsufficientScopeError failure. HTTP 403." + "description": "The @maple/http/v2/InsufficientScopeError failure. HTTP 403. | The @maple/http/v2/OrganizationAccessDeniedError failure. HTTP 403." }, "429": { "content": { @@ -6165,7 +6165,7 @@ } } }, - "description": "The @maple/http/errors/WarehouseUpstreamError failure. HTTP 503. | The @maple/http/errors/OrgClickHouseSettingsPersistenceError failure. HTTP 503. | The @maple/http/errors/ApiKeyLookupPersistenceError failure. HTTP 503." + "description": "The @maple/http/errors/WarehouseUpstreamError failure. HTTP 503. | The @maple/http/errors/OrgClickHouseSettingsPersistenceError failure. HTTP 503. | The @maple/http/errors/ApiKeyLookupPersistenceError failure. HTTP 503. | The @maple/http/errors/AuthorizationUnavailableError failure. HTTP 503." }, "504": { "content": { @@ -6243,7 +6243,7 @@ } } }, - "description": "The @maple/http/v2/InsufficientScopeError failure. HTTP 403." + "description": "The @maple/http/v2/InsufficientScopeError failure. HTTP 403. | The @maple/http/v2/OrganizationAccessDeniedError failure. HTTP 403." }, "429": { "content": { @@ -6300,7 +6300,7 @@ } } }, - "description": "The @maple/http/errors/WarehouseUpstreamError failure. HTTP 503. | The @maple/http/errors/OrgClickHouseSettingsPersistenceError failure. HTTP 503. | The @maple/http/errors/ApiKeyLookupPersistenceError failure. HTTP 503." + "description": "The @maple/http/errors/WarehouseUpstreamError failure. HTTP 503. | The @maple/http/errors/OrgClickHouseSettingsPersistenceError failure. HTTP 503. | The @maple/http/errors/ApiKeyLookupPersistenceError failure. HTTP 503. | The @maple/http/errors/AuthorizationUnavailableError failure. HTTP 503." }, "504": { "content": { From 989398278ae89b1505f9243ee3623afd593f708a Mon Sep 17 00:00:00 2001 From: Makisuo Date: Thu, 20 Aug 2026 00:03:32 +0200 Subject: [PATCH 3/5] fix(cli): narrow with `in` instead of Reflect.get in describeThrown MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Unrelated to this branch: `anti-slop(no-reflect-get)` has been failing the lint job on main since a4973a89e0, and it blocks every PR that runs the whole-repo lint. The rule's advice — parse dynamic input into a named domain type first — does not apply to a function whose entire job is describing an arbitrary thrown value, so the fix is the narrowing that does the same thing without the reflection: `"message" in error` checks the prototype chain exactly as `Reflect.get` walked it, and does not invoke a getter. The read that can throw stays inside the try, which is the property the original comment was protecting. No behaviour change; `describeThrown`'s invariant test still passes. --- apps/cli/src/server/serve.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/apps/cli/src/server/serve.ts b/apps/cli/src/server/serve.ts index 1b1636228..8f142cc7d 100644 --- a/apps/cli/src/server/serve.ts +++ b/apps/cli/src/server/serve.ts @@ -151,8 +151,12 @@ export const describeThrown = (error: unknown): string => { // Both reads are inside the try: `message` may be a getter that throws, and // reading it outside would defeat the whole point of this function. try { - const message = Reflect.get(error, "message") - if (typeof message === "string" && message !== "") return message + // `in` narrows without invoking the getter; the read below is what can + // throw, and it is inside the try for exactly that reason. + if ("message" in error) { + const message = error.message + if (typeof message === "string" && message !== "") return message + } const json = JSON.stringify(error) // `{}` here means every own property was non-enumerable or unserializable // (a `Response`, a class instance) — the empty object is the bug, so say so. From 69be0870a654070787a0ea760fb56b468d6e92bf Mon Sep 17 00:00:00 2001 From: Makisuo Date: Thu, 20 Aug 2026 11:10:40 +0200 Subject: [PATCH 4/5] fix: four defects found reviewing the multi-org change MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `prune` reloaded every widget timeline unconditionally. It runs on each launch and each organization switch, so the common path — nothing evicted — was spending the widget refresh budget iOS meters, to redraw identical data. It now reloads only when an organization was actually dropped. `lastPublished` decoded the whole published-organization index from UserDefaults inside a sort comparator, once per comparison. Read once into a dictionary. Sign-out cleared the per-organization snapshots by iterating the index, which by construction holds only what this build published — so the pre-per-organization `…v1` keys survived it. A phone with a widget placed before this shipped kept rendering the signed-out account's issues. That is the same defect the index iteration was written to fix, one key short. `applyRequestedOrg` returned the original tenant when the membership lookup came back `None`. Unreachable — `None` means "no header" and the guard above rules that out — but it is exactly the shape of a silent ignore, which is the failure the whole path exists to prevent. It fails now, and says why. Also drop the uppercase header fallback in the API-key guard: `HttpServerRequest` lowercases every incoming header, so it covered a casing that cannot occur. --- .../services/auth/ApiAuthorizationV2Layer.ts | 4 ++- apps/ios/Maple/Widgets/WidgetPublisher.swift | 26 ++++++++++++++----- packages/auth/src/index.ts | 6 ++++- 3 files changed, 28 insertions(+), 8 deletions(-) diff --git a/apps/api/src/services/auth/ApiAuthorizationV2Layer.ts b/apps/api/src/services/auth/ApiAuthorizationV2Layer.ts index 929eec054..7236e6891 100644 --- a/apps/api/src/services/auth/ApiAuthorizationV2Layer.ts +++ b/apps/api/src/services/auth/ApiAuthorizationV2Layer.ts @@ -34,8 +34,10 @@ const getBearerToken = (headers: Record): string | u return token } +/** `HttpServerRequest` lowercases every incoming header, and the constant is + * already lowercase — so this is a plain lookup, not a case-insensitive one. */ const getOrgSelectionHeader = (headers: Record): string | undefined => - headers[ORG_SELECTION_HEADER] ?? headers[ORG_SELECTION_HEADER.toUpperCase()] + headers[ORG_SELECTION_HEADER] const requestPath = (url: string): string => { const queryStart = url.indexOf("?") diff --git a/apps/ios/Maple/Widgets/WidgetPublisher.swift b/apps/ios/Maple/Widgets/WidgetPublisher.swift index b2ebca38c..2ad78cba6 100644 --- a/apps/ios/Maple/Widgets/WidgetPublisher.swift +++ b/apps/ios/Maple/Widgets/WidgetPublisher.swift @@ -135,7 +135,12 @@ final class WidgetPublisher { /// when the list came from Clerk's client payload, which can be partial — /// pruning against that would wipe live organizations. func prune(to memberIds: Set) { - for organizationId in index.prune(to: memberIds) { + let evicted = index.prune(to: memberIds) + // Nothing changed on the common path — this runs on every launch and every + // organization switch, and `reloadAllTimelines` spends the widget refresh + // budget iOS is metering. + guard !evicted.isEmpty else { return } + for organizationId in evicted { WidgetSnapshotStore.issues(organizationId: organizationId).clear() WidgetSnapshotStore.throughput(organizationId: organizationId).clear() } @@ -233,11 +238,19 @@ final class WidgetPublisher { trigger: Trigger ) async -> [PublishedOrganization] { let pinned = await pinnedOrganizationIds() + // Read once. Inside the comparator this decoded the whole index from + // UserDefaults on every comparison. + let publishedAt = Dictionary( + index.load().map { ($0.id, $0.lastPublishedAt) }, + uniquingKeysWith: { first, _ in first } + ) let others = context.memberships .filter { $0.id != context.active.id && pinned.contains($0.id) } // Oldest first, so a background round that can only afford one // extra organization round-robins rather than starving one. - .sorted { lastPublished(of: $0) < lastPublished(of: $1) } + .sorted { + publishedAt[$0.id] ?? .distantPast < publishedAt[$1.id] ?? .distantPast + } // A `BGAppRefreshTask` gets tens of seconds; twelve requests inside one // is how the whole chain gets deprioritized. @@ -245,10 +258,6 @@ final class WidgetPublisher { return [context.active] + others.prefix(budget) } - private func lastPublished(of organization: PublishedOrganization) -> Date { - index.load().first { $0.id == organization.id }?.lastPublishedAt ?? .distantPast - } - /// The organizations the user actually pinned a widget to. private func pinnedOrganizationIds() async -> Set { guard let configurations = try? await WidgetCenter.shared.currentConfigurations() else { return [] } @@ -295,6 +304,11 @@ final class WidgetPublisher { WidgetSnapshotStore.issues(organizationId: organizationId).clear() WidgetSnapshotStore.throughput(organizationId: organizationId).clear() } + // The pre-per-organization keys too. They are not in the index — nothing + // published them — so iterating it alone would leave a widget placed + // before this shipped rendering the signed-out account's issues. + WidgetSnapshotStore.legacyIssues.clear() + WidgetSnapshotStore.legacyThroughput.clear() WidgetCenter.shared.reloadAllTimelines() } diff --git a/packages/auth/src/index.ts b/packages/auth/src/index.ts index faa41eb0f..8a4de81f5 100644 --- a/packages/auth/src/index.ts +++ b/packages/auth/src/index.ts @@ -604,8 +604,12 @@ const applyRequestedOrg = Effect.fnUntraced(function* ( const requestedOrgId = yield* decodeOrgId(requested, "Invalid organization selection") if (requestedOrgId === tenant.orgId) return tenant + // `None` means "no header", which the guard above has already ruled out — so + // this either adopts an organization or fails. There is deliberately no + // branch here that returns the original tenant: that shape is what a silent + // ignore looks like, and it is the failure this whole path exists to avoid. const membership = yield* selectRequestedOrg(tenant.userId, headers, verify) - if (Option.isNone(membership)) return tenant + if (Option.isNone(membership)) return yield* Effect.fail(organizationAccessDenied(requestedOrgId)) // The role travels with the organization. Carrying the token's `orgRole` // across would grant an admin of org A admin of org B. From b942710ca177cc19b879632771d4e5a564b7e1f7 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Thu, 20 Aug 2026 11:10:40 +0200 Subject: [PATCH 5/5] fix: stop the formatter rewriting the generated iOS OpenAPI spec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `bun run format` reformatted apps/ios/.../openapi.json, and `ios:openapi:check` re-runs the generator and compares byte for byte — so anyone who formatted the repo after the last regeneration failed the quality shard, on a file they never edited. It cost this branch one red CI run and one wrong diagnosis. Same reasoning as routeTree.gen.ts directly above it: another tool owns the file's style. The difference is that here the churn is not merely transient, it is a failing check. --- .oxfmtrc.jsonc | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.oxfmtrc.jsonc b/.oxfmtrc.jsonc index 36c57f027..ae30c0bf4 100644 --- a/.oxfmtrc.jsonc +++ b/.oxfmtrc.jsonc @@ -15,6 +15,10 @@ // authored in the exact shape they compile to. // TanStack Router owns routeTree.gen.ts and regenerates it in its configured // style during web builds; formatting it only creates transient churn. + // The iOS OpenAPI spec is generated by scripts/generate-ios-openapi.ts, and + // `ios:openapi:check` re-runs that generator and compares byte for byte — so + // formatting it does not merely churn, it fails CI for anyone who has run + // `bun run format` since the last regeneration. "ignorePatterns": [ ".agents/**", ".codex/**", @@ -31,6 +35,7 @@ "packages/email/emails", "packages/email/components", "apps/web/src/routeTree.gen.ts", + "apps/ios/Packages/MapleAPI/Sources/MapleAPI/openapi.json", "scripts/oxlint-plugins/anti-slop/**", ], }