@anthusai/papyrus 2.0.0-next.8 → 2.0.0-next.81

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 (213) hide show
  1. package/amplify/auth/resource.js +16 -1
  2. package/amplify/auth/resource.ts +19 -1
  3. package/amplify/data/function-handler.js +20 -0
  4. package/amplify/data/function-handler.ts +40 -0
  5. package/amplify/data/resource.js +151 -61
  6. package/amplify/data/resource.ts +157 -61
  7. package/amplify/data/schema.js +2096 -1980
  8. package/amplify/data/schema.ts +198 -69
  9. package/amplify/functions/assignment-action/data-access.js +14 -15
  10. package/amplify/functions/assignment-action/data-access.ts +13 -14
  11. package/amplify/functions/assignment-action/handler.ts +1 -1
  12. package/amplify/functions/automation-view/handler.py +17 -0
  13. package/amplify/functions/automation-view/resource.js +24 -0
  14. package/amplify/functions/automation-view/resource.ts +34 -0
  15. package/amplify/functions/automation-view/schema.js +26 -0
  16. package/amplify/functions/automation-view/schema.ts +28 -0
  17. package/amplify/functions/automation-view/wire.js +56 -0
  18. package/amplify/functions/automation-view/wire.ts +76 -0
  19. package/amplify/functions/content-actions/handler.py +61 -2
  20. package/amplify/functions/content-actions/requirements.txt +1 -0
  21. package/amplify/functions/content-actions/resource.js +1 -1
  22. package/amplify/functions/content-actions/resource.ts +1 -1
  23. package/amplify/{plugins/editorial-checks/function → functions/editorial-checks}/handler.py +3 -3
  24. package/amplify/functions/editorial-checks/requirements.txt +1 -0
  25. package/amplify/{plugins/editorial-checks/function → functions/editorial-checks}/resource.js +7 -7
  26. package/amplify/{plugins/editorial-checks/function → functions/editorial-checks}/resource.ts +7 -7
  27. package/amplify/functions/editorial-checks/schema.js +122 -0
  28. package/amplify/{plugins → functions}/editorial-checks/schema.ts +40 -12
  29. package/amplify/functions/editorial-checks/wire.js +53 -0
  30. package/amplify/functions/editorial-checks/wire.ts +61 -0
  31. package/amplify/functions/monitoring-view/handler.py +11 -0
  32. package/amplify/functions/monitoring-view/resource.js +24 -0
  33. package/amplify/functions/monitoring-view/resource.ts +34 -0
  34. package/amplify/functions/monitoring-view/schema.js +17 -0
  35. package/amplify/functions/monitoring-view/schema.ts +19 -0
  36. package/amplify/functions/monitoring-view/wire.js +37 -0
  37. package/amplify/functions/monitoring-view/wire.ts +47 -0
  38. package/amplify/functions/public-forms/core.js +247 -0
  39. package/amplify/functions/public-forms/core.ts +310 -0
  40. package/amplify/functions/public-forms/handler.ts +41 -0
  41. package/amplify/functions/public-forms/origins.js +35 -0
  42. package/amplify/functions/public-forms/origins.ts +43 -0
  43. package/amplify/functions/public-forms/resource.js +9 -0
  44. package/amplify/functions/public-forms/resource.ts +10 -0
  45. package/amplify/functions/public-forms/wire.js +25 -0
  46. package/amplify/functions/public-forms/wire.ts +32 -0
  47. package/amplify/functions/shared/editorial-context.ts +63 -4
  48. package/amplify/functions/shared/python-bundle.js +20 -14
  49. package/amplify/functions/shared/python-bundle.ts +25 -18
  50. package/amplify/plugins/newsletter/config.js +245 -0
  51. package/amplify/plugins/newsletter/config.ts +305 -0
  52. package/amplify/plugins/newsletter/function/handler.py +6 -0
  53. package/amplify/plugins/newsletter/function/requirements.txt +5 -0
  54. package/amplify/plugins/newsletter/ops/handler.py +12 -0
  55. package/amplify/plugins/newsletter/ops/resource.js +27 -0
  56. package/amplify/plugins/newsletter/ops/resource.ts +37 -0
  57. package/amplify/plugins/newsletter/papyrus.plugin.js +263 -0
  58. package/amplify/plugins/newsletter/papyrus.plugin.ts +311 -0
  59. package/amplify/plugins/newsletter/schema.js +57 -0
  60. package/amplify/plugins/newsletter/schema.ts +62 -0
  61. package/amplify/plugins/newsletter/secure-string-parameter.js +107 -0
  62. package/amplify/plugins/newsletter/secure-string-parameter.ts +120 -0
  63. package/amplify/plugins/newsletter/sending.js +140 -0
  64. package/amplify/plugins/newsletter/sending.ts +186 -0
  65. package/amplify/plugins/reader-steering/config.js +182 -0
  66. package/amplify/plugins/reader-steering/config.ts +290 -0
  67. package/amplify/plugins/reader-steering/function/ai_moderation.py +246 -0
  68. package/amplify/plugins/reader-steering/function/handler.py +1554 -0
  69. package/amplify/plugins/reader-steering/function/moderation-prompt.md +27 -0
  70. package/amplify/plugins/reader-steering/function/moderation.py +316 -0
  71. package/amplify/plugins/reader-steering/function/proposals.py +255 -0
  72. package/amplify/plugins/reader-steering/function/resource.js +27 -0
  73. package/amplify/plugins/reader-steering/function/resource.ts +35 -0
  74. package/amplify/plugins/reader-steering/papyrus.plugin.js +186 -0
  75. package/amplify/plugins/reader-steering/papyrus.plugin.ts +212 -0
  76. package/amplify/plugins/reader-steering/schema.js +205 -0
  77. package/amplify/plugins/reader-steering/schema.ts +207 -0
  78. package/amplify/plugins/registry.js +4 -2
  79. package/amplify/plugins/registry.ts +4 -2
  80. package/amplify/plugins/signup/schema.js +2 -1
  81. package/amplify/plugins/signup/schema.ts +2 -1
  82. package/amplify/plugins/types.ts +5 -0
  83. package/amplify/plugins/wire.js +1 -0
  84. package/amplify/plugins/wire.ts +1 -0
  85. package/amplify/publication/projection-lease.js +30 -0
  86. package/amplify/publication/projection-lease.ts +39 -0
  87. package/amplify/publication/redirect-and-index.js +105 -0
  88. package/amplify/publication/redirect-rules.js +73 -0
  89. package/amplify/publication/redirect-rules.ts +93 -0
  90. package/amplify/publication/s3-projection.js +7 -1
  91. package/amplify/publication/s3-projection.ts +9 -1
  92. package/amplify/site-backend-config.ts +18 -1
  93. package/amplify/site-backend-names.js +4 -2
  94. package/amplify/site-backend-names.ts +4 -2
  95. package/amplify/site-backend.js +45 -3
  96. package/amplify/site-backend.ts +51 -3
  97. package/app/api/auth/prospectus/sign-in/route.ts +4 -0
  98. package/app/api/newsletter/confirm/route.ts +13 -0
  99. package/app/api/newsletter/preferences/route.ts +13 -0
  100. package/app/api/newsletter/state/route.ts +9 -0
  101. package/app/api/newsletter/subscribe/route.ts +9 -0
  102. package/app/api/newsletter/unsubscribe/route.ts +13 -0
  103. package/app/api/newsroom/curation/context/route.ts +9 -0
  104. package/app/api/newsroom/curation/priming/route.ts +9 -0
  105. package/app/api/newsroom/curation/publication/route.ts +9 -0
  106. package/app/api/reader/comment/delete/route.ts +9 -0
  107. package/app/api/reader/comment/edit/route.ts +9 -0
  108. package/app/api/reader/comment/route.ts +9 -0
  109. package/app/api/reader/flag/route.ts +9 -0
  110. package/app/api/reader/handle/route.ts +9 -0
  111. package/app/api/reader/me/route.ts +9 -0
  112. package/app/api/reader/rating/route.ts +9 -0
  113. package/app/api/reader/report/route.ts +9 -0
  114. package/app/api/reader/retired/route.ts +9 -0
  115. package/app/api/reader/submit/route.ts +9 -0
  116. package/app/api/reader/vote/route.ts +9 -0
  117. package/bin/papyrus-infra.mjs +20 -1
  118. package/components/automation-panel.tsx +49 -0
  119. package/components/automation-run-now.tsx +97 -0
  120. package/components/automation-view.tsx +192 -0
  121. package/components/editorial-checks-admin-panel.tsx +36 -11
  122. package/components/moderation-panel.tsx +248 -0
  123. package/components/monitoring-overview-view.tsx +241 -0
  124. package/components/monitoring-panel.tsx +40 -0
  125. package/components/news-desk-client-provider.tsx +18 -18
  126. package/components/news-desk-taxonomy-client.ts +28 -8
  127. package/components/newsletter-panel.tsx +474 -0
  128. package/components/newsletter-sections.tsx +163 -0
  129. package/components/newsroom-access-gate.tsx +15 -1
  130. package/components/newsroom-article-editor.tsx +6 -6
  131. package/components/newsroom-articles-view.tsx +6 -6
  132. package/components/newsroom-board-view.tsx +8 -3
  133. package/components/newsroom-curation-bootstrap.tsx +45 -0
  134. package/components/newsroom-ops-overview.tsx +22 -92
  135. package/components/newsroom-ops-shell.tsx +2 -2
  136. package/components/newsroom-organizer.css +33 -0
  137. package/components/newsroom-overview-skeleton.tsx +25 -0
  138. package/components/newsroom-references-view.tsx +192 -49
  139. package/components/notification-center.tsx +273 -0
  140. package/components/publication-cyclotron-panel.tsx +15 -10
  141. package/components/reader-auth-state.ts +1 -1
  142. package/components/reference-add-entry.tsx +75 -0
  143. package/components/reference-curation-controls.tsx +31 -22
  144. package/components/reference-cyclotron-priming.tsx +58 -19
  145. package/components/reference-cyclotron-suggestion.tsx +10 -6
  146. package/components/reference-priming.tsx +14 -10
  147. package/components/reference-relevance-review.tsx +3 -3
  148. package/components/reference-section-suggestion.tsx +1 -1
  149. package/components/topic-steering-workspace.tsx +223 -144
  150. package/components/use-editorial-checks.ts +12 -10
  151. package/infra/amplify-app-shell.js +94 -16
  152. package/infra/amplify-app-shell.ts +106 -18
  153. package/infra/cloud-email-config.js +32 -0
  154. package/infra/cloud-email-config.ts +39 -0
  155. package/infra/cloud-email.js +57 -0
  156. package/infra/cloud-email.test.js +69 -0
  157. package/infra/cloud-email.test.ts +66 -0
  158. package/infra/cloud-email.ts +60 -0
  159. package/infra/index.js +1 -0
  160. package/infra/secure-string-parameter-handler.js +41 -0
  161. package/infra/secure-string-parameter-handler.ts +41 -0
  162. package/infra/secure-string-parameter.js +79 -0
  163. package/infra/secure-string-parameter.ts +91 -0
  164. package/infra/site-config.js +88 -6
  165. package/infra/site-config.ts +134 -6
  166. package/lib/automation-client.ts +54 -0
  167. package/lib/automation-fixture.ts +93 -0
  168. package/lib/automation-overview.ts +232 -0
  169. package/lib/category-repository.ts +11 -9
  170. package/lib/curation-routes.ts +345 -0
  171. package/lib/cyclotron-suggestions.ts +4 -4
  172. package/lib/editorial-checks-backend.ts +1 -1
  173. package/lib/editorial-checks-client.ts +19 -1
  174. package/lib/editorial-checks-demo.ts +2 -2
  175. package/lib/editorial-checks-settings.ts +3 -0
  176. package/lib/editorial-checks.ts +8 -11
  177. package/lib/editorial-preview.ts +5 -5
  178. package/lib/monitoring-client.ts +29 -0
  179. package/lib/monitoring-overview-fixture.ts +51 -0
  180. package/lib/monitoring-overview.ts +145 -0
  181. package/lib/news-desk-session.ts +12 -21
  182. package/lib/newsletter-api.ts +418 -0
  183. package/lib/newsletter-client.ts +46 -0
  184. package/lib/newsletter-fixture.ts +184 -0
  185. package/lib/newsletter-model.ts +202 -0
  186. package/lib/newsroom-organizer-nav.ts +2 -1
  187. package/lib/prospectus-session.ts +15 -14
  188. package/lib/prospectus-sign-in-routes.ts +84 -19
  189. package/lib/prospectus-site-identity.ts +24 -1
  190. package/lib/reader-api.ts +145 -0
  191. package/lib/reader-moderation.ts +220 -0
  192. package/lib/reader-retirement-route.ts +89 -0
  193. package/lib/reader-subject.md +29 -0
  194. package/lib/reader-subject.ts +53 -0
  195. package/lib/reference-review-details.ts +71 -0
  196. package/lib/reference-review-request.ts +14 -0
  197. package/lib/relevance-decisions.ts +42 -10
  198. package/lib/relevance-status.ts +2 -2
  199. package/lib/review-client.ts +99 -0
  200. package/lib/review-fixture.ts +45 -0
  201. package/lib/review-requests.ts +238 -0
  202. package/lib/semantic-graph.ts +2 -0
  203. package/lib/site-brand-tenant.ts +11 -6
  204. package/lib/site-brand.ts +6 -3
  205. package/lib/site-origin.ts +67 -0
  206. package/lib/use-reference-review-data.ts +44 -0
  207. package/middleware.ts +14 -3
  208. package/package.json +56 -3
  209. package/routes.manifest.json +215 -2
  210. package/amplify/plugins/editorial-checks/function/requirements.txt +0 -1
  211. package/amplify/plugins/editorial-checks/papyrus.plugin.js +0 -71
  212. package/amplify/plugins/editorial-checks/papyrus.plugin.ts +0 -87
  213. package/amplify/plugins/editorial-checks/schema.js +0 -94
