@anthusai/papyrus 1.11.0 → 1.12.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.
Files changed (34) hide show
  1. package/amplify/data/resource.js +67 -0
  2. package/amplify/data/resource.ts +72 -0
  3. package/amplify/data/schema.js +67 -0
  4. package/amplify/data/schema.ts +72 -0
  5. package/amplify/functions/category-action/handler.ts +172 -2
  6. package/amplify/functions/content-actions/requirements.txt +1 -1
  7. package/amplify/functions/content-actions/resource.js +1 -1
  8. package/amplify/functions/content-actions/resource.ts +1 -1
  9. package/amplify/functions/renditions/requirements.txt +1 -1
  10. package/amplify/functions/shared/curation-delegation.ts +67 -0
  11. package/amplify/functions/shared/reference-curation-metadata.ts +20 -0
  12. package/amplify/publication/renditions-function.js +1 -1
  13. package/amplify/publication/renditions-function.ts +1 -1
  14. package/components/administration-delegations-panel.tsx +204 -0
  15. package/components/curation-delegation-client.ts +81 -0
  16. package/components/news-desk-taxonomy-client.ts +7 -0
  17. package/components/newsroom-organizer.css +22 -0
  18. package/components/newsroom-references-view.tsx +56 -21
  19. package/components/prospectus-account-chooser.tsx +14 -19
  20. package/components/reference-cyclotron-status.tsx +5 -5
  21. package/components/reference-cyclotron-suggestion.tsx +143 -0
  22. package/components/reference-live-agreement.tsx +62 -0
  23. package/components/topic-steering-workspace.tsx +49 -3
  24. package/lib/content-source-context.ts +9 -1
  25. package/lib/curation-delegation-helpers.ts +59 -0
  26. package/lib/cyclotron-suggestions.ts +263 -0
  27. package/lib/graphql-content-repository.ts +42 -7
  28. package/lib/local-preview-seed.ts +141 -3
  29. package/lib/local-preview-tenant.ts +27 -0
  30. package/lib/prospectus-tenant.ts +22 -0
  31. package/lib/relevance-decisions.ts +29 -0
  32. package/lib/tenant-keys.ts +32 -0
  33. package/package.json +10 -1
  34. package/routes.manifest.json +1 -1
@@ -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 { referenceCurationMetadata } from "../shared/reference-curation-metadata";
7
+ import { curationActorKind, referenceCurationMetadata } 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,7 +183,10 @@ 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);
@@ -234,6 +258,8 @@ async function reviewReferenceCuration(event: Parameters<ReferenceCurationHandle
234
258
  referenceLineageId,
235
259
  decisionRelationId: normalizeOptionalString(event.arguments.decisionRelationId),
236
260
  shareable: event.arguments.shareable ?? null,
261
+ actorKind: delegation ? "human" : curationActorKind(event.identity),
262
+ delegation,
237
263
  }),
238
264
  { filename: "metadata.json", now },
239
265
  );
@@ -300,6 +326,150 @@ async function reviewReferenceCuration(event: Parameters<ReferenceCurationHandle
300
326
  };
301
327
  }
302
328
 
