@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/adapters.d.ts +133 -12
- package/dist/adapters.d.ts.map +1 -1
- package/dist/adapters.js +307 -52
- package/dist/adapters.js.map +1 -1
- package/dist/auth.d.ts +16 -1
- package/dist/auth.d.ts.map +1 -1
- package/dist/auth.js +26 -0
- package/dist/auth.js.map +1 -1
- package/dist/index.d.ts +3 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/init.d.ts +10 -0
- package/dist/init.d.ts.map +1 -1
- package/dist/init.js +72 -25
- package/dist/init.js.map +1 -1
- package/dist/login.d.ts +13 -1
- package/dist/login.d.ts.map +1 -1
- package/dist/login.js +6 -1
- package/dist/login.js.map +1 -1
- package/dist/main.js +102 -45
- package/dist/main.js.map +1 -1
- package/dist/pagination.d.ts +26 -2
- package/dist/pagination.d.ts.map +1 -1
- package/dist/pagination.js +11 -2
- package/dist/pagination.js.map +1 -1
- package/dist/server.d.ts +118 -37
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +388 -22
- package/dist/server.js.map +1 -1
- package/dist/service-keys.d.ts.map +1 -1
- package/dist/service-keys.js +7 -2
- package/dist/service-keys.js.map +1 -1
- package/package.json +2 -2
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
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
:
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
772
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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);
|