@@ -0,0 +1,232 @@
1
+ /** Shapes and wording for the newsroom Automation page. src/papyrus_content/automations.py produces them. */
2
+ import { formatUtc } from "./monitoring-overview";
3
+ import { getNewsroomNavHref } from "./newsroom-nav";
4
+
5
+ export { formatUtc };
6
+
7
+ export type AutomationUsage = {
8
+ calls?: number;
9
+ promptTokens?: number;
10
+ completionTokens?: number;
11
+ models?: Record<string, number>;
12
+ complete?: boolean;
13
+ unpricedModels?: string[];
14
+ };
15
+
16
+ export type AutomationRun = {
17
+ origin: "monitor" | "manual";
18
+ requestedBy: string | null;
19
+ reason: string | null;
20
+ sourceId: string | null;
21
+ referenceId: string | null;
22
+ triggerStatus: string | null;
23
+ runId: string | null;
24
+ status: string | null;
25
+ startedAt: string | null;
26
+ finishedAt: string | null;
27
+ durationSeconds: number | null;
28
+ error: string | null;
29
+ runRecord: "found" | "missing" | "unavailable" | "none";
30
+ storyId: string | null;
31
+ storyVersionId: string | null;
32
+ storyTitle: string | null;
33
+ costUsd: number | null;
34
+ costNote: string;
35
+ budgetUsd: number | null;
36
+ overBudget: boolean;
37
+ usage: AutomationUsage | null;
38
+ };
39
+
40
+ /** A ProcedureRun of the same procedure that this automation did not start ("Started outside this automation"). */
41
+ export type AutomationOutsideRun = {
42
+ runId: string | null;
43
+ status: string | null;
44
+ startedAt: string | null;
45
+ finishedAt: string | null;
46
+ durationSeconds: number | null;
47
+ error: string | null;
48
+ requestedBy: string | null;
49
+ storyId: string | null;
50
+ storyVersionId: string | null;
51
+ storyTitle: string | null;
52
+ costUsd: number | null;
53
+ costNote: string;
54
+ overBudget: boolean;
55
+ };
56
+
57
+ export type AutomationRunNow = { available: boolean; reason: string | null; needsInput: boolean; inputNames: string[] };
58
+
59
+ export type AutomationLink = {
60
+ state: "linked" | "input_differs" | "trigger_not_found" | "publication_not_in_router" | "router_not_found" | "not_applicable";
61
+ text: string;
62
+ routerMode?: string | null;
63
+ routerEnabled?: boolean;
64
+ };
65
+
66
+ export type AutomationNotice = "monitoring_not_configured" | "monitoring_unavailable" | "other_publication" | "publication_not_routed" | "not yet supported";
67
+
68
+ export type AutomationRecord = {
69
+ id: string;
70
+ title: string;
71
+ description: string | null;
72
+ enabled: boolean;
73
+ enabledNote: string;
74
+ trigger: { kind: "monitor" | "schedule" | "event" | "manual"; config: Record<string, unknown> };
75
+ supported: boolean;
76
+ supportNote: string | null;
77
+ procedureKey: string;
78
+ inputTemplate: Record<string, string>;
79
+ limits: { timeoutSeconds?: number; maxConcurrent?: number; budgetUsdPerRun?: number };
80
+ limitsNote: string;
81
+ runNow: AutomationRunNow;
82
+ createdAt: string | null;
83
+ updatedAt: string | null;
84
+ link: AutomationLink | null;
85
+ runs: AutomationRun[];
86
+ lastRun: AutomationRun | null;
87
+ skippedAlreadyHadReference: number;
88
+ notice: AutomationNotice | null;
89
+ outsideRuns: AutomationOutsideRun[];
90
+ outsideNotice: "outside_unavailable" | null;
91
+ };
92
+
93
+ export type AutomationOverview = {
94
+ state: "ready" | "empty";
95
+ asOf: string;
96
+ monitoring: "ready" | "not_configured";
97
+ automations: AutomationRecord[];
98
+ };
99
+
100
+ export type AutomationResult = AutomationOverview | { state: "unavailable" } | { error: "forbidden" | "bad_request" };
101
+
102
+ const KIND_WORDS: Record<string, string> = { monitor: "Monitor", schedule: "Schedule", event: "Event", manual: "Manual" };
103
+
104
+ export function triggerText(trigger: AutomationRecord["trigger"]): string {
105
+ const kind = KIND_WORDS[trigger.kind] ?? trigger.kind;
106
+ if (trigger.kind === "monitor") {
107
+ return `${kind} ${String(trigger.config.monitorId ?? "?")}, delivering to ${String(trigger.config.publicationId ?? "?")}`;
108
+ }
109
+ return kind;
110
+ }
111
+
112
+ export const ENABLED_NOTE = "the router starts nothing for a disabled automation";
113
+
114
+ export function enabledText(record: Pick<AutomationRecord, "enabled">): string {
115
+ return record.enabled ? "Enabled" : "Disabled";
116
+ }
117
+
118
+ const STATUS_WORDS: Record<string, string> = {
119
+ completed: "Completed",
120
+ failed: "Failed",
121
+ running: "Running",
122
+ starting: "Starting",
123
+ started: "Started, no result recorded yet",
124
+ startFailed: "Could not start",
125
+ notRunnable: "Not runnable",
126
+ notTriggered: "Not started",
127
+ skipped: "Skipped",
128
+ refused: "Refused",
129
+ deferred: "Deferred, waiting for room",
130
+ };
131
+ const STATUS_MARKS: Record<string, string> = {
132
+ completed: "✓", failed: "✕", startFailed: "✕", notRunnable: "✕", refused: "✕", skipped: "–", deferred: "…", started: "…", starting: "…", running: "…",
133
+ };
134
+
135
+ export function statusText(status: string | null | undefined): string {
136
+ return status ? (STATUS_WORDS[status] ?? status) : "No runs";
137
+ }
138
+
139
+ export function statusMark(status: string | null | undefined): string {
140
+ return status ? (STATUS_MARKS[status] ?? "") : "";
141
+ }
142
+
143
+ export function durationText(seconds: number | null | undefined): string {
144
+ if (seconds === null || seconds === undefined) return "duration not recorded";
145
+ if (seconds < 90) return `${Math.round(seconds)} s`;
146
+ return `${Math.floor(seconds / 60)} min ${Math.round(seconds % 60)} s`;
147
+ }
148
+
149
+ export function dollars(value: number): string {
150
+ return value < 0.01 ? `$${value.toFixed(4)}` : `$${value.toFixed(2)}`;
151
+ }
152
+
153
+ /** "Cost: $0.04 (estimated ...)" or "Cost: unknown (why)", with the advisory budget flagged in words. */
154
+ export function costText(run: Pick<AutomationRun, "costUsd" | "costNote" | "overBudget"> & { budgetUsd?: number | null }): string {
155
+ const base = run.costUsd === null ? `Cost: unknown (${run.costNote})` : `Cost: ${dollars(run.costUsd)} (${run.costNote})`;
156
+ return run.overBudget && run.budgetUsd != null ? `${base}. Over the budget of ${dollars(run.budgetUsd)} per run` : base;
157
+ }
158
+
159
+ export function whoText(run: Pick<AutomationRun, "origin" | "requestedBy" | "sourceId">): string {
160
+ return run.origin === "manual" ? `Run now by ${run.requestedBy ?? "someone"}` : `Item: ${run.sourceId ?? "unknown"}`;
161
+ }
162
+
163
+ export const LIMITS_NOTE = "the timeout and the number at a time (when set) are enforced when a run starts; the budget is advisory and only flagged";
164
+
165
+ export function atATimeText(limits: { maxConcurrent?: number }): string {
166
+ return limits.maxConcurrent === undefined ? "no limit on how many at a time" : `${limits.maxConcurrent} at a time`;
167
+ }
168
+
169
+ export type RunNowResult =
170
+ | { state: "started"; automationId: string; requestedBy: string }
171
+ | { state: "refused"; reason: string }
172
+ | { state: "unavailable" }
173
+ | { error: "forbidden" | "bad_request" };
174
+
175
+ export function runNowMessage(result: RunNowResult): string {
176
+ if ("error" in result) return result.error === "forbidden" ? "Run now is for editors and admins." : "The request was not understood.";
177
+ if (result.state === "started") return "Started. The run appears in the list below within a minute; the page refreshes by itself.";
178
+ if (result.state === "refused") return `Not started: ${result.reason}`;
179
+ return "Not started: the request could not be completed right now. Nothing was started.";
180
+ }
181
+
182
+ /** The input a person typed for Run now: a JSON object, or nothing (the record's own input is used). */
183
+ export function parseRunInput(text: string): { ok: true; value: Record<string, unknown> | null } | { ok: false; message: string } {
184
+ if (!text.trim()) return { ok: true, value: null };
185
+ try {
186
+ const parsed = JSON.parse(text);
187
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return { ok: false, message: "The input must be a JSON object, for example {\"name\": \"value\"}." };
188
+ return { ok: true, value: parsed as Record<string, unknown> };
189
+ } catch {
190
+ return { ok: false, message: "The input is not valid JSON." };
191
+ }
192
+ }
193
+
194
+ /** A starting point for the input box: the record's input names with empty values. */
195
+ export function inputTemplateText(names: string[]): string {
196
+ return JSON.stringify(Object.fromEntries(names.map((name) => [name, ""])), null, 2);
197
+ }
198
+
199
+ const LINK_WORDS: Record<AutomationLink["state"], string> = {
200
+ linked: "Linked: the router's trigger matches this record.",
201
+ input_differs: "Differs: the router's input template is not the same as this record's.",
202
+ trigger_not_found: "Not linked: the router has no trigger for this procedure.",
203
+ publication_not_in_router: "Not linked: the router does not deliver to this publication.",
204
+ router_not_found: "Not linked: the monitor has no router configuration.",
205
+ not_applicable: "Not yet supported",
206
+ };
207
+
208
+ export function linkText(link: AutomationLink | null): string {
209
+ return link ? LINK_WORDS[link.state] ?? link.text : "Not checked";
210
+ }
211
+
212
+ const NOTICE_WORDS: Record<string, string> = {
213
+ monitoring_not_configured: "Monitoring is not connected to this site's account, so run history cannot be shown.",
214
+ monitoring_unavailable: "Run history could not be read right now. The record is shown without it.",
215
+ other_publication: "This automation belongs to another publication, so its runs are not shown here.",
216
+ publication_not_routed: "This publication is not one of the router's outputs, so there are no runs to show.",
217
+ "not yet supported": "Only monitor automations run today. This one is stored but nothing starts it.",
218
+ };
219
+
220
+ export const OUTSIDE_NOTICE = "The procedure's other runs could not be read right now.";
221
+
222
+ export function noticeText(notice: AutomationNotice | null): string | null {
223
+ return notice ? (NOTICE_WORDS[notice] ?? notice) : null;
224
+ }
225
+
226
+ export function storyHref(storyId: string, demo?: boolean): string {
227
+ return getNewsroomNavHref(`/newsroom/articles/${encodeURIComponent(storyId)}`, demo);
228
+ }
229
+
230
+ export function proceduresHref(demo?: boolean): string {
231
+ return getNewsroomNavHref("/newsroom/administration/procedures", demo);
232
+ }
@@ -200,7 +200,6 @@ export type ProcedureRunRecord = {
200
200
  newsroomFeedKey?: string | null;
201
201
  };
