@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,202 @@
|
|
|
1
|
+
/** Shapes and wording for the newsroom Newsletter panel. `src/papyrus_content/newsletter/` (panel.py, issues.py) produces them. */
|
|
2
|
+
import { getNewsroomNavHref } from "./newsroom-nav";
|
|
3
|
+
import { csvCell } from "./signup-csv";
|
|
4
|
+
|
|
5
|
+
export type NewsletterCounts = Partial<Record<"queued" | "sent" | "failed" | "suppressed" | "skipped" | "delivered" | "bounced" | "complained" | "softBounced", number>>;
|
|
6
|
+
|
|
7
|
+
export type NewsletterList = { id: string; name: string; kind: string; confirmed: number; pending: number; unsubscribed: number; timezone?: string };
|
|
8
|
+
|
|
9
|
+
export type NewsletterEvent = { type: string; at: string; issue?: string; list?: string; detail?: string };
|
|
10
|
+
|
|
11
|
+
export type NewsletterOverview = {
|
|
12
|
+
lists: NewsletterList[];
|
|
13
|
+
totals: { confirmed: number; pending: number; unsubscribed: number };
|
|
14
|
+
suppressed: number;
|
|
15
|
+
paused: { paused: boolean; reason?: string; pausedAt?: string; resumedAt?: string };
|
|
16
|
+
events: NewsletterEvent[];
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export type NewsletterIssue = {
|
|
20
|
+
issueId: string;
|
|
21
|
+
listId: string;
|
|
22
|
+
listName?: string;
|
|
23
|
+
kind: string;
|
|
24
|
+
itemId?: string;
|
|
25
|
+
subject: string;
|
|
26
|
+
status: string;
|
|
27
|
+
sendAt?: string;
|
|
28
|
+
approvedBy?: string;
|
|
29
|
+
approvedAt?: string;
|
|
30
|
+
sentAt?: string;
|
|
31
|
+
counts: NewsletterCounts;
|
|
32
|
+
note?: string;
|
|
33
|
+
archiveUrl?: string;
|
|
34
|
+
editedSinceApproval?: boolean;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export type NewsletterPreview = { subject: string; html: string; text: string; bytes: number; postalAddress: boolean };
|
|
38
|
+
|
|
39
|
+
export type NewsletterDelivery = { address: string; status: string; at: string; eventAt?: string; error?: string };
|
|
40
|
+
|
|
41
|
+
export type NewsletterSubscriber = { email: string; status: string; lists: { id: string; status: string }[]; createdAt: string; confirmedAt?: string };
|
|
42
|
+
|
|
43
|
+
export type NewsletterExportRow = {
|
|
44
|
+
email: string;
|
|
45
|
+
status: string;
|
|
46
|
+
lists: string;
|
|
47
|
+
createdAt: string;
|
|
48
|
+
consentRequestedAt: string;
|
|
49
|
+
consentConfirmedAt: string;
|
|
50
|
+
consentFormId: string;
|
|
51
|
+
consentPageUrl: string;
|
|
52
|
+
consentWording: string;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export type NewsletterFailure = { ok: false; error: string; message?: string };
|
|
56
|
+
|
|
57
|
+
/** The newsroom words for the statuses the functions write. A status this panel does not know is shown as written, never hidden. */
|
|
58
|
+
const STATUS_LABELS: Record<string, string> = {
|
|
59
|
+
draft: "Draft",
|
|
60
|
+
awaiting_approval: "Awaiting approval",
|
|
61
|
+
scheduled: "Scheduled",
|
|
62
|
+
sending: "Sending",
|
|
63
|
+
sent: "Sent",
|
|
64
|
+
stopped: "Stopped",
|
|
65
|
+
cancelled: "Cancelled",
|
|
66
|
+
blocked: "Blocked",
|
|
67
|
+
};
|
|
68
|
+
/** A mark beside the word, so no status relies on colour alone. */
|
|
69
|
+
const STATUS_MARKS: Record<string, string> = { draft: "✎", awaiting_approval: "?", scheduled: "◷", sending: "▶", sent: "✓", stopped: "■", cancelled: "–", blocked: "!" };
|
|
70
|
+
|
|
71
|
+
export function statusLabel(status: string): string {
|
|
72
|
+
if (STATUS_LABELS[status]) return STATUS_LABELS[status];
|
|
73
|
+
const words = status.replace(/_/g, " ").trim();
|
|
74
|
+
return words ? words.charAt(0).toUpperCase() + words.slice(1) : "Unknown";
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function statusMark(status: string): string {
|
|
78
|
+
return STATUS_MARKS[status] ?? "·";
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export const ISSUE_STATUS_FILTERS = ["draft", "awaiting_approval", "scheduled", "sending", "sent", "blocked", "stopped", "cancelled"] as const;
|
|
82
|
+
|
|
83
|
+
/** Approval accepts a draft, an alert awaiting approval, a scheduled issue (a new time) and a blocked one (cause fixed). */
|
|
84
|
+
export function canApprove(status: string): boolean {
|
|
85
|
+
return status === "draft" || status === "awaiting_approval" || status === "scheduled" || status === "blocked";
|
|
86
|
+
}
|
|
87
|
+
export const canCancel = (status: string): boolean => status === "scheduled";
|
|
88
|
+
export const canStop = (status: string): boolean => status === "sending";
|
|
89
|
+
|
|
90
|
+
export function issueEditorHref(itemId: string, demo?: boolean): string {
|
|
91
|
+
return getNewsroomNavHref(`/newsroom/articles/${encodeURIComponent(itemId)}`, demo);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const ERROR_WORDS: Record<string, string> = {
|
|
95
|
+
forbidden: "Your role cannot do this.",
|
|
96
|
+
not_found: "That issue no longer exists.",
|
|
97
|
+
invalid_state: "The issue changed state. Refresh and try again.",
|
|
98
|
+
item_missing: "The issue's item no longer exists.",
|
|
99
|
+
render_failed: "The issue cannot be rendered. Open it in the editor and fix the body.",
|
|
100
|
+
bad_request: "The request was not understood.",
|
|
101
|
+
no_address: "Your account has no email address to send a test to.",
|
|
102
|
+
unavailable: "The newsletter service could not be reached. Try again in a minute.",
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
/** One sentence for a failed operation. A missing postal address is an operator task, so the panel says where it is set. */
|
|
106
|
+
export function failureText(failure: NewsletterFailure): string {
|
|
107
|
+
if (failure.error === "postal_address_missing") {
|
|
108
|
+
return "This site has no postal address for the email footer, and mail law requires one. An operator sets it in the site's AWS configuration (an SSM parameter); it cannot be set from the newsroom.";
|
|
109
|
+
}
|
|
110
|
+
return ERROR_WORDS[failure.error] ?? failure.message ?? "The request failed.";
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// -- times ---------------------------------------------------------------------------------------------------------------
|
|
114
|
+
|
|
115
|
+
export function formatUtc(iso: string | null | undefined): string {
|
|
116
|
+
if (!iso) return "never";
|
|
117
|
+
const match = /^(\d{4}-\d{2}-\d{2})T(\d{2}:\d{2})/.exec(iso);
|
|
118
|
+
return match ? `${match[1]} ${match[2]} UTC` : iso;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function validTimeZone(zone: string | null | undefined): string | null {
|
|
122
|
+
if (!zone) return null;
|
|
123
|
+
try {
|
|
124
|
+
new Intl.DateTimeFormat("en-US", { timeZone: zone });
|
|
125
|
+
return zone;
|
|
126
|
+
} catch {
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function wallParts(instant: Date, zone: string): { year: number; month: number; day: number; hour: number; minute: number } {
|
|
132
|
+
const parts = new Intl.DateTimeFormat("en-US", {
|
|
133
|
+
timeZone: zone, hourCycle: "h23", year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit",
|
|
134
|
+
}).formatToParts(instant);
|
|
135
|
+
const pick = (type: string) => Number(parts.find((part) => part.type === type)?.value ?? "0");
|
|
136
|
+
return { year: pick("year"), month: pick("month"), day: pick("day"), hour: pick("hour") % 24, minute: pick("minute") };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Minutes the zone is ahead of UTC at that instant. */
|
|
140
|
+
function offsetMinutes(instant: Date, zone: string): number {
|
|
141
|
+
const wall = wallParts(instant, zone);
|
|
142
|
+
const asUtc = Date.UTC(wall.year, wall.month - 1, wall.day, wall.hour, wall.minute);
|
|
143
|
+
return Math.round((asUtc - Math.floor(instant.getTime() / 60000) * 60000) / 60000);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const pad = (value: number) => String(value).padStart(2, "0");
|
|
147
|
+
|
|
148
|
+
/** Local wall time `YYYY-MM-DDTHH:mm` (a datetime-local value) as an ISO instant, for the given zone. */
|
|
149
|
+
export function zonedInputToIso(local: string, zone: string): string | null {
|
|
150
|
+
const match = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})$/.exec(local);
|
|
151
|
+
if (!match) return null;
|
|
152
|
+
const [, year, month, day, hour, minute] = match.map(Number) as unknown as number[];
|
|
153
|
+
const wallAsUtc = Date.UTC(year, month - 1, day, hour, minute);
|
|
154
|
+
let guess = wallAsUtc - offsetMinutes(new Date(wallAsUtc), zone) * 60000;
|
|
155
|
+
guess = wallAsUtc - offsetMinutes(new Date(guess), zone) * 60000; // once more: the offset can differ across a clock change
|
|
156
|
+
const instant = new Date(guess);
|
|
157
|
+
return Number.isNaN(instant.getTime()) ? null : instant.toISOString().replace(".000Z", "Z");
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** The next whole hour on the zone's clock, as a datetime-local value. */
|
|
161
|
+
export function nextWholeHourInput(now: Date, zone: string): string {
|
|
162
|
+
const wall = wallParts(now, zone);
|
|
163
|
+
const next = new Date(Date.UTC(wall.year, wall.month - 1, wall.day, wall.hour + 1, 0));
|
|
164
|
+
return `${next.getUTCFullYear()}-${pad(next.getUTCMonth() + 1)}-${pad(next.getUTCDate())}T${pad(next.getUTCHours())}:00`;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** An ISO instant as a datetime-local value in the zone. */
|
|
168
|
+
export function isoToZonedInput(iso: string, zone: string): string {
|
|
169
|
+
const wall = wallParts(new Date(iso), zone);
|
|
170
|
+
return `${wall.year}-${pad(wall.month)}-${pad(wall.day)}T${pad(wall.hour)}:${pad(wall.minute)}`;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** `2027-01-15 09:00 EST` in the zone. */
|
|
174
|
+
export function formatInZone(iso: string | null | undefined, zone: string): string {
|
|
175
|
+
if (!iso) return "not set";
|
|
176
|
+
const instant = new Date(iso);
|
|
177
|
+
if (Number.isNaN(instant.getTime())) return iso;
|
|
178
|
+
const label = new Intl.DateTimeFormat("en-US", { timeZone: zone, timeZoneName: "short" }).formatToParts(instant).find((part) => part.type === "timeZoneName")?.value ?? zone;
|
|
179
|
+
return `${isoToZonedInput(iso, zone).replace("T", " ")} ${label}`;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// -- counts and CSV ------------------------------------------------------------------------------------------------------
|
|
183
|
+
|
|
184
|
+
/** "12 sent, 2 delivered, 1 bounced"; only counters above zero, in reading order. */
|
|
185
|
+
export function countsText(counts: NewsletterCounts): string {
|
|
186
|
+
const order: [keyof NewsletterCounts, string][] = [
|
|
187
|
+
["queued", "queued"], ["sent", "sent"], ["delivered", "delivered"], ["bounced", "bounced"], ["complained", "complained"],
|
|
188
|
+
["failed", "failed"], ["suppressed", "suppressed"], ["skipped", "skipped"], ["softBounced", "soft bounced"],
|
|
189
|
+
];
|
|
190
|
+
const parts = order.filter(([name]) => (counts[name] ?? 0) > 0).map(([name, label]) => `${counts[name]} ${label}`);
|
|
191
|
+
return parts.length ? parts.join(", ") : "nothing sent yet";
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export const NEWSLETTER_CSV_HEADER = [
|
|
195
|
+
"email", "status", "lists", "createdAt", "consentRequestedAt", "consentConfirmedAt", "consentFormId", "consentPageUrl", "consentWording",
|
|
196
|
+
] as const;
|
|
197
|
+
|
|
198
|
+
/** Every cell goes through the formula neutraliser: addresses and consent wording are text people typed. */
|
|
199
|
+
export function subscribersToCsv(rows: NewsletterExportRow[]): string {
|
|
200
|
+
const lines = rows.map((row) => NEWSLETTER_CSV_HEADER.map((column) => csvCell(String(row[column] ?? ""))).join(","));
|
|
201
|
+
return `${[NEWSLETTER_CSV_HEADER.join(","), ...lines].join("\r\n")}\r\n`;
|
|
202
|
+
}
|
package/lib/newsroom-board.ts
CHANGED
|
@@ -21,6 +21,31 @@ export interface NativeBoardIssue extends KanbanIssue {
|
|
|
21
21
|
href: string;
|
|
22
22
|
/** A few lines to show under the headline: the blurb, else the subtitle. */
|
|
23
23
|
excerpt?: string;
|
|
24
|
+
/** The quiet path shown where a board would print the ID, e.g. `articles/my-slug` or `assignments/write-it`. */
|
|
25
|
+
displayId: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function slugify(text: string): string {
|
|
29
|
+
return text.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Section and slug for a content item, falling back to the publication's board path segment. */
|
|
33
|
+
export function contentItemDisplayId(version: { section?: string | null; slug?: string | null; lineageId: string }): string {
|
|
34
|
+
const section = slugify(version.section ?? "") || contentItemBoardPathSegment();
|
|
35
|
+
return `${section}/${slugify(version.slug ?? "") || version.lineageId.slice(0, 8)}`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Assignments have no slug: use the title as one, else a short id. */
|
|
39
|
+
export function assignmentDisplayId(assignment: { id: string; title?: string | null }): string {
|
|
40
|
+
const titleSlug = slugify(assignment.title ?? "").slice(0, 48).replace(/-+$/g, "");
|
|
41
|
+
return `assignments/${titleSlug || assignment.id.slice(0, 8)}`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** `formatIssueId` for the Kanbus UI: the host-provided path, never the raw `kind:id` prefix. */
|
|
45
|
+
export function formatNativeIssueId(issue: KanbanIssue): string {
|
|
46
|
+
const displayId = (issue as Partial<NativeBoardIssue>).displayId;
|
|
47
|
+
if (displayId) return displayId;
|
|
48
|
+
return issue.id.replace(/^[A-Za-z]+:/, "").slice(0, 12);
|
|
24
49
|
}
|
|
25
50
|
|
|
26
51
|
export function contentItemBoardPathSegment(): string {
|
|
@@ -60,7 +85,7 @@ export function projectContentItemBoard(records: {
|
|
|
60
85
|
title: version.headline || version.title || version.slug, nativeStatus: stage, status: `${contentItemType}/${stage}`,
|
|
61
86
|
priority: 2, description: workCount ? `${workCount} assignment${workCount === 1 ? "" : "s"} on this ${contentItemType}.` : undefined,
|
|
62
87
|
updated_at: version.updatedAt ?? undefined, href: `/newsroom/articles/${encodeURIComponent(version.id)}`,
|
|
63
|
-
excerpt: contentItemExcerpt(version),
|
|
88
|
+
excerpt: contentItemExcerpt(version), displayId: contentItemDisplayId(version),
|
|
64
89
|
};
|
|
65
90
|
});
|
|
66
91
|
}
|
|
@@ -73,7 +98,7 @@ export function projectAssignmentBoard(assignments: readonly AssignmentRecord[])
|
|
|
73
98
|
priority: row.priority ?? 2, assignee: row.assigneeId || undefined,
|
|
74
99
|
description: row.summary || row.brief || undefined,
|
|
75
100
|
created_at: row.createdAt, updated_at: row.updatedAt,
|
|
76
|
-
href: `/newsroom/assignments/${encodeURIComponent(row.id)}`,
|
|
101
|
+
href: `/newsroom/assignments/${encodeURIComponent(row.id)}`, displayId: assignmentDisplayId(row),
|
|
77
102
|
}));
|
|
78
103
|
}
|
|
79
104
|
|
|
@@ -237,7 +237,7 @@ export function applyPilobolusDemoDashboardContent(
|
|
|
237
237
|
...record,
|
|
238
238
|
title: "Pilobolus Editorial Policy",
|
|
239
239
|
body: [
|
|
240
|
-
"Publication doctrine is the global inclusion standard for Pilobol.us;
|
|
240
|
+
"Publication doctrine is the global inclusion standard for Pilobol.us; section doctrine sets local intake priorities for field notes and source intake.",
|
|
241
241
|
"Field-notes reporting should prioritize accepted Pilobolus references and annotated intake artifacts—not the Papyrus platform knowledge base.",
|
|
242
242
|
"High-risk claims about host takeover or network control require explicit uncertainty handling and accepted source linkage.",
|
|
243
243
|
],
|
package/lib/newsroom-nav.ts
CHANGED
|
@@ -21,7 +21,7 @@ export type NewsroomNavItem = {
|
|
|
21
21
|
};
|
|
22
22
|
|
|
23
23
|
export const NEWSROOM_OPS_NAV: NewsroomNavItem[] = [
|
|
24
|
-
{ id: "overview", label: "Overview", detail: "
|
|
24
|
+
{ id: "overview", label: "Overview", detail: "Newsroom home", href: newsroomHref() },
|
|
25
25
|
{ id: "articles", label: getContentItemName().plural, detail: "Drafts & published", href: newsroomHref("articles") },
|
|
26
26
|
{ id: "assignments", label: "Assignments", detail: "Work queue", href: newsroomHref("assignments") },
|
|
27
27
|
{ id: "references", label: "References", detail: "Knowledge base", href: newsroomHref("references") },
|
|
@@ -8,6 +8,7 @@ export type OrganizerDestination = {
|
|
|
8
8
|
};
|
|
9
9
|
export const ORGANIZER_NAV_GROUPS: readonly { title: string; items: readonly OrganizerDestination[] }[] = [
|
|
10
10
|
{ title: "Editorial work", items: [
|
|
11
|
+
{ key: "overview", label: "Overview", description: "See notifications and work needing attention.", path: "/newsroom", primary: true },
|
|
11
12
|
{ key: "articles", label: getContentItemName().plural, description: `Follow ${contentItemPluralLowercase} across their stages and open one to write or revise it.`, path: `/newsroom/${contentItemBoardPathSegment()}`, alsoActiveOnPaths: ["/newsroom/articles"], primary: true },
|
|
12
13
|
{ key: "assignments", label: "Assignments", description: "Follow research and reporting work across its statuses and claim it.", path: "/newsroom/assignments" },
|
|
13
14
|
{ key: "messages", label: "Messages", description: "Read and reply to newsroom conversations.", path: "/newsroom/messages", primary: true },
|
|
@@ -23,7 +24,7 @@ export const ORGANIZER_NAV_GROUPS: readonly { title: string; items: readonly Org
|
|
|
23
24
|
] },
|
|
24
25
|
];
|
|
25
26
|
const all = ORGANIZER_NAV_GROUPS.flatMap(group => group.items);
|
|
26
|
-
export const ORGANIZER_PRIMARY_NAV = ["articles", "references", "messages"].map(key => all.find(item => item.key === key)!);
|
|
27
|
+
export const ORGANIZER_PRIMARY_NAV = ["overview", "articles", "references", "messages"].map(key => all.find(item => item.key === key)!);
|
|
27
28
|
|
|
28
29
|
export const ORGANIZER_SETTINGS_DESTINATION = {
|
|
29
30
|
label: "Settings", path: "/newsroom/settings", description: "Theme, motion and interface preferences.",
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { postChatMessage, type ChatHttpTransport, type ChatMessageSubmission, type PostMessageResponse } from "chatticus/chat-client";
|
|
2
|
+
|
|
3
|
+
/** Stable author mapping supplied by the Papyrus host; never infer it from a display label or email. */
|
|
4
|
+
export interface PapyrusChatAuthor {
|
|
5
|
+
hostMappedAuthorId: string;
|
|
6
|
+
addressedToBotId: string | null;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** One logical send. Reuse this exact value only for an explicit retry of the same message. */
|
|
10
|
+
export interface PapyrusChatAdmissionAttempt {
|
|
11
|
+
readonly submission: Readonly<ChatMessageSubmission>;
|
|
12
|
+
readonly idempotencyKey: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface PapyrusChatAdmissionSenderOptions {
|
|
16
|
+
/** Host-owned chat URL; no endpoint is configured or exposed by this module. */
|
|
17
|
+
url: string;
|
|
18
|
+
/** Must obtain current Papyrus auth headers on every call; token type stays a host decision. */
|
|
19
|
+
freshAuthorizationHeaders: () => Promise<Record<string, string>>;
|
|
20
|
+
fetch?: typeof globalThis.fetch;
|
|
21
|
+
/** Override only in hermetic tests. Production defaults to the platform CSPRNG. */
|
|
22
|
+
createIdempotencyKey?: () => string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface PapyrusChatAdmissionSender {
|
|
26
|
+
createAttempt(text: string, author: PapyrusChatAuthor): PapyrusChatAdmissionAttempt;
|
|
27
|
+
send(attempt: PapyrusChatAdmissionAttempt): Promise<PostMessageResponse>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Bind Papyrus's host-owned auth and URL to Chatticus's reusable admission
|
|
32
|
+
* transport. Each send makes one POST; there is no automatic retry or auth
|
|
33
|
+
* fallback. An explicit retry sends the same attempt and refreshes headers.
|
|
34
|
+
*/
|
|
35
|
+
export function createPapyrusChatAdmissionSender(options: PapyrusChatAdmissionSenderOptions): PapyrusChatAdmissionSender {
|
|
36
|
+
const transport: ChatHttpTransport = {
|
|
37
|
+
url: options.url,
|
|
38
|
+
headers: async () => {
|
|
39
|
+
const headers = await options.freshAuthorizationHeaders();
|
|
40
|
+
const authorization = Object.entries(headers).find(([name]) => name.toLowerCase() === "authorization")?.[1];
|
|
41
|
+
if (!authorization?.trim()) throw new Error("Papyrus authorization headers are required");
|
|
42
|
+
return headers;
|
|
43
|
+
},
|
|
44
|
+
...(options.fetch ? { fetch: options.fetch } : {}),
|
|
45
|
+
};
|
|
46
|
+
const createKey = options.createIdempotencyKey ?? (() => globalThis.crypto.randomUUID());
|
|
47
|
+
|
|
48
|
+
return {
|
|
49
|
+
createAttempt(text, author) {
|
|
50
|
+
if (!text.trim() || !author.hostMappedAuthorId.trim()) throw new Error("A message and host-mapped author are required");
|
|
51
|
+
const submission: ChatMessageSubmission = {
|
|
52
|
+
author_kind: "human",
|
|
53
|
+
author_id: author.hostMappedAuthorId,
|
|
54
|
+
body: text,
|
|
55
|
+
addressed_to_bot_id: author.addressedToBotId,
|
|
56
|
+
};
|
|
57
|
+
const idempotencyKey = createKey();
|
|
58
|
+
if (!idempotencyKey.trim()) throw new Error("A stable message admission key is required");
|
|
59
|
+
return Object.freeze({ submission: Object.freeze(submission), idempotencyKey });
|
|
60
|
+
},
|
|
61
|
+
send(attempt) {
|
|
62
|
+
return postChatMessage(transport, attempt.submission, attempt.idempotencyKey);
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
}
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import type { EmbeddedChatAppDependencies } from "chatticus/chat-server";
|
|
3
|
+
import type { NextRequest } from "next/server";
|
|
4
|
+
import {
|
|
5
|
+
ProspectusAccountsError,
|
|
6
|
+
authorizeSignedInPersonForAccount,
|
|
7
|
+
} from "./prospectus-site-identity";
|
|
8
|
+
import {
|
|
9
|
+
resolveProspectusSignInRuntime,
|
|
10
|
+
signedInProspectusSession,
|
|
11
|
+
type ProspectusSignInRuntime,
|
|
12
|
+
} from "./prospectus-session";
|
|
13
|
+
import { prospectusSiteProduct, publishingAccountsForSite } from "./prospectus-publication-access";
|
|
14
|
+
import { publicSiteOrigin } from "./site-origin-value";
|
|
15
|
+
|
|
16
|
+
const NO_STORE_HEADERS = { "cache-control": "no-store" };
|
|
17
|
+
|
|
18
|
+
export interface PapyrusChatScope {
|
|
19
|
+
siteProduct: string;
|
|
20
|
+
accountId: string;
|
|
21
|
+
personId: string;
|
|
22
|
+
tenantId: string;
|
|
23
|
+
editorId: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface PapyrusHostedChatOptions {
|
|
27
|
+
resolveRuntime?: (siteOrigin: string) => Promise<ProspectusSignInRuntime | null>;
|
|
28
|
+
resolvePrivateDependencies?: () => Promise<EmbeddedChatAppDependencies | null>;
|
|
29
|
+
privateChatEnabled?: boolean;
|
|
30
|
+
siteProduct?: string | null;
|
|
31
|
+
environment?: Record<string, string | undefined>;
|
|
32
|
+
nowInSeconds?: () => number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function siteAndAccountKey(siteProduct: string, accountId: string): string {
|
|
36
|
+
return createHash("sha256").update(JSON.stringify([siteProduct, accountId])).digest("hex");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function derivePapyrusChatScope(siteProduct: string, accountId: string, personId: string): PapyrusChatScope {
|
|
40
|
+
const tenantHash = siteAndAccountKey(siteProduct, accountId);
|
|
41
|
+
const editorHash = createHash("sha256").update(JSON.stringify([siteProduct, accountId, personId])).digest("hex");
|
|
42
|
+
return {
|
|
43
|
+
siteProduct,
|
|
44
|
+
personId,
|
|
45
|
+
accountId,
|
|
46
|
+
tenantId: `papyrus-${tenantHash.slice(0, 40)}`,
|
|
47
|
+
editorId: `papyrus-editor-${editorHash.slice(0, 40)}`,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
type ResolvedPapyrusPrincipal = {
|
|
52
|
+
kind: "user";
|
|
53
|
+
tenantId: string;
|
|
54
|
+
userId: string;
|
|
55
|
+
workerId: null;
|
|
56
|
+
organizationStatus: "enabled";
|
|
57
|
+
role: "member";
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
async function principalForRequest(
|
|
61
|
+
request: NextRequest,
|
|
62
|
+
operation: "console:read" | "console:write",
|
|
63
|
+
options: PapyrusHostedChatOptions,
|
|
64
|
+
): Promise<{ principal: ResolvedPapyrusPrincipal | null; status: number }> {
|
|
65
|
+
const environment = options.environment ?? process.env;
|
|
66
|
+
const siteProduct = options.siteProduct !== undefined ? options.siteProduct : prospectusSiteProduct(environment);
|
|
67
|
+
if (!siteProduct) return { principal: null, status: 503 };
|
|
68
|
+
|
|
69
|
+
let runtime: ProspectusSignInRuntime | null;
|
|
70
|
+
try {
|
|
71
|
+
runtime = await (options.resolveRuntime ?? resolveProspectusSignInRuntime)(publicSiteOrigin(request, environment));
|
|
72
|
+
} catch {
|
|
73
|
+
return { principal: null, status: 503 };
|
|
74
|
+
}
|
|
75
|
+
if (!runtime) return { principal: null, status: 503 };
|
|
76
|
+
|
|
77
|
+
const nowInSeconds = options.nowInSeconds?.() ?? Math.floor(Date.now() / 1000);
|
|
78
|
+
const session = signedInProspectusSession(request, runtime.sealingSecret, nowInSeconds);
|
|
79
|
+
if (!session || !session.selectedAccountId || !Number.isFinite(session.accessTokenExpiresAt) || session.accessTokenExpiresAt <= nowInSeconds) {
|
|
80
|
+
return { principal: null, status: 401 };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
try {
|
|
84
|
+
const directory = await runtime.accounts.accountDirectory(session.accessToken);
|
|
85
|
+
if (directory.person?.id !== session.personId) return { principal: null, status: 401 };
|
|
86
|
+
if (!publishingAccountsForSite(directory.accounts, siteProduct).some((account) => account.accountId === session.selectedAccountId)) {
|
|
87
|
+
return { principal: null, status: 403 };
|
|
88
|
+
}
|
|
89
|
+
const authorization = await authorizeSignedInPersonForAccount(
|
|
90
|
+
runtime.accounts,
|
|
91
|
+
session.accessToken,
|
|
92
|
+
session.selectedAccountId,
|
|
93
|
+
operation,
|
|
94
|
+
);
|
|
95
|
+
if (authorization.personId !== session.personId || authorization.accountId !== session.selectedAccountId || authorization.operation !== operation) {
|
|
96
|
+
return { principal: null, status: 403 };
|
|
97
|
+
}
|
|
98
|
+
const scope = derivePapyrusChatScope(siteProduct, session.selectedAccountId, session.personId);
|
|
99
|
+
return {
|
|
100
|
+
principal: {
|
|
101
|
+
kind: "user",
|
|
102
|
+
tenantId: scope.tenantId,
|
|
103
|
+
userId: scope.editorId,
|
|
104
|
+
workerId: null,
|
|
105
|
+
organizationStatus: "enabled",
|
|
106
|
+
role: "member",
|
|
107
|
+
},
|
|
108
|
+
status: 200,
|
|
109
|
+
};
|
|
110
|
+
} catch (error) {
|
|
111
|
+
if (error instanceof ProspectusAccountsError && (error.status === 401 || error.status === 403)) {
|
|
112
|
+
return { principal: null, status: error.status };
|
|
113
|
+
}
|
|
114
|
+
return { principal: null, status: 503 };
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function jsonFailure(status: number, error: string): Response {
|
|
119
|
+
return new Response(JSON.stringify({ error }), {
|
|
120
|
+
status,
|
|
121
|
+
headers: { ...NO_STORE_HEADERS, "content-type": "application/json" },
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function supportedPath(path: readonly string[]): boolean {
|
|
126
|
+
return (path.length === 1 && path[0] === "private-channel") ||
|
|
127
|
+
(path.length === 3 && path[0] === "channels" && path[2] === "messages") ||
|
|
128
|
+
(path.length === 3 && path[0] === "turns" && (path[2] === "events" || path[2] === "stream"));
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function chatRequestPath(path: readonly string[]): string {
|
|
132
|
+
return `/${path.map((part) => encodeURIComponent(part)).join("/")}`;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export async function handlePapyrusHostedChatRequest(
|
|
136
|
+
request: NextRequest,
|
|
137
|
+
path: readonly string[],
|
|
138
|
+
options: PapyrusHostedChatOptions = {},
|
|
139
|
+
): Promise<Response> {
|
|
140
|
+
if (!supportedPath(path) || path.some((part) => !part || part === "." || part === ".." || part.includes("/"))) {
|
|
141
|
+
return jsonFailure(404, "chat_route_not_found");
|
|
142
|
+
}
|
|
143
|
+
if (request.method !== "GET" && request.method !== "POST") return jsonFailure(405, "method_not_allowed");
|
|
144
|
+
const isMessageRoute = path[0] === "channels";
|
|
145
|
+
const isPrivateChannelRoute = path[0] === "private-channel";
|
|
146
|
+
if (isPrivateChannelRoute && request.method !== "POST") return jsonFailure(405, "method_not_allowed");
|
|
147
|
+
if ((isMessageRoute && path[2] === "messages" && request.method === "POST") || (!isMessageRoute && !isPrivateChannelRoute && request.method !== "GET")) {
|
|
148
|
+
if (!isMessageRoute && !isPrivateChannelRoute) return jsonFailure(405, "method_not_allowed");
|
|
149
|
+
}
|
|
150
|
+
if (isMessageRoute && request.method === "GET" && path[2] !== "messages") return jsonFailure(405, "method_not_allowed");
|
|
151
|
+
if ((isMessageRoute || isPrivateChannelRoute) && request.method === "POST") {
|
|
152
|
+
let origin: string;
|
|
153
|
+
try {
|
|
154
|
+
origin = publicSiteOrigin(request, options.environment ?? process.env);
|
|
155
|
+
} catch {
|
|
156
|
+
return jsonFailure(503, "chat_site_origin_unavailable");
|
|
157
|
+
}
|
|
158
|
+
if (request.headers.get("origin") !== origin) return jsonFailure(403, "same_origin_required");
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const environment = options.environment ?? process.env;
|
|
162
|
+
const privateChatEnabled = options.privateChatEnabled ?? environment.PAPYRUS_PRIVATE_CHAT_ENABLED === "true";
|
|
163
|
+
if (!privateChatEnabled) return jsonFailure(404, "private_chat_disabled");
|
|
164
|
+
|
|
165
|
+
let dependencies: EmbeddedChatAppDependencies | null;
|
|
166
|
+
try {
|
|
167
|
+
if (options.resolvePrivateDependencies) {
|
|
168
|
+
dependencies = await options.resolvePrivateDependencies();
|
|
169
|
+
} else {
|
|
170
|
+
const chatRuntime = await import("chatticus/chat-runtime");
|
|
171
|
+
dependencies = chatRuntime.createSiteHostedChatDependencies(
|
|
172
|
+
chatRuntime.siteHostedChatResourcesFromEnvironment(environment),
|
|
173
|
+
chatRuntime.siteHostedChatClients(),
|
|
174
|
+
) as EmbeddedChatAppDependencies;
|
|
175
|
+
}
|
|
176
|
+
} catch {
|
|
177
|
+
dependencies = null;
|
|
178
|
+
}
|
|
179
|
+
if (!dependencies) return jsonFailure(503, "private_chat_storage_unavailable");
|
|
180
|
+
|
|
181
|
+
const authorization = await principalForRequest(request, request.method === "POST" ? "console:write" : "console:read", options);
|
|
182
|
+
if (!authorization.principal) return jsonFailure(authorization.status, authorization.status === 401 ? "sign_in_required" : authorization.status === 403 ? "chat_account_access_denied" : "chat_runtime_unavailable");
|
|
183
|
+
|
|
184
|
+
const headers = new Headers({ accept: request.headers.get("accept") ?? "application/json" });
|
|
185
|
+
let body: string | undefined;
|
|
186
|
+
if (request.method === "POST" && isMessageRoute) {
|
|
187
|
+
const idempotencyKey = request.headers.get("idempotency-key")?.trim();
|
|
188
|
+
const input = await request.json().catch(() => null) as Record<string, unknown> | null;
|
|
189
|
+
const privateBotId = environment.PAPYRUS_CHAT_PRIVATE_BOT_ID?.trim();
|
|
190
|
+
if (!idempotencyKey || typeof input?.body !== "string" || !input.body.trim()) {
|
|
191
|
+
return jsonFailure(422, "message_body_and_idempotency_key_required");
|
|
192
|
+
}
|
|
193
|
+
if (!privateBotId) return jsonFailure(503, "private_chat_storage_unavailable");
|
|
194
|
+
headers.set("content-type", "application/json");
|
|
195
|
+
headers.set("idempotency-key", idempotencyKey);
|
|
196
|
+
body = JSON.stringify({
|
|
197
|
+
author_kind: "human",
|
|
198
|
+
author_id: authorization.principal.userId,
|
|
199
|
+
body: input.body,
|
|
200
|
+
addressed_to_bot_id: privateBotId,
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const target = new URL(chatRequestPath(path) + new URL(request.url).search, request.url);
|
|
205
|
+
const forwarded = new Request(target, { method: request.method, headers, ...(body === undefined ? {} : { body }) });
|
|
206
|
+
let chatServer: typeof import("chatticus/chat-server");
|
|
207
|
+
try {
|
|
208
|
+
chatServer = await import("chatticus/chat-server");
|
|
209
|
+
} catch {
|
|
210
|
+
return jsonFailure(503, "private_chat_storage_unavailable");
|
|
211
|
+
}
|
|
212
|
+
const app = chatServer.createEmbeddedChatApp({
|
|
213
|
+
...dependencies,
|
|
214
|
+
resolvePrincipal: async () => authorization.principal,
|
|
215
|
+
});
|
|
216
|
+
return app.fetch(forwarded);
|
|
217
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
export type PapyrusPrivateChatChannel = {
|
|
2
|
+
channel_id: string;
|
|
3
|
+
participants: Array<{ kind: string; actor_id: string }>;
|
|
4
|
+
};
|
|
5
|
+
|
|
6
|
+
export type PapyrusPrivateChatMessage = {
|
|
7
|
+
message_id: string;
|
|
8
|
+
channel_id: string;
|
|
9
|
+
seq: number;
|
|
10
|
+
author_kind: "human" | "bot";
|
|
11
|
+
body: string;
|
|
12
|
+
addressed_to_bot_id: string | null;
|
|
13
|
+
created_at: string;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export type PapyrusPrivateChatSendResult = {
|
|
17
|
+
message: PapyrusPrivateChatMessage;
|
|
18
|
+
turn_id: string | null;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export type PapyrusPrivateChatFetch = typeof globalThis.fetch;
|
|
22
|
+
|
|
23
|
+
const CHAT_API = "/api/newsroom/chat";
|
|
24
|
+
|
|
25
|
+
async function requestJson<T>(path: string, init: RequestInit, fetcher: PapyrusPrivateChatFetch): Promise<T> {
|
|
26
|
+
const response = await fetcher(`${CHAT_API}/${path}`, {
|
|
27
|
+
...init,
|
|
28
|
+
credentials: "same-origin",
|
|
29
|
+
cache: "no-store",
|
|
30
|
+
});
|
|
31
|
+
const payload = await response.json().catch(() => null) as Record<string, unknown> | null;
|
|
32
|
+
if (!response.ok) {
|
|
33
|
+
const error = typeof payload?.error === "string" ? payload.error
|
|
34
|
+
: typeof payload?.detail === "string" ? payload.detail
|
|
35
|
+
: `Private chat request failed (${response.status}).`;
|
|
36
|
+
throw new Error(error);
|
|
37
|
+
}
|
|
38
|
+
if (!payload || typeof payload !== "object") throw new Error("Private chat returned an invalid response.");
|
|
39
|
+
return payload as T;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Open the current editor's server-derived private channel using the signed same-origin session cookie. */
|
|
43
|
+
export async function openPapyrusPrivateChat(fetcher: PapyrusPrivateChatFetch = globalThis.fetch): Promise<PapyrusPrivateChatChannel> {
|
|
44
|
+
const result = await requestJson<PapyrusPrivateChatChannel>("private-channel", { method: "POST" }, fetcher);
|
|
45
|
+
if (!result.channel_id?.trim() || !Array.isArray(result.participants)) {
|
|
46
|
+
throw new Error("Private chat returned an invalid channel.");
|
|
47
|
+
}
|
|
48
|
+
return result;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Read only the private channel returned by the host's authenticated open request. */
|
|
52
|
+
export async function listPapyrusPrivateChatMessages(
|
|
53
|
+
channelId: string,
|
|
54
|
+
fetcher: PapyrusPrivateChatFetch = globalThis.fetch,
|
|
55
|
+
): Promise<PapyrusPrivateChatMessage[]> {
|
|
56
|
+
if (!channelId.trim()) throw new Error("A private chat channel is required.");
|
|
57
|
+
const result = await requestJson<{ messages?: unknown }>(`channels/${encodeURIComponent(channelId)}/messages`, { method: "GET" }, fetcher);
|
|
58
|
+
if (!Array.isArray(result.messages)) throw new Error("Private chat returned an invalid message list.");
|
|
59
|
+
return result.messages as PapyrusPrivateChatMessage[];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** One explicit send. The browser sends neither its tenant nor author or bot identity. */
|
|
63
|
+
export async function sendPapyrusPrivateChatMessage(
|
|
64
|
+
channelId: string,
|
|
65
|
+
body: string,
|
|
66
|
+
idempotencyKey: string,
|
|
67
|
+
fetcher: PapyrusPrivateChatFetch = globalThis.fetch,
|
|
68
|
+
): Promise<PapyrusPrivateChatSendResult> {
|
|
69
|
+
if (!channelId.trim() || !body.trim() || !idempotencyKey.trim()) {
|
|
70
|
+
throw new Error("A channel, message and idempotency key are required.");
|
|
71
|
+
}
|
|
72
|
+
const result = await requestJson<PapyrusPrivateChatSendResult>(`channels/${encodeURIComponent(channelId)}/messages`, {
|
|
73
|
+
method: "POST",
|
|
74
|
+
headers: { "content-type": "application/json", "idempotency-key": idempotencyKey },
|
|
75
|
+
body: JSON.stringify({ body }),
|
|
76
|
+
}, fetcher);
|
|
77
|
+
if (!result.message?.message_id || result.message.channel_id !== channelId) {
|
|
78
|
+
throw new Error("Private chat returned an invalid sent message.");
|
|
79
|
+
}
|
|
80
|
+
return result;
|
|
81
|
+
}
|