@anthusai/papyrus 1.4.0-next.6 → 1.4.0-next.8

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.
@@ -211,6 +211,15 @@ const schema = a.schema({
211
211
  .returns(a.ref("SteeringActionResult"))
212
212
  .authorization((allow) => [allow.groups(categoryWriteGroups)])
213
213
  .handler(a.handler.function(categoryAction)),
214
+ CurationDelegationResult: a.customType({
215
+ ok: a.boolean().required(),
216
+ delegationId: a.id().required(),
217
+ token: a.string(),
218
+ agentLabel: a.string().required(),
219
+ grantorLabel: a.string(),
220
+ expiresAt: a.datetime().required(),
221
+ revokedAt: a.datetime(),
222
+ }),
214
223
  ReferenceCurationActionResult: a.customType({
215
224
  ok: a.boolean().required(),
216
225
  action: a.string().required(),
@@ -289,12 +298,62 @@ const schema = a.schema({
289
298
  reasonCode: a.string(),
290
299
  decisionRelationId: a.id(),
291
300
  shareable: a.boolean(),
301
+ sectionId: a.id(),
292
302
  })
293
303
  .returns(a.ref("ReferenceCurationActionResult"))
294
304
  .authorization((allow) => [
295
305
  allow.groups(categoryWriteGroups),
296
306
  ])
297
307
  .handler(a.handler.function(categoryAction)),
308
+ createCurationDelegation: a
309
+ .mutation()
310
+ .arguments({
311
+ agentLabel: a.string().required(),
312
+ hours: a.integer(),
313
+ })
314
+ .returns(a.ref("CurationDelegationResult"))
315
+ .authorization((allow) => [
316
+ allow.groups(categoryWriteGroups),
317
+ ])
318
+ .handler(a.handler.function(categoryAction)),
319
+ revokeCurationDelegation: a
320
+ .mutation()
321
+ .arguments({
322
+ delegationId: a.id().required(),
323
+ })
324
+ .returns(a.ref("CurationDelegationResult"))
325
+ .authorization((allow) => [
326
+ allow.groups(categoryWriteGroups),
327
+ ])
328
+ .handler(a.handler.function(categoryAction)),
329
+ listCurationDelegations: a
330
+ .query()
331
+ .returns(a.ref("CurationDelegationResult").array())
332
+ .authorization((allow) => [
333
+ allow.groups(categoryWriteGroups),
334
+ ])
335
+ .handler(a.handler.function(categoryAction)),
336
+ recordDelegatedCuration: a
337
+ .mutation()
338
+ .arguments({
339
+ referenceId: a.id().required(),
340
+ action: a.string().required(),
341
+ delegationId: a.id().required(),
342
+ delegationToken: a.string().required(),
343
+ agentLabel: a.string().required(),
344
+ onBehalfOf: a.string().required(),
345
+ instruction: a.string().required(),
346
+ note: a.string(),
347
+ reasonCode: a.string(),
348
+ decisionRelationId: a.id(),
349
+ shareable: a.boolean(),
350
+ sectionId: a.id(),
351
+ })
352
+ .returns(a.ref("ReferenceCurationActionResult"))
353
+ .authorization((allow) => [
354
+ allow.authenticated("identityPool"),
355
+ ])
356
+ .handler(a.handler.function(categoryAction)),
298
357
  setReferenceQualityRating: a
299
358
  .mutation()
300
359
  .arguments({
@@ -228,6 +228,16 @@ const schema = a.schema({
228
228
  .authorization((allow) => [allow.groups(categoryWriteGroups)])
229
229
  .handler(a.handler.function(categoryAction)),
230
230
 
231
+ CurationDelegationResult: a.customType({
232
+ ok: a.boolean().required(),
233
+ delegationId: a.id().required(),
234
+ token: a.string(),
235
+ agentLabel: a.string().required(),
236
+ grantorLabel: a.string(),
237
+ expiresAt: a.datetime().required(),
238
+ revokedAt: a.datetime(),
239
+ }),
240
+
231
241
  ReferenceCurationActionResult: a.customType({
232
242
  ok: a.boolean().required(),
233
243
  action: a.string().required(),
@@ -314,6 +324,7 @@ const schema = a.schema({
314
324
  reasonCode: a.string(),
315
325
  decisionRelationId: a.id(),
316
326
  shareable: a.boolean(),
327
+ sectionId: a.id(),
317
328
  })
318
329
  .returns(a.ref("ReferenceCurationActionResult"))
319
330
  .authorization((allow) => [
@@ -321,6 +332,59 @@ const schema = a.schema({
321
332
  ])
322
333
  .handler(a.handler.function(categoryAction)),
323
334
 
335
+ createCurationDelegation: a
336
+ .mutation()
337
+ .arguments({
338
+ agentLabel: a.string().required(),
339
+ hours: a.integer(),
340
+ })
341
+ .returns(a.ref("CurationDelegationResult"))
342
+ .authorization((allow) => [
343
+ allow.groups(categoryWriteGroups),
344
+ ])
345
+ .handler(a.handler.function(categoryAction)),
346
+
347
+ revokeCurationDelegation: a
348
+ .mutation()
349
+ .arguments({
350
+ delegationId: a.id().required(),
351
+ })
352
+ .returns(a.ref("CurationDelegationResult"))
353
+ .authorization((allow) => [
354
+ allow.groups(categoryWriteGroups),
355
+ ])
356
+ .handler(a.handler.function(categoryAction)),
357
+
358
+ listCurationDelegations: a
359
+ .query()
360
+ .returns(a.ref("CurationDelegationResult").array())
361
+ .authorization((allow) => [
362
+ allow.groups(categoryWriteGroups),
363
+ ])
364
+ .handler(a.handler.function(categoryAction)),
365
+
366
+ recordDelegatedCuration: a
367
+ .mutation()
368
+ .arguments({
369
+ referenceId: a.id().required(),
370
+ action: a.string().required(),
371
+ delegationId: a.id().required(),
372
+ delegationToken: a.string().required(),
373
+ agentLabel: a.string().required(),
374
+ onBehalfOf: a.string().required(),
375
+ instruction: a.string().required(),
376
+ note: a.string(),
377
+ reasonCode: a.string(),
378
+ decisionRelationId: a.id(),
379
+ shareable: a.boolean(),
380
+ sectionId: a.id(),
381
+ })
382
+ .returns(a.ref("ReferenceCurationActionResult"))
383
+ .authorization((allow) => [
384
+ allow.authenticated("identityPool"),
385
+ ])
386
+ .handler(a.handler.function(categoryAction)),
387
+
324
388
  setReferenceQualityRating: a
325
389
  .mutation()
326
390
  .arguments({
@@ -203,6 +203,15 @@ const baseDefinitions = {
203
203
  .returns(a.ref("SteeringActionResult"))
204
204
  .authorization((allow) => [allow.groups(categoryWriteGroups)])
205
205
  .handler(a.handler.function(categoryAction)),
206
+ CurationDelegationResult: a.customType({
207
+ ok: a.boolean().required(),
208
+ delegationId: a.id().required(),
209
+ token: a.string(),
210
+ agentLabel: a.string().required(),
211
+ grantorLabel: a.string(),
212
+ expiresAt: a.datetime().required(),
213
+ revokedAt: a.datetime(),
214
+ }),
206
215
  ReferenceCurationActionResult: a.customType({
207
216
  ok: a.boolean().required(),
208
217
  action: a.string().required(),
@@ -247,12 +256,62 @@ const baseDefinitions = {
247
256
  reasonCode: a.string(),
248
257
  decisionRelationId: a.id(),
249
258
  shareable: a.boolean(),
259
+ sectionId: a.id(),
250
260
  })
251
261
  .returns(a.ref("ReferenceCurationActionResult"))
252
262
  .authorization((allow) => [
253
263
  allow.groups(categoryWriteGroups),
254
264
  ])
255
265
  .handler(a.handler.function(categoryAction)),
266
+ createCurationDelegation: a
267
+ .mutation()
268
+ .arguments({
269
+ agentLabel: a.string().required(),
270
+ hours: a.integer(),
271
+ })
272
+ .returns(a.ref("CurationDelegationResult"))
273
+ .authorization((allow) => [
274
+ allow.groups(categoryWriteGroups),
275
+ ])
276
+ .handler(a.handler.function(categoryAction)),
277
+ revokeCurationDelegation: a
278
+ .mutation()
279
+ .arguments({
280
+ delegationId: a.id().required(),
281
+ })
282
+ .returns(a.ref("CurationDelegationResult"))
283
+ .authorization((allow) => [
284
+ allow.groups(categoryWriteGroups),
285
+ ])
286
+ .handler(a.handler.function(categoryAction)),
287
+ listCurationDelegations: a
288
+ .query()
289
+ .returns(a.ref("CurationDelegationResult").array())
290
+ .authorization((allow) => [
291
+ allow.groups(categoryWriteGroups),
292
+ ])
293
+ .handler(a.handler.function(categoryAction)),
294
+ recordDelegatedCuration: a
295
+ .mutation()
296
+ .arguments({
297
+ referenceId: a.id().required(),
298
+ action: a.string().required(),
299
+ delegationId: a.id().required(),
300
+ delegationToken: a.string().required(),
301
+ agentLabel: a.string().required(),
302
+ onBehalfOf: a.string().required(),
303
+ instruction: a.string().required(),
304
+ note: a.string(),
305
+ reasonCode: a.string(),
306
+ decisionRelationId: a.id(),
307
+ shareable: a.boolean(),
308
+ sectionId: a.id(),
309
+ })
310
+ .returns(a.ref("ReferenceCurationActionResult"))
311
+ .authorization((allow) => [
312
+ allow.authenticated("identityPool"),
313
+ ])
314
+ .handler(a.handler.function(categoryAction)),
256
315
  setReferenceQualityRating: a
257
316
  .mutation()
258
317
  .arguments({
@@ -222,6 +222,16 @@ const baseDefinitions = {
222
222
  .authorization((allow) => [allow.groups(categoryWriteGroups)])
223
223
  .handler(a.handler.function(categoryAction)),
224
224
 
225
+ CurationDelegationResult: a.customType({
226
+ ok: a.boolean().required(),
227
+ delegationId: a.id().required(),
228
+ token: a.string(),
229
+ agentLabel: a.string().required(),
230
+ grantorLabel: a.string(),
231
+ expiresAt: a.datetime().required(),
232
+ revokedAt: a.datetime(),
233
+ }),
234
+
225
235
  ReferenceCurationActionResult: a.customType({
226
236
  ok: a.boolean().required(),
227
237
  action: a.string().required(),
@@ -270,6 +280,7 @@ const baseDefinitions = {
270
280
  reasonCode: a.string(),
271
281
  decisionRelationId: a.id(),
272
282
  shareable: a.boolean(),
283
+ sectionId: a.id(),
273
284
  })
274
285
  .returns(a.ref("ReferenceCurationActionResult"))
275
286
  .authorization((allow) => [
@@ -277,6 +288,59 @@ const baseDefinitions = {
277
288
  ])
278
289
  .handler(a.handler.function(categoryAction)),
279
290
 
291
+ createCurationDelegation: a
292
+ .mutation()
293
+ .arguments({
294
+ agentLabel: a.string().required(),
295
+ hours: a.integer(),
296
+ })
297
+ .returns(a.ref("CurationDelegationResult"))
298
+ .authorization((allow) => [
299
+ allow.groups(categoryWriteGroups),
300
+ ])
301
+ .handler(a.handler.function(categoryAction)),
302
+
303
+ revokeCurationDelegation: a
304
+ .mutation()
305
+ .arguments({
306
+ delegationId: a.id().required(),
307
+ })
308
+ .returns(a.ref("CurationDelegationResult"))
309
+ .authorization((allow) => [
310
+ allow.groups(categoryWriteGroups),
311
+ ])
312
+ .handler(a.handler.function(categoryAction)),
313
+
314
+ listCurationDelegations: a
315
+ .query()
316
+ .returns(a.ref("CurationDelegationResult").array())
317
+ .authorization((allow) => [
318
+ allow.groups(categoryWriteGroups),
319
+ ])
320
+ .handler(a.handler.function(categoryAction)),
321
+
322
+ recordDelegatedCuration: a
323
+ .mutation()
324
+ .arguments({
325
+ referenceId: a.id().required(),
326
+ action: a.string().required(),
327
+ delegationId: a.id().required(),
328
+ delegationToken: a.string().required(),
329
+ agentLabel: a.string().required(),
330
+ onBehalfOf: a.string().required(),
331
+ instruction: a.string().required(),
332
+ note: a.string(),
333
+ reasonCode: a.string(),
334
+ decisionRelationId: a.id(),
335
+ shareable: a.boolean(),
336
+ sectionId: a.id(),
337
+ })
338
+ .returns(a.ref("ReferenceCurationActionResult"))
339
+ .authorization((allow) => [
340
+ allow.authenticated("identityPool"),
341
+ ])
342
+ .handler(a.handler.function(categoryAction)),
343
+
280
344
  setReferenceQualityRating: a
281
345
  .mutation()
282
346
  .arguments({
@@ -4,10 +4,23 @@ import { Amplify } from "aws-amplify";
4
4
  import { generateClient } from "aws-amplify/data";
5
5
  import type { Schema } from "../../data/resource";
6
6
  import { putJsonModelPayload, putTextModelPayload, readJsonModelPayload } from "../shared/model-payloads";
7
- import { curationActorKind, referenceCurationMetadata } from "../shared/reference-curation-metadata";
7
+ import { curationActorKind, referenceCurationMetadata, validatedCurationSectionId } from "../shared/reference-curation-metadata";
8
+ import {
9
+ CURATION_DELEGATION_OWNER_TYPE,
10
+ CURATION_DELEGATION_PAYLOAD_KIND,
11
+ delegationExpiry,
12
+ delegationRefusalMessage,
13
+ delegationVerdict,
14
+ newDelegationToken,
15
+ type CurationDelegation,
16
+ } from "../shared/curation-delegation";
8
17
 
9
18
  type ReviewHandler = Schema["reviewSteeringProposal"]["functionHandler"];
10
19
  type ReferenceCurationHandler = Schema["reviewReferenceCuration"]["functionHandler"];
20
+ type CreateCurationDelegationHandler = Schema["createCurationDelegation"]["functionHandler"];
21
+ type RevokeCurationDelegationHandler = Schema["revokeCurationDelegation"]["functionHandler"];
22
+ type ListCurationDelegationsHandler = Schema["listCurationDelegations"]["functionHandler"];
23
+ type RecordDelegatedCurationHandler = Schema["recordDelegatedCuration"]["functionHandler"];
11
24
  type ReferenceQualityHandler = Schema["setReferenceQualityRating"]["functionHandler"];
12
25
  type ReferenceNoteHandler = Schema["createReferenceNote"]["functionHandler"];
13
26
  type MoveReferenceCorpusHandler = Schema["moveReferenceCorpus"]["functionHandler"];
@@ -23,6 +36,10 @@ type ReviewReferenceTopicLabelHandler = Schema["reviewReferenceTopicLabel"]["fun
23
36
  type CategoryActionEvent =
24
37
  | Parameters<ReviewHandler>[0]
25
38
  | Parameters<ReferenceCurationHandler>[0]
39
+ | Parameters<CreateCurationDelegationHandler>[0]
40
+ | Parameters<RevokeCurationDelegationHandler>[0]
41
+ | Parameters<ListCurationDelegationsHandler>[0]
42
+ | Parameters<RecordDelegatedCurationHandler>[0]
26
43
  | Parameters<ReferenceQualityHandler>[0]
27
44
  | Parameters<ReferenceNoteHandler>[0]
28
45
  | Parameters<MoveReferenceCorpusHandler>[0]
@@ -88,6 +105,10 @@ export const handler = async (event: CategoryActionEvent) => {
88
105
  }
89
106
  if (fieldName === "reviewSteeringProposal") return reviewSteeringProposal(event as Parameters<ReviewHandler>[0]);
90
107
  if (fieldName === "reviewReferenceCuration") return reviewReferenceCuration(event as Parameters<ReferenceCurationHandler>[0]);
108
+ if (fieldName === "createCurationDelegation") return createCurationDelegation(event as Parameters<CreateCurationDelegationHandler>[0]);
109
+ if (fieldName === "revokeCurationDelegation") return revokeCurationDelegation(event as Parameters<RevokeCurationDelegationHandler>[0]);
110
+ if (fieldName === "listCurationDelegations") return listCurationDelegations(event as Parameters<ListCurationDelegationsHandler>[0]);
111
+ if (fieldName === "recordDelegatedCuration") return recordDelegatedCuration(event as Parameters<RecordDelegatedCurationHandler>[0]);
91
112
  if (fieldName === "setReferenceQualityRating") return setReferenceQualityRating(event as Parameters<ReferenceQualityHandler>[0]);
92
113
  if (fieldName === "createReferenceNote") return createReferenceNote(event as Parameters<ReferenceNoteHandler>[0]);
93
114
  if (fieldName === "moveReferenceCorpus") return moveReferenceCorpus(event as Parameters<MoveReferenceCorpusHandler>[0]);
@@ -162,13 +183,21 @@ async function reviewSteeringProposal(event: Parameters<ReviewHandler>[0]) {
162
183
  };
163
184
  }
164
185
 
165
- async function reviewReferenceCuration(event: Parameters<ReferenceCurationHandler>[0]) {
186
+ async function reviewReferenceCuration(
187
+ event: Parameters<ReferenceCurationHandler>[0],
188
+ delegation: { delegationId: string; relayedBy: string; instruction: string } | null = null,
189
+ ) {
166
190
  const client = await getDataClient();
167
191
  const referenceId = normalizeRequiredString(event.arguments.referenceId, "referenceId");
168
192
  const action = normalizeReferenceCurationAction(event.arguments.action);
169
193
  const nextStatus = referenceCurationStatusForAction(action);
170
194
  const reasonCode = normalizeReferenceRejectionReasonCode(event.arguments.reasonCode, action === "reject");
171
195
  const reference = await getRequiredRecord(client.models.Reference, referenceId, "Reference");
196
+ const requestedSectionId = normalizeOptionalString(event.arguments.sectionId);
197
+ const sectionRecord = requestedSectionId
198
+ ? (await client.models.NewsroomSection.get({ id: requestedSectionId })).data ?? null
199
+ : null;
200
+ const sectionId = validatedCurationSectionId(requestedSectionId, action, sectionRecord);
172
201
  const now = new Date().toISOString();
173
202
  const actorSub = normalizeOptionalString(event.arguments.actorSub) ?? getIdentitySub(event);
174
203
  const actorLabel = normalizeOptionalString(event.arguments.actorLabel) ?? getIdentityLabel(event);
@@ -234,7 +263,9 @@ async function reviewReferenceCuration(event: Parameters<ReferenceCurationHandle
234
263
  referenceLineageId,
235
264
  decisionRelationId: normalizeOptionalString(event.arguments.decisionRelationId),
236
265
  shareable: event.arguments.shareable ?? null,
237
- actorKind: curationActorKind(event.identity),
266
+ actorKind: delegation ? "human" : curationActorKind(event.identity),
267
+ sectionId,
268
+ delegation,
238
269
  }),
239
270
  { filename: "metadata.json", now },
240
271
  );
@@ -301,6 +332,151 @@ async function reviewReferenceCuration(event: Parameters<ReferenceCurationHandle
301
332
  };
302
333
  }
303
334
 
335
+ function requireSignedInPerson(event: { identity?: unknown }): { sub: string; label: string; isAdmin: boolean } {
336
+ if (curationActorKind(event.identity) !== "human") {
337
+ throw new Error("Only a person signed in to the Newsroom can manage curation delegations.");
338
+ }
339
+ const identity = event.identity as { sub?: unknown; claims?: Record<string, unknown> };
340
+ const claims = identity.claims ?? {};
341
+ const groups = Array.isArray(claims["cognito:groups"]) ? (claims["cognito:groups"] as unknown[]) : [];
342
+ return {
343
+ sub: normalizeRequiredString(claims.sub ?? identity.sub, "identity.sub"),
344
+ label: normalizeOptionalString(claims.email) ?? normalizeOptionalString(claims.name) ?? "Newsroom editor",
345
+ isAdmin: groups.includes("admin"),
346
+ };
347
+ }
348
+
349
+ async function readCurationDelegation(client: DataClient, delegationId: string): Promise<CurationDelegation | null> {
350
+ const stored = await readJsonModelPayload(client as any, "knowledgeRawPayload", delegationId, "raw_payload", CURATION_DELEGATION_PAYLOAD_KIND);
351
+ return stored ? (stored as unknown as CurationDelegation) : null;
352
+ }
353
+
354
+ async function writeCurationDelegation(client: DataClient, delegation: CurationDelegation, now: string, created: boolean) {
355
+ const input = {
356
+ id: delegation.delegationId,
357
+ ownerType: CURATION_DELEGATION_OWNER_TYPE,
358
+ ownerId: delegation.grantorSub,
359
+ payloadKind: CURATION_DELEGATION_PAYLOAD_KIND,
360
+ createdAt: delegation.createdAt,
361
+ updatedAt: now,
362
+ };
363
+ if (created) {
364
+ await requireDataResult(client.models.KnowledgeRawPayload.create(input), "create curation delegation");
365
+ } else {
366
+ await requireDataResult(client.models.KnowledgeRawPayload.update(input), "update curation delegation");
367
+ }
368
+ await putJsonModelPayload(
369
+ client as any,
370
+ { ownerKind: "knowledgeRawPayload", ownerId: delegation.delegationId },
371
+ "raw_payload",
372
+ CURATION_DELEGATION_PAYLOAD_KIND,
373
+ delegation as unknown as Record<string, unknown>,
374
+ { filename: "curation-delegation.json", now },
375
+ );
376
+ }
377
+
378
+ function delegationResult(delegation: CurationDelegation, token: string | null) {
379
+ 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,
387
+ };
388
+ }
389
+
390
+ async function createCurationDelegation(event: Parameters<CreateCurationDelegationHandler>[0]) {
391
+ const person = requireSignedInPerson(event);
392
+ const client = await getDataClient();
393
+ const now = new Date().toISOString();
394
+ const delegationId = `curation-delegation-${randomUUID()}`;
395
+ const { token, tokenHash } = newDelegationToken(delegationId);
396
+ const delegation: CurationDelegation = {
397
+ delegationId,
398
+ tokenHash,
399
+ grantorSub: person.sub,
400
+ grantorLabel: person.label,
401
+ agentLabel: normalizeRequiredString(event.arguments.agentLabel, "agentLabel"),
402
+ createdAt: now,
403
+ expiresAt: delegationExpiry(now, event.arguments.hours),
404
+ revokedAt: null,
405
+ };
406
+ await writeCurationDelegation(client, delegation, now, true);
407
+ return delegationResult(delegation, token);
408
+ }
409
+
410
+ async function revokeCurationDelegation(event: Parameters<RevokeCurationDelegationHandler>[0]) {
411
+ const person = requireSignedInPerson(event);
412
+ const client = await getDataClient();
413
+ const delegation = await readCurationDelegation(client, normalizeRequiredString(event.arguments.delegationId, "delegationId"));
414
+ 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.");
416
+ const now = new Date().toISOString();
417
+ const revoked = { ...delegation, revokedAt: delegation.revokedAt ?? now };
418
+ await writeCurationDelegation(client, revoked, now, false);
419
+ return delegationResult(revoked, null);
420
+ }
421
+
422
+ async function listCurationDelegations(event: Parameters<ListCurationDelegationsHandler>[0]) {
423
+ const person = requireSignedInPerson(event);
424
+ const client = await getDataClient();
425
+ const delegations: CurationDelegation[] = [];
426
+ let nextToken: string | null | undefined;
427
+ do {
428
+ const page: any = await (client.models.KnowledgeRawPayload as any).listKnowledgeRawPayloadsByOwnerAndKind(
429
+ { ownerId: person.sub, payloadKind: { eq: CURATION_DELEGATION_PAYLOAD_KIND } },
430
+ { nextToken },
431
+ );
432
+ assertNoDataErrors(page.errors, "list curation delegations");
433
+ for (const payload of page.data ?? []) {
434
+ const delegation = await readCurationDelegation(client, payload.id);
435
+ if (delegation) delegations.push(delegation);
436
+ }
437
+ nextToken = page.nextToken;
438
+ } while (nextToken);
439
+ return delegations
440
+ .sort((first, second) => second.createdAt.localeCompare(first.createdAt))
441
+ .map((delegation) => delegationResult(delegation, null));
442
+ }
443
+
444
+ async function recordDelegatedCuration(event: Parameters<RecordDelegatedCurationHandler>[0]) {
445
+ const client = await getDataClient();
446
+ const now = new Date().toISOString();
447
+ const delegationId = normalizeRequiredString(event.arguments.delegationId, "delegationId");
448
+ const agentLabel = normalizeRequiredString(event.arguments.agentLabel, "agentLabel");
449
+ const delegation = await readCurationDelegation(client, delegationId);
450
+ const verdict = delegationVerdict(delegation, {
451
+ token: normalizeRequiredString(event.arguments.delegationToken, "delegationToken"),
452
+ agentLabel,
453
+ now,
454
+ });
455
+ if (!verdict.ok || !delegation) {
456
+ throw new Error(delegationRefusalMessage(verdict.ok ? "unknown-token" : verdict.reason));
457
+ }
458
+ const instruction = normalizeRequiredString(event.arguments.instruction, "instruction").slice(0, 2000);
459
+ const namedPerson = normalizeRequiredString(event.arguments.onBehalfOf, "onBehalfOf");
460
+ if (namedPerson.toLowerCase() !== delegation.grantorLabel.toLowerCase()) {
461
+ throw new Error(`That delegation was granted by ${delegation.grantorLabel}, not ${namedPerson}.`);
462
+ }
463
+ const onBehalfOfPerson = {
464
+ ...event,
465
+ arguments: {
466
+ referenceId: event.arguments.referenceId,
467
+ action: event.arguments.action,
468
+ note: event.arguments.note,
469
+ reasonCode: event.arguments.reasonCode,
470
+ decisionRelationId: event.arguments.decisionRelationId,
471
+ shareable: event.arguments.shareable,
472
+ sectionId: event.arguments.sectionId,
473
+ actorSub: delegation.grantorSub,
474
+ actorLabel: delegation.grantorLabel,
475
+ },
476
+ } as unknown as Parameters<ReferenceCurationHandler>[0];
477
+ return reviewReferenceCuration(onBehalfOfPerson, { delegationId, relayedBy: agentLabel, instruction });
478
+ }
479
+
304
480
  async function setReferenceQualityRating(event: Parameters<ReferenceQualityHandler>[0]) {
305
481
  const client = await getDataClient();
306
482
  const referenceId = normalizeRequiredString(event.arguments.referenceId, "referenceId");
@@ -0,0 +1,67 @@
1
+ import { createHash, randomBytes } from "node:crypto"
2
+
3
+ export const CURATION_DELEGATION_PAYLOAD_KIND = "curation-delegation"
4
+ export const CURATION_DELEGATION_OWNER_TYPE = "curationDelegation"
5
+ export const DEFAULT_DELEGATION_HOURS = 8
6
+ export const MAXIMUM_DELEGATION_HOURS = 72
7
+
8
+ export type CurationDelegation = {
9
+ delegationId: string
10
+ tokenHash: string
11
+ grantorSub: string
12
+ grantorLabel: string
13
+ agentLabel: string
14
+ createdAt: string
15
+ expiresAt: string
16
+ revokedAt: string | null
17
+ }
18
+
19
+ export type DelegationVerdict =
20
+ | { ok: true }
21
+ | { ok: false; reason: "unknown-token" | "wrong-agent" | "revoked" | "expired" }
22
+
23
+ export function hashDelegationToken(token: string): string {
24
+ return createHash("sha256").update(token, "utf8").digest("hex")
25
+ }
26
+
27
+ export function newDelegationToken(delegationId: string): { token: string; tokenHash: string } {
28
+ const token = `${delegationId}~${randomBytes(24).toString("base64url")}`
29
+ return { token, tokenHash: hashDelegationToken(token) }
30
+ }
31
+
32
+ export function delegationIdFromToken(token: string): string | null {
33
+ const separator = token.indexOf("~")
34
+ return separator > 0 ? token.slice(0, separator) : null
35
+ }
36
+
37
+ export function delegationExpiry(now: string, hours: number | null | undefined): string {
38
+ const requested = hours ?? DEFAULT_DELEGATION_HOURS
39
+ if (!Number.isFinite(requested) || requested <= 0 || requested > MAXIMUM_DELEGATION_HOURS) {
40
+ throw new Error(`A delegation lasts between 1 and ${MAXIMUM_DELEGATION_HOURS} hours.`)
41
+ }
42
+ return new Date(Date.parse(now) + requested * 3_600_000).toISOString()
43
+ }
44
+
45
+ export function delegationVerdict(
46
+ delegation: CurationDelegation | null,
47
+ presented: { token: string; agentLabel: string; now: string },
48
+ ): DelegationVerdict {
49
+ if (!delegation || delegation.tokenHash !== hashDelegationToken(presented.token)) {
50
+ return { ok: false, reason: "unknown-token" }
51
+ }
52
+ if (delegation.agentLabel.trim().toLowerCase() !== presented.agentLabel.trim().toLowerCase()) {
53
+ return { ok: false, reason: "wrong-agent" }
54
+ }
55
+ if (delegation.revokedAt) return { ok: false, reason: "revoked" }
56
+ if (Date.parse(delegation.expiresAt) <= Date.parse(presented.now)) return { ok: false, reason: "expired" }
57
+ return { ok: true }
58
+ }
59
+
60
+ export function delegationRefusalMessage(reason: Exclude<DelegationVerdict, { ok: true }>["reason"]): string {
61
+ return {
62
+ "unknown-token": "That delegation token is not recognized.",
63
+ "wrong-agent": "That delegation was granted to a different agent.",
64
+ "revoked": "That delegation was revoked.",
65
+ "expired": "That delegation has expired. Ask the person for a new one.",
66
+ }[reason]
67
+ }
@@ -13,6 +13,8 @@ export type ReferenceCurationMetadataInput = {
13
13
  decisionRelationId?: string | null
14
14
  shareable?: boolean | null
15
15
  actorKind?: CurationActorKind | null
16
+ sectionId?: string | null
17
+ delegation?: { delegationId: string; relayedBy: string; instruction: string } | null
16
18
  }
17
19
 
18
20
  export type CurationActorKind = "human" | "service"
@@ -38,7 +40,41 @@ export function referenceCurationMetadata(input: ReferenceCurationMetadataInput)
38
40
  referenceId: input.referenceId,
39
41
  referenceLineageId: input.referenceLineageId,
40
42
  ...(input.actorKind ? {actorKind: input.actorKind} : {}),
43
+ ...(input.sectionId?.trim() ? {sectionId: input.sectionId.trim()} : {}),
44
+ ...(input.delegation
45
+ ? {attestation: "delegated", relayedBy: input.delegation.relayedBy, delegationId: input.delegation.delegationId,
46
+ instruction: input.delegation.instruction}
47
+ : {}),
41
48
  ...(decisionRelationId ? {decisionRelationId} : {}),
42
49
  ...(decisionRelationId ? {shareable: input.shareable === true} : {}),
43
50
  }
44
51
  }
52
+
53
+ export type CurationSectionCandidate = {
54
+ id?: string | null
55
+ enabled?: boolean | null
56
+ enabledStatus?: string | null
57
+ } | null | undefined
58
+
59
+ /**
60
+ * A curation may name the newsroom Section the Reference belongs in, but only
61
+ * when it accepts the Reference and the Section exists and is enabled.
62
+ */
63
+ export function validatedCurationSectionId(
64
+ requestedSectionId: string | null | undefined,
65
+ action: string,
66
+ section: CurationSectionCandidate,
67
+ ): string | null {
68
+ const sectionId = requestedSectionId?.trim() || null
69
+ if (!sectionId) return null
70
+ if (action !== "accept") {
71
+ throw new Error("A section can be recorded only when accepting a reference.")
72
+ }
73
+ if (!section || section.id !== sectionId) {
74
+ throw new Error(`Newsroom section ${sectionId} was not found.`)
75
+ }
76
+ if (section.enabled === false || section.enabledStatus === "disabled") {
77
+ throw new Error(`Newsroom section ${sectionId} is disabled.`)
78
+ }
79
+ return sectionId
80
+ }