diff --git a/apps/api/src/platform/Apns.ts b/apps/api/src/platform/Apns.ts index 913fab1fb..8699e08bd 100644 --- a/apps/api/src/platform/Apns.ts +++ b/apps/api/src/platform/Apns.ts @@ -85,6 +85,38 @@ export interface ApnsLiveActivityPush { readonly priority?: 5 | 10 | undefined } +/** + * A background push: no alert, no sound, no badge — just `content-available`, + * which wakes the app for a few seconds so it can refresh something. + * + * Maple sends exactly one of these, and only for the Home Screen widgets: an + * incident opening or resolving is the moment the numbers on a Lock Screen are + * most wrong, and it is the one moment worth spending a wake-up on. + * + * Three things about this channel that are easy to get wrong: + * + * - **iOS decides.** Background pushes are throttled on a schedule Apple does + * not publish and does not honour any particular rate. This is a hint, never + * a delivery guarantee, and nothing may depend on one arriving. + * - **Priority 5, always.** Apple explicitly rejects `content-available` at + * priority 10 on newer iOS, and a background push that jumps the queue is + * also the one users notice as battery drain. + * - **It expires.** A wake-up that arrives after the numbers have moved on + * again is a wasted radio, so these carry a short expiry and a collapse id — + * an organization only ever needs the most recent one. + */ +export interface ApnsBackgroundPush { + readonly deviceToken: string + readonly environment: MobilePushEnvironment + readonly bundleId: string + /** Delivered to the app alongside the wake-up; `aps` stays alert-free. */ + readonly data: Record + /** Newer wake-ups replace older ones — one per organization is plenty. */ + readonly collapseId?: string | undefined + /** Seconds. Past this Apple stops trying, which is the wanted behaviour. */ + readonly expiresInSeconds?: number | undefined +} + export type ApnsSendResult = | { readonly outcome: "sent"; readonly apnsId: string | null } /** Apple says this token is dead: stop sending to it. */ @@ -100,6 +132,7 @@ export interface ApnsClientApi { readonly isConfigured: boolean readonly send: (push: ApnsPush) => Effect.Effect readonly sendLiveActivity: (push: ApnsLiveActivityPush) => Effect.Effect + readonly sendBackground: (push: ApnsBackgroundPush) => Effect.Effect } const APNS_HOSTS = { @@ -184,6 +217,7 @@ export class ApnsClient extends Context.Service()( isConfigured: false, send: unconfigured, sendLiveActivity: unconfigured, + sendBackground: unconfigured, } satisfies ApnsClientApi } @@ -283,10 +317,43 @@ export class ApnsClient extends Context.Service()( return yield* dispatch(push.environment, push.deviceToken, headers, body) }) + const sendBackground = Effect.fn("ApnsClient.sendBackground")(function* ( + push: ApnsBackgroundPush, + ) { + if (!ALLOWED_TOPICS.has(push.bundleId)) { + return yield* new ApnsError({ + message: `Refusing to send for an unknown APNs topic: ${push.bundleId}`, + }) + } + const token = yield* currentToken + const body = { + // `content-available` and nothing else. Any of `alert`, `sound` + // or `badge` alongside it turns this into a visible + // notification, which is not what a widget refresh should cost + // the user. + aps: { "content-available": 1 }, + ...push.data, + } + const nowSeconds = Math.floor((yield* Clock.currentTimeMillis) / 1000) + const headers = { + authorization: `bearer ${token}`, + "apns-topic": push.bundleId, + "apns-push-type": "background", + // Apple rejects `content-available` at priority 10. + "apns-priority": "5", + "apns-expiration": String(nowSeconds + (push.expiresInSeconds ?? 900)), + ...(push.collapseId !== undefined + ? { "apns-collapse-id": push.collapseId.slice(0, 64) } + : undefined), + } + return yield* dispatch(push.environment, push.deviceToken, headers, body) + }) + /** * One POST to Apple and one reading of its answer, shared by both push - * types: the difference between an alert and a Live Activity is entirely - * in the headers and the body, never in how a 410 is interpreted. + * kinds: the difference between an alert, a Live Activity and a + * background wake-up is entirely in the headers and the body, never in + * how a 410 is interpreted. */ const dispatch = Effect.fn("ApnsClient.dispatch")(function* ( environment: MobilePushEnvironment, @@ -399,7 +466,7 @@ export class ApnsClient extends Context.Service()( return yield* dispatch(push.environment, push.pushToken, headers, body) }) - return { isConfigured: true, send, sendLiveActivity } satisfies ApnsClientApi + return { isConfigured: true, send, sendLiveActivity, sendBackground } satisfies ApnsClientApi }), }, ) { diff --git a/apps/api/src/routes/v2/mobile-devices.http.ts b/apps/api/src/routes/v2/mobile-devices.http.ts index e5b12301d..39c0e4238 100644 --- a/apps/api/src/routes/v2/mobile-devices.http.ts +++ b/apps/api/src/routes/v2/mobile-devices.http.ts @@ -33,6 +33,21 @@ export const HttpV2MobileDevicesLive = HttpApiBuilder.group(MapleApiV2, "mobileD const devices = yield* MobileDevicesService const activities = yield* LiveActivitiesService + /** The device the credential belongs to, or a 404 rather than a key. */ + const requireDevice = Effect.fn("HttpV2MobileDevices.requireDevice")(function* ( + orgId: CurrentTenant.TenantSchema["orgId"], + token: string, + ) { + const device = yield* devices.find(orgId, "ios", token) + if (device === null) { + return yield* new MobileDeviceNotFoundError({ + message: "Device not registered", + token, + }) + } + return device + }) + return handlers .handle("list", () => Effect.gen(function* () { @@ -77,13 +92,7 @@ export const HttpV2MobileDevicesLive = HttpApiBuilder.group(MapleApiV2, "mobileD // The activity belongs to a device, and the device is what says // which APNs host its tokens live on — an activity whose device is // gone could never be pushed to. - const device = yield* devices.find(tenant.orgId, "ios", params.token) - if (device === null) { - return yield* new MobileDeviceNotFoundError({ - message: "Device not registered", - token: params.token, - }) - } + const device = yield* requireDevice(tenant.orgId, params.token) const activity = yield* activities.register({ orgId: tenant.orgId, deviceId: device.id, @@ -105,13 +114,7 @@ export const HttpV2MobileDevicesLive = HttpApiBuilder.group(MapleApiV2, "mobileD .handle("endLiveActivity", ({ params }) => Effect.gen(function* () { const tenant = yield* CurrentTenant.Context - const device = yield* devices.find(tenant.orgId, "ios", params.token) - if (device === null) { - return yield* new MobileDeviceNotFoundError({ - message: "Device not registered", - token: params.token, - }) - } + const device = yield* requireDevice(tenant.orgId, params.token) yield* activities.endForDevice( tenant.orgId, device.id, diff --git a/apps/api/src/routes/v2/telemetry.http.ts b/apps/api/src/routes/v2/telemetry.http.ts index 20f1d7414..c37bd40f2 100644 --- a/apps/api/src/routes/v2/telemetry.http.ts +++ b/apps/api/src/routes/v2/telemetry.http.ts @@ -75,7 +75,7 @@ const metricCatalogRowSchema = Schema.Struct({ isMonotonic: CH.CHNumber, }) -const serviceCatalogRowSchema = Schema.Struct({ +export const serviceCatalogRowSchema = Schema.Struct({ serviceName: Schema.String, serviceNamespaces: Schema.Array(Schema.String), deploymentEnvironments: Schema.Array(Schema.String), @@ -1029,7 +1029,7 @@ const BASELINE_CACHE_SECONDS = 3600 * the baseline rows collapse the same way: the busiest row wins rather than * the numbers being averaged across populations that don't compare. */ -type ServiceBaselines = ReadonlyMap +export type ServiceBaselines = ReadonlyMap const collapseBaselines = ( rows: readonly { @@ -1048,7 +1048,7 @@ const collapseBaselines = ( return map } -const toService = ( +export const toService = ( row: { serviceName: string serviceNamespaces: readonly string[] diff --git a/apps/api/src/routes/v2/v2-test-support.ts b/apps/api/src/routes/v2/v2-test-support.ts index 7945e314d..191555ad3 100644 --- a/apps/api/src/routes/v2/v2-test-support.ts +++ b/apps/api/src/routes/v2/v2-test-support.ts @@ -53,6 +53,8 @@ import { HttpV2ServicesLive, HttpV2TracesLive, } from "./telemetry.http" +import { HttpV2WidgetSummaryLive } from "./widget-summary.http" +import { HttpV2WidgetCredentialsLive } from "./widget-credentials.http" /** * Test-only support for the v2 HTTP harnesses. `HttpApiBuilder.layer(MapleApiV2)` @@ -90,6 +92,8 @@ export const AllV2GroupLayersLive = Layer.mergeAll( HttpV2MetricsLive, HttpV2ServicesLive, HttpV2ServiceMapLive, + HttpV2WidgetSummaryLive, + HttpV2WidgetCredentialsLive, // The share group's own dependencies are satisfied here rather than by every // harness: most v2 route tests never touch the share endpoints, and threading // inert services through two dozen call sites to register a group they never diff --git a/apps/api/src/routes/v2/widget-credentials.http.test.ts b/apps/api/src/routes/v2/widget-credentials.http.test.ts new file mode 100644 index 000000000..05b784def --- /dev/null +++ b/apps/api/src/routes/v2/widget-credentials.http.test.ts @@ -0,0 +1,250 @@ +import { afterEach, describe, expect, it } from "@effect/vitest" +import { OrgId, UserId } from "@maple/domain/http" +import { MapleApiV2 } from "@maple/domain/http/v2" +import { ConfigProvider, Context, Effect, Layer, ManagedRuntime, Schema } from "effect" +import { HttpRouter } from "effect/unstable/http" +import { HttpApiBuilder } from "effect/unstable/httpapi" +import { Env } from "@/platform/Env" +import { cleanupTestDbs, createTestDb, type TestDb } from "@/platform/test-pglite" +import { ApiAuthorizationV2Layer } from "@/services/auth/ApiAuthorizationV2Layer" +import { ApiKeysService } from "@/services/org/ApiKeysService" +import { AuthService } from "@/services/auth/AuthService" +import { DashboardPersistenceService } from "@/services/dashboards/DashboardPersistenceService" +import { SharedDashboardService } from "@/services/dashboards/SharedDashboardService" +import { LiveActivitiesService } from "@/services/push/LiveActivitiesService" +import { MobileDevicesService } from "@/services/push/MobileDevicesService" +import { V2TransportErrorBoundaryLive } from "./error-envelope" +import { + AlertsServiceStubLayer, + AllV2GroupLayersLive, + ApiV2RateLimiterAllowAllLayer, + ConfigResourceServiceStubsLayer, + Phase1ResourceStubsLayer, + PlanetScaleServiceStubsLayer, + SlackIntegrationServiceStubLayer, + TelemetryServiceStubsLayer, +} from "./v2-test-support" + +/** + * `/v2/widget_credentials` over an embedded PGlite. The assertions here are + * mostly about what the credential *cannot* do — that is the whole reason it + * exists as a distinct kind rather than a `standard` key with narrow scopes. + */ + +const createdDbs: TestDb[] = [] +afterEach(() => cleanupTestDbs(createdDbs)) + +const ORG = Schema.decodeUnknownSync(OrgId)("org_widget_cred") +const USER = Schema.decodeUnknownSync(UserId)("user_widget_cred") +const INSTALLATION = "F9E1B4C0-8F2A-4C6D-9E1B-4C08F2A4C6D9" +const OTHER_INSTALLATION = "A1B2C3D4-8F2A-4C6D-9E1B-4C08F2A4C6D9" + +const testConfig = () => + ConfigProvider.layer( + ConfigProvider.fromUnknown({ + PORT: "3492", + MCP_PORT: "3493", + TINYBIRD_HOST: "https://api.tinybird.co", + TINYBIRD_TOKEN: "test-token", + MAPLE_AUTH_MODE: "self_hosted", + MAPLE_ROOT_PASSWORD: "test-root-password", + MAPLE_DEFAULT_ORG_ID: "default", + MAPLE_INGEST_KEY_ENCRYPTION_KEY: Buffer.alloc(32, 1).toString("base64"), + MAPLE_INGEST_KEY_LOOKUP_HMAC_KEY: "maple-test-lookup-secret", + INTERNAL_SERVICE_TOKEN: "test-internal-token", + }), + ) + +const makeHarness = () => { + const testDb = createTestDb(createdDbs) + const envLive = Env.layer.pipe(Layer.provide(testConfig())) + const servicesLive = Layer.mergeAll( + ApiKeysService.layer, + AuthService.layer, + DashboardPersistenceService.layer, + SharedDashboardService.layer, + MobileDevicesService.layer, + LiveActivitiesService.layer, + ).pipe(Layer.provideMerge(Layer.mergeAll(envLive, testDb.layer))) + + const routes = HttpApiBuilder.layer(MapleApiV2).pipe( + Layer.provide(AllV2GroupLayersLive), + Layer.provide(V2TransportErrorBoundaryLive), + Layer.provide(AlertsServiceStubLayer), + Layer.provide(ConfigResourceServiceStubsLayer), + Layer.provide(Phase1ResourceStubsLayer), + Layer.provide(SlackIntegrationServiceStubLayer), + Layer.provide(PlanetScaleServiceStubsLayer), + Layer.provide(TelemetryServiceStubsLayer), + Layer.provideMerge(ApiAuthorizationV2Layer), + Layer.provideMerge(ApiV2RateLimiterAllowAllLayer), + Layer.provideMerge(servicesLive), + ) + const { handler, dispose: disposeHandler } = HttpRouter.toWebHandler(routes, { disableLogger: true }) + const runtime = ManagedRuntime.make(servicesLive) + + const request = async (method: string, path: string, token: string) => { + const response = await handler( + new Request(`http://maple.test${path}`, { + method, + headers: { authorization: `Bearer ${token}` }, + }), + Context.empty() as never, + ) + const text = await response.text() + return { status: response.status, body: text.length === 0 ? null : JSON.parse(text) } + } + + const bootstrapKey = () => + runtime.runPromise( + Effect.gen(function* () { + const service = yield* ApiKeysService + return yield* service.create(ORG, USER, { name: "widget-cred-test" }) + }), + ) + + const mint = async (secret: string, installation = INSTALLATION) => + request("PUT", `/v2/widget_credentials/${installation}`, secret) + + return { + request, + bootstrapKey, + mint, + dispose: async () => { + await disposeHandler() + await runtime.dispose() + }, + } +} + +describe("v2 widget credentials", () => { + it("mints a credential the server bounded, not the caller", async () => { + const harness = makeHarness() + try { + const key = await harness.bootstrapKey() + const minted = await harness.mint(key.secret) + + expect(minted.status).toBe(200) + expect(minted.body.object).toBe("widget_credential") + expect(minted.body.secret).toMatch(/^maple_ak_/) + expect(minted.body.organization_id).toBe(ORG) + // The caller asked for none of this. + expect(minted.body.scopes).toEqual(["widget_summary:read"]) + expect(Date.parse(minted.body.expires_at)).toBeGreaterThan(Date.now()) + } finally { + await harness.dispose() + } + }) + + it("is fenced to the widget summary and nothing else", async () => { + const harness = makeHarness() + try { + const key = await harness.bootstrapKey() + const minted = await harness.mint(key.secret) + + const summary = await harness.request("GET", "/v2/widget_summary", minted.body.secret) + expect(summary.status).not.toBe(401) + expect(summary.status).not.toBe(403) + + // Everything the summary is composed from, and everything a credential + // could otherwise be abused for. This is the property that keeps a + // token on a phone from being an organization read key. + for (const path of ["/v2/error_issues", "/v2/services", "/v2/mobile_devices", "/v2/api_keys"]) { + const denied = await harness.request("GET", path, minted.body.secret) + expect(denied.status).toBe(403) + } + } finally { + await harness.dispose() + } + }) + + it("cannot renew itself — renewal always goes through a session", async () => { + const harness = makeHarness() + try { + const key = await harness.bootstrapKey() + const minted = await harness.mint(key.secret) + const renewed = await harness.mint(minted.body.secret) + expect(renewed.status).toBe(403) + } finally { + await harness.dispose() + } + }) + + it("replaces the installation's previous credential rather than adding one", async () => { + const harness = makeHarness() + try { + const key = await harness.bootstrapKey() + const first = await harness.mint(key.secret) + const second = await harness.mint(key.secret) + expect(second.body.secret).not.toBe(first.body.secret) + + // A phone that got a new key must not leave the old one live. + expect((await harness.request("GET", "/v2/widget_summary", first.body.secret)).status).toBe(401) + expect((await harness.request("GET", "/v2/widget_summary", second.body.secret)).status).not.toBe( + 401, + ) + } finally { + await harness.dispose() + } + }) + + it("scopes the replacement to one installation", async () => { + const harness = makeHarness() + try { + const key = await harness.bootstrapKey() + const phone = await harness.mint(key.secret, INSTALLATION) + await harness.mint(key.secret, OTHER_INSTALLATION) + // A second device minting must not knock the first one's Home Screen out. + expect((await harness.request("GET", "/v2/widget_summary", phone.body.secret)).status).not.toBe( + 401, + ) + } finally { + await harness.dispose() + } + }) + + it("revokes on sign-out, idempotently", async () => { + const harness = makeHarness() + try { + const key = await harness.bootstrapKey() + const minted = await harness.mint(key.secret) + + const revoked = await harness.request( + "DELETE", + `/v2/widget_credentials/${INSTALLATION}`, + key.secret, + ) + expect(revoked.status).toBe(200) + expect(revoked.body.deleted).toBe(true) + // The Home Screen outlives the session; the credential must not. + expect((await harness.request("GET", "/v2/widget_summary", minted.body.secret)).status).toBe(401) + + // Nothing to revoke is already the requested state. + const again = await harness.request( + "DELETE", + `/v2/widget_credentials/${OTHER_INSTALLATION}`, + key.secret, + ) + expect(again.status).toBe(200) + } finally { + await harness.dispose() + } + }) + + it("keeps device credentials out of the organization's key list", async () => { + const harness = makeHarness() + try { + const key = await harness.bootstrapKey() + await harness.mint(key.secret) + + // A dozen rows nobody created by hand, in front of an admin looking for + // the two they did, each of them one click from breaking a Home Screen. + const keys = await harness.request("GET", "/v2/api_keys", key.secret) + expect(keys.status).toBe(200) + expect(keys.body.data.every((row: { kind: string }) => row.kind !== "device")).toBe(true) + expect(keys.body.data.some((row: { name: string }) => row.name === "widget-cred-test")).toBe(true) + } finally { + await harness.dispose() + } + }) +}) diff --git a/apps/api/src/routes/v2/widget-credentials.http.ts b/apps/api/src/routes/v2/widget-credentials.http.ts new file mode 100644 index 000000000..12cc2a84c --- /dev/null +++ b/apps/api/src/routes/v2/widget-credentials.http.ts @@ -0,0 +1,75 @@ +import { HttpApiBuilder } from "effect/unstable/httpapi" +import { CurrentTenant } from "@maple/domain/http" +import { MapleApiV2, isoTimestamp } from "@maple/domain/http/v2" +import { Effect } from "effect" +import { ApiKeysService } from "@/services/org/ApiKeysService" + +/** + * The widget credential's ceilings, all of them the server's. + * + * Choosing them here rather than accepting them from the caller is the whole + * reason this is a dedicated operation instead of `POST /v2/api_keys` with a + * `kind`: a client that is compromised — or simply wrong — cannot widen any of + * them. + * + * The TTL is long enough that a phone used weekly never sees a widget go quiet, + * and short enough that a credential lifted off a lost device stops working + * without anyone having to notice. The app re-mints a week ahead of it, on a + * foreground it was making anyway. + */ +const WIDGET_CREDENTIAL_TTL_SECONDS = 60 * 60 * 24 * 30 +/** + * The fence. `requiredScopeForRequest` derives an API key's required scope from + * the first path segment, so this reaches `/v2/widget_summary` and nothing + * else. Composed from the endpoints that summary is built out of, the same + * credential would need `error_issues:read` + `services:read` + `traces:read` — + * an organization read key, sitting on a phone. + * + * Note what is *not* here: `widget_credentials:write`. A credential cannot + * renew itself, so renewal is always something a signed-in human's session did. + */ +const WIDGET_CREDENTIAL_SCOPES = ["widget_summary:read"] as const +const WIDGET_CREDENTIAL_NAME = "Home Screen widgets" + +export const HttpV2WidgetCredentialsLive = HttpApiBuilder.group(MapleApiV2, "widgetCredentials", (handlers) => + Effect.gen(function* () { + const apiKeys = yield* ApiKeysService + + return handlers + .handle("mint", ({ params }) => + Effect.gen(function* () { + const tenant = yield* CurrentTenant.Context + const credential = yield* apiKeys.replaceDeviceKey(tenant.orgId, tenant.userId, { + deviceId: params.installation_id, + name: WIDGET_CREDENTIAL_NAME, + scopes: WIDGET_CREDENTIAL_SCOPES, + expiresInSeconds: WIDGET_CREDENTIAL_TTL_SECONDS, + // The signed-in user's own roles. A credential must never + // outrank the human who asked for it, and the alternative — + // letting it resolve with the API-key default — is `root`. + roles: tenant.roles, + }) + return { + object: "widget_credential" as const, + secret: credential.secret, + organization_id: tenant.orgId, + scopes: credential.scopes ?? WIDGET_CREDENTIAL_SCOPES, + // `replaceDeviceKey` always sets an expiry; the fallback is + // only here because the shared response type allows none. + expires_at: isoTimestamp(credential.expiresAt ?? credential.createdAt), + created_at: isoTimestamp(credential.createdAt), + } + }), + ) + .handle("revoke", ({ params }) => + Effect.gen(function* () { + const tenant = yield* CurrentTenant.Context + // Idempotent, and deliberately not a 404 when there is nothing + // to revoke: this is the sign-out path, and an error the app + // cannot act on while signing out anyway is worse than silence. + yield* apiKeys.revokeDeviceKeys(tenant.orgId, params.installation_id) + return { object: "widget_credential" as const, deleted: true as const } + }), + ) + }), +) diff --git a/apps/api/src/routes/v2/widget-summary.http.test.ts b/apps/api/src/routes/v2/widget-summary.http.test.ts new file mode 100644 index 000000000..25d8d811c --- /dev/null +++ b/apps/api/src/routes/v2/widget-summary.http.test.ts @@ -0,0 +1,323 @@ +import { afterEach, describe, expect, it } from "@effect/vitest" +import { + ErrorIssueDocument, + ErrorIssueId, + ErrorIssuesListResponse, + IsoDateTimeString, + OrgId, + UserId, +} from "@maple/domain/http" +import { MapleApiV2 } from "@maple/domain/http/v2" +import { QueryEngineExecuteResponse } from "@maple/query-engine" +import { ConfigProvider, Context, Effect, Layer, ManagedRuntime, Option, Schema } from "effect" +import { HttpRouter } from "effect/unstable/http" +import { HttpApiBuilder } from "effect/unstable/httpapi" +import { Env } from "@/platform/Env" +import { cleanupTestDbs, createTestDb, type TestDb } from "@/platform/test-pglite" +import { ApiAuthorizationV2Layer } from "@/services/auth/ApiAuthorizationV2Layer" +import { ApiKeysService } from "@/services/org/ApiKeysService" +import { AuthService } from "@/services/auth/AuthService" +import { DashboardPersistenceService } from "@/services/dashboards/DashboardPersistenceService" +import { ErrorIssueReadModelsService } from "@/services/errors/ErrorIssueReadModelsService" +import { LiveActivitiesService } from "@/services/push/LiveActivitiesService" +import { MobileDevicesService } from "@/services/push/MobileDevicesService" +import { SharedDashboardService } from "@/services/dashboards/SharedDashboardService" +import { QueryEngineService, type QueryEngineServiceApi } from "@/services/warehouse/QueryEngineService" +import { WarehouseQueryService } from "@/services/warehouse/WarehouseQueryService" +import { V2TransportErrorBoundaryLive } from "./error-envelope" +import { + AlertsServiceStubLayer, + AllV2GroupLayersLive, + ApiV2RateLimiterAllowAllLayer, + ConfigResourceServiceStubsLayer, + makeWarehouseServiceStub, + PlanetScaleServiceStubsLayer, + SlackIntegrationServiceStubLayer, +} from "./v2-test-support" + +const createdDbs: TestDb[] = [] +afterEach(() => cleanupTestDbs(createdDbs)) + +const testConfig = () => + ConfigProvider.layer( + ConfigProvider.fromUnknown({ + PORT: "3490", + MCP_PORT: "3491", + TINYBIRD_HOST: "https://api.tinybird.co", + TINYBIRD_TOKEN: "test-token", + MAPLE_AUTH_MODE: "self_hosted", + MAPLE_ROOT_PASSWORD: "test-root-password", + MAPLE_DEFAULT_ORG_ID: "default", + MAPLE_INGEST_KEY_ENCRYPTION_KEY: Buffer.alloc(32, 1).toString("base64"), + MAPLE_INGEST_KEY_LOOKUP_HMAC_KEY: "maple-test-lookup-secret", + INTERNAL_SERVICE_TOKEN: "test-internal-token", + }), + ) + +const decodeIssueId = Schema.decodeSync(ErrorIssueId) +const decodeIso = Schema.decodeSync(IsoDateTimeString) + +const issueDocument = (overrides: Partial = {}): ErrorIssueDocument => + new ErrorIssueDocument({ + id: decodeIssueId("11111111-1111-4111-8111-111111111111"), + kind: "error", + fingerprintHash: "12345", + serviceName: "api", + exceptionType: "TypeError", + exceptionMessage: "Cannot read properties of undefined", + errorLabel: "checkout", + topFrame: "checkout.ts:12", + workflowState: "triage", + priority: 1, + severity: "critical", + severitySource: "detector", + sourceRef: null, + assignedActor: null, + leaseHolder: null, + leaseExpiresAt: null, + claimedAt: null, + notes: null, + firstSeenAt: decodeIso("2026-08-20T09:00:00.000Z"), + lastSeenAt: decodeIso("2026-08-21T09:08:12.000Z"), + occurrenceCount: 412, + resolvedAt: null, + lastResolvedAt: null, + lastRegressedAt: null, + regressionCount: 2, + resolvedVersions: [], + snoozeUntil: null, + archivedAt: null, + hasOpenIncident: true, + ...overrides, + }) + +const serviceRow = { + serviceName: "api", + serviceNamespaces: ["checkout"], + deploymentEnvironments: ["production"], + spanCount: "10", + errorCount: "2", + estimatedErrorCount: "4", + estimatedSpanCount: "7200", + p50LatencyMs: "10", + p95LatencyMs: "40", + p99LatencyMs: "50", +} + +const warehouseStub = makeWarehouseServiceStub({ + compiledQuery: (_tenant, compiled) => + compiled.decodeRows(compiled.sql.includes("FROM service_overview_spans") ? [serviceRow] : []), + compiledQueryFirst: (_tenant, compiled) => + compiled.decodeRows([]).pipe(Effect.map((rows) => Option.fromNullishOr(rows[0]))), + ingest: () => Effect.void, +}) + +const queryEngineStub = (execute: QueryEngineServiceApi["execute"]): QueryEngineServiceApi => ({ + execute, + evaluate: () => Effect.die(new Error("not used")), + evaluateSeries: () => Effect.die(new Error("not used")), + cachedDirect: (_tenant, _route, _payload, effect) => effect, +}) + +/** Grouped requests carry a `service` group-by; the ungrouped one does not. */ +const seriesEngine = queryEngineStub((_tenant, request) => + Effect.succeed( + new QueryEngineExecuteResponse({ + result: { + kind: "timeseries", + source: "traces", + data: + request.query.kind === "timeseries" && request.query.groupBy !== undefined + ? [ + { bucket: "2026-08-21T09:00:00", series: { api: 600 } }, + { bucket: "2026-08-21T09:01:00", series: { api: 900 } }, + ] + : [ + { bucket: "2026-08-21T09:00:00", series: { all: 1200 } }, + { bucket: "2026-08-21T09:01:00", series: { all: 1800 } }, + ], + }, + }), + ), +) + +const makeHarness = (options: { + readonly listIssues?: ErrorIssueReadModelsService["listIssues"] + readonly queryEngine?: QueryEngineServiceApi +}) => { + const testDb = createTestDb(createdDbs) + const envLive = Env.layer.pipe(Layer.provide(testConfig())) + const servicesLive = Layer.mergeAll( + ApiKeysService.layer, + AuthService.layer, + DashboardPersistenceService.layer, + SharedDashboardService.layer, + MobileDevicesService.layer, + LiveActivitiesService.layer, + ).pipe(Layer.provideMerge(Layer.mergeAll(envLive, testDb.layer))) + + // Provided ahead of `ConfigResourceServiceStubsLayer`, whose issue read + // models all `die` — this suite is the one that actually calls them. + const readsLive = Layer.mergeAll( + Layer.succeed(WarehouseQueryService, warehouseStub), + Layer.succeed(QueryEngineService, options.queryEngine ?? seriesEngine), + Layer.succeed(ErrorIssueReadModelsService, { + listIssues: + options.listIssues ?? + (() => Effect.succeed(new ErrorIssuesListResponse({ issues: [issueDocument()] }))), + countOpenIssuesByService: () => Effect.die(new Error("not used")), + getIssue: () => Effect.die(new Error("not used")), + listIssueIncidents: () => Effect.die(new Error("not used")), + listOpenIncidents: () => Effect.die(new Error("not used")), + } as ErrorIssueReadModelsService), + ) + + const routes = HttpApiBuilder.layer(MapleApiV2).pipe( + Layer.provide(AllV2GroupLayersLive), + Layer.provide(readsLive), + Layer.provide(V2TransportErrorBoundaryLive), + Layer.provide(SlackIntegrationServiceStubLayer), + Layer.provide(PlanetScaleServiceStubsLayer), + Layer.provide(AlertsServiceStubLayer), + Layer.provide(ConfigResourceServiceStubsLayer), + Layer.provideMerge(ApiAuthorizationV2Layer), + Layer.provideMerge(ApiV2RateLimiterAllowAllLayer), + Layer.provideMerge(servicesLive), + ) + const { handler, dispose: disposeHandler } = HttpRouter.toWebHandler(routes, { disableLogger: true }) + const runtime = ManagedRuntime.make(servicesLive) + const org = Schema.decodeUnknownSync(OrgId)("org_widget_e2e") + const user = Schema.decodeUnknownSync(UserId)("user_widget_e2e") + const bootstrapKey = (scopes?: ReadonlyArray) => + runtime.runPromise( + Effect.gen(function* () { + const service = yield* ApiKeysService + return yield* service.create(org, user, { name: "widget-test", scopes }) + }), + ) + const get = async (token: string, headers: Record = {}) => { + const response = await handler( + new Request("http://maple.test/v2/widget_summary", { + headers: { authorization: `Bearer ${token}`, ...headers }, + }), + Context.empty() as never, + ) + const text = await response.text() + return { status: response.status, body: text ? JSON.parse(text) : null } + } + return { + org, + bootstrapKey, + get, + dispose: async () => { + await disposeHandler() + await runtime.dispose() + }, + } +} + +describe("GET /v2/widget_summary", () => { + it("returns both widget surfaces in one payload", async () => { + const harness = makeHarness({}) + try { + const key = await harness.bootstrapKey(["widget_summary:read"]) + const summary = await harness.get(key.secret) + + expect(summary.status).toBe(200) + expect(summary.body).toMatchObject({ + object: "widget_summary", + schema_version: 1, + organization_id: harness.org, + issues: { window_seconds: 86_400, has_more: false }, + throughput: { window_seconds: 3600 }, + }) + // The raw naming fields, not a rendered title: the client owns the + // fallback so its issue list and its widget cannot disagree. + expect(summary.body.issues.data[0]).toMatchObject({ + exception_type: "TypeError", + error_label: "checkout", + exception_message: "Cannot read properties of undefined", + service_name: "api", + severity: "critical", + occurrence_count: 412, + is_regressed: true, + has_open_incident: true, + }) + expect(summary.body.issues.data[0].id).toMatch(/^iss_/) + + // Bucket counts, not rates — 7200 estimated spans over the hour window. + expect(summary.body.throughput.bucket_seconds).toBeGreaterThan(0) + expect(summary.body.throughput.services[0]).toMatchObject({ + name: "api", + throughput_per_second: 2, + points: [600, 900], + }) + expect(summary.body.throughput.total_points).toEqual([1200, 1800]) + } finally { + await harness.dispose() + } + }) + + it("reports has_more so the widget renders a floor rather than a wrong total", async () => { + const harness = makeHarness({ + listIssues: () => + Effect.succeed( + new ErrorIssuesListResponse({ issues: [issueDocument()], nextCursor: "next" }), + ), + }) + try { + const key = await harness.bootstrapKey(["widget_summary:read"]) + const summary = await harness.get(key.secret) + expect(summary.body.issues.has_more).toBe(true) + } finally { + await harness.dispose() + } + }) + + it("degrades the sparkline rather than the summary when the series read fails", async () => { + const harness = makeHarness({ + queryEngine: queryEngineStub(() => Effect.die(new Error("warehouse down"))), + }) + try { + const key = await harness.bootstrapKey(["widget_summary:read"]) + const summary = await harness.get(key.secret) + + expect(summary.status).toBe(200) + // Null, not the computed length: a bucket length attached to no points + // invites the client to draw a unit it was never given. + expect(summary.body.throughput.bucket_seconds).toBeNull() + expect(summary.body.throughput.total_points).toEqual([]) + expect(summary.body.throughput.services[0].points).toEqual([]) + // The scalars survive, which is the whole point of degrading in place. + expect(summary.body.throughput.services[0].throughput_per_second).toBe(2) + expect(summary.body.issues.data).toHaveLength(1) + } finally { + await harness.dispose() + } + }) + + it("is fenced to its own scope family", async () => { + const harness = makeHarness({}) + try { + // Everything the widget's data is *composed* from, and still a 403: + // this is what stops a device credential from being an org read key. + const key = await harness.bootstrapKey(["error_issues:read", "services:read", "traces:read"]) + const summary = await harness.get(key.secret) + expect(summary.status).toBe(403) + expect(summary.body.error.message).toContain("widget_summary:read") + } finally { + await harness.dispose() + } + }) + + it("rejects an organization selection that disagrees with the key", async () => { + const harness = makeHarness({}) + try { + const key = await harness.bootstrapKey(["widget_summary:read"]) + const summary = await harness.get(key.secret, { "x-maple-org-id": "org_someone_else" }) + expect(summary.status).toBe(403) + } finally { + await harness.dispose() + } + }) +}) diff --git a/apps/api/src/routes/v2/widget-summary.http.ts b/apps/api/src/routes/v2/widget-summary.http.ts new file mode 100644 index 000000000..6744a1b4f --- /dev/null +++ b/apps/api/src/routes/v2/widget-summary.http.ts @@ -0,0 +1,198 @@ +import { HttpApiBuilder } from "effect/unstable/httpapi" +import { CurrentTenant } from "@maple/domain/http" +import type { V2WidgetSummaryService } from "@maple/domain/http/v2" +import { + MapleApiV2, + timestamp, + WIDGET_SUMMARY_ISSUE_LIMIT, + WIDGET_SUMMARY_ISSUES_WINDOW_SECONDS, + WIDGET_SUMMARY_SCHEMA_VERSION, + WIDGET_SUMMARY_SERIES_LIMIT, + WIDGET_SUMMARY_SERVICE_LIMIT, + WIDGET_SUMMARY_THROUGHPUT_WINDOW_SECONDS, +} from "@maple/domain/http/v2" +import { + CH, + formatWarehouseDateTime, + formatWarehouseDateTimeMs, + QueryEngineExecuteRequest, +} from "@maple/query-engine" +import { computeBucketSeconds } from "@maple/query-engine/runtime" +import { Effect, Schema } from "effect" +import { ErrorIssueReadModelsService } from "@/services/errors/ErrorIssueReadModelsService" +import { QueryEngineService } from "@/services/warehouse/QueryEngineService" +import { WarehouseQueryService } from "@/services/warehouse/WarehouseQueryService" +import { serviceCatalogRowSchema, toService, type ServiceBaselines } from "./telemetry.http" + +/** + * The one read behind the iOS Home Screen widgets. + * + * Composed here rather than by the client, because the client is a WidgetKit + * timeline provider with seconds of wall clock: what used to be four requests + * (`/v2/error_issues`, `/v2/services`, and two `/v2/traces/timeseries`) is one. + * See the contract in `packages/domain/src/http/v2/widget-summary.ts` for why + * this is its own resource family and not a shaped view over those. + * + * The windows are built here from constants, never from the caller — which is + * also why this route validates none of them. `parseWindow` and + * `validateTimeseriesBucket` exist to turn a *caller's* bounds into a 400, and + * wiring them in would put three unreachable telemetry errors into a public + * contract whose only consumer is a widget that cannot act on any of them. + * + * The two halves are not independent the way the app's old publisher made them: + * one response means an issues failure also costs the caller its throughput. + * That is the accepted trade, because the caller renders its last good snapshot + * on any failure — a widget never shows an error, it shows an honest age. + * The *series* are the exception and degrade in place: a sparkline is a nicety + * next to the scalars it sits under, so a timeseries read that fails leaves + * `bucket_seconds` null and the points empty rather than costing the summary. + */ + +/** + * Latency baselines are a service-health signal the widgets do not render, and + * loading them is a second warehouse round-trip per request. `toService` omits + * the baseline fields entirely for a service it has no baseline for, which is + * exactly the shape wanted here. + */ +const NO_BASELINES: ServiceBaselines = new Map() + +const decodeExecuteRequest = Schema.decodeUnknownEffect(QueryEngineExecuteRequest) + +/** + * One group's buckets, oldest first, holes filled with zero. + * + * Counts, not rates: the client divides by `bucket_seconds` so that the + * sparkline and the headline provably carry the same unit, and so a bucket + * length it cannot make sense of drops the series instead of drawing counts as + * if they were rates. + */ +const bucketCounts = ( + data: ReadonlyArray<{ readonly bucket: string; readonly series: Readonly> }>, + group: string | undefined, +): ReadonlyArray => + data.map((point) => { + // An ungrouped series carries exactly one key, whose name the engine + // picks; naming it here would couple this file to that choice. + const value = group === undefined ? Object.values(point.series)[0] : point.series[group] + return Number(value ?? 0) + }) + +export const HttpV2WidgetSummaryLive = HttpApiBuilder.group(MapleApiV2, "widgetSummary", (handlers) => + Effect.gen(function* () { + const readModels = yield* ErrorIssueReadModelsService + const warehouse = yield* WarehouseQueryService + const queryEngine = yield* QueryEngineService + + return handlers.handle("retrieve", () => + Effect.gen(function* () { + const tenant = yield* CurrentTenant.Context + // One clock for the whole response. Two `Date.now()` calls would let + // the issues window and the throughput window describe times that do + // not line up, which is the sort of skew a widget renders as a + // contradiction between its two halves. + const nowMs = Date.now() + const issuesStartMs = nowMs - WIDGET_SUMMARY_ISSUES_WINDOW_SECONDS * 1000 + const throughputStartMs = nowMs - WIDGET_SUMMARY_THROUGHPUT_WINDOW_SECONDS * 1000 + + const issuesPage = yield* readModels.listIssues(tenant.orgId, { + actionable: true, + sort: "severity", + startTime: new Date(issuesStartMs).toISOString(), + endTime: new Date(nowMs).toISOString(), + limit: WIDGET_SUMMARY_ISSUE_LIMIT, + }) + + // Whole seconds: the catalog reads the hourly rollups, whose + // Timestamp is a plain `DateTime` and rejects a fractional literal. + const compiled = CH.compile( + CH.serviceCatalogQuery({ limit: WIDGET_SUMMARY_SERVICE_LIMIT }), + { + orgId: tenant.orgId, + startTime: formatWarehouseDateTime(throughputStartMs), + endTime: formatWarehouseDateTime(nowMs), + }, + { rowSchema: serviceCatalogRowSchema }, + ) + const serviceRows = yield* warehouse.compiledQuery(tenant, compiled, { + profile: "aggregation", + context: "v2WidgetSummaryServices", + }) + + const bucketSeconds = computeBucketSeconds(throughputStartMs, nowMs) + const timeseries = (groupByService: boolean) => + decodeExecuteRequest({ + // Millisecond precision: the series read the raw trace table, + // not the rollups the catalog above reads. + startTime: formatWarehouseDateTimeMs(throughputStartMs), + endTime: formatWarehouseDateTimeMs(nowMs), + query: { + kind: "timeseries", + source: "traces", + metric: "count", + bucketSeconds, + ...(groupByService + ? { groupBy: ["service"], seriesLimit: WIDGET_SUMMARY_SERIES_LIMIT } + : undefined), + }, + }).pipe( + Effect.flatMap((request) => queryEngine.execute(tenant, request)), + Effect.map((response) => + response.result.kind === "timeseries" ? response.result.data : [], + ), + Effect.catchCause((cause) => + Effect.as(Effect.logWarning("widget summary timeseries read failed", cause), []), + ), + ) + + const [grouped, total] = yield* Effect.all([timeseries(true), timeseries(false)], { + concurrency: 2, + }) + // Null rather than the computed length when nothing came back: the + // client divides its points by this, and a bucket length attached to + // no points invites it to draw a unit it was never given. + const seriesBucketSeconds = grouped.length > 0 || total.length > 0 ? bucketSeconds : null + + const rangeSeconds = WIDGET_SUMMARY_THROUGHPUT_WINDOW_SECONDS + const services: ReadonlyArray = serviceRows.map((row) => { + const service = toService(row, rangeSeconds, NO_BASELINES) + return { + name: service.name, + throughput_per_second: service.throughput, + error_rate: service.error_rate, + p95_latency_ms: service.p95_latency_ms, + points: bucketCounts(grouped, row.serviceName), + } + }) + + return { + object: "widget_summary" as const, + schema_version: WIDGET_SUMMARY_SCHEMA_VERSION, + generated_at: timestamp(new Date(nowMs).toISOString()), + organization_id: tenant.orgId, + issues: { + window_seconds: WIDGET_SUMMARY_ISSUES_WINDOW_SECONDS, + has_more: issuesPage.nextCursor !== undefined, + data: issuesPage.issues.map((issue) => ({ + id: issue.id, + exception_type: issue.exceptionType, + error_label: issue.errorLabel, + exception_message: issue.exceptionMessage, + service_name: issue.serviceName, + severity: issue.severity, + occurrence_count: issue.occurrenceCount, + last_seen_at: issue.lastSeenAt, + is_regressed: issue.regressionCount > 0, + has_open_incident: issue.hasOpenIncident, + })), + }, + throughput: { + window_seconds: WIDGET_SUMMARY_THROUGHPUT_WINDOW_SECONDS, + bucket_seconds: seriesBucketSeconds, + services, + total_points: bucketCounts(total, undefined), + }, + } + }), + ) + }), +) diff --git a/apps/api/src/runtime/http-graph.ts b/apps/api/src/runtime/http-graph.ts index 269caf101..c9e776f6d 100644 --- a/apps/api/src/runtime/http-graph.ts +++ b/apps/api/src/runtime/http-graph.ts @@ -56,6 +56,8 @@ import { HttpV2ServicesLive, HttpV2TracesLive, } from "@/routes/v2/telemetry.http" +import { HttpV2WidgetSummaryLive } from "@/routes/v2/widget-summary.http" +import { HttpV2WidgetCredentialsLive } from "@/routes/v2/widget-credentials.http" import { ApiAuthorizationLayer } from "@/services/auth/ApiAuthorizationLayer" import { ApiAuthorizationV2Layer } from "@/services/auth/ApiAuthorizationV2Layer" import { SessionAuthorizationLayer } from "@/services/auth/SessionAuthorizationLayer" @@ -138,6 +140,8 @@ const ApiV2Routes = HttpApiBuilder.layer(MapleApiV2).pipe( HttpV2MetricsLive, HttpV2ServicesLive, HttpV2ServiceMapLive, + HttpV2WidgetSummaryLive, + HttpV2WidgetCredentialsLive, ), ), Layer.provide(V2TransportErrorBoundaryLive), diff --git a/apps/api/src/services/auth/ApiAuthorizationV2Layer.ts b/apps/api/src/services/auth/ApiAuthorizationV2Layer.ts index 7236e6891..10010fa08 100644 --- a/apps/api/src/services/auth/ApiAuthorizationV2Layer.ts +++ b/apps/api/src/services/auth/ApiAuthorizationV2Layer.ts @@ -82,12 +82,29 @@ export const ApiAuthorizationV2Layer = Layer.effect( if (Option.isSome(apiKeyResolved)) { const resolved = apiKeyResolved.value - if (resolved.kind !== "standard") { + // Deny-list, not an allow-list: `mcp` keys are minted through a + // path that does not gate on organization admin, so they must + // never reach the public API. `device` keys are admitted + // because every ceiling they have — scopes, TTL, and the + // pinned roles below — is chosen by the server that minted + // them, not by whatever is holding them. + if (resolved.kind === "mcp") { return yield* Effect.fail( V2InvalidCredentials.make("This API key is only valid for the MCP server."), ) } + // A device credential's authority is entirely its pinned + // roles, and `apiKeyDefaultRoles` below is `root`. A device + // row that reaches here without them is not a key with a + // permissive default — it is a key whose defining property + // is missing, so it is rejected rather than promoted. + if (resolved.kind === "device" && resolved.roles === null) { + return yield* Effect.fail( + V2InvalidCredentials.make("This device credential is not valid."), + ) + } + // Attribute before the scope check so scope-rejected // requests are still counted as API-key traffic. yield* annotateAuthSpan("api_key", { diff --git a/apps/api/src/services/org/ApiKeysService.ts b/apps/api/src/services/org/ApiKeysService.ts index 32db1fbbc..950e6064f 100644 --- a/apps/api/src/services/org/ApiKeysService.ts +++ b/apps/api/src/services/org/ApiKeysService.ts @@ -14,7 +14,7 @@ import { RoleName, } from "@maple/domain/http" import { API_KEY_PREFIX, apiKeys, generateApiKey, hashApiKey, parseIngestKeyLookupHmacKey } from "@maple/db" -import { and, desc, eq, getTableColumns, isNull, lt, or } from "drizzle-orm" +import { and, desc, eq, getTableColumns, isNull, lt, ne, or, sql } from "drizzle-orm" import { Clock, Effect, Layer, Option, Redacted, Schema, Context } from "effect" import { Database } from "@/platform/DatabaseLive" import { readTxid, txidColumn } from "@/platform/electric-txid" @@ -53,6 +53,28 @@ const McpApiKeyMetadata = Schema.Struct({ }) const decodeMcpApiKeyMetadata = Schema.decodeUnknownOption(McpApiKeyMetadata) +/** + * Metadata written when a signed-in app mints a credential for one of its own + * devices. Two fields carry weight: + * + * - `roles` pins the *minting user's* roles onto the key, so a credential that + * lives on a phone can never outrank the human who created it. Without it the + * key would resolve with the `root` default in `ApiAuthorizationV2Layer`, + * fenced only by its scopes. + * - `deviceId` is what makes the credential replaceable: minting is idempotent + * per device, so a reinstall or a roll retires the previous key instead of + * leaving a live one behind on a phone nobody has any more. + */ +const DeviceApiKeyMetadata = Schema.Struct({ + source: Schema.Literal("maple_ios_widget"), + roles: Schema.Array(RoleName), + deviceId: Schema.String, +}) +const decodeDeviceApiKeyMetadata = Schema.decodeUnknownOption(DeviceApiKeyMetadata) + +/** The one `source` a `kind: "device"` key may carry today. */ +export const WIDGET_DEVICE_KEY_SOURCE = "maple_ios_widget" + const McpOAuthApiKeyMetadata = Schema.Struct({ source: Schema.Literal("maple_mcp_oauth"), roles: Schema.Array(RoleName), @@ -62,7 +84,7 @@ const McpOAuthApiKeyMetadata = Schema.Struct({ const decodeMcpOAuthApiKeyMetadata = Schema.decodeUnknownOption(McpOAuthApiKeyMetadata) /** Metadata `source` values that pin roles onto a key. */ -const ROLE_BEARING_SOURCES = ["maple_cli", "maple_mcp", "maple_mcp_oauth"] as const +const ROLE_BEARING_SOURCES = ["maple_cli", "maple_mcp", "maple_mcp_oauth", WIDGET_DEVICE_KEY_SOURCE] as const interface KeyRoleMetadata { readonly roles: ReadonlyArray | null @@ -89,6 +111,10 @@ const readKeyRoleMetadata = (metadata: unknown): Option.Option if (Option.isSome(mcp)) { return Option.some({ roles: mcp.value.roles, cliManaged: false, mcpOAuthResource: null }) } + const device = decodeDeviceApiKeyMetadata(metadata) + if (Option.isSome(device)) { + return Option.some({ roles: device.value.roles, cliManaged: false, mcpOAuthResource: null }) + } const mcpOAuth = decodeMcpOAuthApiKeyMetadata(metadata) if (Option.isSome(mcpOAuth)) { return Option.some({ @@ -167,6 +193,16 @@ export class ApiKeysService extends Context.Service()("@maple/ap return rowToResponse(row) }) + /** + * The organization's keys, **excluding device credentials**. + * + * A device key is not a thing anyone manages from the API-keys screen: it + * is minted, rolled, and revoked by the app that owns the phone, one per + * pinned organization per device. Listing them would put a dozen rows + * nobody created by hand in front of an admin looking for the two they + * did — and inviting them to revoke one just breaks a Home Screen until + * the app next mints again. + */ const list = Effect.fn("ApiKeysService.list")(function* (orgId: OrgId) { yield* Effect.annotateCurrentSpan("orgId", orgId) const rows = yield* database @@ -174,7 +210,7 @@ export class ApiKeysService extends Context.Service()("@maple/ap db .select() .from(apiKeys) - .where(eq(apiKeys.orgId, orgId)) + .where(and(eq(apiKeys.orgId, orgId), ne(apiKeys.kind, "device"))) .orderBy(desc(apiKeys.createdAt)), ) .pipe(Effect.mapError(toPersistenceError)) @@ -252,6 +288,146 @@ export class ApiKeysService extends Context.Service()("@maple/ap }) }) + /** + * Matches the live device credentials this app minted for one device. + * + * Containment (`@>`) rather than three column comparisons because the + * association lives in `metadata_json`; the source is pinned too, so a + * future `kind: "device"` credential for something other than the widgets + * cannot be caught by a widget revoke. + */ + const liveDeviceKeysFor = (orgId: OrgId, deviceId: string) => + and( + eq(apiKeys.orgId, orgId), + eq(apiKeys.kind, "device"), + eq(apiKeys.revoked, false), + sql`${apiKeys.metadataJson} @> ${JSON.stringify({ + source: WIDGET_DEVICE_KEY_SOURCE, + deviceId, + })}::jsonb`, + ) + + /** + * Mint the widget credential for one device, retiring whatever it had. + * + * Every ceiling here is the server's, which is the whole point of a + * dedicated operation rather than `create` with a `kind`: the caller + * names a device and nothing else, so a compromised or simply wrong + * client cannot ask for wider scopes, a longer life, or more authority + * than the human running it. + * + * Idempotent per device: mint and roll are the same call, because the app + * re-mints on its own schedule and a phone that gets a new key must not + * leave the old one live. Revoke-then-insert inside one transaction, in + * that order, for the same reason `roll` does it that way. + */ + const replaceDeviceKey = Effect.fn("ApiKeysService.replaceDeviceKey")(function* ( + orgId: OrgId, + userId: UserId, + params: { + deviceId: string + name: string + scopes: ReadonlyArray + expiresInSeconds: number + roles: ReadonlyArray + createdByEmail?: string | null + }, + ) { + yield* Effect.annotateCurrentSpan({ + orgId, + "tenant.userId": userId, + "maple.device.id": params.deviceId, + }) + const id = decodeApiKeyIdSync(randomUUID()) + const rawKey = generateApiKey() + const keyHash = hashApiKey(rawKey, hmacKey) + const keyPrefix = rawKey.slice(0, 12) + "..." + const now = yield* Clock.currentTimeMillis + const expiresAt = now + params.expiresInSeconds * 1000 + const scopes = [...params.scopes] + const createdByEmail = params.createdByEmail ?? null + + const inserted = yield* database + .execute((db) => + db.transaction(async (tx) => { + await tx + .update(apiKeys) + .set({ revoked: true, revokedAt: msToDate(now) }) + .where(liveDeviceKeysFor(orgId, params.deviceId)) + return await tx + .insert(apiKeys) + .values({ + id, + orgId, + name: params.name, + description: null, + keyHash, + keyPrefix, + kind: "device", + scopes, + expiresAt: msToDate(expiresAt), + createdAt: new Date(now), + createdBy: userId, + createdByEmail, + metadataJson: { + source: WIDGET_DEVICE_KEY_SOURCE, + // The minting session's roles, so the key on the + // phone can never outrank the human who created it. + roles: [...params.roles], + deviceId: params.deviceId, + }, + }) + .returning(txidColumn) + }), + ) + .pipe(Effect.mapError(toPersistenceError)) + const txid = readTxid(inserted) + + return new ApiKeyCreatedResponse({ + id, + name: params.name, + description: null, + keyPrefix, + kind: "device", + scopes, + revoked: false, + revokedAt: null, + lastUsedAt: null, + expiresAt, + createdAt: now, + createdBy: userId, + createdByEmail, + secret: rawKey, + ...(txid !== undefined ? { txid } : undefined), + }) + }) + + /** + * Retire one device's widget credentials. Called on sign-out, when the + * user leaves the organization, and when the widget is unpinned — the + * Home Screen outlives the session, and so would the key. + * + * Returns how many it retired, so a caller can tell "revoked" from + * "there was nothing to revoke" without a second read. + */ + const revokeDeviceKeys = Effect.fn("ApiKeysService.revokeDeviceKeys")(function* ( + orgId: OrgId, + deviceId: string, + ) { + yield* Effect.annotateCurrentSpan({ orgId, "maple.device.id": deviceId }) + const now = yield* Clock.currentTimeMillis + const revokedRows = yield* database + .execute((db) => + db + .update(apiKeys) + .set({ revoked: true, revokedAt: msToDate(now) }) + .where(liveDeviceKeysFor(orgId, deviceId)) + .returning({ id: apiKeys.id }), + ) + .pipe(Effect.mapError(toPersistenceError)) + return revokedRows.length + }) + const roll = Effect.fn("ApiKeysService.roll")(function* ( orgId: OrgId, userId: UserId, @@ -498,6 +674,8 @@ export class ApiKeysService extends Context.Service()("@maple/ap resolveByKey, resolveByBearer, touchLastUsed, + replaceDeviceKey, + revokeDeviceKeys, } }), }) { diff --git a/apps/api/src/services/push/MobilePushService.test.ts b/apps/api/src/services/push/MobilePushService.test.ts index d8b191ac9..f7cd2cd0e 100644 --- a/apps/api/src/services/push/MobilePushService.test.ts +++ b/apps/api/src/services/push/MobilePushService.test.ts @@ -2,7 +2,13 @@ import { assert, describe, expect, it } from "@effect/vitest" import { encodePublicId } from "@maple/domain/http/v2" import { MobileDeviceId, OrgId, UserId } from "@maple/domain/primitives" import { Effect, Layer, Schema } from "effect" -import { ApnsClient, type ApnsLiveActivityPush, type ApnsPush, type ApnsSendResult } from "@/platform/Apns" +import { + ApnsClient, + type ApnsBackgroundPush, + type ApnsLiveActivityPush, + type ApnsPush, + type ApnsSendResult, +} from "@/platform/Apns" import { LiveActivitiesService, type LiveActivity } from "./LiveActivitiesService" import { MobileDevicesService, type MobileDevice } from "./MobileDevicesService" import { @@ -83,6 +89,9 @@ interface LiveActivityRecorder { readonly ended: Array } +/** Every silent widget wake-up a run produced. */ +const background: Array = [] + const makeLayer = ( devices: ReadonlyArray, sendImpl: (push: ApnsPush) => ApnsSendResult, @@ -109,6 +118,10 @@ const makeLayer = ( live.pushes.push(push) return Effect.succeed(liveSendImpl(push)) }, + sendBackground: (push) => { + background.push(push) + return Effect.succeed({ outcome: "sent" as const, apnsId: null }) + }, }), Layer.succeed(MobileDevicesService, { register: () => Effect.die("unused"), @@ -540,3 +553,68 @@ describe("MobilePushService live activities", () => { ) }) }) + +describe("MobilePushService widget refresh", () => { + it.effect("wakes every registered phone, silently, collapsed per organization", () => { + background.length = 0 + const sent: Array = [] + const devices = [ + device(1), + // Preferences say which events are worth *interrupting* someone for. A + // widget refresh interrupts nobody, so it goes to this phone too. + device(2, { preferences: { ...device(2).preferences, criticalIncidents: false } }), + device(3, { environment: "sandbox" }), + ] + return Effect.gen(function* () { + const push = yield* MobilePushService + const summary = yield* push.refreshWidgets(ORG, "manual") + assert.deepStrictEqual(summary, { sent: 3, failed: 0, unregistered: 0, skipped: 0 }) + assert.deepStrictEqual( + background.map((p) => [p.deviceToken, p.environment, p.collapseId]), + [ + ["token-1", "production", `widget-refresh:${ORG}`], + ["token-2", "production", `widget-refresh:${ORG}`], + ["token-3", "sandbox", `widget-refresh:${ORG}`], + ], + ) + assert.deepStrictEqual(background[0]!.data, { + maple_kind: "widget_refresh", + maple_org_id: ORG, + maple_reason: "manual", + }) + }).pipe(Effect.provide(makeLayer(devices, () => ({ outcome: "sent", apnsId: null }), sent, []))) + }) + + it.effect("sends nothing when APNs is not configured", () => { + background.length = 0 + return Effect.gen(function* () { + const push = yield* MobilePushService + const summary = yield* push.refreshWidgets(ORG, "manual") + assert.deepStrictEqual(summary, { sent: 0, failed: 0, unregistered: 0, skipped: 0 }) + assert.strictEqual(background.length, 0) + }).pipe( + Effect.provide(makeLayer([device(1)], () => ({ outcome: "sent", apnsId: null }), [], [], false)), + ) + }) + + it.effect("rides along with an incident that actually pushed", () => { + background.length = 0 + const sent: Array = [] + return Effect.gen(function* () { + const push = yield* MobilePushService + yield* push.notifyIncident(event()) + assert.strictEqual(background.length, 1) + assert.strictEqual(background[0]!.data.maple_reason, "incident_trigger") + }).pipe(Effect.provide(makeLayer([device(1)], () => ({ outcome: "sent", apnsId: null }), sent, []))) + }) + + it.effect("never lets somebody trying a rule out refresh real Home Screens", () => { + background.length = 0 + const sent: Array = [] + return Effect.gen(function* () { + const push = yield* MobilePushService + yield* push.notifyIncident(event({ eventType: "test" })) + assert.strictEqual(background.length, 0) + }).pipe(Effect.provide(makeLayer([device(1)], () => ({ outcome: "sent", apnsId: null }), sent, []))) + }) +}) diff --git a/apps/api/src/services/push/MobilePushService.ts b/apps/api/src/services/push/MobilePushService.ts index ec5752dc9..bd6c06cc1 100644 --- a/apps/api/src/services/push/MobilePushService.ts +++ b/apps/api/src/services/push/MobilePushService.ts @@ -122,6 +122,14 @@ export interface IncidentDigestPushEvent { export interface MobilePushServiceApi { readonly notifyIncident: (event: IncidentPushEvent) => Effect.Effect readonly notifyIncidentDigest: (event: IncidentDigestPushEvent) => Effect.Effect + /** + * Wake the organization's phones so their Home Screen widgets can refresh. + * + * Silent — `content-available` and nothing else. It is not a notification + * and must never look like one: the user did not ask to be told anything, + * they asked for a widget that is not hours out of date. + */ + readonly refreshWidgets: (orgId: OrgId, reason: string) => Effect.Effect } const SEND_CONCURRENCY = 8 @@ -569,6 +577,18 @@ export class MobilePushService extends Context.Service + Effect.logWarning( + "Mobile push: could not list devices for a widget refresh", + ).pipe( + Effect.annotateLogs({ orgId, error: error.message }), + Effect.as([] as ReadonlyArray), + ), + ), + ) + if (registered.length === 0) return empty + + const results = yield* Effect.forEach( + registered, + (device) => + apns + .sendBackground({ + deviceToken: device.token, + environment: device.environment, + bundleId: device.bundleId, + // One pending wake-up per organization is all anyone + // needs: they all mean the same thing, and the widget + // re-reads everything when it refreshes. + collapseId: `widget-refresh:${orgId}`, + // A wake-up that arrives after the numbers have moved on + // again is a wasted radio. + expiresInSeconds: 600, + data: { + maple_kind: "widget_refresh", + maple_org_id: orgId, + maple_reason: reason, + }, + }) + .pipe( + Effect.timeout(SEND_TIMEOUT), + Effect.map((result) => ({ device, result })), + Effect.catch((error) => + Effect.succeed({ + device, + result: { + outcome: "failed" as const, + status: 0, + reason: error._tag === "TimeoutError" ? "timeout" : error.message, + retryable: true, + }, + }), + ), + ), + { concurrency: SEND_CONCURRENCY }, + ) + + let sent = 0 + let failed = 0 + let unregistered = 0 + for (const { device, result } of results) { + switch (result.outcome) { + case "sent": + sent += 1 + break + case "unregistered": + unregistered += 1 + yield* devices.disable(device.id, result.reason).pipe( + ignoreLogged("Mobile push: could not disable a dead device", { + orgId, + deviceId: device.id, + }), + ) + break + case "failed": + // Quieter than the alert path on purpose: nobody is waiting + // on this, and a failed wake-up costs a widget some + // freshness, not a missed page. + failed += 1 + break + } + } + yield* Effect.annotateCurrentSpan({ + "maple.push.sent": sent, + "maple.push.failed": failed, + "maple.push.unregistered": unregistered, + }) + // Nothing is `skipped` here — preferences do not apply to a refresh. + return { sent, failed, unregistered, skipped: 0 } + }) + /** * The Lock Screen half of a critical incident. * @@ -811,7 +958,7 @@ export class MobilePushService extends Context.Service WidgetCredential { + try await pause() + return WidgetCredential( + organizationId: FixtureSession.organizationId, + secret: "maple_ak_fixture", + apiBaseURL: URL(string: "https://fixtures.maple.invalid")!, + expiresAt: now.addingTimeInterval(30 * 24 * 60 * 60), + mintedAt: now + ) + } + + func revokeWidgetCredential(installationId: String) async throws { + try await pause() + } + + /// Assembled from the same seeds the rest of the fixtures use, so a Home + /// Screen screenshot and the Services tab behind it agree. + func widgetSummary() async throws -> WidgetSummaryPayload { + try await pause() + let bucketSeconds = 300 + let window = TimeWindow.lastHour.resolve(now: now) + return WidgetSummaryPayload( + schemaVersion: WidgetSummaryPayload.supportedSchemaVersion, + generatedAt: now, + organizationId: FixtureSession.organizationId, + issues: WidgetSummaryPayload.Issues( + windowSeconds: 24 * 60 * 60, + hasMore: false, + data: issues.map { issue in + WidgetSummaryPayload.Issue( + id: issue.id, + exceptionType: issue.exceptionType, + errorLabel: issue.errorLabel, + exceptionMessage: issue.exceptionMessage, + serviceName: issue.serviceName, + severity: issue.severity?.rawValue, + occurrenceCount: issue.occurrenceCount, + lastSeenAt: ResolvedTimeWindow.parse(issue.lastSeenAt) ?? now, + isRegressed: issue.regressionCount > 0, + hasOpenIncident: issue.hasOpenIncident + ) + } + ), + throughput: WidgetSummaryPayload.Throughput( + windowSeconds: Int(window.end.timeIntervalSince(window.start)), + bucketSeconds: bucketSeconds, + services: Self.seeds.map { seed in + WidgetSummaryPayload.Service( + name: seed.name, + throughputPerSecond: seed.throughput, + errorRate: seed.errorRate, + p95LatencyMs: seed.p95, + // Counts, not rates — the wire's unit. The mapper divides. + points: (0..<12).map { index in + seed.throughput * Double(bucketSeconds) * (index.isMultiple(of: 2) ? 0.92 : 1.08) + } + ) + }, + totalPoints: (0..<12).map { index in + Self.seeds.reduce(0) { total, seed in + total + seed.throughput * Double(bucketSeconds) * (index.isMultiple(of: 2) ? 0.92 : 1.08) + } + } + ) + ) + } + // MARK: Telemetry func traceTimeseries(_ request: TraceTimeseriesRequest) async throws -> TraceTimeseriesResult { diff --git a/apps/ios/Maple/Push/PushRegistrar.swift b/apps/ios/Maple/Push/PushRegistrar.swift index 26cf31843..9d5860506 100644 --- a/apps/ios/Maple/Push/PushRegistrar.swift +++ b/apps/ios/Maple/Push/PushRegistrar.swift @@ -4,6 +4,7 @@ import MapleAPI import MapleWidgetData import Observation import UIKit +import WidgetKit import UserNotifications /// Owns everything push: the permission, the APNs token, keeping the server's @@ -247,13 +248,27 @@ final class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCent /// Runs for a silent (`content-available`) push and for a visible one /// delivered while the app has background time; iOS ignores the completion /// result beyond deciding how generous to be next time. + /// + /// Two shapes, and the cheap one is the common one. A push whose only job is + /// to refresh the Home Screen just reloads the timelines: the widget holds + /// its own credential now and fetches for itself, so a `reloadAllTimelines` + /// buys the same freshness as a full publish for a fraction of the few + /// seconds iOS grants — and background time spent here is background time + /// iOS is deciding whether to keep granting. func application( _ application: UIApplication, didReceiveRemoteNotification userInfo: [AnyHashable: Any], fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void ) { + let isWidgetRefresh = userInfo["maple_kind"] as? String == "widget_refresh" Task { @MainActor in - await WidgetPublisher.shared.refresh(trigger: .push, force: true) + if isWidgetRefresh { + WidgetCenter.shared.reloadAllTimelines() + } else { + // An alert push also renews credentials and warms the snapshots, + // which the widget cannot do for itself. + await WidgetPublisher.shared.refresh(trigger: .push, force: true) + } completionHandler(.newData) } } diff --git a/apps/ios/Maple/Telemetry/Telemetry.swift b/apps/ios/Maple/Telemetry/Telemetry.swift index ab73c036d..8cbbc3457 100644 --- a/apps/ios/Maple/Telemetry/Telemetry.swift +++ b/apps/ios/Maple/Telemetry/Telemetry.swift @@ -48,6 +48,8 @@ enum Telemetry { static let widgetRefresh = "widget.refresh" /// One of the two Home Screen surfaces, inside a refresh. static let widgetSnapshot = "widget.snapshot" + /// Minting or rolling the credential the widget extension fetches with. + static let widgetCredential = "widget.credential" static let liveActivitySubmit = "live_activity.submit" static let liveActivityEnd = "live_activity.end" } @@ -92,6 +94,20 @@ enum Telemetry { /// The round reloaded because the widgets would resolve a *different* /// organization or name, not because any snapshot's numbers moved. static let widgetResolutionChanged = "maple.app.widget.resolution_changed" + + // What the widget extension recorded about its *own* fetches. The + // extension links no telemetry — it carries MapleWidgetData and nothing + // else — so without these the path that actually keeps the Home Screen + // current is completely unobservable, which is the failure this whole + // change exists to fix. Drained by WidgetPublisher on the next round. + /// How the extension's last fetch ended: success, unauthorized, … + static let widgetFetchOutcome = "maple.app.widget.fetch.outcome" + /// Seconds since the extension last fetched successfully. Absent when it + /// never has, which is a different statement from "a long time ago". + static let widgetFetchAgeSeconds = "maple.app.widget.fetch.age_seconds" + static let widgetFetchFailures = "maple.app.widget.fetch.consecutive_failures" + /// The extension has stopped fetching until the app mints again. + static let widgetFetchCredentialRejected = "maple.app.widget.fetch.credential_rejected" /// How the publisher got its session: the view tree, or a headless /// bootstrap in a background launch. static let widgetContextSource = "maple.app.widget.context_source" diff --git a/apps/ios/Maple/Widgets/AppInstallation.swift b/apps/ios/Maple/Widgets/AppInstallation.swift new file mode 100644 index 000000000..4a731c69e --- /dev/null +++ b/apps/ios/Maple/Widgets/AppInstallation.swift @@ -0,0 +1,37 @@ +import Foundation +import UIKit + +/// A stable identifier for this installation of the app. +/// +/// It exists to answer one question for the server: "which credential does a +/// re-mint replace?" Nothing else — it is never a user identity, it never +/// leaves this device except as that path parameter, and it is opaque to Maple. +/// +/// Deliberately **not** the APNs device token, which was the obvious choice +/// because push registration already establishes a device row. A user who +/// declines notifications has no APNs token and still pins widgets, so keying +/// on it would have quietly meant "no widget refresh unless you also accept +/// alerts" — two permissions that have nothing to do with each other. +enum AppInstallation { + private static let key = "installation.id" + + /// `identifierForVendor` where the system has one, and a generated UUID + /// where it does not. + /// + /// The fallback matters more than it looks: `identifierForVendor` is nil + /// before the first unlock after a reboot, which is exactly when a widget + /// might be rebuilding. Persisting whichever value is resolved first keeps + /// one installation from minting a second credential — and orphaning its + /// first — just because it happened to ask at a bad moment. + /// + /// It also resets when the last app from this vendor is deleted, which is + /// the right behaviour: a reinstall is a new installation, and the + /// credential the old one held expires on its own. + static var identifier: String { + let defaults = UserDefaults.standard + if let stored = defaults.string(forKey: key), !stored.isEmpty { return stored } + let identifier = UIDevice.current.identifierForVendor?.uuidString ?? UUID().uuidString + defaults.set(identifier, forKey: key) + return identifier + } +} diff --git a/apps/ios/Maple/Widgets/WidgetPublisher.swift b/apps/ios/Maple/Widgets/WidgetPublisher.swift index 30badfcae..f13d11eec 100644 --- a/apps/ios/Maple/Widgets/WidgetPublisher.swift +++ b/apps/ios/Maple/Widgets/WidgetPublisher.swift @@ -14,6 +14,14 @@ import WidgetKit /// process that holds a session — fetches and writes; the widgets only render. /// See `IssuesSnapshot` and `ThroughputSnapshot`. /// +/// One organization costs **one request**, `GET /v2/widget_summary`. It used to +/// cost four, composed here, and the composition was the problem: a +/// `BGAppRefreshTask` gets tens of seconds, and four round-trips per +/// organization is how a background round runs out of them having written +/// nothing. The shape that comes back — `WidgetSummaryPayload` — is the same +/// one the widgets will decode for themselves, so the app and the Home Screen +/// cannot drift apart about what a row says. +/// /// Refreshes are driven from four places, which between them cover every way a /// phone is used: /// @@ -36,20 +44,12 @@ import WidgetKit final class WidgetPublisher { static let shared = WidgetPublisher() - /// Ongoing means what the app's "Needs attention" filter means, over the - /// same day Home considers recent: an issue nobody has actioned that is - /// still happening. Without the window a months-dead issue in `triage` - /// would sit on someone's Home Screen forever. - private static let issuesWindow = TimeWindow.last24Hours - /// Throughput is a "right now" number, so it uses Home's rate window — - /// same hour, same figures as the Services tab. - private static let throughputWindow = TimeWindow.lastHour - /// Enough to make `openCount` meaningful and to be sure the six rows shown - /// are the six worst; beyond that the widget renders "20+". - private static let issueFetchLimit = 20 - /// The picker shows at most `ThroughputSnapshot.maximumServices`, but the - /// org total is summed from every service, so this is deliberately wider. - private static let serviceFetchLimit = 50 + /// The windows, the page sizes, and the ranking all belong to + /// `/v2/widget_summary` now. They used to live here as five constants that + /// had to agree with what the widgets rendered — "ongoing" and "right now" + /// are product definitions, and two App Store builds holding different + /// opinions about them is exactly the drift the endpoint removes. + /// /// Foreground, push, and background refresh can all fire within a second /// of each other. One round per minute is plenty for a surface iOS redraws /// every fifteen. @@ -57,18 +57,22 @@ final class WidgetPublisher { /// 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 + /// Publishing every membership would fetch for a dozen 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`. + /// It also bounds the credentials a round mints, which is the more important + /// ceiling now: each one is a bearer token that then has to be revoked. 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: WidgetOrganizationIndex + /// Where the widgets' own credential lives — a file in the shared App Group + /// container, written here and read by the extension. + private let credentials = WidgetCredentialStore() + /// What the extension records about its own fetches — the only way to see a + /// path that has no telemetry of its own. Read on the way past; written by + /// the widget. + private let fetchStates = WidgetFetchStateStore() private var lastRefreshedAt: Date? /// Something other than a snapshot's contents changed what the widgets /// would render — a corrected name, or a newly known organization. Set @@ -212,9 +216,21 @@ final class WidgetPublisher { // organization switch, and `reloadAllTimelines` spends the widget refresh // budget iOS is metering. guard !evicted.isEmpty else { return } + let api = context?.api + let installationId = AppInstallation.identifier for organizationId in evicted { WidgetSnapshotStore.issues(organizationId: organizationId).clear() WidgetSnapshotStore.throughput(organizationId: organizationId).clear() + credentials.clear(organizationId: organizationId) + fetchStates.clear(organizationId: organizationId) + // Server-side too, and not only locally: deleting the file stops this + // phone using the credential, but the credential itself would stay live + // until it expired. Best effort — it is bound to an organization the + // user has just left, so a failure here is a token that outlives its + // usefulness by up to a month, not one that outlives the membership. + if let api { + Task { try? await api.scoped(to: organizationId).revokeWidgetCredential(installationId: installationId) } + } } WidgetCenter.shared.reloadAllTimelines() } @@ -276,29 +292,17 @@ final class WidgetPublisher { ) } - // 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. + // Sequential, which it did not used to be. + // + // A round was two organizations in flight at a time, in pairs, because + // one organization cost four requests and three of them cost twelve. + // One organization is now one request, so the whole round is at most + // three — and a plain loop is worth more than the overlap: it keeps + // everything on the main actor, which is where `Context` and the + // organization index already live. var outcome = RoundOutcome() - // `cursor`, not `index`: `self.index` is the organization index and - // is read again below, and a shadow here would resolve to an `Int`. - var cursor = rounds.startIndex - while cursor < rounds.endIndex { - let first = rounds[cursor] - let second = rounds.indices.contains(cursor + 1) ? rounds[cursor + 1] : nil - cursor += Self.maximumConcurrentOrganizations - - async let firstDone = self.publish(first) - if let second { - async let secondDone = self.publish(second) - let (left, right) = await (firstDone, secondDone) - outcome.merge(left) - outcome.merge(right) - } else { - outcome.merge(await firstDone) - } + for round in rounds { + outcome.merge(await self.publish(round)) } // **One reload per kind, per round, and only when something a reader @@ -323,6 +327,9 @@ final class WidgetPublisher { outcome.issuesChanged = outcome.issuesChanged || resolutionMoved outcome.throughputChanged = outcome.throughputChanged || resolutionMoved + await self.ensureCredentials(for: organizations, context: context) + self.drainFetchState(for: context.active.id, onto: span) + var reloads = 0 if outcome.issuesChanged { WidgetCenter.shared.reloadTimelines(ofKind: IssuesWidgetKind.identifier) @@ -366,12 +373,10 @@ final class WidgetPublisher { let isActive: Bool } - /// One organization's round: both surfaces, then record it in the index the - /// widget extension reads. + /// One organization's round: one request covering both surfaces, then record + /// it in the index the widget extension reads. private func publish(_ round: PublishRound) async -> (issues: PublishOutcome, throughput: PublishOutcome) { - async let issues = refreshIssues(round.organization, api: round.api) - async let throughput = refreshThroughput(round.organization, api: round.api) - let outcome = await (issues: issues, throughput: throughput) + let outcome = await publishSummary(round.organization, api: round.api) // Only a round that actually published stamps the time. It used to // stamp unconditionally, which made a repeatedly failing organization @@ -390,6 +395,93 @@ final class WidgetPublisher { return outcome } + /// Put the widget extension's own last fetch on this round's span. + /// + /// The extension is otherwise invisible: it links `MapleWidgetData` and + /// nothing else, so it has no tracer, and a fetch that fails there fails in + /// complete silence — which is precisely the shape of the bug that left the + /// Home Screen frozen before any of this. The app is the only process here + /// that can reach a collector, so it reads what the widget wrote and says it + /// out loud. + /// + /// The active organization only: a round already carries one organization id, + /// and fanning these attributes across three would make them unreadable. + private func drainFetchState(for organizationId: String, onto span: Span?) { + let state = fetchStates.load(organizationId: organizationId) + guard let outcome = state.lastOutcome else { return } + span?.setAttribute(Telemetry.Key.widgetFetchOutcome, outcome.rawValue) + span?.setAttribute(Telemetry.Key.widgetFetchFailures, state.consecutiveFailures) + span?.setAttribute(Telemetry.Key.widgetFetchCredentialRejected, state.isCredentialRejected) + // Absent rather than zero when the extension has never succeeded: "it has + // not managed one yet" and "the last one was just now" must not read the + // same. + if let lastSuccessAt = state.lastSuccessAt { + span?.setAttribute( + Telemetry.Key.widgetFetchAgeSeconds, + Int(Date().timeIntervalSince(lastSuccessAt)) + ) + } + } + + /// Make sure every organization this round covered has a live credential for + /// its widgets to fetch with. + /// + /// Lazy, and only for organizations a round actually covered — which is to + /// say the active one plus what is pinned. Minting for every membership + /// would scatter bearer tokens across organizations nobody put on a Home + /// Screen, each of which then has to be revoked. + /// + /// Renewal is the app's job and only the app's: a widget credential does not + /// carry the scope to mint, so it cannot extend its own life. That is why the + /// renewal window is a week — a phone opened at weekends must not be one bad + /// Monday from a Home Screen that has gone quiet with no way back. + private func ensureCredentials(for organizations: [WidgetOrganization], context: Context) async { + let now = Date() + let installationId = AppInstallation.identifier + for organization in organizations { + let stored = credentials.load(organizationId: organization.id) + guard stored == nil || stored?.needsRenewal(at: now) == true else { continue } + await Telemetry.span( + Telemetry.Name.widgetCredential, + attributes: [Telemetry.Key.organizationId: .string(organization.id)] + ) { span in + do { + let credential = try await context.api + .scoped(to: organization.id) + .mintWidgetCredential(installationId: installationId) + // The server binds a credential to the organization it was minted + // in and cannot be asked for another. A disagreement here would + // file one organization's key under another's name, which stays + // invisible until a widget renders the wrong numbers. + guard credential.organizationId == organization.id else { + span?.setStatus(.error("credential organization mismatch")) + return + } + guard self.credentials.save(credential) else { + span?.setStatus(.error("credential not stored")) + return + } + // A widget that met a 401 stopped fetching on purpose — a + // rolled credential answers 401 forever, and retrying would + // spend the whole refresh budget on failures. This is the only + // thing that can lift that, so it has to happen here and be + // followed by a reload: otherwise the widget sits on its + // backoff for hours with a perfectly good credential beside it. + self.fetchStates.clearCredentialRejection(organizationId: organization.id) + span?.setAttribute(Telemetry.Key.widgetChanged, true) + WidgetCenter.shared.reloadAllTimelines() + } catch is CancellationError { + } catch { + // Silent, like everything else here. A failed mint leaves the + // previous credential in place until it expires, and the widgets + // keep rendering what the app published — which is what they did + // before they could fetch at all. + span?.setStatus(.error("credential not minted")) + } + } + } + } + /// Which organizations this round covers. /// /// Driven by what is actually on a Home Screen, not by the membership list: @@ -403,27 +495,23 @@ final class WidgetPublisher { trigger: Trigger ) async -> (organizations: [WidgetOrganization], pinnedCount: Int) { let pinned = await pinnedOrganizationIds() - // Read once. Inside the comparator this decoded the whole index from - // UserDefaults on every comparison. - // Never-published organizations are simply absent, so they fall to - // `.distantPast` below and sort first — which is what a newly pinned - // organization with an empty widget needs. - let publishedAt = Dictionary( - index.load().compactMap { organization in - organization.lastPublishedAt.map { (organization.id, $0) } - }, - 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 { - publishedAt[$0.id] ?? .distantPast < publishedAt[$1.id] ?? .distantPast - } + let others = context.memberships.filter { + $0.id != context.active.id && pinned.contains($0.id) + } - // A `BGAppRefreshTask` gets tens of seconds; twelve requests inside one - // is how the whole chain gets deprioritized. + // There used to be an oldest-first ordering here, so a background round + // that could only afford one extra organization round-robined rather than + // starving one. It has been removed rather than kept: `lastPublishedAt` is + // stamped by this file alone, and the widget extension now refreshes an + // organization without touching it — so the ordering would rank an + // organization the widget has been keeping perfectly current as the most + // starved one in the list. A stale sort key is worse than none. + // + // The budget survives for a different reason than it was written for. One + // organization is one request now, not four, so this is no longer about a + // `BGAppRefreshTask` running out of time; it is that a warm publish for an + // organization nobody pinned is battery spent to make iOS trust the app + // less. let budget = trigger == .background ? 1 : Self.maximumOrganizations - 1 return ([context.active] + others.prefix(budget), pinned.count) } @@ -455,18 +543,22 @@ final class WidgetPublisher { /// what the UI deliberately swallows. private func snapshot( _ surface: String, - _ body: @MainActor @Sendable @escaping () async -> PublishOutcome - ) async -> PublishOutcome { + _ body: @MainActor @Sendable @escaping () async -> (issues: PublishOutcome, throughput: PublishOutcome) + ) async -> (issues: PublishOutcome, throughput: PublishOutcome) { await Telemetry.span( Telemetry.Name.widgetSnapshot, attributes: [Telemetry.Key.widgetSurface: .string(surface)] ) { span in let outcome = await body() - span?.setStatus(outcome == .failed ? .error("snapshot not published") : .ok) + let failed = outcome.issues == .failed && outcome.throughput == .failed + span?.setStatus(failed ? .error("snapshot not published") : .ok) // The other half of the reload-budget story: a round of all-`false` // here is the widget correctly staying put, not the publisher // failing, and the two are indistinguishable without this. - span?.setAttribute(Telemetry.Key.widgetChanged, outcome == .changed) + span?.setAttribute( + Telemetry.Key.widgetChanged, + outcome.issues == .changed || outcome.throughput == .changed + ) return outcome } } @@ -474,11 +566,23 @@ final class WidgetPublisher { /// Sign-out. The widgets outlive the session, so the previous account's /// failures and traffic must not stay legible on the lock screen. func clear() { + // Captured before the context goes: revoking needs the session that is + // about to end, and a credential is the one thing here that stays usable + // after sign-out if nobody tells the server. + let api = context?.api + let installationId = AppInstallation.identifier context = nil lastRefreshedAt = nil + // The local copies go first and unconditionally. Whatever the network + // does, this phone must stop being able to fetch as the previous account. + credentials.clearAll() // 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() { + fetchStates.clear(organizationId: organizationId) + if let api { + Task { try? await api.scoped(to: organizationId).revokeWidgetCredential(installationId: installationId) } + } WidgetSnapshotStore.issues(organizationId: organizationId).clear() WidgetSnapshotStore.throughput(organizationId: organizationId).clear() } @@ -490,143 +594,80 @@ final class WidgetPublisher { WidgetCenter.shared.reloadAllTimelines() } - // MARK: Issues + // MARK: Publishing - private func refreshIssues(_ organization: WidgetOrganization, api: any MapleAPI) async -> PublishOutcome { - await snapshot("issues") { await self.publishIssues(organization, api: api) } + /// One organization's round: one request, then both snapshots. + /// + /// The two surfaces are no longer independent — one response means an issues + /// failure costs throughput too. That is the trade the single request buys, + /// and it is a small one: a failed round leaves both stored snapshots in + /// place and the widgets age them honestly, which is what they did for + /// whichever half failed before. + private func publishSummary( + _ organization: WidgetOrganization, + api: any MapleAPI + ) async -> (issues: PublishOutcome, throughput: PublishOutcome) { + await snapshot("summary") { + let failed = (issues: PublishOutcome.failed, throughput: PublishOutcome.failed) + guard let payload = try? await api.widgetSummary() else { return failed } + // A payload from a newer server may have changed what an existing + // field *means*, which is the one thing a tolerant decoder cannot + // absorb. Keep the last good snapshots rather than render it. + guard payload.isSupported else { return failed } + // The scoped client names the organization in a header and the server + // echoes back the one it resolved. A disagreement means this payload + // would be written under the wrong organization's key — the same + // class of error as opening the wrong organization from a + // notification, and just as invisible once it has happened. + guard payload.organizationId == organization.id else { return failed } + return await self.store(payload, for: organization) + } } - private func publishIssues(_ organization: WidgetOrganization, api: any MapleAPI) async -> PublishOutcome { - guard - let page = try? await api.issues( - query: IssueQuery(actionableOnly: true, sort: .severity), - window: Self.issuesWindow.resolve(), - limit: Self.issueFetchLimit, - cursor: nil - ) - else { return .failed } + /// Both snapshots from one payload. Returns per-surface outcomes so the + /// reload budget is still spent per widget kind. + private func store( + _ payload: WidgetSummaryPayload, + for organization: WidgetOrganization + ) async -> (issues: PublishOutcome, throughput: PublishOutcome) { + // The name comes from the caller (resolved against the membership index), + // never from the payload — see the endpoint's own note on why it carries + // no name. + let issues = payload.issuesSnapshot(organizationName: organization.name) + let throughput = payload.throughputSnapshot() + + let issuesStore = WidgetSnapshotStore.issues(organizationId: organization.id) + let throughputStore = WidgetSnapshotStore.throughput(organizationId: organization.id) + // Read before writing: the reload decision is "does this differ from what + // is on screen", and after the save there is nothing to compare to. + let storedIssues = issuesStore.load() + let storedThroughput = throughputStore.load() - let now = Date() - let snapshot = IssuesSnapshot.make( - organizationId: organization.id, - organizationName: organization.name, - generatedAt: now, - issues: page.items.map(WidgetIssue.init(issue:)), - hasMore: page.hasMore - ) - let store = WidgetSnapshotStore.issues(organizationId: organization.id) - // Read before writing: the reload decision is "does this differ from - // what is on screen", and after the save there is nothing to compare to. - let stored = store.load() // Saved unconditionally even when nothing changed, so `generatedAt` - // advances and the widget's footer is honest the next time it is built + // advances and the footers are honest the next time a widget is built // for any reason. - guard store.save(snapshot) else { return .failed } - return WidgetReloadDecision.shouldReload( - stored: stored, - incoming: snapshot, - storedIsStale: stored?.isStale(at: now) ?? false - ) ? .changed : .unchanged - } - - // MARK: Throughput - - private func refreshThroughput(_ organization: WidgetOrganization, api: any MapleAPI) async -> PublishOutcome { - await snapshot("throughput") { await self.publishThroughput(organization, api: api) } - } - - private func publishThroughput(_ organization: WidgetOrganization, api: any MapleAPI) async -> PublishOutcome { - 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 = api.services(window: window, limit: Self.serviceFetchLimit) - async let groupedTask = api.traceTimeseries( - TraceTimeseriesRequest( - aggregation: .count, - window: window, - groupBy: .service, - seriesLimit: ThroughputSnapshot.maximumServices - ) - ) - async let totalTask = api.traceTimeseries( - TraceTimeseriesRequest(aggregation: .count, window: window) - ) - - guard let services = try? await servicesTask.items else { return .failed } - let grouped = try? await groupedTask - let total = try? await totalTask - - let rows = services.map { service in - ServiceThroughput( - name: service.name, - throughputPerSecond: service.throughput, - errorRate: service.errorRate, - p95LatencyMs: service.p95LatencyMs, - points: Self.perSecond(grouped?.valuesByGroup[service.name] ?? [], bucketSeconds: grouped?.bucketSeconds) - ) - } - - // The total's *numbers* come from the service list (every service, not - // just the charted ones); only its shape comes from the ungrouped - // series, which is the one thing the list cannot provide. - var overall = ServiceThroughput.total(of: rows) - if let total { - overall.points = Self.perSecond(total.values, bucketSeconds: total.bucketSeconds) - } - - let now = Date() - let snapshot = ThroughputSnapshot.make( - organizationId: organization.id, - generatedAt: now, - windowMinutes: Int(Self.throughputWindow.duration / 60), - services: rows, - overall: overall - ) - let store = WidgetSnapshotStore.throughput(organizationId: organization.id) - let stored = store.load() - guard store.save(snapshot) else { return .failed } - // Throughput is the surface where suppression earns the most: its floats - // differ on every fetch, but `contentFingerprint` compares the rendered - // strings, so a rate that still reads "12.5/s" costs nothing. - return WidgetReloadDecision.shouldReload( - stored: stored, - incoming: snapshot, - storedIsStale: stored?.isStale(at: now) ?? false - ) ? .changed : .unchanged - } - - /// Spans per bucket → spans per second, so the sparkline carries the same - /// unit as the headline. A missing or nonsensical bucket length leaves the - /// series out rather than drawing counts as if they were rates. - private static func perSecond(_ values: [Double], bucketSeconds: Int?) -> [Double] { - guard let bucketSeconds, bucketSeconds > 0 else { return [] } - return values.map { $0 / Double(bucketSeconds) } - } -} - -extension WidgetIssue { - /// The wire model, reduced to what a widget row can hold. - /// - /// `last_seen_at` is the one field that can fail to parse. Falling back to - /// `.distantPast` rather than dropping the row keeps the issue visible and - /// sorts it last, which is the failure mode that loses the least. - init(issue: ErrorIssue) { - self.init( - id: issue.id, - title: issue.displayTitle, - subtitle: issue.displaySubtitle, - serviceName: issue.serviceName, - // Unknown-to-this-build severities decode to nil and rank below - // `low` rather than crashing a widget that cannot be updated - // without an App Store release. - severity: issue.severity.flatMap { WidgetIssueSeverity(rawValue: $0.rawValue) }, - occurrenceCount: issue.occurrenceCount, - lastSeenAt: ResolvedTimeWindow.parse(issue.lastSeenAt) ?? .distantPast, - isRegressed: issue.regressionCount > 0, - hasOpenIncident: issue.hasOpenIncident + let issuesSaved = issuesStore.save(issues) + let throughputSaved = throughputStore.save(throughput) + + let now = payload.generatedAt + return ( + issues: issuesSaved + ? (WidgetReloadDecision.shouldReload( + stored: storedIssues, + incoming: issues, + storedIsStale: storedIssues?.isStale(at: now) ?? false + ) ? .changed : .unchanged) + : .failed, + // Throughput is the surface where suppression earns the most: its + // floats differ on every fetch, but `contentFingerprint` compares the + // rendered strings, so a rate that still reads "12.5/s" costs nothing. + throughput: throughputSaved + ? (WidgetReloadDecision.shouldReload( + stored: storedThroughput, + incoming: throughput, + storedIsStale: storedThroughput?.isStale(at: now) ?? false + ) ? .changed : .unchanged) + : .failed ) } } diff --git a/apps/ios/Packages/MapleAPI/Package.resolved b/apps/ios/Packages/MapleAPI/Package.resolved index 6dc2f679c..86c6a9ed1 100644 --- a/apps/ios/Packages/MapleAPI/Package.resolved +++ b/apps/ios/Packages/MapleAPI/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "9963aa4f7b7e8d3bb85199d4360ad35d5b21547fc1453973fd368a3e0623457b", + "originHash" : "9498dbddeb5b8053137fc528a9b9e91f24f80160cc848e9c0349cec788109464", "pins" : [ { "identity" : "clerk-ios", diff --git a/apps/ios/Packages/MapleAPI/Package.swift b/apps/ios/Packages/MapleAPI/Package.swift index 199c238f3..33cb71f17 100644 --- a/apps/ios/Packages/MapleAPI/Package.swift +++ b/apps/ios/Packages/MapleAPI/Package.swift @@ -29,6 +29,12 @@ let package = Package( .target( name: "MapleAPI", dependencies: [ + // One direction only. `MapleWidgetData` owns the shapes the Home + // Screen renders and the mapping into them, so the app and the + // widget extension cannot disagree about what a row says. The + // reverse — the widget reaching for the generated client — is the + // dependency this whole split exists to prevent. + "MapleWidgetData", .product(name: "OpenAPIRuntime", package: "swift-openapi-runtime"), .product(name: "OpenAPIURLSession", package: "swift-openapi-urlsession"), ], diff --git a/apps/ios/Packages/MapleAPI/Sources/MapleAPI/MapleClient+WidgetSummary.swift b/apps/ios/Packages/MapleAPI/Sources/MapleAPI/MapleClient+WidgetSummary.swift new file mode 100644 index 000000000..6de374387 --- /dev/null +++ b/apps/ios/Packages/MapleAPI/Sources/MapleAPI/MapleClient+WidgetSummary.swift @@ -0,0 +1,137 @@ +import Foundation +import MapleWidgetData + +/// The Home Screen widgets' one read. +/// +/// This used to be four calls composed on the device — `listServices`, two +/// `queryTraceTimeseries`, and `listErrorIssues` — which is four round-trips to +/// land inside whatever seconds of background time iOS granted. It is now one, +/// and the shape it returns (`WidgetSummaryPayload`, in `MapleWidgetData`) is +/// the same one the widget extension decodes for itself, so the app and the +/// Home Screen cannot drift apart about what a row says. +extension MapleClient { + public func widgetSummary() async throws -> WidgetSummaryPayload { + try await mapping { + let output = try await client.getWidgetSummary(.init()) + return try Self.payload(from: output.ok.body.json) + } + } + + /// Generated wire type → the shared shape. + /// + /// Mechanical, with one judgement call: a timestamp that will not parse. + /// `generated_at` is the age every widget renders from, so a payload whose + /// own timestamp is unreadable is rejected outright — rendering it would + /// mean claiming an age that is not the data's. A single issue's + /// `last_seen_at` is different: `.distantPast` keeps the row visible and + /// sorts it last, which is the failure that loses least. + static func payload( + from summary: Components.Schemas.WidgetSummary + ) throws -> WidgetSummaryPayload { + guard let generatedAt = ResolvedTimeWindow.parse(summary.generatedAt) else { + throw MapleAPIError.decoding(WidgetSummaryDecodingError.unreadableGeneratedAt) + } + return WidgetSummaryPayload( + schemaVersion: Int(summary.schemaVersion), + generatedAt: generatedAt, + organizationId: summary.organizationId, + issues: WidgetSummaryPayload.Issues( + windowSeconds: Int(summary.issues.windowSeconds), + hasMore: summary.issues.hasMore, + data: summary.issues.data.map { issue in + WidgetSummaryPayload.Issue( + id: issue.id, + exceptionType: issue.exceptionType, + errorLabel: issue.errorLabel, + exceptionMessage: issue.exceptionMessage, + serviceName: issue.serviceName, + severity: issue.severity?.rawValue, + occurrenceCount: issue.occurrenceCount, + lastSeenAt: ResolvedTimeWindow.parse(issue.lastSeenAt) ?? .distantPast, + isRegressed: issue.isRegressed, + hasOpenIncident: issue.hasOpenIncident + ) + } + ), + throughput: WidgetSummaryPayload.Throughput( + windowSeconds: Int(summary.throughput.windowSeconds), + bucketSeconds: summary.throughput.bucketSeconds.map(Int.init), + services: summary.throughput.services.map { service in + WidgetSummaryPayload.Service( + name: service.name, + throughputPerSecond: service.throughputPerSecond, + errorRate: service.errorRate, + p95LatencyMs: service.p95LatencyMs, + points: service.points + ) + }, + totalPoints: summary.throughput.totalPoints + ) + ) + } +} + +/// The credential the widget extension fetches with. +/// +/// Minted by the app because only the app holds a session, and used by the +/// extension because only the extension is awake when WidgetKit rebuilds a +/// timeline. Everything that bounds it — the scopes, the TTL, the roles — is +/// chosen by the server, so there is nothing to get wrong here beyond calling +/// it at the right times. +/// +/// Keyed on the **installation**, not the push token: a user who declines +/// notifications has no APNs token and still pins widgets, and hanging the +/// credential off push would have quietly meant "no widget refresh unless you +/// also accept alerts". +extension MapleClient { + /// Mint or roll this device's credential for the client's organization. + /// + /// Idempotent per installation: the server revokes whatever it had in the + /// same transaction, so a roll cannot leave a live key behind on a phone. + public func mintWidgetCredential(installationId: String) async throws -> WidgetCredential { + try await mapping { + let output = try await client.mintWidgetCredential( + .init(path: .init(installationId: installationId)) + ) + let credential = try output.ok.body.json + guard + let expiresAt = ResolvedTimeWindow.parse(credential.expiresAt), + let mintedAt = ResolvedTimeWindow.parse(credential.createdAt) + else { + // Without a readable expiry there is no way to know when to renew, + // and a credential nobody renews is a Home Screen that goes quiet + // with no signal. Better to have none and mint again. + throw MapleAPIError.decoding(WidgetSummaryDecodingError.unreadableCredentialDates) + } + return WidgetCredential( + organizationId: credential.organizationId, + secret: credential.secret, + // The host this client is pointed at, carried with the credential + // it just issued. A credential minted against a local API is + // worthless to production and vice versa; storing them apart makes + // that mismatch look like an expiry to the widget. + apiBaseURL: serverURL, + expiresAt: expiresAt, + mintedAt: mintedAt + ) + } + } + + /// Sign-out, leaving an organization, or unpinning the last widget. + public func revokeWidgetCredential(installationId: String) async throws { + try await mapping { + _ = try await client.revokeWidgetCredential( + .init(path: .init(installationId: installationId)) + ).ok + } + } +} + +public enum WidgetSummaryDecodingError: Error, Sendable { + /// The payload's own `generated_at` did not parse, so nothing in it can be + /// aged honestly. + case unreadableGeneratedAt + /// A minted credential arrived without a readable expiry, so nothing could + /// decide when to renew it. + case unreadableCredentialDates +} diff --git a/apps/ios/Packages/MapleAPI/Sources/MapleAPI/MapleClient.swift b/apps/ios/Packages/MapleAPI/Sources/MapleAPI/MapleClient.swift index 7a3d16dc0..1dc7c4a2c 100644 --- a/apps/ios/Packages/MapleAPI/Sources/MapleAPI/MapleClient.swift +++ b/apps/ios/Packages/MapleAPI/Sources/MapleAPI/MapleClient.swift @@ -1,4 +1,5 @@ import Foundation +import MapleWidgetData import OpenAPIRuntime import OpenAPIURLSession @@ -151,6 +152,11 @@ public protocol MapleAPI: Sendable { // Telemetry — see MapleClient+Telemetry.swift func traceTimeseries(_ request: TraceTimeseriesRequest) async throws -> TraceTimeseriesResult func traceBreakdown(_ request: TraceBreakdownRequest) async throws -> TraceBreakdownResult + + // Home Screen widgets — see MapleClient+WidgetSummary.swift + func widgetSummary() async throws -> WidgetSummaryPayload + func mintWidgetCredential(installationId: String) async throws -> WidgetCredential + func revokeWidgetCredential(installationId: String) async throws } extension MapleAPI { @@ -163,7 +169,7 @@ extension MapleAPI { public struct MapleClient: MapleAPI { let client: Client private let tokens: any MapleTokenProvider - private let serverURL: URL + let serverURL: URL private let transport: any ClientTransport /// Shared with every client `scoped(to:)` produces, so a widget fetch for /// one organization still dedupes against a foreground fetch for another diff --git a/apps/ios/Packages/MapleAPI/Sources/MapleAPI/openapi.json b/apps/ios/Packages/MapleAPI/Sources/MapleAPI/openapi.json index e10e1cc65..4c51bfdf6 100644 --- a/apps/ios/Packages/MapleAPI/Sources/MapleAPI/openapi.json +++ b/apps/ios/Packages/MapleAPI/Sources/MapleAPI/openapi.json @@ -2645,6 +2645,312 @@ "title": "Trace timeseries result", "type": "object" }, + "WidgetCredential": { + "additionalProperties": false, + "description": "A read-only, expiring credential for one app installation's Home Screen widgets. Minting is idempotent per installation: the previous credential is revoked in the same transaction.", + "examples": [ + { + "created_at": "2026-08-21T09:10:00.000Z", + "expires_at": "2026-09-20T09:10:00.000Z", + "object": "widget_credential", + "organization_id": "org_2abcDEF", + "scopes": [ + "widget_summary:read" + ], + "secret": "maple_ak_1a2b3c4d5e6f708192a3b4c5d6e7f8091a2b3c4d5e6f7081" + } + ], + "properties": { + "created_at": { + "type": "string" + }, + "expires_at": { + "type": "string" + }, + "object": { + "description": "The object type — always `\"widget_credential\"`.", + "enum": [ + "widget_credential" + ], + "type": "string" + }, + "organization_id": { + "minLength": 1, + "pattern": "^\\S[\\s\\S]*\\S$|^\\S$|^$", + "title": "Org ID", + "type": "string" + }, + "scopes": { + "description": "Fixed by the server. Today, exactly `widget_summary:read`.", + "items": { + "type": "string" + }, + "type": "array" + }, + "secret": { + "description": "The bearer token, shown once. Store it where only this installation can read it, and send it nowhere but Maple.", + "type": "string" + } + }, + "required": [ + "object", + "secret", + "organization_id", + "scopes", + "expires_at", + "created_at" + ], + "title": "Widget credential", + "type": "object" + }, + "WidgetCredentialDeleteResponse": { + "additionalProperties": false, + "properties": { + "deleted": { + "enum": [ + true + ], + "type": "boolean" + }, + "object": { + "enum": [ + "widget_credential" + ], + "type": "string" + } + }, + "required": [ + "object", + "deleted" + ], + "title": "Widget credential delete response", + "type": "object" + }, + "WidgetSummary": { + "additionalProperties": false, + "description": "Everything the Maple iOS Home Screen widgets draw, in one response: ongoing error issues over the last day, and per-service traffic over the last hour.", + "examples": [ + { + "generated_at": "2026-08-21T09:10:00.000Z", + "issues": { + "data": [ + { + "error_label": "checkout", + "exception_message": "Cannot read properties of undefined", + "exception_type": "TypeError", + "has_open_incident": true, + "id": "iss_YofPTrK9782DWwcnXhpcCw", + "is_regressed": false, + "last_seen_at": "2026-08-21T09:08:12.000Z", + "occurrence_count": 412, + "service_name": "api", + "severity": "critical" + } + ], + "has_more": false, + "window_seconds": 86400 + }, + "object": "widget_summary", + "organization_id": "org_2abcDEF", + "schema_version": 1, + "throughput": { + "bucket_seconds": 300, + "services": [ + { + "error_rate": 0.012, + "name": "api", + "p95_latency_ms": 184, + "points": [ + 3600, + 3720, + 3540 + ], + "throughput_per_second": 12.5 + } + ], + "total_points": [ + 5200, + 5310, + 5180 + ], + "window_seconds": 3600 + } + } + ], + "properties": { + "generated_at": { + "type": "string" + }, + "issues": { + "$ref": "#/components/schemas/WidgetSummaryIssues" + }, + "object": { + "description": "The object type — always `\"widget_summary\"`.", + "enum": [ + "widget_summary" + ], + "type": "string" + }, + "organization_id": { + "minLength": 1, + "pattern": "^\\S[\\s\\S]*\\S$|^\\S$|^$", + "title": "Org ID", + "type": "string" + }, + "schema_version": { + "type": "number" + }, + "throughput": { + "$ref": "#/components/schemas/WidgetSummaryThroughput" + } + }, + "required": [ + "object", + "schema_version", + "generated_at", + "organization_id", + "issues", + "throughput" + ], + "title": "Widget summary", + "type": "object" + }, + "WidgetSummaryIssue": { + "additionalProperties": false, + "description": "One ongoing error issue, reduced to what a Home Screen row can render.", + "properties": { + "error_label": { + "type": "string" + }, + "exception_message": { + "type": "string" + }, + "exception_type": { + "type": "string" + }, + "has_open_incident": { + "type": "boolean" + }, + "id": { + "$ref": "#/components/schemas/_maple_ErrorIssueId" + }, + "is_regressed": { + "type": "boolean" + }, + "last_seen_at": { + "type": "string" + }, + "occurrence_count": { + "type": "number" + }, + "service_name": { + "type": "string" + }, + "severity": { + "$ref": "#/components/schemas/_maple_IssueSeverity" + } + }, + "required": [ + "id", + "exception_type", + "error_label", + "exception_message", + "service_name", + "occurrence_count", + "last_seen_at", + "is_regressed", + "has_open_incident" + ], + "title": "Widget summary issue", + "type": "object" + }, + "WidgetSummaryIssues": { + "additionalProperties": false, + "properties": { + "data": { + "items": { + "$ref": "#/components/schemas/WidgetSummaryIssue" + }, + "type": "array" + }, + "has_more": { + "type": "boolean" + }, + "window_seconds": { + "type": "number" + } + }, + "required": [ + "window_seconds", + "has_more", + "data" + ], + "title": "Widget summary issues", + "type": "object" + }, + "WidgetSummaryService": { + "additionalProperties": false, + "description": "One service's traffic over the throughput window.", + "properties": { + "error_rate": { + "type": "number" + }, + "name": { + "$ref": "#/components/schemas/_maple_ServiceName" + }, + "p95_latency_ms": { + "type": "number" + }, + "points": { + "items": { + "type": "number" + }, + "type": "array" + }, + "throughput_per_second": { + "type": "number" + } + }, + "required": [ + "name", + "throughput_per_second", + "error_rate", + "p95_latency_ms", + "points" + ], + "title": "Widget summary service", + "type": "object" + }, + "WidgetSummaryThroughput": { + "additionalProperties": false, + "properties": { + "bucket_seconds": { + "type": "number" + }, + "services": { + "items": { + "$ref": "#/components/schemas/WidgetSummaryService" + }, + "type": "array" + }, + "total_points": { + "items": { + "type": "number" + }, + "type": "array" + }, + "window_seconds": { + "type": "number" + } + }, + "required": [ + "window_seconds", + "services", + "total_points" + ], + "title": "Widget summary throughput", + "type": "object" + }, "_maple_ActorId": { "description": "Opaque, prefixed public object ID (e.g. `actor_YofPTrK9782DWwcnXhpcCw`). A reversible base58 encoding of the internal ID — treat it as an opaque string.", "examples": [ @@ -6324,65 +6630,452 @@ "Traces" ] } - } - }, - "security": [], - "servers": [ - { - "description": "Production", - "url": "https://api.maple.dev" - } - ], - "tags": [ - { - "description": "Programmatic credentials for the Maple API. Create scoped keys, list and retrieve them, roll their secrets, and revoke them. Creating, rolling, and revoking keys is admin-only; a key's `secret` is returned only when it is created or rolled.", - "name": "API Keys" - }, - { - "description": "Create and manage dashboards, browse version history, restore snapshots, and instantiate built-in templates.", - "name": "Dashboards" - }, - { - "description": "Monitors over your telemetry: error rate, latency percentiles, Apdex, throughput, metrics, query-builder queries, and raw SQL. Rules evaluate on a rolling window, open incidents after consecutive breaches, and notify their alert destinations. Mutations are admin-only.", - "name": "Alert Rules" - }, - { - "description": "Read-only notification delivery history for alert rules.", - "name": "Alert Deliveries" - }, - { - "description": "Notification channels for alert rules — Slack bot, PagerDuty, generic webhooks, Hazel OAuth, Discord, Telegram, and workspace-member email. Create and manage destinations, then reference them from alert rules via `destination_ids`. Mutations are admin-only; channel secrets are write-only.", - "name": "Alert Destinations" - }, - { - "description": "The incident history produced by your alert rules. Incidents open when a rule breaches for enough consecutive checks and resolve automatically once the signal recovers — this surface is read-only.", - "name": "Alert Incidents" - }, - { - "description": "Telemetry ingest credentials for your organization: a public key for client-side senders and a private key for server-side senders. Retrieve them or roll either key. All operations are admin-only.", - "name": "Ingest Keys" - }, - { - "description": "Install and manage the Maple Slack app for your organization: begin an OAuth install, read installation status, list channels, and uninstall.", - "name": "Slack Integration" - }, - { - "description": "Connect PlanetScale to your organization and manage what Maple collects from it: connection status, organization binding, the metrics service token that enables branch-metrics scraping, the database inventory, webhook setup, query insights, and the lifecycle event timeline.", - "name": "PlanetScale Integration" - }, - { - "description": "Deduplicated errors and alert-backed issues tracked through Maple's triage workflow.", - "name": "Error Issues" - }, - { - "description": "Ingest-time attribute rewrite rules. Move or copy span/resource attribute values to new keys as telemetry arrives, normalizing naming across services without redeploying them.", - "name": "Attribute Mappings" - }, - { - "description": "Metrics endpoints Maple scrapes on a schedule — self-hosted Prometheus endpoints and PlanetScale branch metrics. Manage targets, probe them on demand, and inspect recent scrape checks. Credentials are write-only.", - "name": "Scrape Targets" }, - { + "/v2/widget_credentials/{installation_id}": { + "delete": { + "description": "Retires the installation's credential for this organization; the app calls this on sign-out and when the user leaves the organization. Idempotent — an installation with nothing to revoke is already in the requested state. Requires the `widget_credentials:write` scope.", + "operationId": "revokeWidgetCredential", + "parameters": [ + { + "in": "path", + "name": "installation_id", + "required": true, + "schema": { + "description": "A stable, client-generated identifier for one app installation — on iOS, `identifierForVendor`. Opaque to Maple, and only ever compared against itself: it decides which credential a re-mint replaces.", + "examples": [ + "F9E1B4C0-8F2A-4C6D-9E1B-4C08F2A4C6D9" + ], + "maxLength": 128, + "minLength": 8, + "pattern": "^[A-Za-z0-9_-]+$", + "title": "Installation ID", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WidgetCredentialDeleteResponse" + } + } + }, + "description": "WidgetCredentialDeleteResponse" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MapleErrorEnvelope" + } + } + }, + "description": "The @maple/http/v2/InvalidRequestError failure. HTTP 400." + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MapleErrorEnvelope" + } + } + }, + "description": "The @maple/http/v2/InvalidCredentialsError failure. HTTP 401. | The @maple/http/errors/UnauthorizedError failure. HTTP 401." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MapleErrorEnvelope" + } + } + }, + "description": "The @maple/http/v2/InsufficientScopeError failure. HTTP 403. | The @maple/http/v2/OrganizationAccessDeniedError failure. HTTP 403." + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MapleErrorEnvelope" + } + } + }, + "description": "The @maple/http/v2/RateLimitError failure. HTTP 429.", + "headers": { + "Retry-After": { + "description": "Seconds to wait or an HTTP-date indicating when the request may be retried.", + "example": 60, + "schema": { + "oneOf": [ + { + "minimum": 1, + "type": "integer" + }, + { + "type": "string" + } + ] + } + } + } + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MapleErrorEnvelope" + } + } + }, + "description": "The @maple/http/v2/ResponseSchemaError failure. HTTP 500. | The @maple/http/v2/UnexpectedError failure. HTTP 500." + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MapleErrorEnvelope" + } + } + }, + "description": "The @maple/http/errors/ApiKeyPersistenceError failure. HTTP 503. | The @maple/http/errors/ApiKeyLookupPersistenceError failure. HTTP 503. | The @maple/http/errors/AuthorizationUnavailableError failure. HTTP 503." + }, + "504": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MapleErrorEnvelope" + } + } + }, + "description": "The @maple/http/v2/WorkerUnavailableError failure. HTTP 504." + } + }, + "security": [ + { + "bearer": [] + } + ], + "summary": "Revoke this installation's widget credential", + "tags": [ + "Widget Credentials" + ] + }, + "put": { + "description": "Issues a read-only, expiring credential for this installation's Home Screen widgets, revoking whatever it had. Idempotent, so the app calls it again to roll. Requires the `widget_credentials:write` scope — which a widget credential does not have, so renewal always goes through a signed-in session.", + "operationId": "mintWidgetCredential", + "parameters": [ + { + "in": "path", + "name": "installation_id", + "required": true, + "schema": { + "description": "A stable, client-generated identifier for one app installation — on iOS, `identifierForVendor`. Opaque to Maple, and only ever compared against itself: it decides which credential a re-mint replaces.", + "examples": [ + "F9E1B4C0-8F2A-4C6D-9E1B-4C08F2A4C6D9" + ], + "maxLength": 128, + "minLength": 8, + "pattern": "^[A-Za-z0-9_-]+$", + "title": "Installation ID", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WidgetCredential" + } + } + }, + "description": "A read-only, expiring credential for one app installation's Home Screen widgets. Minting is idempotent per installation: the previous credential is revoked in the same transaction." + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MapleErrorEnvelope" + } + } + }, + "description": "The @maple/http/v2/InvalidRequestError failure. HTTP 400." + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MapleErrorEnvelope" + } + } + }, + "description": "The @maple/http/v2/InvalidCredentialsError failure. HTTP 401. | The @maple/http/errors/UnauthorizedError failure. HTTP 401." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MapleErrorEnvelope" + } + } + }, + "description": "The @maple/http/v2/InsufficientScopeError failure. HTTP 403. | The @maple/http/v2/OrganizationAccessDeniedError failure. HTTP 403." + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MapleErrorEnvelope" + } + } + }, + "description": "The @maple/http/v2/RateLimitError failure. HTTP 429.", + "headers": { + "Retry-After": { + "description": "Seconds to wait or an HTTP-date indicating when the request may be retried.", + "example": 60, + "schema": { + "oneOf": [ + { + "minimum": 1, + "type": "integer" + }, + { + "type": "string" + } + ] + } + } + } + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MapleErrorEnvelope" + } + } + }, + "description": "The @maple/http/v2/ResponseSchemaError failure. HTTP 500. | The @maple/http/v2/UnexpectedError failure. HTTP 500." + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MapleErrorEnvelope" + } + } + }, + "description": "The @maple/http/errors/ApiKeyPersistenceError failure. HTTP 503. | The @maple/http/errors/ApiKeyLookupPersistenceError failure. HTTP 503. | The @maple/http/errors/AuthorizationUnavailableError failure. HTTP 503." + }, + "504": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MapleErrorEnvelope" + } + } + }, + "description": "The @maple/http/v2/WorkerUnavailableError failure. HTTP 504." + } + }, + "security": [ + { + "bearer": [] + } + ], + "summary": "Mint this installation's widget credential", + "tags": [ + "Widget Credentials" + ] + } + }, + "/v2/widget_summary": { + "get": { + "description": "Returns ongoing error issues and per-service traffic in a single small payload sized for a Home Screen widget. The windows are fixed by the server. Requires the `widget_summary:read` scope.", + "operationId": "getWidgetSummary", + "parameters": [], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WidgetSummary" + } + } + }, + "description": "Everything the Maple iOS Home Screen widgets draw, in one response: ongoing error issues over the last day, and per-service traffic over the last hour." + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MapleErrorEnvelope" + } + } + }, + "description": "The @maple/http/errors/QueryEngineValidationError failure. HTTP 400. | The @maple/http/v2/InvalidRequestError failure. HTTP 400." + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MapleErrorEnvelope" + } + } + }, + "description": "The @maple/http/v2/InvalidCredentialsError failure. HTTP 401. | The @maple/http/errors/UnauthorizedError failure. HTTP 401." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MapleErrorEnvelope" + } + } + }, + "description": "The @maple/http/v2/InsufficientScopeError failure. HTTP 403. | The @maple/http/v2/OrganizationAccessDeniedError failure. HTTP 403." + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MapleErrorEnvelope" + } + } + }, + "description": "The @maple/http/errors/WarehouseQuotaExceededError failure. HTTP 429. | The @maple/http/v2/RateLimitError failure. HTTP 429.", + "headers": { + "Retry-After": { + "description": "Seconds to wait or an HTTP-date indicating when the request may be retried.", + "example": 60, + "schema": { + "oneOf": [ + { + "minimum": 1, + "type": "integer" + }, + { + "type": "string" + } + ] + } + } + } + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MapleErrorEnvelope" + } + } + }, + "description": "The @maple/http/errors/WarehouseMalformedQueryError failure. HTTP 500. | The @maple/http/errors/WarehouseScopeError failure. HTTP 500. | The @maple/http/errors/OrgClickHouseSettingsEncryptionError failure. HTTP 500. | The @maple/http/errors/QueryEngineResultMismatchError failure. HTTP 500. | The @maple/http/v2/ResponseSchemaError failure. HTTP 500. | The @maple/http/v2/UnexpectedError failure. HTTP 500." + }, + "502": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MapleErrorEnvelope" + } + } + }, + "description": "The @maple/http/errors/WarehouseQueryError failure. HTTP 502. | The @maple/http/errors/WarehouseAuthError failure. HTTP 502. | The @maple/http/errors/WarehouseConfigError failure. HTTP 502. | The @maple/http/errors/WarehouseClientError failure. HTTP 502. | The @maple/http/errors/WarehouseSchemaDriftError failure. HTTP 502. | The @maple/http/errors/WarehouseResultDecodeError failure. HTTP 502. | The @maple/http/errors/OrgClickHouseSettingsStoredConfigInvalidError failure. HTTP 502." + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MapleErrorEnvelope" + } + } + }, + "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": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MapleErrorEnvelope" + } + } + }, + "description": "The @maple/http/errors/QueryEngineTimeoutError failure. HTTP 504. | The @maple/http/v2/WorkerUnavailableError failure. HTTP 504." + } + }, + "security": [ + { + "bearer": [] + } + ], + "summary": "Retrieve the mobile widget summary", + "tags": [ + "Widget Summary" + ] + } + } + }, + "security": [], + "servers": [ + { + "description": "Production", + "url": "https://api.maple.dev" + } + ], + "tags": [ + { + "description": "Programmatic credentials for the Maple API. Create scoped keys, list and retrieve them, roll their secrets, and revoke them. Creating, rolling, and revoking keys is admin-only; a key's `secret` is returned only when it is created or rolled.", + "name": "API Keys" + }, + { + "description": "Create and manage dashboards, browse version history, restore snapshots, and instantiate built-in templates.", + "name": "Dashboards" + }, + { + "description": "Monitors over your telemetry: error rate, latency percentiles, Apdex, throughput, metrics, query-builder queries, and raw SQL. Rules evaluate on a rolling window, open incidents after consecutive breaches, and notify their alert destinations. Mutations are admin-only.", + "name": "Alert Rules" + }, + { + "description": "Read-only notification delivery history for alert rules.", + "name": "Alert Deliveries" + }, + { + "description": "Notification channels for alert rules — Slack bot, PagerDuty, generic webhooks, Hazel OAuth, Discord, Telegram, and workspace-member email. Create and manage destinations, then reference them from alert rules via `destination_ids`. Mutations are admin-only; channel secrets are write-only.", + "name": "Alert Destinations" + }, + { + "description": "The incident history produced by your alert rules. Incidents open when a rule breaches for enough consecutive checks and resolve automatically once the signal recovers — this surface is read-only.", + "name": "Alert Incidents" + }, + { + "description": "Telemetry ingest credentials for your organization: a public key for client-side senders and a private key for server-side senders. Retrieve them or roll either key. All operations are admin-only.", + "name": "Ingest Keys" + }, + { + "description": "Install and manage the Maple Slack app for your organization: begin an OAuth install, read installation status, list channels, and uninstall.", + "name": "Slack Integration" + }, + { + "description": "Connect PlanetScale to your organization and manage what Maple collects from it: connection status, organization binding, the metrics service token that enables branch-metrics scraping, the database inventory, webhook setup, query insights, and the lifecycle event timeline.", + "name": "PlanetScale Integration" + }, + { + "description": "Deduplicated errors and alert-backed issues tracked through Maple's triage workflow.", + "name": "Error Issues" + }, + { + "description": "Ingest-time attribute rewrite rules. Move or copy span/resource attribute values to new keys as telemetry arrives, normalizing naming across services without redeploying them.", + "name": "Attribute Mappings" + }, + { + "description": "Metrics endpoints Maple scrapes on a schedule — self-hosted Prometheus endpoints and PlanetScale branch metrics. Manage targets, probe them on demand, and inspect recent scrape checks. Credentials are write-only.", + "name": "Scrape Targets" + }, + { "description": "Automatically detected instrumentation improvements — attribute renames toward canonical semantic conventions, double emissions, and naming violations. List them, and dismiss or reopen individual recommendations.", "name": "Instrumentation Recommendations" }, @@ -6433,6 +7126,14 @@ { "description": "Read a dashboard through a share link. The only unauthenticated group in this API: the token in the request body is the credential, so these operations resolve for a viewer with no Maple account.", "name": "Shares" + }, + { + "description": "The single read behind the Maple mobile Home Screen widgets. Deliberately its own scope family so a device credential can be fenced to it alone.", + "name": "Widget Summary" + }, + { + "description": "Device-scoped, read-only credentials for the Maple mobile Home Screen widgets. Minted by a signed-in app for one installation at a time.", + "name": "Widget Credentials" } ] } diff --git a/apps/ios/Packages/MapleAPI/Sources/MapleWidgetData/WidgetCredentialStore.swift b/apps/ios/Packages/MapleAPI/Sources/MapleWidgetData/WidgetCredentialStore.swift new file mode 100644 index 000000000..b4ba02b34 --- /dev/null +++ b/apps/ios/Packages/MapleAPI/Sources/MapleWidgetData/WidgetCredentialStore.swift @@ -0,0 +1,154 @@ +import Foundation + +/// The credential a device's Home Screen widgets fetch with. +/// +/// Read-only, expiring, and fenced by the server to `/v2/widget_summary` alone. +/// Minted by the app — the only process that holds a Clerk session — and read +/// by the widget extension, which holds none: session tokens live one minute, +/// and two processes refreshing the same rotating refresh token is a way to +/// sign the user out. +public struct WidgetCredential: Codable, Sendable, Equatable { + /// The organization the credential is bound to. An API key cannot select a + /// different one, so this is also the key's identity — one per organization + /// the user has actually pinned a widget to. + public var organizationId: String + public var secret: String + /// The host that issued it. + /// + /// Stored with the credential rather than read from the extension's own + /// Info.plist, because the two can never be right separately: a credential + /// minted against a local API is worthless to production and vice versa, and + /// a widget that mixes them fails as a 401 that looks like an expiry. It + /// also keeps the production URL from being written down a second time — the + /// app already gets it from the OpenAPI document. + public var apiBaseURL: URL + public var expiresAt: Date + public var mintedAt: Date + + public init(organizationId: String, secret: String, apiBaseURL: URL, expiresAt: Date, mintedAt: Date) { + self.organizationId = organizationId + self.secret = secret + self.apiBaseURL = apiBaseURL + self.expiresAt = expiresAt + self.mintedAt = mintedAt + } + + public func isExpired(at date: Date) -> Bool { date >= expiresAt } + + /// Re-mint with a week to spare. + /// + /// The window is generous on purpose: renewal needs a signed-in foreground, + /// and a phone that only gets opened at the weekend must not be one bad + /// Monday away from a Home Screen that has gone quiet with no way to + /// recover on its own. + public static let renewalLead: TimeInterval = 7 * 24 * 60 * 60 + + public func needsRenewal(at date: Date) -> Bool { + date.addingTimeInterval(Self.renewalLead) >= expiresAt + } +} + +/// Where the credential lives: a file in the shared App Group container. +/// +/// **Not `UserDefaults`, unlike the snapshots.** A suite plist is a bearer +/// credential sitting in cleartext in a backup; a file can carry a data +/// protection class, and `completeUntilFirstUserAuthentication` is the one that +/// matches when a widget actually runs — WidgetKit rebuilds Lock Screen +/// accessories on a locked phone, so anything stricter would make the widget +/// fail exactly where it is most visible. +/// +/// **Not the Keychain either, yet.** The app's keychain access group is where +/// Clerk keeps the session, and sharing *that* group with the extension would +/// hand it the session this whole design exists to keep out of it. A separate +/// group is the eventual home; it needs a provisioning change, and this store's +/// interface is what lets that happen later without touching the wire. +public struct WidgetCredentialStore: Sendable { + private let appGroupIdentifier: String + /// Tests only. `containerURL(forSecurityApplicationGroupIdentifier:)` answers + /// nil outside a signed app, so without this the store's behaviour could + /// only be exercised on a simulator — and the rules it enforces (whose + /// credential this is, what a sign-out leaves behind) are exactly the ones + /// worth testing with `swift test`. + private let overrideDirectory: URL? + + /// - Parameter appGroupIdentifier: overridden only by tests. + public init(appGroupIdentifier: String = WidgetAppGroup.identifier) { + self.appGroupIdentifier = appGroupIdentifier + self.overrideDirectory = nil + } + + init(directory: URL) { + self.appGroupIdentifier = WidgetAppGroup.identifier + self.overrideDirectory = directory + } + + private var directory: URL? { + if let overrideDirectory { return overrideDirectory } + return FileManager.default + .containerURL(forSecurityApplicationGroupIdentifier: appGroupIdentifier)? + .appendingPathComponent("credentials", isDirectory: true) + } + + /// One file per organization, named by its id. + /// + /// Organization ids are `org_` plus base62 and contain nothing path-ish, but + /// this is the one place a value that arrived over the network becomes a + /// filename — so it is checked rather than trusted. + /// + /// **Rejected, not sanitized.** Stripping the offending characters would map + /// `../../org_a` and `org_a` onto the same file, so an id that should have + /// been refused outright would instead quietly overwrite a real + /// organization's credential. Nothing legitimate ever fails this check. + private func url(for organizationId: String) -> URL? { + guard + !organizationId.isEmpty, + organizationId.allSatisfy({ $0.isLetter || $0.isNumber || $0 == "_" || $0 == "-" }) + else { return nil } + return directory?.appendingPathComponent("widget-\(organizationId).json", isDirectory: false) + } + + public func load(organizationId: String) -> WidgetCredential? { + guard let url = url(for: organizationId), let data = try? Data(contentsOf: url) else { return nil } + // A credential written by a newer build that this one cannot decode is + // dropped rather than crashing the extension mid-timeline. + guard let credential = try? Self.decoder.decode(WidgetCredential.self, from: data) else { + return nil + } + // A file whose contents name a different organization is not this + // organization's credential, whatever it is doing under that name. + return credential.organizationId == organizationId ? credential : nil + } + + @discardableResult + public func save(_ credential: WidgetCredential) -> Bool { + guard + let directory, + let url = url(for: credential.organizationId), + let data = try? Self.encoder.encode(credential) + else { return false } + try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + do { + try data.write(to: url, options: [.atomic, .completeFileProtectionUntilFirstUserAuthentication]) + return true + } catch { + return false + } + } + + public func clear(organizationId: String) { + guard let url = url(for: organizationId) else { return } + try? FileManager.default.removeItem(at: url) + } + + /// Sign-out. Everything, not just the organizations this build knows about: + /// the Home Screen outlives the session, and a credential left behind is one + /// the next person holding the phone could still fetch with. + public func clearAll() { + guard let directory else { return } + try? FileManager.default.removeItem(at: directory) + } + + private static var encoder: JSONEncoder { WidgetJSON.encoder } + /// Tolerant of fractional seconds — see `WidgetJSON`. + private static var decoder: JSONDecoder { WidgetJSON.decoder } +} diff --git a/apps/ios/Packages/MapleAPI/Sources/MapleWidgetData/WidgetFetchState.swift b/apps/ios/Packages/MapleAPI/Sources/MapleWidgetData/WidgetFetchState.swift new file mode 100644 index 000000000..a246415f3 --- /dev/null +++ b/apps/ios/Packages/MapleAPI/Sources/MapleWidgetData/WidgetFetchState.swift @@ -0,0 +1,153 @@ +import Foundation + +/// What happened the last time a widget tried to fetch for itself. +/// +/// The extension links no telemetry — it carries `MapleWidgetData` and nothing +/// else — so a fetch that fails there fails completely silently. That is the +/// exact failure mode this whole project exists to fix, so the extension writes +/// its outcome into the App Group and the app drains it into a span on the next +/// foreground. Without it, "did the widget actually refresh?" has no answer. +/// +/// It is also load-bearing, not just diagnostic: `consecutiveFailures` drives +/// how far out the next timeline is asked for, and `credentialRejectedAt` stops +/// a rolled credential from spending the whole refresh budget on 401s. +public struct WidgetFetchState: Codable, Sendable, Equatable { + public var lastAttemptAt: Date? + public var lastSuccessAt: Date? + public var lastOutcome: Outcome? + public var consecutiveFailures: Int + /// When the server last said this credential is not valid. + /// + /// Terminal, deliberately: a rolled or revoked credential answers 401 + /// forever, and retrying on every rebuild would burn the entire refresh + /// budget on failures. Cleared by the app when it mints a new one. + public var credentialRejectedAt: Date? + /// When an attempt started that has not reported an outcome yet. + /// + /// A dedicated field rather than "there is an attempt but no outcome": after + /// the very first completed fetch `lastOutcome` is never nil again, so + /// inferring in-flight from it would silently stop working the moment it + /// first worked — and the whole point of this is to keep the app and the + /// extension from fetching the same thing at the same time, which is a + /// steady-state concern, not a first-run one. + public var inFlightSince: Date? + + public enum Outcome: String, Codable, Sendable { + case success + /// The credential was rejected. Only the app can fix this. + case unauthorized + /// No network, or the request outlived the provider's deadline. Retry soon. + case unreachable + /// The server answered, unhappily. Retry, but back off. + case server + /// A 200 this build could not read — including a payload from a newer + /// server whose fields may have changed meaning. + case undecodable + } + + public init( + lastAttemptAt: Date? = nil, + lastSuccessAt: Date? = nil, + lastOutcome: Outcome? = nil, + consecutiveFailures: Int = 0, + credentialRejectedAt: Date? = nil, + inFlightSince: Date? = nil + ) { + self.lastAttemptAt = lastAttemptAt + self.lastSuccessAt = lastSuccessAt + self.lastOutcome = lastOutcome + self.consecutiveFailures = consecutiveFailures + self.credentialRejectedAt = credentialRejectedAt + self.inFlightSince = inFlightSince + } + + /// Fetching is pointless until the app mints again. + public var isCredentialRejected: Bool { credentialRejectedAt != nil } + + public func recording(_ outcome: Outcome, at date: Date) -> WidgetFetchState { + var next = self + next.lastAttemptAt = date + next.lastOutcome = outcome + next.inFlightSince = nil + switch outcome { + case .success: + next.lastSuccessAt = date + next.consecutiveFailures = 0 + next.credentialRejectedAt = nil + case .unauthorized: + next.consecutiveFailures += 1 + next.credentialRejectedAt = date + case .unreachable, .server, .undecodable: + next.consecutiveFailures += 1 + } + return next + } + + /// Stamped **before** the request goes out, so two providers woken for the + /// same organization in the same second do not both fetch. See + /// `WidgetSummaryFetcher` for the other half of that. + public func attempting(at date: Date) -> WidgetFetchState { + var next = self + next.lastAttemptAt = date + next.inFlightSince = date + return next + } + + /// Someone else is already fetching this, recently enough to wait for. + /// + /// Bounded rather than open-ended: a process killed mid-fetch never clears + /// `inFlightSince`, and a lock nothing can release would stop the widget + /// refreshing until the next sign-out. + public func isInFlight(at date: Date, within window: TimeInterval) -> Bool { + guard let inFlightSince else { return false } + return date.timeIntervalSince(inFlightSince) < window + } +} + +/// The fetch state, shared across the process boundary. +/// +/// `UserDefaults` rather than a file, unlike the credential: there is nothing +/// secret here, it is written on every timeline build, and the suite gives +/// atomic-enough semantics for a record whose worst-case corruption is one +/// wasted request. +public struct WidgetFetchStateStore: Sendable { + private let appGroupIdentifier: String + + public init(appGroupIdentifier: String = WidgetAppGroup.identifier) { + self.appGroupIdentifier = appGroupIdentifier + } + + private var defaults: UserDefaults? { UserDefaults(suiteName: appGroupIdentifier) } + private func key(_ organizationId: String) -> String { "widget.fetch.v1.\(organizationId)" } + + public func load(organizationId: String) -> WidgetFetchState { + guard + let data = defaults?.data(forKey: key(organizationId)), + let state = try? Self.decoder.decode(WidgetFetchState.self, from: data) + else { return WidgetFetchState() } + return state + } + + public func save(_ state: WidgetFetchState, organizationId: String) { + guard let defaults, let data = try? Self.encoder.encode(state) else { return } + defaults.set(data, forKey: key(organizationId)) + } + + public func clear(organizationId: String) { + defaults?.removeObject(forKey: key(organizationId)) + } + + /// The app, having minted a fresh credential, telling the widget to stop + /// treating 401 as terminal. + public func clearCredentialRejection(organizationId: String) { + var state = load(organizationId: organizationId) + guard state.isCredentialRejected else { return } + state.credentialRejectedAt = nil + state.consecutiveFailures = 0 + save(state, organizationId: organizationId) + } + + private static var encoder: JSONEncoder { WidgetJSON.encoder } + /// Tolerant of fractional seconds — see `WidgetJSON`. + private static var decoder: JSONDecoder { WidgetJSON.decoder } +} diff --git a/apps/ios/Packages/MapleAPI/Sources/MapleWidgetData/WidgetJSON.swift b/apps/ios/Packages/MapleAPI/Sources/MapleWidgetData/WidgetJSON.swift new file mode 100644 index 000000000..1229e7bbb --- /dev/null +++ b/apps/ios/Packages/MapleAPI/Sources/MapleWidgetData/WidgetJSON.swift @@ -0,0 +1,67 @@ +import Foundation + +/// How this module reads and writes JSON, in one place. +/// +/// It exists for the date strategy, and specifically for one trap. +/// `JSONDecoder.DateDecodingStrategy.iso8601` is backed by +/// `ISO8601DateFormatter` with its default options, which **reject fractional +/// seconds**. Maple's v2 API sends every timestamp as `toISOString()` output — +/// `2026-08-21T09:10:00.000Z`, milliseconds and all — so `.iso8601` fails to +/// decode every payload the widget fetches. +/// +/// It does not fail everywhere, which is what makes it dangerous. The Swift +/// Foundation rewrite shipped in newer OS versions parses fractional seconds +/// happily, so this decodes fine on a current macOS and fails on iOS 18 — the +/// deployment target. It was caught by CI running macOS 15 against a laptop +/// running macOS 26, and would otherwise have shipped as "the widgets never +/// refresh on most phones", with no error anywhere: an undecodable payload is +/// indistinguishable from a fetch that failed. +/// +/// So: parse both shapes explicitly, and keep writing the one without +/// fractional seconds. Sub-second precision means nothing to a surface whose +/// smallest unit is "1m ago". +enum WidgetJSON { + static var encoder: JSONEncoder { + let encoder = JSONEncoder() + // ISO-8601 rather than seconds-since-reference-date: these are read by a + // different process, possibly built from a different commit, and a dated + // string survives inspection by eye. + encoder.dateEncodingStrategy = .iso8601 + return encoder + } + + static var decoder: JSONDecoder { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .custom { decoder in + let text = try decoder.singleValueContainer().decode(String.self) + guard let date = parse(text) else { + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Expected an ISO-8601 timestamp, got \(text)" + ) + ) + } + return date + } + return decoder + } + + /// With fractional seconds first, because that is what the API sends. + /// + /// Two formatters rather than one with both option sets: `ISO8601DateFormatter` + /// treats `withFractionalSeconds` as *required*, not permitted, so a single + /// formatter can read one shape or the other but never both. + static func parse(_ text: String) -> Date? { + (try? fractional.parse(text)) ?? (try? whole.parse(text)) + } + + private static let fractional = Date.ISO8601FormatStyle( + includingFractionalSeconds: true, + timeZone: .gmt + ) + private static let whole = Date.ISO8601FormatStyle( + includingFractionalSeconds: false, + timeZone: .gmt + ) +} diff --git a/apps/ios/Packages/MapleAPI/Sources/MapleWidgetData/WidgetOrganizationIndex.swift b/apps/ios/Packages/MapleAPI/Sources/MapleWidgetData/WidgetOrganizationIndex.swift index 383e736dd..9dff54756 100644 --- a/apps/ios/Packages/MapleAPI/Sources/MapleWidgetData/WidgetOrganizationIndex.swift +++ b/apps/ios/Packages/MapleAPI/Sources/MapleWidgetData/WidgetOrganizationIndex.swift @@ -188,16 +188,8 @@ public struct WidgetOrganizationIndex: Sendable { } // 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 - } + // possibly another build — which is also why the decoder is the tolerant + // one. See `WidgetJSON`. + private static var encoder: JSONEncoder { WidgetJSON.encoder } + private static var decoder: JSONDecoder { WidgetJSON.decoder } } diff --git a/apps/ios/Packages/MapleAPI/Sources/MapleWidgetData/WidgetSnapshotStore.swift b/apps/ios/Packages/MapleAPI/Sources/MapleWidgetData/WidgetSnapshotStore.swift index 4c577ea74..401ad6249 100644 --- a/apps/ios/Packages/MapleAPI/Sources/MapleWidgetData/WidgetSnapshotStore.swift +++ b/apps/ios/Packages/MapleAPI/Sources/MapleWidgetData/WidgetSnapshotStore.swift @@ -65,17 +65,9 @@ public struct WidgetSnapshotStore: Sendable { // ISO-8601 rather than the default seconds-since-reference-date: these are // read by a different process, possibly built from a different commit, and // a dated string survives inspection by eye. - 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 - } + private static var encoder: JSONEncoder { WidgetJSON.encoder } + /// Tolerant of fractional seconds — see `WidgetJSON`. + private static var decoder: JSONDecoder { WidgetJSON.decoder } } // Keys are per organization, because a widget can be pinned to one. The `v1` diff --git a/apps/ios/Packages/MapleAPI/Sources/MapleWidgetData/WidgetSummary.swift b/apps/ios/Packages/MapleAPI/Sources/MapleWidgetData/WidgetSummary.swift new file mode 100644 index 000000000..037bfafa7 --- /dev/null +++ b/apps/ios/Packages/MapleAPI/Sources/MapleWidgetData/WidgetSummary.swift @@ -0,0 +1,311 @@ +import Foundation + +/// `GET /v2/widget_summary`, as this module sees it. +/// +/// One request covering both Home Screen widgets, in place of the four the app +/// used to compose (`/v2/error_issues`, `/v2/services`, and two +/// `/v2/traces/timeseries`). It lives here rather than in `MapleAPI` because +/// both readers need it and only one of them can link a generated client: the +/// app fetches through `MapleAPI`, and the widget extension — which holds no +/// Clerk session and no 30k-line client — decodes the same shape by hand. +/// +/// Plain `Codable` with explicit coding keys rather than a `keyDecodingStrategy`: +/// the strategy would be set by whoever happens to own the decoder, and this +/// payload is decoded in two processes. +public struct WidgetSummaryPayload: Codable, Sendable, Equatable { + /// The wire's own version, independent of the API version. A payload from a + /// newer server whose fields have changed meaning is rejected rather than + /// rendered — see `isSupported`. + public var schemaVersion: Int + /// When the **server** read the data, not when the client received it. Every + /// "updated 4m ago" on the Home Screen counts from here. + public var generatedAt: Date + /// Echoed by the server so a caller can prove the payload belongs to the + /// organization it asked for before overwriting that organization's cached + /// snapshot. There is deliberately no name: names come from the app's own + /// membership index, and a second source could put one organization's name + /// over another's numbers. + public var organizationId: String + public var issues: Issues + public var throughput: Throughput + + public struct Issues: Codable, Sendable, Equatable { + public var windowSeconds: Int + /// More ongoing issues exist than `data` carries, so a count derived from + /// it is a floor. The widget renders that as "20+". + public var hasMore: Bool + public var data: [Issue] + + public init(windowSeconds: Int, hasMore: Bool, data: [Issue]) { + self.windowSeconds = windowSeconds + self.hasMore = hasMore + self.data = data + } + + private enum CodingKeys: String, CodingKey { + case windowSeconds = "window_seconds" + case hasMore = "has_more" + case data + } + } + + /// One issue, carrying the *raw* naming fields rather than a rendered title. + /// The fallback between them is `WidgetIssueTitle`, which the app's issue + /// list uses too — a title that resolved differently in the two places would + /// read as two different issues. + public struct Issue: Codable, Sendable, Equatable { + public var id: String + public var exceptionType: String + public var errorLabel: String + public var exceptionMessage: String + public var serviceName: String + /// Null for an untriaged issue, and also for a severity this build does + /// not know: an unknown value decodes to nil rather than crashing a + /// widget that cannot be updated without an App Store release. + public var severity: String? + public var occurrenceCount: Double + public var lastSeenAt: Date + public var isRegressed: Bool + public var hasOpenIncident: Bool + + public init( + id: String, + exceptionType: String, + errorLabel: String, + exceptionMessage: String, + serviceName: String, + severity: String?, + occurrenceCount: Double, + lastSeenAt: Date, + isRegressed: Bool, + hasOpenIncident: Bool + ) { + self.id = id + self.exceptionType = exceptionType + self.errorLabel = errorLabel + self.exceptionMessage = exceptionMessage + self.serviceName = serviceName + self.severity = severity + self.occurrenceCount = occurrenceCount + self.lastSeenAt = lastSeenAt + self.isRegressed = isRegressed + self.hasOpenIncident = hasOpenIncident + } + + private enum CodingKeys: String, CodingKey { + case id + case exceptionType = "exception_type" + case errorLabel = "error_label" + case exceptionMessage = "exception_message" + case serviceName = "service_name" + case severity + case occurrenceCount = "occurrence_count" + case lastSeenAt = "last_seen_at" + case isRegressed = "is_regressed" + case hasOpenIncident = "has_open_incident" + } + } + + public struct Throughput: Codable, Sendable, Equatable { + public var windowSeconds: Int + /// The bucket length behind every `points` array. Null when no series + /// could be read, which is the signal to render the scalars without a + /// sparkline rather than guess a unit. + public var bucketSeconds: Int? + public var services: [Service] + /// The ungrouped organization series, in the same bucket counts as + /// `services[].points`. Not the sum of those: the per-service series is + /// capped at the charted few, so summing it would under-report a large + /// organization's shape. + public var totalPoints: [Double] + + public init(windowSeconds: Int, bucketSeconds: Int?, services: [Service], totalPoints: [Double]) { + self.windowSeconds = windowSeconds + self.bucketSeconds = bucketSeconds + self.services = services + self.totalPoints = totalPoints + } + + private enum CodingKeys: String, CodingKey { + case windowSeconds = "window_seconds" + case bucketSeconds = "bucket_seconds" + case services + case totalPoints = "total_points" + } + } + + public struct Service: Codable, Sendable, Equatable { + public var name: String + public var throughputPerSecond: Double + /// 0–1, not a percentage. + public var errorRate: Double + public var p95LatencyMs: Double + /// Span **counts** per bucket, oldest first. Divided by `bucketSeconds` + /// on the way into a snapshot so the sparkline and the headline provably + /// carry the same unit. + public var points: [Double] + + public init( + name: String, + throughputPerSecond: Double, + errorRate: Double, + p95LatencyMs: Double, + points: [Double] + ) { + self.name = name + self.throughputPerSecond = throughputPerSecond + self.errorRate = errorRate + self.p95LatencyMs = p95LatencyMs + self.points = points + } + + private enum CodingKeys: String, CodingKey { + case name + case throughputPerSecond = "throughput_per_second" + case errorRate = "error_rate" + case p95LatencyMs = "p95_latency_ms" + case points + } + } + + public init( + schemaVersion: Int, + generatedAt: Date, + organizationId: String, + issues: Issues, + throughput: Throughput + ) { + self.schemaVersion = schemaVersion + self.generatedAt = generatedAt + self.organizationId = organizationId + self.issues = issues + self.throughput = throughput + } + + private enum CodingKeys: String, CodingKey { + case schemaVersion = "schema_version" + case generatedAt = "generated_at" + case organizationId = "organization_id" + case issues + case throughput + } + + /// The version this build was written against. A payload above it may have + /// changed what an existing field *means*, which is the one thing a + /// tolerant decoder cannot absorb. + public static let supportedSchemaVersion = 1 + + public var isSupported: Bool { schemaVersion <= Self.supportedSchemaVersion } +} + +/// How an issue names itself, from the raw contract fields. +/// +/// Shared rather than private to either reader: the app's issue list and the +/// Home Screen widget show the same rows, and a title that falls back +/// differently between them reads as two different issues. +public enum WidgetIssueTitle { + /// The exception type, or — for the kinds that carry none (integration and + /// alert issues) — the label, or failing that the message. + public static func title(exceptionType: String, errorLabel: String, exceptionMessage: String) -> String { + let type = exceptionType.trimmingCharacters(in: .whitespacesAndNewlines) + if !type.isEmpty { return type } + let label = errorLabel.trimmingCharacters(in: .whitespacesAndNewlines) + return label.isEmpty ? exceptionMessage : label + } + + /// The message, suppressed when it would merely restate the title — which is + /// what happens once the title has fallen back to the label. + public static func subtitle( + exceptionType: String, + errorLabel: String, + exceptionMessage: String + ) -> String? { + let message = exceptionMessage.trimmingCharacters(in: .whitespacesAndNewlines) + let title = title( + exceptionType: exceptionType, + errorLabel: errorLabel, + exceptionMessage: exceptionMessage + ) + guard !message.isEmpty, !message.hasPrefix(title), !title.hasPrefix(message) else { return nil } + return message + } +} + +extension WidgetSummaryPayload { + /// The issues snapshot this payload describes. + /// + /// `IssuesSnapshot.make` still does the ranking, truncation, and counting: + /// the server returns a wider page than the widget draws precisely so that + /// `openCount` means something, and one place has to reconcile the headline + /// with the rows. + /// + /// - Parameter organizationName: from the app's membership index, never from + /// the payload. The index is corrected the moment memberships load, + /// whereas a name baked into a snapshot is only as current as the round + /// that wrote it. + public func issuesSnapshot(organizationName: String?) -> IssuesSnapshot { + IssuesSnapshot.make( + organizationId: organizationId, + organizationName: organizationName, + generatedAt: generatedAt, + issues: issues.data.map { issue in + WidgetIssue( + id: issue.id, + title: WidgetIssueTitle.title( + exceptionType: issue.exceptionType, + errorLabel: issue.errorLabel, + exceptionMessage: issue.exceptionMessage + ), + subtitle: WidgetIssueTitle.subtitle( + exceptionType: issue.exceptionType, + errorLabel: issue.errorLabel, + exceptionMessage: issue.exceptionMessage + ), + serviceName: issue.serviceName, + severity: issue.severity.flatMap(WidgetIssueSeverity.init(rawValue:)), + occurrenceCount: issue.occurrenceCount, + lastSeenAt: issue.lastSeenAt, + isRegressed: issue.isRegressed, + hasOpenIncident: issue.hasOpenIncident + ) + }, + hasMore: issues.hasMore + ) + } + + /// The throughput snapshot this payload describes. + /// + /// The organization total's *numbers* are summed from every service row — + /// which is why the server returns more of them than the widget charts — + /// while only its *shape* comes from the ungrouped series, the one thing + /// the rows cannot provide. + public func throughputSnapshot() -> ThroughputSnapshot { + let services = throughput.services.map { service in + ServiceThroughput( + name: service.name, + throughputPerSecond: service.throughputPerSecond, + errorRate: service.errorRate, + p95LatencyMs: service.p95LatencyMs, + points: perSecond(service.points) + ) + } + var overall = ServiceThroughput.total(of: services) + let total = perSecond(throughput.totalPoints) + if !total.isEmpty { overall.points = total } + return ThroughputSnapshot.make( + organizationId: organizationId, + generatedAt: generatedAt, + windowMinutes: throughput.windowSeconds / 60, + services: services, + overall: overall + ) + } + + /// Spans per bucket → spans per second, so the sparkline carries the same + /// unit as the headline. A missing or nonsensical bucket length leaves the + /// series out rather than drawing counts as if they were rates. + private func perSecond(_ values: [Double]) -> [Double] { + guard let bucketSeconds = throughput.bucketSeconds, bucketSeconds > 0 else { return [] } + return values.map { $0 / Double(bucketSeconds) } + } +} diff --git a/apps/ios/Packages/MapleAPI/Sources/MapleWidgetData/WidgetSummaryFetcher.swift b/apps/ios/Packages/MapleAPI/Sources/MapleWidgetData/WidgetSummaryFetcher.swift new file mode 100644 index 000000000..13c1ab321 --- /dev/null +++ b/apps/ios/Packages/MapleAPI/Sources/MapleWidgetData/WidgetSummaryFetcher.swift @@ -0,0 +1,217 @@ +import Foundation + +/// The widget extension's own client for `GET /v2/widget_summary`. +/// +/// Hand-written, and deliberately so. The extension does not link `MapleAPI`: +/// that is 30k lines of generated code plus `OpenAPIRuntime`, inside a process +/// the system gives roughly 30MB and a few seconds of wall clock. One endpoint +/// it can decode by hand is the only shape that fits — which is also why the +/// endpoint exists at all. +/// +/// Everything here is written so that **failing changes nothing**. A widget +/// never shows an error; it shows the last snapshot it has, with an honest age. +/// So the fetch is an enrichment of a timeline that was already going to be +/// built, never a precondition for building one. +public actor WidgetSummaryFetcher { + /// Shared per process. WidgetKit builds each pinned instance's timeline + /// separately — issues-small, issues-medium and throughput can all be woken + /// for the same organization at once — and without one place to coalesce + /// them that is three identical requests for one answer. + public static let shared = WidgetSummaryFetcher() + + /// The provider's whole budget, not just the request's. + /// + /// The system kills a slow timeline provider, and a killed provider costs a + /// rebuild from a metered budget having rendered nothing. Five seconds is + /// enough for a small payload on a bad connection and short enough to leave + /// room to fall back and still return a timeline. + public static let deadline: TimeInterval = 5 + + /// Below this age, the stored snapshot is used as-is. + /// + /// The app publishes on every foreground, so without a floor a widget woken + /// moments later would re-fetch what it already has. + public static let freshnessFloor: TimeInterval = 2 * 60 + + /// How long an in-flight attempt suppresses another one. + /// + /// The in-process coalescing below covers three providers in one extension; + /// this covers the app and the extension racing, which are different + /// processes and share nothing but the App Group. + public static let attemptLock: TimeInterval = 60 + + private let credentials: WidgetCredentialStore + private let fetchStates: WidgetFetchStateStore + private let session: URLSession + private var inFlight: [String: Task] = [:] + + public init( + credentials: WidgetCredentialStore = WidgetCredentialStore(), + fetchStates: WidgetFetchStateStore = WidgetFetchStateStore(), + session: URLSession? = nil + ) { + self.credentials = credentials + self.fetchStates = fetchStates + if let session { + self.session = session + } else { + let configuration = URLSessionConfiguration.ephemeral + configuration.timeoutIntervalForRequest = WidgetSummaryFetcher.deadline + configuration.timeoutIntervalForResource = WidgetSummaryFetcher.deadline + // A provider that waits for connectivity is a provider that gets + // killed. Offline is an answer here, and a fast one. + configuration.waitsForConnectivity = false + self.session = URLSession(configuration: configuration) + } + } + + /// Why a fetch was skipped, or that one ran. Returned so a caller can put it + /// on a timeline policy without re-deriving it. + public enum Attempt: Sendable, Equatable { + /// The stored snapshot is younger than `freshnessFloor`. + case fresh + /// No credential yet: the app has not run, or has not covered this + /// organization. + case noCredential + /// The credential has expired, or the server rejected it. Only the app + /// can fix either. + case needsApp + /// Another provider — or the app — is already on it. + case coalesced + case fetched(WidgetSummaryPayload) + case failed + } + + /// Fetch this organization's summary, unless there is a reason not to. + /// + /// Writes a successful payload's snapshots into the App Group **before** + /// returning, so a provider killed on the way to rendering still leaves the + /// data behind for the next rebuild. + public func fetch( + organizationId: String, + organizationName: String?, + storedGeneratedAt: Date?, + now: Date = Date() + ) async -> Attempt { + if let storedGeneratedAt, now.timeIntervalSince(storedGeneratedAt) < Self.freshnessFloor { + return .fresh + } + + let state = fetchStates.load(organizationId: organizationId) + // A rolled credential answers 401 forever. Retrying on every rebuild + // would spend the entire refresh budget on failures and leave the widget + // no fresher than not trying at all. + if state.isCredentialRejected { return .needsApp } + // The app and the extension are different processes and share nothing but + // the App Group, so this is the only thing standing between them when a + // foreground and a timeline rebuild land in the same second. + if state.isInFlight(at: now, within: Self.attemptLock) { return .coalesced } + + guard let credential = credentials.load(organizationId: organizationId) else { + return .noCredential + } + // Expiry is checked here rather than left to the 401: a credential the + // app has simply not renewed yet is not a rejected one, and spending a + // request to be told so helps nobody. + if credential.isExpired(at: now) { return .needsApp } + + if let existing = inFlight[organizationId] { + return await existing.value.map(Attempt.fetched) ?? .coalesced + } + + // Stamped before the request goes out, so a second process starting in + // the same second sees an attempt in progress rather than none. + fetchStates.save(state.attempting(at: now), organizationId: organizationId) + + let task = Task { [credentials, fetchStates, session] in + let outcome = await Self.request(credential: credential, session: session) + switch outcome { + case .success(let payload): + // The organization the credential is bound to is the one the + // server answers for. A disagreement would write one + // organization's numbers under another's name — invisible until + // someone reads the wrong figure off their Home Screen. + guard payload.organizationId == organizationId, payload.isSupported else { + fetchStates.save( + fetchStates.load(organizationId: organizationId) + .recording(.undecodable, at: Date()), + organizationId: organizationId + ) + return nil + } + // Saved before the caller gets it back: a provider killed between + // here and rendering still leaves the data for the next rebuild. + WidgetSnapshotStore + .issues(organizationId: organizationId) + .save(payload.issuesSnapshot(organizationName: organizationName)) + WidgetSnapshotStore + .throughput(organizationId: organizationId) + .save(payload.throughputSnapshot()) + fetchStates.save( + fetchStates.load(organizationId: organizationId).recording(.success, at: Date()), + organizationId: organizationId + ) + _ = credentials + return payload + case .failure(let reason): + fetchStates.save( + fetchStates.load(organizationId: organizationId).recording(reason, at: Date()), + organizationId: organizationId + ) + return nil + } + } + inFlight[organizationId] = task + let payload = await task.value + inFlight[organizationId] = nil + return payload.map(Attempt.fetched) ?? .failed + } + + private enum RequestOutcome { + case success(WidgetSummaryPayload) + case failure(WidgetFetchState.Outcome) + } + + private static func request( + credential: WidgetCredential, + session: URLSession + ) async -> RequestOutcome { + var request = URLRequest( + url: credential.apiBaseURL.appendingPathComponent("v2/widget_summary"), + timeoutInterval: deadline + ) + request.httpMethod = "GET" + request.setValue("Bearer \(credential.secret)", forHTTPHeaderField: "Authorization") + request.setValue("application/json", forHTTPHeaderField: "Accept") + // Deliberately no `x-maple-org-id`. An API key is already bound to one + // organization and the server rejects a header that disagrees, so the + // only way to get this wrong is to send it. + + do { + let (data, response) = try await session.data(for: request) + guard let http = response as? HTTPURLResponse else { return .failure(.server) } + switch http.statusCode { + case 200: + // `WidgetJSON.decoder`, never a plain `.iso8601` one: the API sends + // timestamps with milliseconds and `.iso8601` rejects those on the + // deployment target. See WidgetJSON. + guard let payload = try? WidgetJSON.decoder.decode(WidgetSummaryPayload.self, from: data) + else { + return .failure(.undecodable) + } + return .success(payload) + // 403 belongs here too: a credential whose scopes no longer reach + // this endpoint is as dead as one that was revoked, and the app is + // the only thing that can mint a working replacement. + case 401, 403: + return .failure(.unauthorized) + default: + return .failure(.server) + } + } catch { + // Offline, DNS, TLS, or the deadline. All the same to a widget: try + // again sooner than a server error, and render what it has meanwhile. + return .failure(.unreachable) + } + } +} diff --git a/apps/ios/Packages/MapleAPI/Sources/MapleWidgetData/WidgetTimelineRefresh.swift b/apps/ios/Packages/MapleAPI/Sources/MapleWidgetData/WidgetTimelineRefresh.swift new file mode 100644 index 000000000..095dda35a --- /dev/null +++ b/apps/ios/Packages/MapleAPI/Sources/MapleWidgetData/WidgetTimelineRefresh.swift @@ -0,0 +1,91 @@ +import Foundation + +/// The one place a timeline provider turns "I am awake" into "and now the data +/// is current". +/// +/// Both widgets do exactly the same thing here, and the interesting part — the +/// order of operations — is easy to get subtly wrong in two places: the fetch +/// must be an **enrichment** of a timeline that was already going to be built, +/// never a precondition. A provider that returns nothing costs a rebuild from a +/// metered budget and leaves whatever is on screen frozen for another hour. +public enum WidgetTimelineRefresh { + /// What the provider should do with the result. + public struct Outcome: Sendable { + /// When to ask for the next timeline. Already backed off if the fetch + /// failed, so a permanently broken widget stops spending the budget. + public let refreshDate: Date + /// The App Group has newer data — re-read the snapshot before building + /// entries. + public let didFetch: Bool + + public init(refreshDate: Date, didFetch: Bool) { + self.refreshDate = refreshDate + self.didFetch = didFetch + } + } + + /// Bring this organization's snapshots up to date if there is any point, and + /// say when to come back. + /// + /// - Parameters: + /// - organizationId: nil before the app has ever published — nothing to + /// fetch for, and nothing to fetch with. + /// - organizationName: from the widget's organization index, passed + /// through so a fetched snapshot is named the same way a published one + /// is. + /// - storedGeneratedAt: the snapshot already on disk, so a widget woken + /// moments after the app published does not re-fetch what it has. + public static func run( + organizationId: String?, + organizationName: String?, + storedGeneratedAt: Date?, + now: Date = Date(), + fetcher: WidgetSummaryFetcher = .shared, + fetchStates: WidgetFetchStateStore = WidgetFetchStateStore() + ) async -> Outcome { + guard let organizationId else { + return Outcome(refreshDate: WidgetTimelineSchedule.refreshDate(from: now), didFetch: false) + } + + let attempt = await fetcher.fetch( + organizationId: organizationId, + organizationName: organizationName, + storedGeneratedAt: storedGeneratedAt, + now: now + ) + + // Read *after* the attempt: the fetcher is what writes the failure count + // this backoff is derived from. + let state = fetchStates.load(organizationId: organizationId) + let failures: Int + switch attempt { + // Nothing is wrong. A fetch that succeeded, a snapshot still inside the + // freshness floor, and an attempt someone else is already making are all + // the healthy interval — none of them should inherit a backoff from a + // failure that has since been superseded. + case .fetched, .fresh, .coalesced: + failures = 0 + // The app has not covered this organization yet. It will, on its next + // foreground, and it reloads the timeline when it does — so there is + // nothing to back off from and no reason to hurry. + case .noCredential: + failures = 0 + // Expired or rejected. Only the app can mint a working replacement, and + // it reloads the timeline when it has — so back all the way off rather + // than spend the day's rebuilds on a credential that will answer 401 to + // every one of them. + case .needsApp: + failures = Int.max + case .failed: + failures = state.consecutiveFailures + } + + return Outcome( + refreshDate: WidgetTimelineSchedule.refreshDate(from: now, consecutiveFailures: failures), + didFetch: { + if case .fetched = attempt { return true } + return false + }() + ) + } +} diff --git a/apps/ios/Packages/MapleAPI/Sources/MapleWidgetData/WidgetTimelineSchedule.swift b/apps/ios/Packages/MapleAPI/Sources/MapleWidgetData/WidgetTimelineSchedule.swift index 5368beb9b..c12549abf 100644 --- a/apps/ios/Packages/MapleAPI/Sources/MapleWidgetData/WidgetTimelineSchedule.swift +++ b/apps/ios/Packages/MapleAPI/Sources/MapleWidgetData/WidgetTimelineSchedule.swift @@ -26,16 +26,45 @@ public enum WidgetTimelineSchedule { /// hour old. public static let offsetMinutes: [Int] = [0, 1, 2, 5, 10, 15, 20, 30, 45, 60, 90, 120] - /// One timeline request an hour. iOS meters those against the same budget as - /// the app's `reloadTimelines` calls, so this is the half of the budget the - /// widget spends on its own; see `WidgetPublisher` for the other half. - public static let refreshAfter: TimeInterval = 60 * 60 + /// One timeline request roughly every three quarters of an hour. + /// + /// **Deliberately not shortened now that the widget fetches for itself.** + /// The date in a `TimelineReloadPolicy` is a floor, not a promise: iOS grants + /// rebuilds from a budget derived from how often the widget is actually + /// looked at, and asking four times as often does not produce four times as + /// many. What it does produce is a widget that spends its whole allotment by + /// mid-afternoon and goes cold in the evening — which is exactly when an + /// on-call user needs it. Every granted rebuild now returns fresh data + /// instead of re-rendering what is already on screen; that is where the win + /// is, not in asking more often. + public static let refreshAfter: TimeInterval = 45 * 60 public static func entryDates(from date: Date) -> [Date] { offsetMinutes.map { date.addingTimeInterval(Double($0) * 60) } } - public static func refreshDate(from date: Date) -> Date { - date.addingTimeInterval(refreshAfter) + /// When to come back, given how the last fetches went. + /// + /// A flat interval spends the same budget whether the widget is healthy or + /// permanently broken. A credential the app has to re-mint answers 401 on + /// every attempt, and without a backoff those attempts are the entire day's + /// rebuilds — so a widget that cannot fix itself must stop asking so often + /// and let the app's own reload wake it when there is something to say. + /// + /// The short retry sits at *one* failure, not at repeated ones: a single + /// miss is usually a tunnel or a dropped connection, and being back inside a + /// quarter of an hour is worth one rebuild. + public static func refreshDate(from date: Date, consecutiveFailures: Int = 0) -> Date { + date.addingTimeInterval(refreshInterval(consecutiveFailures: consecutiveFailures)) + } + + public static func refreshInterval(consecutiveFailures: Int) -> TimeInterval { + switch consecutiveFailures { + case ..<1: refreshAfter + case 1: 15 * 60 + case 2: 30 * 60 + case 3: 60 * 60 + default: 4 * 60 * 60 + } } } diff --git a/apps/ios/Packages/MapleAPI/Tests/MapleWidgetDataTests/WidgetCredentialStoreTests.swift b/apps/ios/Packages/MapleAPI/Tests/MapleWidgetDataTests/WidgetCredentialStoreTests.swift new file mode 100644 index 000000000..9ce5e33e3 --- /dev/null +++ b/apps/ios/Packages/MapleAPI/Tests/MapleWidgetDataTests/WidgetCredentialStoreTests.swift @@ -0,0 +1,120 @@ +import Foundation +import Testing + +@testable import MapleWidgetData + +private let now = Date(timeIntervalSince1970: 1_800_000_000) + +private func credential( + organizationId: String = "org_1", + secret: String = "maple_ak_test", + expiresIn: TimeInterval = 30 * 24 * 60 * 60 +) -> WidgetCredential { + WidgetCredential( + organizationId: organizationId, + secret: secret, + apiBaseURL: URL(string: "https://api.maple.test")!, + expiresAt: now.addingTimeInterval(expiresIn), + mintedAt: now + ) +} + +private func withStore(_ body: (WidgetCredentialStore) throws -> Void) rethrows { + let directory = URL(fileURLWithPath: NSTemporaryDirectory()) + .appendingPathComponent("widget-credentials-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: directory) } + try body(WidgetCredentialStore(directory: directory)) +} + +@Suite("Widget credential") +struct WidgetCredentialTests { + @Test("renews with a week to spare, not on the day") + func renewalWindow() { + // A phone opened at weekends must not be one bad Monday from a Home + // Screen that has gone quiet with no way back. + #expect(credential(expiresIn: 30 * 24 * 3_600).needsRenewal(at: now) == false) + #expect(credential(expiresIn: 8 * 24 * 3_600).needsRenewal(at: now) == false) + #expect(credential(expiresIn: 6 * 24 * 3_600).needsRenewal(at: now)) + #expect(credential(expiresIn: -1).needsRenewal(at: now)) + } + + @Test("knows when it has stopped working") + func expiry() { + #expect(credential(expiresIn: 60).isExpired(at: now) == false) + #expect(credential(expiresIn: -60).isExpired(at: now)) + } +} + +@Suite("Widget credential store") +struct WidgetCredentialStoreTests { + @Test("round-trips a credential per organization") + func roundTrips() { + withStore { store in + store.save(credential(organizationId: "org_1", secret: "one")) + store.save(credential(organizationId: "org_2", secret: "two")) + #expect(store.load(organizationId: "org_1")?.secret == "one") + #expect(store.load(organizationId: "org_2")?.secret == "two") + } + } + + @Test("reads nothing before the app has ever minted") + func emptyBeforeFirstMint() { + withStore { store in + #expect(store.load(organizationId: "org_1") == nil) + } + } + + @Test("refuses a credential filed under the wrong organization") + func rejectsMismatch() { + withStore { store in + // Writing org_2's credential to org_1's file is not something the app + // does — but it is exactly the shape of the bug that renders one + // organization's numbers under another's name, so it fails closed. + let directory = URL(fileURLWithPath: NSTemporaryDirectory()) + .appendingPathComponent("widget-credentials-mismatch-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: directory) } + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + try? encoder + .encode(credential(organizationId: "org_2")) + .write(to: directory.appendingPathComponent("widget-org_1.json")) + #expect(WidgetCredentialStore(directory: directory).load(organizationId: "org_1") == nil) + _ = store + } + } + + @Test("one organization's revoke leaves the others alone") + func clearsOne() { + withStore { store in + store.save(credential(organizationId: "org_1")) + store.save(credential(organizationId: "org_2")) + store.clear(organizationId: "org_1") + #expect(store.load(organizationId: "org_1") == nil) + #expect(store.load(organizationId: "org_2") != nil) + } + } + + @Test("sign-out leaves nothing the next account could fetch with") + func clearsAll() { + withStore { store in + store.save(credential(organizationId: "org_1")) + store.save(credential(organizationId: "org_2")) + store.clearAll() + #expect(store.load(organizationId: "org_1") == nil) + #expect(store.load(organizationId: "org_2") == nil) + // And the store still works afterwards — sign-out is not terminal. + store.save(credential(organizationId: "org_3")) + #expect(store.load(organizationId: "org_3") != nil) + } + } + + @Test("an organization id that is not a filename cannot become one") + func rejectsPathishIds() { + withStore { store in + #expect(store.save(credential(organizationId: "../../escape")) == false) + #expect(store.load(organizationId: "../../escape") == nil) + #expect(store.save(credential(organizationId: "")) == false) + } + } +} diff --git a/apps/ios/Packages/MapleAPI/Tests/MapleWidgetDataTests/WidgetSummaryFetcherTests.swift b/apps/ios/Packages/MapleAPI/Tests/MapleWidgetDataTests/WidgetSummaryFetcherTests.swift new file mode 100644 index 000000000..af5b653cf --- /dev/null +++ b/apps/ios/Packages/MapleAPI/Tests/MapleWidgetDataTests/WidgetSummaryFetcherTests.swift @@ -0,0 +1,459 @@ +import Foundation +import Testing + +@testable import MapleWidgetData + +private let now = Date(timeIntervalSince1970: 1_800_000_000) +private let organizationId = "org_fetch" + +/// A transport that answers from a script instead of a network, and counts what +/// it was asked for — which is how the coalescing assertions below can tell "one +/// request served three providers" from "three requests happened to agree". +private final class StubProtocol: URLProtocol, @unchecked Sendable { + nonisolated(unsafe) static var status = 200 + nonisolated(unsafe) static var body = Data() + nonisolated(unsafe) static var failure: Error? + nonisolated(unsafe) static var requestCount = 0 + nonisolated(unsafe) static var delay: TimeInterval = 0 + + static func reset() { + status = 200 + body = Data() + failure = nil + requestCount = 0 + delay = 0 + } + + override class func canInit(with request: URLRequest) -> Bool { true } + override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } + override func stopLoading() {} + + override func startLoading() { + Self.requestCount += 1 + let status = Self.status + let body = Self.body + let failure = Self.failure + let url = request.url! + let finish = { + if let failure { + self.client?.urlProtocol(self, didFailWithError: failure) + return + } + let response = HTTPURLResponse( + url: url, + statusCode: status, + httpVersion: "HTTP/1.1", + headerFields: ["Content-Type": "application/json"] + )! + self.client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + self.client?.urlProtocol(self, didLoad: body) + self.client?.urlProtocolDidFinishLoading(self) + } + if Self.delay > 0 { + DispatchQueue.global().asyncAfter(deadline: .now() + Self.delay, execute: finish) + } else { + finish() + } + } +} + +private func stubSession() -> URLSession { + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [StubProtocol.self] + configuration.timeoutIntervalForRequest = 1 + configuration.waitsForConnectivity = false + return URLSession(configuration: configuration) +} + +private func payloadJSON(organizationId: String = organizationId, schemaVersion: Int = 1) -> Data { + Data( + """ + { + "object": "widget_summary", + "schema_version": \(schemaVersion), + "generated_at": "2027-01-15T08:00:00.000Z", + "organization_id": "\(organizationId)", + "issues": {"window_seconds": 86400, "has_more": false, "data": []}, + "throughput": { + "window_seconds": 3600, "bucket_seconds": 60, + "services": [{"name":"api","throughput_per_second":10,"error_rate":0,"p95_latency_ms":5,"points":[600]}], + "total_points": [600] + } + } + """.utf8 + ) +} + +/// A store trio pointed at throwaway state, so nothing here touches the real +/// App Group. +private struct Harness { + let suite: String + let directory: URL + let credentials: WidgetCredentialStore + let fetchStates: WidgetFetchStateStore + let fetcher: WidgetSummaryFetcher + + init() { + suite = "widget-fetch-tests-\(UUID().uuidString)" + directory = URL(fileURLWithPath: NSTemporaryDirectory()) + .appendingPathComponent(suite, isDirectory: true) + credentials = WidgetCredentialStore(directory: directory) + fetchStates = WidgetFetchStateStore(appGroupIdentifier: suite) + fetcher = WidgetSummaryFetcher( + credentials: credentials, + fetchStates: fetchStates, + session: stubSession() + ) + } + + func credential(expiresIn: TimeInterval = 30 * 24 * 3_600) { + credentials.save( + WidgetCredential( + organizationId: organizationId, + secret: "maple_ak_test", + apiBaseURL: URL(string: "https://api.maple.test")!, + expiresAt: now.addingTimeInterval(expiresIn), + mintedAt: now + ) + ) + } + + func cleanUp() { + try? FileManager.default.removeItem(at: directory) + UserDefaults(suiteName: suite)?.removePersistentDomain(forName: suite) + } +} + +@Suite("Widget summary fetcher", .serialized) +struct WidgetSummaryFetcherTests { + @Test("does not fetch when the app just published") + func skipsFreshSnapshots() async { + StubProtocol.reset() + let harness = Harness() + defer { harness.cleanUp() } + harness.credential() + + let attempt = await harness.fetcher.fetch( + organizationId: organizationId, + organizationName: nil, + // Inside the freshness floor: the app publishes on every foreground, + // so a widget woken moments later already has this. + storedGeneratedAt: now.addingTimeInterval(-30), + now: now + ) + #expect(attempt == .fresh) + #expect(StubProtocol.requestCount == 0) + } + + @Test("says so rather than fetching when the app has not minted yet") + func noCredential() async { + StubProtocol.reset() + let harness = Harness() + defer { harness.cleanUp() } + + let attempt = await harness.fetcher.fetch( + organizationId: organizationId, + organizationName: nil, + storedGeneratedAt: nil, + now: now + ) + #expect(attempt == .noCredential) + #expect(StubProtocol.requestCount == 0) + } + + @Test("does not spend a request to be told a credential it knows is expired is expired") + func expiredCredential() async { + StubProtocol.reset() + let harness = Harness() + defer { harness.cleanUp() } + harness.credential(expiresIn: -60) + + let attempt = await harness.fetcher.fetch( + organizationId: organizationId, + organizationName: nil, + storedGeneratedAt: nil, + now: now + ) + #expect(attempt == .needsApp) + #expect(StubProtocol.requestCount == 0) + } + + @Test("writes both widgets' snapshots from one response") + func writesBothSnapshots() async { + StubProtocol.reset() + StubProtocol.body = payloadJSON() + let harness = Harness() + defer { harness.cleanUp() } + harness.credential() + + let attempt = await harness.fetcher.fetch( + organizationId: organizationId, + organizationName: "Acme", + storedGeneratedAt: nil, + now: now + ) + guard case .fetched = attempt else { + Issue.record("expected a fetch, got \(attempt)") + return + } + #expect(StubProtocol.requestCount == 1) + #expect(harness.fetchStates.load(organizationId: organizationId).lastOutcome == .success) + #expect(harness.fetchStates.load(organizationId: organizationId).consecutiveFailures == 0) + } + + @Test("treats a rejected credential as terminal so it cannot burn the budget") + func unauthorizedIsTerminal() async { + StubProtocol.reset() + StubProtocol.status = 401 + let harness = Harness() + defer { harness.cleanUp() } + harness.credential() + + let first = await harness.fetcher.fetch( + organizationId: organizationId, + organizationName: nil, + storedGeneratedAt: nil, + now: now + ) + #expect(first == .failed) + #expect(harness.fetchStates.load(organizationId: organizationId).isCredentialRejected) + + // A rolled credential answers 401 forever. Every rebuild spent retrying + // is one the widget does not get to be current in. + let second = await harness.fetcher.fetch( + organizationId: organizationId, + organizationName: nil, + storedGeneratedAt: nil, + now: now.addingTimeInterval(3_600) + ) + #expect(second == .needsApp) + #expect(StubProtocol.requestCount == 1) + + // Only the app can lift it. + harness.fetchStates.clearCredentialRejection(organizationId: organizationId) + #expect(harness.fetchStates.load(organizationId: organizationId).isCredentialRejected == false) + } + + @Test("a 403 is as dead as a 401 — scopes it no longer has, not a retryable error") + func forbiddenIsTerminal() async { + StubProtocol.reset() + StubProtocol.status = 403 + let harness = Harness() + defer { harness.cleanUp() } + harness.credential() + + _ = await harness.fetcher.fetch( + organizationId: organizationId, + organizationName: nil, + storedGeneratedAt: nil, + now: now + ) + #expect(harness.fetchStates.load(organizationId: organizationId).isCredentialRejected) + } + + @Test("counts failures so the timeline can back off") + func countsFailures() async { + StubProtocol.reset() + StubProtocol.status = 503 + let harness = Harness() + defer { harness.cleanUp() } + harness.credential() + + for index in 1...3 { + _ = await harness.fetcher.fetch( + organizationId: organizationId, + organizationName: nil, + storedGeneratedAt: nil, + // Past the attempt lock each time, so this counts failures rather + // than coalescing. + now: now.addingTimeInterval(Double(index) * 600) + ) + } + let state = harness.fetchStates.load(organizationId: organizationId) + #expect(state.consecutiveFailures == 3) + #expect(state.lastOutcome == .server) + #expect(state.isCredentialRejected == false) + } + + @Test("a success clears the backoff") + func successResets() async { + StubProtocol.reset() + StubProtocol.status = 503 + let harness = Harness() + defer { harness.cleanUp() } + harness.credential() + _ = await harness.fetcher.fetch( + organizationId: organizationId, + organizationName: nil, + storedGeneratedAt: nil, + now: now + ) + #expect(harness.fetchStates.load(organizationId: organizationId).consecutiveFailures == 1) + + StubProtocol.status = 200 + StubProtocol.body = payloadJSON() + _ = await harness.fetcher.fetch( + organizationId: organizationId, + organizationName: nil, + storedGeneratedAt: nil, + now: now.addingTimeInterval(600) + ) + #expect(harness.fetchStates.load(organizationId: organizationId).consecutiveFailures == 0) + } + + @Test("refuses a payload for a different organization") + func rejectsWrongOrganization() async { + StubProtocol.reset() + // One organization's numbers under another's name is invisible until + // someone reads the wrong figure off their Home Screen. + StubProtocol.body = payloadJSON(organizationId: "org_somebody_else") + let harness = Harness() + defer { harness.cleanUp() } + harness.credential() + + let attempt = await harness.fetcher.fetch( + organizationId: organizationId, + organizationName: nil, + storedGeneratedAt: nil, + now: now + ) + #expect(attempt == .failed) + #expect(harness.fetchStates.load(organizationId: organizationId).lastOutcome == .undecodable) + } + + @Test("refuses a payload from a newer server") + func rejectsFutureSchema() async { + StubProtocol.reset() + StubProtocol.body = payloadJSON(schemaVersion: WidgetSummaryPayload.supportedSchemaVersion + 1) + let harness = Harness() + defer { harness.cleanUp() } + harness.credential() + + let attempt = await harness.fetcher.fetch( + organizationId: organizationId, + organizationName: nil, + storedGeneratedAt: nil, + now: now + ) + #expect(attempt == .failed) + } + + @Test("three providers woken together make one request between them") + func coalesces() async { + StubProtocol.reset() + StubProtocol.body = payloadJSON() + // Long enough that all three are in flight at once, which is the case + // that matters: WidgetKit builds each pinned instance separately. + StubProtocol.delay = 0.2 + let harness = Harness() + defer { harness.cleanUp() } + harness.credential() + + async let a = harness.fetcher.fetch( + organizationId: organizationId, organizationName: nil, storedGeneratedAt: nil, now: now) + async let b = harness.fetcher.fetch( + organizationId: organizationId, organizationName: nil, storedGeneratedAt: nil, now: now) + async let c = harness.fetcher.fetch( + organizationId: organizationId, organizationName: nil, storedGeneratedAt: nil, now: now) + _ = await (a, b, c) + + #expect(StubProtocol.requestCount == 1) + } + + @Test("waits for an attempt another process already has in flight") + func crossProcessLock() async { + StubProtocol.reset() + let harness = Harness() + defer { harness.cleanUp() } + harness.credential() + + // The app stamped an attempt a moment ago and has not reported back. This + // has to keep working *after* the first successful fetch — inferring + // in-flight from "no outcome yet" would stop working the moment it first + // worked. + harness.fetchStates.save( + WidgetFetchState(lastOutcome: .success, consecutiveFailures: 0).attempting(at: now), + organizationId: organizationId + ) + let attempt = await harness.fetcher.fetch( + organizationId: organizationId, + organizationName: nil, + storedGeneratedAt: nil, + now: now.addingTimeInterval(5) + ) + #expect(attempt == .coalesced) + #expect(StubProtocol.requestCount == 0) + } + + @Test("a lock nothing released expires rather than freezing the widget") + func staleLockExpires() async { + StubProtocol.reset() + StubProtocol.body = payloadJSON() + let harness = Harness() + defer { harness.cleanUp() } + harness.credential() + + // A process killed mid-fetch never clears the flag. + harness.fetchStates.save( + WidgetFetchState().attempting(at: now), + organizationId: organizationId + ) + let attempt = await harness.fetcher.fetch( + organizationId: organizationId, + organizationName: nil, + storedGeneratedAt: nil, + now: now.addingTimeInterval(WidgetSummaryFetcher.attemptLock + 1) + ) + guard case .fetched = attempt else { + Issue.record("expected a fetch, got \(attempt)") + return + } + } + + @Test("offline is an answer, and a fast one") + func offline() async { + StubProtocol.reset() + StubProtocol.failure = URLError(.notConnectedToInternet) + let harness = Harness() + defer { harness.cleanUp() } + harness.credential() + + let attempt = await harness.fetcher.fetch( + organizationId: organizationId, + organizationName: nil, + storedGeneratedAt: nil, + now: now + ) + #expect(attempt == .failed) + #expect(harness.fetchStates.load(organizationId: organizationId).lastOutcome == .unreachable) + } +} + +@Suite("Widget timeline backoff") +struct WidgetTimelineBackoffTests { + @Test("a healthy widget asks on the normal interval") + func healthyInterval() { + #expect(WidgetTimelineSchedule.refreshInterval(consecutiveFailures: 0) == 45 * 60) + } + + @Test("one miss is worth a quick retry; repeated failure is not") + func backsOff() { + let intervals = (0...5).map { WidgetTimelineSchedule.refreshInterval(consecutiveFailures: $0) } + // A single miss is usually a tunnel — back inside a quarter of an hour. + #expect(intervals[1] == 15 * 60) + // After that, monotonically further out, so a widget that cannot fix + // itself stops spending the day's rebuilds on failures. + #expect(intervals[2] > intervals[1]) + #expect(intervals[3] > intervals[2]) + #expect(intervals[4] > intervals[3]) + #expect(intervals[5] == intervals[4]) + #expect(intervals[4] == 4 * 60 * 60) + } + + @Test("the entry ladder still reaches past the refresh, so a throttled widget ages honestly") + func ladderOutlastsThePolicy() { + let dates = WidgetTimelineSchedule.entryDates(from: now) + let refresh = WidgetTimelineSchedule.refreshDate(from: now) + #expect(dates.last! > refresh) + } +} diff --git a/apps/ios/Packages/MapleAPI/Tests/MapleWidgetDataTests/WidgetSummaryTests.swift b/apps/ios/Packages/MapleAPI/Tests/MapleWidgetDataTests/WidgetSummaryTests.swift new file mode 100644 index 000000000..f9c104842 --- /dev/null +++ b/apps/ios/Packages/MapleAPI/Tests/MapleWidgetDataTests/WidgetSummaryTests.swift @@ -0,0 +1,286 @@ +import Foundation +import Testing + +@testable import MapleWidgetData + +private let now = Date(timeIntervalSince1970: 1_800_000_000) + +private func issue( + id: String = "iss_1", + exceptionType: String = "TypeError", + errorLabel: String = "checkout", + exceptionMessage: String = "Cannot read properties of undefined", + severity: String? = "critical", + count: Double = 412, + lastSeen: Date = now.addingTimeInterval(-600), + regressed: Bool = false, + paging: Bool = false +) -> WidgetSummaryPayload.Issue { + WidgetSummaryPayload.Issue( + id: id, + exceptionType: exceptionType, + errorLabel: errorLabel, + exceptionMessage: exceptionMessage, + serviceName: "api", + severity: severity, + occurrenceCount: count, + lastSeenAt: lastSeen, + isRegressed: regressed, + hasOpenIncident: paging + ) +} + +private func payload( + issues: [WidgetSummaryPayload.Issue] = [issue()], + hasMore: Bool = false, + bucketSeconds: Int? = 300, + services: [WidgetSummaryPayload.Service] = [], + totalPoints: [Double] = [], + schemaVersion: Int = WidgetSummaryPayload.supportedSchemaVersion +) -> WidgetSummaryPayload { + WidgetSummaryPayload( + schemaVersion: schemaVersion, + generatedAt: now, + organizationId: "org_1", + issues: .init(windowSeconds: 86_400, hasMore: hasMore, data: issues), + throughput: .init( + windowSeconds: 3_600, + bucketSeconds: bucketSeconds, + services: services, + totalPoints: totalPoints + ) + ) +} + +private func service( + _ name: String, + throughput: Double = 12, + errorRate: Double = 0.01, + p95: Double = 100, + points: [Double] = [] +) -> WidgetSummaryPayload.Service { + WidgetSummaryPayload.Service( + name: name, + throughputPerSecond: throughput, + errorRate: errorRate, + p95LatencyMs: p95, + points: points + ) +} + +@Suite("Widget summary wire") +struct WidgetSummaryWireTests { + @Test("decodes the server's snake_case payload") + func decodesWire() throws { + let json = """ + { + "object": "widget_summary", + "schema_version": 1, + "generated_at": "2027-01-15T08:00:00.000Z", + "organization_id": "org_1", + "issues": { + "window_seconds": 86400, + "has_more": true, + "data": [{ + "id": "iss_1", + "exception_type": "TypeError", + "error_label": "checkout", + "exception_message": "boom", + "service_name": "api", + "severity": "critical", + "occurrence_count": 412, + "last_seen_at": "2027-01-15T07:58:00.000Z", + "is_regressed": false, + "has_open_incident": true + }] + }, + "throughput": { + "window_seconds": 3600, + "bucket_seconds": 300, + "services": [{ + "name": "api", + "throughput_per_second": 12.5, + "error_rate": 0.01, + "p95_latency_ms": 184, + "points": [3600, 3720] + }], + "total_points": [5200, 5310] + } + } + """ + let decoded = try WidgetJSON.decoder.decode(WidgetSummaryPayload.self, from: Data(json.utf8)) + + #expect(decoded.isSupported) + #expect(decoded.organizationId == "org_1") + #expect(decoded.issues.hasMore) + #expect(decoded.issues.data.first?.exceptionType == "TypeError") + #expect(decoded.throughput.bucketSeconds == 300) + #expect(decoded.throughput.services.first?.points == [3600, 3720]) + } + + @Test("a bucket_seconds of null decodes rather than failing the whole payload") + func decodesNullBucketSeconds() throws { + let json = """ + {"schema_version":1,"generated_at":"2027-01-15T08:00:00.000Z","organization_id":"org_1", + "issues":{"window_seconds":86400,"has_more":false,"data":[]}, + "throughput":{"window_seconds":3600,"bucket_seconds":null,"services":[],"total_points":[]}} + """ + let decoded = try WidgetJSON.decoder.decode(WidgetSummaryPayload.self, from: Data(json.utf8)) + #expect(decoded.throughput.bucketSeconds == nil) + } + + @Test("reads the timestamps the API actually sends, milliseconds and all") + func decodesFractionalSeconds() throws { + // `JSONDecoder`'s own `.iso8601` rejects fractional seconds on the + // deployment target, and the API sends nothing else — so this is the + // difference between the widgets refreshing and never refreshing. It + // passed on a newer macOS, whose Foundation is lenient, and failed on CI. + // See WidgetJSON. + let withMillis = "2027-01-15T08:00:00.000Z" + let withoutMillis = "2027-01-15T08:00:00Z" + #expect(WidgetJSON.parse(withMillis) == WidgetJSON.parse(withoutMillis)) + #expect(WidgetJSON.parse(withMillis) != nil) + #expect(WidgetJSON.parse("not a timestamp") == nil) + + // And through a real decode, which is where it actually bit. + for stamp in [withMillis, withoutMillis] { + let json = """ + {"schema_version":1,"generated_at":"\(stamp)","organization_id":"org_1", + "issues":{"window_seconds":86400,"has_more":false,"data":[]}, + "throughput":{"window_seconds":3600,"bucket_seconds":60,"services":[],"total_points":[]}} + """ + let decoded = try WidgetJSON.decoder.decode(WidgetSummaryPayload.self, from: Data(json.utf8)) + #expect(decoded.generatedAt == Date(timeIntervalSince1970: 1_800_000_000)) + } + } + + @Test("a snapshot survives a round trip through the App Group's coders") + func roundTripsThroughStoreCoders() throws { + let snapshot = payload().issuesSnapshot(organizationName: "Acme") + let data = try WidgetJSON.encoder.encode(snapshot) + let decoded = try WidgetJSON.decoder.decode(IssuesSnapshot.self, from: data) + #expect(decoded == snapshot) + } + + @Test("a payload from a newer server is not supported") + func rejectsFutureSchema() { + #expect(payload(schemaVersion: WidgetSummaryPayload.supportedSchemaVersion + 1).isSupported == false) + } +} + +@Suite("Widget summary → issues snapshot") +struct WidgetSummaryIssuesTests { + @Test("names an issue the same way the app's list does") + func rendersTitles() { + // Falls back to the label when there is no exception type, and then + // suppresses a message that would merely restate it. + let snapshot = payload(issues: [ + issue(exceptionType: " ", errorLabel: "Timeout", exceptionMessage: "Timeout: upstream"), + issue(id: "iss_2", exceptionType: "TypeError", exceptionMessage: "boom"), + ]).issuesSnapshot(organizationName: "Maple") + + let byId = Dictionary(uniqueKeysWithValues: snapshot.issues.map { ($0.id, $0) }) + #expect(byId["iss_1"]?.title == "Timeout") + #expect(byId["iss_1"]?.subtitle == nil) + #expect(byId["iss_2"]?.title == "TypeError") + #expect(byId["iss_2"]?.subtitle == "boom") + } + + @Test("takes the organization name from the caller, never the payload") + func namesFromCaller() { + #expect(payload().issuesSnapshot(organizationName: "Acme").organizationName == "Acme") + #expect(payload().issuesSnapshot(organizationName: nil).organizationName == nil) + } + + @Test("a severity this build does not know ranks below low rather than crashing") + func unknownSeverity() { + let snapshot = payload(issues: [issue(severity: "apocalyptic")]).issuesSnapshot(organizationName: nil) + #expect(snapshot.issues.first?.severity == nil) + #expect(snapshot.criticalCount == 0) + } + + @Test("carries has_more through so the widget renders a floor") + func carriesHasMore() { + #expect(payload(hasMore: true).issuesSnapshot(organizationName: nil).isCapped) + #expect(payload(hasMore: false).issuesSnapshot(organizationName: nil).isCapped == false) + } + + @Test("counts every issue fetched, not just the rows drawn") + func countsBeyondTheRows() { + let rows = (0..<10).map { issue(id: "iss_\($0)") } + let snapshot = payload(issues: rows).issuesSnapshot(organizationName: nil) + #expect(snapshot.openCount == 10) + #expect(snapshot.criticalCount == 10) + #expect(snapshot.issues.count == IssuesSnapshot.maximumIssues) + } +} + +@Suite("Widget summary → throughput snapshot") +struct WidgetSummaryThroughputTests { + @Test("divides bucket counts into the same unit as the headline") + func convertsToPerSecond() { + let snapshot = payload( + bucketSeconds: 300, + services: [service("api", throughput: 12, points: [3_600, 1_800])] + ).throughputSnapshot() + #expect(snapshot.services.first?.points == [12, 6]) + } + + @Test("drops the series when the bucket length is missing or nonsensical") + func dropsUnitlessSeries() { + for bucketSeconds in [nil, 0, -1] { + let snapshot = payload( + bucketSeconds: bucketSeconds, + services: [service("api", points: [3_600])], + totalPoints: [3_600] + ).throughputSnapshot() + // The scalars survive — only the shape is unrenderable. + #expect(snapshot.services.first?.points.isEmpty == true) + #expect(snapshot.overall.points.isEmpty) + #expect(snapshot.services.first?.throughputPerSecond == 12) + } + } + + @Test("sums the total from every service, but takes its shape from the ungrouped series") + func totalsAcrossEveryService() { + // Two services the widget charts, and an ungrouped series that is larger + // than their sum because the org has traffic past the charted few. + let snapshot = payload( + bucketSeconds: 60, + services: [ + service("api", throughput: 10, points: [600]), + service("web", throughput: 5, points: [300]), + ], + totalPoints: [1_800] + ).throughputSnapshot() + + #expect(snapshot.overall.throughputPerSecond == 15) + // 30/s, not the 15/s the two charted services sum to. + #expect(snapshot.overall.points == [30]) + } + + @Test("falls back to the summed shape when the ungrouped series is empty") + func fallsBackToSummedShape() { + let snapshot = payload( + bucketSeconds: 60, + services: [service("api", throughput: 10, points: [600])], + totalPoints: [] + ).throughputSnapshot() + #expect(snapshot.overall.points == [10]) + } + + @Test("ranks busiest first and caps what crosses the boundary") + func ranksAndCaps() { + let rows = (0..<20).map { service("svc-\($0)", throughput: Double($0)) } + let snapshot = payload(services: rows).throughputSnapshot() + #expect(snapshot.services.count == ThroughputSnapshot.maximumServices) + #expect(snapshot.services.first?.name == "svc-19") + // The total still counts every service, including the uncharted ones. + #expect(snapshot.overall.throughputPerSecond == 190) + } + + @Test("reports the window the server used, in minutes") + func reportsWindow() { + #expect(payload().throughputSnapshot().windowMinutes == 60) + } +} diff --git a/apps/ios/README.md b/apps/ios/README.md index 5430dc8c8..badbab3df 100644 --- a/apps/ios/README.md +++ b/apps/ios/README.md @@ -151,35 +151,98 @@ Deep links are handled by `AppNavigation.open(_:)`, which owns both tab stacks. Every widget must also be listed in `MapleWidgetBundle` — one that compiles but is missing from that body never appears in the gallery, with no error anywhere. -The extension makes **no network requests**. Every v2 request needs a Clerk -session token with a one-minute TTL, and an extension has no interactive way to -recover when refreshing one fails — so the app fetches and writes two small JSON -snapshots into the App Group `group.com.maple.mobile`, and the widgets only -render them. The snapshot types, their ranking, the shared store and the -formatters live in `Packages/MapleAPI/Sources/MapleWidgetData` — a module with -**no dependency on `MapleAPI`**, which is why the extension does not link the -generated client. They are covered by `swift test` alongside the client's own -tests. - -Throughput costs three requests per round: the service list (whose numbers the -organization total is summed from), one `group_by: service` timeseries for every -service's shape at once, and one ungrouped timeseries for the total's shape — -summing only the charted services would under-report a big org. Bucket counts -are divided by the bucket length before publishing, so the sparkline and the -headline are both "per second" and cannot disagree. - -Four things republish it, which between them cover how a phone is used: +**The extension fetches for itself.** It used to render only what the app had +published, which meant the Home Screen was as fresh as the app's last run — for +most people, hours. The provider was already being woken roughly hourly; it was +spending every one of those wake-ups re-rendering the same bytes. + +It still holds no Clerk session — those tokens live one minute, and two +processes refreshing the same rotating refresh token is a way to sign the user +out. Instead the app mints a **device credential** for it (`WidgetCredential`, +`PUT /v2/widget_credentials/{installation_id}`): read-only, 30 days, and fenced +by the server to `/v2/widget_summary` and nothing else. It lives as a file in +the App Group with `completeUntilFirstUserAuthentication` — not `UserDefaults`, +which would be a bearer token in cleartext in a backup, and not the app's +keychain group, which is where Clerk keeps the session this design exists to +keep out of the extension. + +The extension still does not link `MapleAPI`: it gets ~30MB and a few seconds, +and the generated client is 30k lines plus `OpenAPIRuntime`. The hand-written +client for that one endpoint is `WidgetSummaryFetcher`, and the whole module — +snapshots, ranking, store, formatters, fetch — is +`Packages/MapleAPI/Sources/MapleWidgetData`, covered by `swift test`. + +The fetch **enriches** a timeline; it never gates one. `makeEntry` runs first and +would answer on its own, so a fetch that fails, times out, or never happens +costs nothing but freshness — the widget renders its last snapshot with an +honest age, which is what it did before it could fetch at all. The rules that +keep that true: + +- 5s total budget, `waitsForConnectivity = false`. A killed provider costs a + rebuild from a metered budget having rendered nothing. +- Snapshots are written to the App Group **before** the entries are built, so a + provider killed on the way to rendering still leaves the data behind. +- One fetch covers both widgets, and `WidgetSummaryFetcher` coalesces: three + pinned instances woken together make one request between them. A stamp written + to the App Group before the request covers the app racing the extension. +- A 401 or 403 is **terminal**. A rolled credential answers 401 forever, and + retrying every rebuild would spend the whole day's budget on failures. Only + the app can lift it, by minting again — which it does, and then reloads. +- `refreshAfter` stays at 45 minutes. The date in a `TimelineReloadPolicy` is a + floor, not a promise: asking four times as often does not get four times the + rebuilds, it gets a widget that has spent its allotment by mid-afternoon. + Repeated failures back off further (15m → 30m → 1h → 4h). + +The extension links no telemetry, so a fetch that fails there fails in complete +silence — which is the exact shape of the bug this replaced. It records +`WidgetFetchState` into the App Group and `WidgetPublisher` drains it onto the +next `widget.refresh` span. + +One organization costs **one request**: `GET /v2/widget_summary`, which returns +both surfaces in a payload sized for a Home Screen. It used to cost four — +issues, the service list, a `group_by: service` timeseries and an ungrouped one +— and the composition was the problem: a `BGAppRefreshTask` gets tens of +seconds, and four round-trips per organization is how a background round runs +out of them having written nothing. + +That endpoint is deliberately its own scope family rather than a shaped view +over `/v2/error_issues` + `/v2/services` + `/v2/traces/timeseries`. An API key's +required scope is derived from the first path segment, so a credential scoped +`widget_summary:read` reaches exactly this endpoint — which is what will let a +device credential live on a phone without being an organization read key. + +The wire carries bucket **counts** and the bucket length; `WidgetSummaryPayload` +divides them on the way into a snapshot, so the sparkline and the headline are +both "per second" and cannot disagree. It also carries the raw naming fields +(`exception_type`, `error_label`, `exception_message`) rather than a rendered +title: `WidgetIssueTitle` owns the fallback and the app's issue list uses it +too, so a title cannot resolve differently on the Home Screen than in the list. + +The app still publishes, in a reduced role the extension cannot cover: it owns +the organization index (the picker, and how an unconfigured widget resolves), +mints and renews credentials, warms a newly placed widget so it renders +immediately rather than showing "Open Maple", and clears everything on sign-out. +Four things trigger it: - the tabs appearing, and every return to the foreground (`RootView`), - an organization switch — the counts belong to one org, so a switch republishes at once rather than leaving the previous org's numbers on the Home Screen, -- `BGAppRefreshTask` while the app is closed (`WidgetRefreshScheduler`), -- a push arriving, silent or visible (`AppDelegate`). - -Background refresh is opportunistic — iOS decides whether to run it at all — so -the widgets render their own age rather than implying the numbers are current: -past thirty minutes they dim and say "as of 2h ago". Sign-out clears both -snapshots; the Home Screen outlives the session. +- `BGAppRefreshTask` while the app is closed (`WidgetRefreshScheduler`), kept as + a fallback for one release now that the widget refreshes itself, +- a push arriving (`AppDelegate`). + +An incident also sends a **silent wake-up** (`content-available`, priority 5, +collapsed per organization) so the Home Screen does not wait for the next +scheduled rebuild at the one moment its numbers are most wrong. It is a hint, +never a guarantee: iOS throttles background pushes on an unpublished schedule, +and it only reaches phones that accepted notifications at all. The widget's own +refresh is the mechanism; this makes it timely. See +`MobilePushService.refreshWidgets`. + +The widgets render their own age rather than implying the numbers are current: +past thirty minutes they dim and say "updated 2h ago". Sign-out clears both +snapshots and the credential, locally and server-side; the Home Screen outlives +the session. Both targets carry the App Group entitlement. With automatic signing Xcode creates the group on first build; a mismatch between the two entitlements files diff --git a/apps/ios/Widgets/IssuesWidget.swift b/apps/ios/Widgets/IssuesWidget.swift index b5f7b2af7..c324e28ed 100644 --- a/apps/ios/Widgets/IssuesWidget.swift +++ b/apps/ios/Widgets/IssuesWidget.swift @@ -4,9 +4,12 @@ import WidgetKit /// Ongoing issues, on the Home Screen and the Lock Screen. /// -/// The extension holds no session and makes no requests: it renders the -/// snapshot the app publishes into the shared App Group. See `IssuesSnapshot` -/// for why, and `WidgetPublisher` for who writes it. +/// The extension holds no Clerk session. It fetches with a device credential +/// the app mints for it — read-only, expiring, and fenced by the server to +/// `/v2/widget_summary` alone — and falls back to whatever the app last +/// published into the shared App Group. See `WidgetSummaryFetcher` for the +/// fetch, `WidgetCredentialStore` for the credential, and `WidgetPublisher` for +/// the app's half. struct IssuesWidget: Widget { var body: some WidgetConfiguration { // Configurable since the organization picker shipped. Widgets placed @@ -135,21 +138,37 @@ struct IssuesProvider: AppIntentTimelineProvider { WidgetSnapshotStore.legacyIssues.load() } - /// One read, rendered at every point on `WidgetTimelineSchedule`'s ladder. + /// Fetch if it is worth it, then render the result at every point on + /// `WidgetTimelineSchedule`'s ladder. /// - /// The data does not change between them — only its age does, and the row - /// times ("2m", "3h") and the footer are relative, so without these the - /// widget would still 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. + /// **The fetch enriches this timeline; it never gates it.** `makeEntry` runs + /// first and would answer on its own, so a fetch that fails, times out, or + /// never happens costs nothing but freshness — the widget renders the last + /// snapshot with an honest age, which is what it did before it could fetch + /// at all. Only a *successful* fetch sends us back to disk. + /// + /// The ladder is still one read rendered many times: the data does not change + /// between entries, only its age, and the row times ("2m", "3h") and the + /// footer are relative — without them the widget would still claim "2m" an + /// hour later. func timeline(for configuration: SelectOrganizationIntent, in context: Context) async -> Timeline { let now = Date() - let base = makeEntry(for: configuration, at: now) + var base = makeEntry(for: configuration, at: now) + let outcome = await WidgetTimelineRefresh.run( + organizationId: base.organizationId, + organizationName: base.organizationName, + storedGeneratedAt: base.snapshot?.generatedAt, + now: now + ) + // Re-read rather than take the payload back: the fetcher writes both + // widgets' snapshots into the App Group, and going through the store + // keeps this provider's one way of resolving an organization. + if outcome.didFetch { base = makeEntry(for: configuration, at: now) } let entries = WidgetTimelineSchedule.entryDates(from: now).map { date -> IssuesEntry in var entry = base entry.date = date return entry } - return Timeline(entries: entries, policy: .after(WidgetTimelineSchedule.refreshDate(from: now))) + return Timeline(entries: entries, policy: .after(outcome.refreshDate)) } } diff --git a/apps/ios/Widgets/ThroughputWidget.swift b/apps/ios/Widgets/ThroughputWidget.swift index 5a6af667d..6d88b7e37 100644 --- a/apps/ios/Widgets/ThroughputWidget.swift +++ b/apps/ios/Widgets/ThroughputWidget.swift @@ -6,8 +6,9 @@ import WidgetKit /// Throughput — the organization's, or one service's, picked in the widget's /// own configuration rather than by adding a different widget per service. /// -/// Like the issues widget, this renders a snapshot the app published into the -/// shared App Group and makes no requests of its own. See `ThroughputSnapshot`. +/// Like the issues widget, this fetches with the device credential the app +/// minted for it and falls back to the snapshot the app published into the +/// shared App Group. One fetch covers both widgets. See `ThroughputSnapshot`. struct ThroughputWidget: Widget { var body: some WidgetConfiguration { AppIntentConfiguration( @@ -109,18 +110,29 @@ struct ThroughputProvider: AppIntentTimelineProvider { WidgetSnapshotStore.legacyThroughput.load() } - /// One read, several entries — the numbers do not change between them, - /// only how old they are. Same ladder as the issues widget, so the two - /// widgets' footers never disagree about the time; see - /// `WidgetTimelineSchedule`. + /// Fetch if it is worth it, then render the result at every point on the + /// ladder — the same shape as the issues widget, and for the same reasons. + /// See `IssuesProvider.timeline` for why the fetch enriches this timeline + /// rather than gating it. + /// + /// One fetch covers both widgets: `WidgetSummaryFetcher` writes both + /// snapshots, and coalesces, so three pinned instances woken together make + /// one request between them. func timeline(for configuration: SelectServiceIntent, in context: Context) async -> Timeline { let now = Date() - let base = makeEntry(for: configuration, at: now) + var base = makeEntry(for: configuration, at: now) + let outcome = await WidgetTimelineRefresh.run( + organizationId: base.organizationId, + organizationName: base.organizationName, + storedGeneratedAt: base.snapshot?.generatedAt, + now: now + ) + if outcome.didFetch { base = makeEntry(for: configuration, at: now) } let entries = WidgetTimelineSchedule.entryDates(from: now).map { date -> ThroughputEntry in var entry = base entry.date = date return entry } - return Timeline(entries: entries, policy: .after(WidgetTimelineSchedule.refreshDate(from: now))) + return Timeline(entries: entries, policy: .after(outcome.refreshDate)) } } diff --git a/apps/ios/project.yml b/apps/ios/project.yml index c5c946d74..a34b9cfdf 100644 --- a/apps/ios/project.yml +++ b/apps/ios/project.yml @@ -182,10 +182,14 @@ targets: Environment: $(MAPLE_ENVIRONMENT) ServiceName: maple-ios - # The Home Screen / Lock Screen widget. Renders the snapshot the app writes - # into the shared App Group and nothing else — it holds no Clerk session and - # makes no requests, which is why it links MapleWidgetData rather than - # MapleAPI. See Widgets/IssuesWidget.swift. + # The Home Screen / Lock Screen widget. + # + # It fetches `GET /v2/widget_summary` for itself, with a device credential + # the app mints into the shared App Group, and falls back to the snapshot + # the app last published. It still links MapleWidgetData rather than + # MapleAPI: an extension gets ~30MB and a few seconds, and the generated + # client is 30k lines plus OpenAPIRuntime. The hand-written client for that + # one endpoint is WidgetSummaryFetcher. See Widgets/IssuesWidget.swift. MapleWidgets: type: app-extension platform: iOS diff --git a/packages/db/src/schema/api-keys.ts b/packages/db/src/schema/api-keys.ts index c90b78258..89ee4b702 100644 --- a/packages/db/src/schema/api-keys.ts +++ b/packages/db/src/schema/api-keys.ts @@ -17,7 +17,10 @@ export const apiKeys = pgTable( metadataJson: jsonb("metadata_json").$type(), // v2 scope strings (":read"/":write"/"*"); null = legacy full access. scopes: jsonb("scopes").$type(), - kind: text("kind", { enum: ["standard", "mcp"] }) + // `standard` is a human-minted org key; `mcp` is only valid for the MCP + // server; `device` is minted *for* a device by a signed-in app, with the + // server choosing its scopes, TTL, and roles — see ApiKeysService. + kind: text("kind", { enum: ["standard", "mcp", "device"] }) .notNull() .default("standard"), createdAt: timestamp("created_at", { withTimezone: true, mode: "date" }).notNull(), diff --git a/packages/domain/src/http/api-keys.ts b/packages/domain/src/http/api-keys.ts index b85b57f0a..2cc5de62e 100644 --- a/packages/domain/src/http/api-keys.ts +++ b/packages/domain/src/http/api-keys.ts @@ -2,7 +2,14 @@ import { Schema } from "effect" import { HttpTaggedError } from "./error-policy" import { ApiKeyId, PostgresTransactionId, UserId } from "../primitives" -export const ApiKeyKind = Schema.Literals(["standard", "mcp"]) +/** + * `standard` — a human-minted organization key, admin-gated. + * `mcp` — only valid for the MCP server; rejected on /v2. + * `device` — minted for one device by a signed-in app. The *server* chooses its + * scopes, TTL, and roles, so its ceiling is structural rather than a promise + * the client makes; see `ApiKeysService.replaceDeviceKey`. + */ +export const ApiKeyKind = Schema.Literals(["standard", "mcp", "device"]) export type ApiKeyKind = Schema.Schema.Type export class ApiKeyResponse extends Schema.Class("ApiKeyResponse")({ diff --git a/packages/domain/src/http/v2/api.ts b/packages/domain/src/http/v2/api.ts index 337092c6d..98d7ff500 100644 --- a/packages/domain/src/http/v2/api.ts +++ b/packages/domain/src/http/v2/api.ts @@ -19,6 +19,8 @@ import { V2ScrapeTargetsApiGroup } from "./scrape-targets" import { V2SessionReplaysApiGroup } from "./session-replays" import { V2InstrumentationAuditApiGroup } from "./setup-audit" import { V2SharePublicApiGroup } from "./share" +import { V2WidgetCredentialsApiGroup } from "./widget-credentials" +import { V2WidgetSummaryApiGroup } from "./widget-summary" import { V2LogsApiGroup, V2MetricsApiGroup, @@ -105,6 +107,8 @@ export class MapleApiV2 extends HttpApi.make("MapleApiV2") .add(V2ServicesApiGroup) .add(V2ServiceMapApiGroup) .add(V2SharePublicApiGroup) + .add(V2WidgetSummaryApiGroup) + .add(V2WidgetCredentialsApiGroup) .middleware(V2SchemaErrors) .middleware(V2UnexpectedErrors) .annotateMerge( diff --git a/packages/domain/src/http/v2/index.ts b/packages/domain/src/http/v2/index.ts index 045279ea5..1744bfae3 100644 --- a/packages/domain/src/http/v2/index.ts +++ b/packages/domain/src/http/v2/index.ts @@ -27,3 +27,5 @@ export * from "./session-replays" export * from "./setup-audit" export * from "./share" export * from "./telemetry" +export * from "./widget-credentials" +export * from "./widget-summary" diff --git a/packages/domain/src/http/v2/openapi-ios.test.ts b/packages/domain/src/http/v2/openapi-ios.test.ts index 6e9c6858b..01fea37e7 100644 --- a/packages/domain/src/http/v2/openapi-ios.test.ts +++ b/packages/domain/src/http/v2/openapi-ios.test.ts @@ -53,6 +53,9 @@ const IOS_OPERATIONS = [ "unregisterMobileDevice", "registerLiveActivity", "endLiveActivity", + "mintWidgetCredential", + "revokeWidgetCredential", + "getWidgetSummary", ] as const /** diff --git a/packages/domain/src/http/v2/openapi.test.ts b/packages/domain/src/http/v2/openapi.test.ts index 3c46c46da..7b0ed86df 100644 --- a/packages/domain/src/http/v2/openapi.test.ts +++ b/packages/domain/src/http/v2/openapi.test.ts @@ -99,6 +99,7 @@ describe("MapleApiV2 OpenAPI", () => { "DELETE /v2/mobile_devices/{token}", "DELETE /v2/mobile_devices/{token}/live_activities/{incident_id}", "DELETE /v2/scrape_targets/{id}", + "DELETE /v2/widget_credentials/{installation_id}", "GET /v2/alerts/deliveries", "GET /v2/alerts/destinations", "GET /v2/alerts/destinations/{id}", @@ -155,6 +156,7 @@ describe("MapleApiV2 OpenAPI", () => { "GET /v2/session_replays/{id}/transcript", "GET /v2/traces/{trace_id}", "GET /v2/traces/{trace_id}/spans/{span_id}", + "GET /v2/widget_summary", "PATCH /v2/alerts/destinations/{id}", "PATCH /v2/alerts/rules/{id}", "PATCH /v2/anomalies/settings", @@ -213,6 +215,7 @@ describe("MapleApiV2 OpenAPI", () => { "PUT /v2/dashboards/{id}/widgets/{widget_id}/share", "PUT /v2/mobile_devices/{token}", "PUT /v2/mobile_devices/{token}/live_activities/{incident_id}", + "PUT /v2/widget_credentials/{installation_id}", ]) }) diff --git a/packages/domain/src/http/v2/widget-credentials.ts b/packages/domain/src/http/v2/widget-credentials.ts new file mode 100644 index 000000000..da018f787 --- /dev/null +++ b/packages/domain/src/http/v2/widget-credentials.ts @@ -0,0 +1,131 @@ +import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" +import { Schema } from "effect" +import { ApiKeyPersistenceError } from "../api-keys" +import { OrgId } from "../../primitives" +import { AuthorizationV2 } from "./auth" +import { wireExample, Timestamp } from "./envelopes" +import { publicError } from "./public-error" + +/** + * The credential a device's Home Screen widgets fetch with. + * + * A widget extension holds no session: session tokens are minted with a + * one-minute TTL, and two processes refreshing the same rotating refresh token + * is a way to sign the user out. So the app — which does hold the session — + * asks for a long-lived credential *on the installation's behalf*, and the + * widget uses that. + * + * **Its own resource family, not a sub-resource of `/v2/mobile_devices`.** The + * obvious home looked like the device that push registration already + * establishes, but a device row is keyed on an APNs token, and a user who + * declines notifications has none. Widgets and notifications are separate + * permissions, and someone who wants one and not the other is completely + * ordinary — hanging the credential off push would have quietly meant "no + * widget refresh unless you also accept alerts". The installation identifies + * itself instead. + * + * Everything that bounds the credential is chosen by the server, which is why + * this is a dedicated operation rather than `POST /v2/api_keys` with a `kind`: + * the caller names an installation and nothing else, so it cannot ask for wider + * scopes, a longer life, or more authority than the person running the app. + */ +export const InstallationId = Schema.String.check( + Schema.isMinLength(8), + Schema.isMaxLength(128), + Schema.isPattern(/^[A-Za-z0-9_-]+$/, { + description: "an opaque installation identifier (letters, digits, `_` and `-`)", + }), +).annotate({ + title: "Installation ID", + description: + "A stable, client-generated identifier for one app installation — on iOS, `identifierForVendor`. Opaque to Maple, and only ever compared against itself: it decides which credential a re-mint replaces.", + examples: ["F9E1B4C0-8F2A-4C6D-9E1B-4C08F2A4C6D9"], +}) + +export const V2WidgetCredential = Schema.Struct({ + object: Schema.Literal("widget_credential").annotate({ + description: 'The object type — always `"widget_credential"`.', + }), + /** + * Returned **once**, at mint. Maple stores only a hash, so a caller that + * loses this mints again — one call, and what the app does anyway when the + * credential nears expiry. + */ + secret: Schema.String.annotate({ + description: + "The bearer token, shown once. Store it where only this installation can read it, and send it nowhere but Maple.", + }), + /** The organization it is bound to. An API key cannot select another. */ + organization_id: OrgId, + scopes: Schema.Array(Schema.String).annotate({ + description: "Fixed by the server. Today, exactly `widget_summary:read`.", + }), + expires_at: Timestamp.annotate({ + description: + "When the credential stops working. The app re-mints well before this; a widget that reaches it renders its last snapshot and waits for the app.", + }), + created_at: Timestamp, +}).annotate({ + identifier: "WidgetCredential", + title: "Widget credential", + description: + "A read-only, expiring credential for one app installation's Home Screen widgets. Minting is idempotent per installation: the previous credential is revoked in the same transaction.", + examples: [ + wireExample({ + object: "widget_credential", + secret: "maple_ak_1a2b3c4d5e6f708192a3b4c5d6e7f8091a2b3c4d5e6f7081", + organization_id: "org_2abcDEF", + scopes: ["widget_summary:read"], + expires_at: "2026-09-20T09:10:00.000Z", + created_at: "2026-08-21T09:10:00.000Z", + }), + ], +}) +export type V2WidgetCredential = Schema.Schema.Type + +export const V2WidgetCredentialDeleteResponse = Schema.Struct({ + object: Schema.Literal("widget_credential"), + deleted: Schema.Literal(true), +}).annotate({ + identifier: "WidgetCredentialDeleteResponse", + title: "Widget credential delete response", +}) + +export class V2WidgetCredentialsApiGroup extends HttpApiGroup.make("widgetCredentials") + .add( + HttpApiEndpoint.put("mint", "/:installation_id", { + params: { installation_id: InstallationId }, + success: V2WidgetCredential, + error: [publicError(ApiKeyPersistenceError)], + }).annotateMerge( + OpenApi.annotations({ + identifier: "mintWidgetCredential", + summary: "Mint this installation's widget credential", + description: + "Issues a read-only, expiring credential for this installation's Home Screen widgets, revoking whatever it had. Idempotent, so the app calls it again to roll. Requires the `widget_credentials:write` scope — which a widget credential does not have, so renewal always goes through a signed-in session.", + }), + ), + ) + .add( + HttpApiEndpoint.delete("revoke", "/:installation_id", { + params: { installation_id: InstallationId }, + success: V2WidgetCredentialDeleteResponse, + error: [publicError(ApiKeyPersistenceError)], + }).annotateMerge( + OpenApi.annotations({ + identifier: "revokeWidgetCredential", + summary: "Revoke this installation's widget credential", + description: + "Retires the installation's credential for this organization; the app calls this on sign-out and when the user leaves the organization. Idempotent — an installation with nothing to revoke is already in the requested state. Requires the `widget_credentials:write` scope.", + }), + ), + ) + .prefix("/v2/widget_credentials") + .middleware(AuthorizationV2) + .annotateMerge( + OpenApi.annotations({ + title: "Widget Credentials", + description: + "Device-scoped, read-only credentials for the Maple mobile Home Screen widgets. Minted by a signed-in app for one installation at a time.", + }), + ) {} diff --git a/packages/domain/src/http/v2/widget-summary.ts b/packages/domain/src/http/v2/widget-summary.ts new file mode 100644 index 000000000..f1a73c688 --- /dev/null +++ b/packages/domain/src/http/v2/widget-summary.ts @@ -0,0 +1,235 @@ +import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" +import { Schema } from "effect" +import { ErrorPersistenceError, IssueSeverity } from "../errors" +import { OrgId, ServiceName } from "../../primitives" +import { AuthorizationV2 } from "./auth" +import { wireExample, Timestamp } from "./envelopes" +import { publicError } from "./public-error" +import { V2QueryErrors } from "./query-errors" +import { ErrorIssuePublicId } from "./resource-ids" + +/** + * The iOS Home Screen widgets' one read. + * + * A separate resource family rather than a shaped view over `/v2/error_issues` + * + `/v2/services` + `/v2/traces/timeseries`, for two reasons that are not + * "three requests is more than one": + * + * 1. **It is the credential's fence.** `requiredScopeForRequest` derives an API + * key's required scope from the first path segment, so a key scoped + * `widget_summary:read` can reach exactly this endpoint and nothing else. + * Composed from the generic endpoints, the same widget would need + * `error_issues:read` + `services:read` + `traces:read` — an organization + * read key, sitting on a phone. For the same reason this must never be + * nested under another family's prefix. + * 2. **A widget extension has no budget to compose.** It is woken by WidgetKit + * with seconds of wall clock and tens of megabytes, and it deliberately does + * not link the generated client. One request it can decode by hand is the + * only shape that fits. + * + * The windows are the server's, not the caller's: "ongoing issues" and "traffic + * right now" are product definitions the widgets render, and a query parameter + * would let two builds of the app disagree about what the Home Screen means. + */ + +/** Ongoing means the app's "Needs attention" filter over the day Home considers recent. */ +export const WIDGET_SUMMARY_ISSUES_WINDOW_SECONDS = 60 * 60 * 24 +/** Throughput is a "right now" number — Home's rate window, and the Services tab's. */ +export const WIDGET_SUMMARY_THROUGHPUT_WINDOW_SECONDS = 60 * 60 + +/** + * Enough issues that `open_count` is meaningful and the rows shown are really + * the worst ones; past this the widget renders a floor ("20+"). + */ +export const WIDGET_SUMMARY_ISSUE_LIMIT = 20 +/** + * The widget charts far fewer, but the organization total is summed across + * every service — so this is deliberately wider than what is drawn. + */ +export const WIDGET_SUMMARY_SERVICE_LIMIT = 50 +/** Series returned with per-service buckets. Matches what the widget can draw. */ +export const WIDGET_SUMMARY_SERIES_LIMIT = 12 + +/** + * The wire's own version, independent of the API version. + * + * The reader is an App Store binary that cannot be updated in step with a + * deploy, so the server needs a way to know which shape a caller understands + * before it adds a field that changes how one is drawn. Bump only when the + * meaning of an existing field changes; new optional fields do not need it. + */ +export const WIDGET_SUMMARY_SCHEMA_VERSION = 1 + +export const V2WidgetSummaryIssue = Schema.Struct({ + id: ErrorIssuePublicId, + /** + * The raw naming fields, not a rendered title. The app's issue list and the + * widget must fall back identically — a title that resolves differently in + * the two places reads as two different issues — and the one implementation + * of that fallback lives on the client, next to the list that also uses it. + */ + exception_type: Schema.String, + error_label: Schema.String, + exception_message: Schema.String, + service_name: Schema.String, + severity: Schema.NullOr(IssueSeverity), + occurrence_count: Schema.Number, + last_seen_at: Timestamp, + /** Fixed, then seen again — the state that means a deploy undid a fix. */ + is_regressed: Schema.Boolean, + has_open_incident: Schema.Boolean, +}).annotate({ + identifier: "WidgetSummaryIssue", + title: "Widget summary issue", + description: "One ongoing error issue, reduced to what a Home Screen row can render.", +}) +export type V2WidgetSummaryIssue = Schema.Schema.Type + +export const V2WidgetSummaryService = Schema.Struct({ + name: ServiceName, + throughput_per_second: Schema.Number, + /** 0–1, not a percentage. */ + error_rate: Schema.Number, + p95_latency_ms: Schema.Number, + /** + * Span counts per bucket, oldest first — **counts, not rates**. The client + * divides by `bucket_seconds` so the sparkline and the headline provably + * carry the same unit, and so a bucket length the client cannot make sense + * of drops the series rather than drawing counts as if they were rates. + */ + points: Schema.Array(Schema.Number), +}).annotate({ + identifier: "WidgetSummaryService", + title: "Widget summary service", + description: "One service's traffic over the throughput window.", +}) +export type V2WidgetSummaryService = Schema.Schema.Type + +export const V2WidgetSummaryIssues = Schema.Struct({ + window_seconds: Schema.Number, + /** + * More ongoing issues exist than `data` carries, so a count derived from it + * is a floor. The widget renders that as "20+" rather than a wrong total. + */ + has_more: Schema.Boolean, + data: Schema.Array(V2WidgetSummaryIssue), +}).annotate({ + identifier: "WidgetSummaryIssues", + title: "Widget summary issues", +}) + +export const V2WidgetSummaryThroughput = Schema.Struct({ + window_seconds: Schema.Number, + /** + * Bucket length behind every `points` array. Null when no series could be + * read, which tells the client to render the scalars without a sparkline + * instead of guessing a unit. + */ + bucket_seconds: Schema.NullOr(Schema.Number), + services: Schema.Array(V2WidgetSummaryService), + /** + * The ungrouped organization series, in the same bucket counts as + * `services[].points`. + * + * Not the sum of `services[].points`: the per-service series is capped at + * the charted few, so summing it would under-report a large organization's + * shape. The scalar total is still derived client-side from every service + * row, which is why only the series appears here. + */ + total_points: Schema.Array(Schema.Number), +}).annotate({ + identifier: "WidgetSummaryThroughput", + title: "Widget summary throughput", +}) + +export const V2WidgetSummary = Schema.Struct({ + object: Schema.Literal("widget_summary").annotate({ + description: 'The object type — always `"widget_summary"`.', + }), + schema_version: Schema.Number.annotate({ + description: + "The widget wire shape's own version. Clients that do not recognise it should keep rendering their last good snapshot rather than decode against a shape whose fields may have changed meaning.", + }), + /** When the server read the data — the age every widget renders from. */ + generated_at: Timestamp, + /** + * Echoed so the caller can prove the payload belongs to the organization it + * asked for before writing it over that organization's cached snapshot. + * There is deliberately no name here: the client already resolves names from + * its own membership index, and a second source would let a widget render + * one organization's name over another's numbers. + */ + organization_id: OrgId, + issues: V2WidgetSummaryIssues, + throughput: V2WidgetSummaryThroughput, +}).annotate({ + identifier: "WidgetSummary", + title: "Widget summary", + description: + "Everything the Maple iOS Home Screen widgets draw, in one response: ongoing error issues over the last day, and per-service traffic over the last hour.", + examples: [ + wireExample({ + object: "widget_summary", + schema_version: 1, + generated_at: "2026-08-21T09:10:00.000Z", + organization_id: "org_2abcDEF", + issues: { + window_seconds: 86_400, + has_more: false, + data: [ + { + id: "iss_YofPTrK9782DWwcnXhpcCw", + exception_type: "TypeError", + error_label: "checkout", + exception_message: "Cannot read properties of undefined", + service_name: "api", + severity: "critical", + occurrence_count: 412, + last_seen_at: "2026-08-21T09:08:12.000Z", + is_regressed: false, + has_open_incident: true, + }, + ], + }, + throughput: { + window_seconds: 3600, + bucket_seconds: 300, + services: [ + { + name: "api", + throughput_per_second: 12.5, + error_rate: 0.012, + p95_latency_ms: 184, + points: [3600, 3720, 3540], + }, + ], + total_points: [5200, 5310, 5180], + }, + }), + ], +}) +export type V2WidgetSummary = Schema.Schema.Type + +export class V2WidgetSummaryApiGroup extends HttpApiGroup.make("widgetSummary") + .add( + HttpApiEndpoint.get("retrieve", "/", { + success: V2WidgetSummary, + error: [publicError(ErrorPersistenceError), ...V2QueryErrors], + }).annotateMerge( + OpenApi.annotations({ + identifier: "getWidgetSummary", + summary: "Retrieve the mobile widget summary", + description: + "Returns ongoing error issues and per-service traffic in a single small payload sized for a Home Screen widget. The windows are fixed by the server. Requires the `widget_summary:read` scope.", + }), + ), + ) + .prefix("/v2/widget_summary") + .middleware(AuthorizationV2) + .annotateMerge( + OpenApi.annotations({ + title: "Widget Summary", + description: + "The single read behind the Maple mobile Home Screen widgets. Deliberately its own scope family so a device credential can be fenced to it alone.", + }), + ) {} diff --git a/scripts/generate-ios-openapi.ts b/scripts/generate-ios-openapi.ts index f65976ccc..0b68be75c 100644 --- a/scripts/generate-ios-openapi.ts +++ b/scripts/generate-ios-openapi.ts @@ -65,6 +65,10 @@ const IOS_OPERATIONS: ReadonlyArray = [ "unregisterMobileDevice", "registerLiveActivity", "endLiveActivity", + "mintWidgetCredential", + "revokeWidgetCredential", + // Home Screen widgets — one read, and the only one a device credential reaches. + "getWidgetSummary", ] const ERROR_ENVELOPE_SCHEMA_NAME = "MapleErrorEnvelope"