@anthusai/papyrus 2.0.0-next.6 → 2.0.0-next.61

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (164) hide show
  1. package/amplify/data/resource.js +23 -3
  2. package/amplify/data/resource.ts +25 -3
  3. package/amplify/data/schema.js +2028 -1979
  4. package/amplify/data/schema.ts +72 -14
  5. package/amplify/functions/assignment-action/data-access.js +90 -0
  6. package/amplify/functions/assignment-action/data-access.ts +89 -0
  7. package/amplify/functions/assignment-action/handler.ts +1 -1
  8. package/amplify/functions/content-actions/handler.py +61 -2
  9. package/amplify/functions/content-actions/requirements.txt +1 -0
  10. package/amplify/functions/content-actions/resource.js +1 -1
  11. package/amplify/functions/content-actions/resource.ts +1 -1
  12. package/amplify/functions/editorial-checks/handler.py +14 -0
  13. package/amplify/functions/editorial-checks/requirements.txt +1 -0
  14. package/amplify/{plugins/editorial-checks/function → functions/editorial-checks}/resource.js +7 -7
  15. package/amplify/{plugins/editorial-checks/function → functions/editorial-checks}/resource.ts +7 -7
  16. package/amplify/functions/editorial-checks/schema.js +121 -0
  17. package/amplify/{plugins → functions}/editorial-checks/schema.ts +33 -6
  18. package/amplify/functions/editorial-checks/wire.js +53 -0
  19. package/amplify/functions/editorial-checks/wire.ts +61 -0
  20. package/amplify/functions/monitoring-view/handler.py +11 -0
  21. package/amplify/functions/monitoring-view/resource.js +24 -0
  22. package/amplify/functions/monitoring-view/resource.ts +34 -0
  23. package/amplify/functions/monitoring-view/schema.js +16 -0
  24. package/amplify/functions/monitoring-view/schema.ts +18 -0
  25. package/amplify/functions/monitoring-view/wire.js +37 -0
  26. package/amplify/functions/monitoring-view/wire.ts +47 -0
  27. package/amplify/functions/public-forms/core.js +247 -0
  28. package/amplify/functions/public-forms/core.ts +310 -0
  29. package/amplify/functions/public-forms/handler.ts +41 -0
  30. package/amplify/functions/public-forms/origins.js +35 -0
  31. package/amplify/functions/public-forms/origins.ts +43 -0
  32. package/amplify/functions/public-forms/resource.js +9 -0
  33. package/amplify/functions/public-forms/resource.ts +10 -0
  34. package/amplify/functions/public-forms/wire.js +25 -0
  35. package/amplify/functions/public-forms/wire.ts +32 -0
  36. package/amplify/functions/shared/editorial-context.ts +63 -4
  37. package/amplify/functions/shared/python-bundle.js +20 -14
  38. package/amplify/functions/shared/python-bundle.ts +25 -18
  39. package/amplify/plugins/newsletter/config.js +245 -0
  40. package/amplify/plugins/newsletter/config.ts +305 -0
  41. package/amplify/plugins/newsletter/function/handler.py +6 -0
  42. package/amplify/plugins/newsletter/function/requirements.txt +5 -0
  43. package/amplify/plugins/newsletter/ops/handler.py +12 -0
  44. package/amplify/plugins/newsletter/ops/resource.js +27 -0
  45. package/amplify/plugins/newsletter/ops/resource.ts +37 -0
  46. package/amplify/plugins/newsletter/papyrus.plugin.js +263 -0
  47. package/amplify/plugins/newsletter/papyrus.plugin.ts +311 -0
  48. package/amplify/plugins/newsletter/schema.js +56 -0
  49. package/amplify/plugins/newsletter/schema.ts +61 -0
  50. package/amplify/plugins/newsletter/secure-string-parameter.js +107 -0
  51. package/amplify/plugins/newsletter/secure-string-parameter.ts +120 -0
  52. package/amplify/plugins/newsletter/sending.js +140 -0
  53. package/amplify/plugins/newsletter/sending.ts +186 -0
  54. package/amplify/plugins/reader-steering/config.js +173 -0
  55. package/amplify/plugins/reader-steering/config.ts +268 -0
  56. package/amplify/plugins/reader-steering/function/ai_moderation.py +246 -0
  57. package/amplify/plugins/reader-steering/function/handler.py +1363 -0
  58. package/amplify/plugins/reader-steering/function/moderation-prompt.md +27 -0
  59. package/amplify/plugins/reader-steering/function/moderation.py +316 -0
  60. package/amplify/plugins/reader-steering/function/resource.js +27 -0
  61. package/amplify/plugins/reader-steering/function/resource.ts +35 -0
  62. package/amplify/plugins/reader-steering/papyrus.plugin.js +176 -0
  63. package/amplify/plugins/reader-steering/papyrus.plugin.ts +202 -0
  64. package/amplify/plugins/reader-steering/schema.js +140 -0
  65. package/amplify/plugins/reader-steering/schema.ts +142 -0
  66. package/amplify/plugins/registry.js +4 -2
  67. package/amplify/plugins/registry.ts +4 -2
  68. package/amplify/plugins/types.ts +5 -0
  69. package/amplify/plugins/wire.js +1 -0
  70. package/amplify/plugins/wire.ts +1 -0
  71. package/amplify/publication/projection-lease.js +30 -0
  72. package/amplify/publication/projection-lease.ts +39 -0
  73. package/amplify/publication/redirect-and-index.js +105 -0
  74. package/amplify/publication/redirect-rules.js +73 -0
  75. package/amplify/publication/redirect-rules.ts +93 -0
  76. package/amplify/publication/s3-projection.js +7 -1
  77. package/amplify/publication/s3-projection.ts +9 -1
  78. package/amplify/site-backend-config.ts +17 -0
  79. package/amplify/site-backend.js +38 -1
  80. package/amplify/site-backend.ts +46 -1
  81. package/app/api/auth/prospectus/sign-in/route.ts +4 -0
  82. package/app/api/newsletter/confirm/route.ts +13 -0
  83. package/app/api/newsletter/preferences/route.ts +13 -0
  84. package/app/api/newsletter/state/route.ts +9 -0
  85. package/app/api/newsletter/subscribe/route.ts +9 -0
  86. package/app/api/newsletter/unsubscribe/route.ts +13 -0
  87. package/app/api/newsroom/curation/context/route.ts +9 -0
  88. package/app/api/newsroom/curation/priming/route.ts +9 -0
  89. package/app/api/newsroom/curation/publication/route.ts +9 -0
  90. package/app/api/reader/comment/delete/route.ts +9 -0
  91. package/app/api/reader/comment/edit/route.ts +9 -0
  92. package/app/api/reader/comment/route.ts +9 -0
  93. package/app/api/reader/handle/route.ts +9 -0
  94. package/app/api/reader/me/route.ts +9 -0
  95. package/app/api/reader/rating/route.ts +9 -0
  96. package/app/api/reader/report/route.ts +9 -0
  97. package/app/api/reader/retired/route.ts +9 -0
  98. package/app/api/reader/submit/route.ts +9 -0
  99. package/app/api/reader/vote/route.ts +9 -0
  100. package/bin/papyrus-infra.mjs +20 -1
  101. package/components/editorial-checks-admin-panel.tsx +36 -11
  102. package/components/moderation-panel.tsx +248 -0
  103. package/components/monitoring-overview-view.tsx +241 -0
  104. package/components/monitoring-panel.tsx +40 -0
  105. package/components/newsletter-panel.tsx +474 -0
  106. package/components/newsletter-sections.tsx +163 -0
  107. package/components/newsroom-article-editor.tsx +2 -4
  108. package/components/newsroom-curation-bootstrap.tsx +45 -0
  109. package/components/newsroom-organizer.css +31 -0
  110. package/components/newsroom-references-view.tsx +14 -14
  111. package/components/publication-cyclotron-panel.tsx +15 -10
  112. package/components/reference-add-entry.tsx +75 -0
  113. package/components/reference-curation-controls.tsx +31 -22
  114. package/components/reference-cyclotron-priming.tsx +58 -19
  115. package/components/reference-cyclotron-suggestion.tsx +1 -1
  116. package/components/reference-priming.tsx +14 -10
  117. package/components/reference-section-suggestion.tsx +1 -1
  118. package/components/topic-steering-workspace.tsx +64 -20
  119. package/components/use-editorial-checks.ts +12 -10
  120. package/infra/amplify-app-shell.js +90 -16
  121. package/infra/amplify-app-shell.ts +102 -18
  122. package/infra/index.js +1 -0
  123. package/infra/secure-string-parameter-handler.js +41 -0
  124. package/infra/secure-string-parameter-handler.ts +41 -0
  125. package/infra/secure-string-parameter.js +79 -0
  126. package/infra/secure-string-parameter.ts +91 -0
  127. package/infra/site-config.js +86 -6
  128. package/infra/site-config.ts +129 -6
  129. package/lib/category-repository.ts +11 -9
  130. package/lib/curation-routes.ts +345 -0
  131. package/lib/editorial-checks-backend.ts +1 -1
  132. package/lib/editorial-checks-client.ts +19 -1
  133. package/lib/editorial-checks-demo.ts +2 -2
  134. package/lib/editorial-checks-settings.ts +3 -0
  135. package/lib/editorial-checks.ts +8 -11
  136. package/lib/editorial-preview.ts +5 -5
  137. package/lib/monitoring-client.ts +29 -0
  138. package/lib/monitoring-overview-fixture.ts +51 -0
  139. package/lib/monitoring-overview.ts +145 -0
  140. package/lib/newsletter-api.ts +418 -0
  141. package/lib/newsletter-client.ts +46 -0
  142. package/lib/newsletter-fixture.ts +184 -0
  143. package/lib/newsletter-model.ts +202 -0
  144. package/lib/prospectus-session.ts +15 -14
  145. package/lib/prospectus-sign-in-routes.ts +84 -19
  146. package/lib/prospectus-site-identity.ts +24 -1
  147. package/lib/reader-api.ts +145 -0
  148. package/lib/reader-moderation.ts +220 -0
  149. package/lib/reader-retirement-route.ts +89 -0
  150. package/lib/reader-subject.md +29 -0
  151. package/lib/reader-subject.ts +53 -0
  152. package/lib/relevance-status.ts +2 -2
  153. package/lib/semantic-graph.ts +2 -0
  154. package/lib/site-brand-tenant.ts +11 -6
  155. package/lib/site-brand.ts +6 -3
  156. package/lib/site-origin.ts +67 -0
  157. package/middleware.ts +14 -3
  158. package/package.json +41 -3
  159. package/routes.manifest.json +204 -2
  160. package/amplify/plugins/editorial-checks/function/handler.py +0 -11
  161. package/amplify/plugins/editorial-checks/function/requirements.txt +0 -1
  162. package/amplify/plugins/editorial-checks/papyrus.plugin.js +0 -50
  163. package/amplify/plugins/editorial-checks/papyrus.plugin.ts +0 -63
  164. package/amplify/plugins/editorial-checks/schema.js +0 -94
