@nacre.work/api 0.1.0 → 0.3.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.
package/dist/server.js CHANGED
@@ -3,7 +3,7 @@ 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
+ import { logger, MetadataError, MultipartError, multipartBoundary, parseMultipart, queryAudit, parseFilters, parseMetadata, PROTECTED_RESOURCE_PATH, JWKS_PATH, ADMIN_PREFIX, adminRoutes, withAuditSinks, TooBusy, } from '@nacre.work/core';
7
7
  import { authenticate, rejectTenantOverride } from './auth.js';
8
8
  import { badRequest, internal, notFound, Problem } from './errors.js';
9
9
  import { isConflict, isReplay } from './idempotency.js';
@@ -114,7 +114,7 @@ function parseGrant(body) {
114
114
  permission: permission,
115
115
  };
116
116
  }
117
- async function readBody(req, limit = MAX_BODY_BYTES) {
117
+ async function readRaw(req, limit) {
118
118
  const chunks = [];
119
119
  let size = 0;
120
120
  for await (const chunk of req) {
@@ -123,9 +123,55 @@ async function readBody(req, limit = MAX_BODY_BYTES) {
123
123
  throw new BodyTooLarge('body too large');
124
124
  chunks.push(chunk);
125
125
  }
126
- if (chunks.length === 0)
126
+ return Buffer.concat(chunks);
127
+ }
128
+ async function readBody(req, limit = MAX_BODY_BYTES) {
129
+ const raw = await readRaw(req, limit);
130
+ if (raw.length === 0)
127
131
  return undefined;
128
- return JSON.parse(Buffer.concat(chunks).toString('utf8'));
132
+ return JSON.parse(raw.toString('utf8'));
133
+ }
134
+ /**
135
+ * A multipart body, reduced to the same shape a JSON one has.
136
+ *
137
+ * Two things depend on this being a plain object of fields rather than a
138
+ * special case threaded through the handler.
139
+ *
140
+ * The first is T2. `rejectTenantOverride` scans the body for an organization
141
+ * named at any depth, before routing and before validation, and it runs on
142
+ * whatever `body` is. A multipart request whose fields never became `body`
143
+ * would be a second door into the ingest endpoint with that check on the other
144
+ * side of it — which is exactly the shape of hole the rate limiter and the
145
+ * metrics each had when MCP was a second surface.
146
+ *
147
+ * The second is that everything downstream stays one code path: the same
148
+ * required-field checks, the same metadata parsing, the same audit event.
149
+ *
150
+ * The file is kept out of it. Its bytes are not a field, and putting a
151
+ * document body into an object that gets scanned, logged and error-messaged is
152
+ * how content ends up somewhere it should not be.
153
+ */
154
+ function multipartBody(parts) {
155
+ const fields = {};
156
+ let file;
157
+ for (const part of parts) {
158
+ // The file is the part with a filename, or the one called `file` — which
159
+ // is what openapi.yaml names it and what every form sends.
160
+ if (part.filename !== undefined || part.name === 'file') {
161
+ if (file !== undefined) {
162
+ throw new MultipartError('more than one file part; a document is one file');
163
+ }
164
+ file = part;
165
+ continue;
166
+ }
167
+ if (part.name in fields) {
168
+ // Refused rather than last-wins. A repeated field is a caller who
169
+ // believes something different from what would be stored.
170
+ throw new MultipartError(`the field ${part.name} appears more than once`);
171
+ }
172
+ fields[part.name] = Buffer.from(part.bytes).toString('utf8');
173
+ }
174
+ return { fields, ...(file === undefined ? {} : { file }) };
129
175
  }
130
176
  /** The wire shape of a reindex, snake case like every other response here. */
131
177
  function reindexJson(status) {
@@ -146,8 +192,84 @@ function reindexJson(status) {
146
192
  failed: status.failed,
147
193
  progress: status.progress,
148
194
  error: status.error,
195
+ // `null` and not omitted. Absent would read as "this deployment does not do
196
+ // recall checks"; null says "this migration has not been scored", which for
197
+ // a layer with no reference set is the permanent and correct answer.
198
+ check: status.check === null
199
+ ? null
200
+ : {
201
+ recall: status.check.recall,
202
+ floor: status.check.floor,
203
+ passed: status.check.passed,
204
+ queries: status.check.queries,
205
+ scores: status.check.scores.map((s) => ({ query_id: s.queryId, recall: s.recall })),
206
+ ...(status.check.unresolved === undefined
207
+ ? {}
208
+ : { unresolved: [...status.check.unresolved] }),
209
+ },
149
210
  };
150
211
  }
212
+ /** One reference query on the wire, snake case like every other response here. */
213
+ function referenceQueryJson(q) {
214
+ return { id: q.id, query: q.query, expected: [...q.expected] };
215
+ }
216
+ /** At most this many queries in a set, and this many expected documents in one. */
217
+ const MAX_REFERENCE_QUERIES = 50;
218
+ const MAX_EXPECTED_PER_QUERY = 10;
219
+ /**
220
+ * The reference set from a request body.
221
+ *
222
+ * Every bound here is a refusal rather than a truncation, for the reason the
223
+ * multipart parser gives: a truncation is a silent disagreement between what
224
+ * was sent and what got stored, and this one would be measured later as a
225
+ * recall number the operator cannot reconcile with what they wrote.
226
+ *
227
+ * `MAX_EXPECTED_PER_QUERY` is not a size limit, it is `RECALL_K`. A query
228
+ * naming more expected documents than the check retrieves could never score
229
+ * 1.0, so its floor would be unreachable and would read as a regression in the
230
+ * model rather than as a mistake in the set.
231
+ *
232
+ * An empty list is accepted and means "no gate on this layer", which is how a
233
+ * set is removed. Refusing it would leave no way back from having written one.
234
+ */
235
+ function parseReferenceQueries(body) {
236
+ if (typeof body !== 'object' || body === null || Array.isArray(body)) {
237
+ return { error: 'The body must be an object with a "queries" array.' };
238
+ }
239
+ const raw = body['queries'];
240
+ if (!Array.isArray(raw))
241
+ return { error: "'queries' must be an array." };
242
+ if (raw.length > MAX_REFERENCE_QUERIES) {
243
+ return { error: `A reference set may hold at most ${MAX_REFERENCE_QUERIES} queries.` };
244
+ }
245
+ const queries = [];
246
+ for (const [i, entry] of raw.entries()) {
247
+ if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) {
248
+ return { error: `queries[${i}] must be an object.` };
249
+ }
250
+ const { query, expected } = entry;
251
+ if (typeof query !== 'string' || query.trim() === '' || query.length > 1024) {
252
+ return { error: `queries[${i}].query must be a string of 1 to 1024 characters.` };
253
+ }
254
+ if (!Array.isArray(expected) || expected.length === 0) {
255
+ return { error: `queries[${i}].expected must be a non-empty array of external ids.` };
256
+ }
257
+ if (expected.length > MAX_EXPECTED_PER_QUERY) {
258
+ return {
259
+ error: `queries[${i}].expected may name at most ${MAX_EXPECTED_PER_QUERY} documents, ` +
260
+ 'which is how many the check retrieves. A longer list could never score 1.0.',
261
+ };
262
+ }
263
+ if (!expected.every((e) => typeof e === 'string' && e !== '')) {
264
+ return { error: `queries[${i}].expected must hold non-empty external ids.` };
265
+ }
266
+ // Deduplicated rather than refused: a repeated id is a typo with an obvious
267
+ // reading, and leaving it in would divide the score by a denominator the
268
+ // caller did not mean.
269
+ queries.push({ query, expected: [...new Set(expected)] });
270
+ }
271
+ return { queries };
272
+ }
151
273
  function send(res, status, body, requestId, extra = {}) {
152
274
  // 204 means no content, and it meant it while this function wrote the four
153
275
  // bytes `null` into the body. Some clients tolerate that and some treat a
@@ -381,7 +503,7 @@ async function handleAuth(req, res, instance, requestId, options) {
381
503
  // The address, never the password, and never how far the attempt got —
382
504
  // "no such user" and "wrong password" must not be tellable apart from a
383
505
  // log any more than from a response.
384
- console.warn(JSON.stringify({ msg: 'sign-in refused', email: email.trim().toLowerCase(), request_id: requestId }));
506
+ logger.warn('sign-in refused', { email: email.trim().toLowerCase(), request_id: requestId });
385
507
  refuse();
386
508
  return;
387
509
  }
@@ -433,8 +555,22 @@ function tokenJson(tokens) {
433
555
  };
434
556
  }
435
557
  export function createApi(options) {
558
+ // Applied once, here, so every event this surface records reaches a module's
559
+ // sinks whatever adapter is behind the port. It used to live inside the
560
+ // Postgres adapter, which made forwarding a property of how an event was
561
+ // stored rather than of it having been stored.
562
+ const withSinks = {
563
+ ...options,
564
+ audit: withAuditSinks(options.audit, (sink, event, error) => {
565
+ logger.warn('audit sink failed; the event is still in the table', {
566
+ sink,
567
+ action: event.action,
568
+ error: String(error).slice(0, 200),
569
+ });
570
+ }),
571
+ };
436
572
  return createServer((req, res) => {
437
- void handle(req, res, options).catch(() => {
573
+ void handle(req, res, withSinks).catch(() => {
438
574
  // handle() converts everything it can into a Problem. Reaching here means
439
575
  // the failure was in the error path itself; say nothing about it.
440
576
  if (!res.headersSent)
@@ -502,6 +638,25 @@ async function handle(req, res, options) {
502
638
  send(res, 200, options.resourceMetadata, requestId);
503
639
  return;
504
640
  }
641
+ if (req.method === 'GET' && instance === JWKS_PATH) {
642
+ // The public half of the signing key, so anything outside this process can
643
+ // verify a token without a secret — a gateway, a sidecar, a second service
644
+ // in the same deployment.
645
+ //
646
+ // Unauthenticated, like every other `/.well-known` document, and that is
647
+ // not a concession: a public key is public. What would be a leak is the
648
+ // other mode, which is exactly why this answers `404` for a deployment
649
+ // signing with `NACRE_JWT_SECRET`. A shared secret has no publishable half,
650
+ // and an endpoint that "helpfully" served one would be serving the key that
651
+ // mints tokens.
652
+ if (options.jwks === undefined) {
653
+ const problem = notFound(instance, requestId);
654
+ send(res, problem.status, problem.toJSON(), requestId);
655
+ return;
656
+ }
657
+ send(res, 200, { keys: options.jwks }, requestId);
658
+ return;
659
+ }
505
660
  if (req.method === 'GET' && instance === '/v1/health') {
506
661
  // Liveness touches no dependency. A health check that calls Postgres turns
507
662
  // one slow database into a cascading restart loop.
@@ -570,8 +725,21 @@ async function handle(req, res, options) {
570
725
  return;
571
726
  }
572
727
  let body;
728
+ // Held aside from `body` on purpose — see multipartBody.
729
+ let uploaded;
730
+ let wasMultipart = false;
573
731
  try {
574
- body = await readBody(req, options.maxBodyBytes ?? MAX_BODY_BYTES);
732
+ const limit = options.maxBodyBytes ?? MAX_BODY_BYTES;
733
+ const boundary = multipartBoundary(req.headers['content-type']);
734
+ if (boundary === undefined) {
735
+ body = await readBody(req, limit);
736
+ }
737
+ else {
738
+ const reduced = multipartBody(parseMultipart(await readRaw(req, limit), boundary));
739
+ body = reduced.fields;
740
+ uploaded = reduced.file;
741
+ wasMultipart = true;
742
+ }
575
743
  }
576
744
  catch (error) {
577
745
  // 413 for size and 400 for anything else. They were one answer, and the
@@ -586,7 +754,12 @@ async function handle(req, res, options) {
586
754
  instance,
587
755
  requestId,
588
756
  })
589
- : badRequest(instance, requestId, 'The request body could not be read.');
757
+ : error instanceof MultipartError
758
+ ? // Named, because every one of them is a caller mistake with a fix,
759
+ // and "the request body could not be read" sends them looking at
760
+ // their bytes rather than at their boundary.
761
+ badRequest(instance, requestId, `${error.message}.`)
762
+ : badRequest(instance, requestId, 'The request body could not be read.');
590
763
  send(res, problem.status, problem.toJSON(), requestId);
591
764
  return;
592
765
  }
@@ -736,7 +909,8 @@ async function handle(req, res, options) {
736
909
  // rerank — because that is what a caller waits for and what the p95
737
910
  // target in docs/config.md is about. It was never observed at all, so the
738
911
  // histogram rendered no series and the target was unmeasurable.
739
- options.observe?.searchDuration.observe(Number(process.hrtime.bigint() - started) / 1e9);
912
+ const elapsedNs = Number(process.hrtime.bigint() - started);
913
+ options.observe?.searchDuration.observe(elapsedNs / 1e9);
740
914
  options.observe?.searchResults.inc({}, results.length);
741
915
  if (results.length === 0) {
742
916
  // Zero permitted results is what a denial looks like on this endpoint:
@@ -751,15 +925,25 @@ async function handle(req, res, options) {
751
925
  result: 'allow',
752
926
  // `docs/audit.md` opens by promising that "show me which documents your
753
927
  // 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.
928
+ // ids, and this wrote a count.
757
929
  target: {
758
930
  returned_docs: [...new Set(results.map((r) => r.doc_id))],
759
931
  layers: [...new Set(results.map((r) => r.layer))],
760
932
  top_k: boundedTopK(request.top_k),
761
933
  },
762
- detail: { returned: results.length },
934
+ // The hash always, the text only where a deployment asked for it. The
935
+ // same call on the MCP side, so one search leaves one shape of record
936
+ // whichever door it came through.
937
+ // `latency_ms` is in the documented shape of a search event and was
938
+ // not written either. The number is already measured for the histogram
939
+ // one line above; it just never reached the journal, where it is what
940
+ // makes "this search was slow" answerable per caller rather than only
941
+ // as a percentile.
942
+ detail: {
943
+ returned: results.length,
944
+ latency_ms: Math.round(elapsedNs / 1e6),
945
+ ...queryAudit(query, options.auditQueryText === true),
946
+ },
763
947
  requestId,
764
948
  });
765
949
  send(res, 200, { items: results }, requestId);
@@ -768,8 +952,47 @@ async function handle(req, res, options) {
768
952
  if (req.method === 'POST' && instance === '/v1/documents') {
769
953
  const body_ = (body ?? {});
770
954
  const layer = body_.layer;
771
- const externalId = body_.external_id;
772
- const content = body_.content;
955
+ // A file part is content. The external id defaults to its filename,
956
+ // because a form that uploads `q3-plan.md` has already said what the
957
+ // document is called and asking for the same string twice is how a
958
+ // client ends up with two names for one document.
959
+ //
960
+ // The filename is used for that and for nothing else. It never reaches a
961
+ // path, and never an object key — `documentKey` hashes the external id,
962
+ // so a caller cannot choose the shape of anything in the bucket.
963
+ const externalId = typeof body_.external_id === 'string'
964
+ ? body_.external_id
965
+ : (uploaded?.filename ?? undefined);
966
+ let content = body_.content;
967
+ if (uploaded !== undefined) {
968
+ if (typeof content === 'string' || typeof body_.url === 'string') {
969
+ const problem = badRequest(instance, requestId, "A multipart upload carries the document; 'content' and 'url' are for the JSON body.");
970
+ send(res, problem.status, problem.toJSON(), requestId);
971
+ return;
972
+ }
973
+ // Decoded here, and refused here, rather than queued and failed later.
974
+ //
975
+ // The parser this feeds extracts no binary formats — it is stdlib-only
976
+ // on purpose, since it runs hostile input through whatever it depends
977
+ // on. Until this check existed the sidecar decoded with
978
+ // `errors="replace"`, so a PDF became a string of replacement
979
+ // characters that was chunked, embedded, stored as the document body
980
+ // and reported as indexed.
981
+ //
982
+ // At the edge the caller learns immediately and nothing is queued. Deep
983
+ // in the worker they would have learned from a `failed` row minutes
984
+ // later, if they looked.
985
+ const decoder = new TextDecoder('utf-8', { fatal: true });
986
+ try {
987
+ content = decoder.decode(uploaded.bytes);
988
+ }
989
+ catch {
990
+ const problem = badRequest(instance, requestId, 'The uploaded file is not UTF-8 text. This installation extracts no binary formats — ' +
991
+ 'a PDF, a Word file or an image needs an extractor the parser deliberately does not carry.');
992
+ send(res, problem.status, problem.toJSON(), requestId);
993
+ return;
994
+ }
995
+ }
773
996
  const url_ = body_.url;
774
997
  if (typeof layer !== 'string' || typeof externalId !== 'string') {
775
998
  const problem = badRequest(instance, requestId, "'layer' and 'external_id' are required.");
@@ -788,9 +1011,28 @@ async function handle(req, res, options) {
788
1011
  // a document, got 202, and the tag existed nowhere. Refused rather than
789
1012
  // trimmed when it is malformed — a dropped key is a document the caller
790
1013
  // believes is tagged and a filter that will never match it.
1014
+ // Every multipart field is a string, so `metadata` arrives as JSON text
1015
+ // where the JSON body carries an object. Parsed here rather than taught
1016
+ // to parseMetadata, which is shared with `PATCH` and with MCP and should
1017
+ // keep meaning one thing.
1018
+ //
1019
+ // Found by running it: the first version of this branch answered 400 for
1020
+ // a perfectly good `metadata` field, because the string never became an
1021
+ // object.
1022
+ let rawMetadata = body_.metadata;
1023
+ if (wasMultipart && typeof rawMetadata === 'string') {
1024
+ try {
1025
+ rawMetadata = JSON.parse(rawMetadata);
1026
+ }
1027
+ catch {
1028
+ const problem = badRequest(instance, requestId, "The 'metadata' field is not JSON. In a multipart upload it carries a JSON object as text.");
1029
+ send(res, problem.status, problem.toJSON(), requestId);
1030
+ return;
1031
+ }
1032
+ }
791
1033
  let metadata;
792
1034
  try {
793
- metadata = parseMetadata(body_.metadata);
1035
+ metadata = parseMetadata(rawMetadata);
794
1036
  }
795
1037
  catch (error) {
796
1038
  const problem = badRequest(instance, requestId, error instanceof MetadataError ? error.message : "'metadata' is not usable.");
@@ -830,6 +1072,32 @@ async function handle(req, res, options) {
830
1072
  send(res, problem.status, problem.toJSON(), requestId);
831
1073
  return;
832
1074
  }
1075
+ if ('refused' in outcome) {
1076
+ // A module's ingest gate declined a document the caller may write — a
1077
+ // quota, a suspension. Not a 404: the layer is not being hidden, the
1078
+ // caller was allowed to write against it. Recorded as a denial so an
1079
+ // operator can see quota-refused ingests; the gate's reason is the
1080
+ // detail, and the gate chose the status.
1081
+ await options.audit.write({
1082
+ orgId: auth.orgId,
1083
+ actor: `${auth.principal.type}:${auth.principal.id}`,
1084
+ action: 'ingest',
1085
+ result: 'deny',
1086
+ target: { layer },
1087
+ detail: { layer, reason: outcome.reason },
1088
+ requestId,
1089
+ });
1090
+ const problem = new Problem({
1091
+ type: 'https://nacre.work/errors/ingest-refused',
1092
+ title: outcome.status === 429 ? 'Too many requests' : 'Forbidden',
1093
+ status: outcome.status,
1094
+ detail: outcome.reason,
1095
+ instance,
1096
+ requestId,
1097
+ });
1098
+ send(res, outcome.status, problem.toJSON(), requestId);
1099
+ return;
1100
+ }
833
1101
  await options.audit.write({
834
1102
  orgId: auth.orgId,
835
1103
  actor: `${auth.principal.type}:${auth.principal.id}`,
@@ -1237,6 +1505,107 @@ async function handle(req, res, options) {
1237
1505
  send(res, 204, null, requestId);
1238
1506
  return;
1239
1507
  }
1508
+ // `/v1/admin/...` — routes a commercial module mounted.
1509
+ //
1510
+ // After authentication and after `rejectTenantOverride`, deliberately. A
1511
+ // module gets an already-authenticated principal and a body that has
1512
+ // already been scanned for a tenant override, so invariants 1 and 2 hold
1513
+ // for its routes without it having to know they exist. It cannot opt out of
1514
+ // either, because it never sees the request before this point.
1515
+ //
1516
+ // `platform_admin` and `org_admin` only. There is no module-supplied role
1517
+ // check to get wrong: an administrative surface is administrative, and a
1518
+ // member reaching one would be a widening decided in the closed half.
1519
+ if (instance.startsWith(ADMIN_PREFIX)) {
1520
+ // Role before route lookup. Both answer the same 404, so this is not
1521
+ // about what a caller can tell apart — it is that a member must not
1522
+ // reach module code at all, and "the module happened to have no matching
1523
+ // route" is not a reason to be safe.
1524
+ if (auth.role !== 'platform_admin' && auth.role !== 'org_admin') {
1525
+ const problem = notFound(instance, requestId);
1526
+ send(res, problem.status, problem.toJSON(), requestId);
1527
+ return;
1528
+ }
1529
+ const route = adminRoutes().find((r) => r.method === req.method && r.pattern.test(instance));
1530
+ if (route === undefined) {
1531
+ // 404 whether nothing is mounted or nothing matched — the two are the
1532
+ // same answer, and a deployment without the module must not be
1533
+ // distinguishable from one where the path is simply wrong.
1534
+ const problem = notFound(instance, requestId);
1535
+ send(res, problem.status, problem.toJSON(), requestId);
1536
+ return;
1537
+ }
1538
+ const url = new URL(req.url ?? '/', 'http://internal');
1539
+ const matched = route.pattern.exec(instance);
1540
+ const answer = await route.handle({
1541
+ method: req.method ?? 'GET',
1542
+ path: instance,
1543
+ params: (matched ?? []).slice(1),
1544
+ query: url.searchParams,
1545
+ body,
1546
+ auth,
1547
+ });
1548
+ await options.audit.write({
1549
+ orgId: auth.orgId,
1550
+ actor: `${auth.principal.type}:${auth.principal.id}`,
1551
+ action: `admin.${req.method?.toLowerCase() ?? 'get'}`,
1552
+ surface: 'admin',
1553
+ result: answer.status < 400 ? 'allow' : 'deny',
1554
+ target: { path: instance },
1555
+ detail: { status: answer.status },
1556
+ requestId,
1557
+ });
1558
+ send(res, answer.status, answer.body ?? null, requestId);
1559
+ return;
1560
+ }
1561
+ // `/v1/layers/{id}/reference-queries`
1562
+ const referencePath = /^\/v1\/layers\/([0-9a-f-]{36})\/reference-queries$/i.exec(instance);
1563
+ if (referencePath !== null) {
1564
+ const layerId = referencePath[1];
1565
+ if (options.referenceQueries === undefined || (req.method !== 'GET' && req.method !== 'PUT')) {
1566
+ const problem = notFound(instance, requestId);
1567
+ send(res, problem.status, problem.toJSON(), requestId);
1568
+ return;
1569
+ }
1570
+ if (req.method === 'GET') {
1571
+ const found = await options.referenceQueries.list(auth, layerId);
1572
+ if (found === undefined) {
1573
+ // No such layer and no permission to administer it, one answer.
1574
+ const problem = notFound(instance, requestId);
1575
+ send(res, problem.status, problem.toJSON(), requestId);
1576
+ return;
1577
+ }
1578
+ send(res, 200, { items: found.map(referenceQueryJson) }, requestId);
1579
+ return;
1580
+ }
1581
+ const parsed = parseReferenceQueries(body);
1582
+ if ('error' in parsed) {
1583
+ const problem = badRequest(instance, requestId, parsed.error);
1584
+ send(res, problem.status, problem.toJSON(), requestId);
1585
+ return;
1586
+ }
1587
+ const replaced = await options.referenceQueries.replace(auth, layerId, parsed.queries);
1588
+ await options.audit.write({
1589
+ orgId: auth.orgId,
1590
+ actor: `${auth.principal.type}:${auth.principal.id}`,
1591
+ action: 'reference_queries.replace',
1592
+ result: replaced === undefined ? 'deny' : 'allow',
1593
+ target: { layer_id: layerId },
1594
+ // The count and never the queries. They are the operator's own text
1595
+ // rather than a caller's search, so this is not the rule about query
1596
+ // text — but the journal is read by more people than the endpoint is,
1597
+ // and a document's title has already reached it once by this route.
1598
+ detail: { layer_id: layerId, queries: parsed.queries.length },
1599
+ requestId,
1600
+ });
1601
+ if (replaced === undefined) {
1602
+ const problem = notFound(instance, requestId);
1603
+ send(res, problem.status, problem.toJSON(), requestId);
1604
+ return;
1605
+ }
1606
+ send(res, 200, { items: replaced.map(referenceQueryJson) }, requestId);
1607
+ return;
1608
+ }
1240
1609
  // `/v1/layers/{id}/reindex`
1241
1610
  const reindexPath = /^\/v1\/layers\/([0-9a-f-]{36})\/reindex$/i.exec(instance);
1242
1611
  if (reindexPath !== null) {
@@ -1522,15 +1891,12 @@ async function handle(req, res, options) {
1522
1891
  // or a document body in its cause, and neither belongs in a log — see the
1523
1892
  // list in CLAUDE.md. `String(error)` is the class and the message; the
1524
1893
  // stack goes with it because that is what names the line.
1525
- console.error(JSON.stringify({
1526
- msg: 'request failed',
1527
- request_id: requestId,
1894
+ logger.error('request failed', { request_id: requestId,
1528
1895
  method: req.method,
1529
1896
  instance,
1530
1897
  org_id: auth.orgId,
1531
1898
  error: String(error).slice(0, 500),
1532
- stack: error instanceof Error ? error.stack?.split('\n').slice(0, 6).join('\n') : undefined,
1533
- }));
1899
+ stack: error instanceof Error ? error.stack?.split('\n').slice(0, 6).join('\n') : undefined });
1534
1900
  await options.audit
1535
1901
  .write({
1536
1902
  orgId: auth.orgId,
@@ -1545,7 +1911,7 @@ async function handle(req, res, options) {
1545
1911
  })
1546
1912
  .catch((cause) => {
1547
1913
  // Losing the audit row of a failed request is itself worth a line.
1548
- console.error(JSON.stringify({ msg: 'audit write failed', request_id: requestId, error: String(cause) }));
1914
+ logger.error('audit write failed', { request_id: requestId, error: String(cause) });
1549
1915
  });
1550
1916
  const problem = internal(instance, requestId);
1551
1917
  send(res, problem.status, problem.toJSON(), requestId);