329
+ function requireSignedInPerson(event: { identity?: unknown }): { sub: string; label: string; isAdmin: boolean } {
330
+ if (curationActorKind(event.identity) !== "human") {
331
+ throw new Error("Only a person signed in to the Newsroom can manage curation delegations.");
332
+ }
333
+ const identity = event.identity as { sub?: unknown; claims?: Record<string, unknown> };
334
+ const claims = identity.claims ?? {};
335
+ const groups = Array.isArray(claims["cognito:groups"]) ? (claims["cognito:groups"] as unknown[]) : [];
336
+ return {
337
+ sub: normalizeRequiredString(claims.sub ?? identity.sub, "identity.sub"),
338
+ label: normalizeOptionalString(claims.email) ?? normalizeOptionalString(claims.name) ?? "Newsroom editor",
339
+ isAdmin: groups.includes("admin"),
340
+ };
341
+ }
342
+
343
+ async function readCurationDelegation(client: DataClient, delegationId: string): Promise<CurationDelegation | null> {
344
+ const stored = await readJsonModelPayload(client as any, "knowledgeRawPayload", delegationId, "raw_payload", CURATION_DELEGATION_PAYLOAD_KIND);
345
+ return stored ? (stored as unknown as CurationDelegation) : null;
346
+ }
347
+
348
+ async function writeCurationDelegation(client: DataClient, delegation: CurationDelegation, now: string, created: boolean) {
349
+ const input = {
350
+ id: delegation.delegationId,
351
+ ownerType: CURATION_DELEGATION_OWNER_TYPE,
352
+ ownerId: delegation.grantorSub,
353
+ payloadKind: CURATION_DELEGATION_PAYLOAD_KIND,
354
+ createdAt: delegation.createdAt,
355
+ updatedAt: now,
356
+ };
357
+ if (created) {
358
+ await requireDataResult(client.models.KnowledgeRawPayload.create(input), "create curation delegation");
359
+ } else {
360
+ await requireDataResult(client.models.KnowledgeRawPayload.update(input), "update curation delegation");
361
+ }
362
+ await putJsonModelPayload(
363
+ client as any,
364
+ { ownerKind: "knowledgeRawPayload", ownerId: delegation.delegationId },
365
+ "raw_payload",
366
+ CURATION_DELEGATION_PAYLOAD_KIND,
367
+ delegation as unknown as Record<string, unknown>,
368
+ { filename: "curation-delegation.json", now },
369
+ );
370
+ }
371
+
372
+ function delegationResult(delegation: CurationDelegation, token: string | null) {
373
+ return {
374
+ ok: true,
375
+ delegationId: delegation.delegationId,
376
+ token,
377
+ agentLabel: delegation.agentLabel,
378
+ grantorLabel: delegation.grantorLabel,
379
+ expiresAt: delegation.expiresAt,
380
+ revokedAt: delegation.revokedAt,
381
+ };
382
+ }
383
+
384
+ async function createCurationDelegation(event: Parameters<CreateCurationDelegationHandler>[0]) {
385
+ const person = requireSignedInPerson(event);
386
+ const client = await getDataClient();
387
+ const now = new Date().toISOString();
388
+ const delegationId = `curation-delegation-${randomUUID()}`;
389
+ const { token, tokenHash } = newDelegationToken(delegationId);
390
+ const delegation: CurationDelegation = {
391
+ delegationId,
392
+ tokenHash,
393
+ grantorSub: person.sub,
394
+ grantorLabel: person.label,
395
+ agentLabel: normalizeRequiredString(event.arguments.agentLabel, "agentLabel"),
396
+ createdAt: now,
397
+ expiresAt: delegationExpiry(now, event.arguments.hours),
398
+ revokedAt: null,
399
+ };
400
+ await writeCurationDelegation(client, delegation, now, true);
401
+ return delegationResult(delegation, token);
402
+ }
403
+
404
+ async function revokeCurationDelegation(event: Parameters<RevokeCurationDelegationHandler>[0]) {
405
+ const person = requireSignedInPerson(event);
406
+ const client = await getDataClient();
407
+ const delegation = await readCurationDelegation(client, normalizeRequiredString(event.arguments.delegationId, "delegationId"));
408
+ if (!delegation) throw new Error("That delegation was not found.");
409
+ if (delegation.grantorSub !== person.sub && !person.isAdmin) throw new Error("Only the person who granted a delegation can revoke it.");
410
+ const now = new Date().toISOString();
411
+ const revoked = { ...delegation, revokedAt: delegation.revokedAt ?? now };
412
+ await writeCurationDelegation(client, revoked, now, false);
413
+ return delegationResult(revoked, null);
414
+ }
415
+
416
+ async function listCurationDelegations(event: Parameters<ListCurationDelegationsHandler>[0]) {
417
+ const person = requireSignedInPerson(event);
418
+ const client = await getDataClient();
419
+ const delegations: CurationDelegation[] = [];
420
+ let nextToken: string | null | undefined;
421
+ do {
422
+ const page: any = await (client.models.KnowledgeRawPayload as any).listKnowledgeRawPayloadsByOwnerAndKind(
423
+ { ownerId: person.sub, payloadKind: { eq: CURATION_DELEGATION_PAYLOAD_KIND } },
424
+ { nextToken },
425
+ );
426
+ assertNoDataErrors(page.errors, "list curation delegations");
427
+ for (const payload of page.data ?? []) {
428
+ const delegation = await readCurationDelegation(client, payload.id);
429
+ if (delegation) delegations.push(delegation);
430
+ }
431
+ nextToken = page.nextToken;
432
+ } while (nextToken);
433
+ return delegations
434
+ .sort((first, second) => second.createdAt.localeCompare(first.createdAt))
435
+ .map((delegation) => delegationResult(delegation, null));
436
+ }
437
+
438
+ async function recordDelegatedCuration(event: Parameters<RecordDelegatedCurationHandler>[0]) {
439
+ const client = await getDataClient();
440
+ const now = new Date().toISOString();
441
+ const delegationId = normalizeRequiredString(event.arguments.delegationId, "delegationId");
442
+ const agentLabel = normalizeRequiredString(event.arguments.agentLabel, "agentLabel");
443
+ const delegation = await readCurationDelegation(client, delegationId);
444
+ const verdict = delegationVerdict(delegation, {
445
+ token: normalizeRequiredString(event.arguments.delegationToken, "delegationToken"),
446
+ agentLabel,
447
+ now,
448
+ });
449
+ if (!verdict.ok || !delegation) {
450
+ throw new Error(delegationRefusalMessage(verdict.ok ? "unknown-token" : verdict.reason));
451
+ }
452
+ const instruction = normalizeRequiredString(event.arguments.instruction, "instruction").slice(0, 2000);
453
+ const namedPerson = normalizeRequiredString(event.arguments.onBehalfOf, "onBehalfOf");
454
+ if (namedPerson.toLowerCase() !== delegation.grantorLabel.toLowerCase()) {
455
+ throw new Error(`That delegation was granted by ${delegation.grantorLabel}, not ${namedPerson}.`);
456
+ }
457
+ const onBehalfOfPerson = {
458
+ ...event,
459
+ arguments: {
460
+ referenceId: event.arguments.referenceId,
461
+ action: event.arguments.action,
462
+ note: event.arguments.note,
463
+ reasonCode: event.arguments.reasonCode,
464
+ decisionRelationId: event.arguments.decisionRelationId,
465
+ shareable: event.arguments.shareable,
466
+ actorSub: delegation.grantorSub,
467
+ actorLabel: delegation.grantorLabel,
468
+ },
469
+ } as unknown as Parameters<ReferenceCurationHandler>[0];
470
+ return reviewReferenceCuration(onBehalfOfPerson, { delegationId, relayedBy: agentLabel, instruction });
471
+ }
472
+
303
473
  async function setReferenceQualityRating(event: Parameters<ReferenceQualityHandler>[0]) {
304
474
  const client = await getDataClient();
305
475
  const referenceId = normalizeRequiredString(event.arguments.referenceId, "referenceId");
@@ -1,5 +1,5 @@
1
1
  PyYAML>=6.0.2,<7
2
2
  citeproc-py>=0.8.2,<1
3
- anthus-markus==0.6.0
3
+ anthus-markus==0.7.0
4
4
  Pillow>=11.3,<12
5
5
  awscrt>=0.23,<1
@@ -7,7 +7,7 @@ import { fileURLToPath } from "node:url";
7
7
  import { pythonLambdaCode } from "../shared/python-bundle.js";
8
8
  const dirname = path.dirname(fileURLToPath(import.meta.url));
9
9
  const projectRoot = path.resolve(dirname, "../../..");
10
- export const contentActionsRequirements = ["PyYAML>=6.0.2,<7", "citeproc-py>=0.8.2,<1", "anthus-markus==0.6.0", "Pillow>=11.3,<12", "awscrt>=0.23,<1"];
10
+ export const contentActionsRequirements = ["PyYAML>=6.0.2,<7", "citeproc-py>=0.8.2,<1", "anthus-markus==0.7.0", "Pillow>=11.3,<12", "awscrt>=0.23,<1"];
11
11
  function installCheckoutRequirements(outputDir) {
12
12
  const requirementsPath = path.join(projectRoot, "amplify/functions/content-actions/requirements.txt");
13
13
  execSync(`python3 -m pip install -r "${requirementsPath}" -t "${outputDir}" --no-cache-dir --platform manylinux2014_aarch64 --implementation cp --python-version 3.12 --only-binary=:all: --ignore-requires-python`, { cwd: projectRoot, stdio: "inherit" });
@@ -10,7 +10,7 @@ import { pythonLambdaCode, type SiteBundleFile } from "../shared/python-bundle";
10
10
  const dirname = path.dirname(fileURLToPath(import.meta.url));
11
11
  const projectRoot = path.resolve(dirname, "../../..");
12
12
 
13
- export const contentActionsRequirements = ["PyYAML>=6.0.2,<7", "citeproc-py>=0.8.2,<1", "anthus-markus==0.6.0", "Pillow>=11.3,<12", "awscrt>=0.23,<1"];
13
+ export const contentActionsRequirements = ["PyYAML>=6.0.2,<7", "citeproc-py>=0.8.2,<1", "anthus-markus==0.7.0", "Pillow>=11.3,<12", "awscrt>=0.23,<1"];
14
14
 
15
15
  function installCheckoutRequirements(outputDir: string): void {
16
16
  const requirementsPath = path.join(projectRoot, "amplify/functions/content-actions/requirements.txt");
@@ -1,4 +1,4 @@
1
1
  PyYAML>=6.0.2,<7
2
2
  citeproc-py>=0.8.2,<1
3
- anthus-markus==0.6.0
3
+ anthus-markus==0.7.0
4
4
  Pillow>=11.3,<12
@@ -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
+ }
@@ -12,6 +12,21 @@ export type ReferenceCurationMetadataInput = {
12
12
  referenceLineageId: string
13
13
  decisionRelationId?: string | null
14
14
  shareable?: boolean | null
15
+ actorKind?: CurationActorKind | null
16
+ delegation?: { delegationId: string; relayedBy: string; instruction: string } | null
17
+ }
18
+
19
+ export type CurationActorKind = "human" | "service"
20
+
21
+ /**
22
+ * A curation is human only when the caller signed in through the user pool, so
23
+ * AppSync supplied a verified subject claim. Request arguments never decide it;
24
+ * IAM-signed service callers and agents have no such claim.
25
+ */
26
+ export function curationActorKind(identity: unknown): CurationActorKind {
27
+ const claims = (identity as {claims?: Record<string, unknown>} | null | undefined)?.claims
28
+ const subject = claims?.sub
29
+ return typeof subject === "string" && subject.trim() !== "" ? "human" : "service"
15
30
  }
16
31
 
17
32
  export function referenceCurationMetadata(input: ReferenceCurationMetadataInput): Record<string, unknown> {
@@ -23,6 +38,11 @@ export function referenceCurationMetadata(input: ReferenceCurationMetadataInput)
23
38
  curationReasonCode: input.reasonCode,
24
39
  referenceId: input.referenceId,
25
40
  referenceLineageId: input.referenceLineageId,
41
+ ...(input.actorKind ? {actorKind: input.actorKind} : {}),
42
+ ...(input.delegation
43
+ ? {attestation: "delegated", relayedBy: input.delegation.relayedBy, delegationId: input.delegation.delegationId,
44
+ instruction: input.delegation.instruction}
45
+ : {}),
26
46
  ...(decisionRelationId ? {decisionRelationId} : {}),
27
47
  ...(decisionRelationId ? {shareable: input.shareable === true} : {}),
28
48
  }
@@ -11,7 +11,7 @@ import { pythonLambdaCode } from "../functions/shared/python-bundle.js";
11
11
  import { RENDITIONS_EPHEMERAL_STORAGE_MB, RENDITIONS_MEDIA_PREFIX, RENDITIONS_MEMORY_MB, RENDITIONS_TIMEOUT_SECONDS, renditionsEnvironment, renditionsEventPattern, } from "./renditions-settings.js";
12
12
  const dirname = path.dirname(fileURLToPath(import.meta.url));
13
13
  const projectRoot = path.resolve(dirname, "../..");
14
- export const renditionsRequirements = ["PyYAML>=6.0.2,<7", "citeproc-py>=0.8.2,<1", "anthus-markus==0.6.0", "Pillow>=11.3,<12"];
14
+ export const renditionsRequirements = ["PyYAML>=6.0.2,<7", "citeproc-py>=0.8.2,<1", "anthus-markus==0.7.0", "Pillow>=11.3,<12"];
15
15
  function installCheckoutRequirements(outputDir) {
16
16
  const requirementsPath = path.join(projectRoot, "amplify/functions/renditions/requirements.txt");
17
17
  execSync(`python3 -m pip install -r "${requirementsPath}" -t "${outputDir}" --no-cache-dir --platform manylinux2014_aarch64 --implementation cp --python-version 3.12 --only-binary=:all: --ignore-requires-python`, { cwd: projectRoot, stdio: "inherit" });
@@ -22,7 +22,7 @@ import {
22
22
  const dirname = path.dirname(fileURLToPath(import.meta.url));
23
23
  const projectRoot = path.resolve(dirname, "../..");
24
24
 
25
- export const renditionsRequirements = ["PyYAML>=6.0.2,<7", "citeproc-py>=0.8.2,<1", "anthus-markus==0.6.0", "Pillow>=11.3,<12"];
25
+ export const renditionsRequirements = ["PyYAML>=6.0.2,<7", "citeproc-py>=0.8.2,<1", "anthus-markus==0.7.0", "Pillow>=11.3,<12"];
26
26
 
27
27
  export type RenditionsFunctionProps = {
28
28
  siteBucket: s3.IBucket;
@@ -0,0 +1,204 @@
1
+ "use client";
2
+
3
+ import { useEffect, useRef, useState } from "react";
4
+ import { fetchAuthSession } from "aws-amplify/auth";
5
+ import { isLocalPreviewEnabled } from "../lib/local-preview";
6
+ import {
7
+ DEFAULT_DELEGATION_HOURS,
8
+ MAXIMUM_DELEGATION_HOURS,
9
+ MINIMUM_DELEGATION_HOURS,
10
+ buildDelegatedCommandLine,
11
+ delegationExpiryText,
12
+ describeDelegationError,
13
+ validateDelegationInput,
14
+ type CurationDelegationRecord,
15
+ } from "../lib/curation-delegation-helpers";
16
+ import { createCurationDelegation, listCurationDelegations, revokeCurationDelegation, type CreatedCurationDelegation } from "./curation-delegation-client";
17
+
18
+ type IssuedToken = { agentLabel: string; token: string };
19
+
20
+ async function readSignedInEmail(): Promise<string> {
21
+ if (isLocalPreviewEnabled()) return "ryan@example.com";
22
+ try {
23
+ const session = await fetchAuthSession();
24
+ const email = session.tokens?.idToken?.payload?.email;
25
+ return typeof email === "string" ? email : "";
26
+ } catch {
27
+ return "";
28
+ }
29
+ }
30
+
31
+ export function AdministrationDelegationsPanel({ disabled }: { disabled: boolean }) {
32
+ const [agentLabelText, setAgentLabelText] = useState("");
33
+ const [hoursText, setHoursText] = useState(String(DEFAULT_DELEGATION_HOURS));
34
+ const [busy, setBusy] = useState(false);
35
+ const [errorMessage, setErrorMessage] = useState<string | null>(null);
36
+ const [issued, setIssued] = useState<IssuedToken | null>(null);
37
+ const [copied, setCopied] = useState<"token" | "command" | null>(null);
38
+ const [email, setEmail] = useState("");
39
+ const [delegations, setDelegations] = useState<CurationDelegationRecord[]>([]);
40
+ const [confirmingRevokeId, setConfirmingRevokeId] = useState<string | null>(null);
41
+ const [nowMilliseconds, setNowMilliseconds] = useState(() => Date.now());
42
+ const mounted = useRef(true);
43
+
44
+ useEffect(() => {
45
+ mounted.current = true;
46
+ void readSignedInEmail().then((value) => {
47
+ if (mounted.current) setEmail(value);
48
+ });
49
+ void refreshDelegations();
50
+ const timer = window.setInterval(() => setNowMilliseconds(Date.now()), 30_000);
51
+ return () => {
52
+ mounted.current = false;
53
+ window.clearInterval(timer);
54
+ setIssued(null);
55
+ };
56
+ }, []);
57
+
58
+ async function refreshDelegations() {
59
+ try {
60
+ const listed = await listCurationDelegations();
61
+ if (mounted.current) setDelegations(listed);
62
+ } catch (error) {
63
+ if (mounted.current) setErrorMessage(describeDelegationError(error));
64
+ }
65
+ }
66
+
67
+ async function handleCreate() {
68
+ const validation = validateDelegationInput(agentLabelText, hoursText);
69
+ if (!validation.ok) {
70
+ setErrorMessage(validation.message);
71
+ return;
72
+ }
73
+ setBusy(true);
74
+ setErrorMessage(null);
75
+ try {
76
+ const created: CreatedCurationDelegation = await createCurationDelegation(validation.agentLabel, validation.hours);
77
+ setIssued({ agentLabel: created.agentLabel, token: created.token });
78
+ setCopied(null);
79
+ await refreshDelegations();
80
+ setAgentLabelText("");
81
+ setNowMilliseconds(Date.now());
82
+ } catch (error) {
83
+ setErrorMessage(describeDelegationError(error));
84
+ } finally {
85
+ setBusy(false);
86
+ }
87
+ }
88
+
89
+ async function handleRevoke(delegationId: string) {
90
+ setBusy(true);
91
+ setErrorMessage(null);
92
+ try {
93
+ await revokeCurationDelegation(delegationId);
94
+ await refreshDelegations();
95
+ setConfirmingRevokeId(null);
96
+ } catch (error) {
97
+ setErrorMessage(describeDelegationError(error));
98
+ } finally {
99
+ setBusy(false);
100
+ }
101
+ }
102
+
103
+ async function copyText(kind: "token" | "command", text: string) {
104
+ try {
105
+ await navigator.clipboard.writeText(text);
106
+ setCopied(kind);
107
+ } catch {
108
+ setErrorMessage("Copying was blocked by the browser. Select the text and copy it by hand.");
109
+ }
110
+ }
111
+
112
+ const commandLine = issued ? buildDelegatedCommandLine({ email, agentLabel: issued.agentLabel, token: issued.token }) : "";
113
+
114
+ return (
115
+ <div data-news-desk-admin-panel="delegations">
116
+ <article className="news-desk-doctrine-card">
117
+ <div className="news-desk-doctrine-card__header">
118
+ <div>
119
+ <h3>Agent delegations</h3>
120
+ <span>Let one agent approve or reject References for you, for a short time</span>
121
+ </div>
122
+ </div>
123
+ <p className="news-desk-delegation-note">
124
+ A delegation is a token you hand to one named agent. While it lasts, that agent&apos;s approvals and rejections are recorded under your name and marked as relayed. You can revoke it at any moment.
125
+ </p>
126
+ <p className="news-desk-delegation-note" data-delegation-words-note>
127
+ The agent must pass your actual words, in quotes, as the instruction on every approval or rejection. Papyrus keeps those words with the record. An agent that paraphrases or invents them is not following this delegation.
128
+ </p>
129
+ <div className="news-desk-delegation-form">
130
+ <label className="news-desk-doctrine-card__field">
131
+ <span>Agent name</span>
132
+ <input value={agentLabelText} onChange={(event) => setAgentLabelText(event.target.value)} placeholder="curator-agent" autoComplete="off" disabled={disabled || busy} />
133
+ </label>
134
+ <label className="news-desk-doctrine-card__field">
135
+ <span>{`Hours (${MINIMUM_DELEGATION_HOURS} to ${MAXIMUM_DELEGATION_HOURS})`}</span>
136
+ <input value={hoursText} onChange={(event) => setHoursText(event.target.value)} inputMode="numeric" disabled={disabled || busy} />
137
+ </label>
138
+ <button type="button" className="news-desk-primary-action-button" data-delegation-create onClick={() => void handleCreate()} disabled={disabled || busy}>
139
+ {busy && !issued ? "Creating" : "Create delegation"}
140
+ </button>
141
+ </div>
142
+ {errorMessage ? <p className="news-desk-delegation-error" role="alert" data-delegation-error>{errorMessage}</p> : null}
143
+ </article>
144
+
145
+ {issued ? (
146
+ <article className="news-desk-delegation-token" data-delegation-token-box aria-live="polite">
147
+ <h3>Copy this token now</h3>
148
+ <p>This token is shown once. Papyrus does not keep it, and it cannot be shown again. If you lose it, revoke the delegation and create a new one.</p>
149
+ <div className="news-desk-delegation-token__row">
150
+ <code data-delegation-token>{issued.token}</code>
151
+ <button type="button" onClick={() => void copyText("token", issued.token)}>{copied === "token" ? "Copied" : "Copy token"}</button>
152
+ </div>
153
+ <p className="story-label">Command line for {issued.agentLabel}</p>
154
+ <div className="news-desk-delegation-token__row">
155
+ <code data-delegation-command>{commandLine}</code>
156
+ <button type="button" onClick={() => void copyText("command", commandLine)}>{copied === "command" ? "Copied" : "Copy command"}</button>
157
+ </div>
158
+ <div className="news-desk-doctrine-card__actions">
159
+ <button type="button" className="news-desk-primary-action-button" data-delegation-done onClick={() => { setIssued(null); setCopied(null); }}>Done</button>
160
+ </div>
161
+ </article>
162
+ ) : null}
163
+
164
+ <article className="news-desk-doctrine-card">
165
+ <div className="news-desk-doctrine-card__header">
166
+ <div>
167
+ <h3>Delegations</h3>
168
+ <span>Granted by you, newest first</span>
169
+ </div>
170
+ </div>
171
+ {delegations.length === 0 ? (
172
+ <p className="news-desk-delegation-note" data-delegation-empty>You have not granted any delegations.</p>
173
+ ) : (
174
+ <ul className="news-desk-delegation-list">
175
+ {delegations.map((entry) => {
176
+ const stateText = delegationExpiryText(entry.expiresAt, entry.revokedAt, nowMilliseconds);
177
+ const live = !entry.revokedAt && stateText !== "Expired";
178
+ return (
179
+ <li key={entry.delegationId} data-delegation-row={entry.delegationId} data-delegation-live={live || undefined}>
180
+ <div>
181
+ <strong>{entry.agentLabel}</strong>
182
+ <span>{`Granted by ${entry.grantorLabel ?? "you"}`}</span>
183
+ </div>
184
+ <span className="news-desk-delegation-state">{stateText}</span>
185
+ {live ? (
186
+ confirmingRevokeId === entry.delegationId ? (
187
+ <span className="news-desk-delegation-confirm">
188
+ <span>{`Stop ${entry.agentLabel} from curating for you?`}</span>
189
+ <button type="button" data-delegation-revoke-confirm onClick={() => void handleRevoke(entry.delegationId)} disabled={busy}>Revoke now</button>
190
+ <button type="button" onClick={() => setConfirmingRevokeId(null)} disabled={busy}>Keep</button>
191
+ </span>
192
+ ) : (
193
+ <button type="button" data-delegation-revoke onClick={() => setConfirmingRevokeId(entry.delegationId)} disabled={disabled || busy}>Revoke</button>
194
+ )
195
+ ) : null}
196
+ </li>
197
+ );
198
+ })}
199
+ </ul>
200
+ )}
201
+ </article>
202
+ </div>
203
+ );
204
+ }
@@ -0,0 +1,81 @@
1
+ "use client";
2
+
3
+ import { generateClient } from "aws-amplify/data";
4
+ import { isLocalPreviewEnabled } from "../lib/local-preview";
5
+ import type { CurationDelegationRecord } from "../lib/curation-delegation-helpers";
6
+ import { configureAmplifyClient } from "./amplify-client-provider";
7
+
8
+ const USER_POOL_AUTH_MODE = "userPool";
9
+
10
+ export type CreatedCurationDelegation = CurationDelegationRecord & { token: string };
11
+
12
+ const CREATE_MUTATION = `
13
+ mutation CreateCurationDelegation($agentLabel: String!, $hours: Int) {
14
+ createCurationDelegation(agentLabel: $agentLabel, hours: $hours) {
15
+ delegationId token agentLabel grantorLabel expiresAt revokedAt
16
+ }
17
+ }
18
+ `;
19
+
20
+ const REVOKE_MUTATION = `
21
+ mutation RevokeCurationDelegation($delegationId: ID!) {
22
+ revokeCurationDelegation(delegationId: $delegationId) {
23
+ delegationId agentLabel grantorLabel expiresAt revokedAt
24
+ }
25
+ }
26
+ `;
27
+
28
+ const LIST_QUERY = `
29
+ query ListCurationDelegations {
30
+ listCurationDelegations {
31
+ delegationId agentLabel grantorLabel expiresAt revokedAt
32
+ }
33
+ }
34
+ `;
35
+
36
+ const previewDelegations: CurationDelegationRecord[] = [];
37
+
38
+ async function runGraphql<T>(query: string, variables: Record<string, unknown>): Promise<T> {
39
+ configureAmplifyClient();
40
+ const client = generateClient() as unknown as {
41
+ graphql: (options: Record<string, unknown>) => Promise<{ data?: T | null; errors?: Array<{ message?: string }> | null }>;
42
+ };
43
+ const response = await client.graphql({ query, variables, authMode: USER_POOL_AUTH_MODE });
44
+ if (response.errors?.length) throw Object.assign(new Error(response.errors[0]?.message ?? "Request failed."), { errors: response.errors });
45
+ if (!response.data) throw new Error("The server returned no result.");
46
+ return response.data;
47
+ }
48
+
49
+ export async function createCurationDelegation(agentLabel: string, hours: number): Promise<CreatedCurationDelegation> {
50
+ if (isLocalPreviewEnabled()) {
51
+ const delegationId = `curation-delegation-preview-${previewDelegations.length + 1}`;
52
+ const record: CurationDelegationRecord = {
53
+ delegationId,
54
+ agentLabel,
55
+ grantorLabel: "ryan@example.com",
56
+ expiresAt: new Date(Date.now() + hours * 3_600_000).toISOString(),
57
+ revokedAt: null,
58
+ };
59
+ previewDelegations.push(record);
60
+ return { ...record, token: `${delegationId}~preview-token-not-valid-anywhere` };
61
+ }
62
+ const data = await runGraphql<{ createCurationDelegation: CreatedCurationDelegation }>(CREATE_MUTATION, { agentLabel, hours });
63
+ return data.createCurationDelegation;
64
+ }
65
+
66
+ export async function revokeCurationDelegation(delegationId: string): Promise<CurationDelegationRecord> {
67
+ if (isLocalPreviewEnabled()) {
68
+ const record = previewDelegations.find((entry) => entry.delegationId === delegationId);
69
+ if (!record) throw new Error("That delegation was not found.");
70
+ record.revokedAt = record.revokedAt ?? new Date().toISOString();
71
+ return { ...record };
72
+ }
73
+ const data = await runGraphql<{ revokeCurationDelegation: CurationDelegationRecord }>(REVOKE_MUTATION, { delegationId });
74
+ return data.revokeCurationDelegation;
75
+ }
76
+
77
+ export async function listCurationDelegations(): Promise<CurationDelegationRecord[]> {
78
+ if (isLocalPreviewEnabled()) return previewDelegations.map((entry) => ({ ...entry })).reverse();
79
+ const data = await runGraphql<{ listCurationDelegations: CurationDelegationRecord[] }>(LIST_QUERY, {});
80
+ return data.listCurationDelegations ?? [];
81
+ }