@anthusai/papyrus 5.43.0-next.4 → 5.43.0-next.40

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 (117) hide show
  1. package/amplify/data/resource.js +6 -0
  2. package/amplify/data/resource.ts +7 -0
  3. package/amplify/data/schema.js +6 -0
  4. package/amplify/data/schema.ts +7 -0
  5. package/amplify/functions/category-action/handler.ts +2 -1
  6. package/amplify/functions/content-actions/handler.py +8 -0
  7. package/amplify/functions/content-actions/requirements.txt +2 -0
  8. package/amplify/functions/content-actions/resource.js +1 -1
  9. package/amplify/functions/content-actions/resource.ts +1 -1
  10. package/amplify/functions/model-attachment-upload/handler.ts +1 -0
  11. package/amplify/functions/monitoring-view/schema.js +16 -0
  12. package/amplify/functions/monitoring-view/schema.ts +16 -0
  13. package/amplify/functions/procedure-action/handler.ts +2 -10
  14. package/amplify/functions/proposal-actions/proposals.py +20 -3
  15. package/amplify/plugins/reader-steering/function/handler.py +1 -3
  16. package/amplify/plugins/reader-steering/function/moderation.py +19 -0
  17. package/app/api/newsroom/source-frameable/route.ts +12 -0
  18. package/app/api/newsroom/source-icon/route.ts +20 -0
  19. package/app/newsroom/[section]/[...selection]/page.tsx +2 -0
  20. package/app/newsroom/[section]/page.tsx +1 -0
  21. package/bin/papyrus-app.mjs +12 -1
  22. package/bin/papyrus-infra.mjs +21 -1
  23. package/components/auto-accepted-badge.tsx +21 -0
  24. package/components/conversation-context.tsx +52 -0
  25. package/components/conversation-provider.tsx +20 -0
  26. package/components/conversation-thread.tsx +174 -0
  27. package/components/decision-control.tsx +306 -0
  28. package/components/dev-sandbox-editor-sign-in.tsx +2 -2
  29. package/components/markdown-context.tsx +412 -0
  30. package/components/monitoring-overview-view.tsx +143 -21
  31. package/components/monitoring-panel.tsx +64 -4
  32. package/components/needs-me-bodies.tsx +302 -0
  33. package/components/needs-me-detail.tsx +24 -0
  34. package/components/needs-me-feed.tsx +594 -0
  35. package/components/needs-me-reference-detail.tsx +79 -0
  36. package/components/needs-me-shared.tsx +48 -0
  37. package/components/news-desk-client-provider.tsx +24 -3
  38. package/components/news-desk-page.tsx +2 -1
  39. package/components/news-desk-taxonomy-client.ts +253 -31
  40. package/components/newsroom-access-gate.tsx +13 -20
  41. package/components/newsroom-board-skeleton.tsx +20 -0
  42. package/components/newsroom-board-view.tsx +2 -6
  43. package/components/newsroom-client-shell.tsx +2 -1
  44. package/components/newsroom-ops-overview.tsx +88 -73
  45. package/components/newsroom-ops-shell.tsx +8 -28
  46. package/components/newsroom-organizer.css +127 -3
  47. package/components/newsroom-references-view.tsx +334 -158
  48. package/components/notification-cards.tsx +370 -0
  49. package/components/notification-center.tsx +376 -213
  50. package/components/papyrus-console-shell.tsx +2 -1
  51. package/components/proposals-panel.tsx +21 -24
  52. package/components/proposals-view.tsx +4 -2
  53. package/components/reader-auth-control.tsx +3 -1
  54. package/components/reference-capture-client.ts +138 -0
  55. package/components/reference-capture-panel.tsx +268 -0
  56. package/components/reference-conversation.tsx +43 -0
  57. package/components/reference-feed.tsx +514 -0
  58. package/components/reference-review-card.tsx +62 -197
  59. package/components/reference-source-preview.tsx +27 -5
  60. package/components/reference-stories.tsx +34 -0
  61. package/components/references-page-loading.tsx +61 -0
  62. package/components/references-prefetch.ts +52 -0
  63. package/components/story-reporting-trail-client.ts +45 -8
  64. package/components/story-reporting-trail.tsx +24 -13
  65. package/components/topic-steering-workspace.tsx +50 -439
  66. package/components/ui/auto-grow-textarea.tsx +18 -0
  67. package/components/use-conversation.ts +84 -0
  68. package/components/use-media-query.ts +16 -0
  69. package/components/use-reference-preview-attachments.ts +101 -0
  70. package/components/use-reference-ratings.ts +188 -0
  71. package/infra/amplify-app-shell.js +18 -2
  72. package/infra/amplify-app-shell.ts +18 -2
  73. package/infra/chat-stack.js +222 -0
  74. package/infra/chat-stack.ts +250 -0
  75. package/infra/chat.test.js +142 -0
  76. package/infra/chat.test.ts +151 -0
  77. package/infra/index.js +1 -0
  78. package/infra/site-config.js +58 -1
  79. package/infra/site-config.ts +78 -1
  80. package/lib/category-repository.ts +6 -1
  81. package/lib/content-actions-client.ts +20 -0
  82. package/lib/conversation.ts +154 -0
  83. package/lib/local-preview-feed-fixture.ts +63 -0
  84. package/lib/local-preview-seed.ts +24 -2
  85. package/lib/local-preview-synthetic-rejects.ts +41 -0
  86. package/lib/monitoring-client.ts +29 -2
  87. package/lib/monitoring-overview.ts +52 -1
  88. package/lib/monitoring-paging.ts +58 -0
  89. package/lib/needs-me-items.ts +289 -0
  90. package/lib/needs-me-ranking.ts +315 -0
  91. package/lib/newsroom-all-pages.ts +21 -0
  92. package/lib/newsroom-chat-identity.ts +2 -1
  93. package/lib/newsroom-index-filters.ts +18 -0
  94. package/lib/newsroom-nav.ts +1 -1
  95. package/lib/newsroom-organizer-nav.ts +2 -1
  96. package/lib/newsroom-references.ts +25 -1
  97. package/lib/notification-feed.ts +212 -0
  98. package/lib/proposals-client.ts +29 -0
  99. package/lib/reader-subject.ts +3 -2
  100. package/lib/reference-capture.ts +294 -0
  101. package/lib/reference-feed-cache.ts +170 -0
  102. package/lib/reference-feed-ranking.ts +236 -0
  103. package/lib/reference-policy.ts +12 -0
  104. package/lib/reference-preview.ts +112 -0
  105. package/lib/review-client.ts +71 -5
  106. package/lib/review-drafts.ts +277 -0
  107. package/lib/review-fixture.ts +56 -2
  108. package/lib/review-requests.ts +89 -24
  109. package/lib/site-brand.ts +2 -0
  110. package/lib/source-frameable.ts +77 -0
  111. package/lib/source-icon.ts +104 -0
  112. package/lib/staff-groups.ts +23 -0
  113. package/lib/staging-gate.ts +2 -2
  114. package/lib/story-reporting-trail.ts +33 -2
  115. package/package.json +45 -2
  116. package/routes.manifest.json +23 -1
  117. package/components/newsroom-overview-skeleton.tsx +0 -25
