@anthusai/papyrus 2.0.0-next.5 → 2.0.0-next.50
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/amplify/data/resource.js +23 -3
- package/amplify/data/resource.ts +25 -3
- package/amplify/data/schema.js +2028 -1979
- package/amplify/data/schema.ts +72 -14
- 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/content-actions/handler.py +53 -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/{plugins/editorial-checks/function → functions/editorial-checks}/resource.js +7 -7
- package/amplify/{plugins/editorial-checks/function → functions/editorial-checks}/resource.ts +7 -7
- package/amplify/functions/editorial-checks/schema.js +121 -0
- package/amplify/{plugins → functions}/editorial-checks/schema.ts +33 -6
- package/amplify/functions/editorial-checks/wire.js +53 -0
- package/amplify/functions/editorial-checks/wire.ts +61 -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 +16 -0
- package/amplify/functions/monitoring-view/schema.ts +18 -0
- package/amplify/functions/monitoring-view/wire.js +37 -0
- package/amplify/functions/monitoring-view/wire.ts +47 -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 +195 -0
- package/amplify/plugins/newsletter/config.ts +240 -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 +260 -0
- package/amplify/plugins/newsletter/papyrus.plugin.ts +308 -0
- package/amplify/plugins/newsletter/schema.js +56 -0
- package/amplify/plugins/newsletter/schema.ts +61 -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 +162 -0
- package/amplify/plugins/reader-steering/config.ts +248 -0
- package/amplify/plugins/reader-steering/function/ai_moderation.py +230 -0
- package/amplify/plugins/reader-steering/function/handler.py +1363 -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/resource.js +27 -0
- package/amplify/plugins/reader-steering/function/resource.ts +35 -0
- package/amplify/plugins/reader-steering/papyrus.plugin.js +175 -0
- package/amplify/plugins/reader-steering/papyrus.plugin.ts +201 -0
- package/amplify/plugins/reader-steering/schema.js +140 -0
- package/amplify/plugins/reader-steering/schema.ts +142 -0
- package/amplify/plugins/registry.js +4 -2
- package/amplify/plugins/registry.ts +4 -2
- package/amplify/plugins/types.ts +5 -0
- package/amplify/plugins/wire.js +1 -0
- package/amplify/plugins/wire.ts +1 -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.ts +17 -0
- package/amplify/site-backend.js +36 -1
- package/amplify/site-backend.ts +44 -1
- package/app/api/newsletter/confirm/route.ts +13 -0
- package/app/api/newsletter/preferences/route.ts +13 -0
- package/app/api/newsletter/subscribe/route.ts +9 -0
- package/app/api/newsletter/unsubscribe/route.ts +13 -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/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/bin/papyrus-infra.mjs +20 -1
- package/components/editorial-checks-admin-panel.tsx +36 -11
- package/components/moderation-panel.tsx +248 -0
- package/components/monitoring-overview-view.tsx +241 -0
- package/components/monitoring-panel.tsx +40 -0
- package/components/newsletter-panel.tsx +474 -0
- package/components/newsletter-sections.tsx +163 -0
- package/components/newsroom-article-editor.tsx +2 -4
- package/components/newsroom-board-view.tsx +4 -1
- package/components/newsroom-curation-bootstrap.tsx +45 -0
- package/components/newsroom-organizer.css +31 -0
- package/components/newsroom-references-view.tsx +14 -14
- package/components/publication-cyclotron-panel.tsx +15 -10
- package/components/reference-add-entry.tsx +75 -0
- package/components/reference-curation-controls.tsx +31 -22
- package/components/reference-cyclotron-priming.tsx +58 -19
- package/components/reference-cyclotron-suggestion.tsx +1 -1
- package/components/reference-priming.tsx +14 -10
- package/components/reference-section-suggestion.tsx +1 -1
- package/components/topic-steering-workspace.tsx +64 -20
- package/components/use-editorial-checks.ts +12 -10
- package/infra/amplify-app-shell.js +90 -16
- package/infra/amplify-app-shell.ts +102 -18
- 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 +85 -6
- package/infra/site-config.ts +123 -6
- package/lib/category-repository.ts +11 -9
- package/lib/curation-routes.ts +345 -0
- package/lib/editorial-checks-backend.ts +1 -1
- package/lib/editorial-checks-client.ts +19 -1
- package/lib/editorial-checks-demo.ts +2 -2
- package/lib/editorial-checks-settings.ts +3 -0
- package/lib/editorial-checks.ts +8 -11
- package/lib/editorial-preview.ts +5 -5
- package/lib/monitoring-client.ts +29 -0
- package/lib/monitoring-overview-fixture.ts +51 -0
- package/lib/monitoring-overview.ts +145 -0
- package/lib/newsletter-api.ts +391 -0
- package/lib/newsletter-client.ts +46 -0
- package/lib/newsletter-fixture.ts +184 -0
- package/lib/newsletter-model.ts +202 -0
- package/lib/newsletter-pages.ts +264 -0
- package/lib/newsroom-board.ts +27 -2
- package/lib/prospectus-session.ts +15 -14
- package/lib/prospectus-sign-in-routes.ts +54 -13
- package/lib/prospectus-site-identity.ts +24 -1
- 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/relevance-status.ts +2 -2
- package/lib/site-brand-tenant.ts +11 -6
- package/lib/site-brand.ts +6 -3
- package/lib/site-origin.ts +67 -0
- package/middleware.ts +14 -3
- package/package.json +42 -4
- package/routes.manifest.json +191 -1
- package/amplify/plugins/editorial-checks/function/handler.py +0 -11
- package/amplify/plugins/editorial-checks/function/requirements.txt +0 -1
- package/amplify/plugins/editorial-checks/papyrus.plugin.js +0 -50
- package/amplify/plugins/editorial-checks/papyrus.plugin.ts +0 -63
- package/amplify/plugins/editorial-checks/schema.js +0 -94
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The data side of the Administration "Moderation" panel for the reader-steering plugin: rows from the staff-readable
|
|
3
|
+
* models, shaped into a queue, a hidden list and the recent action log. Pure functions, no network: the panel loads the
|
|
4
|
+
* rows and calls the `moderateReader` mutation; everything it shows is decided here so it can be tested.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export type ModerationTarget = "submission" | "comment" | "reader";
|
|
8
|
+
export type ModerationActionName = "approve" | "hide" | "unhide" | "ban" | "unban" | "limit" | "promote" | "undo";
|
|
9
|
+
|
|
10
|
+
export type SubmissionRow = {
|
|
11
|
+
id: string;
|
|
12
|
+
canonicalUrl: string;
|
|
13
|
+
domain: string;
|
|
14
|
+
title: string;
|
|
15
|
+
kind?: string | null;
|
|
16
|
+
note?: string | null;
|
|
17
|
+
readerId: string;
|
|
18
|
+
state: string;
|
|
19
|
+
moderation?: string | null;
|
|
20
|
+
reportCount?: number | null;
|
|
21
|
+
signals?: unknown;
|
|
22
|
+
itemId?: string | null;
|
|
23
|
+
createdAt: string;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export type CommentRow = {
|
|
27
|
+
id: string;
|
|
28
|
+
subjectKey: string;
|
|
29
|
+
readerId: string;
|
|
30
|
+
body: string;
|
|
31
|
+
parentId?: string | null;
|
|
32
|
+
state: string;
|
|
33
|
+
moderation?: string | null;
|
|
34
|
+
reportCount?: number | null;
|
|
35
|
+
signals?: unknown;
|
|
36
|
+
createdAt: string;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export type ReportRow = { id: string; targetType: string; target: string; reporterReaderId: string; reason: string; state: string; createdAt: string };
|
|
40
|
+
|
|
41
|
+
export type ActionRow = {
|
|
42
|
+
id: string;
|
|
43
|
+
by: string;
|
|
44
|
+
actorSub: string;
|
|
45
|
+
action: string;
|
|
46
|
+
targetType: string;
|
|
47
|
+
target: string;
|
|
48
|
+
reason?: string | null;
|
|
49
|
+
fromState: string;
|
|
50
|
+
toState: string;
|
|
51
|
+
undoOf?: string | null;
|
|
52
|
+
undoneBy?: string | null;
|
|
53
|
+
at: string;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
export type Signal = { source: string; policy: string; detector: string; severity: string; excerpt: string };
|
|
57
|
+
|
|
58
|
+
export type ModerationRow = {
|
|
59
|
+
kind: "submission" | "comment";
|
|
60
|
+
id: string;
|
|
61
|
+
readerId: string;
|
|
62
|
+
handle: string | null;
|
|
63
|
+
createdAt: string;
|
|
64
|
+
state: string;
|
|
65
|
+
title: string;
|
|
66
|
+
url: string | null;
|
|
67
|
+
text: string;
|
|
68
|
+
subject: string | null;
|
|
69
|
+
reports: ReportRow[];
|
|
70
|
+
signals: Signal[];
|
|
71
|
+
canPromote: boolean;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
export type ActionEntry = ActionRow & { label: string; undoable: boolean; byLabel: string };
|
|
75
|
+
|
|
76
|
+
const SEVERITY_ORDER: Record<string, number> = { error: 0, warn: 1, info: 2 };
|
|
77
|
+
|
|
78
|
+
export function parseSignals(value: unknown): Signal[] {
|
|
79
|
+
let parsed: unknown = value;
|
|
80
|
+
if (typeof value === "string") {
|
|
81
|
+
try {
|
|
82
|
+
parsed = JSON.parse(value);
|
|
83
|
+
} catch {
|
|
84
|
+
return [];
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
if (!Array.isArray(parsed)) return [];
|
|
88
|
+
return parsed
|
|
89
|
+
.filter((entry): entry is Record<string, unknown> => typeof entry === "object" && entry !== null)
|
|
90
|
+
.map((entry) => ({
|
|
91
|
+
source: String(entry.source ?? ""),
|
|
92
|
+
policy: String(entry.policy ?? ""),
|
|
93
|
+
detector: String(entry.detector ?? ""),
|
|
94
|
+
severity: String(entry.severity ?? "info"),
|
|
95
|
+
excerpt: String(entry.excerpt ?? ""),
|
|
96
|
+
}))
|
|
97
|
+
.sort((left, right) => (SEVERITY_ORDER[left.severity] ?? 3) - (SEVERITY_ORDER[right.severity] ?? 3));
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** An address a moderator may follow: http or https only, never `javascript:` or `data:`. */
|
|
101
|
+
export function safeHttpUrl(value: string | null | undefined): string | null {
|
|
102
|
+
if (!value) return null;
|
|
103
|
+
try {
|
|
104
|
+
const url = new URL(value);
|
|
105
|
+
return url.protocol === "http:" || url.protocol === "https:" ? url.href : null;
|
|
106
|
+
} catch {
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function newestFirst<T extends { createdAt: string; id: string }>(rows: T[]): T[] {
|
|
112
|
+
return [...rows].sort((left, right) => (left.createdAt === right.createdAt ? right.id.localeCompare(left.id) : right.createdAt.localeCompare(left.createdAt)));
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function reportsByTarget(reports: ReportRow[]): Map<string, ReportRow[]> {
|
|
116
|
+
const grouped = new Map<string, ReportRow[]>();
|
|
117
|
+
for (const report of reports) {
|
|
118
|
+
if (report.state !== "open") continue;
|
|
119
|
+
grouped.set(report.target, [...(grouped.get(report.target) ?? []), report]);
|
|
120
|
+
}
|
|
121
|
+
return grouped;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function submissionRow(row: SubmissionRow, handles: Record<string, string>, reports: Map<string, ReportRow[]>): ModerationRow {
|
|
125
|
+
return {
|
|
126
|
+
kind: "submission",
|
|
127
|
+
id: row.id,
|
|
128
|
+
readerId: row.readerId,
|
|
129
|
+
handle: handles[row.readerId] ?? null,
|
|
130
|
+
createdAt: row.createdAt,
|
|
131
|
+
state: row.state,
|
|
132
|
+
title: row.title,
|
|
133
|
+
url: safeHttpUrl(row.canonicalUrl),
|
|
134
|
+
text: row.note ?? "",
|
|
135
|
+
subject: row.itemId ?? row.id,
|
|
136
|
+
reports: reports.get(row.id) ?? [],
|
|
137
|
+
signals: parseSignals(row.signals),
|
|
138
|
+
canPromote: row.state === "pending",
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function commentRow(row: CommentRow, handles: Record<string, string>, reports: Map<string, ReportRow[]>): ModerationRow {
|
|
143
|
+
return {
|
|
144
|
+
kind: "comment",
|
|
145
|
+
id: row.id,
|
|
146
|
+
readerId: row.readerId,
|
|
147
|
+
handle: handles[row.readerId] ?? null,
|
|
148
|
+
createdAt: row.createdAt,
|
|
149
|
+
state: row.state,
|
|
150
|
+
title: "",
|
|
151
|
+
url: null,
|
|
152
|
+
text: row.body,
|
|
153
|
+
subject: row.subjectKey,
|
|
154
|
+
reports: reports.get(row.id) ?? [],
|
|
155
|
+
signals: parseSignals(row.signals),
|
|
156
|
+
canPromote: false,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Everything waiting for a decision: new items and reported ones, newest first. Deleted comments are the author's own and are not in it. */
|
|
161
|
+
export function buildQueue(input: { submissions: SubmissionRow[]; comments: CommentRow[]; reports: ReportRow[]; handles: Record<string, string> }): ModerationRow[] {
|
|
162
|
+
const reports = reportsByTarget(input.reports);
|
|
163
|
+
const rows = [
|
|
164
|
+
...input.submissions.filter((row) => (row.moderation ?? "new") === "new").map((row) => submissionRow(row, input.handles, reports)),
|
|
165
|
+
...input.comments.filter((row) => (row.moderation ?? "new") === "new" && row.state !== "deleted").map((row) => commentRow(row, input.handles, reports)),
|
|
166
|
+
];
|
|
167
|
+
return newestFirst(rows);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Items a moderator or a pre-moderation hold keeps out of the public files, newest first, so they can be brought back. */
|
|
171
|
+
export function buildHidden(input: { submissions: SubmissionRow[]; comments: CommentRow[]; reports: ReportRow[]; handles: Record<string, string> }): ModerationRow[] {
|
|
172
|
+
const reports = reportsByTarget(input.reports);
|
|
173
|
+
return newestFirst([
|
|
174
|
+
...input.submissions.filter((row) => row.state === "hidden").map((row) => submissionRow(row, input.handles, reports)),
|
|
175
|
+
...input.comments.filter((row) => row.state === "hidden").map((row) => commentRow(row, input.handles, reports)),
|
|
176
|
+
]);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const ACTION_LABELS: Record<string, string> = {
|
|
180
|
+
approve: "Approved",
|
|
181
|
+
hide: "Hidden",
|
|
182
|
+
unhide: "Shown again",
|
|
183
|
+
ban: "Banned",
|
|
184
|
+
unban: "Unbanned",
|
|
185
|
+
limit: "Limited",
|
|
186
|
+
promote: "Promoted to a draft",
|
|
187
|
+
undo: "Undid an action",
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
/** The recent log, newest first. An action can be undone while nothing has undone it, and a promotion never. */
|
|
191
|
+
export function buildActionLog(actions: ActionRow[], limit = 50): ActionEntry[] {
|
|
192
|
+
return [...actions]
|
|
193
|
+
.sort((left, right) => (left.at === right.at ? right.id.localeCompare(left.id) : right.at.localeCompare(left.at)))
|
|
194
|
+
.slice(0, limit)
|
|
195
|
+
.map((entry) => ({
|
|
196
|
+
...entry,
|
|
197
|
+
label: ACTION_LABELS[entry.action] ?? entry.action,
|
|
198
|
+
byLabel: entry.by === "ai" ? "AI moderator" : "A moderator",
|
|
199
|
+
undoable: !entry.undoneBy && entry.action !== "promote",
|
|
200
|
+
}));
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export type Receipt = { status?: string | null; id?: string | null; error?: string | null; message?: string | null };
|
|
204
|
+
|
|
205
|
+
export function receiptMessage(receipt: Receipt | null | undefined): string {
|
|
206
|
+
if (!receipt) return "The action did not answer. Reload and check.";
|
|
207
|
+
if (receipt.status === "ok") return "Done.";
|
|
208
|
+
return receipt.message || "The action was refused.";
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export function moderationModeText(mode: unknown): string {
|
|
212
|
+
if (mode === "off") return "Moderation is off for this site: nothing waits in a queue. You can still hide content and ban readers.";
|
|
213
|
+
if (mode === "human") return "New submissions and comments are public at once and wait here for a decision, unless this site holds them until approved.";
|
|
214
|
+
if (mode === "ai") return "An AI moderator reads each new submission and comment and allows or hides it, with a reason in the log. Its decisions are public. You can undo any of them, and what you decide it never judges again.";
|
|
215
|
+
return "Moderation is on.";
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
export function shortReader(readerId: string, handle: string | null): string {
|
|
219
|
+
return handle ? `${handle} (${readerId.slice(0, 8)})` : `reader ${readerId.slice(0, 8)}`;
|
|
220
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { NextResponse, type NextRequest } from "next/server";
|
|
2
|
+
import { GetParameterCommand, SSMClient } from "@aws-sdk/client-ssm";
|
|
3
|
+
import { SiteOriginNotConfiguredError, publicSiteOrigin, siteOriginNotConfiguredResponse } from "./site-origin";
|
|
4
|
+
import { resolveProspectusSignInRuntime, type ProspectusSignInRuntime } from "./prospectus-session";
|
|
5
|
+
import { ProspectusAccountsError } from "./prospectus-site-identity";
|
|
6
|
+
import { secretsMatch } from "./revalidate-secret";
|
|
7
|
+
|
|
8
|
+
export const READER_POLL_SECRET_PARAMETER_ENV = "PAPYRUS_READER_POLL_SECRET_PARAMETER";
|
|
9
|
+
export const READER_POLL_SECRET_HEADER = "x-papyrus-reader-poll-secret";
|
|
10
|
+
export const READER_POLL_SECRET_CACHE_MILLISECONDS = 5 * 60 * 1000;
|
|
11
|
+
const CURSOR_PATTERN = /^[A-Za-z0-9._~=+/-]{1,512}$/;
|
|
12
|
+
const NO_STORE_HEADERS = { "cache-control": "no-store" };
|
|
13
|
+
|
|
14
|
+
export interface ReaderRetirementRouteOptions {
|
|
15
|
+
environment?: Record<string, string | undefined>;
|
|
16
|
+
readSecret?: (parameterName: string) => Promise<string | null>;
|
|
17
|
+
resolveRuntime?: (siteOrigin: string) => Promise<Pick<ProspectusSignInRuntime, "accounts"> | null>;
|
|
18
|
+
now?: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
type CachedSecret = { parameterName: string; value: string; expiresAt: number };
|
|
22
|
+
let cachedSecret: CachedSecret | null = null;
|
|
23
|
+
let defaultSsmClient: SSMClient | null = null;
|
|
24
|
+
|
|
25
|
+
export function resetReaderPollSecretCache(): void {
|
|
26
|
+
cachedSecret = null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function readSecretFromSsm(parameterName: string): Promise<string | null> {
|
|
30
|
+
defaultSsmClient ??= new SSMClient({});
|
|
31
|
+
const response = await defaultSsmClient.send(new GetParameterCommand({ Name: parameterName, WithDecryption: true }));
|
|
32
|
+
return response.Parameter?.Value?.trim() || null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function loadReaderPollSecret(options: ReaderRetirementRouteOptions, environment: Record<string, string | undefined>): Promise<string | null> {
|
|
36
|
+
const parameterName = environment[READER_POLL_SECRET_PARAMETER_ENV]?.trim();
|
|
37
|
+
if (!parameterName) return null;
|
|
38
|
+
const now = options.now ?? Date.now();
|
|
39
|
+
if (cachedSecret && cachedSecret.parameterName === parameterName && cachedSecret.expiresAt > now) return cachedSecret.value;
|
|
40
|
+
try {
|
|
41
|
+
const value = (await (options.readSecret ?? readSecretFromSsm)(parameterName))?.trim();
|
|
42
|
+
if (!value) return null;
|
|
43
|
+
cachedSecret = { parameterName, value, expiresAt: now + READER_POLL_SECRET_CACHE_MILLISECONDS };
|
|
44
|
+
return value;
|
|
45
|
+
} catch (error) {
|
|
46
|
+
console.error(`Reader poll secret could not be read (${error instanceof Error ? error.name : "error"}).`);
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function failure(status: number, error: string, message: string): NextResponse {
|
|
52
|
+
return NextResponse.json({ error, message }, { status, headers: NO_STORE_HEADERS });
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The scheduled snapshot run's way to Prospectus's retired-subject route. Prospectus admits one caller per site, this
|
|
57
|
+
* app's compute role, so the run cannot call it directly: it calls this route with a shared secret from SSM, and the route
|
|
58
|
+
* calls Prospectus as the compute role and passes the answer through. Anyone without the secret gets 401, and a site
|
|
59
|
+
* without the secret parameter gets 503, so this is never open.
|
|
60
|
+
*/
|
|
61
|
+
export async function handleReaderRetiredSubjects(request: NextRequest, options: ReaderRetirementRouteOptions = {}): Promise<NextResponse> {
|
|
62
|
+
const environment = options.environment ?? process.env;
|
|
63
|
+
const expected = await loadReaderPollSecret(options, environment);
|
|
64
|
+
if (!expected) return failure(503, "reader_poll_unavailable", "The retirement poll is not configured for this site.");
|
|
65
|
+
if (!secretsMatch(request.headers.get(READER_POLL_SECRET_HEADER), expected)) return failure(401, "unauthorized", "Missing or wrong poll secret.");
|
|
66
|
+
|
|
67
|
+
const since = request.nextUrl.searchParams.get("since") ?? undefined;
|
|
68
|
+
const unknownParameters = [...request.nextUrl.searchParams.keys()].filter((key) => key !== "since");
|
|
69
|
+
if (unknownParameters.length > 0 || (since !== undefined && !CURSOR_PATTERN.test(since))) {
|
|
70
|
+
return failure(400, "invalid_since", "Send only a since cursor.");
|
|
71
|
+
}
|
|
72
|
+
let siteOrigin: string;
|
|
73
|
+
try {
|
|
74
|
+
siteOrigin = publicSiteOrigin(request, environment);
|
|
75
|
+
} catch (error) {
|
|
76
|
+
if (error instanceof SiteOriginNotConfiguredError) return siteOriginNotConfiguredResponse(error);
|
|
77
|
+
throw error;
|
|
78
|
+
}
|
|
79
|
+
const runtime = await (options.resolveRuntime ?? ((origin) => resolveProspectusSignInRuntime(origin, { environment })))(siteOrigin);
|
|
80
|
+
if (!runtime) return failure(503, "prospectus_sign_in_unavailable", "Prospectus is not configured for this site.");
|
|
81
|
+
try {
|
|
82
|
+
const page = await runtime.accounts.retiredSiteSubjects(since);
|
|
83
|
+
return NextResponse.json({ retired: page.retired, cursor: page.cursor, more: page.more }, { headers: NO_STORE_HEADERS });
|
|
84
|
+
} catch (error) {
|
|
85
|
+
if (error instanceof ProspectusAccountsError) return failure(error.status === 400 ? 400 : 502, error.code, error.message);
|
|
86
|
+
console.error(`Prospectus retired subjects failed (${error instanceof Error ? error.name : "error"}).`);
|
|
87
|
+
return failure(502, "prospectus_unavailable", "Prospectus could not be reached.");
|
|
88
|
+
}
|
|
89
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# Reader subject
|
|
2
|
+
|
|
3
|
+
`readerSubject(session)` in `reader-subject.ts` is the only place a reader's identity for the reader API is
|
|
4
|
+
decided. It returns the per-site pairwise `siteSubject` (`ss1_` plus 43 base64url characters) that Prospectus
|
|
5
|
+
puts on `person.siteSubject`, or `null`.
|
|
6
|
+
|
|
7
|
+
## Production
|
|
8
|
+
|
|
9
|
+
Only the `siteSubject` the sealed session cookie carries counts. While Prospectus does not return one, the
|
|
10
|
+
accessor returns `null`, every reader route answers `503 reader_identity_unavailable`, and the reader features
|
|
11
|
+
stay off. There is no other flag. The global Prospectus person id is never used as a reader key.
|
|
12
|
+
|
|
13
|
+
## Development and test only
|
|
14
|
+
|
|
15
|
+
When `SITE_ENV` is `development` or `test`, `NODE_ENV` is not `production` and `PAPYRUS_READER_DEV_SECRET`
|
|
16
|
+
holds at least 16 characters, a session without a `siteSubject` gets the interim value
|
|
17
|
+
`ssdev_` plus `base64url(HMAC-SHA-256(devSecret, personId))`.
|
|
18
|
+
|
|
19
|
+
That path never runs in production. No remap from `ssdev_` values to `ss1_` values is ever needed: production
|
|
20
|
+
reader writes stay closed until `siteSubject` is live, so no production row is ever keyed on an interim value.
|
|
21
|
+
The `reader-actions` function also refuses `ssdev_` subjects unless the site was synthesized with a
|
|
22
|
+
development or test `SITE_ENV`.
|
|
23
|
+
|
|
24
|
+
## Staff
|
|
25
|
+
|
|
26
|
+
`readerIsStaff(session)` reads the `cognito:groups` claim from the access token inside the sealed session
|
|
27
|
+
cookie (`editor` or `admin`). The cookie is written by the site's own callback route, so the token is trusted
|
|
28
|
+
the way the rest of the session is. The reader API passes the result to the function as `isStaff`; clients never
|
|
29
|
+
send it.
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { createHmac } from "node:crypto";
|
|
2
|
+
import type { ProspectusSignedInSession } from "./prospectus-session";
|
|
3
|
+
|
|
4
|
+
export const SITE_SUBJECT_PATTERN = /^ss1_[A-Za-z0-9_-]{43}$/;
|
|
5
|
+
export const INTERIM_SUBJECT_PATTERN = /^ssdev_[A-Za-z0-9_-]{43}$/;
|
|
6
|
+
export const READER_DEV_SECRET_ENV = "PAPYRUS_READER_DEV_SECRET";
|
|
7
|
+
export const READER_STAFF_GROUPS = ["editor", "admin"] as const;
|
|
8
|
+
|
|
9
|
+
const MINIMUM_DEV_SECRET_LENGTH = 16;
|
|
10
|
+
const INTERIM_SUBJECT_PREFIX = "ssdev_";
|
|
11
|
+
const INTERIM_ENVIRONMENTS = ["development", "test"];
|
|
12
|
+
|
|
13
|
+
type EnvironmentVariables = Record<string, string | undefined>;
|
|
14
|
+
|
|
15
|
+
export function interimSubjectsAllowed(environment: EnvironmentVariables = process.env): boolean {
|
|
16
|
+
if (environment.NODE_ENV === "production") return false;
|
|
17
|
+
const siteEnvironment = (environment.SITE_ENV ?? "").trim();
|
|
18
|
+
if (!INTERIM_ENVIRONMENTS.includes(siteEnvironment)) return false;
|
|
19
|
+
return (environment[READER_DEV_SECRET_ENV] ?? "").length >= MINIMUM_DEV_SECRET_LENGTH;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function interimSubject(personId: string, devSecret: string): string {
|
|
23
|
+
return INTERIM_SUBJECT_PREFIX + createHmac("sha256", devSecret).update(personId).digest("base64url");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The reader's per-site pairwise id, or null. Production reads only the siteSubject Prospectus returned;
|
|
28
|
+
* the interim `ssdev_` value exists in development and test only (lib/reader-subject.md).
|
|
29
|
+
*/
|
|
30
|
+
export function readerSubject(
|
|
31
|
+
session: Pick<ProspectusSignedInSession, "personId" | "siteSubject"> | null | undefined,
|
|
32
|
+
environment: EnvironmentVariables = process.env,
|
|
33
|
+
): string | null {
|
|
34
|
+
if (!session) return null;
|
|
35
|
+
if (typeof session.siteSubject === "string" && SITE_SUBJECT_PATTERN.test(session.siteSubject)) return session.siteSubject;
|
|
36
|
+
if (!interimSubjectsAllowed(environment) || !session.personId) return null;
|
|
37
|
+
return interimSubject(session.personId, environment[READER_DEV_SECRET_ENV] as string);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Staff membership from the Cognito groups claim of the access token that the sealed session cookie carries. */
|
|
41
|
+
export function readerIsStaff(session: Pick<ProspectusSignedInSession, "accessToken"> | null | undefined): boolean {
|
|
42
|
+
const token = session?.accessToken;
|
|
43
|
+
if (typeof token !== "string") return false;
|
|
44
|
+
const payload = token.split(".")[1];
|
|
45
|
+
if (!payload) return false;
|
|
46
|
+
try {
|
|
47
|
+
const claims = JSON.parse(Buffer.from(payload, "base64url").toString("utf8")) as { "cognito:groups"?: unknown };
|
|
48
|
+
const groups = claims["cognito:groups"];
|
|
49
|
+
return Array.isArray(groups) && groups.some((group) => (READER_STAFF_GROUPS as readonly unknown[]).includes(group));
|
|
50
|
+
} catch {
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
}
|
package/lib/relevance-status.ts
CHANGED
|
@@ -52,10 +52,10 @@ export const DEMO_CYCLOTRON_STATUS: CyclotronStatus = {
|
|
|
52
52
|
},
|
|
53
53
|
calibration: { saysSure: null, isRight: null, gapPoints: null, curve: [] },
|
|
54
54
|
reviewRate: {
|
|
55
|
-
state: "onboarding", rate: 1, reason: "
|
|
55
|
+
state: "onboarding", rate: 1, reason: "Getting started: every suggestion is reviewed until suggestions earn a lower review rate.",
|
|
56
56
|
auditFloor: 0.05, confidenceThreshold: 0.7, override: null,
|
|
57
57
|
nextCheckAfterDecisions: 99,
|
|
58
|
-
nextStep: "Steps down to 50% after 2 more windows of 100 decisions with accuracy on confident decisions of at least 85% (at least 20 reviewed) and a
|
|
58
|
+
nextStep: "Steps down to 50% after 2 more windows of 100 decisions with accuracy on confident decisions of at least 85% (at least 20 reviewed) and a gap under 5 points between how sure suggestions say they are and how often they are right. Next check in 99 decisions.",
|
|
59
59
|
expectedReviewsPerWeek: 1,
|
|
60
60
|
},
|
|
61
61
|
lastChange: null,
|
package/lib/site-brand-tenant.ts
CHANGED
|
@@ -9,14 +9,19 @@ export function resolveBrandConfigPath(relativePath: string): string {
|
|
|
9
9
|
return path.isAbsolute(relativePath) ? relativePath : path.join(process.cwd(), relativePath);
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
-
|
|
13
|
-
|
|
12
|
+
/** Optional repo files a brand may still name; undefined means the site keeps that configuration as backend data and nothing is looked up. */
|
|
13
|
+
function optionalBrandConfigPath(relativePath: string | undefined): string | undefined {
|
|
14
|
+
return relativePath ? resolveBrandConfigPath(relativePath) : undefined;
|
|
14
15
|
}
|
|
15
16
|
|
|
16
|
-
export function
|
|
17
|
-
return
|
|
17
|
+
export function getBrandSteeringConfigPath(brand: SiteBrand = resolveActiveSiteBrand()): string | undefined {
|
|
18
|
+
return optionalBrandConfigPath(brand.steeringConfigPath);
|
|
18
19
|
}
|
|
19
20
|
|
|
20
|
-
export function
|
|
21
|
-
return
|
|
21
|
+
export function getBrandNewsroomSectionsConfigPath(brand: SiteBrand = resolveActiveSiteBrand()): string | undefined {
|
|
22
|
+
return optionalBrandConfigPath(brand.newsroomSectionsConfigPath);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function getBrandAnalysisProfilesPath(brand: SiteBrand = resolveActiveSiteBrand()): string | undefined {
|
|
26
|
+
return optionalBrandConfigPath(brand.analysisProfilesPath);
|
|
22
27
|
}
|
package/lib/site-brand.ts
CHANGED
|
@@ -142,9 +142,12 @@ export type SiteBrand = {
|
|
|
142
142
|
readerBasePath?: string;
|
|
143
143
|
opsChrome: OpsChrome;
|
|
144
144
|
corpusKey: string;
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
145
|
+
/** Optional repo file for topic steering. Editorial and steering configuration is backend data; a data-driven site sets none and nothing is looked up. */
|
|
146
|
+
steeringConfigPath?: string;
|
|
147
|
+
/** Optional repo file for the local-preview demo sections only. Sections live in the backend. */
|
|
148
|
+
newsroomSectionsConfigPath?: string;
|
|
149
|
+
/** Optional repo file listing analysis profiles. */
|
|
150
|
+
analysisProfilesPath?: string;
|
|
148
151
|
publicationName: string;
|
|
149
152
|
components?: SiteBrandComponents;
|
|
150
153
|
/**
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { NextResponse, type NextRequest } from "next/server";
|
|
2
|
+
import { getSiteEnv } from "./site-env";
|
|
3
|
+
|
|
4
|
+
type EnvironmentVariables = Record<string, string | undefined>;
|
|
5
|
+
|
|
6
|
+
export const SITE_ORIGIN_ENV = "PAPYRUS_SITE_ORIGIN";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The site's public origin cannot be read from the request on Amplify SSR (WEB_COMPUTE):
|
|
10
|
+
* there `request.nextUrl.origin` is the compute adapter's internal https://localhost:3000.
|
|
11
|
+
* It also must never be derived from Host or X-Forwarded-* headers, which a client can set.
|
|
12
|
+
* The one trusted source is PAPYRUS_SITE_ORIGIN, set by the app shell from the site's host.
|
|
13
|
+
*/
|
|
14
|
+
export class SiteOriginNotConfiguredError extends Error {
|
|
15
|
+
readonly status = 503;
|
|
16
|
+
readonly code = "site_origin_not_configured";
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function siteOriginNotConfiguredResponse(error: SiteOriginNotConfiguredError): NextResponse {
|
|
20
|
+
return NextResponse.json({ error: error.code, message: error.message }, { status: error.status, headers: { "cache-control": "no-store" } });
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function isLoopbackHostname(hostname: string): boolean {
|
|
24
|
+
return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "[::1]";
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function configuredOrigin(value: string): string {
|
|
28
|
+
const invalid = () => new SiteOriginNotConfiguredError(`${SITE_ORIGIN_ENV} must be an https origin such as https://example.com with no path.`);
|
|
29
|
+
let parsed: URL;
|
|
30
|
+
try {
|
|
31
|
+
parsed = new URL(value);
|
|
32
|
+
} catch {
|
|
33
|
+
throw invalid();
|
|
34
|
+
}
|
|
35
|
+
if (parsed.protocol !== "https:" || parsed.origin !== value) throw invalid();
|
|
36
|
+
return parsed.origin;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function siteEnvOrNull(environment: EnvironmentVariables): string | null {
|
|
40
|
+
try {
|
|
41
|
+
return getSiteEnv(environment);
|
|
42
|
+
} catch {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The site's public origin, from trusted configuration.
|
|
49
|
+
* - PAPYRUS_SITE_ORIGIN set: it must be an https origin with no path; it is used as is.
|
|
50
|
+
* - Unset: `request.nextUrl.origin` only when it is loopback, SITE_ENV resolves to development
|
|
51
|
+
* and NODE_ENV is not production (a local `next dev` server). Anything else, production and staging included, throws
|
|
52
|
+
* SiteOriginNotConfiguredError so routes answer 503 instead of redirecting to localhost.
|
|
53
|
+
*/
|
|
54
|
+
export function publicSiteOrigin(request: Pick<NextRequest, "nextUrl">, environment: EnvironmentVariables = process.env): string {
|
|
55
|
+
const configured = (environment[SITE_ORIGIN_ENV] ?? "").trim();
|
|
56
|
+
if (configured) return configuredOrigin(configured);
|
|
57
|
+
const { hostname, origin } = request.nextUrl;
|
|
58
|
+
// SITE_ENV defaults to development when a runtime never received it, so NODE_ENV=production also closes the fallback.
|
|
59
|
+
const developing = siteEnvOrNull(environment) === "development" && environment.NODE_ENV !== "production";
|
|
60
|
+
if (isLoopbackHostname(hostname) && developing) return origin;
|
|
61
|
+
throw new SiteOriginNotConfiguredError(`${SITE_ORIGIN_ENV} is not set; this site cannot tell its public origin.`);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** An absolute URL on the site's public origin, for redirects. Fails closed like publicSiteOrigin. */
|
|
65
|
+
export function publicSiteUrl(path: string, request: Pick<NextRequest, "nextUrl">, environment: EnvironmentVariables = process.env): URL {
|
|
66
|
+
return new URL(path, publicSiteOrigin(request, environment));
|
|
67
|
+
}
|
package/middleware.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { newsroomHref } from "./lib/newsroom-base-path";
|
|
|
5
5
|
import { getReaderBasePath } from "./lib/reader-base-path";
|
|
6
6
|
import { isStagingGatedPath, isStaticOrApiPath } from "./lib/staging-gated-path";
|
|
7
7
|
import { getSiteEnv, isIndexable } from "./lib/site-env";
|
|
8
|
+
import { SiteOriginNotConfiguredError, publicSiteUrl, siteOriginNotConfiguredResponse } from "./lib/site-origin";
|
|
8
9
|
import { getStagingAccess } from "./lib/staging-gate";
|
|
9
10
|
import {
|
|
10
11
|
isPreviewGatedPath,
|
|
@@ -38,11 +39,11 @@ function routeRequest(request: NextRequest): NextResponse {
|
|
|
38
39
|
}
|
|
39
40
|
|
|
40
41
|
if (pathname === "/newsroom" || pathname === "/newsroom/") {
|
|
41
|
-
return NextResponse.redirect(
|
|
42
|
+
return NextResponse.redirect(publicSiteUrl(`/${request.nextUrl.search}`, request));
|
|
42
43
|
}
|
|
43
44
|
if (pathname.startsWith("/newsroom/")) {
|
|
44
45
|
const target = pathname.slice("/newsroom".length) || "/";
|
|
45
|
-
return NextResponse.redirect(
|
|
46
|
+
return NextResponse.redirect(publicSiteUrl(`${target}${request.nextUrl.search}`, request));
|
|
46
47
|
}
|
|
47
48
|
|
|
48
49
|
if (pathname === "/" || pathname === "") {
|
|
@@ -59,6 +60,16 @@ function routeRequest(request: NextRequest): NextResponse {
|
|
|
59
60
|
}
|
|
60
61
|
|
|
61
62
|
export async function middleware(request: NextRequest): Promise<NextResponse> {
|
|
63
|
+
try {
|
|
64
|
+
return await handleRequest(request);
|
|
65
|
+
} catch (error) {
|
|
66
|
+
// Redirects name the site's public origin (request.url is localhost on Amplify); without it, fail closed.
|
|
67
|
+
if (error instanceof SiteOriginNotConfiguredError) return withRobotsHeader(siteOriginNotConfiguredResponse(error));
|
|
68
|
+
throw error;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function handleRequest(request: NextRequest): Promise<NextResponse> {
|
|
62
73
|
let sessionResponse: NextResponse | null = null;
|
|
63
74
|
const { pathname } = request.nextUrl;
|
|
64
75
|
const gated = isStaticPreviewEnabled()
|
|
@@ -68,7 +79,7 @@ export async function middleware(request: NextRequest): Promise<NextResponse> {
|
|
|
68
79
|
sessionResponse = NextResponse.next();
|
|
69
80
|
const access = await getStagingAccess(request, sessionResponse);
|
|
70
81
|
if (access === "anonymous") {
|
|
71
|
-
return withRobotsHeader(NextResponse.redirect(
|
|
82
|
+
return withRobotsHeader(NextResponse.redirect(publicSiteUrl(newsroomHref(), request)));
|
|
72
83
|
}
|
|
73
84
|
if (access === "forbidden") {
|
|
74
85
|
return withRobotsHeader(new NextResponse("Staging is limited to editors and admins.", { status: 403 }));
|