@nacre.work/api 0.23.9 → 0.24.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,
@@ -2370,7 +2406,7 @@ async function handle(req, res, options) {
2370
2406
  return;
2371
2407
  }
2372
2408
  if (req.method === 'PATCH' && documentMatch) {
2373
- const id = decodeURIComponent(documentMatch[1]);
2409
+ const id = documentMatch[1];
2374
2410
  if (options.documents.updateMetadata === undefined) {
2375
2411
  const problem = notFound(instance, requestId);
2376
2412
  send(res, problem.status, problem.toJSON(), requestId);
@@ -2417,7 +2453,7 @@ async function handle(req, res, options) {
2417
2453
  return;
2418
2454
  }
2419
2455
  if (req.method === 'GET' && documentMatch) {
2420
- const id = decodeURIComponent(documentMatch[1]);
2456
+ const id = documentMatch[1];
2421
2457
  const document = await options.documents.read(auth, id);
2422
2458
  if (document === undefined) {
2423
2459
  // T8. The same response for "no such document" and "another
@@ -2448,9 +2484,9 @@ async function handle(req, res, options) {
2448
2484
  send(res, 200, document, requestId);
2449
2485
  return;
2450
2486
  }
2451
- const jobMatch = /^\/v1\/jobs\/([^/]+)$/.exec(instance);
2487
+ const jobMatch = pathMatch(/^\/v1\/jobs\/([^/]+)$/, instance);
2452
2488
  if (req.method === 'GET' && jobMatch && options.jobs !== undefined) {
2453
- const id = decodeURIComponent(jobMatch[1]);
2489
+ const id = jobMatch[1];
2454
2490
  const job = await options.jobs.read(auth, id);
2455
2491
  if (job === undefined) {
2456
2492
  // A job names a document, so it is as much of an oracle as the document
@@ -2816,7 +2852,7 @@ async function handle(req, res, options) {
2816
2852
  }
2817
2853
  if (instance === '/v1/workspaces' && options.workspaces !== undefined) {
2818
2854
  if (req.method === 'GET') {
2819
- const page = readPage(url.searchParams, instance, requestId);
2855
+ const page = readPage(url.searchParams, instance, requestId, 'uuid');
2820
2856
  if (page instanceof Problem) {
2821
2857
  send(res, page.status, page.toJSON(), requestId);
2822
2858
  return;
@@ -2889,7 +2925,7 @@ async function handle(req, res, options) {
2889
2925
  }
2890
2926
  }
2891
2927
  if (req.method === 'GET' && instance === '/v1/layers' && options.layers !== undefined) {
2892
- const page = readPage(url.searchParams, instance, requestId);
2928
+ const page = readPage(url.searchParams, instance, requestId, 'uuid');
2893
2929
  if (page instanceof Problem) {
2894
2930
  send(res, page.status, page.toJSON(), requestId);
2895
2931
  return;
@@ -2979,7 +3015,7 @@ async function handle(req, res, options) {
2979
3015
  return;
2980
3016
  }
2981
3017
  if (req.method === 'GET' && instance === '/v1/grants' && options.grants !== undefined) {
2982
- const page = readPage(url.searchParams, instance, requestId);
3018
+ const page = readPage(url.searchParams, instance, requestId, 'uuid');
2983
3019
  if (page instanceof Problem) {
2984
3020
  send(res, page.status, page.toJSON(), requestId);
2985
3021
  return;
@@ -3018,9 +3054,9 @@ async function handle(req, res, options) {
3018
3054
  send(res, 201, grantJson(issued), requestId);
3019
3055
  return;
3020
3056
  }
3021
- const grantMatch = /^\/v1\/grants\/([^/]+)$/.exec(instance);
3057
+ const grantMatch = pathMatch(/^\/v1\/grants\/([^/]+)$/, instance);
3022
3058
  if (req.method === 'DELETE' && grantMatch && options.grants !== undefined) {
3023
- const id = decodeURIComponent(grantMatch[1]);
3059
+ const id = grantMatch[1];
3024
3060
  const revoked = await options.grants.revoke(auth, id);
3025
3061
  // Written before the response either way. A revocation nobody can prove
3026
3062
  // happened is not a revocation an auditor will accept, and a *refused*
@@ -3339,7 +3375,7 @@ async function handle(req, res, options) {
3339
3375
  send(res, problem.status, problem.toJSON(), requestId);
3340
3376
  return;
3341
3377
  }
3342
- const page = readPage(url.searchParams, instance, requestId);
3378
+ const page = readPage(url.searchParams, instance, requestId, 'sequence');
3343
3379
  if (page instanceof Problem) {
3344
3380
  send(res, page.status, page.toJSON(), requestId);
3345
3381
  return;
@@ -3609,11 +3645,31 @@ async function handle(req, res, options) {
3609
3645
  // consent theirs to give. `404` for anything else, because an agent they
3610
3646
  // cannot see and one that does not exist are the same answer here as
3611
3647
  // everywhere.
3648
+ //
3649
+ // The whole listing, not one page of it. A `find` over the first 200
3650
+ // used to stand here, so in an organization with more accounts than
3651
+ // that, approving one ordered past the cut answered `404`
3652
+ // indistinguishably from "no such agent" — a valid consent refused with
3653
+ // nothing on the screen to say why. The loop terminates because the
3654
+ // listing seeks on `(created_at, id)`, and it stops early on a hit.
3612
3655
  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) {
3656
+ let visible = false;
3657
+ if (options.serviceAccounts !== undefined) {
3658
+ let after;
3659
+ for (;;) {
3660
+ const found = await options.serviceAccounts.list(auth, { limit: 200, after });
3661
+ if (found.items.some((a) => a.id === serviceAccountId)) {
3662
+ visible = true;
3663
+ break;
3664
+ }
3665
+ if (found.nextCursor === null)
3666
+ break;
3667
+ after = decodeCursor(found.nextCursor, 'uuid');
3668
+ if (after === undefined)
3669
+ break;
3670
+ }
3671
+ }
3672
+ if (!visible) {
3617
3673
  const problem = notFound(instance, requestId);
3618
3674
  send(res, problem.status, problem.toJSON(), requestId);
3619
3675
  return;
@@ -3628,10 +3684,26 @@ async function handle(req, res, options) {
3628
3684
  // restriction they wrote is not the one they meant. `404` for an unknown
3629
3685
  // id and for an unreadable one alike, on invariant I6.
3630
3686
  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))) {
3687
+ // The whole listing, for the reason the agent lookup above walks its
3688
+ // pages: one fixed page refused any layer ordered past the cut. The
3689
+ // loop stops early once every named layer has been seen.
3690
+ const readable = new Set();
3691
+ if (options.layers !== undefined) {
3692
+ let after;
3693
+ for (;;) {
3694
+ const found = await options.layers.list(auth, { limit: 500, after });
3695
+ for (const layer of found.items)
3696
+ readable.add(layer.id);
3697
+ if (narrowing.every((l) => readable.has(l.id)))
3698
+ break;
3699
+ if (found.nextCursor === null)
3700
+ break;
3701
+ after = decodeCursor(found.nextCursor, 'uuid');
3702
+ if (after === undefined)
3703
+ break;
3704
+ }
3705
+ }
3706
+ if (narrowing.some((l) => !readable.has(l.id))) {
3635
3707
  const problem = notFound(instance, requestId);
3636
3708
  send(res, problem.status, problem.toJSON(), requestId);
3637
3709
  return;
@@ -3723,7 +3795,7 @@ async function handle(req, res, options) {
3723
3795
  return;
3724
3796
  }
3725
3797
  if (req.method === 'GET') {
3726
- const page = readPage(url.searchParams, instance, requestId);
3798
+ const page = readPage(url.searchParams, instance, requestId, 'uuid');
3727
3799
  if (page instanceof Problem) {
3728
3800
  send(res, page.status, page.toJSON(), requestId);
3729
3801
  return;
@@ -3782,14 +3854,14 @@ async function handle(req, res, options) {
3782
3854
  return;
3783
3855
  }
3784
3856
  }
3785
- const accountMatch = /^\/v1\/service-accounts\/([^/]+)$/.exec(instance);
3857
+ const accountMatch = pathMatch(/^\/v1\/service-accounts\/([^/]+)$/, instance);
3786
3858
  if (req.method === 'DELETE' && accountMatch && options.serviceAccounts !== undefined) {
3787
3859
  if (!administers(auth)) {
3788
3860
  const problem = notFound(instance, requestId);
3789
3861
  send(res, problem.status, problem.toJSON(), requestId);
3790
3862
  return;
3791
3863
  }
3792
- const id = decodeURIComponent(accountMatch[1]);
3864
+ const id = accountMatch[1];
3793
3865
  const revoked = await options.serviceAccounts.revoke(auth, id);
3794
3866
  await options.audit.write({
3795
3867
  orgId: auth.orgId,
@@ -3838,7 +3910,7 @@ async function handle(req, res, options) {
3838
3910
  }
3839
3911
  if (instance === '/v1/users' && options.users !== undefined) {
3840
3912
  if (req.method === 'GET') {
3841
- const page = readPage(url.searchParams, instance, requestId);
3913
+ const page = readPage(url.searchParams, instance, requestId, 'uuid');
3842
3914
  if (page instanceof Problem) {
3843
3915
  send(res, page.status, page.toJSON(), requestId);
3844
3916
  return;
@@ -3924,9 +3996,9 @@ async function handle(req, res, options) {
3924
3996
  return;
3925
3997
  }
3926
3998
  }
3927
- const userMatch = /^\/v1\/users\/([^/]+)$/.exec(instance);
3999
+ const userMatch = pathMatch(/^\/v1\/users\/([^/]+)$/, instance);
3928
4000
  if (userMatch && options.users !== undefined) {
3929
- const id = decodeURIComponent(userMatch[1]);
4001
+ const id = userMatch[1];
3930
4002
  if (req.method === 'DELETE') {
3931
4003
  // Disabled, never deleted, which is what `DELETE` means on every
3932
4004
  // removable thing here: a document is tombstoned, a key is revoked, and
@@ -4047,9 +4119,9 @@ async function handle(req, res, options) {
4047
4119
  return;
4048
4120
  }
4049
4121
  }
4050
- const passwordMatch = /^\/v1\/users\/([^/]+)\/password$/.exec(instance);
4122
+ const passwordMatch = pathMatch(/^\/v1\/users\/([^/]+)\/password$/, instance);
4051
4123
  if (req.method === 'POST' && passwordMatch && options.users !== undefined) {
4052
- const id = decodeURIComponent(passwordMatch[1]);
4124
+ const id = passwordMatch[1];
4053
4125
  const reset = await options.users.resetPassword(auth, id);
4054
4126
  const refused = typeof reset === 'string' ? reset : undefined;
4055
4127
  await options.audit.write({
@@ -4085,7 +4157,7 @@ async function handle(req, res, options) {
4085
4157
  }
4086
4158
  if (instance === '/v1/groups' && options.groups !== undefined) {
4087
4159
  if (req.method === 'GET') {
4088
- const page = readPage(url.searchParams, instance, requestId);
4160
+ const page = readPage(url.searchParams, instance, requestId, 'uuid');
4089
4161
  if (page instanceof Problem) {
4090
4162
  send(res, page.status, page.toJSON(), requestId);
4091
4163
  return;
@@ -4136,9 +4208,9 @@ async function handle(req, res, options) {
4136
4208
  return;
4137
4209
  }
4138
4210
  }
4139
- const groupMatch = /^\/v1\/groups\/([^/]+)$/.exec(instance);
4211
+ const groupMatch = pathMatch(/^\/v1\/groups\/([^/]+)$/, instance);
4140
4212
  if (req.method === 'DELETE' && groupMatch && options.groups !== undefined) {
4141
- const id = decodeURIComponent(groupMatch[1]);
4213
+ const id = groupMatch[1];
4142
4214
  const removed = await options.groups.remove(auth, id);
4143
4215
  await options.audit.write({
4144
4216
  orgId: auth.orgId,
@@ -4157,11 +4229,11 @@ async function handle(req, res, options) {
4157
4229
  send(res, 204, null, requestId);
4158
4230
  return;
4159
4231
  }
4160
- const membersMatch = /^\/v1\/groups\/([^/]+)\/members$/.exec(instance);
4232
+ const membersMatch = pathMatch(/^\/v1\/groups\/([^/]+)\/members$/, instance);
4161
4233
  if (membersMatch && options.groups !== undefined) {
4162
- const groupId = decodeURIComponent(membersMatch[1]);
4234
+ const groupId = membersMatch[1];
4163
4235
  if (req.method === 'GET') {
4164
- const page = readPage(url.searchParams, instance, requestId);
4236
+ const page = readPage(url.searchParams, instance, requestId, 'uuid');
4165
4237
  if (page instanceof Problem) {
4166
4238
  send(res, page.status, page.toJSON(), requestId);
4167
4239
  return;
@@ -4213,11 +4285,11 @@ async function handle(req, res, options) {
4213
4285
  // `{type}/{id}` rather than `{id}` alone: the edge is keyed by which member
4214
4286
  // column it uses, so a bare uuid does not identify one. Same shape `grants`
4215
4287
  // uses for the other end of the same relationship.
4216
- const memberMatch = /^\/v1\/groups\/([^/]+)\/members\/(user|group)\/([^/]+)$/.exec(instance);
4288
+ const memberMatch = pathMatch(/^\/v1\/groups\/([^/]+)\/members\/(user|group)\/([^/]+)$/, instance);
4217
4289
  if (req.method === 'DELETE' && memberMatch && options.groups !== undefined) {
4218
- const groupId = decodeURIComponent(memberMatch[1]);
4290
+ const groupId = memberMatch[1];
4219
4291
  const type = memberMatch[2];
4220
- const memberId = decodeURIComponent(memberMatch[3]);
4292
+ const memberId = memberMatch[3];
4221
4293
  const removed = await options.groups.removeMember(auth, groupId, { type, id: memberId });
4222
4294
  await options.audit.write({
4223
4295
  orgId: auth.orgId,