@anthusai/papyrus 2.0.0-next.1 → 2.0.0-next.100
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/auth/resource.js +16 -1
- package/amplify/auth/resource.ts +19 -1
- package/amplify/data/function-handler.js +20 -0
- package/amplify/data/function-handler.ts +40 -0
- package/amplify/data/resource.js +158 -61
- package/amplify/data/resource.ts +165 -61
- package/amplify/data/schema.js +2103 -1979
- package/amplify/data/schema.ts +206 -68
- package/amplify/functions/assignment-action/data-access.js +90 -0
- package/amplify/functions/assignment-action/data-access.ts +89 -0
- package/amplify/functions/assignment-action/handler.ts +1 -1
- package/amplify/functions/automation-view/handler.py +17 -0
- package/amplify/functions/automation-view/resource.js +24 -0
- package/amplify/functions/automation-view/resource.ts +34 -0
- package/amplify/functions/automation-view/schema.js +26 -0
- package/amplify/functions/automation-view/schema.ts +28 -0
- package/amplify/functions/automation-view/wire.js +56 -0
- package/amplify/functions/automation-view/wire.ts +76 -0
- package/amplify/functions/content-actions/handler.py +70 -2
- package/amplify/functions/content-actions/requirements.txt +1 -0
- package/amplify/functions/content-actions/resource.js +1 -1
- package/amplify/functions/content-actions/resource.ts +1 -1
- package/amplify/functions/editorial-checks/handler.py +14 -0
- package/amplify/functions/editorial-checks/requirements.txt +1 -0
- package/amplify/functions/editorial-checks/resource.js +43 -0
- package/amplify/functions/editorial-checks/resource.ts +60 -0
- package/amplify/functions/editorial-checks/schema.js +122 -0
- package/amplify/functions/editorial-checks/schema.ts +124 -0
- package/amplify/functions/editorial-checks/wire.js +53 -0
- package/amplify/functions/editorial-checks/wire.ts +61 -0
- package/amplify/functions/model-attachment-upload/handler.ts +1 -0
- package/amplify/functions/monitoring-view/handler.py +11 -0
- package/amplify/functions/monitoring-view/resource.js +24 -0
- package/amplify/functions/monitoring-view/resource.ts +34 -0
- package/amplify/functions/monitoring-view/schema.js +29 -0
- package/amplify/functions/monitoring-view/schema.ts +31 -0
- package/amplify/functions/monitoring-view/wire.js +64 -0
- package/amplify/functions/monitoring-view/wire.ts +81 -0
- package/amplify/functions/public-forms/core.js +247 -0
- package/amplify/functions/public-forms/core.ts +310 -0
- package/amplify/functions/public-forms/handler.ts +41 -0
- package/amplify/functions/public-forms/origins.js +35 -0
- package/amplify/functions/public-forms/origins.ts +43 -0
- package/amplify/functions/public-forms/resource.js +9 -0
- package/amplify/functions/public-forms/resource.ts +10 -0
- package/amplify/functions/public-forms/wire.js +25 -0
- package/amplify/functions/public-forms/wire.ts +32 -0
- package/amplify/functions/shared/editorial-context.ts +63 -4
- package/amplify/functions/shared/python-bundle.js +20 -14
- package/amplify/functions/shared/python-bundle.ts +25 -18
- package/amplify/plugins/newsletter/config.js +245 -0
- package/amplify/plugins/newsletter/config.ts +305 -0
- package/amplify/plugins/newsletter/function/handler.py +6 -0
- package/amplify/plugins/newsletter/function/requirements.txt +5 -0
- package/amplify/plugins/newsletter/ops/handler.py +12 -0
- package/amplify/plugins/newsletter/ops/resource.js +27 -0
- package/amplify/plugins/newsletter/ops/resource.ts +37 -0
- package/amplify/plugins/newsletter/papyrus.plugin.js +263 -0
- package/amplify/plugins/newsletter/papyrus.plugin.ts +311 -0
- package/amplify/plugins/newsletter/schema.js +57 -0
- package/amplify/plugins/newsletter/schema.ts +62 -0
- package/amplify/plugins/newsletter/secure-string-parameter.js +107 -0
- package/amplify/plugins/newsletter/secure-string-parameter.ts +120 -0
- package/amplify/plugins/newsletter/sending.js +140 -0
- package/amplify/plugins/newsletter/sending.ts +186 -0
- package/amplify/plugins/reader-steering/config.js +182 -0
- package/amplify/plugins/reader-steering/config.ts +290 -0
- package/amplify/plugins/reader-steering/function/ai_moderation.py +246 -0
- package/amplify/plugins/reader-steering/function/handler.py +1567 -0
- package/amplify/plugins/reader-steering/function/moderation-prompt.md +27 -0
- package/amplify/plugins/reader-steering/function/moderation.py +316 -0
- package/amplify/plugins/reader-steering/function/proposals.py +458 -0
- package/amplify/plugins/reader-steering/function/resource.js +27 -0
- package/amplify/plugins/reader-steering/function/resource.ts +35 -0
- package/amplify/plugins/reader-steering/papyrus.plugin.js +186 -0
- package/amplify/plugins/reader-steering/papyrus.plugin.ts +212 -0
- package/amplify/plugins/reader-steering/schema.js +229 -0
- package/amplify/plugins/reader-steering/schema.ts +231 -0
- package/amplify/plugins/registry.js +4 -0
- package/amplify/plugins/registry.ts +4 -0
- package/amplify/plugins/signup/schema.js +2 -1
- package/amplify/plugins/signup/schema.ts +2 -1
- package/amplify/plugins/types.ts +5 -0
- package/amplify/plugins/wire.js +1 -0
- package/amplify/plugins/wire.ts +1 -0
- package/amplify/publication/projection-lease.js +30 -0
- package/amplify/publication/projection-lease.ts +39 -0
- package/amplify/publication/redirect-and-index.js +105 -0
- package/amplify/publication/redirect-rules.js +73 -0
- package/amplify/publication/redirect-rules.ts +93 -0
- package/amplify/publication/s3-projection.js +7 -1
- package/amplify/publication/s3-projection.ts +9 -1
- package/amplify/site-backend-config.js +19 -0
- package/amplify/site-backend-config.ts +45 -1
- package/amplify/site-backend-names.js +4 -2
- package/amplify/site-backend-names.ts +4 -2
- package/amplify/site-backend.js +51 -4
- package/amplify/site-backend.ts +60 -4
- package/app/api/auth/prospectus/sign-in/route.ts +4 -0
- package/app/api/newsletter/confirm/route.ts +13 -0
- package/app/api/newsletter/preferences/route.ts +13 -0
- package/app/api/newsletter/state/route.ts +9 -0
- package/app/api/newsletter/subscribe/route.ts +9 -0
- package/app/api/newsletter/unsubscribe/route.ts +13 -0
- package/app/api/newsroom/chat/[...path]/route.ts +17 -0
- package/app/api/newsroom/curation/context/route.ts +9 -0
- package/app/api/newsroom/curation/priming/route.ts +9 -0
- package/app/api/newsroom/curation/publication/route.ts +9 -0
- package/app/api/reader/comment/delete/route.ts +9 -0
- package/app/api/reader/comment/edit/route.ts +9 -0
- package/app/api/reader/comment/route.ts +9 -0
- package/app/api/reader/flag/route.ts +9 -0
- package/app/api/reader/handle/route.ts +9 -0
- package/app/api/reader/me/route.ts +9 -0
- package/app/api/reader/rating/route.ts +9 -0
- package/app/api/reader/report/route.ts +9 -0
- package/app/api/reader/retired/route.ts +9 -0
- package/app/api/reader/submit/route.ts +9 -0
- package/app/api/reader/vote/route.ts +9 -0
- package/app/layout.tsx +5 -1
- package/bin/papyrus-infra.mjs +20 -1
- package/components/automation-panel.tsx +49 -0
- package/components/automation-run-now.tsx +97 -0
- package/components/automation-view.tsx +195 -0
- package/components/editorial-checks-admin-panel.tsx +362 -0
- package/components/editorial-finding-card.tsx +162 -0
- package/components/editorial-findings-panel.tsx +91 -0
- package/components/editorial-preview-popover.tsx +103 -0
- package/components/markus-body-editor.tsx +213 -0
- package/components/markus-ir-preview.tsx +147 -6
- package/components/moderation-panel.tsx +248 -0
- package/components/monitoring-overview-view.tsx +414 -0
- package/components/monitoring-panel.tsx +60 -0
- package/components/news-desk-client-provider.tsx +18 -18
- package/components/news-desk-taxonomy-client.ts +28 -8
- package/components/newsletter-panel.tsx +474 -0
- package/components/newsletter-sections.tsx +163 -0
- package/components/newsroom-access-gate.tsx +16 -2
- package/components/newsroom-article-editor.tsx +145 -18
- package/components/newsroom-articles-view.tsx +6 -6
- package/components/newsroom-assignment-detail.tsx +6 -6
- package/components/newsroom-board-view.tsx +14 -10
- package/components/newsroom-curation-bootstrap.tsx +45 -0
- package/components/newsroom-ops-overview.tsx +26 -96
- package/components/newsroom-ops-shell.tsx +9 -5
- package/components/newsroom-organizer.css +90 -61
- package/components/newsroom-overview-skeleton.tsx +25 -0
- package/components/newsroom-references-view.tsx +192 -49
- package/components/newsroom-theme-fonts/broadsheet-green.css +10 -0
- package/components/newsroom-theme-fonts/broadsheet-green.tsx +6 -0
- package/components/newsroom-theme-fonts/newspaper-gothic.css +9 -0
- package/components/newsroom-theme-fonts/newspaper-gothic.tsx +6 -0
- package/components/newsroom-theme-fonts/night-desk.css +10 -0
- package/components/newsroom-theme-fonts/night-desk.tsx +6 -0
- package/components/newsroom-theme-fonts/refined-newsprint.css +11 -0
- package/components/newsroom-theme-fonts/refined-newsprint.tsx +6 -0
- package/components/newsroom-theme.css +39 -0
- package/components/newsroom-theme.tsx +33 -0
- package/components/notification-center.tsx +281 -0
- package/components/papyrus-console-conversation.css +14 -0
- package/components/papyrus-console-conversation.tsx +55 -0
- package/components/papyrus-console-shell.tsx +62 -420
- package/components/papyrus-private-chat-panel.tsx +134 -0
- package/components/publication-cyclotron-panel.tsx +15 -10
- package/components/reader-auth-state.ts +1 -1
- package/components/reference-add-entry.tsx +75 -0
- package/components/reference-curation-controls.tsx +31 -22
- package/components/reference-cyclotron-priming.tsx +63 -25
- package/components/reference-cyclotron-status.tsx +4 -4
- package/components/reference-cyclotron-suggestion.tsx +15 -11
- package/components/reference-live-agreement.tsx +10 -10
- package/components/reference-priming.tsx +14 -10
- package/components/reference-relevance-review.tsx +4 -4
- package/components/reference-section-suggestion.tsx +2 -2
- package/components/topic-steering-workspace.tsx +241 -144
- package/components/use-editorial-checks.ts +133 -0
- package/infra/amplify-app-shell.js +96 -16
- package/infra/amplify-app-shell.ts +108 -18
- package/infra/cloud-email-config.js +32 -0
- package/infra/cloud-email-config.ts +39 -0
- package/infra/cloud-email.js +57 -0
- package/infra/cloud-email.test.js +69 -0
- package/infra/cloud-email.test.ts +66 -0
- package/infra/cloud-email.ts +60 -0
- package/infra/index.js +1 -0
- package/infra/secure-string-parameter-handler.js +41 -0
- package/infra/secure-string-parameter-handler.ts +41 -0
- package/infra/secure-string-parameter.js +79 -0
- package/infra/secure-string-parameter.ts +91 -0
- package/infra/site-config.js +92 -6
- package/infra/site-config.ts +138 -6
- package/lib/automation-client.ts +54 -0
- package/lib/automation-fixture.ts +93 -0
- package/lib/automation-overview.ts +252 -0
- package/lib/category-repository.ts +14 -12
- package/lib/console-web-context.ts +1 -1
- package/lib/curation-routes.ts +345 -0
- package/lib/cyclotron-suggestions.ts +11 -11
- package/lib/editorial-checks-backend.ts +16 -0
- package/lib/editorial-checks-client.ts +212 -0
- package/lib/editorial-checks-demo.ts +147 -0
- package/lib/editorial-checks-settings.ts +27 -0
- package/lib/editorial-checks.ts +175 -0
- package/lib/editorial-preview.ts +228 -0
- package/lib/monitoring-client.ts +83 -0
- package/lib/monitoring-overview-fixture.ts +51 -0
- package/lib/monitoring-overview.ts +188 -0
- package/lib/news-desk-session.ts +12 -21
- package/lib/newsletter-api.ts +418 -0
- package/lib/newsletter-client.ts +46 -0
- package/lib/newsletter-fixture.ts +184 -0
- package/lib/newsletter-model.ts +202 -0
- package/lib/newsroom-board.ts +27 -2
- package/lib/newsroom-demo-dashboard.ts +1 -1
- package/lib/newsroom-nav.ts +1 -1
- package/lib/newsroom-organizer-nav.ts +2 -1
- package/lib/papyrus-chat-admission.ts +65 -0
- package/lib/papyrus-hosted-chat.ts +217 -0
- package/lib/papyrus-private-chat-client.ts +81 -0
- package/lib/prospectus-session.ts +30 -14
- package/lib/prospectus-sign-in-routes.ts +106 -31
- package/lib/prospectus-site-identity.ts +46 -6
- package/lib/reader-api.ts +145 -0
- package/lib/reader-moderation.ts +220 -0
- package/lib/reader-retirement-route.ts +89 -0
- package/lib/reader-subject.md +29 -0
- package/lib/reader-subject.ts +53 -0
- package/lib/reference-review-details.ts +71 -0
- package/lib/reference-review-request.ts +14 -0
- package/lib/relevance-decisions.ts +42 -10
- package/lib/relevance-status.ts +6 -6
- package/lib/review-client.ts +120 -0
- package/lib/review-fixture.ts +45 -0
- package/lib/review-requests.ts +246 -0
- package/lib/semantic-graph.ts +2 -0
- package/lib/shared-chat-admission.ts +182 -0
- package/lib/site-brand-tenant.ts +11 -6
- package/lib/site-brand.ts +23 -3
- package/lib/site-origin-value.ts +50 -0
- package/lib/site-origin.ts +8 -0
- package/lib/use-reference-review-data.ts +44 -0
- package/middleware.ts +14 -3
- package/package.json +98 -4
- package/routes.manifest.json +227 -2
- package/src/with-papyrus.mjs +1 -1
- package/components/newsroom-fonts/playfair-display-700.ttf +0 -0
- package/components/newsroom-fonts/playfair-display-900.ttf +0 -0
- package/components/newsroom-fonts/playfair-display-OFL.txt +0 -93
- package/components/newsroom-fonts/plus-jakarta-sans-400.ttf +0 -0
- package/components/newsroom-fonts/plus-jakarta-sans-600.ttf +0 -0
- package/components/newsroom-fonts/plus-jakarta-sans-OFL.txt +0 -93
- package/components/newsroom-newsprint-board.css +0 -7
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { generateClient } from "aws-amplify/data";
|
|
4
|
+
import { getCurrentUser } from "aws-amplify/auth";
|
|
5
|
+
import {
|
|
6
|
+
answerUpdate, isDue, normalizeReviewRow, timeoutUpdate,
|
|
7
|
+
type AnswerInput, type ReviewRequest,
|
|
8
|
+
} from "./review-requests";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Review requests through the site's own authenticated API (user pool; editors and admins). The condition `state = open`
|
|
12
|
+
* on every change means two people answering at once, or an answer racing the deadline, cannot both win.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const FIELDS = "id subject mode reason ask raisedBy raisedAt deadline state answer links history appliedIn updatedAt";
|
|
16
|
+
const MAX_PAGES = 10;
|
|
17
|
+
|
|
18
|
+
type GraphQLResult = { data?: Record<string, any>; errors?: { message?: string; errorType?: string }[] };
|
|
19
|
+
|
|
20
|
+
async function run(query: string, variables: Record<string, unknown>): Promise<GraphQLResult> {
|
|
21
|
+
const client = generateClient({ authMode: "userPool" });
|
|
22
|
+
try {
|
|
23
|
+
return (await client.graphql({ query, variables })) as GraphQLResult;
|
|
24
|
+
} catch (error) {
|
|
25
|
+
// Amplify throws the response when AppSync returns errors.
|
|
26
|
+
return (error && typeof error === "object" && "errors" in error ? error : { errors: [{ message: "failed" }] }) as GraphQLResult;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export type ReviewLoad = { state: "ready"; requests: ReviewRequest[]; readIds: string[]; userKey: string } | { state: "forbidden" } | { state: "unavailable" };
|
|
31
|
+
|
|
32
|
+
export async function currentUserKey(): Promise<string> {
|
|
33
|
+
return (await getCurrentUser()).userId;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Loads every request (newest pages first), marks due soft reviews as timed out ("proceeded without review"), and reads this user's marks. */
|
|
37
|
+
export async function loadReviews(now: Date = new Date()): Promise<ReviewLoad> {
|
|
38
|
+
let userKey: string;
|
|
39
|
+
try {
|
|
40
|
+
userKey = await currentUserKey();
|
|
41
|
+
} catch {
|
|
42
|
+
return { state: "unavailable" };
|
|
43
|
+
}
|
|
44
|
+
const rows: Record<string, unknown>[] = [];
|
|
45
|
+
let nextToken: string | null = null;
|
|
46
|
+
for (let page = 0; page < MAX_PAGES; page += 1) {
|
|
47
|
+
const result = await run(
|
|
48
|
+
`query ListReviews($limit: Int, $nextToken: String) { listReviewRequests(limit: $limit, nextToken: $nextToken) { items { ${FIELDS} } nextToken } }`,
|
|
49
|
+
{ limit: 500, nextToken },
|
|
50
|
+
);
|
|
51
|
+
if (result.errors?.some((entry) => /unauthorized|not authorized|forbidden/i.test(`${entry.errorType ?? ""} ${entry.message ?? ""}`))) return { state: "forbidden" };
|
|
52
|
+
const connection = result.data?.listReviewRequests;
|
|
53
|
+
if (result.errors?.length || !connection) return { state: "unavailable" };
|
|
54
|
+
rows.push(...connection.items.filter(Boolean));
|
|
55
|
+
nextToken = connection.nextToken ?? null;
|
|
56
|
+
if (!nextToken) break;
|
|
57
|
+
}
|
|
58
|
+
const requests = rows.map(normalizeReviewRow);
|
|
59
|
+
for (const [index, request] of requests.entries()) {
|
|
60
|
+
if (!isDue(request, now)) continue;
|
|
61
|
+
const update = timeoutUpdate(request, now);
|
|
62
|
+
const written = await run(
|
|
63
|
+
`mutation Expire($input: UpdateReviewRequestInput!, $condition: ModelReviewRequestConditionInput) { updateReviewRequest(input: $input, condition: $condition) { ${FIELDS} } }`,
|
|
64
|
+
{ input: update, condition: { state: { eq: "open" } } },
|
|
65
|
+
);
|
|
66
|
+
const row = written.data?.updateReviewRequest;
|
|
67
|
+
requests[index] = row ? normalizeReviewRow(row) : { ...request, state: "timed_out", updatedAt: String(update.updatedAt), history: JSON.parse(String(update.history)) };
|
|
68
|
+
}
|
|
69
|
+
const marks = await run(
|
|
70
|
+
`query Marks($filter: ModelReviewReadMarkFilterInput, $limit: Int) { listReviewReadMarks(filter: $filter, limit: $limit) { items { requestId } } }`,
|
|
71
|
+
{ filter: { userKey: { eq: userKey } }, limit: 5000 },
|
|
72
|
+
);
|
|
73
|
+
const readIds = (marks.data?.listReviewReadMarks?.items ?? []).filter(Boolean).map((mark: { requestId: string }) => mark.requestId);
|
|
74
|
+
return { state: "ready", requests, readIds, userKey };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Records an answer. Throws an Error with a sentence for the page when the request is closed or the write loses a race. */
|
|
78
|
+
export async function answerReviewRequest(request: ReviewRequest, input: AnswerInput, by: string, now: Date = new Date()): Promise<ReviewRequest> {
|
|
79
|
+
const update = answerUpdate(request, input, by, now);
|
|
80
|
+
const result = await run(
|
|
81
|
+
`mutation Answer($input: UpdateReviewRequestInput!, $condition: ModelReviewRequestConditionInput) { updateReviewRequest(input: $input, condition: $condition) { ${FIELDS} } }`,
|
|
82
|
+
{ input: update, condition: { state: { eq: "open" } } },
|
|
83
|
+
);
|
|
84
|
+
if (result.errors?.some((entry) => /conditional/i.test(`${entry.errorType ?? ""} ${entry.message ?? ""}`))) {
|
|
85
|
+
throw new Error("Someone else answered this, or it proceeded at its deadline. Refresh to see the result.");
|
|
86
|
+
}
|
|
87
|
+
if (result.errors?.length || !result.data?.updateReviewRequest) throw new Error("Could not save the answer. Nothing was changed.");
|
|
88
|
+
return normalizeReviewRow(result.data.updateReviewRequest);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** This user has seen the request. Create the mark; if it exists, refresh it. */
|
|
92
|
+
export async function markReviewRead(userKey: string, requestId: string, now: Date = new Date()): Promise<void> {
|
|
93
|
+
const input = { id: `${userKey}#${requestId}`, requestId, userKey, readAt: now.toISOString() };
|
|
94
|
+
const created = await run(
|
|
95
|
+
`mutation Mark($input: CreateReviewReadMarkInput!) { createReviewReadMark(input: $input) { id } }`, { input });
|
|
96
|
+
if (created.errors?.length) {
|
|
97
|
+
await run(`mutation Mark($input: UpdateReviewReadMarkInput!) { updateReviewReadMark(input: $input) { id } }`, { input });
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const MAX_TITLE_LOOKUPS = 40;
|
|
102
|
+
|
|
103
|
+
/** Titles of the stories that review requests are about, by lineage id, and the ids of stories that no longer exist (no version of the lineage remains). A story that cannot be read simply has no entry (the card falls back to the id). */
|
|
104
|
+
export async function loadStoryTitles(ids: string[]): Promise<{ titles: Record<string, string>; deleted: string[] }> {
|
|
105
|
+
const titles: Record<string, string> = {};
|
|
106
|
+
const deleted: string[] = [];
|
|
107
|
+
await Promise.all([...new Set(ids)].slice(0, MAX_TITLE_LOOKUPS).map(async (id) => {
|
|
108
|
+
const result = await run(
|
|
109
|
+
`query StoryTitle($lineageId: ID!) { listItemsByLineageAndVersion(lineageId: $lineageId, limit: 50) { items { versionNumber headline title } } }`,
|
|
110
|
+
{ lineageId: id },
|
|
111
|
+
);
|
|
112
|
+
// Only a clean, empty answer means the story is gone; a failed lookup says nothing.
|
|
113
|
+
if (!result.errors?.length && result.data?.listItemsByLineageAndVersion && !(result.data.listItemsByLineageAndVersion.items ?? []).filter(Boolean).length) deleted.push(id);
|
|
114
|
+
const items = (result.data?.listItemsByLineageAndVersion?.items ?? []).filter(Boolean) as { versionNumber?: number; headline?: string; title?: string }[];
|
|
115
|
+
const newest = items.sort((a, b) => (b.versionNumber ?? 0) - (a.versionNumber ?? 0))[0];
|
|
116
|
+
const title = newest?.headline || newest?.title;
|
|
117
|
+
if (title) titles[id] = title;
|
|
118
|
+
}));
|
|
119
|
+
return { titles, deleted };
|
|
120
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { ReviewRequest } from "./review-requests";
|
|
2
|
+
|
|
3
|
+
/** Demo and test data: one of each mode, a timed-out soft review, and an answered one whose steering was applied. */
|
|
4
|
+
export const FIXTURE_REVIEW_REQUESTS: ReviewRequest[] = [
|
|
5
|
+
{
|
|
6
|
+
id: "rr-hard-1", subject: { type: "story", id: "story-ruling-1" }, mode: "hard_gate", reason: "Approve this ruling summary before it is published",
|
|
7
|
+
ask: "Check the holding and the quoted passages.", raisedBy: "distinctive.ttab.ruling-analysis", raisedAt: "2026-10-23T08:00:00Z", deadline: null,
|
|
8
|
+
state: "open", answer: null, links: [], history: [{ at: "2026-10-23T08:00:00Z", event: "raised", by: "distinctive.ttab.ruling-analysis", note: "hard_gate" }],
|
|
9
|
+
appliedIn: null, updatedAt: "2026-10-23T08:00:00Z",
|
|
10
|
+
},
|
|
11
|
+
{
|
|
12
|
+
id: "rr-soft-2", subject: { type: "batch", id: "2026-W43" }, mode: "soft_review", reason: "3 new story ideas to review",
|
|
13
|
+
ask: "Approve to keep them all, reject to drop the batch, or steer with a note.", raisedBy: "idea-pipeline", raisedAt: "2026-10-23T07:00:00Z",
|
|
14
|
+
deadline: "2026-10-30T07:00:00Z", state: "open", answer: null,
|
|
15
|
+
links: [{ type: "proposal", id: "proposal-1", label: "story idea 1" }, { type: "proposal", id: "proposal-2", label: "story idea 2" }, { type: "proposal", id: "proposal-3", label: "story idea 3" }],
|
|
16
|
+
history: [], appliedIn: null, updatedAt: "2026-10-23T07:00:00Z",
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
id: "rr-soft-1", subject: { type: "proposal", id: "proposal-9" }, mode: "soft_review", reason: "A trend story draft is ready",
|
|
20
|
+
ask: null, raisedBy: "trend-pipeline", raisedAt: "2026-10-22T07:00:00Z", deadline: "2026-10-25T07:00:00Z", state: "open", answer: null,
|
|
21
|
+
links: [], history: [], appliedIn: null, updatedAt: "2026-10-22T07:00:00Z",
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
id: "rr-flag-1", subject: { type: "reference", id: "ref-1" }, mode: "flag", reason: "Two sources disagree about the filing date",
|
|
25
|
+
ask: null, raisedBy: "reference-intake", raisedAt: "2026-10-22T10:00:00Z", deadline: null, state: "open", answer: null, links: [], history: [],
|
|
26
|
+
appliedIn: null, updatedAt: "2026-10-22T10:00:00Z",
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
id: "rr-late-1", subject: { type: "story", id: "story-old" }, mode: "soft_review", reason: "Check the headline", ask: null, raisedBy: "copy-editor",
|
|
30
|
+
raisedAt: "2026-10-10T07:00:00Z", deadline: "2026-10-17T07:00:00Z", state: "timed_out", answer: null, links: [],
|
|
31
|
+
history: [{ at: "2026-10-17T07:00:00Z", event: "timed_out", by: "system", note: "proceeded without review" }], appliedIn: null, updatedAt: "2026-10-17T07:00:00Z",
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
id: "rr-done-1", subject: { type: "batch", id: "2026-W42" }, mode: "soft_review", reason: "4 new story ideas to review", ask: null, raisedBy: "idea-pipeline",
|
|
35
|
+
raisedAt: "2026-10-16T07:00:00Z", deadline: "2026-10-23T07:00:00Z", state: "answered",
|
|
36
|
+
answer: { decision: "feedback", feedback: "Fewer listicles", steer: "not_this_kind", by: "Ed Itor", at: "2026-10-17T09:00:00Z" },
|
|
37
|
+
links: [], history: [], appliedIn: { runId: "run-w43", by: "idea-pipeline", at: "2026-10-20T07:00:00Z" }, updatedAt: "2026-10-17T09:00:00Z",
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
id: "rr-done-2", subject: { type: "proposal", id: "proposal-4" }, mode: "soft_review", reason: "A concept rename was proposed", ask: null, raisedBy: "concept-bot",
|
|
41
|
+
raisedAt: "2026-10-18T07:00:00Z", deadline: "2026-10-25T07:00:00Z", state: "answered",
|
|
42
|
+
answer: { decision: "feedback", feedback: "Keep the old name for sports", steer: "adjust", by: "Ed Itor", at: "2026-10-19T09:00:00Z" },
|
|
43
|
+
links: [], history: [], appliedIn: null, updatedAt: "2026-10-19T09:00:00Z",
|
|
44
|
+
},
|
|
45
|
+
];
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Notification Center's model (PPY-1cd261, PPY-ab6a50): review requests, the Cyclotron items that wait for a person,
|
|
3
|
+
* the auto-decided stream, and the words the page says about each. Pure functions only, so the page and its tests share them.
|
|
4
|
+
*
|
|
5
|
+
* Principle (docs/notification-center.md): everything except a hard gate keeps moving if nobody acts. Each entry says in words
|
|
6
|
+
* what happens next without the reader, and only a hard gate says "Waiting for you".
|
|
7
|
+
*/
|
|
8
|
+
import type { ReferenceRecord } from "./category-repository";
|
|
9
|
+
import { getNewsroomNavHref } from "./newsroom-nav";
|
|
10
|
+
import { referenceDisplayTitle, referenceLineageId } from "./newsroom-references";
|
|
11
|
+
import { normalizeReferenceCurationStatus } from "./reference-policy";
|
|
12
|
+
import type { RelevanceDecisionView } from "./relevance-decisions";
|
|
13
|
+
|
|
14
|
+
export type ReviewMode = "flag" | "soft_review" | "hard_gate";
|
|
15
|
+
export type ReviewState = "open" | "answered" | "timed_out" | "withdrawn";
|
|
16
|
+
export type ReviewDecision = "approve" | "acknowledge" | "reject" | "feedback";
|
|
17
|
+
export type ReviewSteer = "adjust" | "not_this_kind";
|
|
18
|
+
|
|
19
|
+
export type ReviewAnswer = { decision: ReviewDecision; feedback: string | null; steer: ReviewSteer | null; by: string; at: string };
|
|
20
|
+
export type ReviewHistoryEntry = { at: string; event: string; by?: string; note?: string };
|
|
21
|
+
export type ReviewLink = { label?: string; url?: string; type?: string; id?: string };
|
|
22
|
+
|
|
23
|
+
export type ReviewRequest = {
|
|
24
|
+
id: string;
|
|
25
|
+
subject: { type: string; id: string };
|
|
26
|
+
mode: ReviewMode;
|
|
27
|
+
reason: string;
|
|
28
|
+
ask: string | null;
|
|
29
|
+
raisedBy: string;
|
|
30
|
+
raisedAt: string;
|
|
31
|
+
deadline: string | null;
|
|
32
|
+
state: ReviewState;
|
|
33
|
+
answer: ReviewAnswer | null;
|
|
34
|
+
links: ReviewLink[];
|
|
35
|
+
history: ReviewHistoryEntry[];
|
|
36
|
+
appliedIn: { runId: string; by: string; at: string; note?: string | null } | null;
|
|
37
|
+
updatedAt: string;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export const PROCEEDED_WITHOUT_REVIEW = "proceeded without review";
|
|
41
|
+
const MAX_HISTORY = 50;
|
|
42
|
+
|
|
43
|
+
function parseJson<T>(value: unknown, fallback: T): T {
|
|
44
|
+
if (typeof value === "string") {
|
|
45
|
+
try {
|
|
46
|
+
return JSON.parse(value) as T;
|
|
47
|
+
} catch {
|
|
48
|
+
return fallback;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return value === null || value === undefined ? fallback : (value as T);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** A row from the API (JSON fields arrive as strings) as a request. */
|
|
55
|
+
export function normalizeReviewRow(row: Record<string, unknown>): ReviewRequest {
|
|
56
|
+
return {
|
|
57
|
+
id: String(row.id),
|
|
58
|
+
subject: parseJson(row.subject, { type: "unknown", id: "" }),
|
|
59
|
+
mode: row.mode as ReviewMode,
|
|
60
|
+
reason: String(row.reason ?? ""),
|
|
61
|
+
ask: typeof row.ask === "string" ? row.ask : null,
|
|
62
|
+
raisedBy: String(row.raisedBy ?? ""),
|
|
63
|
+
raisedAt: String(row.raisedAt ?? ""),
|
|
64
|
+
deadline: typeof row.deadline === "string" ? row.deadline : null,
|
|
65
|
+
state: row.state as ReviewState,
|
|
66
|
+
answer: parseJson<ReviewAnswer | null>(row.answer, null),
|
|
67
|
+
links: parseJson<ReviewLink[]>(row.links, []),
|
|
68
|
+
history: parseJson<ReviewHistoryEntry[]>(row.history, []),
|
|
69
|
+
appliedIn: parseJson(row.appliedIn, null),
|
|
70
|
+
updatedAt: String(row.updatedAt ?? row.raisedAt ?? ""),
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// --- the deadline (checked on read) ---------------------------------------------------------------------
|
|
75
|
+
|
|
76
|
+
export function isDue(request: ReviewRequest, now: Date): boolean {
|
|
77
|
+
return request.mode === "soft_review" && request.state === "open" && request.deadline !== null && new Date(request.deadline).getTime() <= now.getTime();
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** The update that marks a due soft review as timed out, "proceeded without review". Applied with the condition state = open. */
|
|
81
|
+
export function timeoutUpdate(request: ReviewRequest, now: Date): Record<string, unknown> {
|
|
82
|
+
const at = now.toISOString();
|
|
83
|
+
const history = [...request.history, { at, event: "timed_out", by: "system", note: PROCEEDED_WITHOUT_REVIEW }].slice(-MAX_HISTORY);
|
|
84
|
+
return { id: request.id, state: "timed_out", updatedAt: at, history: JSON.stringify(history) };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export type AnswerInput = { decision: ReviewDecision; feedback?: string | null; steer?: ReviewSteer | null };
|
|
88
|
+
|
|
89
|
+
/** The update for an answer, or an Error saying why it is refused. Applied with the condition state = open. */
|
|
90
|
+
export function answerUpdate(request: ReviewRequest, input: AnswerInput, by: string, now: Date): Record<string, unknown> {
|
|
91
|
+
if (request.state !== "open") throw new Error(`This request is ${request.state.replace("_", " ")}${request.state === "timed_out" ? ` (${PROCEEDED_WITHOUT_REVIEW})` : ""} and can no longer be answered.`);
|
|
92
|
+
if (input.decision === "acknowledge" && request.mode !== "flag") throw new Error("Only a flag is acknowledged; approve or reject this one.");
|
|
93
|
+
const feedback = input.feedback?.trim() || null;
|
|
94
|
+
const steer = input.steer ?? null;
|
|
95
|
+
if (input.decision === "feedback" && !feedback && !steer) throw new Error("Write a note, or choose \"Not this kind of thing\".");
|
|
96
|
+
const at = now.toISOString();
|
|
97
|
+
const answer: ReviewAnswer = { decision: input.decision, feedback, steer, by, at };
|
|
98
|
+
const history = [...request.history, { at, event: "answered", by, note: input.decision }].slice(-MAX_HISTORY);
|
|
99
|
+
return { id: request.id, state: "answered", answer: JSON.stringify(answer), updatedAt: at, history: JSON.stringify(history) };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// --- words ------------------------------------------------------------------------------------------------
|
|
103
|
+
|
|
104
|
+
export function formatWhen(iso: string | null | undefined): string {
|
|
105
|
+
if (!iso) return "";
|
|
106
|
+
const date = new Date(iso);
|
|
107
|
+
if (Number.isNaN(date.getTime())) return "";
|
|
108
|
+
return `${date.toISOString().slice(0, 10)} ${date.toISOString().slice(11, 16)} UTC`;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function modeLabel(mode: ReviewMode): string {
|
|
112
|
+
return mode === "hard_gate" ? "Hard gate" : mode === "soft_review" ? "Soft review" : "Flag";
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** A mark and the words, never colour alone. */
|
|
116
|
+
export function modeMark(mode: ReviewMode): string {
|
|
117
|
+
return mode === "hard_gate" ? "■" : mode === "soft_review" ? "◐" : "⚑";
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** What happens next without the reader. Only a hard gate says "Waiting for you". */
|
|
121
|
+
export function nextText(request: ReviewRequest): string {
|
|
122
|
+
if (request.state === "open") {
|
|
123
|
+
if (request.mode === "hard_gate") return "Waiting for you. Nothing continues until you approve or reject.";
|
|
124
|
+
if (request.mode === "soft_review") return `Proceeds automatically on ${formatWhen(request.deadline)} unless you steer it.`;
|
|
125
|
+
return "Nothing is waiting on this. It stays here until you acknowledge it.";
|
|
126
|
+
}
|
|
127
|
+
if (request.state === "timed_out") return `Proceeded without review on ${formatWhen(request.updatedAt)}.`;
|
|
128
|
+
if (request.state === "withdrawn") return "Withdrawn by the step that raised it.";
|
|
129
|
+
const answer = request.answer;
|
|
130
|
+
return answer ? `${answerWords(answer, request)} by ${answer.by} on ${formatWhen(answer.at)}.` : "Answered.";
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function answerWords(answer: ReviewAnswer, request?: Pick<ReviewRequest, "mode">): string {
|
|
134
|
+
if (answer.steer === "not_this_kind") return "Steered: not this kind of thing";
|
|
135
|
+
if (answer.decision === "acknowledge") return "Acknowledged";
|
|
136
|
+
if (answer.decision === "approve") return request?.mode === "flag" ? "Acknowledged" : "Approved";
|
|
137
|
+
if (answer.decision === "reject") return "Rejected";
|
|
138
|
+
return "Steered with a note";
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** "You steered X, applied in run Y": what became of a person's input. */
|
|
142
|
+
export function steeringText(request: ReviewRequest): string | null {
|
|
143
|
+
const answer = request.answer;
|
|
144
|
+
if (request.state !== "answered" || !answer || !(answer.feedback || answer.steer || answer.decision === "reject")) return null;
|
|
145
|
+
const said = answer.feedback ? `"${answer.feedback}"` : answerWords(answer).toLowerCase();
|
|
146
|
+
if (request.appliedIn) return `You steered ${said}. Applied in run ${request.appliedIn.runId} on ${formatWhen(request.appliedIn.at)}.`;
|
|
147
|
+
return `You steered ${said}. ${request.raisedBy} has not used it yet; it will on its next run.`;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** What a request is about, in words: a story shows its title (the id when the title is not known), anything else its type and id. */
|
|
151
|
+
export function subjectLabel(subject: { type: string; id: string }, titles: Readonly<Record<string, string>> = {}, deleted: ReadonlySet<string> = new Set()): string {
|
|
152
|
+
if (subject.type === "story") return deleted.has(subject.id) ? `story ${subject.id} (story deleted)` : titles[subject.id] ?? `story ${subject.id}`;
|
|
153
|
+
return `${subject.type.replace("_", " ")} ${subject.id}`;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function subjectHref(subject: { type: string; id: string }, demo = false): string | null {
|
|
157
|
+
const id = encodeURIComponent(subject.id);
|
|
158
|
+
if (subject.type === "story") return getNewsroomNavHref(`/newsroom/articles/${id}`, demo);
|
|
159
|
+
if (subject.type === "reference" || subject.type === "cyclotron_decision") return getNewsroomNavHref(`/newsroom/references/${id}`, demo);
|
|
160
|
+
if (subject.type === "proposal") return getNewsroomNavHref(`/newsroom/topics?proposal=${id}`, demo);
|
|
161
|
+
if (subject.type === "automation_run") return getNewsroomNavHref("/newsroom/administration/automations", demo);
|
|
162
|
+
return null;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// --- Cyclotron items ---------------------------------------------------------------------------------------
|
|
166
|
+
|
|
167
|
+
export type CyclotronEntry = {
|
|
168
|
+
lineageId: string;
|
|
169
|
+
title: string;
|
|
170
|
+
label: string;
|
|
171
|
+
confidence: number | null;
|
|
172
|
+
decidedAt: string | null;
|
|
173
|
+
reviewDetail: string | null;
|
|
174
|
+
reviewedByPerson: boolean;
|
|
175
|
+
href: string;
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
function cyclotronEntry(reference: ReferenceRecord, decision: RelevanceDecisionView, reviewed: ReadonlySet<string>, demo: boolean): CyclotronEntry {
|
|
179
|
+
const lineageId = referenceLineageId(reference);
|
|
180
|
+
return {
|
|
181
|
+
lineageId, title: referenceDisplayTitle(reference), label: decision.label, confidence: decision.confidence,
|
|
182
|
+
decidedAt: decision.decidedAt, reviewDetail: decision.reviewDetail, reviewedByPerson: reviewed.has(lineageId),
|
|
183
|
+
href: getNewsroomNavHref(`/newsroom/references/${encodeURIComponent(lineageId)}`, demo),
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Items the Cyclotron routed to a person (the References tab's own rule: review recommended and still pending). Least confident first. */
|
|
188
|
+
export function cyclotronHeldForReview(references: ReferenceRecord[], decisions: ReadonlyMap<string, RelevanceDecisionView>, demo = false): CyclotronEntry[] {
|
|
189
|
+
const entries: CyclotronEntry[] = [];
|
|
190
|
+
for (const reference of references) {
|
|
191
|
+
const decision = decisions.get(referenceLineageId(reference));
|
|
192
|
+
if (decision?.reviewRecommended && normalizeReferenceCurationStatus(reference.curationStatus) === "pending") {
|
|
193
|
+
entries.push(cyclotronEntry(reference, decision, new Set(), demo));
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
return entries.sort((a, b) => (a.confidence ?? 1) - (b.confidence ?? 1));
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** Recent classifications the Cyclotron made on its own confidence. Read only here; correcting happens on the reference. Newest first. */
|
|
200
|
+
export function cyclotronAutoDecided(references: ReferenceRecord[], decisions: ReadonlyMap<string, RelevanceDecisionView>, reviewed: ReadonlySet<string>, demo = false, limit = 20): CyclotronEntry[] {
|
|
201
|
+
const entries: CyclotronEntry[] = [];
|
|
202
|
+
for (const reference of references) {
|
|
203
|
+
const decision = decisions.get(referenceLineageId(reference));
|
|
204
|
+
if (decision && !decision.reviewRecommended) entries.push(cyclotronEntry(reference, decision, reviewed, demo));
|
|
205
|
+
}
|
|
206
|
+
return entries.sort((a, b) => (b.decidedAt ?? "").localeCompare(a.decidedAt ?? "")).slice(0, limit);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// --- the page's shape -------------------------------------------------------------------------------------------
|
|
210
|
+
|
|
211
|
+
export type NotificationSections = {
|
|
212
|
+
hardGates: ReviewRequest[];
|
|
213
|
+
softReviews: ReviewRequest[];
|
|
214
|
+
flags: ReviewRequest[];
|
|
215
|
+
/** Answered, timed out or withdrawn, newest first: what people said and what became of it. */
|
|
216
|
+
decided: ReviewRequest[];
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
export function sectionReviews(requests: ReviewRequest[]): NotificationSections {
|
|
220
|
+
const open = requests.filter((request) => request.state === "open");
|
|
221
|
+
const byRaised = (a: ReviewRequest, b: ReviewRequest) => a.raisedAt.localeCompare(b.raisedAt);
|
|
222
|
+
return {
|
|
223
|
+
hardGates: open.filter((request) => request.mode === "hard_gate").sort(byRaised),
|
|
224
|
+
softReviews: open.filter((request) => request.mode === "soft_review").sort((a, b) => (a.deadline ?? "").localeCompare(b.deadline ?? "")),
|
|
225
|
+
flags: open.filter((request) => request.mode === "flag").sort((a, b) => b.raisedAt.localeCompare(a.raisedAt)),
|
|
226
|
+
decided: requests.filter((request) => request.state !== "open").sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)),
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
export type NotificationSummary = { waiting: number; proceedingSoon: number; decidedBySystem: number; unread: number };
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* "N waiting for you · N proceeding soon · N recently decided by the system".
|
|
234
|
+
* Waiting = hard gates plus Cyclotron items held for review (both stay put until a person acts). Proceeding soon = open soft reviews.
|
|
235
|
+
*/
|
|
236
|
+
export function summarize(sections: NotificationSections, held: CyclotronEntry[], autoDecided: CyclotronEntry[], unread: number): NotificationSummary {
|
|
237
|
+
return { waiting: sections.hardGates.length + held.length, proceedingSoon: sections.softReviews.length, decidedBySystem: autoDecided.length, unread };
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
export function summaryText(summary: NotificationSummary): string {
|
|
241
|
+
return `${summary.waiting} waiting for you · ${summary.proceedingSoon} proceeding soon · ${summary.decidedBySystem} recently decided by the system`;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
export function isUnread(request: ReviewRequest, readIds: ReadonlySet<string>): boolean {
|
|
245
|
+
return !readIds.has(request.id);
|
|
246
|
+
}
|
package/lib/semantic-graph.ts
CHANGED
|
@@ -57,6 +57,7 @@ export type SemanticPredicateId =
|
|
|
57
57
|
| "related_to"
|
|
58
58
|
| "broader_than"
|
|
59
59
|
| "narrower_than"
|
|
60
|
+
| "member_of"
|
|
60
61
|
| "supports"
|
|
61
62
|
| "contradicts";
|
|
62
63
|
|
|
@@ -96,6 +97,7 @@ export const SEMANTIC_PREDICATES: SemanticPredicateDefinition[] = [
|
|
|
96
97
|
{ id: "related_to", label: "related to", group: "generic", inverseLabel: "related from" },
|
|
97
98
|
{ id: "broader_than", label: "broader than", group: "ontology", inverseLabel: "narrower concept" },
|
|
98
99
|
{ id: "narrower_than", label: "narrower than", group: "ontology", inverseLabel: "broader concept" },
|
|
100
|
+
{ id: "member_of", label: "member of", group: "ontology", inverseLabel: "members" },
|
|
99
101
|
{ id: "supports", label: "supports", group: "evidence", inverseLabel: "supported by" },
|
|
100
102
|
{ id: "contradicts", label: "contradicts", group: "evidence", inverseLabel: "contradicted by" },
|
|
101
103
|
];
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Code-local trust contract for a site-hosted shared chat adapter.
|
|
3
|
+
*
|
|
4
|
+
* This module does not verify JWT signatures and is not an HTTP endpoint. The
|
|
5
|
+
* hosting server must inject a real verifier and authoritative membership
|
|
6
|
+
* resolver. Never construct VerifiedChatCredential from request JSON.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export type TokenUse = string;
|
|
10
|
+
|
|
11
|
+
/** Claims returned only after the injected host verifier has validated the JWT. */
|
|
12
|
+
export interface VerifiedChatCredential {
|
|
13
|
+
subject: string;
|
|
14
|
+
issuer: string;
|
|
15
|
+
audiences: readonly string[];
|
|
16
|
+
clientId?: string;
|
|
17
|
+
tokenUse?: TokenUse;
|
|
18
|
+
expiresAtEpochSeconds: number;
|
|
19
|
+
notBeforeEpochSeconds?: number;
|
|
20
|
+
issuedAtEpochSeconds?: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface AudienceRule {
|
|
24
|
+
/** Explicitly choose which JWT claim is authoritative for this host. */
|
|
25
|
+
claim: "aud" | "client_id";
|
|
26
|
+
expectedValue: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface ChatHostTrustProfile {
|
|
30
|
+
issuer: string;
|
|
31
|
+
audienceRule: AudienceRule;
|
|
32
|
+
/** Must be configured by the host after verifying its actual token contract. */
|
|
33
|
+
acceptedTokenUses: readonly TokenUse[];
|
|
34
|
+
configuredSiteId: string;
|
|
35
|
+
clockSkewSeconds?: number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface ChatRequest {
|
|
39
|
+
authorization?: string;
|
|
40
|
+
channelId: string;
|
|
41
|
+
idempotencyKey: string;
|
|
42
|
+
message: { text: string; authorSub?: string; accountId?: string; siteId?: string };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface ChatMembership {
|
|
46
|
+
accountId: string;
|
|
47
|
+
siteId: string;
|
|
48
|
+
editorSub: string;
|
|
49
|
+
active: boolean;
|
|
50
|
+
/** Host-derived authorization for the editorial chat surface (editor/admin policy). */
|
|
51
|
+
canUseEditorialChat: boolean;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface TranscriptOwner {
|
|
55
|
+
accountId: string;
|
|
56
|
+
siteId: string;
|
|
57
|
+
editorSub: string;
|
|
58
|
+
channelId: string;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface SharedChatAdmissionDependencies {
|
|
62
|
+
trust: ChatHostTrustProfile;
|
|
63
|
+
nowEpochSeconds?: () => number;
|
|
64
|
+
verifyCredential: (rawCredential: string) => Promise<VerifiedChatCredential | null>;
|
|
65
|
+
resolveMembership: (subject: string, configuredSiteId: string) => Promise<ChatMembership | null>;
|
|
66
|
+
resolveTranscriptOwner: (channelId: string) => Promise<TranscriptOwner | null>;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface AdmittedChatMessage {
|
|
70
|
+
owner: TranscriptOwner;
|
|
71
|
+
idempotencyKey: string;
|
|
72
|
+
/** Stable canonical representation used for payload-reuse detection. */
|
|
73
|
+
payloadFingerprint: string;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export class ChatAdmissionError extends Error {
|
|
77
|
+
readonly code: "unauthorized" | "forbidden" | "invalid_request" | "idempotency_conflict";
|
|
78
|
+
|
|
79
|
+
constructor(code: ChatAdmissionError["code"]) {
|
|
80
|
+
super(code);
|
|
81
|
+
this.name = "ChatAdmissionError";
|
|
82
|
+
this.code = code;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const reject = (code: ChatAdmissionError["code"]): never => { throw new ChatAdmissionError(code); };
|
|
87
|
+
|
|
88
|
+
function bearerToken(value: string | undefined): string {
|
|
89
|
+
const match = value?.match(/^Bearer ([^\s]+)$/i);
|
|
90
|
+
if (!match) return reject("unauthorized");
|
|
91
|
+
return match[1];
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function assertCredential(credential: VerifiedChatCredential, trust: ChatHostTrustProfile, now: number): void {
|
|
95
|
+
const skew = Math.max(0, trust.clockSkewSeconds ?? 0);
|
|
96
|
+
if (!trust.issuer || credential.issuer !== trust.issuer) reject("unauthorized");
|
|
97
|
+
if (!trust.acceptedTokenUses.length || !credential.tokenUse || !trust.acceptedTokenUses.includes(credential.tokenUse)) reject("unauthorized");
|
|
98
|
+
if (!Number.isFinite(credential.expiresAtEpochSeconds) || now - skew >= credential.expiresAtEpochSeconds) reject("unauthorized");
|
|
99
|
+
if (credential.notBeforeEpochSeconds !== undefined && now + skew < credential.notBeforeEpochSeconds) reject("unauthorized");
|
|
100
|
+
if (credential.issuedAtEpochSeconds !== undefined && now + skew < credential.issuedAtEpochSeconds) reject("unauthorized");
|
|
101
|
+
|
|
102
|
+
const audienceMatches = trust.audienceRule.claim === "aud"
|
|
103
|
+
? credential.audiences.includes(trust.audienceRule.expectedValue)
|
|
104
|
+
: credential.clientId === trust.audienceRule.expectedValue;
|
|
105
|
+
if (!audienceMatches) reject("unauthorized");
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Authenticate, derive the current host membership, and enforce private channel ownership. */
|
|
109
|
+
export async function admitSharedChatMessage(
|
|
110
|
+
request: ChatRequest,
|
|
111
|
+
dependencies: SharedChatAdmissionDependencies,
|
|
112
|
+
): Promise<AdmittedChatMessage> {
|
|
113
|
+
if (!request.channelId || !request.message.text.trim() || !/^[A-Za-z0-9._~-]{16,128}$/.test(request.idempotencyKey)) {
|
|
114
|
+
return reject("invalid_request");
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const rawCredential = bearerToken(request.authorization);
|
|
118
|
+
const credential = await dependencies.verifyCredential(rawCredential);
|
|
119
|
+
if (!credential) return reject("unauthorized");
|
|
120
|
+
const now = (dependencies.nowEpochSeconds ?? (() => Date.now() / 1000))();
|
|
121
|
+
assertCredential(credential, dependencies.trust, now);
|
|
122
|
+
|
|
123
|
+
const membership = await dependencies.resolveMembership(credential.subject, dependencies.trust.configuredSiteId);
|
|
124
|
+
if (!membership || !membership.active || !membership.canUseEditorialChat
|
|
125
|
+
|| membership.editorSub !== credential.subject || membership.siteId !== dependencies.trust.configuredSiteId) {
|
|
126
|
+
return reject("forbidden");
|
|
127
|
+
}
|
|
128
|
+
if ((request.message.authorSub && request.message.authorSub !== membership.editorSub)
|
|
129
|
+
|| (request.message.accountId && request.message.accountId !== membership.accountId)
|
|
130
|
+
|| (request.message.siteId && request.message.siteId !== membership.siteId)) {
|
|
131
|
+
return reject("forbidden");
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const expectedOwner: TranscriptOwner = {
|
|
135
|
+
accountId: membership.accountId,
|
|
136
|
+
siteId: membership.siteId,
|
|
137
|
+
editorSub: membership.editorSub,
|
|
138
|
+
channelId: request.channelId,
|
|
139
|
+
};
|
|
140
|
+
const actualOwner = await dependencies.resolveTranscriptOwner(request.channelId);
|
|
141
|
+
if (!actualOwner || actualOwner.accountId !== expectedOwner.accountId || actualOwner.siteId !== expectedOwner.siteId
|
|
142
|
+
|| actualOwner.editorSub !== expectedOwner.editorSub || actualOwner.channelId !== expectedOwner.channelId) {
|
|
143
|
+
return reject("forbidden");
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return {
|
|
147
|
+
owner: expectedOwner,
|
|
148
|
+
idempotencyKey: request.idempotencyKey,
|
|
149
|
+
payloadFingerprint: JSON.stringify([request.channelId, request.message.text]),
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export interface IdempotencyRequest<T> {
|
|
154
|
+
/** Storage must atomically scope key by the derived owner and operation. */
|
|
155
|
+
scope: string;
|
|
156
|
+
key: string;
|
|
157
|
+
payloadFingerprint: string;
|
|
158
|
+
run: () => Promise<T>;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Store contract for replay-safe writes. Production implementations must be
|
|
163
|
+
* atomic/durable (for example conditional put + canonical result); this module
|
|
164
|
+
* deliberately supplies no in-memory production store.
|
|
165
|
+
*/
|
|
166
|
+
export interface ChatIdempotencyStore {
|
|
167
|
+
executeOnce<T>(request: IdempotencyRequest<T>): Promise<T>;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export function runAdmittedChatMessage<T>(
|
|
171
|
+
admission: AdmittedChatMessage,
|
|
172
|
+
store: ChatIdempotencyStore,
|
|
173
|
+
operation: () => Promise<T>,
|
|
174
|
+
): Promise<T> {
|
|
175
|
+
const { accountId, siteId, editorSub, channelId } = admission.owner;
|
|
176
|
+
return store.executeOnce({
|
|
177
|
+
scope: JSON.stringify(["chat-message", accountId, siteId, editorSub, channelId]),
|
|
178
|
+
key: admission.idempotencyKey,
|
|
179
|
+
payloadFingerprint: admission.payloadFingerprint,
|
|
180
|
+
run: operation,
|
|
181
|
+
});
|
|
182
|
+
}
|
package/lib/site-brand-tenant.ts
CHANGED
|
@@ -9,14 +9,19 @@ export function resolveBrandConfigPath(relativePath: string): string {
|
|
|
9
9
|
return path.isAbsolute(relativePath) ? relativePath : path.join(process.cwd(), relativePath);
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
-
|
|
13
|
-
|
|
12
|
+
/** Optional repo files a brand may still name; undefined means the site keeps that configuration as backend data and nothing is looked up. */
|
|
13
|
+
function optionalBrandConfigPath(relativePath: string | undefined): string | undefined {
|
|
14
|
+
return relativePath ? resolveBrandConfigPath(relativePath) : undefined;
|
|
14
15
|
}
|
|
15
16
|
|
|
16
|
-
export function
|
|
17
|
-
return
|
|
17
|
+
export function getBrandSteeringConfigPath(brand: SiteBrand = resolveActiveSiteBrand()): string | undefined {
|
|
18
|
+
return optionalBrandConfigPath(brand.steeringConfigPath);
|
|
18
19
|
}
|
|
19
20
|
|
|
20
|
-
export function
|
|
21
|
-
return
|
|
21
|
+
export function getBrandNewsroomSectionsConfigPath(brand: SiteBrand = resolveActiveSiteBrand()): string | undefined {
|
|
22
|
+
return optionalBrandConfigPath(brand.newsroomSectionsConfigPath);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function getBrandAnalysisProfilesPath(brand: SiteBrand = resolveActiveSiteBrand()): string | undefined {
|
|
26
|
+
return optionalBrandConfigPath(brand.analysisProfilesPath);
|
|
22
27
|
}
|