@@ -0,0 +1,184 @@
1
+ /**
2
+ * A pretend Newsletter service for `?demo=1` and the tests: the same operations and answers as the functions, held in memory.
3
+ * Placeholders only (reader addresses are @example.com); nothing here is a real subscriber, issue or postal address.
4
+ */
5
+ import type { NewsletterApi, NewsletterOperation } from "./newsletter-client";
6
+ import type {
7
+ NewsletterDelivery,
8
+ NewsletterEvent,
9
+ NewsletterExportRow,
10
+ NewsletterIssue,
11
+ NewsletterList,
12
+ NewsletterSubscriber,
13
+ } from "./newsletter-model";
14
+
15
+ export const FIXTURE_TIME_ZONE = "America/New_York";
16
+
17
+ const LISTS: NewsletterList[] = [
18
+ { id: "weekly", name: "Weekly roundup", kind: "roundup", confirmed: 412, pending: 17, unsubscribed: 38, timezone: FIXTURE_TIME_ZONE },
19
+ { id: "alerts-security", name: "Security alerts", kind: "alert", confirmed: 96, pending: 4, unsubscribed: 9 },
20
+ ];
21
+
22
+ export function fixtureIssues(): NewsletterIssue[] {
23
+ const counts = { queued: 0, sent: 0, failed: 0, suppressed: 0, skipped: 0, delivered: 0, bounced: 0, complained: 0, softBounced: 0 };
24
+ return [
25
+ {
26
+ issueId: "a1", listId: "weekly", listName: "Weekly roundup", kind: "roundup", itemId: "item-weekly-0122", status: "draft",
27
+ subject: "Weekly roundup: Jan 15 to Jan 22, 2027", counts,
28
+ },
29
+ {
30
+ issueId: "a2", listId: "alerts-security", listName: "Security alerts", kind: "alert", itemId: "item-alert-1", status: "awaiting_approval",
31
+ subject: "A critical patch is out for the build tool", counts,
32
+ },
33
+ {
34
+ issueId: "a3", listId: "weekly", listName: "Weekly roundup", kind: "roundup", itemId: "item-weekly-0115", status: "scheduled",
35
+ subject: "Weekly roundup: Jan 8 to Jan 15, 2027", sendAt: "2027-01-15T14:00:00Z", approvedBy: "admin-user", approvedAt: "2027-01-14T18:30:00Z",
36
+ counts, editedSinceApproval: true,
37
+ },
38
+ {
39
+ issueId: "a4", listId: "weekly", listName: "Weekly roundup", kind: "roundup", itemId: "item-weekly-0108", status: "blocked",
40
+ subject: "Weekly roundup: Jan 1 to Jan 8, 2027", sendAt: "2027-01-08T14:00:00Z", note: "postal address missing", counts,
41
+ },
42
+ {
43
+ issueId: "a5", listId: "weekly", listName: "Weekly roundup", kind: "roundup", itemId: "item-weekly-0101", status: "sending",
44
+ subject: "Weekly roundup: Dec 25 to Jan 1, 2027", sendAt: "2027-01-01T14:00:00Z", approvedBy: "admin-user",
45
+ counts: { ...counts, queued: 429, sent: 240, delivered: 188 },
46
+ },
47
+ {
48
+ issueId: "a6", listId: "weekly", listName: "Weekly roundup", kind: "roundup", itemId: "item-weekly-1225", status: "sent",
49
+ subject: "Weekly roundup: Dec 18 to Dec 25, 2026", sendAt: "2026-12-25T14:00:00Z", sentAt: "2026-12-25T14:09:40Z", approvedBy: "admin-user",
50
+ archiveUrl: "https://news.example.com/roundup/2026-12-25.html",
51
+ counts: { ...counts, queued: 421, sent: 418, delivered: 409, bounced: 3, complained: 1, failed: 1, suppressed: 2, softBounced: 2 },
52
+ },
53
+ {
54
+ issueId: "a7", listId: "alerts-security", listName: "Security alerts", kind: "alert", itemId: "item-alert-0", status: "quarantined",
55
+ subject: "An alert with a status this panel does not know", counts,
56
+ },
57
+ ];
58
+ }
59
+
60
+ function deliveriesFor(issueId: string, full: boolean): NewsletterDelivery[] {
61
+ const address = (index: number) => (full ? `reader${index}@example.com` : `r***@example.com`);
62
+ const rows: NewsletterDelivery[] = [
63
+ { address: address(1), status: "delivered", at: "2026-12-25T14:00:04Z", eventAt: "2026-12-25T14:00:09Z" },
64
+ { address: address(2), status: "delivered", at: "2026-12-25T14:00:05Z", eventAt: "2026-12-25T14:00:12Z" },
65
+ { address: address(3), status: "bounced", at: "2026-12-25T14:00:06Z", eventAt: "2026-12-25T14:00:20Z" },
66
+ { address: address(4), status: "complained", at: "2026-12-25T14:00:07Z", eventAt: "2026-12-25T14:02:31Z" },
67
+ { address: address(5), status: "failed", at: "2026-12-25T14:00:08Z", error: "MessageRejected" },
68
+ { address: "(erased)", status: "sent", at: "2026-12-25T14:00:09Z" },
69
+ ];
70
+ return issueId === "a6" || issueId === "a5" ? rows : [];
71
+ }
72
+
73
+ const SUBSCRIBERS: NewsletterSubscriber[] = [
74
+ { email: "reader1@example.com", status: "confirmed", lists: [{ id: "weekly", status: "subscribed" }, { id: "alerts-security", status: "subscribed" }], createdAt: "2026-11-02T10:11:00Z", confirmedAt: "2026-11-02T10:13:00Z" },
75
+ { email: "reader2@example.com", status: "confirmed", lists: [{ id: "weekly", status: "subscribed" }], createdAt: "2026-11-09T08:00:00Z", confirmedAt: "2026-11-09T08:02:00Z" },
76
+ { email: "reader3@example.com", status: "pending", lists: [{ id: "alerts-security", status: "pending" }], createdAt: "2026-12-30T21:45:00Z" },
77
+ { email: "=reader4@example.com", status: "unsubscribed", lists: [{ id: "weekly", status: "unsubscribed" }], createdAt: "2026-10-01T09:00:00Z", confirmedAt: "2026-10-01T09:05:00Z" },
78
+ ];
79
+
80
+ const EVENTS: NewsletterEvent[] = [
81
+ { type: "Complaint", at: "2026-12-25T14:02:31Z", issue: "a6", list: "weekly" },
82
+ { type: "Bounce", at: "2026-12-25T14:00:20Z", issue: "a6", list: "weekly", detail: "Permanent" },
83
+ { type: "Delivery", at: "2026-12-25T14:00:12Z", issue: "a6", list: "weekly" },
84
+ { type: "Delivery", at: "2026-12-25T14:00:09Z", issue: "a6", list: "weekly" },
85
+ { type: "Bounce", at: "2026-12-25T13:59:50Z", issue: "a5", list: "weekly", detail: "Transient" },
86
+ ];
87
+
88
+ const SAMPLE_HTML = `<!doctype html><html><body style="margin:0;font-family:Georgia,serif;color:#111"><div style="max-width:560px;margin:0 auto;padding:24px"><h1 style="font-size:24px;margin:0 0 12px">Weekly roundup</h1><p>A short introduction goes here.</p><h2 style="font-size:18px">Security</h2><ul><li><a href="#preview">A critical patch is out</a>: update today.</li></ul><hr><p style="font-size:12px;color:#555">You get this because you subscribed. <a href="#preview">Manage</a> &middot; <a href="#preview">Unsubscribe</a><br>1 Example Street, Example City</p></div></body></html>`;
89
+
90
+ export type FixtureOptions = { postalAddress?: boolean; admin?: boolean };
91
+
92
+ /** One fixture service per mounted panel, so a demo session keeps its changes until reload. */
93
+ export function createFixtureApi(options: FixtureOptions = {}): NewsletterApi {
94
+ const full = options.admin !== false;
95
+ const issues = fixtureIssues();
96
+ const subscribers = [...SUBSCRIBERS];
97
+ const paused = { paused: true, reason: "papyrus-newsletter-example-bounce-rate: bounce rate above 4%", pausedAt: "2026-12-26T09:12:00Z" } as { paused: boolean; reason?: string; pausedAt?: string };
98
+ const findIssue = (input: Record<string, unknown>) => issues.find((issue) => issue.issueId === input.issueId);
99
+ const failure = (error: string, message = "") => ({ ok: false as const, error, message });
100
+
101
+ const handlers: Record<NewsletterOperation, (input: Record<string, unknown>) => object> = {
102
+ newsletterOverview: () => ({
103
+ ok: true,
104
+ lists: LISTS,
105
+ totals: { confirmed: 488, pending: 20, unsubscribed: 45 },
106
+ suppressed: 12,
107
+ paused: { ...paused },
108
+ events: EVENTS,
109
+ }),
110
+ newsletterIssues: (input) => ({
111
+ ok: true,
112
+ issues: issues.filter((issue) => (!input.status || issue.status === input.status) && (!input.listId || issue.listId === input.listId)).map(({ editedSinceApproval: _edited, ...rest }) => rest),
113
+ }),
114
+ newsletterIssue: (input) => {
115
+ const issue = findIssue(input);
116
+ return issue ? { ok: true, issue } : failure("not_found");
117
+ },
118
+ newsletterPreview: (input) => {
119
+ const issue = findIssue(input);
120
+ if (!issue) return failure("not_found");
121
+ return { ok: true, subject: issue.subject, html: SAMPLE_HTML, text: `${issue.subject}\n\nA short introduction goes here.\n\nSecurity\n- A critical patch is out: update today.\n\nYou get this because you subscribed.`, bytes: SAMPLE_HTML.length, postalAddress: options.postalAddress !== false };
122
+ },
123
+ newsletterIssueDeliveries: (input) => (findIssue(input) ? { ok: true, deliveries: deliveriesFor(String(input.issueId), full) } : failure("not_found")),
124
+ newsletterSubscribers: (input) => ({
125
+ ok: true,
126
+ subscribers: subscribers.filter((row) => (!input.status || row.status === input.status) && (!input.listId || row.lists.some((entry) => entry.id === input.listId))),
127
+ }),
128
+ newsletterExport: () => ({
129
+ ok: true,
130
+ rows: subscribers.filter((row) => row.status !== "unsubscribed").map((row): NewsletterExportRow => ({
131
+ email: row.email, status: row.status, lists: row.lists.map((entry) => `${entry.id}:${entry.status}`).join(";"), createdAt: row.createdAt,
132
+ consentRequestedAt: row.createdAt, consentConfirmedAt: row.confirmedAt ?? "", consentFormId: "newsletter",
133
+ consentPageUrl: "https://news.example.com/roundup/", consentWording: "Send me the weekly email.",
134
+ })),
135
+ }),
136
+ newsletterApproveIssue: (input) => {
137
+ const issue = findIssue(input);
138
+ if (!issue) return failure("not_found");
139
+ if (!["draft", "awaiting_approval", "scheduled", "blocked"].includes(issue.status)) return failure("invalid_state");
140
+ issue.status = "scheduled";
141
+ issue.sendAt = String(input.sendAt);
142
+ issue.approvedBy = "admin-user";
143
+ delete issue.note;
144
+ delete issue.editedSinceApproval;
145
+ return { ok: true, issue, adjusted: false, warnings: options.postalAddress === false ? ["postal address missing: the send will be blocked until an operator sets it"] : [] };
146
+ },
147
+ newsletterCancelIssue: (input) => {
148
+ const issue = findIssue(input);
149
+ if (!issue) return failure("not_found");
150
+ if (issue.status !== "scheduled") return failure("invalid_state");
151
+ issue.status = "draft";
152
+ issue.note = "cancelled by admin-user";
153
+ delete issue.sendAt;
154
+ delete issue.editedSinceApproval;
155
+ return { ok: true, issue };
156
+ },
157
+ newsletterStopIssue: (input) => {
158
+ const issue = findIssue(input);
159
+ if (!issue) return failure("not_found");
160
+ if (issue.status !== "sending") return failure("invalid_state");
161
+ issue.status = "stopped";
162
+ issue.note = "stopped by admin-user";
163
+ return { ok: true, issue };
164
+ },
165
+ newsletterTestSend: (input) => (findIssue(input) ? { ok: true } : failure("not_found")),
166
+ newsletterCreateDraft: (input) => (input.listId === "weekly" ? { ok: true, status: "exists", issueId: "a1", itemId: "item-weekly-0122" } : failure("bad_request")),
167
+ newsletterErase: (input) => {
168
+ const at = subscribers.findIndex((row) => row.email === input.email);
169
+ if (at >= 0) subscribers.splice(at, 1);
170
+ return { ok: true, removed: at >= 0 ? 3 : 2 };
171
+ },
172
+ newsletterResume: () => {
173
+ paused.paused = false;
174
+ paused.reason = undefined;
175
+ return { ok: true, paused: false };
176
+ },
177
+ };
178
+
179
+ return (async (operation: NewsletterOperation, input: Record<string, unknown> = {}) => {
180
+ const admin = ["newsletterApproveIssue", "newsletterCancelIssue", "newsletterStopIssue", "newsletterSubscribers", "newsletterExport", "newsletterErase", "newsletterResume"];
181
+ if (options.admin === false && admin.includes(operation)) return { ok: false, error: "forbidden" };
182
+ return handlers[operation](input);
183
+ }) as NewsletterApi;
184
+ }
@@ -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
+ }
@@ -1,5 +1,5 @@
1
1
  import { createCipheriv, createDecipheriv, hkdfSync, randomBytes } from "node:crypto";
