@nacre.work/api 0.0.0 → 0.1.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.
Files changed (69) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +9 -0
  3. package/dist/adapters.d.ts +326 -15
  4. package/dist/adapters.d.ts.map +1 -1
  5. package/dist/adapters.js +992 -52
  6. package/dist/adapters.js.map +1 -1
  7. package/dist/audit-export.d.ts +45 -0
  8. package/dist/audit-export.d.ts.map +1 -0
  9. package/dist/audit-export.js +155 -0
  10. package/dist/audit-export.js.map +1 -0
  11. package/dist/auth.d.ts +17 -0
  12. package/dist/auth.d.ts.map +1 -1
  13. package/dist/auth.js +17 -7
  14. package/dist/auth.js.map +1 -1
  15. package/dist/idempotency.d.ts +62 -0
  16. package/dist/idempotency.d.ts.map +1 -0
  17. package/dist/idempotency.js +147 -0
  18. package/dist/idempotency.js.map +1 -0
  19. package/dist/index.d.ts +11 -2
  20. package/dist/index.d.ts.map +1 -1
  21. package/dist/index.js +10 -1
  22. package/dist/index.js.map +1 -1
  23. package/dist/init.d.ts +9 -0
  24. package/dist/init.d.ts.map +1 -1
  25. package/dist/init.js +50 -5
  26. package/dist/init.js.map +1 -1
  27. package/dist/limits.d.ts +80 -0
  28. package/dist/limits.d.ts.map +1 -0
  29. package/dist/limits.js +63 -0
  30. package/dist/limits.js.map +1 -0
  31. package/dist/login.d.ts +94 -0
  32. package/dist/login.d.ts.map +1 -0
  33. package/dist/login.js +283 -0
  34. package/dist/login.js.map +1 -0
  35. package/dist/main.js +136 -53
  36. package/dist/main.js.map +1 -1
  37. package/dist/pagination.d.ts +72 -0
  38. package/dist/pagination.d.ts.map +1 -0
  39. package/dist/pagination.js +110 -0
  40. package/dist/pagination.js.map +1 -0
  41. package/dist/rerank.d.ts +99 -0
  42. package/dist/rerank.d.ts.map +1 -0
  43. package/dist/rerank.js +138 -0
  44. package/dist/rerank.js.map +1 -0
  45. package/dist/server.d.ts +345 -6
  46. package/dist/server.d.ts.map +1 -1
  47. package/dist/server.js +1035 -24
  48. package/dist/server.js.map +1 -1
  49. package/dist/service-keys.d.ts +13 -5
  50. package/dist/service-keys.d.ts.map +1 -1
  51. package/dist/service-keys.js +54 -31
  52. package/dist/service-keys.js.map +1 -1
  53. package/dist/source.d.ts +53 -0
  54. package/dist/source.d.ts.map +1 -0
  55. package/dist/source.js +109 -0
  56. package/dist/source.js.map +1 -0
  57. package/package.json +28 -10
  58. package/dist/__tests__/admin-surface.test.d.ts +0 -2
  59. package/dist/__tests__/admin-surface.test.d.ts.map +0 -1
  60. package/dist/__tests__/admin-surface.test.js +0 -431
  61. package/dist/__tests__/admin-surface.test.js.map +0 -1
  62. package/dist/__tests__/init.test.d.ts +0 -2
  63. package/dist/__tests__/init.test.d.ts.map +0 -1
  64. package/dist/__tests__/init.test.js +0 -125
  65. package/dist/__tests__/init.test.js.map +0 -1
  66. package/dist/__tests__/service-keys.test.d.ts +0 -2
  67. package/dist/__tests__/service-keys.test.d.ts.map +0 -1
  68. package/dist/__tests__/service-keys.test.js +0 -204
  69. package/dist/__tests__/service-keys.test.js.map +0 -1
package/dist/server.js CHANGED
@@ -1,11 +1,47 @@
1
1
  import { createServer } from 'node:http';
2
- import { randomUUID } from 'node:crypto';
2
+ import { createHash, randomUUID, timingSafeEqual } from 'node:crypto';
3
3
  // Imported rather than taken from the global scope: `lib` is ES2023 with no
4
4
  // DOM, so the global URL is not typed here.
5
5
  import { URL } from 'node:url';
6
+ import { MetadataError, parseFilters, parseMetadata, PROTECTED_RESOURCE_PATH, TooBusy, } from '@nacre.work/core';
6
7
  import { authenticate, rejectTenantOverride } from './auth.js';
7
8
  import { badRequest, internal, notFound, Problem } from './errors.js';
9
+ import { isConflict, isReplay } from './idempotency.js';
10
+ import { limitHeaders } from './limits.js';
11
+ import { clientSource } from './source.js';
12
+ import { auditFormat, auditJson, readAuditQuery, toCsv, toNdjson, } from './audit-export.js';
13
+ import { readPage } from './pagination.js';
14
+ /**
15
+ * The default body cap, in bytes.
16
+ *
17
+ * `NACRE_MAX_DOCUMENT_BYTES` overrides it — the variable was validated at
18
+ * startup and read by nothing, so `docs/api.md` promised 50 MB while the server
19
+ * refused anything over 1 MB, and refused it with a `400` whose message did not
20
+ * mention size. An operator raising the documented limit saw no change.
21
+ */
8
22
  const MAX_BODY_BYTES = 1_000_000;
23
+ /** Distinguishable from a malformed body, because the answers differ: 413, not 400. */
24
+ class BodyTooLarge extends Error {
25
+ }
26
+ /**
27
+ * `top_k`, clamped to what the contract declares.
28
+ *
29
+ * It was passed through as whatever JSON produced: `1e309` becomes `Infinity`
30
+ * and reached Qdrant's `limit` verbatim, and with reranking on the same number
31
+ * decided how many rows to hydrate from Postgres. Negative and fractional
32
+ * values went through too.
33
+ *
34
+ * Clamped rather than refused. The bound is a resource limit and not a
35
+ * permission one, so a client that asks for more than the maximum is answered
36
+ * with the maximum — the same thing every paginated endpoint here does.
37
+ */
38
+ const MAX_TOP_K = 50;
39
+ const isStringArray = (value) => Array.isArray(value) && value.every((v) => typeof v === 'string');
40
+ const boundedTopK = (value) => {
41
+ if (typeof value !== 'number' || !Number.isFinite(value))
42
+ return 10;
43
+ return Math.min(MAX_TOP_K, Math.max(1, Math.floor(value)));
44
+ };
9
45
  const PRINCIPAL_TYPES = ['user', 'group', 'service_account'];
10
46
  const PERMISSIONS = ['read', 'write', 'admin'];
11
47
  function accountJson(a) {
@@ -78,27 +114,324 @@ function parseGrant(body) {
78
114
  permission: permission,
79
115
  };
80
116
  }
