@meith/api 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,251 @@
1
+ import type { OpenApiDocument } from './openapi'
2
+
3
+ interface Endpoint {
4
+ readonly method: string
5
+ readonly path: string
6
+ readonly scope: string
7
+ readonly cost: number
8
+ readonly summary: string
9
+ readonly anonymous: boolean
10
+ }
11
+
12
+ function endpoints(document: OpenApiDocument): readonly Endpoint[] {
13
+ const out: Endpoint[] = []
14
+
15
+ for (const [path, operations] of Object.entries(document.paths)) {
16
+ for (const [method, raw] of Object.entries(operations)) {
17
+ const operation = raw as {
18
+ summary: string
19
+ security: readonly Record<string, unknown>[]
20
+ 'x-scope': string
21
+ 'x-rate-limit-cost': number
22
+ }
23
+
24
+ out.push({
25
+ method: method.toUpperCase(),
26
+ path,
27
+ scope: operation['x-scope'],
28
+ cost: operation['x-rate-limit-cost'],
29
+ summary: operation.summary,
30
+ anonymous: operation.security.some((option) => Object.keys(option).length === 0),
31
+ })
32
+ }
33
+ }
34
+
35
+ return out
36
+ }
37
+
38
+ export function renderReference(document: OpenApiDocument): string {
39
+ const routes = endpoints(document)
40
+ const scopes = Object.keys(document['x-scopes'])
41
+ const anonymous = routes.filter((route) => route.anonymous)
42
+ const window = document['x-rate-limit'] as {
43
+ window: { seconds: number; budget: number }
44
+ anonymousWindow: { seconds: number; budget: number }
45
+ }
46
+
47
+ const out: string[] = []
48
+ const push = (...lines: string[]): void => {
49
+ out.push(...lines)
50
+ }
51
+
52
+ push(
53
+ '# REST API v1',
54
+ '',
55
+ '<!--',
56
+ ' GENERATED FILE — do not edit.',
57
+ '',
58
+ ' Written by scripts/api-docs.mts from the OpenAPI document the board serves,',
59
+ ' which is itself generated from packages/api/src/{routes,schema,tokens}.ts. Run',
60
+ ' `pnpm api:docs` after changing any of them; `pnpm verify` and CI run',
61
+ ' `pnpm api:docs:check` and fail when this file, docs/openapi.json and the code',
62
+ ' disagree.',
63
+ '-->',
64
+ '',
65
+ `${routes.length} endpoints, ${scopes.length} scopes. Base path: \`/api/v1\`.`,
66
+ '',
67
+ 'The machine-readable form of everything below — request and response schemas,',
68
+ 'parameters, status codes, scopes and rate-limit costs — is the OpenAPI 3 document',
69
+ 'at [`docs/openapi.json`](openapi.json), which a board also serves live at',
70
+ '`/api/v1/openapi.json`. Point a generator at that rather than reading this table',
71
+ 'into code.',
72
+ '',
73
+ '## Authentication',
74
+ '',
75
+ 'A bearer token in the `Authorization` header:',
76
+ '',
77
+ '```',
78
+ 'Authorization: Bearer forum_pat_<lookup>_<secret>',
79
+ '```',
80
+ '',
81
+ 'A token is a **restriction on an actor, never a grant to one**. Every request',
82
+ 'resolves the owner’s permissions and asks the Authorizer, exactly as a page does,',
83
+ '*in addition to* checking the token’s scope. A token can therefore never reach',
84
+ 'anything its owner could not; revoking the owner’s access revokes the token’s in',
85
+ 'the same instant, because nothing is baked in at creation.',
86
+ '',
87
+ 'Every authentication failure is one `401` with one message. The reason — expired,',
88
+ 'revoked, unknown, malformed — is in the board’s logs and not in the response:',
89
+ 'telling a caller "expired" confirms the token was real.',
90
+ '',
91
+ '## Reading without a token',
92
+ '',
93
+ `${anonymous.length} of the ${routes.length} endpoints answer an unauthenticated request. They are all reads,`,
94
+ 'and they resolve as **the board’s guest** — the same actor a logged-out browser',
95
+ 'gets — through the same Authorizer and the same visibility filter as every other',
96
+ 'request. A forum a guest may not see is not in `GET /forums` for them, its threads',
97
+ '404, and its posts never appear in search. A board whose forums are all closed to',
98
+ 'guests therefore has no anonymous API surface at all, without anything having to',
99
+ 'be configured for that to be true.',
100
+ '',
101
+ 'An offline board answers `503` to an anonymous caller, exactly as it serves the',
102
+ 'offline page to a browser, and answers normally to a token whose owner may see a',
103
+ 'board that is offline.',
104
+ '',
105
+ 'Sending a token to one of these endpoints is not the same as sending none: the',
106
+ 'token’s scope is still required, because a token only ever narrows what its',
107
+ 'owner could do. A token without `forums:read` gets `missing_scope` from',
108
+ '`GET /forums` even though a stranger with no token at all gets an answer.',
109
+ '',
110
+ 'Nothing writable is anonymous. Every write is `401` without a token.',
111
+ '',
112
+ '| Method | Path |',
113
+ '|---|---|',
114
+ ...anonymous.map((route) => `| \`${route.method}\` | \`${route.path}\` |`),
115
+ '',
116
+ '## Scopes',
117
+ '',
118
+ ...scopes.map((scope) => `- \`${scope}\``),
119
+ '',
120
+ 'Every scope on that list is required by at least one endpoint below, and a test',
121
+ 'holds it that way: a scope no route consumes is a checkbox that grants nothing,',
122
+ 'which reads as a permission and is not one.',
123
+ '',
124
+ 'There is deliberately no administrative scope at all. A token is a long-lived',
125
+ 'string in somebody’s CI configuration; reconfiguring a board should need a person',
126
+ 'at a keyboard with the admin panel’s re-authentication in front of them. For the',
127
+ 'same reason there is no moderation scope: removing somebody else’s post through',
128
+ 'the API is `posts:write` resolving to a moderator’s own permissions, not a',
129
+ 'separate grant a token can carry on its own.',
130
+ '',
131
+ 'A token stored before a scope was retired keeps working. The scope is dropped as',
132
+ 'the token is read, so it simply no longer carries it — the endpoints it still has',
133
+ 'a scope for answer as before, and the rest answer `missing_scope`.',
134
+ '',
135
+ '## Issuing a token',
136
+ '',
137
+ 'Tokens are issued from **API tokens** in the control panel. Issuing one is treated',
138
+ 'as a destructive operation: it asks for the administrator’s password again, on the',
139
+ 'same clock as banning a member or moving a forum, because a bearer string that',
140
+ 'leaves the building is at least as consequential. Revoking one does',
141
+ 'not ask — a revocation is the thing you want to be quick during an incident, and',
142
+ 'it is undone by issuing a new token rather than by recovering the old one.',
143
+ '',
144
+ '**Expires in (days)** takes a whole number of days, or nothing at all for a token',
145
+ 'that never expires. Anything else — a fraction, a word, a number in exponent',
146
+ 'notation — is refused and mints nothing, rather than being read as "never".',
147
+ '',
148
+ '## Rate limits',
149
+ '',
150
+ 'Metered in **units of work, not requests** — a search is not a forum listing, and',
151
+ 'a limit that prices them the same invites the expensive call. Every response,',
152
+ 'refused or not, carries `x-ratelimit-limit`, `x-ratelimit-remaining` and',
153
+ '`x-ratelimit-reset`; a refusal is `429` with `retry-after`.',
154
+ '',
155
+ `A token spends against **${window.window.budget} units per ${window.window.seconds} seconds**, held against the token. An`,
156
+ 'unauthenticated caller has no token to hold a budget against, so theirs is held',
157
+ `against their address prefix and is smaller: **${window.anonymousWindow.budget} units per ${window.anonymousWindow.seconds} seconds**. The`,
158
+ 'board’s ordinary anti-flood limits apply to writes on top of both, exactly as they',
159
+ 'do to the web forms.',
160
+ '',
161
+ '## Endpoints',
162
+ '',
163
+ 'Full schemas for every request and response are in the OpenAPI document; this',
164
+ 'table is the map.',
165
+ '',
166
+ '| Method | Path | Scope | Cost | Token | Summary |',
167
+ '|---|---|---|---|---|---|',
168
+ )
169
+
170
+ for (const route of routes) {
171
+ push(
172
+ `| \`${route.method}\` | \`${route.path}\` | \`${route.scope}\` | ${route.cost} | ` +
173
+ `${route.anonymous ? 'optional' : 'required'} | ${route.summary} |`,
174
+ )
175
+ }
176
+
177
+ push(
178
+ '',
179
+ '## Errors',
180
+ '',
181
+ 'Every error is the same shape, so a client parses one thing:',
182
+ '',
183
+ '```json',
184
+ '{ "error": { "code": "missing_scope", "message": "…", "requestId": "…" } }',
185
+ '```',
186
+ '',
187
+ '`code` is stable and machine-readable; `message` is for a human reading a',
188
+ 'terminal. `requestId` is the board’s correlation id — quote it in a report and an',
189
+ 'operator can find the request in their logs.',
190
+ '',
191
+ 'The API refuses a request before it reaches the board with these:',
192
+ '',
193
+ '| Status | Code | Meaning |',
194
+ '|---|---|---|',
195
+ '| 400 | `bad_request` | A query parameter was missing or unusable. |',
196
+ '| 401 | `unauthenticated` | No bearer token, or the token is not valid. |',
197
+ '| 403 | `missing_scope` | Authenticated, but this token lacks the endpoint’s scope. |',
198
+ '| 403 | `owner_unavailable` | The account the token belongs to can no longer act. |',
199
+ '| 404 | `no_such_route` | No such endpoint. |',
200
+ '| 404 | `not_found` | No such resource, or none this caller may see. |',
201
+ '| 429 | `rate_limited` | Over the window budget. See `retry-after`. |',
202
+ '| 501 | `not_implemented` | Declared in the registry, handler not yet written. |',
203
+ '| 503 | `board_offline` | The board is offline and this caller may not see it. |',
204
+ '',
205
+ 'Past that point a request is running the same domain command the web form runs,',
206
+ 'so it fails the way the web form fails, with the board’s own error codes and the',
207
+ 'message translated into the board’s language:',
208
+ '',
209
+ '| Status | Code | Meaning |',
210
+ '|---|---|---|',
211
+ '| 403 | `FORBIDDEN` | The owner’s permissions do not allow this, whatever the token carries. |',
212
+ '| 404 | `NOT_FOUND` | The thing named in the request is not there. |',
213
+ '| 409 | `CONFLICT` | Something else changed underneath the request. |',
214
+ '| 422 | `VALIDATION` | The board’s posting rules refused the contents — too long, too soon, a subject missing. |',
215
+ '| 429 | `RATE_LIMITED` | Over one of the board’s anti-flood limits, which are separate from the API budget. |',
216
+ '',
217
+ 'A resource the caller may not see is `404`, never `403`. Telling a stranger that',
218
+ 'a thread exists but is not for them is the same leak as showing it to them. A',
219
+ '`403` means the caller can see the thing and may not do this to it.',
220
+ '',
221
+ '## Webhooks',
222
+ '',
223
+ 'The board POSTs a JSON body and four headers:',
224
+ '',
225
+ '| Header | Meaning |',
226
+ '|---|---|',
227
+ '| `x-forum-event` | The topic. |',
228
+ '| `x-forum-delivery` | Stable across retries — de-duplicate on this. |',
229
+ '| `x-forum-timestamp` | Unix seconds, and part of the signed material. |',
230
+ '| `x-forum-signature` | `sha256=<hex>` of `HMAC(secret, "<timestamp>.<body>")`. |',
231
+ '',
232
+ // biome-ignore lint/suspicious/noTemplateCurlyInString: prose about the signed material, not an interpolation
233
+ 'Verify by recomputing the HMAC over `` `${timestamp}.${rawBody}` `` and comparing in',
234
+ 'constant time — **and reject anything older than five minutes**. The timestamp is',
235
+ 'inside the signed material precisely so it cannot be edited; checking the',
236
+ 'signature without checking the age leaves every captured delivery replayable',
237
+ 'forever.',
238
+ '',
239
+ 'Delivery is queued, never inline. Failures retry with exponential backoff and',
240
+ 'jitter (30s doubling, capped at an hour, six attempts) and then **dead-letter**',
241
+ 'rather than disappearing, so an operator can retry them once the receiver is',
242
+ 'fixed. A `410 Gone` stops the retries immediately: the receiver has said the',
243
+ 'endpoint is finished.',
244
+ '',
245
+ )
246
+
247
+ return `${out
248
+ .join('\n')
249
+ .replace(/\n{3,}/g, '\n\n')
250
+ .trimEnd()}\n`
251
+ }