/** * OpenAPI route table — device (APNs) registration. * * Part of the OpenAPI route table; aggregated in `./index.ts`. * Schemas come from `src/lib/validations/*` where shared with the * runtime request parsing, so the wire contract stays single-source. */ import { z } from "zod/v4"; import type { ZodOpenApiObject } from "zod-openapi"; import { dataEnvelope, deviceRevokeResponse, errorEnvelope, stdResponses, } from "./shared"; const deviceRegisterRequest = z .object({ token: z .string() .min(8) .max(512) .regex(/^[A-Za-z0-9+/=._:-]+$/) .describe("Generic device identifier (legacy; APNs token below)."), 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(), apnsToken: z .string() .min(8) .max(256) .regex(/^[A-Fa-f0-9]+$/) .optional() .describe( "Hex-encoded APNs device token. Must be paired with `apnsEnvironment`.", ), apnsEnvironment: z .enum(["sandbox", "production"]) .optional() .describe( "Gateway the iOS client received `apnsToken` from. Server never auto-detects.", ), medicationDelivery: z .enum(["server", "client"]) .nullable() .optional() .describe( 'v1.7.0 per-device medication-delivery override. NULL / omitted = inherit the user-level roaming default. "server" forces server APNs for this device; "client" forces local. Stored + echoed; cron suppression stays user-level.', ), liveActivityPushToken: z .string() .min(8) .max(256) .regex(/^[A-Fa-f0-9]+$/) .nullable() .optional() .describe( "v1.17.1 (#22) hex-encoded ActivityKit Live Activity push token (distinct from the device APNs token). The server addresses a Live Activity update / end push on a medication-intake mutation. Omitted = keep the prior value; null = clear it when the Activity ends.", ), }) .meta({ id: "DeviceRegisterRequest", description: "Native device registration. Re-registering an APNs token belonging to another user returns 409 (cross-user-hijack guard).", }); export const devicePaths: NonNullable = { "/api/devices": { post: { tags: ["Devices"], summary: "Register native device + APNs token", description: "Idempotent upsert by `token`. APNs token + environment are paired — supplying one without the other returns 422. Cross-user re-registration of either identifier returns 409.", requestBody: { required: true, content: { "application/json": { schema: deviceRegisterRequest } }, }, responses: { "201": { description: "Device registered or refreshed.", content: { "application/json": { schema: dataEnvelope( z.object({ id: z.string() }), "DeviceRegisterResponse", ), }, }, }, "409": { description: "Device or APNs token already registered to another user.", content: { "application/json": { schema: errorEnvelope } }, }, ...stdResponses, }, }, }, "/api/devices/{id}": { delete: { tags: ["Devices"], summary: "Revoke a device and everything bound to it", description: "Deletes the device and, in one transaction, revokes every refresh and access token bound to it — the cleanup a native client runs when it rotates its own registration. The counts come back so the caller can log what the cascade actually killed. Scoped to the caller's own devices: an id belonging to somebody else is indistinguishable from one that does not exist. This is a second URL for `DELETE /api/auth/me/devices/{id}`; the two share one transactional helper and behave identically, and the duplication exists so the native rotation loop has a path that is not the settings-surface one. Auth via cookie or Bearer.", parameters: [ { name: "id", in: "path", required: true, schema: { type: "string" }, description: "Device id.", }, ], responses: { "200": { description: "The device and its tokens were revoked.", content: { "application/json": { schema: dataEnvelope( deviceRevokeResponse, "DeviceRevokeEnvelope", ), }, }, }, "404": { description: "No such device for this caller — also the answer for one already revoked, so a retry after a successful revoke is a 404 rather than an idempotent 200.", content: { "application/json": { schema: errorEnvelope } }, }, ...stdResponses, }, }, }, };