mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 22:02:16 +00:00
Two component-id collisions, both from the same cause: two paths that
are two doors onto one action, documented independently.
DELETE /api/devices/{id} and DELETE /api/auth/me/devices/{id} both call
revokeDeviceCascade and answer with the same shape. Each grew its own
DeviceRevokeResponse, which the emitter refuses outright. The response
moves to shared.ts so neither module owns it and the next door onto the
same call finds it; the two envelopes keep separate names because an
envelope names an operation.
The settings module took both halves by hand rather than by keep-both:
one side added nine paths, the other three, with no overlap but a shared
import block and a shared reminder-thresholds entry that a mechanical
union duplicated.
137 lines
4.9 KiB
TypeScript
137 lines
4.9 KiB
TypeScript
/**
|
|
* 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<ZodOpenApiObject["paths"]> = {
|
|
"/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,
|
|
},
|
|
},
|
|
},
|
|
};
|