@@ -603,6 +603,12 @@ const schema = a.schema({
603
603
  .returns(a.json())
604
604
  .authorization((allow) => [allow.groups(contentWriteGroups)])
605
605
  .handler(functionHandler("contentActions", contentActions)),
606
+ correctReferenceCapture: a
607
+ .mutation()
608
+ .arguments({ input: a.json().required() })
609
+ .returns(a.json())
610
+ .authorization((allow) => [allow.groups(contentWriteGroups)])
611
+ .handler(functionHandler("contentActions", contentActions)),
606
612
  saveItemDraft: a
607
613
  .mutation()
608
614
  .arguments({ input: a.json().required() })
@@ -657,6 +657,13 @@ const schema = a.schema({
657
657
  .authorization((allow) => [allow.groups(contentWriteGroups)])
658
658
  .handler(functionHandler("contentActions", contentActions)),
659
659
 
660
+ correctReferenceCapture: a
661
+ .mutation()
662
+ .arguments({ input: a.json().required() })
663
+ .returns(a.json())
664
+ .authorization((allow) => [allow.groups(contentWriteGroups)])
665
+ .handler(functionHandler("contentActions", contentActions)),
666
+
660
667
  saveItemDraft: a
661
668
  .mutation()
662
669
  .arguments({ input: a.json().required() })
@@ -530,6 +530,12 @@ function buildBaseDefinitions(includeEditions) {
530
530
  .returns(a.json())
531
531
  .authorization((allow) => [allow.groups(contentWriteGroups)])
532
532
  .handler(functionHandler("contentActions", contentActions)),
533
+ correctReferenceCapture: a
534
+ .mutation()
535
+ .arguments({ input: a.json().required() })
536
+ .returns(a.json())
537
+ .authorization((allow) => [allow.groups(contentWriteGroups)])
538
+ .handler(functionHandler("contentActions", contentActions)),
533
539
  saveItemDraft: a
534
540
  .mutation()
535
541
  .arguments({ input: a.json().required() })
@@ -582,6 +582,13 @@ const definitions = {
582
582
  .authorization((allow) => [allow.groups(contentWriteGroups)])
583
583
  .handler(functionHandler("contentActions", contentActions)),
584
584
 
585
+ correctReferenceCapture: a
586
+ .mutation()
587
+ .arguments({ input: a.json().required() })
588
+ .returns(a.json())
589
+ .authorization((allow) => [allow.groups(contentWriteGroups)])
590
+ .handler(functionHandler("contentActions", contentActions)),
591
+
585
592
  saveItemDraft: a
586
593
  .mutation()
587
594
  .arguments({ input: a.json().required() })
@@ -24,6 +24,7 @@ import {
24
24
  requestDelegation,
25
25
  type DelegationStore,
26
26
  } from "../shared/curation-delegation-flow";
27
+ import { claimGroups } from "../../../lib/staff-groups";
27
28
 
28
29
  type ReviewHandler = Schema["reviewSteeringProposal"]["functionHandler"];
29
30
  type ReferenceCurationHandler = Schema["reviewReferenceCuration"]["functionHandler"];
@@ -360,7 +361,7 @@ function requireSignedInPerson(event: { identity?: unknown }): { sub: string; la
360
361
  }
361
362
  const identity = event.identity as { sub?: unknown; claims?: Record<string, unknown> };
362
363
  const claims = identity.claims ?? {};
363
- const groups = Array.isArray(claims["cognito:groups"]) ? (claims["cognito:groups"] as unknown[]) : [];
364
+ const groups = claimGroups(claims["cognito:groups"]);
364
365
  return {
365
366
  sub: normalizeRequiredString(claims.sub ?? identity.sub, "identity.sub"),
366
367
  label: normalizeOptionalString(claims.email) ?? normalizeOptionalString(claims.name) ?? "Newsroom editor",
@@ -365,6 +365,14 @@ def handler(event, context):
365
365
  client, arguments, event.get("identity") or {}, receipt_store=receipt_store)
366
366
  except IntakeError as error:
367
367
  return {"ok": False, "errors": [{"code": "intake-rejected", "message": str(error)}]}
368
+ if field == "correctReferenceCapture":
369
+ from papyrus_content.reference_curation_store import CurationError, correct_reference_capture
370
+
371
+ client, _claims = create_authoring_client()
372
+ try:
373
+ return correct_reference_capture(client, arguments, event.get("identity") or {})
374
+ except CurationError as error:
375
+ return {"ok": False, "errors": [{"code": "correction-rejected", "message": str(error)}]}
368
376
  if field == "deriveMarkus":
369
377
  return derive_markus_action(arguments)
370
378
  if field == "saveItemDraft":
@@ -4,3 +4,5 @@ anthus-markus==0.7.0
4
4
  Pillow>=11.3,<12
5
5
  awscrt>=0.23,<1
6
6
  humanus>=0.33,<0.34
7
+ pypdf>=6.12.1,<7
8
+ youtube-transcript-api>=1.2.4,<2
@@ -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.7.0", "Pillow>=11.3,<12", "awscrt>=0.23,<1", "humanus>=0.33,<0.34"];
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", "humanus>=0.33,<0.34", "pypdf>=6.12.1,<7", "youtube-transcript-api>=1.2.4,<2"];
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.7.0", "Pillow>=11.3,<12", "awscrt>=0.23,<1", "humanus>=0.33,<0.34"];
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", "humanus>=0.33,<0.34", "pypdf>=6.12.1,<7", "youtube-transcript-api>=1.2.4,<2"];
14
14
 
15
15
  function installCheckoutRequirements(outputDir: string): void {
16
16
  const requirementsPath = path.join(projectRoot, "amplify/functions/content-actions/requirements.txt");
@@ -41,6 +41,7 @@ const ALLOWED_ROLES = new Set([
41
41
  "item_excerpt",
42
42
  "message_body",
43
43
  "metadata",
44
+ "preview",
44
45
  "ontology_concept_profile",
45
46
  "ontology_relation_explanation",
46
47
  "published_item_body",
@@ -14,6 +14,14 @@ export const monitoringViewSchemaDefinitions = {
14
14
  .returns(a.json())
15
15
  .authorization((allow) => [allow.groups(editors)])
16
16
  .handler(functionHandler("monitoringView", monitoringView)),
17
+ // One more page of one Monitoring list (runs, routerRuns, items, emailSubmissions), after a cursor from
18
+ // the overview or a previous page. filter "held" lists only held email submissions, restarting from the newest.
19
+ monitoringPage: a
20
+ .query()
21
+ .arguments({ list: a.string().required(), cursor: a.string(), filter: a.string(), scope: a.string() })
22
+ .returns(a.json())
23
+ .authorization((allow) => [allow.groups(editors)])
24
+ .handler(functionHandler("monitoringView", monitoringView)),
17
25
  monitoringEmailSubmission: a
18
26
  .query()
19
27
  .arguments({ receiptId: a.string().required() })
@@ -26,6 +34,14 @@ export const monitoringViewSchemaDefinitions = {
26
34
  .returns(a.json())
27
35
  .authorization((allow) => [allow.groups(editors)])
28
36
  .handler(functionHandler("monitoringView", monitoringView)),
37
+ // Accept ("release") or Reject ("discard") one held email submission. The function validates the
38
+ // signed-in editor and that the message is routed to this publication; the receiver applies it.
39
+ monitoringDecideEmailSubmission: a
40
+ .mutation()
41
+ .arguments({ receiptId: a.string().required(), decision: a.string().required(), reason: a.string() })
42
+ .returns(a.json())
43
+ .authorization((allow) => [allow.groups(editors)])
44
+ .handler(functionHandler("monitoringView", monitoringView)),
29
45
  monitoringResolveEmailArticleLink: a
30
46
  .mutation()
31
47
  .arguments({ receiptId: a.string().required(), candidateIndex: a.integer().required() })
@@ -16,6 +16,14 @@ export const monitoringViewSchemaDefinitions = {
16
16
  .returns(a.json())
17
17
  .authorization((allow) => [allow.groups(editors)])
18
18
  .handler(functionHandler("monitoringView", monitoringView)),
19
+ // One more page of one Monitoring list (runs, routerRuns, items, emailSubmissions), after a cursor from
20
+ // the overview or a previous page. filter "held" lists only held email submissions, restarting from the newest.
21
+ monitoringPage: a
22
+ .query()
23
+ .arguments({ list: a.string().required(), cursor: a.string(), filter: a.string(), scope: a.string() })
24
+ .returns(a.json())
25
+ .authorization((allow) => [allow.groups(editors)])
26
+ .handler(functionHandler("monitoringView", monitoringView)),
19
27
  monitoringEmailSubmission: a
20
28
  .query()
21
29
  .arguments({ receiptId: a.string().required() })
@@ -28,6 +36,14 @@ export const monitoringViewSchemaDefinitions = {
28
36
  .returns(a.json())
29
37
  .authorization((allow) => [allow.groups(editors)])
30
38
  .handler(functionHandler("monitoringView", monitoringView)),
39
+ // Accept ("release") or Reject ("discard") one held email submission. The function validates the
40
+ // signed-in editor and that the message is routed to this publication; the receiver applies it.
41
+ monitoringDecideEmailSubmission: a
42
+ .mutation()
43
+ .arguments({ receiptId: a.string().required(), decision: a.string().required(), reason: a.string() })
44
+ .returns(a.json())
45
+ .authorization((allow) => [allow.groups(editors)])
46
+ .handler(functionHandler("monitoringView", monitoringView)),
31
47
  monitoringResolveEmailArticleLink: a
32
48
  .mutation()
33
49
  .arguments({ receiptId: a.string().required(), candidateIndex: a.integer().required() })
@@ -3,6 +3,7 @@ import { getAmplifyDataClientConfig } from "@aws-amplify/backend/function/runtim
3
3
  import { Amplify } from "aws-amplify";
4
4
  import { generateClient } from "aws-amplify/data";
5
5
  import type { Schema } from "../../data/resource";
6
+ import { claimGroups } from "../../../lib/staff-groups";
6
7
 
7
8
  type DataClient = ReturnType<typeof generateClient<Schema>>;
8
9
  type DataClientErrors = Array<{ message?: string | null } | string | null> | null | undefined;
@@ -474,20 +475,11 @@ function validateJsonSchemaInput(schema: Record<string, unknown>, input: Record<
474
475
 
475
476
  function requireAdmin(event: ProcedureEvent): void {
476
477
  const claims = objectValue(event.identity?.claims);
477
- const groups = [
478
- ...normalizeClaimGroups(claims["cognito:groups"]),
479
- ...normalizeClaimGroups(claims.groups),
480
- ];
478
+ const groups = [...claimGroups(claims["cognito:groups"]), ...claimGroups(claims.groups)];
481
479
  if (groups.includes("admin")) return;
482
480
  throw new Error("Admin role required.");
483
481
  }
484
482
 
485
- function normalizeClaimGroups(value: unknown): string[] {
486
- if (Array.isArray(value)) return value.map((entry) => String(entry).trim()).filter(Boolean);
487
- if (typeof value === "string") return value.split(",").map((entry) => entry.trim()).filter(Boolean);
488
- return [];
489
- }
490
-
491
483
  async function getDataClient(): Promise<DataClient> {
492
484
  if (!clientPromise) {
493
485
  clientPromise = (async () => {
@@ -92,6 +92,25 @@ def is_appsync_event(event: dict[str, Any]) -> bool:
92
92
  return "arguments" in event and ("info" in event or event_field_name(event) is not None)
93
93
  PROPOSER_AI = "ai"
94
94
  STAFF_GROUPS = frozenset({"editor", "admin"})
95
+
96
+ _GROUP_SEPARATORS = re.compile(r"[\s,]+")
97
+
98
+
99
+ def claim_groups(value: Any) -> list[str]:
100
+ """Copy of ``papyrus_content.staff_groups.claim_groups`` (this function ships without that package; a test keeps them equal)."""
101
+ if isinstance(value, (list, tuple, set, frozenset)):
102
+ raw = [item for item in value if isinstance(item, str)]
103
+ elif isinstance(value, str):
104
+ raw = _GROUP_SEPARATORS.split(value)
105
+ else:
106
+ return []
107
+ cleaned = (item.strip().strip("\"'[](){}").strip() for item in raw)
108
+ return [item for item in cleaned if item]
109
+
110
+
111
+ def has_editor_rights(groups: Any) -> bool:
112
+ """``admin`` has every editor right: an admin who is not in the ``editor`` group is staff."""
113
+ return any(group.lower() in STAFF_GROUPS for group in claim_groups(groups))
95
114
  #: The only moves an operator's IAM role may make, as (from, to). Everything after the draft is an editor's decision.
96
115
  OPERATOR_TRANSITIONS = frozenset({(DRAFT, IN_STEERING), (DRAFT, DECLINED)})
97
116
  BY_STAFF, BY_OPERATOR = "staff", "operator"
@@ -140,10 +159,8 @@ def staff_subject(identity: dict[str, Any]) -> str | None:
140
159
  """The Cognito subject of an editor or admin, or ``None`` for anyone else."""
141
160
  claims = identity.get("claims") or {}
142
161
  groups = claims.get("cognito:groups") or identity.get("groups") or []
143
- if isinstance(groups, str):
144
- groups = [group.strip() for group in groups.strip("[]").replace(",", " ").split()]
145
162
  subject = identity.get("sub")
146
- if not isinstance(subject, str) or not subject or not STAFF_GROUPS.intersection(groups):
163
+ if not isinstance(subject, str) or not subject or not has_editor_rights(groups):
147
164
  return None
148
165
  return subject
149
166
 
@@ -1468,10 +1468,8 @@ def handle_staff_event(event: dict[str, Any], *, store: Any, settings: ReaderSet
1468
1468
  identity = event.get("identity") or {}
1469
1469
  claims = identity.get("claims") or {}
1470
1470
  groups = claims.get("cognito:groups") or identity.get("groups") or []
1471
- if isinstance(groups, str):
1472
- groups = [group.strip() for group in groups.strip("[]").replace(",", " ").split()]
1473
1471
  subject = identity.get("sub")
1474
- if not isinstance(subject, str) or not subject or not moderation_rules.STAFF_GROUPS.intersection(groups):
1472
+ if not isinstance(subject, str) or not subject or not moderation_rules.has_editor_rights(groups):
1475
1473
  return moderation_rules.refused("not_staff", "Only editors and admins can moderate.").to_dict()
1476
1474
  arguments = event.get("arguments") or {}
1477
1475
  outcome = moderation_rules.apply_action(
@@ -37,6 +37,25 @@ READER_ID = re.compile(r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f
37
37
  ACTION_ID = re.compile(r"mod-[0-9a-f]{32}")
38
38
  MAXIMUM_REASON = 500
39
39
  STAFF_GROUPS = frozenset({"editor", "admin"})
40
+
41
+ _GROUP_SEPARATORS = re.compile(r"[\s,]+")
42
+
43
+
44
+ def claim_groups(value: Any) -> list[str]:
45
+ """Copy of ``papyrus_content.staff_groups.claim_groups`` (this function ships without that package; a test keeps them equal)."""
46
+ if isinstance(value, (list, tuple, set, frozenset)):
47
+ raw = [item for item in value if isinstance(item, str)]
48
+ elif isinstance(value, str):
49
+ raw = _GROUP_SEPARATORS.split(value)
50
+ else:
51
+ return []
52
+ cleaned = (item.strip().strip("\"'[](){}").strip() for item in raw)
53
+ return [item for item in cleaned if item]
54
+
55
+
56
+ def has_editor_rights(groups: Any) -> bool:
57
+ """``admin`` has every editor right: an admin who is not in the ``editor`` group is staff."""
58
+ return any(group.lower() in STAFF_GROUPS for group in claim_groups(groups))
40
59
  ITEM_ID_PREFIX = "item-"
41
60
 
42
61
 
@@ -0,0 +1,12 @@
1
+ import { NextResponse } from "next/server";
2
+ import { checkSourceFrameable } from "../../../../lib/source-frameable";
3
+
4
+ export const runtime = "nodejs";
5
+ export const dynamic = "force-dynamic";
6
+
7
+ /** GET ?url=<source page> -> { frameable }: whether the newsroom can show that page's live preview. Cached for a day. */
8
+ export async function GET(request: Request) {
9
+ const url = new URL(request.url).searchParams.get("url") ?? "";
10
+ const frameable = url ? await checkSourceFrameable(url) : false;
11
+ return NextResponse.json({ frameable }, { headers: { "cache-control": "public, max-age=86400" } });
12
+ }
@@ -0,0 +1,20 @@
1
+ import { fetchSourceIcon } from "../../../../lib/source-icon";
2
+
3
+ export const runtime = "nodejs";
4
+ export const dynamic = "force-dynamic";
5
+
6
+ /** GET ?host=<source host> -> the source's own icon, or 404 so the newsroom shows its letter mark. Cached for a week. */
7
+ export async function GET(request: Request) {
8
+ const host = (new URL(request.url).searchParams.get("host") ?? "").toLowerCase();
9
+ const icon = host ? await fetchSourceIcon(host) : null;
10
+ if (!icon) return new Response(null, { status: 404, headers: { "cache-control": "public, max-age=86400" } });
11
+ return new Response(icon.body, {
12
+ headers: {
13
+ "cache-control": "public, max-age=604800",
14
+ "content-type": icon.contentType,
15
+ // An SVG icon is shown only through <img>, but never let it run as a document on our origin.
16
+ "content-security-policy": "default-src 'none'; style-src 'unsafe-inline'; sandbox",
17
+ "x-content-type-options": "nosniff",
18
+ },
19
+ });
20
+ }
@@ -41,6 +41,8 @@ export default async function NewsDeskNestedSectionPage({ params, searchParams }
41
41
  if (section === "users") redirect(toPublicNewsroomPath("/newsroom/administration/users"));
42
42
  if (section === "doctrine") redirect(toPublicNewsroomPath("/newsroom/administration/policies"));
43
43
  if (section === "administration" && selection[0] === "analysis") redirect(toPublicNewsroomPath("/newsroom/assignments"));
44
+ // A Notification Center deep link (`/needs-me/<item id>`): the console home, which opens that item from the address.
45
+ if (section === "needs-me") return <NewsDeskPage searchParams={searchParams} />;
44
46
  if (!NEWS_DESK_ROUTE_SECTIONS.has(section as NewsDeskTab)) notFound();
45
47
  return <NewsDeskPage section={section} selectionPath={selection} searchParams={searchParams} />;
46
48
  }
@@ -43,6 +43,7 @@ const NEWS_DESK_ROUTE_SECTIONS = new Set<NewsDeskTab>([
43
43
  export default async function NewsDeskSectionPage({ params, searchParams }: NewsDeskSectionPageProps) {
44
44
  const { section } = await params;
45
45
  if (section === "work") redirect(toPublicNewsroomPath(`/newsroom/${contentItemBoardPathSegment()}`));
46
+ if (section === "needs-me") redirect(toPublicNewsroomPath("/newsroom"));
46
47
  if (section === "users") redirect(toPublicNewsroomPath("/newsroom/administration/users"));
47
48
  if (section === "doctrine") redirect(toPublicNewsroomPath("/newsroom/administration/policies"));
48
49
  if (section === contentItemBoardPathSegment()) return <NewsroomBoardView subject="contentItem" />;
@@ -1,9 +1,20 @@
1
1
  #!/usr/bin/env node
2
+ import path from "node:path";
2
3
  import { sync } from "../src/sync.mjs";
3
4
 
4
5
  const [command, ...rest] = process.argv.slice(2);
5
6
  if (command === "sync") {
6
7
  sync();
8
+ } else if (command === "brand" && rest[0] === "diff") {
9
+ const { brandDiff } = await import("../src/brand.mjs");
10
+ const flag = rest.indexOf("--papyrus");
11
+ const checkout = flag === -1 ? undefined : rest[flag + 1];
12
+ try {
13
+ process.exit(brandDiff({ id: rest[1], checkout: checkout && path.resolve(checkout) }).drifted ? 1 : 0);
14
+ } catch (error) {
15
+ console.error(error.message);
16
+ process.exit(2);
17
+ }
7
18
  } else if (command === "videoml-bundle") {
8
19
  const preview = rest.includes("--preview");
9
20
  const { buildBrowserBundle } = await import("../scripts/videoml/build-browser-bundle.mjs");
@@ -15,6 +26,6 @@ if (command === "sync") {
15
26
  process.exit(1);
16
27
  }
17
28
  } else {
18
- console.error("Usage: papyrus-app sync | papyrus-app videoml-bundle [--preview]");
29
+ console.error("Usage: papyrus-app sync | papyrus-app brand diff <brand-id> [--papyrus <checkout>] | papyrus-app videoml-bundle [--preview]");
19
30
  process.exit(command ? 1 : 0);
20
31
  }
@@ -3,6 +3,7 @@
3
3
  * papyrus-infra synth --site infra/site.json [--out cdk.out] [--account A] [--region R]
4
4
  * papyrus-infra synth --account-stack github-oidc [--out cdk.out] [--account A] [--region R]
5
5
  * papyrus-infra print-buildspec --site infra/site.json --app cms-production|cms-staging|reader
6
+ * papyrus-infra synth --site infra/site.json --part chat [--chat-lambdas DIR] [--out cdk.out] (the private chat data stack)
6
7
  * papyrus-infra deploy --site infra/site.json [--only-branch main] [--only-app-id ID] [--out cdk.out] [--account A] [--region R]
7
8
  * Synthesizes the app-shell stack for a publication from its own site.json,
8
9
  * prints one of the generated Amplify build specs, or deploys the stack from
@@ -16,6 +17,7 @@ import { resolveCdkEnvironment } from "../src/cdk-environment.mjs";
16
17
 
17
18
  const USAGE = [
18
19
  "Usage: papyrus-infra synth --site infra/site.json [--out cdk.out] [--account A] [--region R]",
20
+ " papyrus-infra synth --site infra/site.json --part chat [--chat-lambdas DIR] [--out cdk.out] [--account A] [--region R]",
19
21
  " papyrus-infra synth --account-stack github-oidc [--out cdk.out] [--account A] [--region R]",
20
22
  " papyrus-infra print-buildspec --site infra/site.json --app cms-production|cms-staging|reader",
21
23
  " papyrus-infra deploy-edge --stack NAME --template FILE [--adoption-template FILE --adopt-resources FILE] [--parameter KEY=VALUE]... [--delete-function NAME] [--only-branch main] [--only-app-id ID] [--region R]",
@@ -186,8 +188,9 @@ if (command === "deploy") {
186
188
  }
187
189
 
188
190
  let AmplifyAppShellStack;
191
+ let PapyrusChatStack;
189
192
  try {
190
- ({ AmplifyAppShellStack } = await import("../infra/index.js"));
193
+ ({ AmplifyAppShellStack, PapyrusChatStack } = await import("../infra/index.js"));
191
194
  } catch (error) {
192
195
  console.error(`papyrus-infra: ${error instanceof Error ? error.message : error}`);
193
196
  process.exit(1);
@@ -195,6 +198,23 @@ try {
195
198
 
196
199
  const { App } = createRequire(path.join(process.cwd(), "noop.js"))("aws-cdk-lib");
197
200
  const app = new App({ outdir: path.resolve(opt("out", "cdk.out")) });
201
+ if (opt("part", "shell") === "chat") {
202
+ // The site's private newsroom chat data stack (docs/private-newsroom-chat-per-site.md); deployed before the app shell enables chat.
203
+ if (!config.chat?.enabled) {
204
+ console.error(`papyrus-infra: site ${config.siteId} has no enabled chat block`);
205
+ process.exit(1);
206
+ }
207
+ const lambdaAssetDirectory = opt("chat-lambdas");
208
+ const chatStack = new PapyrusChatStack(app, {
209
+ config,
210
+ env: resolveEnvironmentOrExit(),
211
+ lambdaAssetDirectory: lambdaAssetDirectory ? path.resolve(lambdaAssetDirectory) : undefined,
212
+ description: `Private Papyrus newsroom chat data plane for ${config.siteId}.`,
213
+ });
214
+ app.synth();
215
+ console.log(`synthesized ${chatStack.stackName} -> ${path.resolve(opt("out", "cdk.out"))}`);
216
+ process.exit(0);
217
+ }
198
218
  new AmplifyAppShellStack(app, `AmplifyAppShell-${config.siteId}`, config, {
199
219
  env: resolveEnvironmentOrExit(),
200
220
  stackName: resolveStackName(config),
@@ -0,0 +1,21 @@
1
+ import { BotIcon } from "lucide-react";
2
+ import { AUTO_ACCEPT_RULE_ACTOR } from "../lib/reference-policy";
3
+ import { cn } from "../lib/utils";
4
+
5
+ /**
6
+ * Shown on a Reference accepted automatically: by the Cyclotron's confidence after the editors' usual time, or, for a monitor that delivers
7
+ * by a fixed rule, on delivery ("by rule"). Words and an icon, never colour alone, and different from an editor's Accept.
8
+ */
9
+ export function AutoAcceptedBadge({ actor, className, reason }: { actor?: string | null; className?: string; reason?: string | null }) {
10
+ const byRule = (actor ?? "").trim() === AUTO_ACCEPT_RULE_ACTOR;
11
+ return (
12
+ <span
13
+ className={cn("inline-flex items-center gap-1 rounded-full bg-[var(--feed-control)] px-2 py-0.5 text-[0.7rem] font-medium text-foreground", className)}
14
+ data-auto-accepted={byRule ? "rule" : "cyclotron"}
15
+ title={reason?.trim() || (byRule ? "The monitor's rule matched this item, so it was accepted on delivery. An editor can still Reject it." : "The Cyclotron accepted this automatically because nobody voted in the time editors usually take. An editor can still Reject it.")}
16
+ >
17
+ <BotIcon aria-hidden="true" className="size-3.5" />
18
+ Accepted automatically{byRule ? " (by rule)" : ""}
19
+ </span>
20
+ );
21
+ }
@@ -0,0 +1,52 @@
1
+ "use client";
2
+
3
+ import { createContext, useContext, type ReactNode } from "react";
4
+ import type { ConversationAnchor, ConversationMessage } from "../lib/conversation";
5
+
6
+ /**
7
+ * Where conversations are read and written. The newsroom provides the network one; a demo or a test provides the in-memory one. A
8
+ * component that finds no store shows no conversation at all.
9
+ */
10
+ export type ConversationStore = {
11
+ /** Who the optimistic Message says wrote it, until the saved one replaces it. */
12
+ authorLabel: string;
13
+ load: (anchor: ConversationAnchor) => Promise<ConversationMessage[]>;
14
+ append: (input: { anchor: ConversationAnchor; body: string; parentMessageId: string | null }) => Promise<ConversationMessage>;
15
+ /** Message counts by Reference lineage, for the feed cards. */
16
+ counts: () => Promise<Map<string, number>>;
17
+ };
18
+
19
+ const ConversationStoreContext = createContext<ConversationStore | null>(null);
20
+
21
+ export function ConversationStoreProvider({ store, children }: { store: ConversationStore; children: ReactNode }) {
22
+ return <ConversationStoreContext.Provider value={store}>{children}</ConversationStoreContext.Provider>;
23
+ }
24
+
25
+ export function useConversationStore(): ConversationStore | null {
26
+ return useContext(ConversationStoreContext);
27
+ }
28
+
29
+ /** A store that keeps everything in memory: the demo newsroom, and the tests. */
30
+ export function createMemoryConversationStore(authorLabel: string, seed: Record<string, ConversationMessage[]> = {}): ConversationStore {
31
+ const byAnchor = new Map<string, ConversationMessage[]>(Object.entries(seed));
32
+ const keyOf = (anchor: ConversationAnchor) => `${anchor.kind}#${anchor.lineageId ?? ""}`;
33
+ return {
34
+ authorLabel,
35
+ load: async (anchor) => [...(byAnchor.get(keyOf(anchor)) ?? [])],
36
+ append: async ({ anchor, body, parentMessageId }) => {
37
+ const list = byAnchor.get(keyOf(anchor)) ?? [];
38
+ const sequenceNumber = Math.max(0, ...list.map((message) => message.sequenceNumber)) + 1;
39
+ const message: ConversationMessage = {
40
+ id: `message-memory-${sequenceNumber}-${list.length}`,
41
+ parentMessageId,
42
+ authorLabel,
43
+ content: body.trim(),
44
+ createdAt: new Date().toISOString(),
45
+ sequenceNumber,
46
+ };
47
+ byAnchor.set(keyOf(anchor), [...list, message]);
48
+ return message;
49
+ },
50
+ counts: async () => new Map([...byAnchor].filter(([key]) => key.startsWith("reference#")).map(([key, list]) => [key.slice("reference#".length), list.length])),
51
+ };
52
+ }
@@ -0,0 +1,20 @@
1
+ "use client";
2
+
3
+ import { useMemo, type ReactNode } from "react";
4
+ import { ConversationStoreProvider, createMemoryConversationStore, type ConversationStore } from "./conversation-context";
5
+ import { appendConversationMessage, loadConversationMessages, loadReferenceConversationCounts } from "./news-desk-taxonomy-client";
6
+
7
+ function networkConversationStore(authorLabel: string): ConversationStore {
8
+ return {
9
+ authorLabel,
10
+ load: loadConversationMessages,
11
+ append: async ({ anchor, body, parentMessageId }) => (await appendConversationMessage({ anchor, body, parentMessageId })).message,
12
+ counts: loadReferenceConversationCounts,
13
+ };
14
+ }
15
+
16
+ /** Gives the newsroom its conversations: saved to the backend, or kept in memory in the demo. */
17
+ export function ConversationProvider({ authorLabel, demo, children }: { authorLabel: string; demo: boolean; children: ReactNode }) {
18
+ const store = useMemo(() => (demo ? createMemoryConversationStore(authorLabel) : networkConversationStore(authorLabel)), [authorLabel, demo]);
19
+ return <ConversationStoreProvider store={store}>{children}</ConversationStoreProvider>;
20
+ }