@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.
- package/LICENSE +202 -0
- package/NOTICE +9 -0
- package/dist/adapters.d.ts +326 -15
- package/dist/adapters.d.ts.map +1 -1
- package/dist/adapters.js +992 -52
- package/dist/adapters.js.map +1 -1
- package/dist/audit-export.d.ts +45 -0
- package/dist/audit-export.d.ts.map +1 -0
- package/dist/audit-export.js +155 -0
- package/dist/audit-export.js.map +1 -0
- package/dist/auth.d.ts +17 -0
- package/dist/auth.d.ts.map +1 -1
- package/dist/auth.js +17 -7
- package/dist/auth.js.map +1 -1
- package/dist/idempotency.d.ts +62 -0
- package/dist/idempotency.d.ts.map +1 -0
- package/dist/idempotency.js +147 -0
- package/dist/idempotency.js.map +1 -0
- package/dist/index.d.ts +11 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +10 -1
- package/dist/index.js.map +1 -1
- package/dist/init.d.ts +9 -0
- package/dist/init.d.ts.map +1 -1
- package/dist/init.js +50 -5
- package/dist/init.js.map +1 -1
- package/dist/limits.d.ts +80 -0
- package/dist/limits.d.ts.map +1 -0
- package/dist/limits.js +63 -0
- package/dist/limits.js.map +1 -0
- package/dist/login.d.ts +94 -0
- package/dist/login.d.ts.map +1 -0
- package/dist/login.js +283 -0
- package/dist/login.js.map +1 -0
- package/dist/main.js +136 -53
- package/dist/main.js.map +1 -1
- package/dist/pagination.d.ts +72 -0
- package/dist/pagination.d.ts.map +1 -0
- package/dist/pagination.js +110 -0
- package/dist/pagination.js.map +1 -0
- package/dist/rerank.d.ts +99 -0
- package/dist/rerank.d.ts.map +1 -0
- package/dist/rerank.js +138 -0
- package/dist/rerank.js.map +1 -0
- package/dist/server.d.ts +345 -6
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +1035 -24
- package/dist/server.js.map +1 -1
- package/dist/service-keys.d.ts +13 -5
- package/dist/service-keys.d.ts.map +1 -1
- package/dist/service-keys.js +54 -31
- package/dist/service-keys.js.map +1 -1
- package/dist/source.d.ts +53 -0
- package/dist/source.d.ts.map +1 -0
- package/dist/source.js +109 -0
- package/dist/source.js.map +1 -0
- package/package.json +28 -10
- package/dist/__tests__/admin-surface.test.d.ts +0 -2
- package/dist/__tests__/admin-surface.test.d.ts.map +0 -1
- package/dist/__tests__/admin-surface.test.js +0 -431
- package/dist/__tests__/admin-surface.test.js.map +0 -1
- package/dist/__tests__/init.test.d.ts +0 -2
- package/dist/__tests__/init.test.d.ts.map +0 -1
- package/dist/__tests__/init.test.js +0 -125
- package/dist/__tests__/init.test.js.map +0 -1
- package/dist/__tests__/service-keys.test.d.ts +0 -2
- package/dist/__tests__/service-keys.test.d.ts.map +0 -1
- package/dist/__tests__/service-keys.test.js +0 -204
- 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 >
|
|
87
|
-
throw new
|
|
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
|
-
|
|
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
|
|
118
|
-
// nothing that is not already a count. No document ids, no query
|
|
119
|
-
// organization ids — organizations appear by slug, which is in the
|
|
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
|
-
|
|
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
|
|
169
|
-
const
|
|
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
|
-
|
|
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
|
|
260
|
-
|
|
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
|
|
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:
|
|
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
|
|
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
|
|
380
|
-
|
|
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
|
-
|
|
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
|
|
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}`,
|