mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 05:42:19 +00:00
The ntfy and webhook 502 descriptions shared one list of refused credentials that neither channel fully has. Each now names its own, including the userinfo and long path segments of a webhook URL. The email description names the TLS, protocol and envelope cases. Refs #947
882 lines
44 KiB
TypeScript
882 lines
44 KiB
TypeScript
/**
|
||
* OpenAPI route table — settings surfaces: the reminder thresholds, the
|
||
* operator's instance-wide service switches, and account deletion.
|
||
*
|
||
* 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 {
|
||
emailSettingsSchema,
|
||
notificationChannelEnabledSchema,
|
||
ntfySettingsSchema,
|
||
webhookSettingsSchema,
|
||
} from "@/lib/validations/notifications";
|
||
import { telegramSettingsSchema } from "@/lib/validations/telegram";
|
||
import {
|
||
dataEnvelope,
|
||
errorEnvelope,
|
||
recordRefusal,
|
||
SHARING_NOT_PERMITTED_DESCRIPTION,
|
||
stdResponses,
|
||
} from "./shared";
|
||
import { WEBHOOK_PAYLOAD_FORMATS } from "@/lib/notifications/types";
|
||
|
||
// v1.16.11 — the one threshold read every dose-status consumer makes
|
||
// (cards, table, take-all-due derivation). `lateMinutes` /
|
||
// `missedMinutes` are the operator-level singleton; the low-stock
|
||
// runway threshold rides along but is PER-USER (written through
|
||
// `PATCH /api/auth/me/notification-prefs`).
|
||
const reminderThresholdsResponse = z
|
||
.object({
|
||
lateMinutes: z
|
||
.number()
|
||
.int()
|
||
.describe(
|
||
"Minutes after the dose anchor at which an open dose tiers 'late' (operator-level singleton; default 120).",
|
||
),
|
||
missedMinutes: z
|
||
.number()
|
||
.int()
|
||
.describe(
|
||
"Minutes after the dose anchor at which an open dose tiers 'missed' (operator-level singleton; default 240).",
|
||
),
|
||
lowStockRunwayDays: z
|
||
.number()
|
||
.int()
|
||
.min(1)
|
||
.max(60)
|
||
.nullable()
|
||
.describe(
|
||
"Per-user low-stock alert threshold as remaining runway days (1–60). null = the alert is off. Default 7. Written via PATCH /api/auth/me/notification-prefs.",
|
||
),
|
||
reorderLeadDays: z
|
||
.number()
|
||
.int()
|
||
.min(0)
|
||
.max(60)
|
||
.describe(
|
||
"v1.17.0 — per-user reorder lead default in days (0–60, default 10). The low-stock alert fires this lead plus one dose-interval before the supply runs out so a refill arrives before the last dose; a per-medication reorderLeadDays overrides it. Written via PATCH /api/auth/me/notification-prefs.",
|
||
),
|
||
})
|
||
.meta({
|
||
id: "ReminderThresholdsResponse",
|
||
description:
|
||
"Medication reminder thresholds: the operator-level late/missed minute marks plus the calling user's low-stock runway threshold (v1.16.11).",
|
||
});
|
||
|
||
// ── Instance-level service switches ──────────────────────────────────
|
||
|
||
const globalServiceAvailabilityResponse = z
|
||
.object({
|
||
telegramGlobal: z.boolean().describe("Telegram notifications are offered."),
|
||
ntfyGlobal: z.boolean().describe("ntfy notifications are offered."),
|
||
webPushGlobal: z.boolean().describe("Web Push is offered."),
|
||
apiGlobal: z
|
||
.boolean()
|
||
.describe(
|
||
"The API-token surface is on. When false, `/api/tokens` and its revoke sibling answer 403 — the read included.",
|
||
),
|
||
})
|
||
.meta({
|
||
id: "GlobalServiceAvailability",
|
||
description:
|
||
"Which optional services the operator has left switched on instance-wide. Every flag defaults to true, and a failed settings read returns all-true rather than an error, so a client must not treat this as proof a channel is configured — only that the operator has not switched it off.",
|
||
});
|
||
|
||
// ── Account deletion ─────────────────────────────────────────────────
|
||
// The body is hand-parsed rather than Zod-validated (the handler reads the raw
|
||
// text, caps it, and compares one field), so the shape is described here rather
|
||
// than imported. Documenting it as a schema is still right: it is the wire
|
||
// contract, whatever the handler uses to check it.
|
||
|
||
const deleteAccountRequest = z
|
||
.object({
|
||
confirm: z
|
||
.literal("DELETE_ACCOUNT")
|
||
.describe(
|
||
"The literal string `DELETE_ACCOUNT`. Any other value, and any non-string, is refused with 422.",
|
||
),
|
||
})
|
||
.meta({
|
||
id: "DeleteAccountRequest",
|
||
description:
|
||
"Typed confirmation for permanent account deletion. There is no second step and no undo.",
|
||
});
|
||
|
||
// ── Email channel, channel self-tests, the data wipe, privacy facts ──
|
||
|
||
const emailChannelResponse = z
|
||
.object({
|
||
enabled: z.boolean(),
|
||
recipient: z
|
||
.string()
|
||
.describe("The saved recipient address, or an empty string when none."),
|
||
smtpConfigured: z
|
||
.boolean()
|
||
.describe(
|
||
"Whether the OPERATOR has configured an SMTP transport. False means no email can be sent from this instance whatever the user saves, so a client should hide the card rather than offer a control that cannot work.",
|
||
),
|
||
})
|
||
.meta({
|
||
id: "EmailChannelResponse",
|
||
description:
|
||
"The per-user email channel. Only the recipient is per-user — host, port, credentials and sender come from the operator's environment and never enter a user's encrypted channel blob.",
|
||
});
|
||
|
||
const emailChannelPutRequest = z
|
||
.union([notificationChannelEnabledSchema, emailSettingsSchema])
|
||
.meta({
|
||
id: "EmailChannelPutRequest",
|
||
description:
|
||
"Two accepted shapes. `{ enabled }` alone toggles the existing channel without restating the address — and is refused with 422 when no recipient has ever been saved, because enabling a channel with nowhere to send is not a state worth storing. `{ recipient, enabled }` upserts both; `recipient` may be an empty string only while `enabled` is false.",
|
||
});
|
||
|
||
const channelTestResponse = z.object({ sent: z.literal(true) }).meta({
|
||
id: "ChannelTestResponse",
|
||
description:
|
||
"The provider accepted the test message. Acceptance is not delivery — a message can still be dropped downstream.",
|
||
});
|
||
|
||
const privacySummaryResponse = z
|
||
.object({
|
||
retention: z
|
||
.object({
|
||
coachMessagesDays: z
|
||
.number()
|
||
.int()
|
||
.describe(
|
||
"How long Coach conversation history is kept. Operator-configurable, floor 30, default 365.",
|
||
),
|
||
auditLogDays: z
|
||
.number()
|
||
.int()
|
||
.describe(
|
||
"How long audit history is kept. Read from the same resolver the purge job uses, so the number a person is shown is the number that is enforced.",
|
||
),
|
||
deliveryLogDays: z
|
||
.number()
|
||
.int()
|
||
.describe(
|
||
"Fixed 90-day window on the push-attempt and mood-dispatch ledgers.",
|
||
),
|
||
})
|
||
.describe(
|
||
"Retention windows as the privacy dashboard states them. Off-host backup retention is operator infrastructure rather than an app setting and is deliberately absent — it is disclosed as prose in the UI, not as a number here.",
|
||
),
|
||
encryption: z
|
||
.object({
|
||
algorithm: z.literal("AES-256-GCM"),
|
||
columnCount: z
|
||
.number()
|
||
.int()
|
||
.describe("How many columns are encrypted at rest."),
|
||
modelCount: z
|
||
.number()
|
||
.int()
|
||
.describe("How many models carry at least one of them."),
|
||
})
|
||
.describe(
|
||
"Counted from the canonical encrypted-column registry rather than hand-maintained, so the disclosure cannot drift from what is actually encrypted.",
|
||
),
|
||
})
|
||
.meta({
|
||
id: "PrivacySummaryResponse",
|
||
description:
|
||
"Server-authoritative facts for the Data & Privacy dashboard. No key material, no secrets, no per-row data — the same numbers for every account on the instance.",
|
||
});
|
||
|
||
const dataWipeRequest = z
|
||
.object({
|
||
confirm: z
|
||
.literal("DELETE")
|
||
.describe(
|
||
"The literal string `DELETE`. Note it is NOT the `DELETE_ACCOUNT` string the account-deletion route wants — the two confirmations are deliberately different so one cannot be pasted into the other.",
|
||
),
|
||
})
|
||
.meta({
|
||
id: "DataWipeRequest",
|
||
description: "Typed confirmation for erasing the record. No undo.",
|
||
});
|
||
|
||
const dataWipeResponse = z
|
||
.object({
|
||
cleared: z.literal(true),
|
||
deletedRows: z.number().int().describe("Total rows removed."),
|
||
models: z
|
||
.record(z.string(), z.number().int())
|
||
.describe(
|
||
"Per-model row counts, listing only the models that actually had rows. A model absent from this map had nothing to delete — it was not skipped.",
|
||
),
|
||
})
|
||
.meta({ id: "DataWipeResponse" });
|
||
|
||
// ── Per-channel notification config ──────────────────────────────────
|
||
// Three credential-bearing channels the Settings page and the iOS client both
|
||
// write. Every GET here reports the SECRET AS A BOOLEAN and never the value:
|
||
// `hasAuthToken` (ntfy), `hasBotToken` (Telegram), `hasHeaderValue` (webhook).
|
||
// The PUT side is what that costs: because a client cannot read the secret back,
|
||
// an empty value on the way in means "keep what is stored", never "clear it".
|
||
|
||
const jsonBodyRefusals = {
|
||
"400": {
|
||
description: "Body is not parseable JSON.",
|
||
content: { "application/json": { schema: errorEnvelope } },
|
||
},
|
||
"413": {
|
||
description: "Body exceeds 64 KiB.",
|
||
content: { "application/json": { schema: errorEnvelope } },
|
||
},
|
||
"415": {
|
||
description: "Content-Type is not `application/json`.",
|
||
content: { "application/json": { schema: errorEnvelope } },
|
||
},
|
||
};
|
||
|
||
const channelEnabledOnlyRequest = notificationChannelEnabledSchema.meta({
|
||
id: "NotificationChannelEnabledRequest",
|
||
description:
|
||
"The toggle-only arm of a channel PUT: exactly `{ enabled }` and nothing else. Any additional key falls through to the channel's full config body instead. Enabling a channel whose stored config is incomplete fails with 422.",
|
||
});
|
||
|
||
const ntfySettingsRequest = ntfySettingsSchema.meta({
|
||
id: "NtfySettingsRequest",
|
||
description:
|
||
"ntfy channel config. `serverUrl` must pass the SSRF floor — a URL resolving into a private range is refused at input time unless the operator listed its exact origin in `NOTIFICATION_PRIVATE_ORIGINS`. An EMPTY OR OMITTED `authToken` preserves the stored one rather than clearing it: the GET never returns the secret, so a client round-tripping the form would otherwise wipe it on every unrelated save. Sending a non-empty value replaces it. There is no way to clear a stored auth token through this endpoint.",
|
||
});
|
||
|
||
const telegramSettingsRequest = telegramSettingsSchema.meta({
|
||
id: "TelegramSettingsRequest",
|
||
description:
|
||
"Telegram channel config. Both credential fields are optional and behave by key PRESENCE, not by value: omit `botToken` to keep the stored one, send an empty string to clear it. Same for `chatId`. Enabling the channel registers the Telegram webhook against `APP_URL` as part of the write, so a deployment whose `APP_URL` is not a public HTTPS host on port 80/88/443/8443 cannot enable Telegram at all.",
|
||
});
|
||
|
||
const webhookSettingsRequest = webhookSettingsSchema.meta({
|
||
id: "WebhookSettingsRequest",
|
||
description:
|
||
"Generic-webhook channel config — one channel covering Gotify, Discord, Slack, a Matrix bridge, Home Assistant, or any relay accepting an inbound JSON POST. `url` must pass the SSRF floor at input time and is re-checked at dispatch time; a private origin passes both only when the operator listed it in `NOTIFICATION_PRIVATE_ORIGINS`. An EMPTY OR OMITTED `headerValue` preserves the stored one, for the same reason as ntfy's `authToken`: the GET never returns it. There is no way to clear a stored header value through this endpoint. An omitted `format` keeps the stored body shape.",
|
||
});
|
||
|
||
/**
|
||
* The two shapes every channel PUT accepts, as one `anyOf`.
|
||
*
|
||
* The handler tries the strict `{ enabled }` object first and falls through to
|
||
* the full config schema, so the wire genuinely admits either. Publishing only
|
||
* the full schema would tell a client it has to resend the whole config to flip
|
||
* a toggle — exactly the round-trip the preserve-on-empty rule exists to
|
||
* survive, and exactly the round-trip that used to wipe the stored secret.
|
||
*/
|
||
const ntfyPutRequest = z
|
||
.union([channelEnabledOnlyRequest, ntfySettingsRequest])
|
||
.meta({
|
||
id: "NtfySettingsPutRequest",
|
||
description:
|
||
"Either the toggle-only body or the full ntfy config. The toggle arm is tried first and matches only an object with `enabled` and no other key.",
|
||
});
|
||
|
||
const telegramPutRequest = z
|
||
.union([channelEnabledOnlyRequest, telegramSettingsRequest])
|
||
.meta({
|
||
id: "TelegramSettingsPutRequest",
|
||
description:
|
||
"Either the toggle-only body or the full Telegram config. The toggle arm is tried first and matches only an object with `enabled` and no other key.",
|
||
});
|
||
|
||
const webhookPutRequest = z
|
||
.union([channelEnabledOnlyRequest, webhookSettingsRequest])
|
||
.meta({
|
||
id: "WebhookSettingsPutRequest",
|
||
description:
|
||
"Either the toggle-only body or the full webhook config. The toggle arm is tried first and matches only an object with `enabled` and no other key.",
|
||
});
|
||
|
||
const ntfySettingsResponse = z
|
||
.object({
|
||
enabled: z.boolean(),
|
||
serverUrl: z
|
||
.string()
|
||
.describe("Defaults to `https://ntfy.sh` when no channel row exists."),
|
||
topic: z.string().describe("Empty string when unset."),
|
||
hasAuthToken: z
|
||
.boolean()
|
||
.describe(
|
||
"Whether an auth token is stored. The token itself is never returned.",
|
||
),
|
||
})
|
||
.meta({
|
||
id: "NtfySettingsResponse",
|
||
description:
|
||
"Stored ntfy channel config, with the auth token reduced to a presence flag.",
|
||
});
|
||
|
||
const telegramSettingsResponse = z
|
||
.object({
|
||
enabled: z.boolean(),
|
||
hasBotToken: z
|
||
.boolean()
|
||
.describe(
|
||
"Whether a bot token is stored. The token itself is never returned.",
|
||
),
|
||
chatId: z
|
||
.string()
|
||
.nullable()
|
||
.describe(
|
||
"The stored chat id, returned in full. It is an addressing value, not a credential — the bot token is what authorises a send.",
|
||
),
|
||
})
|
||
.meta({
|
||
id: "TelegramSettingsResponse",
|
||
description:
|
||
"Stored Telegram channel config, with the bot token reduced to a presence flag.",
|
||
});
|
||
|
||
const webhookSettingsResponse = z
|
||
.object({
|
||
enabled: z.boolean(),
|
||
url: z.string().describe("Empty string when no channel row exists."),
|
||
headerName: z
|
||
.string()
|
||
.describe("Empty string when unset. The header NAME is not a secret."),
|
||
hasHeaderValue: z
|
||
.boolean()
|
||
.describe(
|
||
"Whether a header value (the shared secret) is stored. The value itself is never returned.",
|
||
),
|
||
format: z
|
||
.enum(WEBHOOK_PAYLOAD_FORMATS)
|
||
.describe(
|
||
"Body shape the webhook sends. `generic` when no channel row exists or the config predates the choice.",
|
||
),
|
||
})
|
||
.meta({
|
||
id: "WebhookSettingsResponse",
|
||
description:
|
||
"Stored generic-webhook channel config, with the secret header value reduced to a presence flag.",
|
||
});
|
||
|
||
const channelSaved = z.object({ saved: z.literal(true) });
|
||
const channelUpdated = z.object({ updated: z.literal(true) });
|
||
|
||
export const settingsPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||
"/api/settings/ntfy": {
|
||
get: {
|
||
tags: ["Notifications"],
|
||
summary: "Read the caller's ntfy channel config",
|
||
description:
|
||
'Returns the stored server URL, topic and enabled flag. The auth token is reported as `hasAuthToken` only. When the user has no ntfy channel row the defaults `{ enabled: false, serverUrl: "https://ntfy.sh", topic: "", hasAuthToken: false }` are returned rather than a 404. Auth via cookie or Bearer.',
|
||
responses: {
|
||
"200": {
|
||
description: "Stored (or default) ntfy config.",
|
||
content: {
|
||
"application/json": {
|
||
schema: dataEnvelope(ntfySettingsResponse, "GetNtfySettings"),
|
||
},
|
||
},
|
||
},
|
||
...stdResponses,
|
||
},
|
||
},
|
||
put: {
|
||
tags: ["Notifications"],
|
||
summary: "Update the caller's ntfy channel config",
|
||
description:
|
||
"Upserts the channel. Accepts either the toggle-only body or the full config. The response is `{ saved: true }` and carries no config echo, so re-read the GET if the client needs the resolved state. Auth via cookie or Bearer.\n\n" +
|
||
"An operator can switch ntfy off for the whole instance. While that switch is off, a body that sets `enabled` to true is refused with 403 and nothing is stored — the channel would not deliver, so the toggle does not pretend otherwise. Disabling the channel keeps working either way. Read the switch from `GET /api/settings/global-services`.",
|
||
requestBody: {
|
||
required: true,
|
||
content: { "application/json": { schema: ntfyPutRequest } },
|
||
},
|
||
responses: {
|
||
"200": {
|
||
description: "Config stored.",
|
||
content: {
|
||
"application/json": {
|
||
schema: dataEnvelope(channelSaved, "PutNtfySettingsResponse"),
|
||
},
|
||
},
|
||
},
|
||
"403": {
|
||
description:
|
||
"The operator switched ntfy off for this instance and the body asked to enable it. Nothing was stored.",
|
||
content: { "application/json": { schema: errorEnvelope } },
|
||
},
|
||
...jsonBodyRefusals,
|
||
...stdResponses,
|
||
"422": {
|
||
description:
|
||
"Body matched neither accepted shape, or `enabled` is true while the stored config has no server URL and topic; or the server URL is on a private network the operator has not listed in `NOTIFICATION_PRIVATE_ORIGINS` (`meta.errorCode` = `private_origin_not_approved`), or it is a link-local, metadata or unspecified address that no grant can open (`private_origin_not_grantable`). Nothing was stored.",
|
||
content: { "application/json": { schema: errorEnvelope } },
|
||
},
|
||
},
|
||
},
|
||
},
|
||
"/api/settings/telegram": {
|
||
get: {
|
||
tags: ["Notifications"],
|
||
summary: "Read the caller's Telegram channel config",
|
||
description:
|
||
"Returns the enabled flag, the chat id and `hasBotToken`. The bot token is never returned. Auth via cookie or Bearer.",
|
||
responses: {
|
||
"200": {
|
||
description: "Stored Telegram config.",
|
||
content: {
|
||
"application/json": {
|
||
schema: dataEnvelope(
|
||
telegramSettingsResponse,
|
||
"GetTelegramSettings",
|
||
),
|
||
},
|
||
},
|
||
},
|
||
...stdResponses,
|
||
},
|
||
},
|
||
put: {
|
||
tags: ["Notifications"],
|
||
summary: "Update the caller's Telegram channel config",
|
||
description:
|
||
"Accepts either the toggle-only body or the full config, then registers (on enable) or deletes (on disable) the Telegram webhook for the bot before persisting. Webhook deletion is best-effort and its failure does not fail the write; webhook REGISTRATION is not — a rejected registration aborts the whole call and nothing is stored. The response is `{ updated: true }` with no config echo. Auth via cookie or Bearer.\n\n" +
|
||
"An operator can switch Telegram off for the whole instance. While that switch is off, a body that sets `enabled` to true is refused with 403 before any webhook call and nothing is stored. Disabling the channel keeps working either way. Read the switch from `GET /api/settings/global-services`.",
|
||
requestBody: {
|
||
required: true,
|
||
content: { "application/json": { schema: telegramPutRequest } },
|
||
},
|
||
responses: {
|
||
"200": {
|
||
description: "Config stored.",
|
||
content: {
|
||
"application/json": {
|
||
schema: dataEnvelope(
|
||
channelUpdated,
|
||
"PutTelegramSettingsResponse",
|
||
),
|
||
},
|
||
},
|
||
},
|
||
"403": {
|
||
description:
|
||
"The operator switched Telegram off for this instance and the body asked to enable it. No webhook was registered and nothing was stored.",
|
||
content: { "application/json": { schema: errorEnvelope } },
|
||
},
|
||
"404": {
|
||
description: "The session's user row no longer exists.",
|
||
content: { "application/json": { schema: errorEnvelope } },
|
||
},
|
||
...jsonBodyRefusals,
|
||
"500": {
|
||
description:
|
||
"The deployment cannot register a Telegram webhook because `APP_URL` (or `NEXT_PUBLIC_APP_URL`) is missing or unparseable. An operator configuration problem, not a client one — retrying will not help.",
|
||
content: { "application/json": { schema: errorEnvelope } },
|
||
},
|
||
...stdResponses,
|
||
"422": {
|
||
description:
|
||
"Body matched neither accepted shape; or `enabled` is true without both a bot token and a chat id; or the deployment's `APP_URL` is not a public HTTPS host on port 80/88/443/8443; or Telegram refused the webhook registration.",
|
||
content: { "application/json": { schema: errorEnvelope } },
|
||
},
|
||
},
|
||
},
|
||
},
|
||
"/api/settings/webhook": {
|
||
get: {
|
||
tags: ["Notifications"],
|
||
summary: "Read the caller's generic-webhook channel config",
|
||
description:
|
||
'Returns the stored URL, header name and enabled flag. The secret header value is reported as `hasHeaderValue` only. When the user has no webhook channel row the defaults `{ enabled: false, url: "", headerName: "", hasHeaderValue: false }` are returned rather than a 404. Auth via cookie or Bearer.',
|
||
responses: {
|
||
"200": {
|
||
description: "Stored (or default) webhook config.",
|
||
content: {
|
||
"application/json": {
|
||
schema: dataEnvelope(
|
||
webhookSettingsResponse,
|
||
"GetWebhookSettings",
|
||
),
|
||
},
|
||
},
|
||
},
|
||
...stdResponses,
|
||
},
|
||
},
|
||
put: {
|
||
tags: ["Notifications"],
|
||
summary: "Update the caller's generic-webhook channel config",
|
||
description:
|
||
"Upserts the channel. Accepts either the toggle-only body or the full config. The response is `{ saved: true }` and carries no config echo. Auth via cookie or Bearer.",
|
||
requestBody: {
|
||
required: true,
|
||
content: { "application/json": { schema: webhookPutRequest } },
|
||
},
|
||
responses: {
|
||
"200": {
|
||
description: "Config stored.",
|
||
content: {
|
||
"application/json": {
|
||
schema: dataEnvelope(channelSaved, "PutWebhookSettingsResponse"),
|
||
},
|
||
},
|
||
},
|
||
...jsonBodyRefusals,
|
||
...stdResponses,
|
||
"422": {
|
||
description:
|
||
"Body matched neither accepted shape, or `enabled` is true while no webhook URL is stored; or the URL is on a private network the operator has not listed in `NOTIFICATION_PRIVATE_ORIGINS` (`meta.errorCode` = `private_origin_not_approved`), or it is a link-local, metadata or unspecified address that no grant can open (`private_origin_not_grantable`). Nothing was stored.",
|
||
content: { "application/json": { schema: errorEnvelope } },
|
||
},
|
||
},
|
||
},
|
||
},
|
||
"/api/settings/reminder-thresholds": {
|
||
get: {
|
||
tags: ["Notifications"],
|
||
summary: "Read the medication reminder thresholds",
|
||
description:
|
||
"Returns the operator-level late/missed minute thresholds that tier an open dose's status, plus the calling user's low-stock runway threshold (days; null = alert off). One endpoint so every threshold consumer reads one shape. Auth via cookie or Bearer.",
|
||
responses: {
|
||
...recordRefusal(),
|
||
"200": {
|
||
description: "Resolved thresholds.",
|
||
content: {
|
||
"application/json": {
|
||
schema: dataEnvelope(
|
||
reminderThresholdsResponse,
|
||
"GetReminderThresholdsResponse",
|
||
),
|
||
},
|
||
},
|
||
},
|
||
...stdResponses,
|
||
},
|
||
},
|
||
},
|
||
// ── Appended: two settings surfaces the registry never carried.
|
||
"/api/settings/global-services": {
|
||
get: {
|
||
tags: ["Notifications"],
|
||
summary: "Read the operator's instance-wide service switches",
|
||
description:
|
||
"Which optional services this instance offers at all, so a client can hide a channel the operator has switched off rather than let a user configure something that will never deliver. Auth via cookie or a wildcard Bearer token; the answer is about the DEPLOYMENT and is identical for every account on it.\n\n" +
|
||
"Reads fail soft: when the settings row cannot be loaded the handler returns all-true rather than an error, so a true flag means “not switched off”, never “configured and working”.",
|
||
responses: {
|
||
"200": {
|
||
description: "The instance's service switches.",
|
||
content: {
|
||
"application/json": {
|
||
schema: dataEnvelope(
|
||
globalServiceAvailabilityResponse,
|
||
"GlobalServiceAvailabilityEnvelope",
|
||
),
|
||
},
|
||
},
|
||
},
|
||
...stdResponses,
|
||
},
|
||
},
|
||
},
|
||
"/api/settings/account": {
|
||
delete: {
|
||
tags: ["Auth"],
|
||
summary: "Permanently delete the account and everything in it",
|
||
description:
|
||
"Erasure, not deactivation: the user row goes, every related row cascades with it, and the account's own audit history — IP addresses, login geo — is purged inside the same transaction rather than being left orphaned. There is no undo and no grace period.\n\n" +
|
||
"Two gates, and they compose. The body must carry the typed confirmation. And for an account with a second factor enrolled — a confirmed TOTP secret OR a registered security key — the caller must additionally present a fresh factor proof. An account with NO second factor keeps the typed-confirmation-only contract on either transport, deliberately, because a user who never enrolled has no ceremony to complete.\n\n" +
|
||
"How the fresh proof is presented depends on the transport. A cookie session carries it on the session row: complete the second factor within the last five minutes and retry. A Bearer caller presents a single-use elevation from `POST /api/auth/step-up` in the `X-Step-Up` header, minted against a `totp`, `webauthn`, or `passkey` proof — a `password`-proved elevation is refused here exactly as a password login never satisfies step-up on the web. The elevation is bound to the token that minted it, valid five minutes, and spent only when the deletion is about to run, so a rejected confirmation does not burn it. A recovery code is not accepted on the Bearer path: the mint does not take one, so an account that has lost its authenticator deletes itself from the web.\n\n" +
|
||
"Two refusals are not about the caller's credentials at all. The last remaining admin cannot delete themselves, and the last Guardian of a managed profile cannot leave that profile unattended.",
|
||
requestBody: {
|
||
required: true,
|
||
content: { "application/json": { schema: deleteAccountRequest } },
|
||
},
|
||
responses: {
|
||
"200": {
|
||
description: "Account and all associated data deleted.",
|
||
content: {
|
||
"application/json": {
|
||
schema: dataEnvelope(
|
||
z.object({ deleted: z.boolean() }),
|
||
"DeleteAccountEnvelope",
|
||
),
|
||
},
|
||
},
|
||
},
|
||
"400": {
|
||
description:
|
||
"This is the last admin account on the instance and cannot be deleted. Promote another admin first.",
|
||
content: { "application/json": { schema: errorEnvelope } },
|
||
},
|
||
"409": {
|
||
description:
|
||
"The caller is the last Guardian of a managed profile (`meta.errorCode` = `managed_profile.guardian.required`). Add another Guardian, or delete the managed profile, before deleting this account. Nothing was deleted.",
|
||
content: { "application/json": { schema: errorEnvelope } },
|
||
},
|
||
"413": {
|
||
description: "Request body exceeds 65536 bytes. Nothing was deleted.",
|
||
content: { "application/json": { schema: errorEnvelope } },
|
||
},
|
||
...stdResponses,
|
||
"401": {
|
||
description:
|
||
"Not authenticated — or the account has a second factor and the caller carries no proof of it fresh enough for a destructive action (`meta.errorCode` = `auth.stepup.required`, or `auth.stepup.mfa_not_enrolled`). On a cookie session, re-verify the factor and retry; on a Bearer token, mint an elevation at `POST /api/auth/step-up` with a `totp`, `webauthn`, or `passkey` proof and resend with `X-Step-Up`. The same code covers an elevation that is unknown, expired, already spent, minted for a different token, or minted from too weak a factor — the refusals are deliberately indistinguishable on the wire, and the real reason is audited.",
|
||
content: { "application/json": { schema: errorEnvelope } },
|
||
},
|
||
"403": {
|
||
description:
|
||
"The Bearer token carries a narrow scope. Erasure needs a cookie session or a full-access token; the refusal happens before any second-factor proof is looked at.\n\n" +
|
||
SHARING_NOT_PERMITTED_DESCRIPTION,
|
||
content: { "application/json": { schema: errorEnvelope } },
|
||
},
|
||
},
|
||
},
|
||
},
|
||
|
||
// ── Appended: the email channel, the four channel self-tests, the record
|
||
// wipe and the privacy dashboard's facts.
|
||
"/api/settings/email": {
|
||
get: {
|
||
tags: ["Notifications"],
|
||
summary: "Read the email channel",
|
||
description:
|
||
'The caller\'s recipient address and opt-in, plus whether the operator has configured SMTP at all. An account that has never saved a channel gets `{ enabled: false, recipient: "" }` rather than a 404, so a client can render the empty form without branching.\n\n' +
|
||
"Read `smtpConfigured` before offering the control: it is an instance fact, and when it is false nothing this endpoint saves can ever be delivered.",
|
||
responses: {
|
||
"200": {
|
||
description: "The channel, and whether the instance can send at all.",
|
||
content: {
|
||
"application/json": {
|
||
schema: dataEnvelope(
|
||
emailChannelResponse,
|
||
"GetEmailChannelEnvelope",
|
||
),
|
||
},
|
||
},
|
||
},
|
||
...stdResponses,
|
||
},
|
||
},
|
||
put: {
|
||
tags: ["Notifications"],
|
||
summary: "Save the email channel",
|
||
description:
|
||
"Accepts either shape: `{ enabled }` to toggle an existing channel, or `{ recipient, enabled }` to upsert both. The recipient is stored encrypted; SMTP credentials are the operator's and never enter it.\n\n" +
|
||
"Enabling without a saved recipient is refused — both when the toggle-only body finds no stored address and when the full body carries `enabled: true` with an empty one. Body capped at 16 KB.",
|
||
requestBody: {
|
||
required: true,
|
||
content: { "application/json": { schema: emailChannelPutRequest } },
|
||
},
|
||
responses: {
|
||
"200": {
|
||
description: "Channel saved.",
|
||
content: {
|
||
"application/json": {
|
||
schema: dataEnvelope(
|
||
z.object({ saved: z.literal(true) }),
|
||
"PutEmailChannelEnvelope",
|
||
),
|
||
},
|
||
},
|
||
},
|
||
"400": {
|
||
description:
|
||
"The operator has configured no SMTP transport, so there is nothing to save a channel against. An instance-level refusal, not a validation error — which is why it is 400 rather than 422.",
|
||
content: { "application/json": { schema: errorEnvelope } },
|
||
},
|
||
...stdResponses,
|
||
},
|
||
},
|
||
},
|
||
"/api/settings/email/test": {
|
||
post: {
|
||
tags: ["Notifications"],
|
||
summary: "Send a test email to the saved recipient",
|
||
description:
|
||
"Fires one message through the operator's SMTP transport to the address saved on this account. Takes no body. Rate-limited 5 per 5 minutes per user.\n\n" +
|
||
"Refuses with 400 when the instance has no SMTP transport, when the account has no email channel, or when the channel carries no recipient — three distinguishable prose messages on one status. A failure at the mail server or on the way to it is a 502 that names its cause; only a failure the server cannot name is a 500.",
|
||
responses: {
|
||
"200": {
|
||
description:
|
||
"The provider accepted the message. Not proof it was delivered.",
|
||
content: {
|
||
"application/json": {
|
||
schema: dataEnvelope(channelTestResponse, "EmailTestEnvelope"),
|
||
},
|
||
},
|
||
},
|
||
...stdResponses,
|
||
"502": {
|
||
description:
|
||
"The mail server, or the way to it, failed. `meta.errorCode` names the cause: `credentials_rejected` (the server refused the SMTP login), `upstream_rejected` (a permanent 5xx rejection, e.g. an unknown mailbox or relay denied, or an envelope or message refused without a reply code), `upstream_error` (a temporary 4xx rejection), `timeout`, or `connection_failed` (including a TLS handshake or protocol failure). `meta.smtpCode` carries the SMTP reply code when the mail server sent one.",
|
||
content: { "application/json": { schema: errorEnvelope } },
|
||
},
|
||
"500": {
|
||
description:
|
||
"The send failed in a way the server could not name. An internal fault, not a statement about the mail server.",
|
||
content: { "application/json": { schema: errorEnvelope } },
|
||
},
|
||
},
|
||
},
|
||
},
|
||
"/api/settings/ntfy/test": {
|
||
post: {
|
||
tags: ["Notifications"],
|
||
summary: "Send a test message to the saved ntfy topic",
|
||
description:
|
||
"Publishes one message to the account's configured ntfy server and topic. Takes no body. Rate-limited 5 per 5 minutes per user. Refuses with 400 when no ntfy channel is saved or when the saved config is missing a server URL or topic. A failure at the ntfy server or on the way to it is a 502 that carries the upstream status and, when safe, what the server answered; only a failure the server cannot name is a 500. A server on a private network is refused with 422 and `meta.errorCode` = `private_origin_not_approved` unless the operator listed its exact origin in `NOTIFICATION_PRIVATE_ORIGINS`; the test and the scheduled delivery take the same decision.",
|
||
responses: {
|
||
"200": {
|
||
description:
|
||
"The provider accepted the message. Not proof it was delivered.",
|
||
content: {
|
||
"application/json": {
|
||
schema: dataEnvelope(channelTestResponse, "NtfyTestEnvelope"),
|
||
},
|
||
},
|
||
},
|
||
...stdResponses,
|
||
"502": {
|
||
description:
|
||
"The ntfy server, or the way to it, failed. `meta.errorCode` names the cause: `upstream_rejected` (the other end refused the payload, e.g. 400/409/415/422), `credentials_rejected` (401/403), `endpoint_not_found` (404/410), `rate_limited` (429), `upstream_error` (5xx), `redirected` (3xx, never followed), `timeout`, or `connection_failed`. `meta.upstreamStatus` carries the HTTP status whenever one arrived, and `meta.upstreamBody` up to 200 characters of what the other end answered, with control characters removed; it is omitted when the body was empty or could carry a credential: a token-shaped string, or the saved auth token, the topic, or a credential in the server URL (userinfo, a query value, a path segment of 16 or more characters), literal, JSON-escaped or percent-encoded.",
|
||
content: { "application/json": { schema: errorEnvelope } },
|
||
},
|
||
"500": {
|
||
description:
|
||
"The send failed in a way the server could not name. An internal fault, not a statement about the ntfy server.",
|
||
content: { "application/json": { schema: errorEnvelope } },
|
||
},
|
||
"422": {
|
||
description:
|
||
"The saved server is on a private network the operator has not approved (`meta.errorCode` = `private_origin_not_approved`), or is a link-local, metadata or unspecified address that no grant can open (`private_origin_not_grantable`). The resolved address is never echoed.",
|
||
content: { "application/json": { schema: errorEnvelope } },
|
||
},
|
||
},
|
||
},
|
||
},
|
||
"/api/settings/telegram/test": {
|
||
post: {
|
||
tags: ["Notifications"],
|
||
summary: "Send a test message to the saved Telegram chat",
|
||
description:
|
||
"Sends one message through the account's saved bot token and chat id, in the account's own interface language. Takes no body. Rate-limited 5 per 5 minutes per user.\n\n" +
|
||
"This one differs from its siblings: both the not-configured case and the send failure answer 422 rather than 400 and 500. The failure is genuinely ambiguous — a wrong token and a wrong chat id look the same from here — so one message covers both.",
|
||
responses: {
|
||
"200": {
|
||
description:
|
||
"The provider accepted the message. Not proof it was delivered.",
|
||
content: {
|
||
"application/json": {
|
||
schema: dataEnvelope(channelTestResponse, "TelegramTestEnvelope"),
|
||
},
|
||
},
|
||
},
|
||
...stdResponses,
|
||
},
|
||
},
|
||
},
|
||
"/api/settings/webhook/test": {
|
||
post: {
|
||
tags: ["Notifications"],
|
||
summary: "Send a test payload to the saved webhook",
|
||
description:
|
||
"Posts one payload to the account's configured webhook URL. Takes no body. Rate-limited 5 per 5 minutes per user. Refuses with 400 when no webhook channel is saved or the saved config has no URL. A failure at the relay or on the way to it is a 502 that carries the upstream status and, when safe, what the relay answered; only a failure the server cannot name is a 500. The URL was checked against the SSRF floor when it was saved, so a test cannot be used to reach an internal host. A URL on a private network is refused with 422 and `meta.errorCode` = `private_origin_not_approved` unless the operator listed its exact origin in `NOTIFICATION_PRIVATE_ORIGINS`; the test and the scheduled delivery take the same decision.",
|
||
responses: {
|
||
"200": {
|
||
description:
|
||
"The provider accepted the message. Not proof it was delivered.",
|
||
content: {
|
||
"application/json": {
|
||
schema: dataEnvelope(channelTestResponse, "WebhookTestEnvelope"),
|
||
},
|
||
},
|
||
},
|
||
...stdResponses,
|
||
"502": {
|
||
description:
|
||
"The relay, or the way to it, failed. `meta.errorCode` names the cause: `upstream_rejected` (the other end refused the payload, e.g. 400/409/415/422), `credentials_rejected` (401/403), `endpoint_not_found` (404/410), `rate_limited` (429), `upstream_error` (5xx), `redirected` (3xx, never followed), `timeout`, or `connection_failed`. `meta.upstreamStatus` carries the HTTP status whenever one arrived, and `meta.upstreamBody` up to 200 characters of what the other end answered, with control characters removed; it is omitted when the body was empty or could carry a credential: a token-shaped string, or the saved header value (and its token part), the URL's userinfo, a query value, or a path segment of 16 or more characters (the token in a Discord or Slack webhook URL), literal, JSON-escaped or percent-encoded.",
|
||
content: { "application/json": { schema: errorEnvelope } },
|
||
},
|
||
"500": {
|
||
description:
|
||
"The send failed in a way the server could not name. An internal fault, not a statement about the relay.",
|
||
content: { "application/json": { schema: errorEnvelope } },
|
||
},
|
||
"422": {
|
||
description:
|
||
"The saved URL is on a private network the operator has not approved (`meta.errorCode` = `private_origin_not_approved`), or is a link-local, metadata or unspecified address that no grant can open (`private_origin_not_grantable`). The resolved address is never echoed.",
|
||
content: { "application/json": { schema: errorEnvelope } },
|
||
},
|
||
},
|
||
},
|
||
},
|
||
"/api/settings/data": {
|
||
delete: {
|
||
tags: ["Auth"],
|
||
summary: "Erase the record, keeping the account",
|
||
description:
|
||
"The other half of account deletion: this removes what the account HAS and keeps what signs into it. Ninety-nine models are hard-deleted for this user — measurements and every derived rollup, medications and their intake history, mood, labs, documents, cycle, workouts, nutrients, notification channels, Coach conversations, consent receipts, audit history — with no `deletedAt` filter, so soft-deleted tombstones go too. The account row survives with its personal columns reset: body and identity fields, home location, cached AI output. What is deliberately kept is the credential set (password hash, SSO binding, second-factor secret and enrolment, trusted-device state) and the interface preferences (timezone, locale, units, time and date format, glucose unit), because those are about the account rather than the record.\n\n" +
|
||
"The model list is not written at the route. It is declared once in the wipe plan and held against the Prisma schema by a completeness test, which exists because this endpoint once deleted thirteen tables out of eighty-odd while telling people it had deleted everything.\n\n" +
|
||
"One transaction, with a two-minute budget: a half-applied wipe is worse than none, because the person is told it succeeded either way. Every cached payload for the account is evicted afterwards so no surface serves pre-wipe data.\n\n" +
|
||
"Gated like account deletion: the typed confirmation, plus a fresh factor proof for an MFA-enrolled account — on a cookie session the second factor completed within the last five minutes, on a Bearer token a single-use `X-Step-Up` elevation minted at `POST /api/auth/step-up` against a `totp`, `webauthn`, or `passkey` proof. The confirmation string is `DELETE`, NOT the `DELETE_ACCOUNT` that route wants.\n\n" +
|
||
"One audit row survives the wipe that clears the rest of the audit history: it is written after the transaction commits, as the receipt for the erasure the person asked for.\n\n" +
|
||
"A Bearer client should expect to be signed out by its own request: API tokens are on the wipe list, so the token that called this — and any step-up elevation minted from it — is removed along with the record. Sign in again rather than retrying with the same credential.",
|
||
requestBody: {
|
||
required: true,
|
||
content: { "application/json": { schema: dataWipeRequest } },
|
||
},
|
||
responses: {
|
||
"200": {
|
||
description:
|
||
"Record erased. The counts say what was actually removed.",
|
||
content: {
|
||
"application/json": {
|
||
schema: dataEnvelope(dataWipeResponse, "DataWipeEnvelope"),
|
||
},
|
||
},
|
||
},
|
||
"409": {
|
||
description:
|
||
"The caller is the last Guardian of a managed profile (`meta.errorCode` = `managed_profile.guardian.required`). Nothing was deleted — the managed records are locked for the whole transaction, so the refusal is atomic rather than partial. Add another Guardian, or delete the managed profile, first.",
|
||
content: { "application/json": { schema: errorEnvelope } },
|
||
},
|
||
"413": {
|
||
description: "Request body exceeds 65536 bytes. Nothing was deleted.",
|
||
content: { "application/json": { schema: errorEnvelope } },
|
||
},
|
||
...stdResponses,
|
||
"401": {
|
||
description:
|
||
"Not authenticated — or the account has a second factor and the caller carries no proof of it fresh enough for a destructive action (`meta.errorCode` = `auth.stepup.required`, or `auth.stepup.mfa_not_enrolled`). A Bearer caller clears it with an `X-Step-Up` elevation from `POST /api/auth/step-up`; the same code covers an elevation that is unknown, expired, already spent, bound to another token, or minted from too weak a factor.",
|
||
content: { "application/json": { schema: errorEnvelope } },
|
||
},
|
||
"403": {
|
||
description:
|
||
"The Bearer token carries a narrow scope. Erasure needs a cookie session or a full-access token; the refusal happens before any second-factor proof is looked at.\n\n" +
|
||
SHARING_NOT_PERMITTED_DESCRIPTION,
|
||
content: { "application/json": { schema: errorEnvelope } },
|
||
},
|
||
},
|
||
},
|
||
},
|
||
"/api/settings/privacy-summary": {
|
||
get: {
|
||
tags: ["Meta"],
|
||
summary: "Read the retention and encryption facts",
|
||
description:
|
||
"What the Data & Privacy dashboard states, computed server-side so the page cannot claim a retention window the purge job does not enforce or an encryption count the registry does not back. Identical for every account on the instance; carries no key material and no per-row data.",
|
||
responses: {
|
||
"200": {
|
||
description: "Retention windows and the encryption-at-rest summary.",
|
||
content: {
|
||
"application/json": {
|
||
schema: dataEnvelope(
|
||
privacySummaryResponse,
|
||
"PrivacySummaryEnvelope",
|
||
),
|
||
},
|
||
},
|
||
},
|
||
...stdResponses,
|
||
},
|
||
},
|
||
},
|
||
};
|