Skip to content

Commit 46b32db

Browse files
HMarzbanclaude
andcommitted
docs(openapi): match the spec to today's admin, email, media and health changes
Every zValidator outside link-metadata now passes houseEnvelopeHook. So the ZodValidationError schema and response are gone. Each 400 now points to ValidationError or ErrorEnvelope, and ValidationError names the hook's fields. Admin routes: - Ghost delete validates a uuid path. It reads the account again before the delete, and it answers 409 with one of three refusal reasons. - Toggle-admin refuses your own id and the last admin. A revoke is one locked admin_revoke_admin call. - Test-send lists its real 400, 429 and 503 answers. - The DLQ route returns masked summaries, never the job data. - The spec now lists the mounted digest-grouping GET and PUT routes and the MCP usage route. The MCP usage 500 can be either error shape. Documents: PUT {docRef} uses documentIdField and answers 400 for a bad id. A duplicate skips media under a document in Trash, and that media does not count toward the cap. Email: with Redis, the Resend webhook records each svix-id once for 24 hours. An unsubscribe form post with a token always gets an HTML page. Health: /health and the three dependency probes cache each result for 5 seconds. Refs #405 Refs #408 Refs #412 Refs #418 Refs #421 Refs #422 Refs #426 Refs #445 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 655ca8a commit 46b32db

7 files changed

Lines changed: 125 additions & 53 deletions

File tree

‎apps/hocuspocus.server/src/modules/openapi/domain/components.ts‎

Lines changed: 1 addition & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -60,25 +60,6 @@ const errorEnvelope: JsonSchema = {
6060
required: ['success', 'error']
6161
}
6262

