diff --git a/src/app/api/mcp/tokens/route.ts b/src/app/api/mcp/tokens/route.ts index dfa644806..0fe706e7d 100644 --- a/src/app/api/mcp/tokens/route.ts +++ b/src/app/api/mcp/tokens/route.ts @@ -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); } diff --git a/src/app/api/notifications/status/route.ts b/src/app/api/notifications/status/route.ts index 402521ee7..54fb387cc 100644 --- a/src/app/api/notifications/status/route.ts +++ b/src/app/api/notifications/status/route.ts @@ -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({ diff --git a/src/lib/openapi/routes/index.ts b/src/lib/openapi/routes/index.ts index 1d053c5ee..4d91aadcf 100644 --- a/src/lib/openapi/routes/index.ts +++ b/src/lib/openapi/routes/index.ts @@ -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 = { // 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 = { diff --git a/src/lib/openapi/routes/integrations.ts b/src/lib/openapi/routes/integrations.ts index f40e24eb2..9b257147b 100644 --- a/src/lib/openapi/routes/integrations.ts +++ b/src/lib/openapi/routes/integrations.ts @@ -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 = { "/api/integrations/healthkit": { get: { @@ -452,4 +641,238 @@ export const integrationPaths: NonNullable = { }, }, }, + "/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, + }, + }, + }, }; diff --git a/src/lib/openapi/routes/mcp.ts b/src/lib/openapi/routes/mcp.ts new file mode 100644 index 000000000..c1f8b22e7 --- /dev/null +++ b/src/lib/openapi/routes/mcp.ts @@ -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 = { + "/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, + }, + }, + }, +}; diff --git a/src/lib/openapi/routes/notifications.ts b/src/lib/openapi/routes/notifications.ts new file mode 100644 index 000000000..ddc95d5ab --- /dev/null +++ b/src/lib/openapi/routes/notifications.ts @@ -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 = + { + "/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 } }, + }, + }, + }, + }, + }; diff --git a/src/lib/openapi/routes/settings.ts b/src/lib/openapi/routes/settings.ts index ecb83a289..60fbe29f1 100644 --- a/src/lib/openapi/routes/settings.ts +++ b/src/lib/openapi/routes/settings.ts @@ -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 = { "/api/settings/reminder-thresholds": { get: { @@ -76,4 +229,160 @@ export const settingsPaths: NonNullable = { }, }, }, + "/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 } }, + }, + }, + }, + }, }; diff --git a/src/lib/validations/mcp.ts b/src/lib/validations/mcp.ts new file mode 100644 index 000000000..2ba60a5ec --- /dev/null +++ b/src/lib/validations/mcp.ts @@ -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; diff --git a/src/lib/validations/notifications.ts b/src/lib/validations/notifications.ts index 2ee35963d..f5aeca557 100644 --- a/src/lib/validations/notifications.ts +++ b/src/lib/validations/notifications.ts @@ -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. *