@nacre.work/api 0.23.10 → 0.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/server.js CHANGED
@@ -13,7 +13,7 @@ import { MIN_PASSWORD_LENGTH } from './recovery.js';
13
13
  import { looksLikeEmail } from './principals.js';
14
14
  import { clientSource } from './source.js';
15
15
  import { auditFormat, auditJson, readAuditQuery, toCsv, toNdjson, } from './audit-export.js';
16
- import { readPage } from './pagination.js';
16
+ import { decodeCursor, readPage } from './pagination.js';
17
17
  /**
18
18
  * The default body cap, in bytes.
19
19
  *
@@ -868,6 +868,37 @@ function sendSessionOutcome(res, outcome, instance, requestId) {
868
868
  });
869
869
  send(res, problem.status, problem.toJSON(), requestId);
870
870
  }
871
+ /**
872
+ * Match a route whose captured segments are percent-decoded, or `null`.
873
+ *
874
+ * WHATWG `URL` leaves an invalid escape in `pathname` verbatim, so a bare
875
+ * `decodeURIComponent` over a captured segment throws `URIError` on
876
+ * `/v1/documents/%ZZ` — which the error boundary turned into a `500` and an
877
+ * audit row saying `error`, where the right answer is the `404` every other
878
+ * unknown id gets. A malformed escape names nothing, so here it is a path
879
+ * that does not match: the request falls through to the same `notFound` an
880
+ * unrouted path gets, and any authenticated caller stops being able to fill
881
+ * the journal with `error` rows on demand. Twelve routes decode a segment;
882
+ * this is the one place they all do it, and eslint refuses a bare
883
+ * `decodeURIComponent` in this package so the thirteenth cannot be written
884
+ * the old way.
885
+ */
886
+ function pathMatch(pattern, instance) {
887
+ const matched = pattern.exec(instance);
888
+ if (matched === null)
889
+ return null;
890
+ try {
891
+ for (let i = 1; i < matched.length; i += 1) {
892
+ const raw = matched[i];
893
+ if (raw !== undefined)
894
+ matched[i] = decodeURIComponent(raw);
895
+ }
896
+ return matched;
897
+ }
898
+ catch {
899
+ return null;
900
+ }
901
+ }
871
902
  async function handleAuth(req, res, instance, requestId, options) {
872
903
  // Every route here produces a credential and therefore takes a body, with
873
904
  // exactly one exception: `/v1/auth/methods` is a **read**, and the contract
@@ -1064,10 +1095,15 @@ async function handleAuth(req, res, instance, requestId, options) {
1064
1095
  return;
1065
1096
  }
1066
1097
  }
