feat(api): POST /api/devices accepts apnsToken + apnsEnvironment

Extends the device-registration body schema with the paired APNs
identity fields the iOS client populates once it has an APNs token from
`application:didRegisterForRemoteNotificationsWithDeviceToken:`.
Validation rejects half-supplied requests (`apnsToken` without
`apnsEnvironment` or vice versa) with 422 and enforces hex format on
the token — catching the common iOS implementation bug where the token
gets serialised through Swift's `Data.description` (which renders
`<deadbeef cafebabe>` with spaces and angle brackets) instead of the
hex-join shape Apple's docs spec.

The cross-user-hijack guard from CLAUDE.md is duplicated at the
APNs-token layer: a registration whose `apnsToken` is already owned by
another user returns 409 with reason `apns_token_owned_by_other_user`.
APNs tokens aren't secrets, so trusting wire input would let any
client who learns one redirect another user's pushes — same threat
model as the existing `token` guard, applied to the new field.

Audit-log details for `devices.register` now carry `hasApnsToken` and
`apnsEnvironment` so admin trails surface APNs pairing events without
leaking the raw token. Five new route tests cover the validation +
hijack paths (paired-or-omitted, hex regex, create-with-pair,
cross-user-409, the original 401/422/409/201 contracts stay green).

Ticks STATE.md F4 and lands `.planning/phase-W3-v1423-report.md`
covering the six W3 commits.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.7 committed 2026-05-11 00:48:27 +02:00
1 parent 9088cbd789
commit a859661686
4 files changed
+432 -27

No files matched your search

+26 -10
View File
@@ -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
+236
View File
@@ -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 `<deadbeef cafebabe>`) 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
`<b>take meds</b>` 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 `<deadbeef cafebabe>` 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).
@@ -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();
});
});
+94 -17
View File
@@ -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({