mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
Describe the integration, notification and MCP surfaces in the contract
Sixteen routes the native client already calls were absent from the OpenAPI registry, so the published spec said nothing about them. `openapi:check` compares the registry against the YAML and never the routes against the registry, which is why the gap survived. Nightscout status / test / disconnect, the WHOOP credential and status reads, and the Withings status / disconnect pair land in the integrations module. The three per-channel configs under `/api/settings/*` join the settings module; the notification preference and delivery-health surfaces get their own module, as do the MCP connector connections and tokens. These are credential-bearing surfaces, so the contract states what each response actually carries. Every channel GET reduces its secret to a boolean, and the descriptions say so, along with the consequence: a client cannot read the value back, so an empty value on the way in preserves the stored one rather than clearing it. `POST /api/mcp/tokens` is the one operation that returns a usable Bearer, and its description says outright that this is the only copy. Two inline request schemas move to the validations modules they belong in, so the registry documents the same object the handler parses rather than a restatement of it.
This commit is contained in:
1 parent
a6c239d952
commit
c8035fdd78
9 files changed
+1330
-21
No files matched your search
@@ -18,7 +18,6 @@
|
||||
* every REST write) — it only admits the confirmed in-process write tools.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import {
|
||||
@@ -33,16 +32,11 @@ import { issueApiToken } from "@/lib/auth/issue-token";
|
||||
import { isApiGloballyEnabled } from "@/lib/app-settings";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { SCOPE_HEALTH_READ, SCOPE_HEALTH_WRITE } from "@/lib/mcp/oauth/config";
|
||||
|
||||
const createSchema = z.object({
|
||||
name: z.string().min(1, "Name required").max(100),
|
||||
expiresInDays: z.number().int().min(1).max(365).optional(),
|
||||
// The ONLY two scope shapes this endpoint will mint. `read` (default) is the
|
||||
// least-privilege read token; `read_write` adds `health:write` so the
|
||||
// confirmed `/mcp` write tools become available. Anything else is rejected
|
||||
// by the enum — the endpoint can never mint a wildcard or arbitrary grant.
|
||||
scope: z.enum(["read", "read_write"]).optional().default("read"),
|
||||
});
|
||||
// The ONLY two scope shapes this endpoint will mint. `read` (default) is the
|
||||
// least-privilege read token; `read_write` adds `health:write` so the confirmed
|
||||
// `/mcp` write tools become available. Anything else is rejected by the enum —
|
||||
// the endpoint can never mint a wildcard or arbitrary grant.
|
||||
import { createMcpTokenSchema } from "@/lib/validations/mcp";
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
@@ -89,7 +83,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
});
|
||||
if (jsonError) return jsonError;
|
||||
|
||||
const parsed = createSchema.safeParse(body);
|
||||
const parsed = createMcpTokenSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return returnAllZodIssues(parsed.error, 422);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { z } from "zod/v4";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
@@ -7,6 +6,7 @@ import { apiSuccess, apiError } from "@/lib/api-response";
|
||||
import { CHANNEL_TYPE_LABELS, EVENT_TYPES } from "@/lib/notifications/types";
|
||||
import type { ChannelType, EventType } from "@/lib/notifications/types";
|
||||
import { reEnableChannel } from "@/lib/notifications/channel-state";
|
||||
import { reEnableChannelSchema } from "@/lib/validations/notifications";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -131,10 +131,6 @@ export const GET = apiHandler(async () => {
|
||||
return apiSuccess({ channels: data, events });
|
||||
});
|
||||
|
||||
const reEnableSchema = z.object({
|
||||
channelId: z.string().min(1).max(64),
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/notifications/status
|
||||
*
|
||||
@@ -159,7 +155,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
return apiError("Invalid JSON data", 422);
|
||||
}
|
||||
|
||||
const parsed = reEnableSchema.safeParse(body);
|
||||
const parsed = reEnableChannelSchema.safeParse(body);
|
||||
if (!parsed.success) return apiError("Invalid data", 422);
|
||||
|
||||
const channel = await prisma.notificationChannel.findFirst({
|
||||
|
||||
@@ -65,8 +65,10 @@ import {
|
||||
medicationResource,
|
||||
medicationDoseHistoryImportFatalReasonEnum,
|
||||
} from "./medications";
|
||||
import { mcpPaths } from "./mcp";
|
||||
import { metaPaths } from "./meta";
|
||||
import { moodPaths } from "./mood";
|
||||
import { notificationChannelPaths } from "./notifications";
|
||||
import { nutrientPaths } from "./nutrients";
|
||||
import { onboardingPaths } from "./onboarding";
|
||||
import { profilePaths } from "./profile";
|
||||
@@ -128,6 +130,13 @@ export const openApiPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
// v1.36.0 — account sharing: the grant lifecycle and the switch endpoint
|
||||
// (appended, spread order is load-bearing).
|
||||
...accountSharingPaths,
|
||||
// Notification channels + MCP connector credentials: both families answered
|
||||
// the native client for several releases while being absent from the
|
||||
// registry, because `openapi:check` compares the registry against the YAML
|
||||
// and never the routes against the registry (appended, spread order is
|
||||
// load-bearing).
|
||||
...notificationChannelPaths,
|
||||
...mcpPaths,
|
||||
};
|
||||
|
||||
export const openApiComponents: NonNullable<ZodOpenApiObject["components"]> = {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/**
|
||||
* OpenAPI route table — third-party integration config (HealthKit).
|
||||
* OpenAPI route table — third-party integration config (HealthKit) and the
|
||||
* per-provider connection surfaces (Nightscout / WHOOP / Withings).
|
||||
*
|
||||
* Part of the OpenAPI route table; aggregated in `./index.ts`.
|
||||
* Response DTOs are declared here mirroring the route handler under
|
||||
@@ -8,6 +9,7 @@
|
||||
*/
|
||||
import { z } from "zod/v4";
|
||||
import type { ZodOpenApiObject } from "zod-openapi";
|
||||
import { whoopCredentialsSchema } from "@/lib/validations/whoop";
|
||||
import { dataEnvelope, errorEnvelope, stdResponses } from "./shared";
|
||||
|
||||
// Mirror the route's `directionEnum` — per-metric sync direction.
|
||||
@@ -276,6 +278,193 @@ const googleHealthSyncStatusResponse = z
|
||||
"The caller's current/most-recent Google Health sync run, or null when none has ever run. `data` carries this shape directly (not nested under a named key).",
|
||||
});
|
||||
|
||||
// ── Per-provider connection surfaces ─────────────────────────────────
|
||||
// Nightscout / WHOOP / Withings each expose a status read plus a teardown.
|
||||
// They answered iOS since v1.11 and were absent from the registry entirely,
|
||||
// so nothing below is new behaviour — it is the shape the handlers already
|
||||
// send, written down.
|
||||
|
||||
// Mirrors `IntegrationState` in `src/lib/integrations/status.ts`. The ledger
|
||||
// records what the last ATTEMPT did; the verdict in `syncHealth` is what says
|
||||
// whether the pipe is still alive. Both are published because they answer
|
||||
// different questions and a client that reads only `state` cannot tell a row
|
||||
// retrying hourly from one that stopped being tried at all.
|
||||
const integrationLedgerState = z
|
||||
.enum([
|
||||
"connected",
|
||||
"error_transient",
|
||||
"error_reauth",
|
||||
"disconnected",
|
||||
"parked",
|
||||
])
|
||||
.describe(
|
||||
"Last recorded ledger state for this provider. `connected` is also the value a user who has never synced sees — the ledger row is created on the first attempt, and its absence reads as `connected`, not as an error.",
|
||||
);
|
||||
|
||||
const nightscoutStatusResponse = z
|
||||
.object({
|
||||
connected: z
|
||||
.boolean()
|
||||
.describe(
|
||||
"A connection exists. Not a liveness claim — a pipe dead for a fortnight still answers `true`; read `syncHealth.verdict` for that.",
|
||||
),
|
||||
configured: z
|
||||
.boolean()
|
||||
.describe("An instance URL is stored. The connect marker."),
|
||||
hasToken: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe(
|
||||
"Whether an API token is stored alongside the URL. A fully-public Nightscout instance needs none, so `false` is not a misconfiguration. The token itself is never returned.",
|
||||
),
|
||||
allowPrivateHost: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe(
|
||||
"The operator has approved this user's private-origin (RFC 1918 / loopback / link-local) Nightscout instance. False for every public instance.",
|
||||
),
|
||||
state: integrationLedgerState.optional(),
|
||||
lastSuccessAt: z.iso.datetime({ offset: true }).nullable().optional(),
|
||||
lastAttemptAt: z.iso.datetime({ offset: true }).nullable().optional(),
|
||||
lastError: z
|
||||
.string()
|
||||
.nullable()
|
||||
.optional()
|
||||
.describe(
|
||||
"Last recorded failure message, decrypted from the ledger. Null when the last attempt succeeded or none has run.",
|
||||
),
|
||||
syncHealth: syncHealth.optional(),
|
||||
})
|
||||
.meta({
|
||||
id: "NightscoutStatusResponse",
|
||||
description:
|
||||
"Nightscout connection status. When nothing is configured the body is exactly `{ connected: false, configured: false }` — every other field is absent, not null. The stored instance URL is the user's own and is NOT echoed here; the API token never is.",
|
||||
});
|
||||
|
||||
const nightscoutTestResponse = z
|
||||
.object({
|
||||
ok: z.literal(true),
|
||||
latencyMs: z
|
||||
.number()
|
||||
.int()
|
||||
.describe("Round-trip time of the single-entry probe, in milliseconds."),
|
||||
})
|
||||
.meta({
|
||||
id: "NightscoutTestResponse",
|
||||
description:
|
||||
"A live probe that reached the configured instance and read one SGV entry. Only ever returned on success — a failed probe is an error status, never `ok: false`.",
|
||||
});
|
||||
|
||||
const whoopCredentialsStatusResponse = z
|
||||
.object({
|
||||
hasCredentials: z
|
||||
.boolean()
|
||||
.describe(
|
||||
"Both the client id and the client secret are stored. Neither value is ever returned.",
|
||||
),
|
||||
})
|
||||
.meta({
|
||||
id: "WhoopCredentialsStatusResponse",
|
||||
description:
|
||||
"Presence check for the per-user WHOOP BYO-key credentials. Presence only: this endpoint has no read path for the stored id or secret.",
|
||||
});
|
||||
|
||||
const whoopCredentialsRequest = whoopCredentialsSchema.meta({
|
||||
id: "WhoopCredentialsRequest",
|
||||
description:
|
||||
"Per-user WHOOP BYO-key credentials. Each self-hoster registers their own WHOOP developer app — the per-app authorized-user cap makes one shared app unworkable for a multi-operator product. Both values are trimmed (a trailing space from the portal's copy button reaches WHOOP verbatim and answers as 'unknown client') and stored encrypted at rest. Write-only: no endpoint reads them back.",
|
||||
});
|
||||
|
||||
const whoopStatusResponse = z
|
||||
.object({
|
||||
connected: z
|
||||
.boolean()
|
||||
.describe("A WHOOP connection row exists and carries a WHOOP user id."),
|
||||
configured: z
|
||||
.boolean()
|
||||
.describe("Per-user BYO-key client id AND secret are both stored."),
|
||||
lastSyncedAt: z.iso
|
||||
.datetime({ offset: true })
|
||||
.nullable()
|
||||
.optional()
|
||||
.describe("Null when no sync has completed yet."),
|
||||
connectedAt: z.iso
|
||||
.datetime({ offset: true })
|
||||
.optional()
|
||||
.describe("When the connection row was created."),
|
||||
tokenExpired: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe(
|
||||
"The stored access token's expiry is in the past. Read straight off the row — unlike the Withings status read, this endpoint does not refresh; the sync path refreshes lazily instead.",
|
||||
),
|
||||
tokenExpiresAt: z.iso.datetime({ offset: true }).optional(),
|
||||
backfillCompleted: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe(
|
||||
"The self-converging history backfill has walked every collection to completion.",
|
||||
),
|
||||
scope: z
|
||||
.string()
|
||||
.nullable()
|
||||
.optional()
|
||||
.describe(
|
||||
"Space-separated OAuth scope string granted by WHOOP. Null on a legacy connection that predates scope tracking.",
|
||||
),
|
||||
})
|
||||
.meta({
|
||||
id: "WhoopStatusResponse",
|
||||
description:
|
||||
"WHOOP connection status. When no connection row carries a WHOOP user id the body is exactly `{ connected: false, configured }` — every other field is absent, not null.",
|
||||
});
|
||||
|
||||
const withingsStatusResponse = z
|
||||
.object({
|
||||
connected: z.boolean().describe("A Withings connection row exists."),
|
||||
configured: z
|
||||
.boolean()
|
||||
.describe("Per-user client id AND secret are both stored."),
|
||||
lastSyncedAt: z.iso.datetime({ offset: true }).nullable().optional(),
|
||||
connectedAt: z.iso.datetime({ offset: true }).optional(),
|
||||
tokenExpired: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe(
|
||||
"True after a refresh attempt that did not produce a live token.",
|
||||
),
|
||||
tokenRefreshFailed: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe(
|
||||
"The read tried to refresh an expired token and could not — either the per-user credentials are gone or Withings refused. A permanently revoked grant additionally parks the ledger at `error_reauth` as a side effect of this read.",
|
||||
),
|
||||
tokenExpiresAt: z.iso
|
||||
.datetime({ offset: true })
|
||||
.optional()
|
||||
.describe(
|
||||
"Post-refresh expiry when the read refreshed, the stored expiry otherwise.",
|
||||
),
|
||||
scope: z
|
||||
.string()
|
||||
.nullable()
|
||||
.optional()
|
||||
.describe(
|
||||
"Comma-separated OAuth scope string from the authorisation flow. Null on a legacy connection that has never re-authed.",
|
||||
),
|
||||
hasActivityScope: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe(
|
||||
"Pre-computed convenience flag the reconnect banner reads. A null `scope` counts as missing activity.",
|
||||
),
|
||||
})
|
||||
.meta({
|
||||
id: "WithingsStatusResponse",
|
||||
description:
|
||||
"Withings connection status. When no connection row exists the body is exactly `{ connected: false, configured }` — every other field is absent, not null. Note that this read is not side-effect free: a token within 60 s of expiry is refreshed and re-persisted before the response is built, so a GET here can rotate the stored token.",
|
||||
});
|
||||
|
||||
export const integrationPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
"/api/integrations/healthkit": {
|
||||
get: {
|
||||
@@ -452,4 +641,238 @@ export const integrationPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/nightscout/status": {
|
||||
get: {
|
||||
tags: ["Integrations"],
|
||||
summary: "Read the caller's Nightscout connection status",
|
||||
description:
|
||||
"Returns the connect marker plus the shared `nightscout` ledger snapshot and the server-resolved liveness verdict, so the Settings card and the iOS client paint the same pill from one read. The stored API token is never returned — `hasToken` reports its presence and nothing more. Auth via cookie or Bearer.",
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Connection status.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
nightscoutStatusResponse,
|
||||
"NightscoutStatusEnvelope",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/nightscout/test": {
|
||||
post: {
|
||||
tags: ["Integrations"],
|
||||
summary: "Probe the configured Nightscout instance",
|
||||
description:
|
||||
"Pulls a single SGV entry through the same client the sync uses, to re-validate the stored URL and token without waiting for a sync window. No body. Nothing is written: this neither ingests the entry nor touches the ledger. Rate-limited 5 requests / 60 s per user. Auth via cookie or Bearer.",
|
||||
responses: {
|
||||
"200": {
|
||||
description: "The probe reached the instance.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
nightscoutTestResponse,
|
||||
"NightscoutTestEnvelope",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
"502": {
|
||||
description:
|
||||
"The probe did not complete. `meta.errorCode` names the class: `credentials_rejected` (401/403 from Nightscout), `rate_limited` (429 from Nightscout — distinct from this endpoint's own 429), `upstream_error` (5xx), `timeout` (no answer within 5 s), `url_not_public` (the stored origin resolves to a private host and the operator has not approved it), `connection_failed` (everything else). The message is fixed per class and never the caught error's own: a Nightscout base URL carries its API token as a query parameter, so an upstream message could leak it.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
...stdResponses,
|
||||
"422": {
|
||||
description:
|
||||
"No Nightscout instance is configured for this user. `meta.errorCode` = `not_configured`.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
"429": {
|
||||
description:
|
||||
"More than 5 probes in 60 s from this user. `meta.errorCode` = `rate_limited_self` — this is the local limiter, not Nightscout's.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/nightscout/disconnect": {
|
||||
post: {
|
||||
tags: ["Integrations"],
|
||||
summary: "Disconnect the caller's Nightscout instance",
|
||||
description:
|
||||
"Clears the stored URL, token and private-host approval, and parks the `nightscout` ledger at `disconnected`. Glucose rows already ingested are left in place — this stops future syncs, it does not delete history. No body. Rate-limited 20 requests / 60 s per user. Auth via cookie or Bearer.",
|
||||
responses: {
|
||||
"200": {
|
||||
description: "The connection was cleared.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
z.object({ disconnected: z.literal(true) }),
|
||||
"NightscoutDisconnectEnvelope",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
"404": {
|
||||
description:
|
||||
"No Nightscout connection to clear. Note that this is NOT an idempotent no-op: a second disconnect answers 404 rather than 200.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/whoop/credentials": {
|
||||
get: {
|
||||
tags: ["Integrations"],
|
||||
summary: "Check whether WHOOP BYO-key credentials are stored",
|
||||
description:
|
||||
"Presence only. There is no read path for the stored client id or secret — the response carries a single boolean. Auth via cookie or Bearer.",
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Credential presence.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
whoopCredentialsStatusResponse,
|
||||
"WhoopCredentialsStatusEnvelope",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
put: {
|
||||
tags: ["Integrations"],
|
||||
summary: "Store the caller's WHOOP BYO-key credentials",
|
||||
description:
|
||||
"Replaces both values; there is no partial update. Both are encrypted at rest and never read back. Body capped at 64 KiB. Auth via cookie or Bearer.",
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
"application/json": { schema: whoopCredentialsRequest },
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Credentials stored.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
z.object({ updated: z.literal(true) }),
|
||||
"WhoopCredentialsUpdateEnvelope",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
"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 } },
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
delete: {
|
||||
tags: ["Integrations"],
|
||||
summary: "Delete the caller's WHOOP credentials and connection",
|
||||
description:
|
||||
"Deletes the connection row (with its encrypted OAuth tokens), clears both credential columns, audits the teardown and parks the `whoop` ledger at `disconnected`. Idempotent: a missing connection row is a benign no-op, so a repeat call still answers 200. Auth via cookie or Bearer.",
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Credentials and connection removed.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
z.object({ deleted: z.literal(true) }),
|
||||
"WhoopCredentialsDeleteEnvelope",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/whoop/status": {
|
||||
get: {
|
||||
tags: ["Integrations"],
|
||||
summary: "Read the caller's WHOOP connection status",
|
||||
description:
|
||||
"Reports the BYO-key configuration marker plus the connection row's last sync, token expiry, granted scope and backfill progress. Read-only — token refresh happens lazily on the sync path, not here. Auth via cookie or Bearer.",
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Connection status.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(whoopStatusResponse, "WhoopStatusEnvelope"),
|
||||
},
|
||||
},
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/withings/status": {
|
||||
get: {
|
||||
tags: ["Integrations"],
|
||||
summary: "Read the caller's Withings connection status",
|
||||
description:
|
||||
"Reports the per-user credential marker plus the connection row's last sync, token expiry and granted scope. Not side-effect free: a token within 60 s of expiry is refreshed and the new pair re-persisted before the response is built, so the reported `tokenExpiresAt` may be newer than the stored one was at request time, and a permanently revoked grant parks the ledger at `error_reauth` during this read. Auth via cookie or Bearer.",
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Connection status.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
withingsStatusResponse,
|
||||
"WithingsStatusEnvelope",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/withings/disconnect": {
|
||||
post: {
|
||||
tags: ["Integrations"],
|
||||
summary: "Disconnect the caller's Withings account",
|
||||
description:
|
||||
"Best-effort unsubscribes every webhook category, deletes the connection row, audits the teardown and parks the `withings` ledger at `disconnected`. A failing unsubscribe (expired token, category never subscribed) does not stop the teardown. Measurements already ingested are left in place. No body. Auth via cookie or Bearer.",
|
||||
responses: {
|
||||
"200": {
|
||||
description: "The connection was removed.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
z.object({ disconnected: z.literal(true) }),
|
||||
"WithingsDisconnectEnvelope",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
"404": {
|
||||
description:
|
||||
"No Withings connection to remove. Note that this is NOT an idempotent no-op: a second disconnect answers 404 rather than 200.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,276 @@
|
||||
/**
|
||||
* OpenAPI route table — MCP connector credentials.
|
||||
*
|
||||
* Part of the OpenAPI route table; aggregated in `./index.ts`. The request
|
||||
* body comes from `src/lib/validations/mcp.ts` so the wire contract stays
|
||||
* single-source; the response DTOs mirror the handlers under
|
||||
* `src/app/api/mcp/*`.
|
||||
*
|
||||
* Two credential families reach the `/mcp` endpoint and both are managed here,
|
||||
* because a user revoking access needs one place that lists everything that can
|
||||
* read their record over MCP:
|
||||
*
|
||||
* - A **connection** is what the OAuth bridge (`/api/mcp/oauth/*`) creates
|
||||
* for a remote connector. It outlives the 60-minute access tokens it
|
||||
* issues, so revoking the connection ends the whole refresh chain.
|
||||
* - A **connector token** is the manual / stdio path: a `hlk_` Bearer minted
|
||||
* here, scoped `health:read` (optionally plus `health:write`), never the
|
||||
* `["*"]` wildcard.
|
||||
*
|
||||
* The OAuth routes themselves are deliberately absent: they answer RFC-format
|
||||
* error bodies rather than the standard envelope, and they are described by the
|
||||
* OAuth metadata documents, not by this contract.
|
||||
*/
|
||||
import { z } from "zod/v4";
|
||||
import type { ZodOpenApiObject } from "zod-openapi";
|
||||
|
||||
import { createMcpTokenSchema } from "@/lib/validations/mcp";
|
||||
import { dataEnvelope, errorEnvelope, stdResponses } from "./shared";
|
||||
|
||||
/**
|
||||
* The operator kill switch. `isApiGloballyEnabled()` gates every route in this
|
||||
* module, so an instance with the API turned off refuses connector management
|
||||
* as well as connector traffic — a valid session does not get past it.
|
||||
*/
|
||||
const apiDisabledResponse = {
|
||||
"403": {
|
||||
description:
|
||||
"The operator has disabled the API instance-wide. Every route in this module refuses while that setting is off, for a cookie session as well as for a Bearer token; it is not a per-user permission and retrying will not help.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
};
|
||||
|
||||
const mcpConnection = z
|
||||
.object({
|
||||
id: z.string(),
|
||||
clientName: z
|
||||
.string()
|
||||
.describe(
|
||||
"Name the OAuth client registered itself under. Client-supplied at registration time — display it as such, do not treat it as verified.",
|
||||
),
|
||||
scope: z
|
||||
.string()
|
||||
.describe("Space-separated scope string granted to this connection."),
|
||||
createdAt: z.iso.datetime({ offset: true }),
|
||||
lastUsedAt: z.iso
|
||||
.datetime({ offset: true })
|
||||
.nullable()
|
||||
.describe("Null when the connection has never been exercised."),
|
||||
})
|
||||
.meta({
|
||||
id: "McpConnection",
|
||||
description:
|
||||
"A live remote-connector authorization created through the MCP OAuth bridge. Carries no token material: the access tokens it issued are not addressable here, they are ended by revoking the connection.",
|
||||
});
|
||||
|
||||
const mcpConnectorToken = z
|
||||
.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
permissions: z
|
||||
.array(z.string())
|
||||
.describe(
|
||||
'The granted scopes — `["health:read"]` or `["health:read", "health:write"]`. A `health:write` token is audience-bound to `/mcp`: the resource-server guard refuses it on every REST write, so it only admits the confirmed in-process write tools.',
|
||||
),
|
||||
lastUsedAt: z.iso.datetime({ offset: true }).nullable(),
|
||||
expiresAt: z.iso.datetime({ offset: true }).nullable(),
|
||||
createdAt: z.iso.datetime({ offset: true }),
|
||||
revoked: z
|
||||
.boolean()
|
||||
.describe(
|
||||
"Revoked tokens stay in this list rather than disappearing from it, so a client must filter if it wants live credentials only.",
|
||||
),
|
||||
})
|
||||
.meta({
|
||||
id: "McpConnectorToken",
|
||||
description:
|
||||
"A manually-minted MCP connector token. The token VALUE is not part of this shape — it exists in one response only, the 201 that mints it.",
|
||||
});
|
||||
|
||||
const createMcpTokenRequest = createMcpTokenSchema.meta({
|
||||
id: "CreateMcpTokenRequest",
|
||||
description:
|
||||
"Mint a connector Bearer. `scope` is a closed two-value choice, not a permission list: `read` (the default) grants `health:read`, `read_write` adds `health:write`. The permission array is built field by field from that choice, so no request shape can widen the grant. `expiresInDays` defaults to 90.",
|
||||
});
|
||||
|
||||
const createMcpTokenResponse = z
|
||||
.object({
|
||||
token: z
|
||||
.string()
|
||||
.describe(
|
||||
"The raw `hlk_` Bearer, RETURNED EXACTLY ONCE. It is stored only as an HMAC-SHA256 hash, so there is no way to retrieve it later; a client that does not capture it here has to mint a replacement. Treat it as a secret in logs, crash reports and analytics.",
|
||||
),
|
||||
name: z.string(),
|
||||
expiresAt: z.iso.datetime({ offset: true }),
|
||||
})
|
||||
.meta({
|
||||
id: "CreateMcpTokenResponse",
|
||||
description:
|
||||
"The one response in the API that carries a usable Bearer token value for an MCP connector.",
|
||||
});
|
||||
|
||||
export const mcpPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
"/api/mcp/connections": {
|
||||
get: {
|
||||
tags: ["MCP"],
|
||||
summary: "List the caller's live MCP connector connections",
|
||||
description:
|
||||
"Returns the caller's non-revoked OAuth-bridge connections, newest first. `data` is the array itself, not nested under a named key. Auth via cookie or Bearer.",
|
||||
responses: {
|
||||
...apiDisabledResponse,
|
||||
"200": {
|
||||
description: "Live connections.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
z.array(mcpConnection),
|
||||
"McpConnectionListEnvelope",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/mcp/connections/{id}": {
|
||||
delete: {
|
||||
tags: ["MCP"],
|
||||
summary: "Revoke an MCP connector connection",
|
||||
description:
|
||||
"Stamps the connection revoked so every future refresh fails, and revokes every access token it ever issued. Ownership is enforced inside the revoke, so a connection id alone cannot reach somebody else's connection — an id the caller does not own is indistinguishable from one that does not exist. Auth via cookie or Bearer.",
|
||||
parameters: [
|
||||
{
|
||||
name: "id",
|
||||
in: "path",
|
||||
required: true,
|
||||
schema: { type: "string" },
|
||||
description: "Connection id from the list endpoint.",
|
||||
},
|
||||
],
|
||||
responses: {
|
||||
...apiDisabledResponse,
|
||||
"200": {
|
||||
description: "The connection was revoked.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
z.object({ revoked: z.literal(true) }),
|
||||
"McpConnectionRevokeEnvelope",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
"404": {
|
||||
description:
|
||||
"No live connection with this id for this caller — also the answer for an already-revoked one, so a repeat revoke is 404 rather than an idempotent 200.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/mcp/tokens": {
|
||||
get: {
|
||||
tags: ["MCP"],
|
||||
summary: "List the caller's MCP connector tokens",
|
||||
description:
|
||||
"Returns the caller's manually-minted `health:read` tokens, newest first, revoked ones included. Access tokens issued by the OAuth bridge are excluded — they are transient 60-minute rows that would flood the list, and they are surfaced and revoked as connections instead. `data` is the array itself, not nested under a named key. Auth via cookie or Bearer.",
|
||||
responses: {
|
||||
...apiDisabledResponse,
|
||||
"200": {
|
||||
description: "Connector tokens.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
z.array(mcpConnectorToken),
|
||||
"McpConnectorTokenListEnvelope",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
post: {
|
||||
tags: ["MCP"],
|
||||
summary: "Mint an MCP connector token",
|
||||
description:
|
||||
"Mints a `health:read` (optionally read+write) Bearer for the manual / stdio path and audits the mint. THE RESPONSE CARRIES THE RAW TOKEN — this is the only place it ever exists, and it cannot be retrieved again. Body capped at 16 KiB. Auth via cookie or Bearer.",
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: { "application/json": { schema: createMcpTokenRequest } },
|
||||
},
|
||||
responses: {
|
||||
...apiDisabledResponse,
|
||||
"201": {
|
||||
description:
|
||||
"Token minted. The `token` field is the only copy of the secret.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
createMcpTokenResponse,
|
||||
"CreateMcpTokenEnvelope",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
"400": {
|
||||
description: "Body is not parseable JSON.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
"413": {
|
||||
description: "Body exceeds 16 KiB.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
"415": {
|
||||
description: "Content-Type is not `application/json`.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
...stdResponses,
|
||||
"422": {
|
||||
description:
|
||||
"Validation failed. The envelope carries every offending issue, not just the first.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/mcp/tokens/{id}": {
|
||||
delete: {
|
||||
tags: ["MCP"],
|
||||
summary: "Revoke an MCP connector token",
|
||||
description:
|
||||
"Marks the token revoked and audits it. The lookup requires the token to be the caller's own AND to carry `health:read`, so this endpoint can never revoke a differently-scoped credential by id alone — a native-client or ingest token is a 404 here. Auth via cookie or Bearer.",
|
||||
parameters: [
|
||||
{
|
||||
name: "id",
|
||||
in: "path",
|
||||
required: true,
|
||||
schema: { type: "string" },
|
||||
description: "Token id from the list endpoint.",
|
||||
},
|
||||
],
|
||||
responses: {
|
||||
...apiDisabledResponse,
|
||||
"200": {
|
||||
description: "The token was revoked.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
z.object({ revoked: z.literal(true) }),
|
||||
"McpConnectorTokenRevokeEnvelope",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
"404": {
|
||||
description:
|
||||
"No `health:read` token with this id for this caller. An already-revoked token is still found, so a repeat revoke answers 200.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,275 @@
|
||||
/**
|
||||
* OpenAPI route table — notification channels: per-event preferences and
|
||||
* per-channel delivery health.
|
||||
*
|
||||
* Part of the OpenAPI route table; aggregated in `./index.ts`. The request
|
||||
* bodies come from `src/lib/validations/notifications.ts` so the wire contract
|
||||
* stays single-source; the response DTOs mirror the handlers under
|
||||
* `src/app/api/notifications/*`.
|
||||
*
|
||||
* The channel and event vocabularies are read from
|
||||
* `src/lib/notifications/types.ts` rather than restated, so a new channel type
|
||||
* or event category cannot be added to the dispatcher and quietly go missing
|
||||
* from the published contract.
|
||||
*/
|
||||
import { z } from "zod/v4";
|
||||
import type { ZodOpenApiObject } from "zod-openapi";
|
||||
|
||||
import { CHANNEL_TYPES, EVENT_TYPES } from "@/lib/notifications/types";
|
||||
import {
|
||||
notificationPreferenceSchema,
|
||||
reEnableChannelSchema,
|
||||
} from "@/lib/validations/notifications";
|
||||
import { dataEnvelope, errorEnvelope, stdResponses } from "./shared";
|
||||
|
||||
const channelTypeEnum = z
|
||||
.enum(CHANNEL_TYPES)
|
||||
.describe("Delivery transport for a notification channel.");
|
||||
|
||||
const eventTypeEnum = z
|
||||
.enum(EVENT_TYPES)
|
||||
.describe("Notification category a per-channel preference can toggle.");
|
||||
|
||||
const notificationChannel = z
|
||||
.object({
|
||||
id: z.string(),
|
||||
type: channelTypeEnum,
|
||||
label: z
|
||||
.string()
|
||||
.describe(
|
||||
"Human-readable channel name. Falls back to the raw `type` when no label is registered.",
|
||||
),
|
||||
enabled: z.boolean().describe("The user's own per-channel toggle."),
|
||||
globallyEnabled: z
|
||||
.boolean()
|
||||
.describe(
|
||||
"The operator's instance-wide toggle for this transport. Only TELEGRAM, NTFY and WEB_PUSH have one; every other transport reports `true`. A channel the user has enabled still sends nothing while this is false.",
|
||||
),
|
||||
})
|
||||
.meta({
|
||||
id: "NotificationChannelSummary",
|
||||
description:
|
||||
"One of the caller's notification channels, with both the per-user and the instance-wide toggle so a client can explain why an enabled channel is silent.",
|
||||
});
|
||||
|
||||
const notificationPreference = z
|
||||
.object({
|
||||
channelId: z.string(),
|
||||
eventType: eventTypeEnum,
|
||||
enabled: z.boolean(),
|
||||
})
|
||||
.meta({
|
||||
id: "NotificationPreference",
|
||||
description:
|
||||
"An explicit per-(channel, event) override. Only STORED rows appear — an event with no row here follows the server-side default for that category, which is ON for most and OFF for the opt-in ones (personal records, mood and cycle reminders). Absence is therefore not 'disabled'.",
|
||||
});
|
||||
|
||||
const notificationPreferencesResponse = z
|
||||
.object({
|
||||
channels: z.array(notificationChannel),
|
||||
preferences: z.array(notificationPreference),
|
||||
eventTypes: z
|
||||
.array(z.string())
|
||||
.describe(
|
||||
"Every event category the server knows, in declaration order — the list a client renders rows for.",
|
||||
),
|
||||
})
|
||||
.meta({
|
||||
id: "NotificationPreferencesResponse",
|
||||
description:
|
||||
"The caller's notification channels, their stored per-event overrides, and the full event vocabulary.",
|
||||
});
|
||||
|
||||
const notificationPreferenceRequest = notificationPreferenceSchema.meta({
|
||||
id: "NotificationPreferenceRequest",
|
||||
description:
|
||||
"Set one per-(channel, event) override. `channelId` must belong to the caller — a channel id owned by somebody else answers 404, not 403. Upsert: the row is created when absent.",
|
||||
});
|
||||
|
||||
const channelDeliveryStatus = z
|
||||
.object({
|
||||
id: z.string(),
|
||||
type: channelTypeEnum,
|
||||
label: z.string(),
|
||||
enabled: z.boolean(),
|
||||
state: z
|
||||
.enum(["active", "auto_disabled", "manually_disabled", "sending_paused"])
|
||||
.describe(
|
||||
"Server-derived. `auto_disabled` — the dispatcher turned it off after repeated hard rejects (`disabledReason` says why). `manually_disabled` — the user turned it off. `sending_paused` — still enabled but inside a retry cooldown until `nextRetryAt`. `active` — enabled and sending.",
|
||||
),
|
||||
disabledReason: z
|
||||
.string()
|
||||
.nullable()
|
||||
.describe(
|
||||
"Why the dispatcher auto-disabled the channel; null when it did not. This is the field that separates `auto_disabled` from `manually_disabled`.",
|
||||
),
|
||||
consecutiveFailures: z.number().int(),
|
||||
lastSuccessAt: z.iso.datetime({ offset: true }).nullable(),
|
||||
lastFailureAt: z.iso.datetime({ offset: true }).nullable(),
|
||||
lastFailureReason: z.string().nullable(),
|
||||
nextRetryAt: z.iso
|
||||
.datetime({ offset: true })
|
||||
.nullable()
|
||||
.describe("End of the current cooldown; null when not paused."),
|
||||
})
|
||||
.meta({
|
||||
id: "NotificationChannelDeliveryStatus",
|
||||
description:
|
||||
"Delivery health for one channel: the derived state plus the raw counters and timestamps behind it.",
|
||||
});
|
||||
|
||||
const eventDeliveryStatus = z
|
||||
.object({
|
||||
lastDeliveredAt: z.iso
|
||||
.datetime({ offset: true })
|
||||
.nullable()
|
||||
.describe("Null when this category has never been delivered."),
|
||||
})
|
||||
.meta({ id: "NotificationEventDeliveryStatus" });
|
||||
|
||||
const notificationStatusResponse = z
|
||||
.object({
|
||||
channels: z.array(channelDeliveryStatus),
|
||||
events: z
|
||||
.record(z.string(), eventDeliveryStatus)
|
||||
.describe(
|
||||
"Keyed by event category. EVERY known category is present, with `lastDeliveredAt: null` when no data exists, so a client can render an empty row without conditional plumbing. Only categories with their own dispatch ledger can ever report a non-null instant — today that is `MOOD_REMINDER` alone; every other category reports null regardless of what was actually delivered.",
|
||||
),
|
||||
})
|
||||
.meta({
|
||||
id: "NotificationStatusResponse",
|
||||
description:
|
||||
"Per-channel delivery health plus a per-category last-delivered map.",
|
||||
});
|
||||
|
||||
const reEnableChannelRequest = reEnableChannelSchema.meta({
|
||||
id: "ReEnableNotificationChannelRequest",
|
||||
description:
|
||||
"Clear the auto-disable on one of the caller's own channels. A channel id the caller does not own answers 404.",
|
||||
});
|
||||
|
||||
export const notificationChannelPaths: NonNullable<ZodOpenApiObject["paths"]> =
|
||||
{
|
||||
"/api/notifications/preferences": {
|
||||
get: {
|
||||
tags: ["Notifications"],
|
||||
summary: "Read the caller's notification channels and preferences",
|
||||
description:
|
||||
"Returns every channel the caller has, its stored per-event overrides, and the full event vocabulary. Not a pure read: a user whose Telegram config still lives only on the legacy `User` columns has it migrated into a channel row as a side effect of this call, and that migration failing is swallowed as a warning rather than surfaced. Auth via cookie or Bearer.",
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Channels, stored overrides and event vocabulary.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
notificationPreferencesResponse,
|
||||
"GetNotificationPreferences",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
put: {
|
||||
tags: ["Notifications"],
|
||||
summary: "Set one per-channel notification preference",
|
||||
description:
|
||||
"Upserts a single (channel, event) override and echoes the stored row. One preference per call — there is no batch arm. Body capped at 64 KiB. Auth via cookie or Bearer.",
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
"application/json": { schema: notificationPreferenceRequest },
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
"200": {
|
||||
description: "The stored preference.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
notificationPreference,
|
||||
"PutNotificationPreferenceResponse",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
"404": {
|
||||
description: "No such channel for this caller.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
"413": {
|
||||
description: "Body exceeds 64 KiB.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
...stdResponses,
|
||||
"422": {
|
||||
description:
|
||||
"Body is not parseable JSON, or failed validation. Note that unlike the routes wrapped in `safeJson`, a malformed body here is 422 rather than 400, and no Content-Type check runs.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/notifications/status": {
|
||||
get: {
|
||||
tags: ["Notifications"],
|
||||
summary: "Read per-channel delivery health",
|
||||
description:
|
||||
"What the notification settings surface paints into its Active / Auto-disabled / Sending-paused badges, plus a per-category last-delivered map. Auth via cookie or Bearer.",
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Delivery health per channel and per category.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
notificationStatusResponse,
|
||||
"GetNotificationStatus",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
post: {
|
||||
tags: ["Notifications"],
|
||||
summary: "Re-enable an auto-disabled channel",
|
||||
description:
|
||||
"Clears `disabledReason`, `consecutiveFailures` and `nextRetryAt`, sets `enabled` true, and audits the change. Accepted on any of the caller's channels, not only auto-disabled ones — re-enabling an already-active channel is a no-op that still answers 200. Follow up with a test send if the client wants confirmation the transport works again. Body capped at 64 KiB. Auth via cookie or Bearer.",
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
"application/json": { schema: reEnableChannelRequest },
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
"200": {
|
||||
description: "The channel was re-enabled.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
z.object({ ok: z.literal(true) }),
|
||||
"ReEnableNotificationChannelResponse",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
"404": {
|
||||
description: "No such channel for this caller.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
"413": {
|
||||
description: "Body exceeds 64 KiB.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
...stdResponses,
|
||||
"422": {
|
||||
description:
|
||||
"Body is not parseable JSON, or failed validation. As above, a malformed body here is 422 rather than 400.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -1,5 +1,6 @@
|
||||
/**
|
||||
* OpenAPI route table — settings read surfaces.
|
||||
* OpenAPI route table — settings read surfaces and the per-channel
|
||||
* notification config under `/api/settings/*`.
|
||||
*
|
||||
* Part of the OpenAPI route table; aggregated in `./index.ts`.
|
||||
* Schemas come from `src/lib/validations/*` where shared with the
|
||||
@@ -7,7 +8,18 @@
|
||||
*/
|
||||
import { z } from "zod/v4";
|
||||
import type { ZodOpenApiObject } from "zod-openapi";
|
||||
import { dataEnvelope, recordRefusal, stdResponses } from "./shared";
|
||||
import {
|
||||
notificationChannelEnabledSchema,
|
||||
ntfySettingsSchema,
|
||||
webhookSettingsSchema,
|
||||
} from "@/lib/validations/notifications";
|
||||
import { telegramSettingsSchema } from "@/lib/validations/telegram";
|
||||
import {
|
||||
dataEnvelope,
|
||||
errorEnvelope,
|
||||
recordRefusal,
|
||||
stdResponses,
|
||||
} from "./shared";
|
||||
|
||||
// v1.16.11 — the one threshold read every dose-status consumer makes
|
||||
// (cards, table, take-all-due derivation). `lateMinutes` /
|
||||
@@ -52,6 +64,147 @@ const reminderThresholdsResponse = z
|
||||
"Medication reminder thresholds: the operator-level late/missed minute marks plus the calling user's low-stock runway threshold (v1.16.11).",
|
||||
});
|
||||
|
||||
// ── 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. 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. 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.",
|
||||
});
|
||||
|
||||
/**
|
||||
* 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.",
|
||||
),
|
||||
})
|
||||
.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/reminder-thresholds": {
|
||||
get: {
|
||||
@@ -76,4 +229,160 @@ 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.",
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: { "application/json": { schema: ntfyPutRequest } },
|
||||
},
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Config stored.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(channelSaved, "PutNtfySettingsResponse"),
|
||||
},
|
||||
},
|
||||
},
|
||||
...jsonBodyRefusals,
|
||||
...stdResponses,
|
||||
"422": {
|
||||
description:
|
||||
"Body matched neither accepted shape, or `enabled` is true while the stored config has no server URL and topic.",
|
||||
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.",
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: { "application/json": { schema: telegramPutRequest } },
|
||||
},
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Config stored.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
channelUpdated,
|
||||
"PutTelegramSettingsResponse",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
"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.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
/**
|
||||
* Body of `POST /api/mcp/tokens` — mint a connector Bearer for the manual /
|
||||
* stdio path.
|
||||
*
|
||||
* `scope` is a closed two-value choice, not a permission list: `read` maps to
|
||||
* `["health:read"]` and `read_write` adds `health:write`. The route builds the
|
||||
* `permissions` array from that choice field by field, so no request shape can
|
||||
* coerce this endpoint into minting a wildcard or any other grant.
|
||||
*/
|
||||
export const createMcpTokenSchema = z.object({
|
||||
name: z.string().min(1, "Name required").max(100),
|
||||
expiresInDays: z.number().int().min(1).max(365).optional(),
|
||||
scope: z.enum(["read", "read_write"]).optional().default("read"),
|
||||
});
|
||||
|
||||
export type CreateMcpTokenInput = z.infer<typeof createMcpTokenSchema>;
|
||||
@@ -12,6 +12,15 @@ export const notificationChannelEnabledSchema = z.strictObject({
|
||||
enabled: z.boolean(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Body of `POST /api/notifications/status` — clear the auto-disable on one of
|
||||
* the caller's own channels. The channel is looked up scoped to the session
|
||||
* user, so an id alone cannot re-enable somebody else's channel.
|
||||
*/
|
||||
export const reEnableChannelSchema = z.object({
|
||||
channelId: z.string().min(1).max(64),
|
||||
});
|
||||
|
||||
/**
|
||||
* Parse a dotted-quad IPv4 string into normalized octets.
|
||||
*
|
||||
|
||||
Reference in new issue
Block a user