mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 05:42:19 +00:00
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:
1 parent
9088cbd789
commit
a859661686
4 files changed
+432
-27
No files matched your search
+26
-10
@@ -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
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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({
|
||||
|
||||
Reference in new issue
Block a user