1067
- // Awaited, but its failure is swallowed: the answer is the same either way,
1068
- // and an unhandled rejection would take the process down over a relay being
1069
- // briefly unreachable.
1070
- await options.recovery.request(email.email).catch((error) => {
1098
+ // Deliberately not awaited. The body is the same `204` either way, but the
1099
+ // *time* to it was not: a miss returns after one SELECT and a hit does a
1100
+ // full SMTP round trip — hundreds of milliseconds against a couple — so an
1101
+ // awaited request() made the one endpoint reachable without a credential a
1102
+ // timing oracle for "does this address have an account", which is exactly
1103
+ // what the module's own header says it must not be. The failure is still
1104
+ // swallowed into a log line: an unhandled rejection would take the process
1105
+ // down over a relay being briefly unreachable.
1106
+ void options.recovery.request(email.email).catch((error) => {
1071
1107
  logger.warn('could not start a password recovery', {
1072
1108
  request_id: requestId,
1073
1109
  error: String(error).slice(0, 200),
@@ -2348,9 +2384,9 @@ async function handle(req, res, options) {
2348
2384
  send(res, outcome.unchanged ? 200 : 202, { document_id: outcome.documentId, job_id: outcome.jobId, status: outcome.unchanged ? 'indexed' : 'queued' }, requestId);
2349
2385
  return;
2350
2386
  }
2351
- const documentMatch = /^\/v1\/documents\/([^/]+)$/.exec(instance);
2387
+ const documentMatch = pathMatch(/^\/v1\/documents\/([^/]+)$/, instance);
2352
2388
  if (req.method === 'DELETE' && documentMatch) {
2353
- const id = decodeURIComponent(documentMatch[1]);
2389
+ const id = documentMatch[1];
2354
2390
  const removed = await options.ingest.remove(auth, id);
2355
2391
  await options.audit.write({
2356
2392
  orgId: auth.orgId,
@@ -2369,8 +2405,51 @@ async function handle(req, res, options) {
2369
2405
  send(res, 204, null, requestId);
2370
2406
  return;
2371
2407
  }
2408
+ const retryMatch = pathMatch(/^\/v1\/documents\/([^/]+)\/retry$/, instance);
2409
+ if (req.method === 'POST' && retryMatch) {
2410
+ const id = retryMatch[1];
2411
+ if (options.documents.retry === undefined) {
2412
+ const problem = notFound(instance, requestId);
2413
+ send(res, problem.status, problem.toJSON(), requestId);
2414
+ return;
2415
+ }
2416
+ const outcome = await options.documents.retry(auth, id);
2417
+ await options.audit.write({
2418
+ orgId: auth.orgId,
2419
+ actor: `${auth.principal.type}:${auth.principal.id}`,
2420
+ action: 'retry_document',
2421
+ result: outcome === 'requeued' ? 'allow' : 'deny',
2422
+ target: { document_id: id },
2423
+ detail: { document_id: id, outcome },
2424
+ requestId,
2425
+ });
2426
+ if (outcome === 'unreachable') {
2427
+ const problem = notFound(instance, requestId);
2428
+ send(res, problem.status, problem.toJSON(), requestId);
2429
+ return;
2430
+ }
2431
+ if (outcome === 'not-failed') {
2432
+ const problem = new Problem({
2433
+ type: 'https://nacre.work/errors/conflict',
2434
+ title: 'Conflict',
2435
+ status: 409,
2436
+ detail: 'This document has not failed, so there is nothing to retry. ' +
2437
+ 'Only a document whose status is `failed` can be requeued this way; ' +
2438
+ 'send it again through POST /v1/documents to re-index one that has not.',
2439
+ instance,
2440
+ requestId,
2441
+ });
2442
+ send(res, problem.status, problem.toJSON(), requestId);
2443
+ return;
2444
+ }
2445
+ // `204`, and never the document, for the same reason the PATCH beside it
2446
+ // does: rule 6 means a caller may hold `write` without `read`, so a body
2447
+ // describing what was requeued would be a read this caller has not got.
2448
+ send(res, 204, null, requestId);
2449
+ return;
2450
+ }
2372
2451
  if (req.method === 'PATCH' && documentMatch) {
2373
- const id = decodeURIComponent(documentMatch[1]);
2452
+ const id = documentMatch[1];
2374
2453
  if (options.documents.updateMetadata === undefined) {
2375
2454
  const problem = notFound(instance, requestId);
2376
2455
  send(res, problem.status, problem.toJSON(), requestId);
@@ -2417,7 +2496,7 @@ async function handle(req, res, options) {
2417
2496
  return;
2418
2497
  }
2419
2498
  if (req.method === 'GET' && documentMatch) {
2420
- const id = decodeURIComponent(documentMatch[1]);
2499
+ const id = documentMatch[1];
2421
2500
  const document = await options.documents.read(auth, id);
2422
2501
  if (document === undefined) {
2423
2502
  // T8. The same response for "no such document" and "another
@@ -2448,9 +2527,9 @@ async function handle(req, res, options) {
2448
2527
  send(res, 200, document, requestId);
2449
2528
  return;
2450
2529
  }
2451
- const jobMatch = /^\/v1\/jobs\/([^/]+)$/.exec(instance);
2530
+ const jobMatch = pathMatch(/^\/v1\/jobs\/([^/]+)$/, instance);
2452
2531
  if (req.method === 'GET' && jobMatch && options.jobs !== undefined) {
2453
- const id = decodeURIComponent(jobMatch[1]);
2532
+ const id = jobMatch[1];
2454
2533
  const job = await options.jobs.read(auth, id);
2455
2534
  if (job === undefined) {
2456
2535
  // A job names a document, so it is as much of an oracle as the document
@@ -2816,7 +2895,7 @@ async function handle(req, res, options) {
2816
2895
  }
2817
2896
  if (instance === '/v1/workspaces' && options.workspaces !== undefined) {
2818
2897
  if (req.method === 'GET') {
2819
- const page = readPage(url.searchParams, instance, requestId);
2898
+ const page = readPage(url.searchParams, instance, requestId, 'uuid');
2820
2899
  if (page instanceof Problem) {
2821
2900
  send(res, page.status, page.toJSON(), requestId);
2822
2901
  return;
@@ -2889,7 +2968,7 @@ async function handle(req, res, options) {
2889
2968
  }
2890
2969
  }
2891
2970
  if (req.method === 'GET' && instance === '/v1/layers' && options.layers !== undefined) {
2892
- const page = readPage(url.searchParams, instance, requestId);
2971
+ const page = readPage(url.searchParams, instance, requestId, 'uuid');
2893
2972
  if (page instanceof Problem) {
2894
2973
  send(res, page.status, page.toJSON(), requestId);
2895
2974
  return;
@@ -2979,7 +3058,7 @@ async function handle(req, res, options) {
2979
3058
  return;
2980
3059
  }
2981
3060
  if (req.method === 'GET' && instance === '/v1/grants' && options.grants !== undefined) {
2982
- const page = readPage(url.searchParams, instance, requestId);
3061
+ const page = readPage(url.searchParams, instance, requestId, 'uuid');
2983
3062
  if (page instanceof Problem) {
2984
3063
  send(res, page.status, page.toJSON(), requestId);
2985
3064
  return;
@@ -3018,9 +3097,9 @@ async function handle(req, res, options) {
3018
3097
  send(res, 201, grantJson(issued), requestId);
3019
3098
  return;
3020
3099
  }
3021
- const grantMatch = /^\/v1\/grants\/([^/]+)$/.exec(instance);
3100
+ const grantMatch = pathMatch(/^\/v1\/grants\/([^/]+)$/, instance);
3022
3101
  if (req.method === 'DELETE' && grantMatch && options.grants !== undefined) {
3023
- const id = decodeURIComponent(grantMatch[1]);
3102
+ const id = grantMatch[1];
3024
3103
  const revoked = await options.grants.revoke(auth, id);
3025
3104
  // Written before the response either way. A revocation nobody can prove
3026
3105
  // happened is not a revocation an auditor will accept, and a *refused*
@@ -3339,7 +3418,7 @@ async function handle(req, res, options) {
3339
3418
  send(res, problem.status, problem.toJSON(), requestId);
3340
3419
  return;
3341
3420
  }
3342
- const page = readPage(url.searchParams, instance, requestId);
3421
+ const page = readPage(url.searchParams, instance, requestId, 'sequence');
3343
3422
  if (page instanceof Problem) {
3344
3423
  send(res, page.status, page.toJSON(), requestId);
3345
3424
  return;
@@ -3609,11 +3688,31 @@ async function handle(req, res, options) {
3609
3688
  // consent theirs to give. `404` for anything else, because an agent they
3610
3689
  // cannot see and one that does not exist are the same answer here as
3611
3690
  // everywhere.
3691
+ //
3692
+ // The whole listing, not one page of it. A `find` over the first 200
3693
+ // used to stand here, so in an organization with more accounts than
3694
+ // that, approving one ordered past the cut answered `404`
3695
+ // indistinguishably from "no such agent" — a valid consent refused with
3696
+ // nothing on the screen to say why. The loop terminates because the
3697
+ // listing seeks on `(created_at, id)`, and it stops early on a hit.
3612
3698
  if (!delegating) {
3613
- const visible = options.serviceAccounts === undefined
3614
- ? undefined
3615
- : (await options.serviceAccounts.list(auth, { limit: 200, after: undefined })).items.find((a) => a.id === serviceAccountId);
3616
- if (visible === undefined) {
3699
+ let visible = false;
3700
+ if (options.serviceAccounts !== undefined) {
3701
+ let after;
3702
+ for (;;) {
3703
+ const found = await options.serviceAccounts.list(auth, { limit: 200, after });
3704
+ if (found.items.some((a) => a.id === serviceAccountId)) {
3705
+ visible = true;
3706
+ break;
3707
+ }
3708
+ if (found.nextCursor === null)
3709
+ break;
3710
+ after = decodeCursor(found.nextCursor, 'uuid');
3711
+ if (after === undefined)
3712
+ break;
3713
+ }
3714
+ }
3715
+ if (!visible) {
3617
3716
  const problem = notFound(instance, requestId);
3618
3717
  send(res, problem.status, problem.toJSON(), requestId);
3619
3718
  return;
@@ -3628,10 +3727,26 @@ async function handle(req, res, options) {
3628
3727
  // restriction they wrote is not the one they meant. `404` for an unknown
3629
3728
  // id and for an unreadable one alike, on invariant I6.
3630
3729
  if (delegating && narrowing !== undefined && narrowing.length > 0) {
3631
- const readable = options.layers === undefined
3632
- ? []
3633
- : (await options.layers.list(auth, { limit: 500, after: undefined })).items.map((l) => l.id);
3634
- if (narrowing.some((l) => !readable.includes(l.id))) {
3730
+ // The whole listing, for the reason the agent lookup above walks its
3731
+ // pages: one fixed page refused any layer ordered past the cut. The
3732
+ // loop stops early once every named layer has been seen.
3733
+ const readable = new Set();
3734
+ if (options.layers !== undefined) {
3735
+ let after;
3736
+ for (;;) {
3737
+ const found = await options.layers.list(auth, { limit: 500, after });
3738
+ for (const layer of found.items)
3739
+ readable.add(layer.id);
3740
+ if (narrowing.every((l) => readable.has(l.id)))
3741
+ break;
3742
+ if (found.nextCursor === null)
3743
+ break;
3744
+ after = decodeCursor(found.nextCursor, 'uuid');
3745
+ if (after === undefined)
3746
+ break;
3747
+ }
3748
+ }
3749
+ if (narrowing.some((l) => !readable.has(l.id))) {
3635
3750
  const problem = notFound(instance, requestId);
3636
3751
  send(res, problem.status, problem.toJSON(), requestId);
3637
3752
  return;
@@ -3723,7 +3838,7 @@ async function handle(req, res, options) {
3723
3838
  return;
3724
3839
  }
3725
3840
  if (req.method === 'GET') {
3726
- const page = readPage(url.searchParams, instance, requestId);
3841
+ const page = readPage(url.searchParams, instance, requestId, 'uuid');
3727
3842
  if (page instanceof Problem) {
3728
3843
  send(res, page.status, page.toJSON(), requestId);
3729
3844
  return;
@@ -3782,14 +3897,14 @@ async function handle(req, res, options) {
3782
3897
  return;
3783
3898
  }
3784
3899
  }
3785
- const accountMatch = /^\/v1\/service-accounts\/([^/]+)$/.exec(instance);
3900
+ const accountMatch = pathMatch(/^\/v1\/service-accounts\/([^/]+)$/, instance);
3786
3901
  if (req.method === 'DELETE' && accountMatch && options.serviceAccounts !== undefined) {
3787
3902
  if (!administers(auth)) {
3788
3903
  const problem = notFound(instance, requestId);
3789
3904
  send(res, problem.status, problem.toJSON(), requestId);
3790
3905
  return;
3791
3906
  }
3792
- const id = decodeURIComponent(accountMatch[1]);
3907
+ const id = accountMatch[1];
3793
3908
  const revoked = await options.serviceAccounts.revoke(auth, id);
3794
3909
  await options.audit.write({
3795
3910
  orgId: auth.orgId,
@@ -3838,7 +3953,7 @@ async function handle(req, res, options) {
3838
3953
  }
3839
3954
  if (instance === '/v1/users' && options.users !== undefined) {
3840
3955
  if (req.method === 'GET') {
3841
- const page = readPage(url.searchParams, instance, requestId);
3956
+ const page = readPage(url.searchParams, instance, requestId, 'uuid');
3842
3957
  if (page instanceof Problem) {
3843
3958
  send(res, page.status, page.toJSON(), requestId);
3844
3959
  return;
@@ -3924,9 +4039,9 @@ async function handle(req, res, options) {
3924
4039
  return;
3925
4040
  }
3926
4041
  }
3927
- const userMatch = /^\/v1\/users\/([^/]+)$/.exec(instance);
4042
+ const userMatch = pathMatch(/^\/v1\/users\/([^/]+)$/, instance);
3928
4043
  if (userMatch && options.users !== undefined) {
3929
- const id = decodeURIComponent(userMatch[1]);
4044
+ const id = userMatch[1];
3930
4045
  if (req.method === 'DELETE') {
3931
4046
  // Disabled, never deleted, which is what `DELETE` means on every
3932
4047
  // removable thing here: a document is tombstoned, a key is revoked, and
@@ -4047,9 +4162,9 @@ async function handle(req, res, options) {
4047
4162
  return;
4048
4163
  }
4049
4164
  }
4050
- const passwordMatch = /^\/v1\/users\/([^/]+)\/password$/.exec(instance);
4165
+ const passwordMatch = pathMatch(/^\/v1\/users\/([^/]+)\/password$/, instance);
4051
4166
  if (req.method === 'POST' && passwordMatch && options.users !== undefined) {
4052
- const id = decodeURIComponent(passwordMatch[1]);
4167
+ const id = passwordMatch[1];
4053
4168
  const reset = await options.users.resetPassword(auth, id);
4054
4169
  const refused = typeof reset === 'string' ? reset : undefined;
4055
4170
  await options.audit.write({
@@ -4085,7 +4200,7 @@ async function handle(req, res, options) {
4085
4200
  }
4086
4201
  if (instance === '/v1/groups' && options.groups !== undefined) {
4087
4202
  if (req.method === 'GET') {
4088
- const page = readPage(url.searchParams, instance, requestId);
4203
+ const page = readPage(url.searchParams, instance, requestId, 'uuid');
4089
4204
  if (page instanceof Problem) {
4090
4205
  send(res, page.status, page.toJSON(), requestId);
4091
4206
  return;
@@ -4136,9 +4251,9 @@ async function handle(req, res, options) {
4136
4251
  return;
4137
4252
  }
4138
4253
  }
4139
- const groupMatch = /^\/v1\/groups\/([^/]+)$/.exec(instance);
4254
+ const groupMatch = pathMatch(/^\/v1\/groups\/([^/]+)$/, instance);
4140
4255
  if (req.method === 'DELETE' && groupMatch && options.groups !== undefined) {
4141
- const id = decodeURIComponent(groupMatch[1]);
4256
+ const id = groupMatch[1];
4142
4257
  const removed = await options.groups.remove(auth, id);
4143
4258
  await options.audit.write({
4144
4259
  orgId: auth.orgId,
@@ -4157,11 +4272,11 @@ async function handle(req, res, options) {
4157
4272
  send(res, 204, null, requestId);
4158
4273
  return;
4159
4274
  }
4160
- const membersMatch = /^\/v1\/groups\/([^/]+)\/members$/.exec(instance);
4275
+ const membersMatch = pathMatch(/^\/v1\/groups\/([^/]+)\/members$/, instance);
4161
4276
  if (membersMatch && options.groups !== undefined) {
4162
- const groupId = decodeURIComponent(membersMatch[1]);
4277
+ const groupId = membersMatch[1];
4163
4278
  if (req.method === 'GET') {
4164
- const page = readPage(url.searchParams, instance, requestId);
4279
+ const page = readPage(url.searchParams, instance, requestId, 'uuid');
4165
4280
  if (page instanceof Problem) {
4166
4281
  send(res, page.status, page.toJSON(), requestId);
4167
4282
  return;
@@ -4213,11 +4328,11 @@ async function handle(req, res, options) {
4213
4328
  // `{type}/{id}` rather than `{id}` alone: the edge is keyed by which member
4214
4329
  // column it uses, so a bare uuid does not identify one. Same shape `grants`
4215
4330
  // uses for the other end of the same relationship.
4216
- const memberMatch = /^\/v1\/groups\/([^/]+)\/members\/(user|group)\/([^/]+)$/.exec(instance);
4331
+ const memberMatch = pathMatch(/^\/v1\/groups\/([^/]+)\/members\/(user|group)\/([^/]+)$/, instance);
4217
4332
  if (req.method === 'DELETE' && memberMatch && options.groups !== undefined) {
4218
- const groupId = decodeURIComponent(memberMatch[1]);
4333
+ const groupId = memberMatch[1];
4219
4334
  const type = memberMatch[2];
4220
- const memberId = decodeURIComponent(memberMatch[3]);
4335
+ const memberId = memberMatch[3];
4221
4336
  const removed = await options.groups.removeMember(auth, groupId, { type, id: memberId });
4222
4337
  await options.audit.write({
4223
4338
  orgId: auth.orgId,