81
- async function readBody(req) {
117
+ async function readBody(req, limit = MAX_BODY_BYTES) {
82
118
  const chunks = [];
83
119
  let size = 0;
84
120
  for await (const chunk of req) {
85
121
  size += chunk.length;
86
- if (size > MAX_BODY_BYTES)
87
- throw new Error('body too large');
122
+ if (size > limit)
123
+ throw new BodyTooLarge('body too large');
88
124
  chunks.push(chunk);
89
125
  }
90
126
  if (chunks.length === 0)
91
127
  return undefined;
92
128
  return JSON.parse(Buffer.concat(chunks).toString('utf8'));
93
129
  }
94
- function send(res, status, body, requestId) {
130
+ /** The wire shape of a reindex, snake case like every other response here. */
131
+ function reindexJson(status) {
132
+ return {
133
+ layer_id: status.layerId,
134
+ status: status.status,
135
+ phase: status.phase,
136
+ // Both names, because "which model is search using right now" and "which
137
+ // one is being built" are different questions and an operator watching a
138
+ // migration needs to see them move independently.
139
+ current_vector: status.currentVector,
140
+ shadow_vector: status.shadowVector,
141
+ provider_id: status.providerId,
142
+ started_at: status.startedAt,
143
+ finished_at: status.finishedAt,
144
+ total: status.total,
145
+ done: status.done,
146
+ failed: status.failed,
147
+ progress: status.progress,
148
+ error: status.error,
149
+ };
150
+ }
151
+ function send(res, status, body, requestId, extra = {}) {
152
+ // 204 means no content, and it meant it while this function wrote the four
153
+ // bytes `null` into the body. Some clients tolerate that and some treat a
154
+ // body on a 204 as a framing error.
155
+ if (status === 204) {
156
+ res.writeHead(204, { 'x-request-id': requestId, ...extra });
157
+ res.end();
158
+ return;
159
+ }
95
160
  const json = JSON.stringify(body);
96
161
  res.writeHead(status, {
97
162
  'content-type': status >= 400 ? 'application/problem+json' : 'application/json',
98
163
  'x-request-id': requestId,
164
+ ...extra,
99
165
  });
100
166
  res.end(json);
101
167
  }
168
+ /**
169
+ * Merge headers into whatever the handler writes, and capture the body.
170
+ *
171
+ * `writeHead` is patched rather than wrapped at each call site because the
172
+ * headers apply to every response on the request, including the ones produced
173
+ * deep inside a handler's error path. `end` is patched to hand the serialized
174
+ * body to the idempotency store — after the response is on the wire, since a
175
+ * cache write must never be what delays an answer.
176
+ */
177
+ function decorate(res, extra, store) {
178
+ if (Object.keys(extra).length === 0 && store === undefined)
179
+ return;
180
+ const writeHead = res.writeHead.bind(res);
181
+ let status = 200;
182
+ res.writeHead = ((code, ...rest) => {
183
+ status = code;
184
+ const headers = rest.find((r) => typeof r === 'object' && r !== null);
185
+ return writeHead(code, { ...extra, ...(headers ?? {}) });
186
+ });
187
+ if (store === undefined)
188
+ return;
189
+ const end = res.end.bind(res);
190
+ res.end = ((chunk, ...rest) => {
191
+ if (typeof chunk === 'string' && chunk.length > 0) {
192
+ try {
193
+ // Only a JSON body is worth replaying. A 204 has none, and anything
194
+ // else on this API would be a bug rather than a response to cache.
195
+ void store(status, JSON.parse(chunk));
196
+ }
197
+ catch {
198
+ // Not JSON — nothing to replay, and refusing to answer over it would
199
+ // be absurd.
200
+ }
201
+ }
202
+ else if (status === 204) {
203
+ void store(status, null);
204
+ }
205
+ return end(chunk, ...rest);
206
+ });
207
+ }
208
+ /**
209
+ * Which limit a request counts against, or none.
210
+ *
211
+ * Search and ingest are the two the contract names, and they are the two that
212
+ * cost real money — an embedding call each. Reads of metadata are cheap and are
213
+ * not counted; adding them later means adding a policy, not restructuring this.
214
+ */
215
+ function resourceFor(method, instance) {
216
+ if (method === 'POST' && instance === '/v1/search')
217
+ return 'search';
218
+ if (method === 'POST' && instance === '/v1/documents')
219
+ return 'ingest';
220
+ return undefined;
221
+ }
222
+ /**
223
+ * Paths whose responses must never enter the idempotency cache.
224
+ *
225
+ * `/v1/service-accounts` is here because its response carries the key itself,
226
+ * once, and the key is stored hashed precisely so that it cannot be recovered
227
+ * from the database or from a backup. Caching that response puts the plaintext
228
+ * key in Redis for 24 hours and undoes the whole reason for hashing it — a
229
+ * convenience feature quietly weakening a credential store. The endpoint is
230
+ * already safe to retry without a cache: the unique name constraint answers a
231
+ * duplicate with `409` rather than minting a second key.
232
+ *
233
+ * `/v1/documents` is here for a different reason and not a security one: it is
234
+ * already idempotent on `(layer, external_id)` and the content hash, which
235
+ * survives this cache expiring and is therefore the stronger guarantee.
236
+ * Wrapping it would add a weaker one on top and a second thing to reason about.
237
+ *
238
+ * `/v1/search` is here because it is the one response made entirely of other
239
+ * people's documents. Caching it put chunk text in Redis for 24 hours — the
240
+ * defect already found once with service account keys, on the endpoint that
241
+ * returns the most sensitive thing in the product. A search is also safe to
242
+ * repeat by definition, so idempotency bought it nothing in the first place.
243
+ *
244
+ * The test for adding a path: **would the response be a problem in a cache
245
+ * dump?** If a response is only ever shown once on purpose, or is assembled
246
+ * from what one caller in particular may read, it does not go in a store with a
247
+ * 24-hour TTL and no access control of its own.
248
+ */
249
+ const NEVER_CACHED = ['/v1/service-accounts', '/v1/documents', '/v1/search'];
250
+ /** Prefix rather than exact match, so `/v1/service-accounts/{id}` is covered too. */
251
+ const neverCached = (instance) => NEVER_CACHED.some((path) => instance === path || instance.startsWith(`${path}/`));
252
+ /**
253
+ * `/v1/auth/*`: the endpoints that exist to produce a credential.
254
+ *
255
+ * Separate from `handle` because everything there assumes an `AuthContext`, and
256
+ * these three run before there is one. The refusals are deliberately
257
+ * indistinguishable: unknown address, wrong password, wrong organization,
258
+ * disabled account and an account with no password set are one `401` with one
259
+ * message, and `Login` spends the same time on each.
260
+ *
261
+ * Rate limited on the address rather than the organization, because there is no
262
+ * organization yet and because the thing being defended against is guessing one
263
+ * account's password. That is the one limit keyed on something a caller
264
+ * chooses, so it is normalized and hashed into the key rather than used raw —
265
+ * an unbounded string from an unauthenticated request should not become a Redis
266
+ * key, and an operator reading `KEYS nacre:rl:*` should not be reading a list
267
+ * of who has an account here.
268
+ */
269
+ async function handleAuth(req, res, instance, requestId, options) {
270
+ if (options.login === undefined || req.method !== 'POST') {
271
+ const problem = notFound(instance, requestId);
272
+ send(res, problem.status, problem.toJSON(), requestId);
273
+ return;
274
+ }
275
+ let body;
276
+ try {
277
+ body = await readBody(req);
278
+ }
279
+ catch {
280
+ const problem = badRequest(instance, requestId, 'The request body could not be read.');
281
+ send(res, problem.status, problem.toJSON(), requestId);
282
+ return;
283
+ }
284
+ const refuse = () => {
285
+ const problem = new Problem({
286
+ type: 'https://nacre.work/errors/unauthorized',
287
+ title: 'Unauthorized',
288
+ status: 401,
289
+ // One message for every reason. A wrong password and an address with no
290
+ // account must not be tellable apart, or this endpoint enumerates users.
291
+ detail: 'Those credentials are not valid.',
292
+ instance,
293
+ requestId,
294
+ });
295
+ send(res, problem.status, problem.toJSON(), requestId);
296
+ };
297
+ if (instance === '/v1/auth/login') {
298
+ const { email, password, organization } = (body ?? {});
299
+ if (typeof email !== 'string' || typeof password !== 'string') {
300
+ const problem = badRequest(instance, requestId, "'email' and 'password' are required.");
301
+ send(res, problem.status, problem.toJSON(), requestId);
302
+ return;
303
+ }
304
+ if (options.limits !== undefined && options.limitPolicies?.login !== undefined) {
305
+ // Two limits, on two different things, because either alone leaves a
306
+ // hole. The address limit stops one account being ground down; on its own
307
+ // it does nothing about the attack that is actually run, which is one
308
+ // password against a directory — that never repeats an address and never
309
+ // meets the limit. The source limit bounds that; on its own it would
310
+ // over-trust topology, because an office behind one NAT is one source.
311
+ //
312
+ // Checked in that order and reported as one 429 with the address limit's
313
+ // headers, which is the tighter of the two: the alternative is a response
314
+ // whose RateLimit-Remaining depends on which limit fired, and that is a
315
+ // signal about other people's traffic.
316
+ const subject = createHash('sha256').update(email.trim().toLowerCase()).digest('hex').slice(0, 32);
317
+ const source = clientSource(req, { trustProxy: options.trustProxy ?? 0 });
318
+ const decisions = [await options.limits.check(subject, 'login')];
319
+ // `undefined` means the transport gave no address to key on. Skipped
320
+ // rather than folded into a shared bucket — one bucket for every client
321
+ // that cannot be identified is a denial of service with extra steps.
322
+ if (source !== undefined && options.limitPolicies.login_source !== undefined) {
323
+ decisions.push(await options.limits.check(`src:${source}`, 'login_source'));
324
+ }
325
+ const refused = decisions.find((d) => !d.allowed);
326
+ if (refused !== undefined) {
327
+ const problem = new Problem({
328
+ type: 'https://nacre.work/errors/rate-limited',
329
+ title: 'Too many requests',
330
+ status: 429,
331
+ detail: `Too many sign-in attempts. Try again in ${refused.reset} seconds.`,
332
+ instance,
333
+ requestId,
334
+ });
335
+ send(res, problem.status, problem.toJSON(), requestId, limitHeaders(decisions[0], options.limitPolicies.login, 'login'));
336
+ return;
337
+ }
338
+ }
339
+ let tokens;
340
+ try {
341
+ tokens = await options.login.login({
342
+ email,
343
+ password,
344
+ ...(typeof organization === 'string' ? { organization } : {}),
345
+ });
346
+ }
347
+ catch (error) {
348
+ // The process is already verifying as many passwords as it will. scrypt
349
+ // runs on libuv's thread pool, which is shared with DNS and file I/O, so
350
+ // an unbounded login endpoint stops the *rest* of the API on a name
351
+ // lookup — see the gate in core/passwords.ts.
352
+ //
353
+ // 503 and not 401: nothing was decided about these credentials, and
354
+ // answering "not valid" to a request that was never checked is a lie the
355
+ // client will act on. 503 with Retry-After is the honest one.
356
+ //
357
+ // Not an oracle either. It depends on how loaded the process is and not
358
+ // at all on whether the account exists.
359
+ if (error instanceof TooBusy) {
360
+ const problem = new Problem({
361
+ type: 'https://nacre.work/errors/unavailable',
362
+ title: 'Service unavailable',
363
+ status: 503,
364
+ detail: 'Too many sign-in attempts are being processed. Try again shortly.',
365
+ instance,
366
+ requestId,
367
+ });
368
+ send(res, problem.status, problem.toJSON(), requestId, { 'retry-after': '2' });
369
+ return;
370
+ }
371
+ throw error;
372
+ }
373
+ if (tokens === undefined) {
374
+ // Logged rather than audited, and the difference is not laziness. The
375
+ // audit log is per-organization; an address that matches no user belongs
376
+ // to no tenant, and giving that row an owner would put one
377
+ // organization's failed attempts into another's access log. A security
378
+ // team watching for password spraying wants this across the whole
379
+ // installation anyway, which is what a log line is.
380
+ //
381
+ // The address, never the password, and never how far the attempt got —
382
+ // "no such user" and "wrong password" must not be tellable apart from a
383
+ // log any more than from a response.
384
+ console.warn(JSON.stringify({ msg: 'sign-in refused', email: email.trim().toLowerCase(), request_id: requestId }));
385
+ refuse();
386
+ return;
387
+ }
388
+ // The successful one does have an organization to belong to, and it is the
389
+ // event that answers "who has been in here". Awaited: a lost audit event is
390
+ // worse than a slow response.
391
+ await options.audit.write({
392
+ orgId: tokens.orgId,
393
+ actor: `user:${tokens.userId}`,
394
+ action: 'login',
395
+ result: 'allow',
396
+ detail: {},
397
+ requestId,
398
+ });
399
+ send(res, 200, tokenJson(tokens), requestId);
400
+ return;
401
+ }
402
+ const token = body?.refresh_token;
403
+ if (typeof token !== 'string' || token === '') {
404
+ const problem = badRequest(instance, requestId, "'refresh_token' is required.");
405
+ send(res, problem.status, problem.toJSON(), requestId);
406
+ return;
407
+ }
408
+ if (instance === '/v1/auth/refresh') {
409
+ const tokens = await options.login.refresh(token);
410
+ if (tokens === undefined) {
411
+ refuse();
412
+ return;
413
+ }
414
+ send(res, 200, tokenJson(tokens), requestId);
415
+ return;
416
+ }
417
+ if (instance === '/v1/auth/logout') {
418
+ // 204 whether or not the token was live. Saying "that token did not exist"
419
+ // tells whoever is holding a stolen one whether it is still worth using.
420
+ await options.login.logout(token);
421
+ send(res, 204, null, requestId);
422
+ return;
423
+ }
424
+ const problem = notFound(instance, requestId);
425
+ send(res, problem.status, problem.toJSON(), requestId);
426
+ }
427
+ function tokenJson(tokens) {
428
+ return {
429
+ access_token: tokens.accessToken,
430
+ token_type: 'Bearer',
431
+ expires_in: tokens.expiresIn,
432
+ refresh_token: tokens.refreshToken,
433
+ };
434
+ }
102
435
  export function createApi(options) {
103
436
  return createServer((req, res) => {
104
437
  void handle(req, res, options).catch(() => {
@@ -114,37 +447,146 @@ async function handle(req, res, options) {
114
447
  const url = new URL(req.url ?? '/', 'http://localhost');
115
448
  const instance = url.pathname;
116
449
  if (req.method === 'GET' && instance === '/metrics') {
117
- // Unauthenticated, like every Prometheus endpoint, and therefore carrying
118
- // nothing that is not already a count. No document ids, no query text, no
119
- // organization ids — organizations appear by slug, which is in the URL of
120
- // every request that tenant makes anyway.
450
+ // Unauthenticated by default, like every Prometheus endpoint, and therefore
451
+ // carrying nothing that is not already a count. No document ids, no query
452
+ // text, no organization ids — organizations appear by slug, which is in the
453
+ // URL of every request that tenant makes anyway.
454
+ //
455
+ // A token is available and off unless configured. Requiring one would break
456
+ // every existing scrape config for a product people self-host, and the
457
+ // default is right for the deployment this is designed around: the port is
458
+ // on an internal network. It stops being right the moment somebody puts the
459
+ // API behind a public ingress without carving this path out, which is a
460
+ // thing that happens — so `NACRE_METRICS_TOKEN` exists for the operator who
461
+ // knows they are in that situation. See docs/config.md.
121
462
  if (options.metrics === undefined) {
122
463
  const problem = notFound(instance, requestId);
123
464
  send(res, problem.status, problem.toJSON(), requestId);
124
465
  return;
125
466
  }
467
+ if (options.metricsToken !== undefined) {
468
+ const header = req.headers.authorization;
469
+ const presented = header?.startsWith('Bearer ') === true ? header.slice(7) : undefined;
470
+ // Constant time, and length-checked first because timingSafeEqual throws
471
+ // on a mismatch. A scrape token is a credential like any other.
472
+ const expected = Buffer.from(options.metricsToken, 'utf8');
473
+ const given = Buffer.from(presented ?? '', 'utf8');
474
+ if (given.length !== expected.length || !timingSafeEqual(given, expected)) {
475
+ // 404 rather than 401, for the same reason invariant 4 gives: a
476
+ // deployment that hides its metrics endpoint should not confirm it has
477
+ // one. There is nothing to authenticate *into* here, so a challenge
478
+ // would only say "keep guessing".
479
+ const problem = notFound(instance, requestId);
480
+ send(res, problem.status, problem.toJSON(), requestId);
481
+ return;
482
+ }
483
+ }
126
484
  const body = await options.metrics.render();
127
485
  res.writeHead(200, { 'content-type': 'text/plain; version=0.0.4; charset=utf-8' });
128
486
  res.end(body);
129
487
  return;
130
488
  }
489
+ if (req.method === 'GET' && instance === PROTECTED_RESOURCE_PATH) {
490
+ // RFC 9728 discovery, and unauthenticated by definition: it is what a
491
+ // client reads *because* it has no credential yet. Every 401 from the MCP
492
+ // transport names this path in `WWW-Authenticate`, and nothing served it —
493
+ // so a client doing exactly what the header told it to got a 404.
494
+ //
495
+ // Absent means the deployment did not configure a canonical URL, which
496
+ // cannot happen: it is required at startup.
497
+ if (options.resourceMetadata === undefined) {
498
+ const problem = notFound(instance, requestId);
499
+ send(res, problem.status, problem.toJSON(), requestId);
500
+ return;
501
+ }
502
+ send(res, 200, options.resourceMetadata, requestId);
503
+ return;
504
+ }
131
505
  if (req.method === 'GET' && instance === '/v1/health') {
132
506
  // Liveness touches no dependency. A health check that calls Postgres turns
133
507
  // one slow database into a cascading restart loop.
134
508
  send(res, 200, { status: 'ok' }, requestId);
135
509
  return;
136
510
  }
511
+ if (req.method === 'GET' && instance === '/v1/ready') {
512
+ // Readiness, which is the opposite of liveness and touches everything this
513
+ // process cannot serve a request without.
514
+ //
515
+ // `docs/config.md` has told operators to point a Kubernetes readinessProbe
516
+ // here since before there was a server, and the path answered `401` — it
517
+ // fell through to the authenticator, so the probe never succeeded and the
518
+ // rollout never completed. Unauthenticated for the same reason `/metrics`
519
+ // is: a probe has no credential to present, and the body says only which
520
+ // dependency is unhappy, never why.
521
+ //
522
+ // `503` rather than `200` with a body to parse. An orchestrator reads the
523
+ // status code, and a readiness endpoint that answers 200 while saying it is
524
+ // not ready is a readiness endpoint that does nothing.
525
+ if (options.ready === undefined) {
526
+ const problem = notFound(instance, requestId);
527
+ send(res, problem.status, problem.toJSON(), requestId);
528
+ return;
529
+ }
530
+ const checks = await options.ready();
531
+ const ok = Object.values(checks).every(Boolean);
532
+ send(res, ok ? 200 : 503, { status: ok ? 'ready' : 'not ready', checks }, requestId);
533
+ return;
534
+ }
535
+ // Sign-in, before authentication, because it is what produces the credential
536
+ // everything below this line requires.
537
+ //
538
+ // `rejectTenantOverride` does not apply here and must not: it refuses a body
539
+ // naming an organization, and naming one is exactly what a login on a
540
+ // multi-organization installation is allowed to do. The invariant is kept a
541
+ // different way — see login.ts. The organization in the issued token comes
542
+ // from the row that authenticated, never from the request.
543
+ if (instance.startsWith('/v1/auth/')) {
544
+ await handleAuth(req, res, instance, requestId, options);
545
+ return;
546
+ }
137
547
  const auth = await authenticate(req.headers.authorization, options.verify, instance, requestId);
138
548
  if (auth instanceof Problem) {
549
+ // Counted by what was presented, never by why it failed.
550
+ //
551
+ // `authenticate` answers one 401 with one message for every reason, on
552
+ // purpose — "revoked key" and "wrong audience" and "expired" must not be
553
+ // tellable apart by a caller. A label carrying the reason would put that
554
+ // distinction back on an endpoint that is unauthenticated by default, so
555
+ // this counts the *kind of credential* instead, which is what an operator
556
+ // actually needs and what no caller learns anything from.
557
+ //
558
+ // Rotating the signing key is the case it exists for: every outstanding
559
+ // access token 401s at once and there is no dual-key window, so an operator
560
+ // needs to watch that spike drain. Nothing here logs requests, so before
561
+ // this there was no way to see it at all.
562
+ const presented = req.headers.authorization;
563
+ const kind = presented === undefined || !presented.startsWith('Bearer ')
564
+ ? 'missing'
565
+ : presented.slice(7).startsWith('nacre_sk_')
566
+ ? 'service_key'
567
+ : 'jwt';
568
+ options.observe?.authFailures.inc({ kind });
139
569
  send(res, auth.status, auth.toJSON(), requestId);
140
570
  return;
141
571
  }
142
572
  let body;
143
573
  try {
144
- body = await readBody(req);
574
+ body = await readBody(req, options.maxBodyBytes ?? MAX_BODY_BYTES);
145
575
  }
146
- catch {
147
- const problem = badRequest(instance, requestId, 'The request body could not be read.');
576
+ catch (error) {
577
+ // 413 for size and 400 for anything else. They were one answer, and the
578
+ // message said neither — a caller over the limit was told their body could
579
+ // not be read, which is true and useless.
580
+ const problem = error instanceof BodyTooLarge
581
+ ? new Problem({
582
+ type: 'https://nacre.work/errors/payload-too-large',
583
+ title: 'Payload too large',
584
+ status: 413,
585
+ detail: `The request body is over the ${options.maxBodyBytes ?? MAX_BODY_BYTES} byte limit set by NACRE_MAX_DOCUMENT_BYTES.`,
586
+ instance,
587
+ requestId,
588
+ })
589
+ : badRequest(instance, requestId, 'The request body could not be read.');
148
590
  send(res, problem.status, problem.toJSON(), requestId);
149
591
  return;
150
592
  }
@@ -163,21 +605,160 @@ async function handle(req, res, options) {
163
605
  send(res, override.status, override.toJSON(), requestId);
164
606
  return;
165
607
  }
608
+ // Rate limiting, after the token is verified and before any work is done.
609
+ //
610
+ // After verification because the limit is per organization and the
611
+ // organization comes from the token — there is nothing to count against
612
+ // until it has been read. Before the handler because the point is to refuse
613
+ // before spending an embedding call.
614
+ //
615
+ // The headers go on the successful response too. A client that only learns
616
+ // its budget by being refused has to be refused to learn it.
617
+ let rateHeaders = {};
618
+ const resource = resourceFor(req.method ?? 'GET', instance);
619
+ if (resource !== undefined && options.limits !== undefined && options.limitPolicies !== undefined) {
620
+ const decision = await options.limits.check(auth.orgId, resource);
621
+ rateHeaders = limitHeaders(decision, options.limitPolicies[resource], resource);
622
+ if (!decision.allowed) {
623
+ await options.audit.write({
624
+ orgId: auth.orgId,
625
+ actor: `${auth.principal.type}:${auth.principal.id}`,
626
+ action: 'rate_limited',
627
+ result: 'deny',
628
+ detail: { resource, limit: decision.limit },
629
+ requestId,
630
+ });
631
+ const problem = new Problem({
632
+ type: 'https://nacre.work/errors/rate-limited',
633
+ title: 'Too many requests',
634
+ status: 429,
635
+ detail: `The ${resource} limit for this organization is ${decision.limit} per ${options.limitPolicies[resource].windowSeconds} seconds. Try again in ${decision.reset} seconds.`,
636
+ instance,
637
+ requestId,
638
+ });
639
+ send(res, problem.status, problem.toJSON(), requestId, rateHeaders);
640
+ return;
641
+ }
642
+ }
643
+ // Idempotency-Key, for the unsafe methods that are not already idempotent.
644
+ const idempotencyKey = req.headers['idempotency-key'];
645
+ let storeIdempotent;
646
+ if (typeof idempotencyKey === 'string' &&
647
+ idempotencyKey.length > 0 &&
648
+ options.idempotency !== undefined &&
649
+ ['POST', 'PUT', 'PATCH', 'DELETE'].includes(req.method ?? '') &&
650
+ !neverCached(instance)) {
651
+ const outcome = await options.idempotency.begin(idempotencyKey,
652
+ // The principal, not just the organization. Two principals in one tenant
653
+ // see different things, so a response cached for one must never be
654
+ // replayed to another — a replay is sent before any handler runs, so
655
+ // there is no permission check left to catch it.
656
+ { orgId: auth.orgId, type: auth.principal.type, id: auth.principal.id }, req.method ?? '', instance, body);
657
+ if (isReplay(outcome)) {
658
+ // `send` drops the body on a 204, so a replayed delete replays as a
659
+ // delete rather than as a 204 with `null` in it.
660
+ send(res, outcome.cached.status, outcome.cached.body, requestId, {
661
+ ...rateHeaders,
662
+ 'idempotency-replayed': 'true',
663
+ });
664
+ return;
665
+ }
666
+ if (isConflict(outcome)) {
667
+ // The same key with a different body, or the first attempt still running.
668
+ // Replaying a response that does not match what was asked for — or does
669
+ // not exist yet — is worse than saying so.
670
+ const problem = new Problem({
671
+ type: 'https://nacre.work/errors/conflict',
672
+ title: 'Conflict',
673
+ status: 409,
674
+ detail: 'This Idempotency-Key is in use for a different request, or the first ' +
675
+ 'attempt has not finished. Use a new key, or retry the identical request.',
676
+ instance,
677
+ requestId,
678
+ });
679
+ send(res, problem.status, problem.toJSON(), requestId, rateHeaders);
680
+ return;
681
+ }
682
+ storeIdempotent = outcome.store;
683
+ }
684
+ // The response is decorated rather than every `send` call being rewritten.
685
+ //
686
+ // Thirty handlers each remembering to attach rate headers and to cache their
687
+ // body is thirty chances to forget, and the one that forgets is invisible
688
+ // until a client asks why its budget headers vanished on one endpoint. This
689
+ // patches `writeHead` and `end` once, so the handlers stay unaware.
690
+ decorate(res, rateHeaders, storeIdempotent);
166
691
  try {
167
692
  if (req.method === 'POST' && instance === '/v1/search') {
168
- const query = body?.query;
169
- const topK = body?.top_k;
693
+ const request = (body ?? {});
694
+ const query = request.query;
170
695
  if (typeof query !== 'string' || query.length === 0) {
171
696
  const problem = badRequest(instance, requestId, "'query' is required.");
172
697
  send(res, problem.status, problem.toJSON(), requestId);
173
698
  return;
174
699
  }
175
- const results = await options.search.search(auth, query, typeof topK === 'number' ? topK : 10);
700
+ if (request.layers !== undefined && !isStringArray(request.layers)) {
701
+ const problem = badRequest(instance, requestId, "'layers' must be an array of layer slugs.");
702
+ send(res, problem.status, problem.toJSON(), requestId);
703
+ return;
704
+ }
705
+ // Narrowing, and only ever narrowing.
706
+ //
707
+ // `filters` was in the contract from before there was a server and read
708
+ // by nothing, then refused with 400 rather than ignored — because a
709
+ // caller who filters a search and gets everything back believes they
710
+ // scoped it. It is applied now, and the shape it is applied in is the
711
+ // point: it never becomes a filter the caller assembled. `buildFilter`
712
+ // turns each entry into a `must` on a namespaced payload key alongside
713
+ // the permission constraint, so the only thing a filter can do is remove
714
+ // results the caller was already permitted to see.
715
+ let filters;
716
+ try {
717
+ filters = parseFilters(request.filters);
718
+ }
719
+ catch (error) {
720
+ const problem = badRequest(instance, requestId, error instanceof MetadataError ? error.message : "'filters' is not usable.");
721
+ send(res, problem.status, problem.toJSON(), requestId);
722
+ return;
723
+ }
724
+ const started = process.hrtime.bigint();
725
+ const results = await options.search.search(auth, query, boundedTopK(request.top_k), {
726
+ ...(Object.keys(filters).length === 0 ? {} : { filters }),
727
+ // Only ever a way to turn it off; a deployment with no reranker
728
+ // configured does not acquire one because a client asked.
729
+ ...(request.rerank === false ? { rerank: false } : {}),
730
+ ...(isStringArray(request.layers) && request.layers.length > 0
731
+ ? { layers: request.layers }
732
+ : {}),
733
+ ...(request.include_content === false ? { includeContent: false } : {}),
734
+ });
735
+ // Measured around the whole thing — resolve, embed, traverse, hydrate,
736
+ // rerank — because that is what a caller waits for and what the p95
737
+ // target in docs/config.md is about. It was never observed at all, so the
738
+ // histogram rendered no series and the target was unmeasurable.
739
+ options.observe?.searchDuration.observe(Number(process.hrtime.bigint() - started) / 1e9);
740
+ options.observe?.searchResults.inc({}, results.length);
741
+ if (results.length === 0) {
742
+ // Zero permitted results is what a denial looks like on this endpoint:
743
+ // there is no 403 to count, by design. Without it the denial counter sat
744
+ // at zero forever, which reads as "nobody is being refused".
745
+ options.observe?.aclDenials.inc({ reason: 'search_empty' });
746
+ }
176
747
  await options.audit.write({
177
748
  orgId: auth.orgId,
178
749
  actor: `${auth.principal.type}:${auth.principal.id}`,
179
750
  action: 'search',
180
751
  result: 'allow',
752
+ // `docs/audit.md` opens by promising that "show me which documents your
753
+ // agent read last quarter has to get a precise answer". That needs the
754
+ // ids, and this wrote a count. The query text is deliberately still not
755
+ // here — CLAUDE.md forbids logging it, and `NACRE_AUDIT_QUERY_TEXT`
756
+ // exists for deployments that decide otherwise.
757
+ target: {
758
+ returned_docs: [...new Set(results.map((r) => r.doc_id))],
759
+ layers: [...new Set(results.map((r) => r.layer))],
760
+ top_k: boundedTopK(request.top_k),
761
+ },
181
762
  detail: { returned: results.length },
182
763
  requestId,
183
764
  });
@@ -202,14 +783,37 @@ async function handle(req, res, options) {
202
783
  send(res, problem.status, problem.toJSON(), requestId);
203
784
  return;
204
785
  }
786
+ // Declared in openapi.yaml with no caveat and read by nothing until now,
787
+ // which made it the quietest of the parameters that were: a caller tagged
788
+ // a document, got 202, and the tag existed nowhere. Refused rather than
789
+ // trimmed when it is malformed — a dropped key is a document the caller
790
+ // believes is tagged and a filter that will never match it.
791
+ let metadata;
792
+ try {
793
+ metadata = parseMetadata(body_.metadata);
794
+ }
795
+ catch (error) {
796
+ const problem = badRequest(instance, requestId, error instanceof MetadataError ? error.message : "'metadata' is not usable.");
797
+ send(res, problem.status, problem.toJSON(), requestId);
798
+ return;
799
+ }
800
+ const queuedAt = process.hrtime.bigint();
205
801
  const outcome = await options.ingest.queue(auth, {
206
802
  layer,
207
803
  externalId,
208
804
  ...(typeof body_.title === 'string' ? { title: body_.title } : {}),
209
805
  ...(typeof content === 'string' ? { content } : {}),
210
806
  ...(typeof url_ === 'string' ? { url: url_ } : {}),
807
+ metadata,
808
+ });
809
+ // The accept stage only — parse, chunk and embed happen in the worker,
810
+ // which has no registry of its own. Labelled so the rest can join it
811
+ // later without changing the metric's meaning.
812
+ options.observe?.ingestDuration.observe(Number(process.hrtime.bigint() - queuedAt) / 1e9, {
813
+ stage: 'accept',
211
814
  });
212
815
  if (outcome === undefined) {
816
+ options.observe?.aclDenials.inc({ reason: 'ingest_layer' });
213
817
  // Not 403. A caller without write access must not learn which layers
214
818
  // exist by seeing which ones refuse differently from which ones are
215
819
  // absent.
@@ -218,6 +822,7 @@ async function handle(req, res, options) {
218
822
  actor: `${auth.principal.type}:${auth.principal.id}`,
219
823
  action: 'ingest',
220
824
  result: 'deny',
825
+ target: { layer },
221
826
  detail: { layer },
222
827
  requestId,
223
828
  });
@@ -230,6 +835,7 @@ async function handle(req, res, options) {
230
835
  actor: `${auth.principal.type}:${auth.principal.id}`,
231
836
  action: 'ingest',
232
837
  result: 'allow',
838
+ target: { layer, document_id: outcome.documentId },
233
839
  detail: { document_id: outcome.documentId, unchanged: outcome.unchanged },
234
840
  requestId,
235
841
  });
@@ -248,6 +854,7 @@ async function handle(req, res, options) {
248
854
  actor: `${auth.principal.type}:${auth.principal.id}`,
249
855
  action: 'delete_document',
250
856
  result: removed ? 'allow' : 'deny',
857
+ target: { document_id: id },
251
858
  detail: { document_id: id },
252
859
  requestId,
253
860
  });
@@ -256,8 +863,54 @@ async function handle(req, res, options) {
256
863
  send(res, problem.status, problem.toJSON(), requestId);
257
864
  return;
258
865
  }
259
- res.writeHead(204, { 'x-request-id': requestId });
260
- res.end();
866
+ send(res, 204, null, requestId);
867
+ return;
868
+ }
869
+ if (req.method === 'PATCH' && documentMatch) {
870
+ const id = decodeURIComponent(documentMatch[1]);
871
+ if (options.documents.updateMetadata === undefined) {
872
+ const problem = notFound(instance, requestId);
873
+ send(res, problem.status, problem.toJSON(), requestId);
874
+ return;
875
+ }
876
+ const body_ = (body ?? {});
877
+ if (body_.metadata === undefined) {
878
+ const problem = badRequest(instance, requestId, "'metadata' is required. It is the only field this endpoint changes: " +
879
+ 'content goes through POST /v1/documents, which re-indexes.');
880
+ send(res, problem.status, problem.toJSON(), requestId);
881
+ return;
882
+ }
883
+ let patch;
884
+ try {
885
+ patch = parseMetadata(body_.metadata);
886
+ }
887
+ catch (error) {
888
+ const problem = badRequest(instance, requestId, error instanceof MetadataError ? error.message : "'metadata' is not usable.");
889
+ send(res, problem.status, problem.toJSON(), requestId);
890
+ return;
891
+ }
892
+ const updated = await options.documents.updateMetadata(auth, id, patch);
893
+ await options.audit.write({
894
+ orgId: auth.orgId,
895
+ actor: `${auth.principal.type}:${auth.principal.id}`,
896
+ action: 'update_metadata',
897
+ result: updated ? 'allow' : 'deny',
898
+ target: { document_id: id },
899
+ // The keys, never the values. A tag can carry anything a caller puts in
900
+ // it, and the journal is read by more people than the document is.
901
+ detail: { document_id: id, keys: Object.keys(patch).sort() },
902
+ requestId,
903
+ });
904
+ if (!updated) {
905
+ const problem = notFound(instance, requestId);
906
+ send(res, problem.status, problem.toJSON(), requestId);
907
+ return;
908
+ }
909
+ // 204, not the updated document. Rule 6: `write` does not imply `read`,
910
+ // so a caller who may retag a document and not read it must not learn its
911
+ // title or its layer from a successful PATCH. `GET` is where the document
912
+ // is, for whoever may see it.
913
+ send(res, 204, null, requestId);
261
914
  return;
262
915
  }
263
916
  if (req.method === 'GET' && documentMatch) {
@@ -272,6 +925,7 @@ async function handle(req, res, options) {
272
925
  actor: `${auth.principal.type}:${auth.principal.id}`,
273
926
  action: 'get_document',
274
927
  result: 'deny',
928
+ target: { document_id: id },
275
929
  detail: { document_id: id },
276
930
  requestId,
277
931
  });
@@ -284,6 +938,7 @@ async function handle(req, res, options) {
284
938
  actor: `${auth.principal.type}:${auth.principal.id}`,
285
939
  action: 'get_document',
286
940
  result: 'allow',
941
+ target: { document_id: id },
287
942
  detail: { document_id: id },
288
943
  requestId,
289
944
  });
@@ -310,12 +965,88 @@ async function handle(req, res, options) {
310
965
  }, requestId);
311
966
  return;
312
967
  }
968
+ if (instance === '/v1/workspaces' && options.workspaces !== undefined) {
969
+ if (req.method === 'GET') {
970
+ const page = readPage(url.searchParams, instance, requestId);
971
+ if (page instanceof Problem) {
972
+ send(res, page.status, page.toJSON(), requestId);
973
+ return;
974
+ }
975
+ const { items, nextCursor } = await options.workspaces.list(auth, page);
976
+ // No audit event, for the same reason the layer listing has none: this
977
+ // returns what the caller may already reach, and one event per listing
978
+ // buries the ones that matter.
979
+ send(res, 200, {
980
+ items: items.map((w) => ({
981
+ id: w.id,
982
+ slug: w.slug,
983
+ name: w.name,
984
+ layer_count: w.layerCount,
985
+ created_at: w.createdAt,
986
+ })),
987
+ next_cursor: nextCursor,
988
+ }, requestId);
989
+ return;
990
+ }
991
+ if (req.method === 'POST') {
992
+ const body_ = (body ?? {});
993
+ const slug = body_.slug;
994
+ const name = body_.name;
995
+ if (typeof slug !== 'string' || typeof name !== 'string') {
996
+ const problem = badRequest(instance, requestId, "'slug' and 'name' are required.");
997
+ send(res, problem.status, problem.toJSON(), requestId);
998
+ return;
999
+ }
1000
+ const outcome = await options.workspaces.create(auth, { slug, name });
1001
+ await options.audit.write({
1002
+ orgId: auth.orgId,
1003
+ actor: `${auth.principal.type}:${auth.principal.id}`,
1004
+ action: 'create_workspace',
1005
+ result: outcome.kind === 'created' ? 'allow' : 'deny',
1006
+ detail: { slug, outcome: outcome.kind },
1007
+ requestId,
1008
+ });
1009
+ if (outcome.kind === 'denied') {
1010
+ // 404, not 403. Whether an organization has workspaces a caller
1011
+ // cannot administer is not something they are told.
1012
+ const problem = notFound(instance, requestId);
1013
+ send(res, problem.status, problem.toJSON(), requestId);
1014
+ return;
1015
+ }
1016
+ if (outcome.kind === 'conflict') {
1017
+ const problem = new Problem({
1018
+ type: 'https://nacre.work/errors/conflict',
1019
+ title: 'Conflict',
1020
+ status: 409,
1021
+ detail: `A workspace with the slug '${slug}' already exists in this organization.`,
1022
+ instance,
1023
+ requestId,
1024
+ });
1025
+ send(res, problem.status, problem.toJSON(), requestId);
1026
+ return;
1027
+ }
1028
+ const created = outcome.workspace;
1029
+ send(res, 201, {
1030
+ id: created.id,
1031
+ slug: created.slug,
1032
+ name: created.name,
1033
+ layer_count: created.layerCount,
1034
+ created_at: created.createdAt,
1035
+ }, requestId);
1036
+ return;
1037
+ }
1038
+ }
313
1039
  if (req.method === 'GET' && instance === '/v1/layers' && options.layers !== undefined) {
314
- const layers = await options.layers.list(auth);
1040
+ const page = readPage(url.searchParams, instance, requestId);
1041
+ if (page instanceof Problem) {
1042
+ send(res, page.status, page.toJSON(), requestId);
1043
+ return;
1044
+ }
1045
+ const { items, nextCursor } = await options.layers.list(auth, page);
315
1046
  // No audit event: this returns what the caller may already read, and one
316
1047
  // event per listing buries the ones that matter.
317
1048
  send(res, 200, {
318
- items: layers.map((l) => ({
1049
+ items: items.map((l) => ({
319
1050
  id: l.id,
320
1051
  slug: l.slug,
321
1052
  name: l.name,
@@ -323,6 +1054,7 @@ async function handle(req, res, options) {
323
1054
  description: l.description,
324
1055
  document_count: l.documentCount,
325
1056
  })),
1057
+ next_cursor: nextCursor,
326
1058
  }, requestId);
327
1059
  return;
328
1060
  }
@@ -336,7 +1068,18 @@ async function handle(req, res, options) {
336
1068
  send(res, problem.status, problem.toJSON(), requestId);
337
1069
  return;
338
1070
  }
339
- const outcome = await options.layers.create(auth, { workspaceId, slug, name });
1071
+ const providerId = body_.provider_id;
1072
+ if (providerId !== undefined && (typeof providerId !== 'string' || !/^[0-9a-f-]{36}$/i.test(providerId))) {
1073
+ const problem = badRequest(instance, requestId, "'provider_id' must be a uuid.");
1074
+ send(res, problem.status, problem.toJSON(), requestId);
1075
+ return;
1076
+ }
1077
+ const outcome = await options.layers.create(auth, {
1078
+ workspaceId,
1079
+ slug,
1080
+ name,
1081
+ ...(providerId === undefined ? {} : { providerId }),
1082
+ });
340
1083
  await options.audit.write({
341
1084
  orgId: auth.orgId,
342
1085
  actor: `${auth.principal.type}:${auth.principal.id}`,
@@ -352,6 +1095,11 @@ async function handle(req, res, options) {
352
1095
  send(res, problem.status, problem.toJSON(), requestId);
353
1096
  return;
354
1097
  }
1098
+ if (outcome.kind === 'provider') {
1099
+ const problem = badRequest(instance, requestId, outcome.detail);
1100
+ send(res, problem.status, problem.toJSON(), requestId);
1101
+ return;
1102
+ }
355
1103
  if (outcome.kind === 'conflict') {
356
1104
  const problem = new Problem({
357
1105
  type: 'https://nacre.work/errors/conflict',
@@ -376,8 +1124,13 @@ async function handle(req, res, options) {
376
1124
  return;
377
1125
  }
378
1126
  if (req.method === 'GET' && instance === '/v1/grants' && options.grants !== undefined) {
379
- const grants = await options.grants.list(auth);
380
- send(res, 200, { items: grants.map(grantJson) }, requestId);
1127
+ const page = readPage(url.searchParams, instance, requestId);
1128
+ if (page instanceof Problem) {
1129
+ send(res, page.status, page.toJSON(), requestId);
1130
+ return;
1131
+ }
1132
+ const { items, nextCursor } = await options.grants.list(auth, page);
1133
+ send(res, 200, { items: items.map(grantJson), next_cursor: nextCursor }, requestId);
381
1134
  return;
382
1135
  }
383
1136
  if (req.method === 'POST' && instance === '/v1/grants' && options.grants !== undefined) {
@@ -435,6 +1188,234 @@ async function handle(req, res, options) {
435
1188
  send(res, 204, null, requestId);
436
1189
  return;
437
1190
  }
1191
+ const layerMatch = /^\/v1\/layers\/([0-9a-f-]{36})$/i.exec(instance);
1192
+ if (req.method === 'PATCH' && layerMatch !== null) {
1193
+ const layerId = layerMatch[1];
1194
+ if (options.layers?.update === undefined) {
1195
+ const problem = notFound(instance, requestId);
1196
+ send(res, problem.status, problem.toJSON(), requestId);
1197
+ return;
1198
+ }
1199
+ const body_ = (body ?? {});
1200
+ const name = body_.name;
1201
+ const description = body_.description;
1202
+ if (name !== undefined && typeof name !== 'string') {
1203
+ const problem = badRequest(instance, requestId, "'name' must be a string.");
1204
+ send(res, problem.status, problem.toJSON(), requestId);
1205
+ return;
1206
+ }
1207
+ if (description !== undefined && typeof description !== 'string') {
1208
+ const problem = badRequest(instance, requestId, "'description' must be a string.");
1209
+ send(res, problem.status, problem.toJSON(), requestId);
1210
+ return;
1211
+ }
1212
+ if (name === undefined && description === undefined) {
1213
+ const problem = badRequest(instance, requestId, "One of 'name' or 'description' is required. The slug is not editable — " +
1214
+ 'clients address a layer by it — and the embedding model changes through ' +
1215
+ 'POST /v1/layers/{id}/reindex, which rebuilds the vectors.');
1216
+ send(res, problem.status, problem.toJSON(), requestId);
1217
+ return;
1218
+ }
1219
+ const updated = await options.layers.update(auth, layerId, {
1220
+ ...(typeof name === 'string' ? { name } : {}),
1221
+ ...(typeof description === 'string' ? { description } : {}),
1222
+ });
1223
+ await options.audit.write({
1224
+ orgId: auth.orgId,
1225
+ actor: `${auth.principal.type}:${auth.principal.id}`,
1226
+ action: 'update_layer',
1227
+ result: updated ? 'allow' : 'deny',
1228
+ target: { layer_id: layerId },
1229
+ detail: { layer_id: layerId, fields: Object.keys(body_).sort() },
1230
+ requestId,
1231
+ });
1232
+ if (!updated) {
1233
+ const problem = notFound(instance, requestId);
1234
+ send(res, problem.status, problem.toJSON(), requestId);
1235
+ return;
1236
+ }
1237
+ send(res, 204, null, requestId);
1238
+ return;
1239
+ }
1240
+ // `/v1/layers/{id}/reindex`
1241
+ const reindexPath = /^\/v1\/layers\/([0-9a-f-]{36})\/reindex$/i.exec(instance);
1242
+ if (reindexPath !== null) {
1243
+ const layerId = reindexPath[1];
1244
+ if (options.reindex === undefined || (req.method !== 'POST' && req.method !== 'GET')) {
1245
+ const problem = notFound(instance, requestId);
1246
+ send(res, problem.status, problem.toJSON(), requestId);
1247
+ return;
1248
+ }
1249
+ if (req.method === 'GET') {
1250
+ const status = await options.reindex.status(auth, layerId);
1251
+ if (status === undefined) {
1252
+ // No reindex, no such layer, and no permission are one answer. The
1253
+ // first is not a permission fact and the other two must not be
1254
+ // separable, so all three are 404 — see invariant 4.
1255
+ const problem = notFound(instance, requestId);
1256
+ send(res, problem.status, problem.toJSON(), requestId);
1257
+ return;
1258
+ }
1259
+ send(res, 200, reindexJson(status), requestId);
1260
+ return;
1261
+ }
1262
+ const providerId = body?.provider_id;
1263
+ if (typeof providerId !== 'string' || !/^[0-9a-f-]{36}$/i.test(providerId)) {
1264
+ const problem = badRequest(instance, requestId, "'provider_id' must be a uuid.");
1265
+ send(res, problem.status, problem.toJSON(), requestId);
1266
+ return;
1267
+ }
1268
+ const outcome = await options.reindex.start(auth, layerId, providerId);
1269
+ if (outcome === undefined) {
1270
+ const problem = notFound(instance, requestId);
1271
+ send(res, problem.status, problem.toJSON(), requestId);
1272
+ return;
1273
+ }
1274
+ if (outcome.kind === 'unknown_provider') {
1275
+ // 400 and not 404: the caller has already proved they administer this
1276
+ // layer, so this is a statement about their request rather than about
1277
+ // what exists. A provider is installation-level configuration, not a
1278
+ // tenant object with visibility rules of its own.
1279
+ const problem = badRequest(instance, requestId, 'No embedding provider with that id is available to this organization.');
1280
+ send(res, problem.status, problem.toJSON(), requestId);
1281
+ return;
1282
+ }
1283
+ if (outcome.kind === 'already_current') {
1284
+ const problem = badRequest(instance, requestId, `This layer already uses ${outcome.vectorName}. Reindexing onto the model it ` +
1285
+ 'is on would rewrite every vector to the same values.');
1286
+ send(res, problem.status, problem.toJSON(), requestId);
1287
+ return;
1288
+ }
1289
+ if (outcome.kind === 'conflict') {
1290
+ const problem = new Problem({
1291
+ type: 'https://nacre.work/errors/conflict',
1292
+ title: 'Conflict',
1293
+ status: 409,
1294
+ detail: `A reindex onto ${outcome.status.shadowVector} is already running for this layer. ` +
1295
+ 'Wait for it, or let it fail — two at once would race to switch vector_name.',
1296
+ instance,
1297
+ requestId,
1298
+ });
1299
+ // The problem body and not the status object: a 409 is an error
1300
+ // response and RFC 9457 says what shape one has. `GET` on this same
1301
+ // path is where the state lives, and the detail above names what to
1302
+ // wait for.
1303
+ send(res, problem.status, problem.toJSON(), requestId);
1304
+ return;
1305
+ }
1306
+ await options.audit?.write({
1307
+ orgId: auth.orgId,
1308
+ actor: `${auth.principal.type}:${auth.principal.id}`,
1309
+ action: 'layer.reindex',
1310
+ result: 'allow',
1311
+ surface: 'api',
1312
+ target: { layer_id: layerId, shadow_vector: outcome.status.shadowVector },
1313
+ detail: { provider_id: providerId, total: outcome.status.total },
1314
+ requestId,
1315
+ });
1316
+ // 202: the work happens in the worker. docs/api.md says an operation that
1317
+ // outlives its request answers 202 with somewhere to poll, which is this
1318
+ // same path with GET.
1319
+ send(res, 202, reindexJson(outcome.status), requestId);
1320
+ return;
1321
+ }
1322
+ if (instance === '/v1/audit') {
1323
+ // 404 rather than 403 for a caller who may not read it, and rather than
1324
+ // 405 for a method this path does not have. Invariant 4 reserves 403 for
1325
+ // an operation forbidden on an object the caller can already see, and
1326
+ // whether an organization keeps an audit log is not something a member is
1327
+ // told. The contract published this answer before the endpoint existed.
1328
+ if (options.auditReader === undefined || req.method !== 'GET') {
1329
+ const problem = notFound(instance, requestId);
1330
+ send(res, problem.status, problem.toJSON(), requestId);
1331
+ return;
1332
+ }
1333
+ // Two roles, two different logs, and the difference is rule 2 rather than
1334
+ // a convenience.
1335
+ //
1336
+ // `org_admin` administers the tenant and sees its log in full — including
1337
+ // which documents were read, which is the question docs/audit.md opens
1338
+ // with. `platform_admin` administers the *installation*: it sees grants
1339
+ // issued, accounts created, configuration changed, and deliberately not
1340
+ // the record of who read what. A platform administrator who can read
1341
+ // every tenant's document-access log has the access the permission model
1342
+ // spends its whole effort denying, obtained through the journal that
1343
+ // exists to prove they did not.
1344
+ //
1345
+ // On a single-organization community install the two sit in the same
1346
+ // organization and this reads as an odd distinction. It is not for this
1347
+ // build; it is for the multi-tenancy module, which inherits this endpoint
1348
+ // and where a platform administrator spans tenants. Writing the rule now
1349
+ // costs three lines. Retrofitting it later means auditing every caller.
1350
+ if (auth.role !== 'org_admin' && auth.role !== 'platform_admin') {
1351
+ const problem = notFound(instance, requestId);
1352
+ send(res, problem.status, problem.toJSON(), requestId);
1353
+ return;
1354
+ }
1355
+ const page = readPage(url.searchParams, instance, requestId);
1356
+ if (page instanceof Problem) {
1357
+ send(res, page.status, page.toJSON(), requestId);
1358
+ return;
1359
+ }
1360
+ const query = readAuditQuery(url.searchParams, instance, requestId);
1361
+ if (query instanceof Problem) {
1362
+ send(res, query.status, query.toJSON(), requestId);
1363
+ return;
1364
+ }
1365
+ const format = auditFormat(req.headers.accept);
1366
+ if (format === undefined) {
1367
+ const problem = new Problem({
1368
+ type: 'https://nacre.work/errors/not-acceptable',
1369
+ title: 'Not acceptable',
1370
+ status: 406,
1371
+ detail: 'This endpoint serves application/json, application/x-ndjson and text/csv. ' +
1372
+ 'Ask for one of those, or send no Accept header for JSON.',
1373
+ instance,
1374
+ requestId,
1375
+ });
1376
+ send(res, problem.status, problem.toJSON(), requestId);
1377
+ return;
1378
+ }
1379
+ const { items, nextCursor } = await options.auditReader.read(auth,
1380
+ // `administrativeOnly` is set here and never read from the request. A
1381
+ // caller cannot widen their own view by omitting a parameter, which is
1382
+ // the shape this would take if it were a query filter.
1383
+ { ...query, administrativeOnly: auth.role === 'platform_admin' }, page);
1384
+ // Reading the log is itself an access worth recording. It is the one
1385
+ // action where leaving it out is self-serving: an administrator who can
1386
+ // read who-read-what without that read appearing is a hole in exactly the
1387
+ // guarantee this endpoint exists to provide.
1388
+ await options.audit?.write({
1389
+ orgId: auth.orgId,
1390
+ actor: `${auth.principal.type}:${auth.principal.id}`,
1391
+ action: 'audit.read',
1392
+ result: 'allow',
1393
+ surface: 'api',
1394
+ target: { format, returned: items.length, ...query },
1395
+ detail: {},
1396
+ requestId,
1397
+ });
1398
+ if (format === 'json') {
1399
+ send(res, 200, { items: items.map(auditJson), next_cursor: nextCursor }, requestId);
1400
+ return;
1401
+ }
1402
+ // JSONL and CSV are exports, so they carry no cursor — a client streaming
1403
+ // to a file has nowhere to put one. `Link` is where the contract puts it,
1404
+ // which keeps the body a clean stream of records rather than a stream
1405
+ // with a footer that every consumer has to know to strip.
1406
+ const headers = {
1407
+ 'content-type': format === 'ndjson' ? 'application/x-ndjson' : 'text/csv; charset=utf-8',
1408
+ 'x-request-id': requestId,
1409
+ };
1410
+ if (nextCursor !== null) {
1411
+ const next = new URL(url.href);
1412
+ next.searchParams.set('cursor', nextCursor);
1413
+ headers.link = `<${next.pathname}${next.search}>; rel="next"`;
1414
+ }
1415
+ res.writeHead(200, headers);
1416
+ res.end(format === 'ndjson' ? toNdjson(items) : toCsv(items));
1417
+ return;
1418
+ }
438
1419
  if (instance === '/v1/service-accounts' && options.serviceAccounts !== undefined) {
439
1420
  // org_admin, not "admin on some scope". A service account is a principal
440
1421
  // in the organization rather than an object inside a workspace, and there
@@ -446,7 +1427,13 @@ async function handle(req, res, options) {
446
1427
  return;
447
1428
  }
448
1429
  if (req.method === 'GET') {
449
- send(res, 200, { items: (await options.serviceAccounts.list(auth)).map(accountJson) }, requestId);
1430
+ const page = readPage(url.searchParams, instance, requestId);
1431
+ if (page instanceof Problem) {
1432
+ send(res, page.status, page.toJSON(), requestId);
1433
+ return;
1434
+ }
1435
+ const { items, nextCursor } = await options.serviceAccounts.list(auth, page);
1436
+ send(res, 200, { items: items.map(accountJson), next_cursor: nextCursor }, requestId);
450
1437
  return;
451
1438
  }
452
1439
  if (req.method === 'POST') {
@@ -456,7 +1443,31 @@ async function handle(req, res, options) {
456
1443
  send(res, problem.status, problem.toJSON(), requestId);
457
1444
  return;
458
1445
  }
459
- const { account, key } = await options.serviceAccounts.create(auth, name.trim());
1446
+ const created = await options.serviceAccounts.create(auth, name.trim());
1447
+ if (created === undefined) {
1448
+ // 409, not 500. The name is unique per organization and a duplicate
1449
+ // is something the caller typed — it surfaced as an internal error
1450
+ // with the constraint name in the log and nothing on screen.
1451
+ await options.audit.write({
1452
+ orgId: auth.orgId,
1453
+ actor: `${auth.principal.type}:${auth.principal.id}`,
1454
+ action: 'create_service_account',
1455
+ result: 'deny',
1456
+ detail: { name: name.trim(), reason: 'name taken' },
1457
+ requestId,
1458
+ });
1459
+ const problem = new Problem({
1460
+ type: 'https://nacre.work/errors/conflict',
1461
+ title: 'Conflict',
1462
+ status: 409,
1463
+ detail: `A service account named '${name.trim()}' already exists in this organization.`,
1464
+ instance,
1465
+ requestId,
1466
+ });
1467
+ send(res, problem.status, problem.toJSON(), requestId);
1468
+ return;
1469
+ }
1470
+ const { account, key } = created;
460
1471
  await options.audit.write({
461
1472
  orgId: auth.orgId,
462
1473
  actor: `${auth.principal.type}:${auth.principal.id}`,