2
- import { GetSecretValueCommand, SecretsManagerClient } from "@aws-sdk/client-secrets-manager";
2
+ import { GetParameterCommand, SSMClient } from "@aws-sdk/client-ssm";
3
3
  import { readAmplifyOutputsFile } from "./amplify-outputs-path";
4
4
  import {
5
5
  ProspectusSiteAccounts,
@@ -21,7 +21,7 @@ import {
21
21
  export const PROSPECTUS_PENDING_SIGN_IN_COOKIE = "__Host-papyrus-prospectus-pending";
22
22
  export const PROSPECTUS_SESSION_COOKIE = "__Host-papyrus-prospectus-session";
23
23
  export const PROSPECTUS_ACCOUNTS_ENDPOINT_ENV = "PAPYRUS_PROSPECTUS_ACCOUNTS_ENDPOINT";
24
- export const PROSPECTUS_SESSION_SECRET_ARN_ENV = "PAPYRUS_PROSPECTUS_SESSION_SECRET_ARN";
24
+ export const PROSPECTUS_SESSION_SECRET_PARAMETER_ENV = "PAPYRUS_PROSPECTUS_SESSION_SECRET_PARAMETER";
25
25
  export const PROSPECTUS_SESSION_SECRET_CACHE_MILLISECONDS = 5 * 60 * 1000;
26
26
 
27
27
  const SEALED_COOKIE_FORMAT_VERSION = "v1";
@@ -39,6 +39,7 @@ export interface ProspectusSignedInSession {
39
39
  accessToken: string;
40
40
  accessTokenExpiresAt: number;
41
41
  selectedAccountId?: string;
42
+ siteSubject?: string;
42
43
  }
43
44
 
44
45
  function sealingKeyForCookie(sealingSecret: string, cookieName: string): Buffer {
@@ -101,37 +102,37 @@ export function sameSiteReturnPath(requestedReturnPath: string | null | undefine
101
102
  return `${parsed.pathname}${parsed.search}${parsed.hash}`;
102
103
  }
103
104
 
104
- export type ProspectusSessionSecretReader = (secretArn: string) => Promise<string | null>;
105
+ export type ProspectusSessionSecretReader = (parameterName: string) => Promise<string | null>;
105
106
 
106
- type CachedSealingSecret = { secretArn: string; value: string; expiresAt: number };
107
+ type CachedSealingSecret = { parameterName: string; value: string; expiresAt: number };
107
108
 
108
109
  let cachedSealingSecret: CachedSealingSecret | null = null;
109
- let defaultSecretsManagerClient: SecretsManagerClient | null = null;
110
+ let defaultSsmClient: SSMClient | null = null;
110
111
 
111
112
  export function resetProspectusSessionSecretCache(): void {
112
113
  cachedSealingSecret = null;
113
114
  }
114
115
 
115
- async function readSealingSecretFromSecretsManager(secretArn: string): Promise<string | null> {
116
- defaultSecretsManagerClient ??= new SecretsManagerClient({});
117
- const response = await defaultSecretsManagerClient.send(new GetSecretValueCommand({ SecretId: secretArn }));
118
- return response.SecretString?.trim() || null;
116
+ export async function readSealingSecretFromSsm(parameterName: string, client?: Pick<SSMClient, "send">): Promise<string | null> {
117
+ defaultSsmClient ??= new SSMClient({});
118
+ const response = await (client ?? defaultSsmClient).send(new GetParameterCommand({ Name: parameterName, WithDecryption: true }));
119
+ return response.Parameter?.Value?.trim() || null;
119
120
  }
120
121
 
121
122
  export async function loadProspectusSessionSealingSecret(
122
123
  options: { environment?: Record<string, string | undefined>; readSecret?: ProspectusSessionSecretReader; now?: number } = {},
123
124
  ): Promise<string | null> {
124
125
  const environment = options.environment ?? process.env;
125
- const secretArn = environment[PROSPECTUS_SESSION_SECRET_ARN_ENV]?.trim();
126
- if (!secretArn) return null;
126
+ const parameterName = environment[PROSPECTUS_SESSION_SECRET_PARAMETER_ENV]?.trim();
127
+ if (!parameterName) return null;
127
128
  const now = options.now ?? Date.now();
128
- if (cachedSealingSecret && cachedSealingSecret.secretArn === secretArn && cachedSealingSecret.expiresAt > now) {
129
+ if (cachedSealingSecret && cachedSealingSecret.parameterName === parameterName && cachedSealingSecret.expiresAt > now) {
129
130
  return cachedSealingSecret.value;
130
131
  }
131
132
  try {
132
- const value = (await (options.readSecret ?? readSealingSecretFromSecretsManager)(secretArn))?.trim();
133
+ const value = (await (options.readSecret ?? readSealingSecretFromSsm)(parameterName))?.trim();
133
134
  if (!value || value.length < MINIMUM_SEALING_SECRET_LENGTH) return null;
134
- cachedSealingSecret = { secretArn, value, expiresAt: now + PROSPECTUS_SESSION_SECRET_CACHE_MILLISECONDS };
135
+ cachedSealingSecret = { parameterName, value, expiresAt: now + PROSPECTUS_SESSION_SECRET_CACHE_MILLISECONDS };
135
136
  return value;
136
137
  } catch (error) {
137
138
  console.error(`Prospectus session sealing secret could not be read (${error instanceof Error ? error.name : "error"}).`);