From 3858087b89cd0a1950f507da19df0733774face8 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 21 Aug 2026 12:21:58 +0200 Subject: [PATCH 1/2] feat(ios): let the Home Screen widgets refresh themselves MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Widget freshness was "time since the app last ran a successful publish" — for someone who opens Maple twice a day, hours. BGAppRefreshTask was not moving it; its own doc comment already conceded it is the floor, not the mechanism. The thing that made this fixable: the widget was already getting its wake-ups. The timeline policy is .after(+1h), so the provider ran ~24x a day per instance and spent every one of those rebuilds re-reading the same stale bytes off disk. The fix is not more budget, it is using the rebuilds we already get. GET /v2/widget_summary returns both widget surfaces in one payload, replacing the four requests the app used to compose. The request count is the weakest reason for it. The two real ones: it is the credential's fence, because an API key's required scope is derived from the first path segment, so widget_summary:read reaches exactly one endpoint where error_issues+services+traces would have been an org read key on a phone; and a BGAppRefreshTask now has one round-trip to land instead of four, which raises the success rate of the mechanism that already existed. The extension fetches with a device credential the app mints for it — read-only, 30 days, scoped by the server, roles pinned from the minting session so it can never outrank the human who asked for it. It cannot renew itself, so renewal always goes through a signed-in session. New api_keys kind "device", admitted on /v2 by turning the standard-only check into an mcp-only rejection. An incident additionally sends one silent content-available wake-up per phone, collapsed per organization, riding the alert fan-out's existing budget. It is a hint and nothing may depend on it: iOS throttles these on an unpublished schedule, and it only reaches phones that accepted notifications at all. Two places this differs from what was planned: - The mint route is not a mobile_devices sub-resource. A device row is keyed on an APNs token, so that would have quietly meant "no widget refresh unless you also accept alerts" — two permissions that have nothing to do with each other. It keys on identifierForVendor under its own family instead. - refreshAfter is 45 minutes, not shortened. The date in a TimelineReloadPolicy is a floor iOS may ignore, so asking four times as often mainly produces a widget that has spent its allotment by mid-afternoon and goes cold in the evening. Repeated failures back off (15m, 30m, 1h, 4h) so a widget that cannot fix itself stops spending the day's rebuilds on 401s. The fetch enriches a timeline and never gates one: makeEntry runs first and would answer alone, so a failure costs freshness and nothing else. Snapshots are written to the App Group before entries are built, one fetch covers both widgets and coalesces across pinned instances, and a 401/403 is terminal until the app mints again. The extension links no telemetry, so it records its outcome into the App Group and the app drains it onto the next widget.refresh span — silent failure is the exact bug this replaces. Expected outcome: hours-stale becomes under an hour for widgets people actually look at. Not live. --- apps/api/src/platform/Apns.ts | 73 +- apps/api/src/routes/v2/mobile-devices.http.ts | 31 +- apps/api/src/routes/v2/telemetry.http.ts | 6 +- apps/api/src/routes/v2/v2-test-support.ts | 4 + .../routes/v2/widget-credentials.http.test.ts | 250 ++++++ .../src/routes/v2/widget-credentials.http.ts | 75 ++ .../src/routes/v2/widget-summary.http.test.ts | 323 +++++++ apps/api/src/routes/v2/widget-summary.http.ts | 198 +++++ apps/api/src/runtime/http-graph.ts | 4 + .../services/auth/ApiAuthorizationV2Layer.ts | 19 +- apps/api/src/services/org/ApiKeysService.ts | 184 +++- .../services/push/MobilePushService.test.ts | 80 +- .../src/services/push/MobilePushService.ts | 149 +++- .../Maple/Features/Issues/IssueDisplay.swift | 28 +- apps/ios/Maple/Fixtures/FixtureAPI.swift | 73 ++ apps/ios/Maple/Push/PushRegistrar.swift | 17 +- apps/ios/Maple/Telemetry/Telemetry.swift | 16 + apps/ios/Maple/Widgets/AppInstallation.swift | 37 + apps/ios/Maple/Widgets/WidgetPublisher.swift | 449 +++++----- apps/ios/Packages/MapleAPI/Package.resolved | 2 +- apps/ios/Packages/MapleAPI/Package.swift | 6 + .../MapleAPI/MapleClient+WidgetSummary.swift | 137 +++ .../Sources/MapleAPI/MapleClient.swift | 8 +- .../MapleAPI/Sources/MapleAPI/openapi.json | 817 ++++++++++++++++-- .../WidgetCredentialStore.swift | 162 ++++ .../MapleWidgetData/WidgetFetchState.swift | 161 ++++ .../MapleWidgetData/WidgetSummary.swift | 311 +++++++ .../WidgetSummaryFetcher.swift | 215 +++++ .../WidgetTimelineRefresh.swift | 91 ++ .../WidgetTimelineSchedule.swift | 41 +- .../WidgetCredentialStoreTests.swift | 120 +++ .../WidgetSummaryFetcherTests.swift | 459 ++++++++++ .../WidgetSummaryTests.swift | 257 ++++++ apps/ios/README.md | 113 ++- apps/ios/Widgets/IssuesWidget.swift | 41 +- apps/ios/Widgets/ThroughputWidget.swift | 28 +- apps/ios/project.yml | 12 +- packages/db/src/schema/api-keys.ts | 5 +- packages/domain/src/http/api-keys.ts | 9 +- packages/domain/src/http/v2/api.ts | 4 + packages/domain/src/http/v2/index.ts | 2 + .../domain/src/http/v2/openapi-ios.test.ts | 3 + packages/domain/src/http/v2/openapi.test.ts | 3 + .../domain/src/http/v2/widget-credentials.ts | 131 +++ packages/domain/src/http/v2/widget-summary.ts | 235 +++++ scripts/generate-ios-openapi.ts | 4 + 46 files changed, 5035 insertions(+), 358 deletions(-) create mode 100644 apps/api/src/routes/v2/widget-credentials.http.test.ts create mode 100644 apps/api/src/routes/v2/widget-credentials.http.ts create mode 100644 apps/api/src/routes/v2/widget-summary.http.test.ts create mode 100644 apps/api/src/routes/v2/widget-summary.http.ts create mode 100644 apps/ios/Maple/Widgets/AppInstallation.swift create mode 100644 apps/ios/Packages/MapleAPI/Sources/MapleAPI/MapleClient+WidgetSummary.swift create mode 100644 apps/ios/Packages/MapleAPI/Sources/MapleWidgetData/WidgetCredentialStore.swift create mode 100644 apps/ios/Packages/MapleAPI/Sources/MapleWidgetData/WidgetFetchState.swift create mode 100644 apps/ios/Packages/MapleAPI/Sources/MapleWidgetData/WidgetSummary.swift create mode 100644 apps/ios/Packages/MapleAPI/Sources/MapleWidgetData/WidgetSummaryFetcher.swift create mode 100644 apps/ios/Packages/MapleAPI/Sources/MapleWidgetData/WidgetTimelineRefresh.swift create mode 100644 apps/ios/Packages/MapleAPI/Tests/MapleWidgetDataTests/WidgetCredentialStoreTests.swift create mode 100644 apps/ios/Packages/MapleAPI/Tests/MapleWidgetDataTests/WidgetSummaryFetcherTests.swift create mode 100644 apps/ios/Packages/MapleAPI/Tests/MapleWidgetDataTests/WidgetSummaryTests.swift create mode 100644 packages/domain/src/http/v2/widget-credentials.ts create mode 100644 packages/domain/src/http/v2/widget-summary.ts 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..35a7785f9 --- /dev/null +++ b/apps/ios/Packages/MapleAPI/Sources/MapleWidgetData/WidgetCredentialStore.swift @@ -0,0 +1,162 @@ +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 { + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + return encoder + } + + private static var decoder: JSONDecoder { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + return decoder + } +} diff --git a/apps/ios/Packages/MapleAPI/Sources/MapleWidgetData/WidgetFetchState.swift b/apps/ios/Packages/MapleAPI/Sources/MapleWidgetData/WidgetFetchState.swift new file mode 100644 index 000000000..5d0e00462 --- /dev/null +++ b/apps/ios/Packages/MapleAPI/Sources/MapleWidgetData/WidgetFetchState.swift @@ -0,0 +1,161 @@ +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 { + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + return encoder + } + + private static var decoder: JSONDecoder { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + return decoder + } +} diff --git a/apps/ios/Packages/MapleAPI/Sources/MapleWidgetData/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..856bcf851 --- /dev/null +++ b/apps/ios/Packages/MapleAPI/Sources/MapleWidgetData/WidgetSummaryFetcher.swift @@ -0,0 +1,215 @@ +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: + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + guard let payload = try? 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..b0a837993 --- /dev/null +++ b/apps/ios/Packages/MapleAPI/Tests/MapleWidgetDataTests/WidgetSummaryTests.swift @@ -0,0 +1,257 @@ +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 decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + let decoded = try 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 decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + let decoded = try decoder.decode(WidgetSummaryPayload.self, from: Data(json.utf8)) + #expect(decoded.throughput.bucketSeconds == nil) + } + + @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" From a375102269243e52017c17919f910575f882d607 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 21 Aug 2026 12:29:32 +0200 Subject: [PATCH 2/2] fix(ios): decode the timestamps the API actually sends MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI caught a real bug, not a test artifact: the widget extension would have failed to decode every payload it fetched on iOS 18. `JSONDecoder.DateDecodingStrategy.iso8601` is backed by `ISO8601DateFormatter` with default options, which reject fractional seconds. Every v2 timestamp is `toISOString()` output — milliseconds and all — so `.iso8601` cannot read any of them. It does not fail everywhere, which is what hid it. The Swift Foundation rewrite in newer OS versions parses fractional seconds happily, so this passed on a macOS 26 laptop and failed on a macOS 15 runner. The deployment target is iOS 18, so it would have shipped as "the widgets never refresh on most phones" with no error anywhere — an undecodable payload is indistinguishable from a fetch that failed. MapleAPI already knew about this trap and handles it in `ResolvedTimeWindow.parse`, whose comment says so; the new module reintroduced it. `WidgetJSON` now owns the coders for MapleWidgetData and parses both shapes, matching that proven approach. Every store in the module moves onto it — including the ones that only read what they wrote, since a payload written by a different build should not be able to break a widget either. Keeps writing timestamps without fractional seconds: sub-second precision means nothing to a surface whose smallest unit is "1m ago". --- .../WidgetCredentialStore.swift | 14 +--- .../MapleWidgetData/WidgetFetchState.swift | 14 +--- .../Sources/MapleWidgetData/WidgetJSON.swift | 67 +++++++++++++++++++ .../WidgetOrganizationIndex.swift | 16 ++--- .../MapleWidgetData/WidgetSnapshotStore.swift | 14 +--- .../WidgetSummaryFetcher.swift | 8 ++- .../WidgetSummaryTests.swift | 41 ++++++++++-- 7 files changed, 120 insertions(+), 54 deletions(-) create mode 100644 apps/ios/Packages/MapleAPI/Sources/MapleWidgetData/WidgetJSON.swift diff --git a/apps/ios/Packages/MapleAPI/Sources/MapleWidgetData/WidgetCredentialStore.swift b/apps/ios/Packages/MapleAPI/Sources/MapleWidgetData/WidgetCredentialStore.swift index 35a7785f9..b4ba02b34 100644 --- a/apps/ios/Packages/MapleAPI/Sources/MapleWidgetData/WidgetCredentialStore.swift +++ b/apps/ios/Packages/MapleAPI/Sources/MapleWidgetData/WidgetCredentialStore.swift @@ -148,15 +148,7 @@ public struct WidgetCredentialStore: Sendable { try? FileManager.default.removeItem(at: directory) } - 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 } } diff --git a/apps/ios/Packages/MapleAPI/Sources/MapleWidgetData/WidgetFetchState.swift b/apps/ios/Packages/MapleAPI/Sources/MapleWidgetData/WidgetFetchState.swift index 5d0e00462..a246415f3 100644 --- a/apps/ios/Packages/MapleAPI/Sources/MapleWidgetData/WidgetFetchState.swift +++ b/apps/ios/Packages/MapleAPI/Sources/MapleWidgetData/WidgetFetchState.swift @@ -147,15 +147,7 @@ public struct WidgetFetchStateStore: Sendable { save(state, organizationId: organizationId) } - 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 } } 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/WidgetSummaryFetcher.swift b/apps/ios/Packages/MapleAPI/Sources/MapleWidgetData/WidgetSummaryFetcher.swift index 856bcf851..13c1ab321 100644 --- a/apps/ios/Packages/MapleAPI/Sources/MapleWidgetData/WidgetSummaryFetcher.swift +++ b/apps/ios/Packages/MapleAPI/Sources/MapleWidgetData/WidgetSummaryFetcher.swift @@ -192,9 +192,11 @@ public actor WidgetSummaryFetcher { guard let http = response as? HTTPURLResponse else { return .failure(.server) } switch http.statusCode { case 200: - let decoder = JSONDecoder() - decoder.dateDecodingStrategy = .iso8601 - guard let payload = try? decoder.decode(WidgetSummaryPayload.self, from: data) else { + // `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) diff --git a/apps/ios/Packages/MapleAPI/Tests/MapleWidgetDataTests/WidgetSummaryTests.swift b/apps/ios/Packages/MapleAPI/Tests/MapleWidgetDataTests/WidgetSummaryTests.swift index b0a837993..f9c104842 100644 --- a/apps/ios/Packages/MapleAPI/Tests/MapleWidgetDataTests/WidgetSummaryTests.swift +++ b/apps/ios/Packages/MapleAPI/Tests/MapleWidgetDataTests/WidgetSummaryTests.swift @@ -108,9 +108,7 @@ struct WidgetSummaryWireTests { } } """ - let decoder = JSONDecoder() - decoder.dateDecodingStrategy = .iso8601 - let decoded = try decoder.decode(WidgetSummaryPayload.self, from: Data(json.utf8)) + let decoded = try WidgetJSON.decoder.decode(WidgetSummaryPayload.self, from: Data(json.utf8)) #expect(decoded.isSupported) #expect(decoded.organizationId == "org_1") @@ -127,12 +125,43 @@ struct WidgetSummaryWireTests { "issues":{"window_seconds":86400,"has_more":false,"data":[]}, "throughput":{"window_seconds":3600,"bucket_seconds":null,"services":[],"total_points":[]}} """ - let decoder = JSONDecoder() - decoder.dateDecodingStrategy = .iso8601 - let decoded = try decoder.decode(WidgetSummaryPayload.self, from: Data(json.utf8)) + 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)