@nacre.work/api 0.2.0 → 0.4.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 { logger, MetadataError, queryAudit, 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
@@ -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
  }
@@ -779,15 +952,97 @@ async function handle(req, res, options) {
779
952
  if (req.method === 'POST' && instance === '/v1/documents') {
780
953
  const body_ = (body ?? {});
781
954
  const layer = body_.layer;
782
- const externalId = body_.external_id;
783
- 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
+ let binary;
968
+ if (uploaded !== undefined) {
969
+ if (typeof content === 'string' || typeof body_.url === 'string') {
970
+ const problem = badRequest(instance, requestId, "A multipart upload carries the document; 'content' and 'url' are for the JSON body.");
971
+ send(res, problem.status, problem.toJSON(), requestId);
972
+ return;
973
+ }
974
+ // PDF first, and both signals must agree: the part must declare
975
+ // `application/pdf` AND the bytes must begin with `%PDF-`. Either
976
+ // alone is a refusal that names the other — a declared type the bytes
977
+ // contradict is exactly the disagreement the multipart parser's
978
+ // strictness doctrine exists to refuse, and sniffing alone would turn
979
+ // the declared type into decoration. Other formats extend this table;
980
+ // nothing falls through to a guess.
981
+ const declared = (uploaded.contentType ?? '').split(';')[0]?.trim().toLowerCase() ?? '';
982
+ const MAGIC = [0x25, 0x50, 0x44, 0x46, 0x2d]; // %PDF-
983
+ const magic = uploaded.bytes.length >= MAGIC.length && MAGIC.every((b, i) => uploaded.bytes[i] === b);
984
+ if (declared === 'application/pdf' && !magic) {
985
+ const problem = badRequest(instance, requestId, "The file part declares 'application/pdf' but the bytes do not begin with the %PDF- magic. " +
986
+ 'Both must agree; a declared type the bytes contradict is refused rather than trusted.');
987
+ send(res, problem.status, problem.toJSON(), requestId);
988
+ return;
989
+ }
990
+ if (declared !== 'application/pdf' && magic) {
991
+ const problem = badRequest(instance, requestId, "The bytes begin with the %PDF- magic but the file part does not declare 'application/pdf'. " +
992
+ 'Both must agree; declare the type rather than relying on sniffing.');
993
+ send(res, problem.status, problem.toJSON(), requestId);
994
+ return;
995
+ }
996
+ if (declared === 'application/pdf' && magic) {
997
+ // Binary requires object storage, at the edge. The bytes' only home
998
+ // is the bucket — `documents.source_ref` is text and stays text — so
999
+ // a deployment without one learns on the request, naming the
1000
+ // variables, not from a `failed` row minutes later.
1001
+ if (options.objectStorage !== true) {
1002
+ const problem = badRequest(instance, requestId, 'A PDF upload needs object storage, and this deployment has none configured. ' +
1003
+ 'Set NACRE_S3_* (endpoint, bucket, access key, secret key) to enable binary ingest.');
1004
+ send(res, problem.status, problem.toJSON(), requestId);
1005
+ return;
1006
+ }
1007
+ binary = { bytes: uploaded.bytes, contentType: 'application/pdf' };
1008
+ }
1009
+ else {
1010
+ // Decoded here, and refused here, rather than queued and failed
1011
+ // later.
1012
+ //
1013
+ // The parser extracts exactly the formats in the table above — it
1014
+ // took its first dependency for PDF and nothing else. Until this
1015
+ // check existed the sidecar decoded with `errors="replace"`, so a
1016
+ // binary file became a string of replacement characters that was
1017
+ // chunked, embedded, stored as the document body and reported as
1018
+ // indexed.
1019
+ //
1020
+ // At the edge the caller learns immediately and nothing is queued.
1021
+ // Deep in the worker they would have learned from a `failed` row
1022
+ // minutes later, if they looked.
1023
+ const decoder = new TextDecoder('utf-8', { fatal: true });
1024
+ try {
1025
+ content = decoder.decode(uploaded.bytes);
1026
+ }
1027
+ catch {
1028
+ const problem = badRequest(instance, requestId, 'The uploaded file is not UTF-8 text. This installation extracts PDF and nothing else — ' +
1029
+ 'a Word file or an image needs an extractor the parser deliberately does not carry, ' +
1030
+ 'and a PDF must declare application/pdf on the file part.');
1031
+ send(res, problem.status, problem.toJSON(), requestId);
1032
+ return;
1033
+ }
1034
+ }
1035
+ }
784
1036
  const url_ = body_.url;
785
1037
  if (typeof layer !== 'string' || typeof externalId !== 'string') {
786
1038
  const problem = badRequest(instance, requestId, "'layer' and 'external_id' are required.");
787
1039
  send(res, problem.status, problem.toJSON(), requestId);
788
1040
  return;
789
1041
  }
790
- if ((typeof content === 'string') === (typeof url_ === 'string')) {
1042
+ // A PDF file part is the third source and already excludes the other
1043
+ // two: `content` and `url` beside a file were refused above, before the
1044
+ // bytes were even looked at.
1045
+ if (binary === undefined && (typeof content === 'string') === (typeof url_ === 'string')) {
791
1046
  // Accepting both would mean choosing silently, and the choice would
792
1047
  // differ from whatever the caller assumed.
793
1048
  const problem = badRequest(instance, requestId, "Exactly one of 'content' or 'url' is required.");
@@ -799,9 +1054,28 @@ async function handle(req, res, options) {
799
1054
  // a document, got 202, and the tag existed nowhere. Refused rather than
800
1055
  // trimmed when it is malformed — a dropped key is a document the caller
801
1056
  // believes is tagged and a filter that will never match it.
1057
+ // Every multipart field is a string, so `metadata` arrives as JSON text
1058
+ // where the JSON body carries an object. Parsed here rather than taught
1059
+ // to parseMetadata, which is shared with `PATCH` and with MCP and should
1060
+ // keep meaning one thing.
1061
+ //
1062
+ // Found by running it: the first version of this branch answered 400 for
1063
+ // a perfectly good `metadata` field, because the string never became an
1064
+ // object.
1065
+ let rawMetadata = body_.metadata;
1066
+ if (wasMultipart && typeof rawMetadata === 'string') {
1067
+ try {
1068
+ rawMetadata = JSON.parse(rawMetadata);
1069
+ }
1070
+ catch {
1071
+ const problem = badRequest(instance, requestId, "The 'metadata' field is not JSON. In a multipart upload it carries a JSON object as text.");
1072
+ send(res, problem.status, problem.toJSON(), requestId);
1073
+ return;
1074
+ }
1075
+ }
802
1076
  let metadata;
803
1077
  try {
804
- metadata = parseMetadata(body_.metadata);
1078
+ metadata = parseMetadata(rawMetadata);
805
1079
  }
806
1080
  catch (error) {
807
1081
  const problem = badRequest(instance, requestId, error instanceof MetadataError ? error.message : "'metadata' is not usable.");
@@ -815,6 +1089,7 @@ async function handle(req, res, options) {
815
1089
  ...(typeof body_.title === 'string' ? { title: body_.title } : {}),
816
1090
  ...(typeof content === 'string' ? { content } : {}),
817
1091
  ...(typeof url_ === 'string' ? { url: url_ } : {}),
1092
+ ...(binary === undefined ? {} : { bytes: binary.bytes, contentType: binary.contentType }),
818
1093
  metadata,
819
1094
  });
820
1095
  // The accept stage only — parse, chunk and embed happen in the worker,
@@ -841,6 +1116,32 @@ async function handle(req, res, options) {
841
1116
  send(res, problem.status, problem.toJSON(), requestId);
842
1117
  return;
843
1118
  }
1119
+ if ('refused' in outcome) {
1120
+ // A module's ingest gate declined a document the caller may write — a
1121
+ // quota, a suspension. Not a 404: the layer is not being hidden, the
1122
+ // caller was allowed to write against it. Recorded as a denial so an
1123
+ // operator can see quota-refused ingests; the gate's reason is the
1124
+ // detail, and the gate chose the status.
1125
+ await options.audit.write({
1126
+ orgId: auth.orgId,
1127
+ actor: `${auth.principal.type}:${auth.principal.id}`,
1128
+ action: 'ingest',
1129
+ result: 'deny',
1130
+ target: { layer },
1131
+ detail: { layer, reason: outcome.reason },
1132
+ requestId,
1133
+ });
1134
+ const problem = new Problem({
1135
+ type: 'https://nacre.work/errors/ingest-refused',
1136
+ title: outcome.status === 429 ? 'Too many requests' : 'Forbidden',
1137
+ status: outcome.status,
1138
+ detail: outcome.reason,
1139
+ instance,
1140
+ requestId,
1141
+ });
1142
+ send(res, outcome.status, problem.toJSON(), requestId);
1143
+ return;
1144
+ }
844
1145
  await options.audit.write({
845
1146
  orgId: auth.orgId,
846
1147
  actor: `${auth.principal.type}:${auth.principal.id}`,
@@ -1248,6 +1549,107 @@ async function handle(req, res, options) {
1248
1549
  send(res, 204, null, requestId);
1249
1550
  return;
1250
1551
  }
1552
+ // `/v1/admin/...` — routes a commercial module mounted.
1553
+ //
1554
+ // After authentication and after `rejectTenantOverride`, deliberately. A
1555
+ // module gets an already-authenticated principal and a body that has
1556
+ // already been scanned for a tenant override, so invariants 1 and 2 hold
1557
+ // for its routes without it having to know they exist. It cannot opt out of
1558
+ // either, because it never sees the request before this point.
1559
+ //
1560
+ // `platform_admin` and `org_admin` only. There is no module-supplied role
1561
+ // check to get wrong: an administrative surface is administrative, and a
1562
+ // member reaching one would be a widening decided in the closed half.
1563
+ if (instance.startsWith(ADMIN_PREFIX)) {
1564
+ // Role before route lookup. Both answer the same 404, so this is not
1565
+ // about what a caller can tell apart — it is that a member must not
1566
+ // reach module code at all, and "the module happened to have no matching
1567
+ // route" is not a reason to be safe.
1568
+ if (auth.role !== 'platform_admin' && auth.role !== 'org_admin') {
1569
+ const problem = notFound(instance, requestId);
1570
+ send(res, problem.status, problem.toJSON(), requestId);
1571
+ return;
1572
+ }
1573
+ const route = adminRoutes().find((r) => r.method === req.method && r.pattern.test(instance));
1574
+ if (route === undefined) {
1575
+ // 404 whether nothing is mounted or nothing matched — the two are the
1576
+ // same answer, and a deployment without the module must not be
1577
+ // distinguishable from one where the path is simply wrong.
1578
+ const problem = notFound(instance, requestId);
1579
+ send(res, problem.status, problem.toJSON(), requestId);
1580
+ return;
1581
+ }
1582
+ const url = new URL(req.url ?? '/', 'http://internal');
1583
+ const matched = route.pattern.exec(instance);
1584
+ const answer = await route.handle({
1585
+ method: req.method ?? 'GET',
1586
+ path: instance,
1587
+ params: (matched ?? []).slice(1),
1588
+ query: url.searchParams,
1589
+ body,
1590
+ auth,
1591
+ });
1592
+ await options.audit.write({
1593
+ orgId: auth.orgId,
1594
+ actor: `${auth.principal.type}:${auth.principal.id}`,
1595
+ action: `admin.${req.method?.toLowerCase() ?? 'get'}`,
1596
+ surface: 'admin',
1597
+ result: answer.status < 400 ? 'allow' : 'deny',
1598
+ target: { path: instance },
1599
+ detail: { status: answer.status },
1600
+ requestId,
1601
+ });
1602
+ send(res, answer.status, answer.body ?? null, requestId);
1603
+ return;
1604
+ }
1605
+ // `/v1/layers/{id}/reference-queries`
1606
+ const referencePath = /^\/v1\/layers\/([0-9a-f-]{36})\/reference-queries$/i.exec(instance);
1607
+ if (referencePath !== null) {
1608
+ const layerId = referencePath[1];
1609
+ if (options.referenceQueries === undefined || (req.method !== 'GET' && req.method !== 'PUT')) {
1610
+ const problem = notFound(instance, requestId);
1611
+ send(res, problem.status, problem.toJSON(), requestId);
1612
+ return;
1613
+ }
1614
+ if (req.method === 'GET') {
1615
+ const found = await options.referenceQueries.list(auth, layerId);
1616
+ if (found === undefined) {
1617
+ // No such layer and no permission to administer it, one answer.
1618
+ const problem = notFound(instance, requestId);
1619
+ send(res, problem.status, problem.toJSON(), requestId);
1620
+ return;
1621
+ }
1622
+ send(res, 200, { items: found.map(referenceQueryJson) }, requestId);
1623
+ return;
1624
+ }
1625
+ const parsed = parseReferenceQueries(body);
1626
+ if ('error' in parsed) {
1627
+ const problem = badRequest(instance, requestId, parsed.error);
1628
+ send(res, problem.status, problem.toJSON(), requestId);
1629
+ return;
1630
+ }
1631
+ const replaced = await options.referenceQueries.replace(auth, layerId, parsed.queries);
1632
+ await options.audit.write({
1633
+ orgId: auth.orgId,
1634
+ actor: `${auth.principal.type}:${auth.principal.id}`,
1635
+ action: 'reference_queries.replace',
1636
+ result: replaced === undefined ? 'deny' : 'allow',
1637
+ target: { layer_id: layerId },
1638
+ // The count and never the queries. They are the operator's own text
1639
+ // rather than a caller's search, so this is not the rule about query
1640
+ // text — but the journal is read by more people than the endpoint is,
1641
+ // and a document's title has already reached it once by this route.
1642
+ detail: { layer_id: layerId, queries: parsed.queries.length },
1643
+ requestId,
1644
+ });
1645
+ if (replaced === undefined) {
1646
+ const problem = notFound(instance, requestId);
1647
+ send(res, problem.status, problem.toJSON(), requestId);
1648
+ return;
1649
+ }
1650
+ send(res, 200, { items: replaced.map(referenceQueryJson) }, requestId);
1651
+ return;
1652
+ }
1251
1653
  // `/v1/layers/{id}/reindex`
1252
1654
  const reindexPath = /^\/v1\/layers\/([0-9a-f-]{36})\/reindex$/i.exec(instance);
1253
1655
  if (reindexPath !== null) {