@anthusai/papyrus 2.0.0-next.40 → 2.0.0-next.42
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 +3 -0
- 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 +246 -0
- package/amplify/plugins/newsletter/papyrus.plugin.ts +291 -0
- package/amplify/plugins/newsletter/schema.js +37 -0
- package/amplify/plugins/newsletter/schema.ts +42 -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 +139 -0
- package/amplify/plugins/newsletter/sending.ts +185 -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/reference-cyclotron-priming.tsx +54 -15
- package/components/topic-steering-workspace.tsx +19 -1
- 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-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 +22 -1
- package/routes.manifest.json +158 -1
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { NextRequest } from "next/server";
|
|
2
|
+
import { handleReaderRequest } from "../../../../lib/reader-api";
|
|
3
|
+
|
|
4
|
+
export const dynamic = "force-dynamic";
|
|
5
|
+
export const runtime = "nodejs";
|
|
6
|
+
|
|
7
|
+
export function POST(request: NextRequest) {
|
|
8
|
+
return handleReaderRequest(request, "rating");
|
|
9
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { NextRequest } from "next/server";
|
|
2
|
+
import { handleReaderRequest } from "../../../../lib/reader-api";
|
|
3
|
+
|
|
4
|
+
export const dynamic = "force-dynamic";
|
|
5
|
+
export const runtime = "nodejs";
|
|
6
|
+
|
|
7
|
+
export function POST(request: NextRequest) {
|
|
8
|
+
return handleReaderRequest(request, "report");
|
|
9
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { NextRequest } from "next/server";
|
|
2
|
+
import { handleReaderRetiredSubjects } from "../../../../lib/reader-retirement-route";
|
|
3
|
+
|
|
4
|
+
export const dynamic = "force-dynamic";
|
|
5
|
+
export const runtime = "nodejs";
|
|
6
|
+
|
|
7
|
+
export function GET(request: NextRequest) {
|
|
8
|
+
return handleReaderRetiredSubjects(request);
|
|
9
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { NextRequest } from "next/server";
|
|
2
|
+
import { handleReaderRequest } from "../../../../lib/reader-api";
|
|
3
|
+
|
|
4
|
+
export const dynamic = "force-dynamic";
|
|
5
|
+
export const runtime = "nodejs";
|
|
6
|
+
|
|
7
|
+
export function POST(request: NextRequest) {
|
|
8
|
+
return handleReaderRequest(request, "submit");
|
|
9
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { NextRequest } from "next/server";
|
|
2
|
+
import { handleReaderRequest } from "../../../../lib/reader-api";
|
|
3
|
+
|
|
4
|
+
export const dynamic = "force-dynamic";
|
|
5
|
+
export const runtime = "nodejs";
|
|
6
|
+
|
|
7
|
+
export function POST(request: NextRequest) {
|
|
8
|
+
return handleReaderRequest(request, "vote");
|
|
9
|
+
}
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { generateClient } from "aws-amplify/data";
|
|
4
|
+
import amplifyOutputs from "papyrus-amplify-outputs";
|
|
5
|
+
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
6
|
+
import { pluginSettings, type PluginOutputs } from "../lib/plugin-settings";
|
|
7
|
+
import {
|
|
8
|
+
buildActionLog,
|
|
9
|
+
buildHidden,
|
|
10
|
+
buildQueue,
|
|
11
|
+
moderationModeText,
|
|
12
|
+
receiptMessage,
|
|
13
|
+
shortReader,
|
|
14
|
+
type ActionEntry,
|
|
15
|
+
type ActionRow,
|
|
16
|
+
type CommentRow,
|
|
17
|
+
type ModerationActionName,
|
|
18
|
+
type ModerationRow,
|
|
19
|
+
type ModerationTarget,
|
|
20
|
+
type Receipt,
|
|
21
|
+
type ReportRow,
|
|
22
|
+
type SubmissionRow,
|
|
23
|
+
} from "../lib/reader-moderation";
|
|
24
|
+
|
|
25
|
+
const SUBMISSION_FIELDS = "id canonicalUrl domain title kind note readerId state moderation reportCount signals itemId createdAt";
|
|
26
|
+
const COMMENT_FIELDS = "id subjectKey readerId body parentId state moderation reportCount signals createdAt";
|
|
27
|
+
|
|
28
|
+
const QUEUE_SUBMISSIONS = `query Queue($nextToken: String) { listReaderSubmissionsByModerationAndCreatedAt(moderation: "new", sortDirection: DESC, limit: 100, nextToken: $nextToken) { items { ${SUBMISSION_FIELDS} } nextToken } }`;
|
|
29
|
+
const QUEUE_COMMENTS = `query Queue($nextToken: String) { listReaderCommentsByModerationAndCreatedAt(moderation: "new", sortDirection: DESC, limit: 100, nextToken: $nextToken) { items { ${COMMENT_FIELDS} } nextToken } }`;
|
|
30
|
+
const HIDDEN_SUBMISSIONS = `query Hidden($nextToken: String) { listReaderSubmissions(filter: { state: { eq: "hidden" } }, limit: 500, nextToken: $nextToken) { items { ${SUBMISSION_FIELDS} } nextToken } }`;
|
|
31
|
+
const HIDDEN_COMMENTS = `query Hidden($nextToken: String) { listReaderComments(filter: { state: { eq: "hidden" } }, limit: 500, nextToken: $nextToken) { items { ${COMMENT_FIELDS} } nextToken } }`;
|
|
32
|
+
const OPEN_REPORTS = `query Reports($nextToken: String) { listReaderReports(filter: { state: { eq: "open" } }, limit: 500, nextToken: $nextToken) { items { id targetType target reporterReaderId reason state createdAt } nextToken } }`;
|
|
33
|
+
const ACTIONS = `query Actions($nextToken: String) { listModerationActions(limit: 500, nextToken: $nextToken) { items { id by actorSub action targetType target reason fromState toState undoOf undoneBy at } nextToken } }`;
|
|
34
|
+
const HANDLES = `query Handles($nextToken: String) { listReaderHandles(limit: 1000, nextToken: $nextToken) { items { id readerId } nextToken } }`;
|
|
35
|
+
const MODERATE = `mutation Moderate($action: String!, $targetType: String!, $target: String!, $reason: String, $undoOf: String) { moderateReader(action: $action, targetType: $targetType, target: $target, reason: $reason, undoOf: $undoOf) { status id error message } }`;
|
|
36
|
+
|
|
37
|
+
type GraphResult = { data?: Record<string, { items: unknown[]; nextToken?: string | null } | Receipt | null>; errors?: { message?: string }[] };
|
|
38
|
+
const MAXIMUM_PAGES = 20;
|
|
39
|
+
|
|
40
|
+
async function loadAll<T>(query: string, field: string): Promise<T[]> {
|
|
41
|
+
const client = generateClient({ authMode: "userPool" });
|
|
42
|
+
const rows: T[] = [];
|
|
43
|
+
let nextToken: string | null | undefined;
|
|
44
|
+
for (let page = 0; page < MAXIMUM_PAGES; page += 1) {
|
|
45
|
+
const result = (await client.graphql({ query, variables: { nextToken } })) as GraphResult;
|
|
46
|
+
if (result.errors?.length) throw new Error(result.errors[0]?.message ?? "Could not load moderation data.");
|
|
47
|
+
const connection = result.data?.[field] as { items: T[]; nextToken?: string | null } | undefined;
|
|
48
|
+
rows.push(...(connection?.items ?? []).filter(Boolean));
|
|
49
|
+
nextToken = connection?.nextToken;
|
|
50
|
+
if (!nextToken) break;
|
|
51
|
+
}
|
|
52
|
+
return rows;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function moderate(variables: Record<string, string | undefined>): Promise<Receipt> {
|
|
56
|
+
const client = generateClient({ authMode: "userPool" });
|
|
57
|
+
const result = (await client.graphql({ query: MODERATE, variables })) as GraphResult;
|
|
58
|
+
if (result.errors?.length) return { status: "refused", message: result.errors[0]?.message ?? "The action failed." };
|
|
59
|
+
return (result.data?.moderateReader as Receipt | null) ?? { status: "refused", message: "The action did not answer." };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export type ModerationViewProps = {
|
|
63
|
+
mode: unknown;
|
|
64
|
+
queue: ModerationRow[];
|
|
65
|
+
hidden: ModerationRow[];
|
|
66
|
+
log: ActionEntry[];
|
|
67
|
+
tab: "queue" | "hidden" | "log";
|
|
68
|
+
busy: boolean;
|
|
69
|
+
status: string;
|
|
70
|
+
reason: string;
|
|
71
|
+
onTab: (tab: "queue" | "hidden" | "log") => void;
|
|
72
|
+
onReason: (reason: string) => void;
|
|
73
|
+
onAction: (action: ModerationActionName, targetType: ModerationTarget, target: string, undoOf?: string) => void;
|
|
74
|
+
onRefresh: () => void;
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
function Row({ row, busy, onAction, listed }: { row: ModerationRow; busy: boolean; onAction: ModerationViewProps["onAction"]; listed: "queue" | "hidden" }) {
|
|
78
|
+
const kind = row.kind === "submission" ? "Submission" : "Comment";
|
|
79
|
+
return (
|
|
80
|
+
<li data-moderation-row={row.id}>
|
|
81
|
+
<article aria-label={`${kind} by ${shortReader(row.readerId, row.handle)}`}>
|
|
82
|
+
<p>
|
|
83
|
+
<strong>{kind}</strong> by {shortReader(row.readerId, row.handle)} <time dateTime={row.createdAt}>{row.createdAt}</time> ({row.state}
|
|
84
|
+
{row.reports.length > 0 ? `, ${row.reports.length} ${row.reports.length === 1 ? "report" : "reports"}` : ""})
|
|
85
|
+
</p>
|
|
86
|
+
{row.kind === "submission" ? (
|
|
87
|
+
<p>
|
|
88
|
+
{row.url ? (
|
|
89
|
+
<a href={row.url} target="_blank" rel="noopener noreferrer nofollow">{row.title}</a>
|
|
90
|
+
) : (
|
|
91
|
+
<span>{row.title}</span>
|
|
92
|
+
)}
|
|
93
|
+
</p>
|
|
94
|
+
) : null}
|
|
95
|
+
{row.text ? <p style={{ whiteSpace: "pre-wrap", overflowWrap: "anywhere" }}>{row.text}</p> : null}
|
|
96
|
+
{row.signals.length > 0 ? (
|
|
97
|
+
<ul aria-label="Automatic signals">
|
|
98
|
+
{row.signals.map((signal, index) => (
|
|
99
|
+
<li key={`${signal.detector}-${index}`}>Signal ({signal.severity}): {signal.policy} / {signal.detector}{signal.excerpt ? ` — “${signal.excerpt}”` : ""}</li>
|
|
100
|
+
))}
|
|
101
|
+
</ul>
|
|
102
|
+
) : null}
|
|
103
|
+
{row.reports.length > 0 ? (
|
|
104
|
+
<ul aria-label="Reports">
|
|
105
|
+
{row.reports.map((report) => (
|
|
106
|
+
<li key={report.id}>Reported: {report.reason}</li>
|
|
107
|
+
))}
|
|
108
|
+
</ul>
|
|
109
|
+
) : null}
|
|
110
|
+
<p>
|
|
111
|
+
{listed === "queue" ? (
|
|
112
|
+
<button type="button" disabled={busy} onClick={() => onAction("approve", row.kind, row.id)}>Approve</button>
|
|
113
|
+
) : (
|
|
114
|
+
<button type="button" disabled={busy} onClick={() => onAction("unhide", row.kind, row.id)}>Show again</button>
|
|
115
|
+
)}{" "}
|
|
116
|
+
{row.state !== "hidden" ? <button type="button" disabled={busy} onClick={() => onAction("hide", row.kind, row.id)}>Hide</button> : null}{" "}
|
|
117
|
+
{row.canPromote ? <button type="button" disabled={busy} onClick={() => onAction("promote", row.kind, row.id)}>Promote to a draft</button> : null}{" "}
|
|
118
|
+
<button type="button" disabled={busy} onClick={() => onAction("ban", "reader", row.readerId)}>Ban reader</button>{" "}
|
|
119
|
+
<button type="button" disabled={busy} onClick={() => onAction("limit", "reader", row.readerId)}>Limit reader</button>
|
|
120
|
+
</p>
|
|
121
|
+
</article>
|
|
122
|
+
</li>
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function ModerationView(props: ModerationViewProps) {
|
|
127
|
+
const { queue, hidden, log, tab, busy } = props;
|
|
128
|
+
return (
|
|
129
|
+
<section aria-labelledby="newsroom-moderation-title" data-news-desk-admin-panel="moderation">
|
|
130
|
+
<h3 id="newsroom-moderation-title">Moderation</h3>
|
|
131
|
+
<p>{moderationModeText(props.mode)} Only editors and admins can see this.</p>
|
|
132
|
+
<p role="tablist" aria-label="Moderation views">
|
|
133
|
+
<button type="button" role="tab" aria-selected={tab === "queue"} onClick={() => props.onTab("queue")}>Queue ({queue.length})</button>{" "}
|
|
134
|
+
<button type="button" role="tab" aria-selected={tab === "hidden"} onClick={() => props.onTab("hidden")}>Hidden ({hidden.length})</button>{" "}
|
|
135
|
+
<button type="button" role="tab" aria-selected={tab === "log"} onClick={() => props.onTab("log")}>Recent actions</button>{" "}
|
|
136
|
+
<button type="button" onClick={props.onRefresh} disabled={busy}>Refresh</button>
|
|
137
|
+
</p>
|
|
138
|
+
<label>
|
|
139
|
+
Reason for the next action (optional){" "}
|
|
140
|
+
<input type="text" value={props.reason} maxLength={500} onChange={(event) => props.onReason(event.target.value)} />
|
|
141
|
+
</label>
|
|
142
|
+
<p role="status" aria-live="polite">{props.status}</p>
|
|
143
|
+
{tab === "queue" ? (
|
|
144
|
+
queue.length === 0 ? <p>Nothing is waiting for a decision.</p> : <ul>{queue.map((row) => <Row key={row.id} row={row} busy={busy} onAction={props.onAction} listed="queue" />)}</ul>
|
|
145
|
+
) : null}
|
|
146
|
+
{tab === "hidden" ? (
|
|
147
|
+
hidden.length === 0 ? <p>Nothing is hidden.</p> : <ul>{hidden.map((row) => <Row key={row.id} row={row} busy={busy} onAction={props.onAction} listed="hidden" />)}</ul>
|
|
148
|
+
) : null}
|
|
149
|
+
{tab === "log" ? (
|
|
150
|
+
log.length === 0 ? <p>No moderation actions yet.</p> : (
|
|
151
|
+
<table>
|
|
152
|
+
<thead>
|
|
153
|
+
<tr><th scope="col">When</th><th scope="col">Action</th><th scope="col">Target</th><th scope="col">By</th><th scope="col">Reason</th><th scope="col"><span>Undo</span></th></tr>
|
|
154
|
+
</thead>
|
|
155
|
+
<tbody>
|
|
156
|
+
{log.map((entry) => (
|
|
157
|
+
<tr key={entry.id}>
|
|
158
|
+
<td>{entry.at}</td>
|
|
159
|
+
<td>{entry.label} ({entry.fromState} to {entry.toState}){entry.undoneBy ? ", undone" : ""}</td>
|
|
160
|
+
<td>{entry.targetType} {entry.target.slice(0, 12)}</td>
|
|
161
|
+
<td>{entry.byLabel}</td>
|
|
162
|
+
<td>{entry.reason}</td>
|
|
163
|
+
<td>
|
|
164
|
+
{entry.undoable ? (
|
|
165
|
+
<button type="button" disabled={busy} onClick={() => props.onAction("undo", entry.targetType as ModerationTarget, entry.target, entry.id)}>Undo</button>
|
|
166
|
+
) : null}
|
|
167
|
+
</td>
|
|
168
|
+
</tr>
|
|
169
|
+
))}
|
|
170
|
+
</tbody>
|
|
171
|
+
</table>
|
|
172
|
+
)
|
|
173
|
+
) : null}
|
|
174
|
+
</section>
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export function ModerationPanel() {
|
|
179
|
+
const mode = useMemo(() => pluginSettings(amplifyOutputs as PluginOutputs, "reader-steering")?.moderation, []);
|
|
180
|
+
const [tab, setTab] = useState<"queue" | "hidden" | "log">("queue");
|
|
181
|
+
const [data, setData] = useState<{ queue: ModerationRow[]; hidden: ModerationRow[]; log: ActionEntry[] } | null>(null);
|
|
182
|
+
const [status, setStatus] = useState("Loading...");
|
|
183
|
+
const [busy, setBusy] = useState(false);
|
|
184
|
+
const [reason, setReason] = useState("");
|
|
185
|
+
|
|
186
|
+
const refresh = useCallback(async (quiet = false) => {
|
|
187
|
+
setBusy(true);
|
|
188
|
+
try {
|
|
189
|
+
const [newSubmissions, newComments, hiddenSubmissions, hiddenComments, reports, actions, handleRows] = await Promise.all([
|
|
190
|
+
loadAll<SubmissionRow>(QUEUE_SUBMISSIONS, "listReaderSubmissionsByModerationAndCreatedAt"),
|
|
191
|
+
loadAll<CommentRow>(QUEUE_COMMENTS, "listReaderCommentsByModerationAndCreatedAt"),
|
|
192
|
+
loadAll<SubmissionRow>(HIDDEN_SUBMISSIONS, "listReaderSubmissions"),
|
|
193
|
+
loadAll<CommentRow>(HIDDEN_COMMENTS, "listReaderComments"),
|
|
194
|
+
loadAll<ReportRow>(OPEN_REPORTS, "listReaderReports"),
|
|
195
|
+
loadAll<ActionRow>(ACTIONS, "listModerationActions"),
|
|
196
|
+
loadAll<{ id: string; readerId: string }>(HANDLES, "listReaderHandles"),
|
|
197
|
+
]);
|
|
198
|
+
const handles = Object.fromEntries(handleRows.map((row) => [row.readerId, row.id]));
|
|
199
|
+
setData({
|
|
200
|
+
queue: buildQueue({ submissions: newSubmissions, comments: newComments, reports, handles }),
|
|
201
|
+
hidden: buildHidden({ submissions: hiddenSubmissions, comments: hiddenComments, reports, handles }),
|
|
202
|
+
log: buildActionLog(actions),
|
|
203
|
+
});
|
|
204
|
+
if (!quiet) setStatus("");
|
|
205
|
+
} catch (error) {
|
|
206
|
+
setStatus(`Error: ${error instanceof Error ? error.message : "Could not load moderation data."}`);
|
|
207
|
+
} finally {
|
|
208
|
+
setBusy(false);
|
|
209
|
+
}
|
|
210
|
+
}, []);
|
|
211
|
+
|
|
212
|
+
useEffect(() => {
|
|
213
|
+
void refresh();
|
|
214
|
+
}, [refresh]);
|
|
215
|
+
|
|
216
|
+
const onAction = useCallback(
|
|
217
|
+
async (action: ModerationActionName, targetType: ModerationTarget, target: string, undoOf?: string) => {
|
|
218
|
+
setBusy(true);
|
|
219
|
+
setStatus("Working...");
|
|
220
|
+
try {
|
|
221
|
+
const receipt = await moderate({ action, targetType, target, ...(reason.trim() ? { reason: reason.trim() } : {}), ...(undoOf ? { undoOf } : {}) });
|
|
222
|
+
setStatus(receiptMessage(receipt));
|
|
223
|
+
if (receipt.status === "ok") setReason("");
|
|
224
|
+
} catch (error) {
|
|
225
|
+
setStatus(`Error: ${error instanceof Error ? error.message : "The action failed."}`);
|
|
226
|
+
}
|
|
227
|
+
await refresh(true);
|
|
228
|
+
},
|
|
229
|
+
[reason, refresh],
|
|
230
|
+
);
|
|
231
|
+
|
|
232
|
+
return (
|
|
233
|
+
<ModerationView
|
|
234
|
+
mode={mode}
|
|
235
|
+
queue={data?.queue ?? []}
|
|
236
|
+
hidden={data?.hidden ?? []}
|
|
237
|
+
log={data?.log ?? []}
|
|
238
|
+
tab={tab}
|
|
239
|
+
busy={busy}
|
|
240
|
+
status={status}
|
|
241
|
+
reason={reason}
|
|
242
|
+
onTab={setTab}
|
|
243
|
+
onReason={setReason}
|
|
244
|
+
onAction={(action, targetType, target, undoOf) => void onAction(action, targetType, target, undoOf)}
|
|
245
|
+
onRefresh={() => void refresh()}
|
|
246
|
+
/>
|
|
247
|
+
);
|
|
248
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import { useState } from "react";
|
|
3
|
+
import { useId, useRef, useState, type KeyboardEvent } from "react";
|
|
4
4
|
import { primingStateText, type PrimingRequestView } from "../lib/relevance-status";
|
|
5
5
|
|
|
6
6
|
export type PrimingFormInput = { url: string; label: string; explanation: string };
|
|
@@ -23,6 +23,19 @@ export function ReferenceCyclotronPriming({
|
|
|
23
23
|
const [explanation, setExplanation] = useState("");
|
|
24
24
|
const [message, setMessage] = useState<string | null>(null);
|
|
25
25
|
|
|
26
|
+
const ratingLabelId = useId();
|
|
27
|
+
const buttons = useRef<Array<HTMLButtonElement | null>>([]);
|
|
28
|
+
|
|
29
|
+
function moveSelection(event: KeyboardEvent<HTMLButtonElement>, index: number) {
|
|
30
|
+
const forward = event.key === "ArrowRight" || event.key === "ArrowDown";
|
|
31
|
+
const backward = event.key === "ArrowLeft" || event.key === "ArrowUp";
|
|
32
|
+
if (!forward && !backward) return;
|
|
33
|
+
event.preventDefault();
|
|
34
|
+
const next = (index + (forward ? 1 : -1) + labels.length) % labels.length;
|
|
35
|
+
setLabel(labels[next]);
|
|
36
|
+
buttons.current[next]?.focus();
|
|
37
|
+
}
|
|
38
|
+
|
|
26
39
|
async function submit() {
|
|
27
40
|
setMessage("Adding…");
|
|
28
41
|
try {
|
|
@@ -38,13 +51,13 @@ export function ReferenceCyclotronPriming({
|
|
|
38
51
|
return (
|
|
39
52
|
<div className="space-y-2 text-sm" data-reference-cyclotron-priming>
|
|
40
53
|
<form
|
|
41
|
-
className="flex flex-
|
|
54
|
+
className="flex flex-col gap-3"
|
|
42
55
|
onSubmit={(event) => {
|
|
43
56
|
event.preventDefault();
|
|
44
57
|
void submit();
|
|
45
58
|
}}
|
|
46
59
|
>
|
|
47
|
-
<label className="flex
|
|
60
|
+
<label className="flex flex-col gap-1">
|
|
48
61
|
<span className="text-xs text-muted-foreground">Web page address</span>
|
|
49
62
|
<input
|
|
50
63
|
aria-label="Reference URL"
|
|
@@ -55,20 +68,46 @@ export function ReferenceCyclotronPriming({
|
|
|
55
68
|
value={url}
|
|
56
69
|
/>
|
|
57
70
|
</label>
|
|
71
|
+
<div className="flex flex-col gap-1">
|
|
72
|
+
<span className="text-xs text-muted-foreground" id={ratingLabelId}>Should Papyrus include or exclude pages like this?</span>
|
|
73
|
+
<div aria-labelledby={ratingLabelId} className="flex flex-wrap gap-2" role="radiogroup">
|
|
74
|
+
{labels.map((choice, index) => {
|
|
75
|
+
const checked = choice === label;
|
|
76
|
+
return (
|
|
77
|
+
<button
|
|
78
|
+
aria-checked={checked}
|
|
79
|
+
className={`min-w-32 flex-1 rounded border px-4 py-3 text-base font-semibold capitalize ${checked ? "border-foreground bg-foreground text-background" : "border-border bg-transparent"}`}
|
|
80
|
+
data-rating-choice={choice}
|
|
81
|
+
key={choice}
|
|
82
|
+
onClick={() => setLabel(choice)}
|
|
83
|
+
onKeyDown={(event) => moveSelection(event, index)}
|
|
84
|
+
ref={(node) => { buttons.current[index] = node; }}
|
|
85
|
+
role="radio"
|
|
86
|
+
tabIndex={checked || (!labels.includes(label) && index === 0) ? 0 : -1}
|
|
87
|
+
type="button"
|
|
88
|
+
>
|
|
89
|
+
{choice}
|
|
90
|
+
</button>
|
|
91
|
+
);
|
|
92
|
+
})}
|
|
93
|
+
</div>
|
|
94
|
+
</div>
|
|
58
95
|
<label className="flex flex-col gap-1">
|
|
59
|
-
<span className="text-
|
|
60
|
-
<
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
))}
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
<
|
|
96
|
+
<span className="text-sm font-medium">Why?</span>
|
|
97
|
+
<textarea
|
|
98
|
+
aria-label="Reason"
|
|
99
|
+
className="w-full"
|
|
100
|
+
onChange={(event) => setExplanation(event.target.value)}
|
|
101
|
+
placeholder="Why? A sentence or two helps Papyrus learn what you want."
|
|
102
|
+
rows={4}
|
|
103
|
+
value={explanation}
|
|
104
|
+
/>
|
|
105
|
+
<span className="text-xs text-muted-foreground">Optional, but a short reason helps Papyrus learn what you want.</span>
|
|
69
106
|
</label>
|
|
70
|
-
<
|
|
71
|
-
|
|
107
|
+
<div>
|
|
108
|
+
<button className="cyclotron-review-secondary" type="submit">Add reference</button>
|
|
109
|
+
</div>
|
|
110
|
+
{message ? <p className="text-xs" role="status">{message}</p> : null}
|
|
72
111
|
</form>
|
|
73
112
|
{requests.length ? (
|
|
74
113
|
<ul aria-label="Added references" className="space-y-1 text-xs text-muted-foreground">
|
|
@@ -15,6 +15,7 @@ import { useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useSta
|
|
|
15
15
|
import amplifyOutputs from "papyrus-amplify-outputs";
|
|
16
16
|
import { EditorialChecksAdminPanel } from "./editorial-checks-admin-panel";
|
|
17
17
|
import { MonitoringPanel } from "./monitoring-panel";
|
|
18
|
+
import { ModerationPanel } from "./moderation-panel";
|
|
18
19
|
import { SignupEntriesPanel } from "./signup-entries-panel";
|
|
19
20
|
import { pluginSettings, type PluginOutputs } from "../lib/plugin-settings";
|
|
20
21
|
import { generateClient } from "aws-amplify/data";
|
|
@@ -249,7 +250,7 @@ type AssignmentAction = "claim" | "release" | "complete" | "cancel" | "reopen" |
|
|
|
249
250
|
type ReportingPacketReviewDecision = "select" | "merge" | "brief" | "hold" | "kill";
|
|
250
251
|
type AssignmentDeskViewMode = "queue" | "budget";
|
|
251
252
|
type UserRoleAction = "grant" | "revoke";
|
|
252
|
-
type AdministrationPanel = "users" | "policies" | "sections" | "procedures" | "delegations" | "signups" | "editorial-checks" | "monitoring";
|
|
253
|
+
type AdministrationPanel = "users" | "policies" | "sections" | "procedures" | "delegations" | "signups" | "moderation" | "editorial-checks" | "monitoring";
|
|
253
254
|
export type NewsDeskTab = "overview" | "desks" | "topics" | "concepts" | "references" | "messages" | "assignments" | "administration" | "search";
|
|
254
255
|
type LexicalRuleScope = "publication" | "corpus" | "classifier" | "category";
|
|
255
256
|
type AnalysisReindexMode = "online-update" | "classifier-retrain" | "scoped-topic-rebuild" | "entity-graph-rebuild" | "generated-analysis-rebuild";
|
|
@@ -4351,9 +4352,21 @@ function AdministrationDeskView({
|
|
|
4351
4352
|
<span>Addresses from the site's signup forms</span>
|
|
4352
4353
|
</Link>
|
|
4353
4354
|
) : null}
|
|
4355
|
+
{readerPluginEnabled() ? (
|
|
4356
|
+
<Link
|
|
4357
|
+
href={administrationPanelHref("moderation", isDemo)}
|
|
4358
|
+
data-news-desk-admin-nav="moderation"
|
|
4359
|
+
data-active={administrationPanel === "moderation" || undefined}
|
|
4360
|
+
onClick={() => onPanelChange("moderation")}
|
|
4361
|
+
>
|
|
4362
|
+
<strong>Moderation</strong>
|
|
4363
|
+
<span>Reader submissions and comments</span>
|
|
4364
|
+
</Link>
|
|
4365
|
+
) : null}
|
|
4354
4366
|
</nav>}
|
|
4355
4367
|
<div className="news-desk-settings-panel">
|
|
4356
4368
|
{administrationPanel === "signups" ? <SignupEntriesPanel /> : null}
|
|
4369
|
+
{administrationPanel === "moderation" ? <ModerationPanel /> : null}
|
|
4357
4370
|
{administrationPanel === "editorial-checks" ? <EditorialChecksAdminPanel /> : null}
|
|
4358
4371
|
{administrationPanel === "monitoring" ? <MonitoringPanel isAdmin={canManageUsers} isDemo={isDemo} /> : null}
|
|
4359
4372
|
{administrationPanel === "delegations" ? <AdministrationDelegationsPanel disabled={disabled} /> : null}
|
|
@@ -12819,9 +12832,14 @@ function signupPluginEnabled(): boolean {
|
|
|
12819
12832
|
return pluginSettings(amplifyOutputs as PluginOutputs, "signup") !== null;
|
|
12820
12833
|
}
|
|
12821
12834
|
|
|
12835
|
+
function readerPluginEnabled(): boolean {
|
|
12836
|
+
return pluginSettings(amplifyOutputs as PluginOutputs, "reader-steering") !== null;
|
|
12837
|
+
}
|
|
12838
|
+
|
|
12822
12839
|
function normalizeAdministrationPanel(value: string | null | undefined): AdministrationPanel {
|
|
12823
12840
|
if (value === "policies" || value === "sections" || value === "procedures" || value === "delegations" || value === "editorial-checks" || value === "monitoring") return value;
|
|
12824
12841
|
if (value === "signups" && signupPluginEnabled()) return value;
|
|
12842
|
+
if (value === "moderation" && readerPluginEnabled()) return value;
|
|
12825
12843
|
return "users";
|
|
12826
12844
|
}
|
|
12827
12845
|
|
|
@@ -4,7 +4,7 @@ import * as amplify from "aws-cdk-lib/aws-amplify";
|
|
|
4
4
|
import { SecureStringParameter } from "./secure-string-parameter.js";
|
|
5
5
|
import { GITHUB_OIDC_PROVIDER_HOST } from "./github-oidc-provider.js";
|
|
6
6
|
import { cmsProductionBuildSpec, cmsStagingBuildSpec, readerBuildSpec } from "./build-specs.js";
|
|
7
|
-
import { PROSPECTUS_PAPYRUS_SITE_ROUTES, hasCmsApp, isStagingEnabled, resolveRedirectSubDomainPrefix, resolveCmsHostName, resolveSiteOrigin, githubOidcSubjects, resolveGithubRepository, resolveProspectusAccountsEndpoint, resolveProspectusSessionSealingParameterName, resolveRevalidateSecretParameterName, resolveStackName, resolveStagingDomainName, resolveStoragePreviewPrefix } from "./site-config.js";
|
|
7
|
+
import { PROSPECTUS_PAPYRUS_SITE_ROUTES, hasCmsApp, isStagingEnabled, resolveRedirectSubDomainPrefix, resolveCmsHostName, resolveSiteOrigin, githubOidcSubjects, resolveGithubRepository, resolveProspectusAccountsEndpoint, resolveProspectusSessionSealingParameterName, resolveReaderPollSecretParameterName, resolveRevalidateSecretParameterName, resolveStackName, resolveStagingDomainName, resolveStoragePreviewPrefix } from "./site-config.js";
|
|
8
8
|
function environmentVariables(variables) {
|
|
9
9
|
return Object.entries(variables).map(([name, value]) => ({ name, value }));
|
|
10
10
|
}
|
|
@@ -123,6 +123,20 @@ export class AmplifyAppShellStack extends Stack {
|
|
|
123
123
|
resources: [prospectusSessionSealing.parameterArn],
|
|
124
124
|
}));
|
|
125
125
|
}
|
|
126
|
+
if (prospectusAccountsApiStageArn && config.cms.readerRetirementPoll) {
|
|
127
|
+
// Prospectus admits one caller per site, this compute role, so the scheduled snapshot run reaches the retired-subject
|
|
128
|
+
// route through the CMS route /api/reader/retired, which it authenticates with this secret.
|
|
129
|
+
const readerPollSecret = new SecureStringParameter(this, "ReaderPollSecretParameter", {
|
|
130
|
+
parameterName: resolveReaderPollSecretParameterName(config),
|
|
131
|
+
description: `Authorizes the scheduled reader snapshot run to call the retired-subject route of ${cmsAppName}`,
|
|
132
|
+
});
|
|
133
|
+
prospectusEnvironment.PAPYRUS_READER_POLL_SECRET_PARAMETER = readerPollSecret.parameterName;
|
|
134
|
+
computeRole.addToPolicy(new iam.PolicyStatement({
|
|
135
|
+
sid: "ReadReaderPollSecret",
|
|
136
|
+
actions: ["ssm:GetParameter"],
|
|
137
|
+
resources: [readerPollSecret.parameterArn],
|
|
138
|
+
}));
|
|
139
|
+
}
|
|
126
140
|
if (prospectusAccountsApiStageArn) {
|
|
127
141
|
computeRole.addToPolicy(new iam.PolicyStatement({
|
|
128
142
|
sid: "InvokeProspectusAccountsAsRegisteredSite",
|
|
@@ -5,7 +5,7 @@ import * as amplify from "aws-cdk-lib/aws-amplify";
|
|
|
5
5
|
import { SecureStringParameter } from "./secure-string-parameter";
|
|
6
6
|
import { GITHUB_OIDC_PROVIDER_HOST } from "./github-oidc-provider";
|
|
7
7
|
import { cmsProductionBuildSpec, cmsStagingBuildSpec, readerBuildSpec } from "./build-specs";
|
|
8
|
-
import { AmplifyAppShellSiteConfig, PROSPECTUS_PAPYRUS_SITE_ROUTES, ReaderCustomHeaderRule, hasCmsApp, isStagingEnabled, resolveRedirectSubDomainPrefix, resolveCmsHostName, resolveSiteOrigin, githubOidcSubjects, resolveGithubRepository, resolveProspectusAccountsEndpoint, resolveProspectusSessionSealingParameterName, resolveRevalidateSecretParameterName, resolveStackName, resolveStagingDomainName, resolveStoragePreviewPrefix } from "./site-config";
|
|
8
|
+
import { AmplifyAppShellSiteConfig, PROSPECTUS_PAPYRUS_SITE_ROUTES, ReaderCustomHeaderRule, hasCmsApp, isStagingEnabled, resolveRedirectSubDomainPrefix, resolveCmsHostName, resolveSiteOrigin, githubOidcSubjects, resolveGithubRepository, resolveProspectusAccountsEndpoint, resolveProspectusSessionSealingParameterName, resolveReaderPollSecretParameterName, resolveRevalidateSecretParameterName, resolveStackName, resolveStagingDomainName, resolveStoragePreviewPrefix } from "./site-config";
|
|
9
9
|
|
|
10
10
|
function environmentVariables(variables: Record<string, string>): amplify.CfnBranch.EnvironmentVariableProperty[] {
|
|
11
11
|
return Object.entries(variables).map(([name, value]) => ({ name, value }));
|
|
@@ -136,6 +136,22 @@ export class AmplifyAppShellStack extends Stack {
|
|
|
136
136
|
}),
|
|
137
137
|
);
|
|
138
138
|
}
|
|
139
|
+
if (prospectusAccountsApiStageArn && config.cms.readerRetirementPoll) {
|
|
140
|
+
// Prospectus admits one caller per site, this compute role, so the scheduled snapshot run reaches the retired-subject
|
|
141
|
+
// route through the CMS route /api/reader/retired, which it authenticates with this secret.
|
|
142
|
+
const readerPollSecret = new SecureStringParameter(this, "ReaderPollSecretParameter", {
|
|
143
|
+
parameterName: resolveReaderPollSecretParameterName(config),
|
|
144
|
+
description: `Authorizes the scheduled reader snapshot run to call the retired-subject route of ${cmsAppName}`,
|
|
145
|
+
});
|
|
146
|
+
prospectusEnvironment.PAPYRUS_READER_POLL_SECRET_PARAMETER = readerPollSecret.parameterName;
|
|
147
|
+
computeRole.addToPolicy(
|
|
148
|
+
new iam.PolicyStatement({
|
|
149
|
+
sid: "ReadReaderPollSecret",
|
|
150
|
+
actions: ["ssm:GetParameter"],
|
|
151
|
+
resources: [readerPollSecret.parameterArn],
|
|
152
|
+
}),
|
|
153
|
+
);
|
|
154
|
+
}
|
|
139
155
|
if (prospectusAccountsApiStageArn) {
|
|
140
156
|
computeRole.addToPolicy(
|
|
141
157
|
new iam.PolicyStatement({
|
package/infra/site-config.js
CHANGED
|
@@ -35,7 +35,7 @@ const GITHUB_REPO_PATTERN = /^[A-Za-z0-9_.-]+$/;
|
|
|
35
35
|
const COGNITO_DOMAIN_PREFIX_PATTERN = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
|
|
36
36
|
const COGNITO_RESERVED_WORDS = ["aws", "amazon", "cognito"];
|
|
37
37
|
const PROSPECTUS_ACCOUNTS_API_STAGE_ARN_PATTERN = /^arn:aws:execute-api:[a-z0-9-]+:\d{12}:[a-z0-9]+\/api$/;
|
|
38
|
-
const TEMPLATE_MANAGED_ENVIRONMENT_KEYS = ["PAPYRUS_COGNITO_DOMAIN_PREFIX", "PAPYRUS_APPLY_COGNITO_DOMAIN_PREFIX", "PAPYRUS_DISABLE_GOOGLE_OAUTH", "PAPYRUS_REVALIDATE_SECRET_PARAMETER", "PAPYRUS_PROSPECTUS_ACCOUNTS_ENDPOINT", "PAPYRUS_PROSPECTUS_SESSION_SECRET_PARAMETER", "PAPYRUS_PROSPECTUS_SITE_PRODUCT", "PAPYRUS_SITE_ORIGIN"];
|
|
38
|
+
const TEMPLATE_MANAGED_ENVIRONMENT_KEYS = ["PAPYRUS_COGNITO_DOMAIN_PREFIX", "PAPYRUS_APPLY_COGNITO_DOMAIN_PREFIX", "PAPYRUS_DISABLE_GOOGLE_OAUTH", "PAPYRUS_REVALIDATE_SECRET_PARAMETER", "PAPYRUS_PROSPECTUS_ACCOUNTS_ENDPOINT", "PAPYRUS_PROSPECTUS_SESSION_SECRET_PARAMETER", "PAPYRUS_PROSPECTUS_SITE_PRODUCT", "PAPYRUS_READER_POLL_SECRET_PARAMETER", "PAPYRUS_SITE_ORIGIN"];
|
|
39
39
|
/** Routes Prospectus admits for a registered Papyrus site (Prospectus accounts/papyrus-sites.ts). */
|
|
40
40
|
export const PROSPECTUS_PAPYRUS_SITE_ROUTES = [
|
|
41
41
|
"GET/v1/identities/me",
|
|
@@ -63,6 +63,9 @@ function originOfRedirectUrl(value) {
|
|
|
63
63
|
export function resolveProspectusSessionSealingParameterName(config) {
|
|
64
64
|
return `/papyrus/${config.siteId}/prospectus-session-sealing`;
|
|
65
65
|
}
|
|
66
|
+
export function resolveReaderPollSecretParameterName(config) {
|
|
67
|
+
return `/papyrus/${config.siteId}/reader-poll-secret`;
|
|
68
|
+
}
|
|
66
69
|
export function resolveRevalidateSecretParameterName(config) {
|
|
67
70
|
return `/papyrus/${config.siteId}/revalidate-secret`;
|
|
68
71
|
}
|
|
@@ -222,7 +225,7 @@ function parseStagingPreview(value) {
|
|
|
222
225
|
}
|
|
223
226
|
function parseCms(value) {
|
|
224
227
|
const cmsRecord = requireObject(value, "cms");
|
|
225
|
-
rejectUnknownKeys(cmsRecord, ["appName", "domainName", "staging", "cognitoDomainPrefix", "applyCognitoDomainPrefix", "buildComputeType", "environment", "stagingDomainName", "domainPrefix", "siteOrigin", "stagingDomainPrefix", "redirects", "stagingPreview", "prospectusAccountsApiStageArn", "deployStackInBuild"], "cms");
|
|
228
|
+
rejectUnknownKeys(cmsRecord, ["appName", "domainName", "staging", "cognitoDomainPrefix", "applyCognitoDomainPrefix", "buildComputeType", "environment", "stagingDomainName", "domainPrefix", "siteOrigin", "stagingDomainPrefix", "redirects", "stagingPreview", "prospectusAccountsApiStageArn", "readerRetirementPoll", "deployStackInBuild"], "cms");
|
|
226
229
|
const buildComputeType = cmsRecord.buildComputeType;
|
|
227
230
|
if (buildComputeType !== undefined && buildComputeType !== "STANDARD" && buildComputeType !== "STANDARD_8GB") {
|
|
228
231
|
fail("cms.buildComputeType", `must be "STANDARD" or "STANDARD_8GB", got ${JSON.stringify(buildComputeType)}`);
|
|
@@ -233,6 +236,12 @@ function parseCms(value) {
|
|
|
233
236
|
if (cmsRecord.applyCognitoDomainPrefix !== undefined && typeof cmsRecord.applyCognitoDomainPrefix !== "boolean") {
|
|
234
237
|
fail("cms.applyCognitoDomainPrefix", `must be a boolean, got ${JSON.stringify(cmsRecord.applyCognitoDomainPrefix)}`);
|
|
235
238
|
}
|
|
239
|
+
if (cmsRecord.readerRetirementPoll !== undefined && typeof cmsRecord.readerRetirementPoll !== "boolean") {
|
|
240
|
+
fail("cms.readerRetirementPoll", `must be a boolean, got ${JSON.stringify(cmsRecord.readerRetirementPoll)}`);
|
|
241
|
+
}
|
|
242
|
+
if (cmsRecord.readerRetirementPoll === true && cmsRecord.prospectusAccountsApiStageArn === undefined) {
|
|
243
|
+
fail("cms.readerRetirementPoll", "requires cms.prospectusAccountsApiStageArn: the poll calls Prospectus as the site");
|
|
244
|
+
}
|
|
236
245
|
if (cmsRecord.deployStackInBuild !== undefined && typeof cmsRecord.deployStackInBuild !== "boolean") {
|
|
237
246
|
fail("cms.deployStackInBuild", `must be a boolean, got ${JSON.stringify(cmsRecord.deployStackInBuild)}`);
|
|
238
247
|
}
|
|
@@ -250,6 +259,7 @@ function parseCms(value) {
|
|
|
250
259
|
stagingDomainPrefix: optionalString(cmsRecord.stagingDomainPrefix, "cms.stagingDomainPrefix", DOMAIN_PREFIX_PATTERN, "single DNS label"),
|
|
251
260
|
redirects: parseRedirects(cmsRecord.redirects),
|
|
252
261
|
deployStackInBuild: cmsRecord.deployStackInBuild,
|
|
262
|
+
readerRetirementPoll: cmsRecord.readerRetirementPoll,
|
|
253
263
|
stagingPreview: parseStagingPreview(cmsRecord.stagingPreview),
|
|
254
264
|
prospectusAccountsApiStageArn: optionalString(cmsRecord.prospectusAccountsApiStageArn, "cms.prospectusAccountsApiStageArn", PROSPECTUS_ACCOUNTS_API_STAGE_ARN_PATTERN, "arn:aws:execute-api:<region>:<account>:<api id>/api"),
|
|
255
265
|
};
|
package/infra/site-config.ts
CHANGED
|
@@ -44,6 +44,12 @@ export type CmsConfig = {
|
|
|
44
44
|
stagingDomainPrefix?: string;
|
|
45
45
|
redirects?: CmsRedirect[];
|
|
46
46
|
prospectusAccountsApiStageArn?: string;
|
|
47
|
+
/**
|
|
48
|
+
* The site is a public-reader site that polls Prospectus for retired reader subjects. The template then generates a
|
|
49
|
+
* shared secret that the scheduled snapshot run presents to the CMS route `/api/reader/retired` (requires
|
|
50
|
+
* `prospectusAccountsApiStageArn`; docs/reader-steering.md).
|
|
51
|
+
*/
|
|
52
|
+
readerRetirementPoll?: boolean;
|
|
47
53
|
deployStackInBuild?: boolean;
|
|
48
54
|
};
|
|
49
55
|
|
|
@@ -146,7 +152,7 @@ const GITHUB_REPO_PATTERN = /^[A-Za-z0-9_.-]+$/;
|
|
|
146
152
|
const COGNITO_DOMAIN_PREFIX_PATTERN = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
|
|
147
153
|
const COGNITO_RESERVED_WORDS = ["aws", "amazon", "cognito"];
|
|
148
154
|
const PROSPECTUS_ACCOUNTS_API_STAGE_ARN_PATTERN = /^arn:aws:execute-api:[a-z0-9-]+:\d{12}:[a-z0-9]+\/api$/;
|
|
149
|
-
const TEMPLATE_MANAGED_ENVIRONMENT_KEYS = ["PAPYRUS_COGNITO_DOMAIN_PREFIX", "PAPYRUS_APPLY_COGNITO_DOMAIN_PREFIX", "PAPYRUS_DISABLE_GOOGLE_OAUTH", "PAPYRUS_REVALIDATE_SECRET_PARAMETER", "PAPYRUS_PROSPECTUS_ACCOUNTS_ENDPOINT", "PAPYRUS_PROSPECTUS_SESSION_SECRET_PARAMETER", "PAPYRUS_PROSPECTUS_SITE_PRODUCT", "PAPYRUS_SITE_ORIGIN"];
|
|
155
|
+
const TEMPLATE_MANAGED_ENVIRONMENT_KEYS = ["PAPYRUS_COGNITO_DOMAIN_PREFIX", "PAPYRUS_APPLY_COGNITO_DOMAIN_PREFIX", "PAPYRUS_DISABLE_GOOGLE_OAUTH", "PAPYRUS_REVALIDATE_SECRET_PARAMETER", "PAPYRUS_PROSPECTUS_ACCOUNTS_ENDPOINT", "PAPYRUS_PROSPECTUS_SESSION_SECRET_PARAMETER", "PAPYRUS_PROSPECTUS_SITE_PRODUCT", "PAPYRUS_READER_POLL_SECRET_PARAMETER", "PAPYRUS_SITE_ORIGIN"];
|
|
150
156
|
|
|
151
157
|
/** Routes Prospectus admits for a registered Papyrus site (Prospectus accounts/papyrus-sites.ts). */
|
|
152
158
|
export const PROSPECTUS_PAPYRUS_SITE_ROUTES = [
|
|
@@ -177,6 +183,10 @@ export function resolveProspectusSessionSealingParameterName(config: Pick<Amplif
|
|
|
177
183
|
return `/papyrus/${config.siteId}/prospectus-session-sealing`;
|
|
178
184
|
}
|
|
179
185
|
|
|
186
|
+
export function resolveReaderPollSecretParameterName(config: Pick<AmplifyAppShellSiteConfig, "siteId">): string {
|
|
187
|
+
return `/papyrus/${config.siteId}/reader-poll-secret`;
|
|
188
|
+
}
|
|
189
|
+
|
|
180
190
|
export function resolveRevalidateSecretParameterName(config: Pick<AmplifyAppShellSiteConfig, "siteId">): string {
|
|
181
191
|
return `/papyrus/${config.siteId}/revalidate-secret`;
|
|
182
192
|
}
|
|
@@ -336,7 +346,7 @@ function parseStagingPreview(value: unknown): StagingPreviewConfig | undefined {
|
|
|
336
346
|
|
|
337
347
|
function parseCms(value: unknown): CmsConfig {
|
|
338
348
|
const cmsRecord = requireObject(value, "cms");
|
|
339
|
-
rejectUnknownKeys(cmsRecord, ["appName", "domainName", "staging", "cognitoDomainPrefix", "applyCognitoDomainPrefix", "buildComputeType", "environment", "stagingDomainName", "domainPrefix", "siteOrigin", "stagingDomainPrefix", "redirects", "stagingPreview", "prospectusAccountsApiStageArn", "deployStackInBuild"], "cms");
|
|
349
|
+
rejectUnknownKeys(cmsRecord, ["appName", "domainName", "staging", "cognitoDomainPrefix", "applyCognitoDomainPrefix", "buildComputeType", "environment", "stagingDomainName", "domainPrefix", "siteOrigin", "stagingDomainPrefix", "redirects", "stagingPreview", "prospectusAccountsApiStageArn", "readerRetirementPoll", "deployStackInBuild"], "cms");
|
|
340
350
|
const buildComputeType = cmsRecord.buildComputeType;
|
|
341
351
|
if (buildComputeType !== undefined && buildComputeType !== "STANDARD" && buildComputeType !== "STANDARD_8GB") {
|
|
342
352
|
fail("cms.buildComputeType", `must be "STANDARD" or "STANDARD_8GB", got ${JSON.stringify(buildComputeType)}`);
|
|
@@ -347,6 +357,12 @@ function parseCms(value: unknown): CmsConfig {
|
|
|
347
357
|
if (cmsRecord.applyCognitoDomainPrefix !== undefined && typeof cmsRecord.applyCognitoDomainPrefix !== "boolean") {
|
|
348
358
|
fail("cms.applyCognitoDomainPrefix", `must be a boolean, got ${JSON.stringify(cmsRecord.applyCognitoDomainPrefix)}`);
|
|
349
359
|
}
|
|
360
|
+
if (cmsRecord.readerRetirementPoll !== undefined && typeof cmsRecord.readerRetirementPoll !== "boolean") {
|
|
361
|
+
fail("cms.readerRetirementPoll", `must be a boolean, got ${JSON.stringify(cmsRecord.readerRetirementPoll)}`);
|
|
362
|
+
}
|
|
363
|
+
if (cmsRecord.readerRetirementPoll === true && cmsRecord.prospectusAccountsApiStageArn === undefined) {
|
|
364
|
+
fail("cms.readerRetirementPoll", "requires cms.prospectusAccountsApiStageArn: the poll calls Prospectus as the site");
|
|
365
|
+
}
|
|
350
366
|
if (cmsRecord.deployStackInBuild !== undefined && typeof cmsRecord.deployStackInBuild !== "boolean") {
|
|
351
367
|
fail("cms.deployStackInBuild", `must be a boolean, got ${JSON.stringify(cmsRecord.deployStackInBuild)}`);
|
|
352
368
|
}
|
|
@@ -369,6 +385,7 @@ function parseCms(value: unknown): CmsConfig {
|
|
|
369
385
|
stagingDomainPrefix: optionalString(cmsRecord.stagingDomainPrefix, "cms.stagingDomainPrefix", DOMAIN_PREFIX_PATTERN, "single DNS label"),
|
|
370
386
|
redirects: parseRedirects(cmsRecord.redirects),
|
|
371
387
|
deployStackInBuild: cmsRecord.deployStackInBuild as boolean | undefined,
|
|
388
|
+
readerRetirementPoll: cmsRecord.readerRetirementPoll as boolean | undefined,
|
|
372
389
|
stagingPreview: parseStagingPreview(cmsRecord.stagingPreview),
|
|
373
390
|
prospectusAccountsApiStageArn: optionalString(
|
|
374
391
|
cmsRecord.prospectusAccountsApiStageArn,
|