@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.
- package/amplify/data/resource.js +6 -0
- package/amplify/data/resource.ts +7 -0
- package/amplify/data/schema.js +6 -0
- package/amplify/data/schema.ts +7 -0
- package/amplify/functions/category-action/handler.ts +2 -1
- package/amplify/functions/content-actions/handler.py +8 -0
- package/amplify/functions/content-actions/requirements.txt +2 -0
- package/amplify/functions/content-actions/resource.js +1 -1
- package/amplify/functions/content-actions/resource.ts +1 -1
- package/amplify/functions/model-attachment-upload/handler.ts +1 -0
- package/amplify/functions/monitoring-view/schema.js +16 -0
- package/amplify/functions/monitoring-view/schema.ts +16 -0
- package/amplify/functions/procedure-action/handler.ts +2 -10
- package/amplify/functions/proposal-actions/proposals.py +20 -3
- package/amplify/plugins/reader-steering/function/handler.py +1 -3
- package/amplify/plugins/reader-steering/function/moderation.py +19 -0
- package/app/api/newsroom/source-frameable/route.ts +12 -0
- package/app/api/newsroom/source-icon/route.ts +20 -0
- package/app/newsroom/[section]/[...selection]/page.tsx +2 -0
- package/app/newsroom/[section]/page.tsx +1 -0
- package/bin/papyrus-app.mjs +12 -1
- package/bin/papyrus-infra.mjs +21 -1
- package/components/auto-accepted-badge.tsx +21 -0
- package/components/conversation-context.tsx +52 -0
- package/components/conversation-provider.tsx +20 -0
- package/components/conversation-thread.tsx +174 -0
- package/components/decision-control.tsx +306 -0
- package/components/dev-sandbox-editor-sign-in.tsx +2 -2
- package/components/markdown-context.tsx +412 -0
- package/components/monitoring-overview-view.tsx +143 -21
- package/components/monitoring-panel.tsx +64 -4
- package/components/needs-me-bodies.tsx +302 -0
- package/components/needs-me-detail.tsx +24 -0
- package/components/needs-me-feed.tsx +594 -0
- package/components/needs-me-reference-detail.tsx +79 -0
- package/components/needs-me-shared.tsx +48 -0
- package/components/news-desk-client-provider.tsx +24 -3
- package/components/news-desk-page.tsx +2 -1
- package/components/news-desk-taxonomy-client.ts +253 -31
- package/components/newsroom-access-gate.tsx +13 -20
- package/components/newsroom-board-skeleton.tsx +20 -0
- package/components/newsroom-board-view.tsx +2 -6
- package/components/newsroom-client-shell.tsx +2 -1
- package/components/newsroom-ops-overview.tsx +88 -73
- package/components/newsroom-ops-shell.tsx +8 -28
- package/components/newsroom-organizer.css +127 -3
- package/components/newsroom-references-view.tsx +334 -158
- package/components/notification-cards.tsx +370 -0
- package/components/notification-center.tsx +376 -213
- package/components/papyrus-console-shell.tsx +2 -1
- package/components/proposals-panel.tsx +21 -24
- package/components/proposals-view.tsx +4 -2
- package/components/reader-auth-control.tsx +3 -1
- package/components/reference-capture-client.ts +138 -0
- package/components/reference-capture-panel.tsx +268 -0
- package/components/reference-conversation.tsx +43 -0
- package/components/reference-feed.tsx +514 -0
- package/components/reference-review-card.tsx +62 -197
- package/components/reference-source-preview.tsx +27 -5
- package/components/reference-stories.tsx +34 -0
- package/components/references-page-loading.tsx +61 -0
- package/components/references-prefetch.ts +52 -0
- package/components/story-reporting-trail-client.ts +45 -8
- package/components/story-reporting-trail.tsx +24 -13
- package/components/topic-steering-workspace.tsx +50 -439
- package/components/ui/auto-grow-textarea.tsx +18 -0
- package/components/use-conversation.ts +84 -0
- package/components/use-media-query.ts +16 -0
- package/components/use-reference-preview-attachments.ts +101 -0
- package/components/use-reference-ratings.ts +188 -0
- package/infra/amplify-app-shell.js +18 -2
- package/infra/amplify-app-shell.ts +18 -2
- package/infra/chat-stack.js +222 -0
- package/infra/chat-stack.ts +250 -0
- package/infra/chat.test.js +142 -0
- package/infra/chat.test.ts +151 -0
- package/infra/index.js +1 -0
- package/infra/site-config.js +58 -1
- package/infra/site-config.ts +78 -1
- package/lib/category-repository.ts +6 -1
- package/lib/content-actions-client.ts +20 -0
- package/lib/conversation.ts +154 -0
- package/lib/local-preview-feed-fixture.ts +63 -0
- package/lib/local-preview-seed.ts +24 -2
- package/lib/local-preview-synthetic-rejects.ts +41 -0
- package/lib/monitoring-client.ts +29 -2
- package/lib/monitoring-overview.ts +52 -1
- package/lib/monitoring-paging.ts +58 -0
- package/lib/needs-me-items.ts +289 -0
- package/lib/needs-me-ranking.ts +315 -0
- package/lib/newsroom-all-pages.ts +21 -0
- package/lib/newsroom-chat-identity.ts +2 -1
- package/lib/newsroom-index-filters.ts +18 -0
- package/lib/newsroom-nav.ts +1 -1
- package/lib/newsroom-organizer-nav.ts +2 -1
- package/lib/newsroom-references.ts +25 -1
- package/lib/notification-feed.ts +212 -0
- package/lib/proposals-client.ts +29 -0
- package/lib/reader-subject.ts +3 -2
- package/lib/reference-capture.ts +294 -0
- package/lib/reference-feed-cache.ts +170 -0
- package/lib/reference-feed-ranking.ts +236 -0
- package/lib/reference-policy.ts +12 -0
- package/lib/reference-preview.ts +112 -0
- package/lib/review-client.ts +71 -5
- package/lib/review-drafts.ts +277 -0
- package/lib/review-fixture.ts +56 -2
- package/lib/review-requests.ts +89 -24
- package/lib/site-brand.ts +2 -0
- package/lib/source-frameable.ts +77 -0
- package/lib/source-icon.ts +104 -0
- package/lib/staff-groups.ts +23 -0
- package/lib/staging-gate.ts +2 -2
- package/lib/story-reporting-trail.ts +33 -2
- package/package.json +45 -2
- package/routes.manifest.json +23 -1
- package/components/newsroom-overview-skeleton.tsx +0 -25
package/amplify/data/resource.js
CHANGED
|
@@ -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() })
|
package/amplify/data/resource.ts
CHANGED
|
@@ -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() })
|
package/amplify/data/schema.js
CHANGED
|
@@ -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() })
|
package/amplify/data/schema.ts
CHANGED
|
@@ -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 =
|
|
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":
|
|
@@ -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");
|
|
@@ -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
|
|
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.
|
|
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" />;
|
package/bin/papyrus-app.mjs
CHANGED
|
@@ -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
|
}
|
package/bin/papyrus-infra.mjs
CHANGED
|
@@ -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
|
+
}
|