diff --git a/.planning/STATE.md b/.planning/STATE.md index f52a97e93..9fa3d1250 100644 --- a/.planning/STATE.md +++ b/.planning/STATE.md @@ -64,16 +64,32 @@ Last update: 2026-05-11T00:25+02:00 ## Wave 3 — APNs scaffolding (F4) -- [ ] Library install + lock-file (W1 decision) -- [ ] `src/lib/notifications/senders/apns.ts` (send-push helper) -- [ ] Env-var contract (`APNS_KEY_ID`, `APNS_TEAM_ID`, - `APNS_KEY_FILE`, `APNS_BUNDLE_ID`) -- [ ] `Device` Prisma model extension: `apnsToken` field + - `apnsEnvironment` (sandbox/production) -- [ ] Dispatcher wiring: APNs joins the existing notification - cascade alongside Telegram + ntfy + Web Push -- [ ] Mock provider for tests -- [ ] Integration test for batched APNs send +- [x] Library install + lock-file (`@parse/node-apn@8.1.0`, + W1 stream-2 decision) +- [x] `src/lib/notifications/senders/apns.ts` — `sendApnsPush()` + one-shot helper plus `sendViaApns(userId, payload)` dispatcher + entry mirroring the web-push contract +- [x] Env-var contract: `APNS_KEY_ID`, `APNS_TEAM_ID`, + `APNS_BUNDLE_ID`, one of `APNS_KEY` / `APNS_KEY_FILE`, + optional `APNS_PRODUCTION` — documented in `.env.example`, + all-or-none guard with one-shot warning when partially set +- [x] `Device` Prisma model extension via migration + `0037_apns_device_columns`: nullable `apnsToken` + + `apnsEnvironment`, paired CHECK constraint, single-column + index on `apns_token` for the dispatcher fan-out lookup +- [x] Dispatcher wiring: `APNS` joins the cascade as channel-type + 4; cascade order is now deterministic + (APNs → Telegram → ntfy → Web Push) +- [x] Mock provider at `src/lib/notifications/senders/__mocks__/apns.ts` + with queued per-token responses + recorded calls +- [x] Integration test + `tests/integration/apns-dispatch.test.ts` — round-trip, + hard-reject + cleanup, cascade fall-through (3 tests) +- [x] `POST /api/devices` accepts paired `apnsToken` + + `apnsEnvironment`, enforces hex format, applies the + cross-user-hijack guard at the APNs-token layer + (409 `apns_token_owned_by_other_user`) +- Detailed report: `.planning/phase-W3-v1423-report.md` ## Wave 4 — OpenAPI + Coach schema + native auth + Coolify diff --git a/.planning/phase-W3-v1423-report.md b/.planning/phase-W3-v1423-report.md new file mode 100644 index 000000000..13499c893 --- /dev/null +++ b/.planning/phase-W3-v1423-report.md @@ -0,0 +1,236 @@ +# Wave 3 — v1.4.23 APNs scaffolding report + +Status: shipped on `develop` (2026-05-11). +Scope: F4 — APNs send-side library + sender + Device-model +extension + dispatcher wiring + tests + `POST /api/devices` +contract update so the v1.5 iOS app can land per-metric alerts +via Apple's push gateway alongside the existing Telegram → ntfy +→ Web Push cascade. + +## What landed + +### Six atomic commits + +1. **`chore(deps)` — install `@parse/node-apn` + env-var contract.** + Adds the W1-recommended APNs Provider library, documents the + four required env vars (`APNS_KEY_ID`, `APNS_TEAM_ID`, + `APNS_BUNDLE_ID`, plus one of `APNS_KEY` / `APNS_KEY_FILE`) + and the optional `APNS_PRODUCTION` override in `.env.example`. + Lockfile delta is `@parse/node-apn` plus its expected children + (`jsonwebtoken`, `node-forge`, `lodash.*`, `verror`, `assert-plus`, + `core-util-is`, `extsprintf`). +2. **`feat(schema)` — APNs device columns + dispatcher channel-type + extension.** Migration `0037_apns_device_columns` adds nullable + `apns_token` + `apns_environment` columns to the `devices` table, + pairs them with a CHECK constraint (neither set ⇔ both set), and + indexes `apns_token` for the dispatcher's per-user lookup + the + cross-user-hijack guard. The existing + `NotificationChannel.type` column is a free-form string, so + adding `"APNS"` as the fourth channel type is a TS-only change + in commit 4 — no DDL. +3. **`feat(notifications)` — APNs sender mirrors web-push contract.** + `src/lib/notifications/senders/apns.ts` exposes + `sendApnsPush({ deviceToken, environment, payload })` for + one-shot delivery and `sendViaApns(userId, payload)` as the + dispatcher entry. The Provider singleton is per-gateway + (sandbox vs production), lazy-initialised so the JWT-signing + parse doesn't run at boot. `@parse/node-apn` regenerates the + bearer JWT every ~50 minutes inside the Provider; the only + cache reset path is the `resetApnsForTesting()` helper that + tests call between cases. + Permanent-failure detection covers `Unregistered`, + `BadDeviceToken`, `DeviceTokenNotForTopic`; on hard reject the + dispatcher branch deletes the dead Device row before + returning, mirroring the web-push 410 cleanup. +4. **`feat(notifications)` — APNs joins the dispatcher cascade.** + `dispatcher.sendToChannel()` gains an `APNS` switch case + calling `sendViaApns()`. A new `channelPriority()` sort makes + the cascade order deterministic + (APNs → Telegram → ntfy → Web Push) so a Postgres scan order + change can't reorder delivery between deploys. `CHANNEL_TYPES`, + `CHANNEL_TYPE_LABELS`, and the `ChannelConfig` union pick up + the new value plus an empty `ApnsChannelConfig` (the per-device + token + environment live on the Device row, not the channel + config). +5. **`test(notifications)` — APNs sender unit + dispatcher + integration coverage.** 23 unit tests (env-var loader, Provider + lazy-init + per-gateway cache, permanent-failure detection, + dispatcher fan-out, HTML stripping) and 3 integration tests + (round-trip success, dead-device cleanup + audit log, cascade + fall-through to Telegram). Adds the deterministic mock at + `src/lib/notifications/senders/__mocks__/apns.ts` with queued + per-token responses + recorded calls so future test files can + swap the real sender for a fixture-driven double. +6. **`feat(api)` — `POST /api/devices` accepts apnsToken + + apnsEnvironment.** The body schema gains the paired fields; + supplying one without the other returns 422. The + cross-user-hijack guard from CLAUDE.md is duplicated at the + APNs-token layer — re-registering an `apnsToken` already + owned by another user returns 409 with reason + `apns_token_owned_by_other_user`. Audit-log details now carry + `hasApnsToken` + `apnsEnvironment` so admin trails surface APNs + pairing events. Five new route tests cover the validation + + hijack paths. + +### Test count delta + +- Unit tests: 2130 → **2153** (+23 in apns.test.ts; +5 in the + devices route test that's already counted above is wrong — + recounted below). +- Re-counted: unit total 2130 → **2158** (+23 apns sender + 5 new + devices-route assertions). +- Integration tests: 97 → **100** (+3 apns-dispatch). +- `pnpm typecheck`, `pnpm lint`, `pnpm test --run`, and + `pnpm test:integration` are all green between every commit. + +### Migration name + SQL summary + +`prisma/migrations/0037_apns_device_columns/migration.sql`. + +- `ALTER TABLE "devices" ADD COLUMN "apns_token" TEXT, ADD COLUMN "apns_environment" TEXT` — both nullable. +- `ALTER TABLE "devices" ADD CONSTRAINT "devices_apns_environment_required_with_token" CHECK (...)` — pairs the two columns so neither can be set without the other. +- `CREATE INDEX "devices_apns_token_idx" ON "devices" ("apns_token")` — backs the dispatcher's `findMany({ where: { apnsToken: { not: null } } })` fan-out and the cross-user-hijack guard's `findFirst({ where: { apnsToken } })` lookup. + +Strictly additive: no row mutations, no constraint changes that +could fail on existing data, no enum reorderings. + +### Lockfile delta + +`pnpm-lock.yaml` adds `@parse/node-apn@8.1.0` and the documented +transitive dependency chain only: + +| package | reason | +|---|---| +| `@parse/node-apn` | direct dependency | +| `jsonwebtoken` | JWT bearer for APNs auth tokens | +| `node-forge` | PEM parsing for the .p8 signing key | +| `verror` | error wrapping inside node-apn | +| `assert-plus`, `core-util-is`, `extsprintf` | verror's transitive set | +| `lodash.includes`, `lodash.isboolean`, `lodash.isinteger`, … | jsonwebtoken's per-helper imports | + +`web-push` already pulls `node-forge` for VAPID JWT signing, so +the on-disk install footprint delta is small. + +## W1 recommendations adopted verbatim + +| W1 recommendation | Status | +|---|---| +| Library = `@parse/node-apn` (vs `apns2` / hand-rolled HTTP/2) | Adopted | +| APNs joins existing `NotificationChannel` enum as 4th type | Adopted | +| Reuse existing `consecutiveFailures` / `nextRetryAt` / `disabledReason` columns | Adopted | +| Per-device `apnsEnvironment` (`sandbox` \| `production`); no server-side auto-detect | Adopted | +| Env-var contract `APNS_KEY_ID` / `APNS_TEAM_ID` / `APNS_BUNDLE_ID` / one of `APNS_KEY` / `APNS_KEY_FILE` | Adopted | +| Provider singleton per process — lazy-init, JWT rotated by library | Adopted | +| Permanent failures (`Unregistered`, `BadDeviceToken`) drop the device row | Adopted, plus `DeviceTokenNotForTopic` for completeness | +| Mock provider for test isolation | Adopted (`__mocks__/apns.ts` + `__tests__/apns.test.ts`) | + +## Decisions made beyond W1's scope + +- **Cascade ordering is now an explicit priority sort.** Before + W3 the dispatcher iterated the channels in Postgres-scan order; + APNs joining as the highest-priority channel made implicit + ordering brittle, so `channelPriority()` now drives the loop + with APNs at 0 and unknown types at 99. Deterministic across + deploys, easy to extend with a future `live-activity` channel. +- **`channelPriority()` sorts unknown types last.** A stale enum + value (e.g. an experimental `LIVE_ACTIVITY` row written by a + pre-deploy migration) can't preempt a real channel — its + `default: 99` priority slot pushes it past every known type so + the dispatcher still tries APNs / Telegram / ntfy / Web Push + first. +- **Per-environment Provider cache.** A user with one iPhone on + sandbox (TestFlight build) and another on production (App Store + build) gets two distinct `apn.Provider` instances with the + right gateway each. `forceProduction` (env override) collapses + both to production for staging environments that need to talk + to a TestFlight build over the prod gateway. +- **`apnsToken` regex enforces hex.** Apple's docs spec the token + as the hex-string form of a `Data` blob; rejecting non-hex + values at the API boundary catches the iOS implementation bug + where someone passes `data.description` (Swift's debug-print + format ``) instead of `data.map { String(format: "%02x", $0) }.joined()`. +- **APNs-token cross-user-hijack guard runs BEFORE the legacy + `token` upsert.** Two separate `findFirst` checks. The cost is + one extra indexed query per registration; the benefit is a + single user can't backdoor another user's pushes by + registering with their own legacy `token` and someone else's + `apnsToken`. +- **HTML is stripped from APNs alert bodies.** Mirrors what + web-push and ntfy already do; the iOS lock-screen renderer + treats angle brackets literally and a notification body of + `take meds` would land verbatim. Stripped server-side + so the Coach prompt template doesn't have to special-case the + channel. + +## What I couldn't ship and why + +Nothing in scope was dropped. Three adjacent items are explicitly +deferred per the brief: + +- **Background `apns-push-type` pushes** — the `pushType` field + on the Notification stays at the default `alert`; W1 + recommended deferring `background` (priority 5, throttled + aggressively by Apple) to v1.5 P4 when the iOS app needs the + HealthKit observer-query wake hook. +- **Per-device opt-out toggles** — today's `NotificationPreference` + is per `(channel, eventType)`. Per-device-token mute (silence + APNs on one phone but not the other) is a v1.5 P4 follow-up. +- **OpenAPI generator + Coach schema extension (Wave 4 F5/F6)** — + outside this wave's scope. The new `POST /api/devices` shape + needs the OpenAPI registry update in W4 so the iOS DTO doc + stays in sync. + +## Open questions for the maintainer about the iOS DTO contract + +1. **APNs token wire format.** Server expects lowercase / mixed-case + hex without spaces or angle brackets (Apple's modern + `data.map { String(format: "%02x", $0) }.joined()` shape). If + the iOS app currently logs the token via Swift's + `data.description` (which renders `` with + spaces and brackets) the body will return 422 with + `apnsToken must be hex`. Confirm the iOS DTO's serialiser uses + the hex-join shape. +2. **`apnsEnvironment` value at first registration.** The iOS app + will receive its APNs token before it knows for certain whether + the runtime is sandbox or production (the entitlement decides, + but the client can `#if DEBUG` it). Confirm the iOS contract + pins `Debug → "sandbox"`, `Release → "production"` so the server + doesn't have to double-send through both gateways on startup. +3. **Token rotation cadence.** APNs rotates a device's token on + reinstall, OS upgrade, and occasionally on backup-restore. + Server treats every `POST /api/devices` as upsert-by-`token`, + so a fresh `apnsToken` on the same legacy `token` overwrites + the old pairing — but a fresh legacy `token` AND fresh + `apnsToken` creates a brand-new Device row. Does the iOS app + delete the previous Device row on the server when it observes + a token rotation, or does it rely on APNs' `Unregistered` + reason to GC the old row? Recommendation: add a + `DELETE /api/devices/:id` route in W4 alongside the + `GET /api/auth/me/devices` listing in F7. +4. **Multiple iOS devices per user during the cascade.** The + dispatcher fans the same payload to every Device row owned by + the user. With two iPhones paired the user receives two + notifications, which matches the iCloud iMessage UX. Confirm + that's the intended behaviour — the alternative is pinning + one Device as "primary" per user, which would need a UI flag + in v1.5. +5. **`collapseId` value.** Server currently sets it to the + `eventType` (e.g. `MEDICATION_REMINDER`). That replaces the + previous reminder in Notification Center for the SAME + `eventType` — so a third reminder for the same medication-day + collapses the older two. If the iOS app wants per-medication + collapsing (so two simultaneous reminders for two different + meds both stay visible), the server should embed the + medicationId in the collapseId — confirm before v1.5 ships. + +## Verification commands + +```bash +pnpm typecheck # green +pnpm lint # green (15 pre-existing warnings, 0 new) +pnpm test --run # 2158 passed (was 2130, +28) +pnpm test:integration # 100 passed (was 97, +3) +``` + +Six commit SHAs land on `develop` (W3 sequence — see +`git log origin/develop..HEAD` once pushed). diff --git a/src/app/api/devices/__tests__/route.test.ts b/src/app/api/devices/__tests__/route.test.ts index 8f7f45485..63d2800a7 100644 --- a/src/app/api/devices/__tests__/route.test.ts +++ b/src/app/api/devices/__tests__/route.test.ts @@ -5,6 +5,7 @@ vi.mock("@/lib/db", () => ({ prisma: { device: { findUnique: vi.fn(), + findFirst: vi.fn(), create: vi.fn(), update: vi.fn(), }, @@ -52,6 +53,7 @@ function req(body: unknown): NextRequest { beforeEach(() => { vi.resetAllMocks(); vi.mocked(prisma.device.findUnique).mockResolvedValue(null); + vi.mocked(prisma.device.findFirst).mockResolvedValue(null); vi.mocked(prisma.device.create).mockResolvedValue({ id: "dev-1" } as never); vi.mocked(prisma.device.update).mockResolvedValue({ id: "dev-1" } as never); }); @@ -119,4 +121,78 @@ describe("POST /api/devices", () => { expect(prisma.device.update).toHaveBeenCalledTimes(1); expect(prisma.device.create).not.toHaveBeenCalled(); }); + + it("returns 422 when only apnsToken is supplied without apnsEnvironment", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + const res = await POST( + req({ + token: "abcd1234efgh5678", + bundleId: "io.healthlog.app", + apnsToken: "deadbeef".repeat(8), + }), + ); + expect(res.status).toBe(422); + }); + + it("returns 422 when only apnsEnvironment is supplied without apnsToken", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + const res = await POST( + req({ + token: "abcd1234efgh5678", + bundleId: "io.healthlog.app", + apnsEnvironment: "sandbox", + }), + ); + expect(res.status).toBe(422); + }); + + it("returns 422 when apnsToken is not hex", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + const res = await POST( + req({ + token: "abcd1234efgh5678", + bundleId: "io.healthlog.app", + apnsToken: "not-hex-z", + apnsEnvironment: "sandbox", + }), + ); + expect(res.status).toBe(422); + }); + + it("creates a Device row with the apnsToken + apnsEnvironment pair", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + const res = await POST( + req({ + token: "abcd1234efgh5678", + bundleId: "io.healthlog.app", + apnsToken: "deadbeef".repeat(8), + apnsEnvironment: "sandbox", + }), + ); + expect(res.status).toBe(201); + expect(prisma.device.create).toHaveBeenCalledTimes(1); + const args = vi.mocked(prisma.device.create).mock.calls[0][0] as { + data: { apnsToken?: string; apnsEnvironment?: string }; + }; + expect(args.data.apnsToken).toBe("deadbeef".repeat(8)); + expect(args.data.apnsEnvironment).toBe("sandbox"); + }); + + it("rejects re-registration of an apnsToken owned by another user with 409", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.device.findFirst).mockResolvedValue({ + id: "other-dev", + } as never); + const res = await POST( + req({ + token: "abcd1234efgh5678", + bundleId: "io.healthlog.app", + apnsToken: "deadbeef".repeat(8), + apnsEnvironment: "sandbox", + }), + ); + expect(res.status).toBe(409); + expect(prisma.device.create).not.toHaveBeenCalled(); + expect(prisma.device.update).not.toHaveBeenCalled(); + }); }); diff --git a/src/app/api/devices/route.ts b/src/app/api/devices/route.ts index 23dac745f..a498b2a97 100644 --- a/src/app/api/devices/route.ts +++ b/src/app/api/devices/route.ts @@ -1,11 +1,25 @@ /** * POST /api/devices * - * APNs device registration. Native clients call this on login and on - * APNs token rotation. We upsert by `token`; sending the same token on a - * different account simply transfers ownership. + * Native-client device registration. The iOS app calls this on login, + * on APNs token rotation, and whenever a fresh `apnsToken` arrives from + * `application:didRegisterForRemoteNotificationsWithDeviceToken:`. We + * upsert by `token` (the legacy generic identifier). * - * No APNs send-side logic lives here — that ships in Phase 8. + * Cross-user-hijack guard: + * * A device `token` belongs to exactly one user. Re-registering the + * same value under a different account returns 409. APNs tokens + * aren't secrets, so trusting wire input would let anyone who + * learns one redirect another user's pushes. + * * The same guard applies to `apnsToken` — supplying an `apnsToken` + * that's already registered to a different user returns 409 with + * reason `apns_token_owned_by_other_user`. + * + * APNs registration: + * * `apnsToken` and `apnsEnvironment` are paired — supplying one + * without the other returns 422. The iOS client picks the gateway + * (`sandbox` for Debug builds, `production` for Release / TestFlight) + * because the server has no way to tell from the token alone. */ import { NextRequest } from "next/server"; import { z } from "zod/v4"; @@ -15,17 +29,38 @@ import { annotate } from "@/lib/logging/context"; import { prisma } from "@/lib/db"; import { auditLog } from "@/lib/auth/audit"; -const deviceSchema = z.object({ - token: z - .string() - .min(8) - .max(512) - .regex(/^[A-Za-z0-9+/=._:-]+$/, "Invalid token format"), - bundleId: z.string().min(1).max(128), - locale: z.string().min(2).max(16).optional(), - appVersion: z.string().min(1).max(32).optional(), - model: z.string().min(1).max(64).optional(), -}); +const deviceSchema = z + .object({ + token: z + .string() + .min(8) + .max(512) + .regex(/^[A-Za-z0-9+/=._:-]+$/, "Invalid token format"), + bundleId: z.string().min(1).max(128), + locale: z.string().min(2).max(16).optional(), + appVersion: z.string().min(1).max(32).optional(), + model: z.string().min(1).max(64).optional(), + // APNs-specific pair — the iOS client populates these once it has + // an APNs token from the OS callback. Hex string per Apple's spec + // (64 chars on most devices; cap at 256 for forward compat). + apnsToken: z + .string() + .min(8) + .max(256) + .regex(/^[A-Fa-f0-9]+$/, "apnsToken must be hex") + .optional(), + apnsEnvironment: z.enum(["sandbox", "production"]).optional(), + }) + .refine( + (v) => + (v.apnsToken === undefined && v.apnsEnvironment === undefined) || + (v.apnsToken !== undefined && v.apnsEnvironment !== undefined), + { + message: + "apnsToken and apnsEnvironment must be supplied together or both omitted", + path: ["apnsEnvironment"], + }, + ); export const POST = apiHandler(async (request: NextRequest) => { const { user } = await requireAuth(); @@ -38,7 +73,39 @@ export const POST = apiHandler(async (request: NextRequest) => { return apiError(parsed.error.issues[0].message, 422); } - const { token, bundleId, locale, appVersion, model } = parsed.data; + const { + token, + bundleId, + locale, + appVersion, + model, + apnsToken, + apnsEnvironment, + } = parsed.data; + + // APNs-token cross-user-hijack guard: APNs tokens aren't secrets, so + // accepting one already owned by another user would let any client + // who learns one redirect that user's pushes. Reject before any + // upsert so we never accidentally transfer ownership. + if (apnsToken) { + const existingApns = await prisma.device.findFirst({ + where: { apnsToken, NOT: { userId: user.id } }, + select: { id: true }, + }); + if (existingApns) { + await auditLog("device.register.denied", { + userId: user.id, + details: { + reason: "apns_token_owned_by_other_user", + deviceId: existingApns.id, + }, + }); + return apiError( + "APNs token already registered to another account", + 409, + ); + } + } const existing = await prisma.device.findUnique({ where: { token } }); let id: string; @@ -65,6 +132,8 @@ export const POST = apiHandler(async (request: NextRequest) => { locale: locale ?? null, appVersion: appVersion ?? null, model: model ?? null, + apnsToken: apnsToken ?? null, + apnsEnvironment: apnsEnvironment ?? null, lastSeen: new Date(), }, select: { id: true }, @@ -80,6 +149,8 @@ export const POST = apiHandler(async (request: NextRequest) => { locale: locale ?? null, appVersion: appVersion ?? null, model: model ?? null, + apnsToken: apnsToken ?? null, + apnsEnvironment: apnsEnvironment ?? null, }, select: { id: true }, }); @@ -88,7 +159,13 @@ export const POST = apiHandler(async (request: NextRequest) => { await auditLog("devices.register", { userId: user.id, - details: { deviceId: id, bundleId, model: model ?? null }, + details: { + deviceId: id, + bundleId, + model: model ?? null, + hasApnsToken: Boolean(apnsToken), + apnsEnvironment: apnsEnvironment ?? null, + }, }); annotate({