@anthusai/papyrus 1.14.0 → 1.15.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.
@@ -219,6 +219,25 @@ const schema = a.schema({
219
219
  grantorLabel: a.string(),
220
220
  expiresAt: a.datetime().required(),
221
221
  revokedAt: a.datetime(),
222
+ state: a.string(),
223
+ userCode: a.string(),
224
+ requestedHours: a.integer(),
225
+ pendingExpiresAt: a.datetime(),
226
+ approvedAt: a.datetime(),
227
+ deniedAt: a.datetime(),
228
+ }),
229
+ CurationDelegationRequestResult: a.customType({
230
+ ok: a.boolean().required(),
231
+ delegationId: a.id().required(),
232
+ userCode: a.string().required(),
233
+ pendingExpiresAt: a.datetime().required(),
234
+ requestedHours: a.integer().required(),
235
+ }),
236
+ CurationDelegationStatusResult: a.customType({
237
+ delegationId: a.id().required(),
238
+ state: a.string().required(),
239
+ expiresAt: a.datetime().required(),
240
+ grantorLabel: a.string(),
222
241
  }),
223
242
  ReferenceCurationActionResult: a.customType({
224
243
  ok: a.boolean().required(),
@@ -333,6 +352,49 @@ const schema = a.schema({
333
352
  allow.groups(categoryWriteGroups),
334
353
  ])
335
354
  .handler(a.handler.function(categoryAction)),
355
+ requestCurationDelegation: a
356
+ .mutation()
357
+ .arguments({
358
+ delegationId: a.id().required(),
359
+ tokenHash: a.string().required(),
360
+ agentLabel: a.string().required(),
361
+ hours: a.integer(),
362
+ })
363
+ .returns(a.ref("CurationDelegationRequestResult"))
364
+ .authorization((allow) => [
365
+ allow.authenticated("identityPool"),
366
+ ])
367
+ .handler(a.handler.function(categoryAction)),
368
+ approveCurationDelegation: a
369
+ .mutation()
370
+ .arguments({
371
+ userCode: a.string().required(),
372
+ })
373
+ .returns(a.ref("CurationDelegationResult"))
374
+ .authorization((allow) => [
375
+ allow.groups(categoryWriteGroups),
376
+ ])
377
+ .handler(a.handler.function(categoryAction)),
378
+ denyCurationDelegation: a
379
+ .mutation()
380
+ .arguments({
381
+ userCode: a.string().required(),
382
+ })
383
+ .returns(a.ref("CurationDelegationResult"))
384
+ .authorization((allow) => [
385
+ allow.groups(categoryWriteGroups),
386
+ ])
387
+ .handler(a.handler.function(categoryAction)),
388
+ getCurationDelegationStatus: a
389
+ .query()
390
+ .arguments({
391
+ delegationId: a.id().required(),
392
+ })
393
+ .returns(a.ref("CurationDelegationStatusResult"))
394
+ .authorization((allow) => [
395
+ allow.authenticated("identityPool"),
396
+ ])
397
+ .handler(a.handler.function(categoryAction)),
336
398
  recordDelegatedCuration: a
337
399
  .mutation()
338
400
  .arguments({
@@ -236,6 +236,27 @@ const schema = a.schema({
236
236
  grantorLabel: a.string(),
237
237
  expiresAt: a.datetime().required(),
238
238
  revokedAt: a.datetime(),
239
+ state: a.string(),
240
+ userCode: a.string(),
241
+ requestedHours: a.integer(),
242
+ pendingExpiresAt: a.datetime(),
243
+ approvedAt: a.datetime(),
244
+ deniedAt: a.datetime(),
245
+ }),
246
+
247
+ CurationDelegationRequestResult: a.customType({
248
+ ok: a.boolean().required(),
249
+ delegationId: a.id().required(),
250
+ userCode: a.string().required(),
251
+ pendingExpiresAt: a.datetime().required(),
252
+ requestedHours: a.integer().required(),
253
+ }),
254
+
255
+ CurationDelegationStatusResult: a.customType({
256
+ delegationId: a.id().required(),
257
+ state: a.string().required(),
258
+ expiresAt: a.datetime().required(),
259
+ grantorLabel: a.string(),
239
260
  }),
240
261
 
241
262
  ReferenceCurationActionResult: a.customType({
@@ -363,6 +384,53 @@ const schema = a.schema({
363
384
  ])
364
385
  .handler(a.handler.function(categoryAction)),
365
386
 
387
+ requestCurationDelegation: a
388
+ .mutation()
389
+ .arguments({
390
+ delegationId: a.id().required(),
391
+ tokenHash: a.string().required(),
392
+ agentLabel: a.string().required(),
393
+ hours: a.integer(),
394
+ })
395
+ .returns(a.ref("CurationDelegationRequestResult"))
396
+ .authorization((allow) => [
397
+ allow.authenticated("identityPool"),
398
+ ])
399
+ .handler(a.handler.function(categoryAction)),
400
+
401
+ approveCurationDelegation: a
402
+ .mutation()
403
+ .arguments({
404
+ userCode: a.string().required(),
405
+ })
406
+ .returns(a.ref("CurationDelegationResult"))
407
+ .authorization((allow) => [
408
+ allow.groups(categoryWriteGroups),
409
+ ])
410
+ .handler(a.handler.function(categoryAction)),
411
+
412
+ denyCurationDelegation: a
413
+ .mutation()
414
+ .arguments({
415
+ userCode: a.string().required(),
416
+ })
417
+ .returns(a.ref("CurationDelegationResult"))
418
+ .authorization((allow) => [
419
+ allow.groups(categoryWriteGroups),
420
+ ])
421
+ .handler(a.handler.function(categoryAction)),
422
+
423
+ getCurationDelegationStatus: a
424
+ .query()
425
+ .arguments({
426
+ delegationId: a.id().required(),
427
+ })
428
+ .returns(a.ref("CurationDelegationStatusResult"))
429
+ .authorization((allow) => [
430
+ allow.authenticated("identityPool"),
431
+ ])
432
+ .handler(a.handler.function(categoryAction)),
433
+
366
434
  recordDelegatedCuration: a
367
435
  .mutation()
368
436
  .arguments({
@@ -211,6 +211,25 @@ const baseDefinitions = {
211
211
  grantorLabel: a.string(),
212
212
  expiresAt: a.datetime().required(),
213
213
  revokedAt: a.datetime(),
214
+ state: a.string(),
215
+ userCode: a.string(),
216
+ requestedHours: a.integer(),
217
+ pendingExpiresAt: a.datetime(),
218
+ approvedAt: a.datetime(),
219
+ deniedAt: a.datetime(),
220
+ }),
221
+ CurationDelegationRequestResult: a.customType({
222
+ ok: a.boolean().required(),
223
+ delegationId: a.id().required(),
224
+ userCode: a.string().required(),
225
+ pendingExpiresAt: a.datetime().required(),
226
+ requestedHours: a.integer().required(),
227
+ }),
228
+ CurationDelegationStatusResult: a.customType({
229
+ delegationId: a.id().required(),
230
+ state: a.string().required(),
231
+ expiresAt: a.datetime().required(),
232
+ grantorLabel: a.string(),
214
233
  }),
215
234
  ReferenceCurationActionResult: a.customType({
216
235
  ok: a.boolean().required(),
@@ -291,6 +310,49 @@ const baseDefinitions = {
291
310
  allow.groups(categoryWriteGroups),
292
311
  ])
293
312
  .handler(a.handler.function(categoryAction)),
313
+ requestCurationDelegation: a
314
+ .mutation()
315
+ .arguments({
316
+ delegationId: a.id().required(),
317
+ tokenHash: a.string().required(),
318
+ agentLabel: a.string().required(),
319
+ hours: a.integer(),
320
+ })
321
+ .returns(a.ref("CurationDelegationRequestResult"))
322
+ .authorization((allow) => [
323
+ allow.authenticated("identityPool"),
324
+ ])
325
+ .handler(a.handler.function(categoryAction)),
326
+ approveCurationDelegation: a
327
+ .mutation()
328
+ .arguments({
329
+ userCode: a.string().required(),
330
+ })
331
+ .returns(a.ref("CurationDelegationResult"))
332
+ .authorization((allow) => [
333
+ allow.groups(categoryWriteGroups),
334
+ ])
335
+ .handler(a.handler.function(categoryAction)),
336
+ denyCurationDelegation: a
337
+ .mutation()
338
+ .arguments({
339
+ userCode: a.string().required(),
340
+ })
341
+ .returns(a.ref("CurationDelegationResult"))
342
+ .authorization((allow) => [
343
+ allow.groups(categoryWriteGroups),
344
+ ])
345
+ .handler(a.handler.function(categoryAction)),
346
+ getCurationDelegationStatus: a
347
+ .query()
348
+ .arguments({
349
+ delegationId: a.id().required(),
350
+ })
351
+ .returns(a.ref("CurationDelegationStatusResult"))
352
+ .authorization((allow) => [
353
+ allow.authenticated("identityPool"),
354
+ ])
355
+ .handler(a.handler.function(categoryAction)),
294
356
  recordDelegatedCuration: a
295
357
  .mutation()
296
358
  .arguments({
@@ -230,6 +230,27 @@ const baseDefinitions = {
230
230
  grantorLabel: a.string(),
231
231
  expiresAt: a.datetime().required(),
232
232
  revokedAt: a.datetime(),
233
+ state: a.string(),
234
+ userCode: a.string(),
235
+ requestedHours: a.integer(),
236
+ pendingExpiresAt: a.datetime(),
237
+ approvedAt: a.datetime(),
238
+ deniedAt: a.datetime(),
239
+ }),
240
+
241
+ CurationDelegationRequestResult: a.customType({
242
+ ok: a.boolean().required(),
243
+ delegationId: a.id().required(),
244
+ userCode: a.string().required(),
245
+ pendingExpiresAt: a.datetime().required(),
246
+ requestedHours: a.integer().required(),
247
+ }),
248
+
249
+ CurationDelegationStatusResult: a.customType({
250
+ delegationId: a.id().required(),
251
+ state: a.string().required(),
252
+ expiresAt: a.datetime().required(),
253
+ grantorLabel: a.string(),
233
254
  }),
234
255
 
235
256
  ReferenceCurationActionResult: a.customType({
@@ -319,6 +340,53 @@ const baseDefinitions = {
319
340
  ])
320
341
  .handler(a.handler.function(categoryAction)),
321
342
 
343
+ requestCurationDelegation: a
344
+ .mutation()
345
+ .arguments({
346
+ delegationId: a.id().required(),
347
+ tokenHash: a.string().required(),
348
+ agentLabel: a.string().required(),
349
+ hours: a.integer(),
350
+ })
351
+ .returns(a.ref("CurationDelegationRequestResult"))
352
+ .authorization((allow) => [
353
+ allow.authenticated("identityPool"),
354
+ ])
355
+ .handler(a.handler.function(categoryAction)),
356
+
357
+ approveCurationDelegation: a
358
+ .mutation()
359
+ .arguments({
360
+ userCode: a.string().required(),
361
+ })
362
+ .returns(a.ref("CurationDelegationResult"))
363
+ .authorization((allow) => [
364
+ allow.groups(categoryWriteGroups),
365
+ ])
366
+ .handler(a.handler.function(categoryAction)),
367
+
368
+ denyCurationDelegation: a
369
+ .mutation()
370
+ .arguments({
371
+ userCode: a.string().required(),
372
+ })
373
+ .returns(a.ref("CurationDelegationResult"))
374
+ .authorization((allow) => [
375
+ allow.groups(categoryWriteGroups),
376
+ ])
377
+ .handler(a.handler.function(categoryAction)),
378
+
379
+ getCurationDelegationStatus: a
380
+ .query()
381
+ .arguments({
382
+ delegationId: a.id().required(),
383
+ })
384
+ .returns(a.ref("CurationDelegationStatusResult"))
385
+ .authorization((allow) => [
386
+ allow.authenticated("identityPool"),
387
+ ])
388
+ .handler(a.handler.function(categoryAction)),
389
+
322
390
  recordDelegatedCuration: a
323
391
  .mutation()
324
392
  .arguments({
@@ -12,14 +12,28 @@ import {
12
12
  delegationRefusalMessage,
13
13
  delegationVerdict,
14
14
  newDelegationToken,
15
+ PENDING_REQUEST_GRANTOR_SUB,
15
16
  type CurationDelegation,
16
17
  } from "../shared/curation-delegation";
18
+ import {
19
+ approveDelegation,
20
+ delegationResult as flowDelegationResult,
21
+ delegationStatus,
22
+ denyDelegation,
23
+ livePendingRequests,
24
+ requestDelegation,
25
+ type DelegationStore,
26
+ } from "../shared/curation-delegation-flow";
17
27
 
18
28
  type ReviewHandler = Schema["reviewSteeringProposal"]["functionHandler"];
19
29
  type ReferenceCurationHandler = Schema["reviewReferenceCuration"]["functionHandler"];
20
30
  type CreateCurationDelegationHandler = Schema["createCurationDelegation"]["functionHandler"];
21
31
  type RevokeCurationDelegationHandler = Schema["revokeCurationDelegation"]["functionHandler"];
22
32
  type ListCurationDelegationsHandler = Schema["listCurationDelegations"]["functionHandler"];
33
+ type RequestCurationDelegationHandler = Schema["requestCurationDelegation"]["functionHandler"];
34
+ type ApproveCurationDelegationHandler = Schema["approveCurationDelegation"]["functionHandler"];
35
+ type DenyCurationDelegationHandler = Schema["denyCurationDelegation"]["functionHandler"];
36
+ type GetCurationDelegationStatusHandler = Schema["getCurationDelegationStatus"]["functionHandler"];
23
37
  type RecordDelegatedCurationHandler = Schema["recordDelegatedCuration"]["functionHandler"];
24
38
  type ReferenceQualityHandler = Schema["setReferenceQualityRating"]["functionHandler"];
25
39
  type ReferenceNoteHandler = Schema["createReferenceNote"]["functionHandler"];
@@ -39,6 +53,10 @@ type CategoryActionEvent =
39
53
  | Parameters<CreateCurationDelegationHandler>[0]
40
54
  | Parameters<RevokeCurationDelegationHandler>[0]
41
55
  | Parameters<ListCurationDelegationsHandler>[0]
56
+ | Parameters<RequestCurationDelegationHandler>[0]
57
+ | Parameters<ApproveCurationDelegationHandler>[0]
58
+ | Parameters<DenyCurationDelegationHandler>[0]
59
+ | Parameters<GetCurationDelegationStatusHandler>[0]
42
60
  | Parameters<RecordDelegatedCurationHandler>[0]
43
61
  | Parameters<ReferenceQualityHandler>[0]
44
62
  | Parameters<ReferenceNoteHandler>[0]
@@ -108,6 +126,10 @@ export const handler = async (event: CategoryActionEvent) => {
108
126
  if (fieldName === "createCurationDelegation") return createCurationDelegation(event as Parameters<CreateCurationDelegationHandler>[0]);
109
127
  if (fieldName === "revokeCurationDelegation") return revokeCurationDelegation(event as Parameters<RevokeCurationDelegationHandler>[0]);
110
128
  if (fieldName === "listCurationDelegations") return listCurationDelegations(event as Parameters<ListCurationDelegationsHandler>[0]);
129
+ if (fieldName === "requestCurationDelegation") return requestCurationDelegationAction(event as Parameters<RequestCurationDelegationHandler>[0]);
130
+ if (fieldName === "approveCurationDelegation") return approveCurationDelegationAction(event as Parameters<ApproveCurationDelegationHandler>[0]);
131
+ if (fieldName === "denyCurationDelegation") return denyCurationDelegationAction(event as Parameters<DenyCurationDelegationHandler>[0]);
132
+ if (fieldName === "getCurationDelegationStatus") return getCurationDelegationStatusAction(event as Parameters<GetCurationDelegationStatusHandler>[0]);
111
133
  if (fieldName === "recordDelegatedCuration") return recordDelegatedCuration(event as Parameters<RecordDelegatedCurationHandler>[0]);
112
134
  if (fieldName === "setReferenceQualityRating") return setReferenceQualityRating(event as Parameters<ReferenceQualityHandler>[0]);
113
135
  if (fieldName === "createReferenceNote") return createReferenceNote(event as Parameters<ReferenceNoteHandler>[0]);
@@ -376,17 +398,68 @@ async function writeCurationDelegation(client: DataClient, delegation: CurationD
376
398
  }
377
399
 
378
400
  function delegationResult(delegation: CurationDelegation, token: string | null) {
401
+ return { ...flowDelegationResult(delegation, new Date().toISOString()), token };
402
+ }
403
+
404
+ async function listPendingCurationDelegations(client: DataClient): Promise<CurationDelegation[]> {
405
+ const pending: CurationDelegation[] = [];
406
+ let nextToken: string | null | undefined;
407
+ do {
408
+ const page: any = await (client.models.KnowledgeRawPayload as any).listKnowledgeRawPayloadsByOwnerAndKind(
409
+ { ownerId: PENDING_REQUEST_GRANTOR_SUB, payloadKind: { eq: CURATION_DELEGATION_PAYLOAD_KIND } },
410
+ { nextToken },
411
+ );
412
+ assertNoDataErrors(page.errors, "list pending curation delegations");
413
+ for (const payload of page.data ?? []) {
414
+ const delegation = await readCurationDelegation(client, payload.id);
415
+ if (delegation) pending.push(delegation);
416
+ }
417
+ nextToken = page.nextToken;
418
+ } while (nextToken);
419
+ return pending;
420
+ }
421
+
422
+ function curationDelegationStore(client: DataClient): DelegationStore {
379
423
  return {
380
- ok: true,
381
- delegationId: delegation.delegationId,
382
- token,
383
- agentLabel: delegation.agentLabel,
384
- grantorLabel: delegation.grantorLabel,
385
- expiresAt: delegation.expiresAt,
386
- revokedAt: delegation.revokedAt,
424
+ read: (delegationId) => readCurationDelegation(client, delegationId),
425
+ write: (delegation, now, created) => writeCurationDelegation(client, delegation, now, created),
426
+ listPending: () => listPendingCurationDelegations(client),
387
427
  };
388
428
  }
389
429
 
430
+ async function requestCurationDelegationAction(event: Parameters<RequestCurationDelegationHandler>[0]) {
431
+ const client = await getDataClient();
432
+ return requestDelegation(
433
+ curationDelegationStore(client),
434
+ {
435
+ delegationId: normalizeRequiredString(event.arguments.delegationId, "delegationId"),
436
+ tokenHash: normalizeRequiredString(event.arguments.tokenHash, "tokenHash"),
437
+ agentLabel: normalizeRequiredString(event.arguments.agentLabel, "agentLabel"),
438
+ hours: event.arguments.hours,
439
+ },
440
+ new Date().toISOString(),
441
+ );
442
+ }
443
+
444
+ async function approveCurationDelegationAction(event: Parameters<ApproveCurationDelegationHandler>[0]) {
445
+ const person = requireSignedInPerson(event);
446
+ const client = await getDataClient();
447
+ const now = new Date().toISOString();
448
+ return { ...(await approveDelegation(curationDelegationStore(client), person, normalizeRequiredString(event.arguments.userCode, "userCode"), now)), token: null };
449
+ }
450
+
451
+ async function denyCurationDelegationAction(event: Parameters<DenyCurationDelegationHandler>[0]) {
452
+ const person = requireSignedInPerson(event);
453
+ const client = await getDataClient();
454
+ const now = new Date().toISOString();
455
+ return { ...(await denyDelegation(curationDelegationStore(client), person, normalizeRequiredString(event.arguments.userCode, "userCode"), now)), token: null };
456
+ }
457
+
458
+ async function getCurationDelegationStatusAction(event: Parameters<GetCurationDelegationStatusHandler>[0]) {
459
+ const client = await getDataClient();
460
+ return delegationStatus(curationDelegationStore(client), normalizeRequiredString(event.arguments.delegationId, "delegationId"), new Date().toISOString());
461
+ }
462
+
390
463
  async function createCurationDelegation(event: Parameters<CreateCurationDelegationHandler>[0]) {
391
464
  const person = requireSignedInPerson(event);
392
465
  const client = await getDataClient();
@@ -412,7 +485,7 @@ async function revokeCurationDelegation(event: Parameters<RevokeCurationDelegati
412
485
  const client = await getDataClient();
413
486
  const delegation = await readCurationDelegation(client, normalizeRequiredString(event.arguments.delegationId, "delegationId"));
414
487
  if (!delegation) throw new Error("That delegation was not found.");
415
- if (delegation.grantorSub !== person.sub && !person.isAdmin) throw new Error("Only the person who granted a delegation can revoke it.");
488
+ if (delegation.grantorSub !== person.sub && delegation.grantorSub !== PENDING_REQUEST_GRANTOR_SUB && !person.isAdmin) throw new Error("Only the person who granted a delegation can revoke it.");
416
489
  const now = new Date().toISOString();
417
490
  const revoked = { ...delegation, revokedAt: delegation.revokedAt ?? now };
418
491
  await writeCurationDelegation(client, revoked, now, false);
@@ -436,6 +509,8 @@ async function listCurationDelegations(event: Parameters<ListCurationDelegations
436
509
  }
437
510
  nextToken = page.nextToken;
438
511
  } while (nextToken);
512
+ const now = new Date().toISOString();
513
+ delegations.push(...(await livePendingRequests(curationDelegationStore(client), now)));
439
514
  return delegations
440
515
  .sort((first, second) => second.createdAt.localeCompare(first.createdAt))
441
516
  .map((delegation) => delegationResult(delegation, null));
@@ -0,0 +1,147 @@
1
+ import {
2
+ EXPIRED_REQUEST_GRANTOR_SUB,
3
+ MAXIMUM_PENDING_DELEGATION_REQUESTS,
4
+ PENDING_DELEGATION_MINUTES,
5
+ PENDING_REQUEST_GRANTOR_SUB,
6
+ delegationExpiry,
7
+ delegationState,
8
+ formatUserCode,
9
+ newUserCode,
10
+ normalizeUserCode,
11
+ type CurationDelegation,
12
+ } from "./curation-delegation"
13
+
14
+ export type DelegationStore = {
15
+ read(delegationId: string): Promise<CurationDelegation | null>
16
+ write(delegation: CurationDelegation, now: string, created: boolean): Promise<void>
17
+ listPending(): Promise<CurationDelegation[]>
18
+ }
19
+
20
+ export type SignedInPerson = { sub: string; label: string; isAdmin: boolean }
21
+
22
+ const DELEGATION_ID_PATTERN = /^curation-delegation-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/
23
+ const TOKEN_HASH_PATTERN = /^[0-9a-f]{64}$/
24
+ const MAXIMUM_AGENT_LABEL_LENGTH = 80
25
+ const USER_CODE_ATTEMPTS = 50
26
+
27
+ export function delegationResult(delegation: CurationDelegation, now: string) {
28
+ const state = delegationState(delegation, now)
29
+ return {
30
+ ok: true,
31
+ delegationId: delegation.delegationId,
32
+ token: null,
33
+ agentLabel: delegation.agentLabel,
34
+ grantorLabel: delegation.grantorLabel || null,
35
+ expiresAt: delegation.expiresAt,
36
+ revokedAt: delegation.revokedAt,
37
+ state,
38
+ userCode: state === "pending" && delegation.userCode ? formatUserCode(delegation.userCode) : null,
39
+ requestedHours: delegation.requestedHours ?? null,
40
+ pendingExpiresAt: delegation.pendingExpiresAt ?? null,
41
+ approvedAt: delegation.approvedAt ?? null,
42
+ deniedAt: delegation.deniedAt ?? null,
43
+ }
44
+ }
45
+
46
+ export async function livePendingRequests(store: DelegationStore, now: string): Promise<CurationDelegation[]> {
47
+ const live: CurationDelegation[] = []
48
+ for (const candidate of await store.listPending()) {
49
+ if (delegationState(candidate, now) === "pending") {
50
+ live.push(candidate)
51
+ } else if (candidate.grantorSub === PENDING_REQUEST_GRANTOR_SUB) {
52
+ await store.write({ ...candidate, grantorSub: EXPIRED_REQUEST_GRANTOR_SUB }, now, false)
53
+ }
54
+ }
55
+ return live
56
+ }
57
+
58
+ export async function requestDelegation(
59
+ store: DelegationStore,
60
+ input: { delegationId: string; tokenHash: string; agentLabel: string; hours: number | null | undefined },
61
+ now: string,
62
+ ) {
63
+ if (!DELEGATION_ID_PATTERN.test(input.delegationId)) {
64
+ throw new Error("The delegation id must look like curation-delegation-<uuid>.")
65
+ }
66
+ if (!TOKEN_HASH_PATTERN.test(input.tokenHash)) throw new Error("The token hash must be 64 lowercase hexadecimal characters.")
67
+ const agentLabel = input.agentLabel.trim()
68
+ if (!agentLabel) throw new Error("Name the agent asking for the delegation.")
69
+ if (agentLabel.length > MAXIMUM_AGENT_LABEL_LENGTH) throw new Error("The agent name is too long.")
70
+ const requestedHours = input.hours ?? 8
71
+ delegationExpiry(now, requestedHours)
72
+ if (await store.read(input.delegationId)) throw new Error("That delegation id is already in use.")
73
+ const live = await livePendingRequests(store, now)
74
+ if (live.length >= MAXIMUM_PENDING_DELEGATION_REQUESTS) {
75
+ throw new Error("Too many delegation requests are waiting for approval. Try again later.")
76
+ }
77
+ const takenCodes = new Set(live.map((request) => request.userCode))
78
+ let userCode = newUserCode()
79
+ for (let attempt = 0; takenCodes.has(userCode); attempt += 1) {
80
+ if (attempt >= USER_CODE_ATTEMPTS) throw new Error("Could not allocate a unique approval code. Try again.")
81
+ userCode = newUserCode()
82
+ }
83
+ const pendingExpiresAt = new Date(Date.parse(now) + PENDING_DELEGATION_MINUTES * 60_000).toISOString()
84
+ const delegation: CurationDelegation = {
85
+ delegationId: input.delegationId,
86
+ tokenHash: input.tokenHash,
87
+ grantorSub: PENDING_REQUEST_GRANTOR_SUB,
88
+ grantorLabel: "",
89
+ agentLabel,
90
+ createdAt: now,
91
+ expiresAt: pendingExpiresAt,
92
+ revokedAt: null,
93
+ userCode,
94
+ requestedHours,
95
+ pendingExpiresAt,
96
+ approvedAt: null,
97
+ deniedAt: null,
98
+ }
99
+ await store.write(delegation, now, true)
100
+ return {
101
+ ok: true,
102
+ delegationId: delegation.delegationId,
103
+ userCode: formatUserCode(userCode),
104
+ pendingExpiresAt,
105
+ requestedHours,
106
+ }
107
+ }
108
+
109
+ async function findLivePendingByCode(store: DelegationStore, typedCode: string, now: string): Promise<CurationDelegation> {
110
+ const code = normalizeUserCode(typedCode)
111
+ const found = code ? (await livePendingRequests(store, now)).find((request) => request.userCode === code) : undefined
112
+ if (!found) throw new Error("No pending delegation request has that code. It may have expired or already been decided.")
113
+ return found
114
+ }
115
+
116
+ export async function approveDelegation(store: DelegationStore, person: SignedInPerson, typedCode: string, now: string) {
117
+ const pending = await findLivePendingByCode(store, typedCode, now)
118
+ const approved: CurationDelegation = {
119
+ ...pending,
120
+ grantorSub: person.sub,
121
+ grantorLabel: person.label,
122
+ createdAt: now,
123
+ expiresAt: delegationExpiry(now, pending.requestedHours),
124
+ approvedAt: now,
125
+ userCode: null,
126
+ }
127
+ await store.write(approved, now, false)
128
+ return delegationResult(approved, now)
129
+ }
130
+
131
+ export async function denyDelegation(store: DelegationStore, person: SignedInPerson, typedCode: string, now: string) {
132
+ const pending = await findLivePendingByCode(store, typedCode, now)
133
+ const denied: CurationDelegation = { ...pending, grantorSub: person.sub, grantorLabel: person.label, deniedAt: now, userCode: null }
134
+ await store.write(denied, now, false)
135
+ return delegationResult(denied, now)
136
+ }
137
+
138
+ export async function delegationStatus(store: DelegationStore, delegationId: string, now: string) {
139
+ const delegation = await store.read(delegationId)
140
+ if (!delegation) throw new Error("That delegation was not found.")
141
+ return {
142
+ delegationId: delegation.delegationId,
143
+ state: delegationState(delegation, now),
144
+ expiresAt: delegation.expiresAt,
145
+ grantorLabel: delegation.grantorLabel || null,
146
+ }
147
+ }
@@ -1,4 +1,4 @@
1
- import { createHash, randomBytes } from "node:crypto"
1
+ import { createHash, randomBytes, randomInt } from "node:crypto"
2
2
 
3
3
  export const CURATION_DELEGATION_PAYLOAD_KIND = "curation-delegation"
4
4
  export const CURATION_DELEGATION_OWNER_TYPE = "curationDelegation"
@@ -14,11 +14,18 @@ export type CurationDelegation = {
14
14
  createdAt: string
15
15
  expiresAt: string
16
16
  revokedAt: string | null
17
+ userCode?: string | null
18
+ requestedHours?: number | null
19
+ pendingExpiresAt?: string | null
20
+ approvedAt?: string | null
21
+ deniedAt?: string | null
17
22
  }
18
23
 
24
+ export type DelegationState = "pending" | "approved" | "denied" | "expired" | "revoked"
25
+
19
26
  export type DelegationVerdict =
20
27
  | { ok: true }
21
- | { ok: false; reason: "unknown-token" | "wrong-agent" | "revoked" | "expired" }
28
+ | { ok: false; reason: "unknown-token" | "wrong-agent" | "revoked" | "not-approved" | "expired" }
22
29
 
23
30
  export function hashDelegationToken(token: string): string {
24
31
  return createHash("sha256").update(token, "utf8").digest("hex")
@@ -53,6 +60,10 @@ export function delegationVerdict(
53
60
  return { ok: false, reason: "wrong-agent" }
54
61
  }
55
62
  if (delegation.revokedAt) return { ok: false, reason: "revoked" }
63
+ const state = delegationState(delegation, presented.now)
64
+ if (state === "pending" || state === "denied" || (state === "expired" && delegation.userCode && !delegation.approvedAt)) {
65
+ return { ok: false, reason: "not-approved" }
66
+ }
56
67
  if (Date.parse(delegation.expiresAt) <= Date.parse(presented.now)) return { ok: false, reason: "expired" }
57
68
  return { ok: true }
58
69
  }
@@ -62,6 +73,43 @@ export function delegationRefusalMessage(reason: Exclude<DelegationVerdict, { ok
62
73
  "unknown-token": "That delegation token is not recognized.",
63
74
  "wrong-agent": "That delegation was granted to a different agent.",
64
75
  "revoked": "That delegation was revoked.",
76
+ "not-approved": "That delegation has not been approved by a person. Ask them to approve the code in the Newsroom.",
65
77
  "expired": "That delegation has expired. Ask the person for a new one.",
66
78
  }[reason]
67
79
  }
80
+
81
+ export const PENDING_DELEGATION_MINUTES = 10
82
+ export const MAXIMUM_PENDING_DELEGATION_REQUESTS = 5
83
+ export const PENDING_REQUEST_GRANTOR_SUB = "pending-device-authorization"
84
+ export const EXPIRED_REQUEST_GRANTOR_SUB = "expired-device-authorization"
85
+ export const USER_CODE_ALPHABET = "ABCDEFGHJKMNPQRSTUVWXYZ23456789"
86
+ export const USER_CODE_LENGTH = 8
87
+
88
+ export function newUserCode(): string {
89
+ let code = ""
90
+ for (let position = 0; position < USER_CODE_LENGTH; position += 1) {
91
+ code += USER_CODE_ALPHABET[randomInt(USER_CODE_ALPHABET.length)]
92
+ }
93
+ return code
94
+ }
95
+
96
+ export function formatUserCode(code: string): string {
97
+ return `${code.slice(0, 4)}-${code.slice(4)}`
98
+ }
99
+
100
+ export function normalizeUserCode(typed: string | null | undefined): string | null {
101
+ const stripped = (typed ?? "").toUpperCase().replace(/[\s-]/g, "")
102
+ if (stripped.length !== USER_CODE_LENGTH) return null
103
+ for (const character of stripped) if (!USER_CODE_ALPHABET.includes(character)) return null
104
+ return stripped
105
+ }
106
+
107
+ export function delegationState(delegation: CurationDelegation, now: string): DelegationState {
108
+ if (delegation.revokedAt) return "revoked"
109
+ if (delegation.deniedAt) return "denied"
110
+ if (delegation.userCode && !delegation.approvedAt) {
111
+ const pendingEnd = delegation.pendingExpiresAt ?? delegation.expiresAt
112
+ return Date.parse(pendingEnd) <= Date.parse(now) ? "expired" : "pending"
113
+ }
114
+ return Date.parse(delegation.expiresAt) <= Date.parse(now) ? "expired" : "approved"
115
+ }