63-
/** Verified against `@hono/zod-validator`'s default hook: a serialized ZodError, no `code`. */
64-
const zodValidationError: JsonSchema = {
65-
type: 'object',
66-
description:
67-
'Raw `@hono/zod-validator` rejection. Routes without a custom hook return this instead of `ErrorEnvelope` — `error` is a serialized `ZodError`, so there is no `error.code`.',
68-
properties: {
69-
success: { type: 'boolean', const: false },
70-
error: {
71-
type: 'object',
72-
properties: {
73-
name: { type: 'string', const: 'ZodError' },
74-
message: { type: 'string', description: 'JSON-encoded array of zod issues.' }
75-
},
76-
required: ['name', 'message']
77-
}
78-
},
79-
required: ['success', 'error']
80-
}
81-
8263
const legacyError: JsonSchema = {
8364
type: 'object',
8465
description:
@@ -271,7 +252,6 @@ export const components: OpenApiComponents = {
271252
securitySchemes,
272253
schemas: {
273254
ErrorEnvelope: errorEnvelope,
274-
ZodValidationError: zodValidationError,
275255
LegacyError: legacyError,
276256
LinkMetadataError: linkMetadataError,
277257
RateLimitError: rateLimitError,
@@ -286,11 +266,7 @@ export const components: OpenApiComponents = {
286266
},
287267
responses: {
288268
ValidationError: envelopeResponse(
289-
'Rejected by a handler or service guard (an `AppError`), so it carries the house envelope.'
290-
),
291-
ZodValidationError: jsonResponse(
292-
'Rejected by `zValidator` before the handler ran, on routes that do not pass `houseEnvelopeHook`. `error` is a serialized `ZodError`, so there is no `error.code`. Routes that pass the hook document `ValidationError` instead.',
293-
'#/components/schemas/ZodValidationError'
269+
'Rejected by `zValidator` through `houseEnvelopeHook`, with `fields` naming each bad input, or by a handler or service guard.'
294270
),
295271
Unauthorized: envelopeResponse('Missing, invalid or expired credentials.'),
296272
Forbidden: envelopeResponse('Authenticated but not permitted.'),

‎apps/hocuspocus.server/src/modules/openapi/domain/paths/admin.ts‎

Lines changed: 98 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,20 +7,29 @@ import {
77
bulkDeleteSchema,
88
daysQuerySchema,
99
deleteDocumentSchema,
10+
digestSettingsBodySchema,
1011
disableFailedSubsSchema,
1112
ghostAccountsQuerySchema,
1213
ghostBulkDeleteSchema,
1314
ghostCleanupAnonymousSchema,
15+
ghostDeleteSchema,
1416
ghostResendSchema,
1517
listDocumentsQuerySchema,
18+
mcpUsageQuerySchema,
1619
mediaStorageQuerySchema,
1720
paginationQuerySchema,
1821
staleDocumentsQuerySchema,
1922
trendQuerySchema,
2023
updateDocumentSchema
2124
} from '../../../../schemas/admin.schema'
22-
import type { HttpMethod, OpenApiOperation, OpenApiParameter, OpenApiPaths } from '../../types'
23-
import { rateLimitedRef } from '../components'
25+
import type {
26+
HttpMethod,
27+
OpenApiOperation,
28+
OpenApiParameter,
29+
OpenApiPaths,
30+
OpenApiResponse
31+
} from '../../types'
32+
import { envelopeResponse, rateLimitedRef } from '../components'
2433
import { pathParam, toJsonSchema, toParameters } from '../jsonSchema'
2534

2635
interface AdminRoute {
@@ -32,7 +41,11 @@ interface AdminRoute {
3241
description?: string
3342
query?: z.ZodType
3443
body?: z.ZodType
44+
/** The path the router validates. `params` is for a path it does not validate. */
45+
pathSchema?: z.ZodType
3546
params?: OpenApiParameter[]
47+
/** Responses that differ from the shared map in `toOperation`. */
48+
extraResponses?: Record<string, OpenApiResponse>
3649
}
3750

3851
const idParam = pathParam('id', 'Target row id.')
@@ -67,6 +80,48 @@ const routes: AdminRoute[] = [
6780
summary: 'Email stats',
6881
group: 'Dashboard & users'
6982
},
83+
{
84+
path: '/email/digest-grouping',
85+
method: 'get',
86+
id: 'adminGetDigestGrouping',
87+
summary: 'Digest grouping and size cap',
88+
group: 'Email digest'
89+
},
90+
{
91+
path: '/email/digest-grouping',
92+
method: 'put',
93+
id: 'adminSetDigestGrouping',
94+
summary: 'Set digest grouping or size cap',
95+
group: 'Email digest',
96+
description: 'Send `grouping`, `maxKb` or both. Stored in Redis, so `503` without Redis.',
97+
body: digestSettingsBodySchema
98+
},
99+
{
100+
path: '/mcp/usage',
101+
method: 'get',
102+
id: 'adminGetMcpUsage',
103+
summary: 'MCP connector usage',
104+
group: 'MCP connector usage',
105+
description:
106+
'Calls and distinct callers per UTC day, plus tool and app totals for the window. House envelope.',
107+
query: mcpUsageQuerySchema,
108+
extraResponses: {
109+
'500': {
110+
description:
111+
'The usage read failed (`ErrorEnvelope`, code `MCP_USAGE_FAILED`), or the admin guard failed (`LegacyError`).',
112+
content: {
113+
'application/json': {
114+
schema: {
115+
oneOf: [
116+
{ $ref: '#/components/schemas/ErrorEnvelope' },
117+
{ $ref: '#/components/schemas/LegacyError' }
118+
]
119+
}
120+
}
121+
}
122+
}
123+
}
124+
},
70125
{
71126
path: '/stats/push',
72127
method: 'get',
@@ -138,6 +193,8 @@ const routes: AdminRoute[] = [
138193
id: 'adminToggleAdminRole',
139194
summary: 'Grant or revoke admin',
140195
group: 'Dashboard & users',
196+
description:
197+
'Refuses your own id and the last admin with `403`. A revoke is one locked `admin_revoke_admin` call, so two revokes at once cannot leave zero admins.',
141198
params: [idParam]
142199
},
143200
{
@@ -400,7 +457,9 @@ const routes: AdminRoute[] = [
400457
method: 'get',
401458
id: 'adminGetDeadLetterQueue',
402459
summary: 'BullMQ dead-letter contents',
403-
group: 'Notification audit'
460+
group: 'Notification audit',
461+
description:
462+
'One summary per job: type, failure kind and code, masked `to` and the reason. Never the job data.'
404463
},
405464
{
406465
path: '/email/setup',
@@ -418,7 +477,27 @@ const routes: AdminRoute[] = [
418477
summary: 'Send a test email to yourself',
419478
group: 'Email setup',
420479
description:
421-
"No body. Sends to the signed-in admin's own address. Answers `{ sent: true, messageId, to }`, with `to` masked, or `{ sent: false, kind, code }`. One per admin per minute: `429` past it, `503` without Redis, `400` when the account has no email."
480+
"No body. Sends to the signed-in admin's own address. Answers `{ sent: true, messageId, to }`, with `to` masked, or `{ sent: false, kind, code }`.",
481+
extraResponses: {
482+
'400': envelopeResponse('Code `NO_EMAIL`: the admin account has no email address.'),
483+
'429': envelopeResponse(
484+
'Code `RATE_LIMITED`: one test email per admin per minute. The global limiter answers `RATE_LIMIT_EXCEEDED`.'
485+
),
486+
'503': {
487+
description:
488+
'Redis is not available (`ErrorEnvelope`, code `SERVICE_UNAVAILABLE`), or the admin guard cannot reach Supabase auth (`LegacyError`).',
489+
content: {
490+
'application/json': {
491+
schema: {
492+
oneOf: [
493+
{ $ref: '#/components/schemas/ErrorEnvelope' },
494+
{ $ref: '#/components/schemas/LegacyError' }
495+
]
496+
}
497+
}
498+
}
499+
}
500+
}
422501
},
423502

424503
{
@@ -450,7 +529,16 @@ const routes: AdminRoute[] = [
450529
id: 'adminDeleteGhostAccount',
451530
summary: 'Smart-delete one ghost account',
452531
group: 'Ghost accounts audit',
453-
params: [idParam]
532+
description:
533+
'Reads the account again first, because the list is cached. A user with blocking messages or owned documents outside Trash is soft-deleted: banned, with the public profile cleared. Others are hard-deleted.',
534+
pathSchema: ghostDeleteSchema,
535+
extraResponses: {
536+
'409': {
537+
description:
538+
'`{ error }` names the refusal: `User not found`, `User is no longer a ghost account` or `User is an admin`. An unknown id is a 409, not a 404.',
539+
content: { 'application/json': { schema: { $ref: '#/components/schemas/LegacyError' } } }
540+
}
541+
}
454542
},
455543
{
456544
path: '/audit/ghost-accounts/bulk-delete',
@@ -481,9 +569,10 @@ const routes: AdminRoute[] = [
481569
const toOperation = (route: AdminRoute): OpenApiOperation => {
482570
const parameters = [
483571
...(route.params ?? []),
572+
...(route.pathSchema ? toParameters(route.pathSchema, 'path') : []),
484573
...(route.query ? toParameters(route.query, 'query') : [])
485574
]
486-
const hasValidator = Boolean(route.query || route.body)
575+
const hasValidator = Boolean(route.query || route.body || route.pathSchema)
487576

488577
return {
489578
operationId: route.id,
@@ -507,12 +596,13 @@ const toOperation = (route: AdminRoute): OpenApiOperation => {
507596
},
508597
// zValidator is registered after adminAuthMiddleware, so a 400 here still
509598
// means the caller passed the admin gate.
510-
...(hasValidator ? { '400': { $ref: '#/components/responses/ZodValidationError' } } : {}),
599+
...(hasValidator ? { '400': { $ref: '#/components/responses/ValidationError' } } : {}),
511600
'401': { $ref: '#/components/responses/LegacyUnauthorized' },
512601
'403': { $ref: '#/components/responses/LegacyForbidden' },
513602
'429': rateLimitedRef,
514603
'500': { $ref: '#/components/responses/LegacyInternalError' },
515-
'503': { $ref: '#/components/responses/LegacyInternalError' }
604+
'503': { $ref: '#/components/responses/LegacyInternalError' },
605+
...route.extraResponses
516606
}
517607
}
518608
}

‎apps/hocuspocus.server/src/modules/openapi/domain/paths/documentVersions.ts‎

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,8 +37,6 @@ const okResponse = (description: string): OpenApiResponse => ({
3737
content: { 'application/json': { schema: opaqueData } }
3838
})
3939

40-
// Every `zValidator` on these routes passes `houseEnvelopeHook`, so a rejected
41-
// request carries `ErrorEnvelope` — not the raw `ZodValidationError` shape.
4240
const versionErrors = {
4341
'400': { $ref: '#/components/responses/ValidationError' },
4442
'401': { $ref: '#/components/responses/Unauthorized' },

‎apps/hocuspocus.server/src/modules/openapi/domain/paths/documents.ts‎

Lines changed: 19 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,10 @@ import {
77
updateDocumentMetadataSchema,
88
userIdQuerySchema
99
} from '../../../../schemas/document.schema'
10-
import { MAX_DUPLICATE_MEDIA_OBJECTS } from '../../../../schemas/hypermultimedia.schema'
10+
import {
11+
documentIdField,
12+
MAX_DUPLICATE_MEDIA_OBJECTS
13+
} from '../../../../schemas/hypermultimedia.schema'
1114
import type { JsonSchema, OpenApiOperation, OpenApiPaths, SecurityRequirement } from '../../types'
1215
import { envelopeResponse, rateLimitedRef } from '../components'
1316
import {
@@ -97,7 +100,7 @@ export const documentsPaths: OpenApiPaths = {
97100
'200': okEnvelope('The created document.', {
98101
$ref: '#/components/schemas/DocumentSummary'
99102
}),
100-
'400': { $ref: '#/components/responses/ZodValidationError' },
103+
'400': { $ref: '#/components/responses/ValidationError' },
101104
'401': { $ref: '#/components/responses/Unauthorized' },
102105
'403': { $ref: '#/components/responses/Forbidden' },
103106
'409': { $ref: '#/components/responses/Conflict' },
@@ -129,7 +132,7 @@ export const documentsPaths: OpenApiPaths = {
129132
'200': okEnvelope('Document metadata, or a synthesized draft.', {
130133
$ref: '#/components/schemas/DocumentSummary'
131134
}),
132-
'400': { $ref: '#/components/responses/ZodValidationError' },
135+
'400': { $ref: '#/components/responses/ValidationError' },
133136
'403': {
134137
description: 'Private document, and the caller is not its owner.',
135138
content: {
@@ -161,13 +164,19 @@ export const documentsPaths: OpenApiPaths = {
161164
"Addressed by **`documentId`**. Every field is optional. `readOnly` and `isPrivate` are owner-only — a non-owner's change to either is silently ignored and logged server-side. `slug` is used only when the row is created; it never renames a document.",
162165
tags,
163166
security: optionalUserSecurity,
164-
parameters: [pathParam('docRef', 'The 19-character `documentId`.')],
167+
parameters: [
168+
pathParam(
169+
'docRef',
170+
'The 19-character `documentId`. An id longer than 100 characters, or with a character outside `[A-Za-z0-9_-]`, is a 400.',
171+
toJsonSchema(documentIdField)
172+
)
173+
],
165174
requestBody: jsonBody(toJsonSchema(updateDocumentMetadataSchema)),
166175
responses: {
167176
'200': okEnvelope('The updated document.', {
168177
$ref: '#/components/schemas/DocumentSummary'
169178
}),
170-
'400': { $ref: '#/components/responses/ZodValidationError' },
179+
'400': { $ref: '#/components/responses/ValidationError' },
171180
'403': { $ref: '#/components/responses/Forbidden' },
172181
'404': { $ref: '#/components/responses/NotFound' },
173182
'429': rateLimitedRef
@@ -219,7 +228,7 @@ export const documentsPaths: OpenApiPaths = {
219228
},
220229
required: ['documentId', 'isFavorite']
221230
}),
222-
'400': { $ref: '#/components/responses/ZodValidationError' },
231+
'400': { $ref: '#/components/responses/ValidationError' },
223232
'401': { $ref: '#/components/responses/Unauthorized' },
224233
'403': { $ref: '#/components/responses/Forbidden' },
225234
'404': { $ref: '#/components/responses/NotFound' },
@@ -239,10 +248,10 @@ export const documentsPaths: OpenApiPaths = {
239248
post: ownedLifecycle(
240249
'duplicateDocument',
241250
'Duplicate a document',
242-
`Owner-only. Copies the source's latest Yjs bytes into a fresh owner-owned document; the slug is \`<title> (copy)\`, uniquified. Media is cloned, not shared: the source's objects are copied under the copy's own prefix and the snapshot's URLs repointed there, so purging either document leaves the other intact. Capped at ${MAX_DUPLICATE_MEDIA_OBJECTS} media objects. A soft-deleted source is a 404.`,
251+
`Owner-only. Copies the source's latest Yjs bytes into a fresh owner-owned document; the slug is \`<title> (copy)\`, uniquified. Media is cloned, not shared: the source's objects are copied under the copy's own prefix and the snapshot's URLs repointed there, so purging either document leaves the other intact. Capped at ${MAX_DUPLICATE_MEDIA_OBJECTS} media objects. Media under a document in Trash is not copied and does not count. A soft-deleted source is a 404.`,
243252
{
244253
'413': envelopeResponse(
245-
`The source's snapshot names more than ${MAX_DUPLICATE_MEDIA_OBJECTS} media objects. Each one is copied inside the request, so the count is refused up front rather than left to time out.`
254+
`The source's snapshot names more than ${MAX_DUPLICATE_MEDIA_OBJECTS} media objects outside Trash. Each one is copied inside the request, so the count is refused up front rather than left to time out.`
246255
)
247256
}
248257
)
@@ -282,7 +291,7 @@ export const documentsPaths: OpenApiPaths = {
282291
properties: { purged: { type: 'integer' } },
283292
required: ['purged']
284293
}),
285-
'400': { $ref: '#/components/responses/ZodValidationError' },
294+
'400': { $ref: '#/components/responses/ValidationError' },
286295
'401': { $ref: '#/components/responses/Unauthorized' },
287296
'429': rateLimitedRef,
288297
'503': { $ref: '#/components/responses/ServiceUnavailable' }
@@ -303,7 +312,7 @@ export const documentsPaths: OpenApiPaths = {
303312
properties: { restored: { type: 'integer' } },
304313
required: ['restored']
305314
}),
306-
'400': { $ref: '#/components/responses/ZodValidationError' },
315+
'400': { $ref: '#/components/responses/ValidationError' },
307316
'401': { $ref: '#/components/responses/Unauthorized' },
308317
'429': rateLimitedRef,
309318
'503': { $ref: '#/components/responses/ServiceUnavailable' }

‎apps/hocuspocus.server/src/modules/openapi/domain/paths/email.ts‎

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,6 @@ const security = [{ serviceRoleKey: [] }]
1616
const legacyRef = { $ref: '#/components/schemas/LegacyError' }
1717

1818
const serviceRoleErrors = {
19-
// Every email zValidator uses houseEnvelopeHook, so 400 is ErrorEnvelope.
2019
'400': { $ref: '#/components/responses/ValidationError' },
2120
'401': { $ref: '#/components/responses/LegacyUnauthorized' },
2221
'429': rateLimitedRef,
@@ -102,7 +101,7 @@ export const emailPaths: OpenApiPaths = {
102101
operationId: 'receiveResendWebhook',
103102
summary: 'Receive a Resend delivery event',
104103
description:
105-
'Mounted only when `RESEND_WEBHOOK_SECRET` is set and valid; otherwise 404. Authenticated by the Svix signature over the raw body, not by a key. A permanent bounce, a complaint or a suppression is recorded through `record_email_bounce`, which turns email off for that user. `email.failed` is logged for the operator alert. Each `svix-id` is recorded once; a redelivery answers 200 and writes nothing.',
104+
'Mounted only when `RESEND_WEBHOOK_SECRET` is set and valid; otherwise 404. Authenticated by the Svix signature over the raw body, not by a key. A permanent bounce, a complaint or a suppression is recorded through `record_email_bounce`, which turns email off for that user. `email.failed` is logged for the operator alert. With Redis, each `svix-id` is recorded once for 24 hours, and a redelivery writes nothing.',
106105
tags,
107106
security: [{}],
108107
parameters: [
@@ -289,7 +288,7 @@ export const emailPaths: OpenApiPaths = {
289288
responses: {
290289
'200': {
291290
description:
292-
'Unsubscribed (JSON), or the result page for a `confirm=yes` form post (HTML).',
291+
'Unsubscribed (JSON). A `confirm=yes` form post with a token gets an HTML page, also when the unsubscribe fails.',
293292
content: {
294293
'application/json': {
295294
schema: {

‎apps/hocuspocus.server/src/modules/openapi/domain/paths/health.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import type { OpenApiOperation, OpenApiPaths } from '../../types'
44
const probe = (operationId: string, summary: string, description: string): OpenApiOperation => ({
55
operationId,
66
summary,
7-
description,
7+
description: `${description} The result is cached for 5 seconds.`,
88
tags: ['Health'],
99
security: [{}],
1010
responses: {
@@ -29,7 +29,7 @@ export const healthPaths: OpenApiPaths = {
2929
operationId: 'getHealth',
3030
summary: 'Aggregate health check',
3131
description:
32-
'Database, Redis and Supabase. Reports `degraded` (503) only when a critical service is unhealthy — Supabase may be down without failing the overall check.',
32+
'Database, Redis and Supabase. Reports `degraded` (503) only when a critical service is unhealthy — Supabase may be down without failing the overall check. Each check result is cached for 5 seconds.',
3333
tags: ['Health'],
3434
security: [{}],
3535
responses: {

0 commit comments

Comments
 (0)