@anthusai/papyrus 5.19.1 → 5.21.0
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 +1 -0
- package/amplify/data/resource.ts +1 -0
- package/amplify/data/schema.js +1 -0
- package/amplify/data/schema.ts +1 -0
- package/amplify/functions/assignment-action/data-access.js +2 -0
- package/amplify/functions/assignment-action/data-access.ts +2 -0
- package/amplify/functions/assignment-action/handler.ts +1 -1
- package/amplify/functions/content-actions/handler.py +20 -2
- package/amplify/functions/shared/editorial-context.ts +63 -4
- 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 -0
- package/amplify/plugins/registry.ts +4 -0
- package/amplify/plugins/types.ts +5 -0
- package/amplify/plugins/wire.js +1 -0
- package/amplify/plugins/wire.ts +1 -0
- 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/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/components/moderation-panel.tsx +248 -0
- package/components/newsletter-panel.tsx +474 -0
- package/components/newsletter-sections.tsx +163 -0
- package/components/newsroom-organizer.css +14 -0
- package/components/reference-cyclotron-priming.tsx +54 -15
- package/components/topic-steering-workspace.tsx +42 -5
- package/infra/amplify-app-shell.js +15 -1
- package/infra/amplify-app-shell.ts +17 -1
- package/infra/site-config.js +12 -2
- package/infra/site-config.ts +19 -2
- package/lib/editorial-checks.ts +6 -2
- 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/prospectus-session.ts +1 -0
- package/lib/prospectus-sign-in-routes.ts +2 -2
- 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/package.json +27 -1
- package/routes.manifest.json +158 -1
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { NextResponse, type NextRequest } from "next/server";
|
|
2
|
+
import { readAmplifyOutputsFile } from "./amplify-outputs-path";
|
|
3
|
+
import { loadProspectusSessionSealingSecret, type ProspectusSignedInSession } from "./prospectus-session";
|
|
4
|
+
import { signedInProspectusSession } from "./prospectus-sign-in-routes";
|
|
5
|
+
import { readerIsStaff, readerSubject } from "./reader-subject";
|
|
6
|
+
import { SiteOriginNotConfiguredError, publicSiteOrigin, siteOriginNotConfiguredResponse } from "./site-origin";
|
|
7
|
+
|
|
8
|
+
export const READER_ACTIONS_FUNCTION_ENV = "PAPYRUS_READER_ACTIONS_FUNCTION";
|
|
9
|
+
export const READER_MAXIMUM_BODY_BYTES = 4096;
|
|
10
|
+
/** Larger bodies for actions that carry reader text: a URL, a title and a note; a comment of 2,000 characters. */
|
|
11
|
+
export const READER_BODY_BYTES_BY_ACTION: Partial<Record<ReaderAction, number>> = { submit: 8192, comment: 12288, "comment-edit": 12288 };
|
|
12
|
+
export const READER_MAXIMUM_SUBJECTS = 50;
|
|
13
|
+
export const READER_SUBJECT_KEY = /^[A-Za-z0-9._:-]{1,200}$/;
|
|
14
|
+
|
|
15
|
+
export type ReaderAction = "me" | "handle" | "vote" | "rating" | "submit" | "comment" | "comment-edit" | "comment-delete" | "report";
|
|
16
|
+
|
|
17
|
+
export interface ReaderActionEvent {
|
|
18
|
+
publication: string;
|
|
19
|
+
siteSubject: string;
|
|
20
|
+
isStaff: boolean;
|
|
21
|
+
action: ReaderAction;
|
|
22
|
+
payload: Record<string, unknown>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface ReaderActionResult {
|
|
26
|
+
statusCode: number;
|
|
27
|
+
body: Record<string, unknown>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export type ReaderActionInvoker = (functionName: string, event: ReaderActionEvent) => Promise<ReaderActionResult>;
|
|
31
|
+
|
|
32
|
+
export interface ReaderApiOptions {
|
|
33
|
+
environment?: Record<string, string | undefined>;
|
|
34
|
+
invoke?: ReaderActionInvoker;
|
|
35
|
+
actionsFunctionName?: string | null;
|
|
36
|
+
loadSealingSecret?: () => Promise<string | null>;
|
|
37
|
+
nowInSeconds?: number;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const NO_STORE_HEADERS = { "cache-control": "no-store" };
|
|
41
|
+
|
|
42
|
+
function failure(status: number, error: string, message: string, extra: Record<string, unknown> = {}, headers: Record<string, string> = {}): NextResponse {
|
|
43
|
+
return NextResponse.json({ error, message, ...extra }, { status, headers: { ...NO_STORE_HEADERS, ...headers } });
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function identityUnavailableResponse(): NextResponse {
|
|
47
|
+
return failure(503, "reader_identity_unavailable", "Reader sign-in is not open yet.");
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function actionsFunctionNameFrom(options: ReaderApiOptions, environment: Record<string, string | undefined>): string | null {
|
|
51
|
+
if (options.actionsFunctionName !== undefined) return options.actionsFunctionName;
|
|
52
|
+
const fromEnvironment = (environment[READER_ACTIONS_FUNCTION_ENV] ?? "").trim();
|
|
53
|
+
if (fromEnvironment) return fromEnvironment;
|
|
54
|
+
const outputs = readAmplifyOutputsFile() as { custom?: { readerSteering?: { actionsFunction?: unknown } | null } } | null;
|
|
55
|
+
const fromOutputs = outputs?.custom?.readerSteering?.actionsFunction;
|
|
56
|
+
return typeof fromOutputs === "string" && fromOutputs.trim() ? fromOutputs.trim() : null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export async function invokeReaderActionsLambda(functionName: string, event: ReaderActionEvent): Promise<ReaderActionResult> {
|
|
60
|
+
const { InvokeCommand, LambdaClient } = await import("@aws-sdk/client-lambda");
|
|
61
|
+
const response = await new LambdaClient({}).send(
|
|
62
|
+
new InvokeCommand({ FunctionName: functionName, InvocationType: "RequestResponse", Payload: Buffer.from(JSON.stringify(event)) }),
|
|
63
|
+
);
|
|
64
|
+
if (response.FunctionError || !response.Payload) throw new Error("reader-actions failed");
|
|
65
|
+
const result = JSON.parse(Buffer.from(response.Payload).toString("utf8")) as Partial<ReaderActionResult>;
|
|
66
|
+
if (typeof result.statusCode !== "number" || typeof result.body !== "object" || result.body === null) throw new Error("reader-actions returned an unexpected shape");
|
|
67
|
+
return { statusCode: result.statusCode, body: result.body as Record<string, unknown> };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function readJsonBody(request: NextRequest, maximumBytes: number): Promise<{ payload: Record<string, unknown> } | { response: NextResponse }> {
|
|
71
|
+
const contentType = (request.headers.get("content-type") ?? "").split(";")[0].trim().toLowerCase();
|
|
72
|
+
if (contentType !== "application/json") return { response: failure(415, "unsupported_media_type", "Send application/json.") };
|
|
73
|
+
const declared = Number(request.headers.get("content-length") ?? "0");
|
|
74
|
+
if (Number.isFinite(declared) && declared > maximumBytes) return { response: failure(413, "payload_too_large", "The request body is too large.") };
|
|
75
|
+
const text = await request.text();
|
|
76
|
+
if (Buffer.byteLength(text, "utf8") > maximumBytes) return { response: failure(413, "payload_too_large", "The request body is too large.") };
|
|
77
|
+
try {
|
|
78
|
+
const parsed = JSON.parse(text) as unknown;
|
|
79
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw new Error("not an object");
|
|
80
|
+
return { payload: parsed as Record<string, unknown> };
|
|
81
|
+
} catch {
|
|
82
|
+
return { response: failure(400, "invalid_json", "The request body is not a JSON object.") };
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function subjectsFromQuery(request: NextRequest): { payload: Record<string, unknown> } | { response: NextResponse } {
|
|
87
|
+
const raw = request.nextUrl.searchParams.get("subjects") ?? "";
|
|
88
|
+
const subjects = raw === "" ? [] : raw.split(",");
|
|
89
|
+
if (subjects.length > READER_MAXIMUM_SUBJECTS || !subjects.every((subject) => READER_SUBJECT_KEY.test(subject))) {
|
|
90
|
+
return { response: failure(400, "invalid_subjects", `Send at most ${READER_MAXIMUM_SUBJECTS} comma separated item keys.`) };
|
|
91
|
+
}
|
|
92
|
+
return { payload: { subjects } };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async function signedInReaderSession(request: NextRequest, options: ReaderApiOptions): Promise<ProspectusSignedInSession | null | "unavailable"> {
|
|
96
|
+
const sealingSecret = await (options.loadSealingSecret ?? (() => loadProspectusSessionSealingSecret({ environment: options.environment })))();
|
|
97
|
+
if (!sealingSecret) return "unavailable";
|
|
98
|
+
return signedInProspectusSession(request, sealingSecret, options.nowInSeconds);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* One reader route: session, readerSubject (503 while Prospectus has no siteSubject), Origin, JSON size, then the
|
|
103
|
+
* reader-actions function. The siteSubject only travels to the function and never into a response.
|
|
104
|
+
*/
|
|
105
|
+
export async function handleReaderRequest(request: NextRequest, action: ReaderAction, options: ReaderApiOptions = {}): Promise<NextResponse> {
|
|
106
|
+
const environment = options.environment ?? process.env;
|
|
107
|
+
let origin: string;
|
|
108
|
+
try {
|
|
109
|
+
origin = publicSiteOrigin(request, environment);
|
|
110
|
+
} catch (error) {
|
|
111
|
+
if (error instanceof SiteOriginNotConfiguredError) return siteOriginNotConfiguredResponse(error);
|
|
112
|
+
throw error;
|
|
113
|
+
}
|
|
114
|
+
const session = await signedInReaderSession(request, options);
|
|
115
|
+
if (session === "unavailable") return identityUnavailableResponse();
|
|
116
|
+
if (!session) return failure(401, "signed_out", "Sign in to continue.");
|
|
117
|
+
const siteSubject = readerSubject(session, environment);
|
|
118
|
+
if (siteSubject === null) return identityUnavailableResponse();
|
|
119
|
+
|
|
120
|
+
let parsed: { payload: Record<string, unknown> } | { response: NextResponse };
|
|
121
|
+
if (action === "me") {
|
|
122
|
+
parsed = subjectsFromQuery(request);
|
|
123
|
+
} else {
|
|
124
|
+
if (request.headers.get("origin") !== origin) return failure(403, "origin_mismatch", "This request did not come from this site.");
|
|
125
|
+
parsed = await readJsonBody(request, READER_BODY_BYTES_BY_ACTION[action] ?? READER_MAXIMUM_BODY_BYTES);
|
|
126
|
+
}
|
|
127
|
+
if ("response" in parsed) return parsed.response;
|
|
128
|
+
|
|
129
|
+
const functionName = actionsFunctionNameFrom(options, environment);
|
|
130
|
+
if (!functionName) return failure(503, "reader_actions_unavailable", "Reader features are not enabled for this site.");
|
|
131
|
+
try {
|
|
132
|
+
const result = await (options.invoke ?? invokeReaderActionsLambda)(functionName, {
|
|
133
|
+
publication: new URL(origin).host,
|
|
134
|
+
siteSubject,
|
|
135
|
+
isStaff: readerIsStaff(session),
|
|
136
|
+
action,
|
|
137
|
+
payload: parsed.payload,
|
|
138
|
+
});
|
|
139
|
+
const retryAfter: Record<string, string> = typeof result.body.retryAfter === "number" ? { "retry-after": String(result.body.retryAfter) } : {};
|
|
140
|
+
return NextResponse.json(result.body, { status: result.statusCode, headers: { ...NO_STORE_HEADERS, ...retryAfter } });
|
|
141
|
+
} catch (error) {
|
|
142
|
+
console.error(`Reader action ${action} failed (${error instanceof Error ? error.name : "error"}).`);
|
|
143
|
+
return failure(502, "reader_actions_failed", "Reader features could not be reached.");
|
|
144
|
+
}
|
|
145
|
+
}
|
|
@@ -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/package.json
CHANGED
|
@@ -85,11 +85,14 @@
|
|
|
85
85
|
"./components/item-stream-view": "./components/item-stream-view.tsx",
|
|
86
86
|
"./components/markus-body-editor": "./components/markus-body-editor.tsx",
|
|
87
87
|
"./components/markus-ir-preview": "./components/markus-ir-preview.tsx",
|
|
88
|
+
"./components/moderation-panel": "./components/moderation-panel.tsx",
|
|
88
89
|
"./components/monitoring-overview-view": "./components/monitoring-overview-view.tsx",
|
|
89
90
|
"./components/monitoring-panel": "./components/monitoring-panel.tsx",
|
|
90
91
|
"./components/news-desk-client-provider": "./components/news-desk-client-provider.tsx",
|
|
91
92
|
"./components/news-desk-page": "./components/news-desk-page.tsx",
|
|
92
93
|
"./components/news-desk-taxonomy-client": "./components/news-desk-taxonomy-client.ts",
|
|
94
|
+
"./components/newsletter-panel": "./components/newsletter-panel.tsx",
|
|
95
|
+
"./components/newsletter-sections": "./components/newsletter-sections.tsx",
|
|
93
96
|
"./components/newspaper": "./components/newspaper.tsx",
|
|
94
97
|
"./components/newsroom-access-gate": "./components/newsroom-access-gate.tsx",
|
|
95
98
|
"./components/newsroom-article-editor": "./components/newsroom-article-editor.tsx",
|
|
@@ -233,6 +236,11 @@
|
|
|
233
236
|
"./lib/monitoring-overview-fixture": "./lib/monitoring-overview-fixture.ts",
|
|
234
237
|
"./lib/monitoring-overview": "./lib/monitoring-overview.ts",
|
|
235
238
|
"./lib/news-desk-session": "./lib/news-desk-session.ts",
|
|
239
|
+
"./lib/newsletter-api": "./lib/newsletter-api.ts",
|
|
240
|
+
"./lib/newsletter-client": "./lib/newsletter-client.ts",
|
|
241
|
+
"./lib/newsletter-fixture": "./lib/newsletter-fixture.ts",
|
|
242
|
+
"./lib/newsletter-model": "./lib/newsletter-model.ts",
|
|
243
|
+
"./lib/newsletter-pages": "./lib/newsletter-pages.ts",
|
|
236
244
|
"./lib/newspaper-layout": "./lib/newspaper-layout.ts",
|
|
237
245
|
"./lib/newsroom-articles": "./lib/newsroom-articles.ts",
|
|
238
246
|
"./lib/newsroom-assignments": "./lib/newsroom-assignments.ts",
|
|
@@ -273,11 +281,15 @@
|
|
|
273
281
|
"./lib/publication-cyclotron": "./lib/publication-cyclotron.ts",
|
|
274
282
|
"./lib/publication-items": "./lib/publication-items.ts",
|
|
275
283
|
"./lib/rate-limit-retry": "./lib/rate-limit-retry.ts",
|
|
284
|
+
"./lib/reader-api": "./lib/reader-api.ts",
|
|
276
285
|
"./lib/reader-base-path": "./lib/reader-base-path.ts",
|
|
277
286
|
"./lib/reader-cache-lifetime": "./lib/reader-cache-lifetime.ts",
|
|
287
|
+
"./lib/reader-moderation": "./lib/reader-moderation.ts",
|
|
288
|
+
"./lib/reader-retirement-route": "./lib/reader-retirement-route.ts",
|
|
278
289
|
"./lib/reader-route-config": "./lib/reader-route-config.ts",
|
|
279
290
|
"./lib/reader-static-params": "./lib/reader-static-params.ts",
|
|
280
291
|
"./lib/reader-storage-url": "./lib/reader-storage-url.ts",
|
|
292
|
+
"./lib/reader-subject": "./lib/reader-subject.ts",
|
|
281
293
|
"./lib/reference-display": "./lib/reference-display.ts",
|
|
282
294
|
"./lib/reference-policy": "./lib/reference-policy.ts",
|
|
283
295
|
"./lib/reference-source-preview": "./lib/reference-source-preview.ts",
|
|
@@ -339,9 +351,23 @@
|
|
|
339
351
|
"./app/api/local-preview/derive/route": "./app/api/local-preview/derive/route.ts",
|
|
340
352
|
"./app/api/local-preview/media/route": "./app/api/local-preview/media/route.ts",
|
|
341
353
|
"./app/api/media/[...path]/route": "./app/api/media/[...path]/route.ts",
|
|
354
|
+
"./app/api/newsletter/confirm/route": "./app/api/newsletter/confirm/route.ts",
|
|
355
|
+
"./app/api/newsletter/preferences/route": "./app/api/newsletter/preferences/route.ts",
|
|
356
|
+
"./app/api/newsletter/subscribe/route": "./app/api/newsletter/subscribe/route.ts",
|
|
357
|
+
"./app/api/newsletter/unsubscribe/route": "./app/api/newsletter/unsubscribe/route.ts",
|
|
342
358
|
"./app/api/newsroom/curation/context/route": "./app/api/newsroom/curation/context/route.ts",
|
|
343
359
|
"./app/api/newsroom/curation/priming/route": "./app/api/newsroom/curation/priming/route.ts",
|
|
344
360
|
"./app/api/newsroom/curation/publication/route": "./app/api/newsroom/curation/publication/route.ts",
|
|
361
|
+
"./app/api/reader/comment/delete/route": "./app/api/reader/comment/delete/route.ts",
|
|
362
|
+
"./app/api/reader/comment/edit/route": "./app/api/reader/comment/edit/route.ts",
|
|
363
|
+
"./app/api/reader/comment/route": "./app/api/reader/comment/route.ts",
|
|
364
|
+
"./app/api/reader/handle/route": "./app/api/reader/handle/route.ts",
|
|
365
|
+
"./app/api/reader/me/route": "./app/api/reader/me/route.ts",
|
|
366
|
+
"./app/api/reader/rating/route": "./app/api/reader/rating/route.ts",
|
|
367
|
+
"./app/api/reader/report/route": "./app/api/reader/report/route.ts",
|
|
368
|
+
"./app/api/reader/retired/route": "./app/api/reader/retired/route.ts",
|
|
369
|
+
"./app/api/reader/submit/route": "./app/api/reader/submit/route.ts",
|
|
370
|
+
"./app/api/reader/vote/route": "./app/api/reader/vote/route.ts",
|
|
345
371
|
"./app/api/revalidate/route": "./app/api/revalidate/route.ts",
|
|
346
372
|
"./app/archive/page": "./app/archive/page.tsx",
|
|
347
373
|
"./app/articles/[slug]/page": "./app/articles/[slug]/page.tsx",
|
|
@@ -407,7 +433,7 @@
|
|
|
407
433
|
"optional": true
|
|
408
434
|
}
|
|
409
435
|
},
|
|
410
|
-
"version": "5.
|
|
436
|
+
"version": "5.21.0",
|
|
411
437
|
"dependencies": {
|
|
412
438
|
"@aws-amplify/adapter-nextjs": "^1.7.3",
|
|
413
439
|
"@aws-crypto/sha256-js": "5.2.0",
|