202
202
 
203
- const DEFAULT_NEWSROOM_SECTIONS_CONFIG_PATH = path.join(process.cwd(), "corpora", "papyrus-newsroom-sections.yml");
204
203
  const NEWSROOM_SECTION_TYPES = new Set<NewsroomSectionType>(["canonical", "floating", "rotating"]);
205
204
  type NewsroomSectionSeedRow = Omit<NewsroomSectionRecord, "sortOrder" | "enabled" | "enabledStatus" | "createdAt" | "updatedAt"> & {
206
205
  enabled: boolean;
@@ -690,10 +689,10 @@ export type CategorySteeringDashboard = {
690
689
  loadError?: string | null;
691
690
  };
692
691
 
693
- const DEFAULT_ANALYSIS_PROFILES_PATH = path.join(process.cwd(), "corpora", "papyrus-analysis-profiles.yml");
694
- const DEFAULT_STEERING_CONFIG_PATH = path.join(process.cwd(), "corpora", "papyrus-steering.yml");
695
692
 
696
- export async function loadAnalysisProfileSummaries(filepath = DEFAULT_ANALYSIS_PROFILES_PATH): Promise<AnalysisProfileSummary[]> {
693
+ /** Analysis profiles the site keeps in a file; a site that configures none (data-driven sites) has none. No default file is looked up. */
694
+ export async function loadAnalysisProfileSummaries(filepath?: string): Promise<AnalysisProfileSummary[]> {
695
+ if (!filepath) return [];
697
696
  try {
698
697
  const parsed = YAML.parse(fs.readFileSync(filepath, "utf8")) as {
699
698
  schemaVersion?: number;
@@ -722,7 +721,9 @@ export async function loadAnalysisProfileSummaries(filepath = DEFAULT_ANALYSIS_P
722
721
  }
723
722
  }
724
723
 
725
- export async function loadConfiguredCorpusSummaries(filepath = DEFAULT_STEERING_CONFIG_PATH): Promise<CategorySteeringCorpus[]> {
724
+ /** Corpora a site lists in a steering file; no configured file means none. No default file is looked up. */
725
+ export async function loadConfiguredCorpusSummaries(filepath?: string): Promise<CategorySteeringCorpus[]> {
726
+ if (!filepath) return [];
726
727
  try {
727
728
  const parsed = YAML.parse(fs.readFileSync(filepath, "utf8")) as {
728
729
  corpora?: Array<Record<string, unknown>>;
@@ -818,7 +819,8 @@ function sortCategorys(categorys: CategorySteeringCategory[]): CategorySteeringC
818
819
  });
819
820
  }
820
821
 
821
- function defaultNewsroomSections(importedAt: string, sectionsConfigPath = DEFAULT_NEWSROOM_SECTIONS_CONFIG_PATH): NewsroomSectionRecord[] {
822
+ function defaultNewsroomSections(importedAt: string, sectionsConfigPath?: string): NewsroomSectionRecord[] {
823
+ if (!sectionsConfigPath) return [];
822
824
  return loadNewsroomSectionSeedRows(sectionsConfigPath).map((row, index) => ({
823
825
  ...row,
824
826
  enabledStatus: "enabled",
@@ -828,7 +830,7 @@ function defaultNewsroomSections(importedAt: string, sectionsConfigPath = DEFAUL
828
830
  }));
829
831
  }
830
832
 
831
- function loadNewsroomSectionSeedRows(sectionsConfigPath = DEFAULT_NEWSROOM_SECTIONS_CONFIG_PATH) {
833
+ function loadNewsroomSectionSeedRows(sectionsConfigPath: string) {
832
834
  const cached = newsroomSectionSeedRowsCache.get(sectionsConfigPath);
833
835
  if (cached) return cached;
834
836
  const parsed = YAML.parse(fs.readFileSync(sectionsConfigPath, "utf8")) as {
@@ -883,7 +885,7 @@ function normalizeNewsroomSectionSeedRow(
883
885
 
884
886
  export function createDemoCategorySteeringDashboard(
885
887
  brandId: SiteBrandId = resolveSiteBrandId(),
886
- newsroomSectionsConfigPath = DEFAULT_NEWSROOM_SECTIONS_CONFIG_PATH,
888
+ newsroomSectionsConfigPath?: string,
887
889
  ): CategorySteeringDashboard {
888
890
  const importedAt = "2026-05-16T12:00:00.000Z";
889
891
  const profile = getNewsroomDemoProfile(brandId);
@@ -1698,7 +1700,7 @@ export function createDemoCategorySteeringDashboard(
1698
1700
  question: "Should this candidate source become a reference for this publication?",
1699
1701
  reviewControl: "thumbs",
1700
1702
  reviewReason: "program",
1701
- reviewDetail: "Every decision is reviewed while the cyclotron is onboarding.",
1703
+ reviewDetail: "Every suggestion is reviewed while suggestions are getting started.",
1702
1704
  version: 1,
1703
1705
  }),
1704
1706
  },
@@ -0,0 +1,345 @@
1
+ import { NextResponse, type NextRequest } from "next/server";
2
+ import { SITE_BRAND } from "./site-brand";
3
+ import type { PublicationCapability } from "./prospectus-publication-access";
4
+ import {
5
+ currentProspectusPublishingSelection,
6
+ signedInProspectusSession,
7
+ type ProspectusPublishingSelection,
8
+ type ProspectusSignInRouteOptions,
9
+ } from "./prospectus-sign-in-routes";
10
+ import { resolveProspectusSignInRuntime } from "./prospectus-session";
11
+ import { SiteOriginNotConfiguredError, publicSiteOrigin } from "./site-origin";
12
+
13
+ /**
14
+ * Same-origin curation routes for the References tab. Each is an authenticated
15
+ * pass-through to the curation upstream (the central Cyclotron lab): this
16
+ * module decides who may call and what they may ask, the upstream repeats its
17
+ * own checks, and neither trusts the browser's pins as identity.
18
+ *
19
+ * Order, failing closed at each step: upstream configured, Origin and body,
20
+ * sealed session cookie, fresh Prospectus re-authorization, scope pins,
21
+ * capability, forward, then re-authorize again and verify the echoed scope
22
+ * before any byte of the answer reaches the browser.
23
+ */
24
+ export const CURATION_UPSTREAM_URL_ENV = "PAPYRUS_CURATION_UPSTREAM_URL";
25
+ export const CURATION_PUBLICATION_ID_ENV = "PAPYRUS_CURATION_PUBLICATION_ID";
26
+ export const CURATION_MAXIMUM_REQUEST_BYTES = 4_000_000;
27
+ export const CURATION_MAXIMUM_RESPONSE_BYTES = 8_000_000;
28
+ export const CURATION_UPSTREAM_TIMEOUT_MILLISECONDS = 15_000;
29
+
30
+ export type CurationRoute = "context" | "publication" | "priming";
31
+
32
+ export interface CurationRouteOptions extends ProspectusSignInRouteOptions {
33
+ /** Overrides PAPYRUS_CURATION_UPSTREAM_URL. null means not configured. */
34
+ upstreamUrl?: string | null;
35
+ /** Overrides PAPYRUS_CURATION_PUBLICATION_ID, which defaults to the site brand id. */
36
+ publicationId?: string;
37
+ fetcher?: typeof fetch;
38
+ timeoutMilliseconds?: number;
39
+ }
40
+
41
+ const NO_STORE_HEADERS = { "cache-control": "no-store", "x-content-type-options": "nosniff" };
42
+ const PIN_HEADERS = ["x-cyclotron-account", "x-papyrus-publication", "x-papyrus-binding"] as const;
43
+ const PUBLICATION_OPERATION_CAPABILITIES: Record<string, PublicationCapability> = {
44
+ decide: "decide",
45
+ review: "review",
46
+ status: "read",
47
+ history: "read",
48
+ events: "read",
49
+ replay: "read",
50
+ steer: "steer",
51
+ };
52
+ const FORWARDED_UPSTREAM_REFUSALS = new Set([400, 401, 403, 409, 413, 415, 503]);
53
+
54
+ /** Editor-facing wording for the refusal codes the curation upstream returns. Unknown codes get a generic line. */
55
+ export const UPSTREAM_REFUSAL_MESSAGES: Record<string, string> = {
56
+ pins_required: "Your account or publication selection is incomplete. Refresh the page and try again.",
57
+ invalid_request: "That request could not be understood. Refresh the page and try again.",
58
+ json_required: "That request could not be understood. Refresh the page and try again.",
59
+ bearer_invalid: "Sign in again: your session expired. Your selection is preserved.",
60
+ access_refused: "Your account does not have access to this publication.",
61
+ outside_publication: "That item is outside the publication you selected.",
62
+ account_changed: "The selected account or publication changed. Refresh and try again.",
63
+ operation_unavailable: "This action is not available yet.",
64
+ firewall_overlap: "This example is also in material kept aside for checking results, so it cannot be added.",
65
+ auto_prime_failed: "Your example could not be fully applied. Check saved history before retrying.",
66
+ request_too_large: "That request is too large. Try something smaller.",
67
+ import_too_large: "That file is too large. Try a smaller file.",
68
+ storage_limit: "This publication has reached its storage limit for imported examples.",
69
+ authority_unavailable: "Account access could not be confirmed right now. Try again shortly.",
70
+ priming_not_configured: "Importing examples is not set up for this publication yet.",
71
+ status_unavailable: "Monitoring is not available right now. Try again shortly.",
72
+ live_disabled: "Live decisions are switched off for this publication.",
73
+ no_hosted_transformer: "Adding a reference from a web address is not available. Import a file instead.",
74
+ };
75
+ const GENERIC_UPSTREAM_REFUSAL = "The curation service refused the request. Check saved history before retrying.";
76
+
77
+ class CurationRefusal extends Error {
78
+ constructor(readonly status: number, readonly code: string, message: string) {
79
+ super(message);
80
+ }
81
+ }
82
+
83
+ function refusal(status: number, code: string, message: string): CurationRefusal {
84
+ return new CurationRefusal(status, code, message);
85
+ }
86
+
87
+ function jsonResponse(body: unknown, status = 200, extraHeaders: Record<string, string> = {}): NextResponse {
88
+ return NextResponse.json(body, { status, headers: { ...NO_STORE_HEADERS, ...extraHeaders } });
89
+ }
90
+
91
+ function upstreamBase(options: CurationRouteOptions): URL | null {
92
+ const configured = options.upstreamUrl !== undefined ? options.upstreamUrl : process.env[CURATION_UPSTREAM_URL_ENV];
93
+ if (!configured?.trim()) return null;
94
+ try {
95
+ const url = new URL(configured.trim());
96
+ const loopback = ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname);
97
+ if (url.protocol !== "https:" && !(loopback && url.protocol === "http:")) return null;
98
+ if (url.username || url.password) return null;
99
+ return url;
100
+ } catch {
101
+ return null;
102
+ }
103
+ }
104
+
105
+ function publicationIdFor(options: CurationRouteOptions): string {
106
+ return options.publicationId ?? (process.env[CURATION_PUBLICATION_ID_ENV]?.trim() || SITE_BRAND.id);
107
+ }
108
+
109
+ /** Which capability a priming GraphQL document needs. Any hint of a mutation requires review. */
110
+ export function primingCapability(query: string): PublicationCapability {
111
+ return /\bmutation\b|\bprimingCommand\b/i.test(query) ? "review" : "read";
112
+ }
113
+
114
+ async function boundedText(request: NextRequest): Promise<string> {
115
+ const declared = Number(request.headers.get("content-length") ?? "0");
116
+ if (declared > CURATION_MAXIMUM_REQUEST_BYTES) throw refusal(413, "request_too_large", "Curation request exceeds the size limit.");
117
+ const text = await request.text();
118
+ if (Buffer.byteLength(text) > CURATION_MAXIMUM_REQUEST_BYTES) throw refusal(413, "request_too_large", "Curation request exceeds the size limit.");
119
+ return text;
120
+ }
121
+
122
+ async function boundedResponseText(response: Response): Promise<string> {
123
+ const declared = Number(response.headers.get("content-length") ?? "0");
124
+ if (declared > CURATION_MAXIMUM_RESPONSE_BYTES) throw refusal(502, "upstream_response_too_large", "The curation service answered too much.");
125
+ if (!response.body) return "";
126
+ const reader = response.body.getReader();
127
+ const chunks: Uint8Array[] = [];
128
+ let total = 0;
129
+ for (;;) {
130
+ const { done, value } = await reader.read();
131
+ if (done) break;
132
+ total += value.byteLength;
133
+ if (total > CURATION_MAXIMUM_RESPONSE_BYTES) {
134
+ await reader.cancel();
135
+ throw refusal(502, "upstream_response_too_large", "The curation service answered too much.");
136
+ }
137
+ chunks.push(value);
138
+ }
139
+ return Buffer.concat(chunks).toString("utf8");
140
+ }
141
+
142
+ function sameSelection(left: ProspectusPublishingSelection, right: ProspectusPublishingSelection | null): boolean {
143
+ return right !== null
144
+ && left.personId === right.personId
145
+ && left.accountId === right.accountId
146
+ && left.role === right.role
147
+ && left.publicationCapabilities.join() === right.publicationCapabilities.join();
148
+ }
149
+
150
+ interface ParsedRequest {
151
+ capability: PublicationCapability;
152
+ /** Raw body to forward; undefined for the context read. */
153
+ body?: string;
154
+ }
155
+
156
+ function parseRequest(route: CurationRoute, text: string | null): ParsedRequest {
157
+ if (route === "context") return { capability: "read" };
158
+ let value: unknown;
159
+ try {
160
+ value = JSON.parse(text ?? "");
161
+ } catch {
162
+ throw refusal(400, "invalid_request", "A JSON curation request is required.");
163
+ }
164
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw refusal(400, "invalid_request", "A JSON curation request is required.");
165
+ const body = value as Record<string, unknown>;
166
+ if (route === "publication") {
167
+ const keys = Object.keys(body).sort().join();
168
+ const operation = typeof body.operation === "string" ? body.operation : "";
169
+ if (keys !== "operation,payload" || !Object.hasOwn(PUBLICATION_OPERATION_CAPABILITIES, operation) || !body.payload || typeof body.payload !== "object" || Array.isArray(body.payload)) {
170
+ throw refusal(400, "invalid_request", "An explicit publication operation is required.");
171
+ }
172
+ return { capability: PUBLICATION_OPERATION_CAPABILITIES[operation], body: text! };
173
+ }
174
+ const variablesValid = body.variables === undefined || (body.variables !== null && typeof body.variables === "object" && !Array.isArray(body.variables));
175
+ if (typeof body.query !== "string" || !body.query.trim() || !variablesValid || Object.keys(body).some((key) => key !== "query" && key !== "variables")) {
176
+ throw refusal(400, "invalid_request", "An explicit priming query is required.");
177
+ }
178
+ return { capability: primingCapability(body.query), body: text! };
179
+ }
180
+
181
+ function requestPins(request: NextRequest, selection: ProspectusPublishingSelection, publicationId: string): Record<(typeof PIN_HEADERS)[number], string> {
182
+ const pins = Object.fromEntries(PIN_HEADERS.map((name) => [name, request.headers.get(name)?.trim() ?? ""])) as Record<(typeof PIN_HEADERS)[number], string>;
183
+ if (PIN_HEADERS.some((name) => !pins[name])) throw refusal(400, "pins_required", "Expected account, publication and binding pins are required.");
184
+ if (pins["x-cyclotron-account"] !== selection.accountId || pins["x-papyrus-publication"] !== publicationId) {
185
+ throw refusal(409, "account_changed", "The selected account or publication changed. Refresh and try again.");
186
+ }
187
+ return pins;
188
+ }
189
+
190
+ function authorizedCapabilities(selection: ProspectusPublishingSelection): PublicationCapability[] {
191
+ return selection.publicationCapabilities;
192
+ }
193
+
194
+ async function forward(
195
+ request: NextRequest,
196
+ route: CurationRoute,
197
+ base: URL,
198
+ options: CurationRouteOptions,
199
+ accessToken: string,
200
+ upstreamHeaders: Record<string, string>,
201
+ body: string | undefined,
202
+ ): Promise<Response> {
203
+ const target = new URL(`/api/newsroom/curation/${route}`, base);
204
+ const headers: Record<string, string> = {
205
+ ...upstreamHeaders,
206
+ authorization: `Bearer ${accessToken}`,
207
+ accept: "application/json",
208
+ };
209
+ if (body !== undefined) headers["content-type"] = "application/json";
210
+ const response = await (options.fetcher ?? fetch)(target, {
211
+ method: route === "context" ? "GET" : "POST",
212
+ headers,
213
+ body,
214
+ redirect: "manual",
215
+ cache: "no-store",
216
+ signal: AbortSignal.timeout(options.timeoutMilliseconds ?? CURATION_UPSTREAM_TIMEOUT_MILLISECONDS),
217
+ });
218
+ if (response.status >= 300 && response.status < 400) throw refusal(502, "upstream_redirect", "The curation service redirected, which is refused.");
219
+ return response;
220
+ }
221
+
222
+ function sanitizedUpstreamRefusal(status: number, text: string): CurationRefusal {
223
+ let code = "";
224
+ try {
225
+ const parsed = JSON.parse(text) as { code?: unknown };
226
+ code = typeof parsed.code === "string" ? parsed.code : "";
227
+ } catch {
228
+ code = "";
229
+ }
230
+ const known = Object.hasOwn(UPSTREAM_REFUSAL_MESSAGES, code);
231
+ return refusal(status, known ? code : "upstream_refused", known ? UPSTREAM_REFUSAL_MESSAGES[code] : GENERIC_UPSTREAM_REFUSAL);
232
+ }
233
+
234
+ function validPriming(value: unknown): Record<string, unknown> | null {
235
+ const priming = value as { listId?: unknown; classifiers?: unknown; transformers?: unknown } | null;
236
+ if (!priming || typeof priming.listId !== "string" || !priming.listId || !Array.isArray(priming.classifiers) || !priming.classifiers.length) return null;
237
+ return { listId: priming.listId, classifiers: priming.classifiers, transformers: Array.isArray(priming.transformers) ? priming.transformers : [] };
238
+ }
239
+
240
+ function contextBootstrap(value: unknown, selection: ProspectusPublishingSelection, publicationId: string): Record<string, unknown> {
241
+ const body = value as { scope?: Record<string, unknown>; mode?: unknown; priming?: unknown; capabilities?: unknown; bindingRef?: unknown } | null;
242
+ const scope = body?.scope;
243
+ if (!scope || scope.accountId !== selection.accountId || scope.publicationId !== publicationId
244
+ || typeof scope.bindingRevision !== "string" || !scope.bindingRevision
245
+ || (body!.mode !== "embedded" && body!.mode !== "remote") || !Array.isArray(body!.capabilities)) {
246
+ throw refusal(502, "upstream_scope_mismatch", "The curation service answered for a different scope.");
247
+ }
248
+ const granted = new Set<string>(authorizedCapabilities(selection));
249
+ return {
250
+ scope: { accountId: scope.accountId, publicationId: scope.publicationId, bindingRevision: scope.bindingRevision },
251
+ mode: body!.mode,
252
+ priming: validPriming(body!.priming),
253
+ capabilities: body!.capabilities.filter((capability): capability is string => typeof capability === "string" && granted.has(capability)),
254
+ bindingRef: body!.bindingRef ?? null,
255
+ };
256
+ }
257
+
258
+ function verifiedPublicationEnvelope(value: unknown, pins: Record<string, string>): void {
259
+ const envelope = value as Record<string, unknown> | null;
260
+ if (!envelope || envelope.contractVersion !== "cyclotron-publication/v1"
261
+ || envelope.accountId !== pins["x-cyclotron-account"]
262
+ || envelope.publicationId !== pins["x-papyrus-publication"]
263
+ || envelope.bindingRevision !== pins["x-papyrus-binding"]) {
264
+ throw refusal(502, "upstream_scope_mismatch", "The curation service answered for a different scope.");
265
+ }
266
+ }
267
+
268
+ export async function handleCurationRoute(request: NextRequest, route: CurationRoute, options: CurationRouteOptions = {}): Promise<NextResponse> {
269
+ try {
270
+ const base = upstreamBase(options);
271
+ if (!base) return jsonResponse({ error: "curation_not_configured", message: "Curation is not configured for this site." }, 503);
272
+
273
+ const origin = request.headers.get("origin");
274
+ const siteOrigin = publicSiteOrigin(request, options.environment);
275
+ if (route !== "context" ? origin !== siteOrigin : origin !== null && origin !== siteOrigin) {
276
+ throw refusal(403, "cross_origin", "Curation requests must come from this site.");
277
+ }
278
+ let text: string | null = null;
279
+ if (route !== "context") {
280
+ if (request.headers.get("content-type")?.split(";")[0].trim() !== "application/json") throw refusal(415, "json_required", "A JSON request is required.");
281
+ text = await boundedText(request);
282
+ }
283
+
284
+ const runtime = await (options.resolveRuntime ?? resolveProspectusSignInRuntime)(siteOrigin);
285
+ if (!runtime) return jsonResponse({ error: "prospectus_sign_in_unavailable", message: "Sign-in through Prospectus is not configured for this site." }, 503);
286
+ const nowInSeconds = options.nowInSeconds?.();
287
+ const cookieSession = signedInProspectusSession(request, runtime.sealingSecret, nowInSeconds);
288
+ if (!cookieSession?.selectedAccountId) throw refusal(401, "signed_out", "Sign in and choose an account first.");
289
+
290
+ const selection = await currentProspectusPublishingSelection(request, options);
291
+ if (!selection || selection.accountId !== cookieSession.selectedAccountId) throw refusal(403, "not_authorized", "Your account no longer grants access.");
292
+
293
+ const publicationId = publicationIdFor(options);
294
+ const pins = route === "context" ? null : requestPins(request, selection, publicationId);
295
+ const parsed = parseRequest(route, text);
296
+ if (!authorizedCapabilities(selection).includes(parsed.capability)) throw refusal(403, "capability_refused", "Your role does not allow this curation action.");
297
+
298
+ // The token is read from the sealed cookie again for this call, never held across calls.
299
+ const fresh = signedInProspectusSession(request, runtime.sealingSecret, nowInSeconds);
300
+ if (!fresh || fresh.selectedAccountId !== selection.accountId) throw refusal(401, "signed_out", "Sign in and choose an account first.");
301
+ const upstreamHeaders = pins ?? { "x-cyclotron-account": selection.accountId, "x-papyrus-publication": publicationId };
302
+ const upstream = await forward(request, route, base, options, fresh.accessToken, upstreamHeaders, parsed.body);
303
+ const upstreamText = await boundedResponseText(upstream);
304
+ if (upstream.status !== 200) {
305
+ if (FORWARDED_UPSTREAM_REFUSALS.has(upstream.status)) throw sanitizedUpstreamRefusal(upstream.status, upstreamText);
306
+ throw refusal(502, "upstream_failed", "The curation service could not complete the request. Check saved history before retrying.");
307
+ }
308
+
309
+ // Post-upstream check: nothing is released if authority or scope moved while we waited.
310
+ if (!sameSelection(selection, await currentProspectusPublishingSelection(request, options))) {
311
+ throw refusal(409, "account_changed", "The selected account or access changed. Refresh and try again.");
312
+ }
313
+ let answer: unknown;
314
+ try {
315
+ answer = JSON.parse(upstreamText);
316
+ } catch {
317
+ throw refusal(502, "upstream_invalid", "The curation service answered with an invalid response.");
318
+ }
319
+ if (pins) {
320
+ if (PIN_HEADERS.some((name) => upstream.headers.get(name) !== pins[name])) {
321
+ throw refusal(502, "upstream_scope_mismatch", "The curation service answered for a different scope.");
322
+ }
323
+ if (route === "publication") verifiedPublicationEnvelope(answer, pins);
324
+ if (route === "priming" && (!answer || typeof answer !== "object" || Array.isArray(answer))) {
325
+ throw refusal(502, "upstream_invalid", "The curation service answered with an invalid response.");
326
+ }
327
+ return jsonResponse(answer, 200, pins);
328
+ }
329
+ const bootstrap = contextBootstrap(answer, selection, publicationId);
330
+ const bindingRevision = (bootstrap.scope as { bindingRevision: string }).bindingRevision;
331
+ if (upstream.headers.get("x-cyclotron-account") !== selection.accountId
332
+ || upstream.headers.get("x-papyrus-publication") !== publicationId
333
+ || upstream.headers.get("x-papyrus-binding") !== bindingRevision) {
334
+ throw refusal(502, "upstream_scope_mismatch", "The curation service answered for a different scope.");
335
+ }
336
+ return jsonResponse(bootstrap, 200, {
337
+ "x-cyclotron-account": selection.accountId, "x-papyrus-publication": publicationId, "x-papyrus-binding": bindingRevision,
338
+ });
339
+ } catch (error) {
340
+ if (error instanceof CurationRefusal) return jsonResponse({ error: error.message, code: error.code }, error.status);
341
+ if (error instanceof SiteOriginNotConfiguredError) return jsonResponse({ error: error.message, code: error.code }, error.status);
342
+ console.error(`Curation route failed (${error instanceof Error ? error.name : "error"}).`);
343
+ return jsonResponse({ error: "Curation is unavailable. Check saved history before retrying.", code: "curation_unavailable" }, 503);
344
+ }
345
+ }
@@ -187,8 +187,8 @@ export function curationForSuggestionChoice(decision: RelevanceDecisionView, cho
187
187
  }
188
188
 
189
189
  export type ProvenanceSummary = {
190
- teachingLabelsSentence: string;
191
- primingExamplesSentence: string;
190
+ teachingLabelsSentence: string | null;
191
+ primingExamplesSentence: string | null;
192
192
  doctrineShortHash: string | null;
193
193
  modelVersion: string | null;
194
194
  decidedAt: string | null;
@@ -202,8 +202,8 @@ export function provenanceSummary(decision: RelevanceDecisionView): ProvenanceSu
202
202
  if (!provenance) return null;
203
203
  const plural = (count: number, singular: string) => `${count} ${singular}${count === 1 ? "" : "s"}`;
204
204
  return {
205
- teachingLabelsSentence: `Based on ${plural(provenance.teachingLabels, "decision")} that editors made themselves`,
206
- primingExamplesSentence: `${plural(provenance.primingExamples, "example reference")} supplied as starting examples`,
205
+ teachingLabelsSentence: provenance.teachingLabels === null ? null : `Based on ${plural(provenance.teachingLabels, "decision")} that editors made themselves`,
206
+ primingExamplesSentence: provenance.primingExamples === null ? null : `${plural(provenance.primingExamples, "example reference")} supplied as starting examples`,
207
207
  doctrineShortHash: provenance.doctrineFingerprint ? provenance.doctrineFingerprint.slice(0, SHORT_HASH_LENGTH) : null,
208
208
  modelVersion: decision.version === null ? null : `revision ${decision.version}`,
209
209
  decidedAt: decision.decidedAt,