@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.
Files changed (75) hide show
  1. package/amplify/data/resource.js +1 -0
  2. package/amplify/data/resource.ts +1 -0
  3. package/amplify/data/schema.js +1 -0
  4. package/amplify/data/schema.ts +1 -0
  5. package/amplify/functions/assignment-action/data-access.js +2 -0
  6. package/amplify/functions/assignment-action/data-access.ts +2 -0
  7. package/amplify/functions/assignment-action/handler.ts +1 -1
  8. package/amplify/functions/content-actions/handler.py +3 -0
  9. package/amplify/functions/shared/editorial-context.ts +63 -4
  10. package/amplify/plugins/newsletter/config.js +195 -0
  11. package/amplify/plugins/newsletter/config.ts +240 -0
  12. package/amplify/plugins/newsletter/function/handler.py +6 -0
  13. package/amplify/plugins/newsletter/function/requirements.txt +5 -0
  14. package/amplify/plugins/newsletter/ops/handler.py +12 -0
  15. package/amplify/plugins/newsletter/ops/resource.js +27 -0
  16. package/amplify/plugins/newsletter/ops/resource.ts +37 -0
  17. package/amplify/plugins/newsletter/papyrus.plugin.js +246 -0
  18. package/amplify/plugins/newsletter/papyrus.plugin.ts +291 -0
  19. package/amplify/plugins/newsletter/schema.js +37 -0
  20. package/amplify/plugins/newsletter/schema.ts +42 -0
  21. package/amplify/plugins/newsletter/secure-string-parameter.js +107 -0
  22. package/amplify/plugins/newsletter/secure-string-parameter.ts +120 -0
  23. package/amplify/plugins/newsletter/sending.js +139 -0
  24. package/amplify/plugins/newsletter/sending.ts +185 -0
  25. package/amplify/plugins/reader-steering/config.js +162 -0
  26. package/amplify/plugins/reader-steering/config.ts +248 -0
  27. package/amplify/plugins/reader-steering/function/ai_moderation.py +230 -0
  28. package/amplify/plugins/reader-steering/function/handler.py +1363 -0
  29. package/amplify/plugins/reader-steering/function/moderation-prompt.md +27 -0
  30. package/amplify/plugins/reader-steering/function/moderation.py +316 -0
  31. package/amplify/plugins/reader-steering/function/resource.js +27 -0
  32. package/amplify/plugins/reader-steering/function/resource.ts +35 -0
  33. package/amplify/plugins/reader-steering/papyrus.plugin.js +175 -0
  34. package/amplify/plugins/reader-steering/papyrus.plugin.ts +201 -0
  35. package/amplify/plugins/reader-steering/schema.js +140 -0
  36. package/amplify/plugins/reader-steering/schema.ts +142 -0
  37. package/amplify/plugins/registry.js +4 -0
  38. package/amplify/plugins/registry.ts +4 -0
  39. package/amplify/plugins/types.ts +5 -0
  40. package/amplify/plugins/wire.js +1 -0
  41. package/amplify/plugins/wire.ts +1 -0
  42. package/app/api/newsletter/confirm/route.ts +13 -0
  43. package/app/api/newsletter/preferences/route.ts +13 -0
  44. package/app/api/newsletter/subscribe/route.ts +9 -0
  45. package/app/api/newsletter/unsubscribe/route.ts +13 -0
  46. package/app/api/reader/comment/delete/route.ts +9 -0
  47. package/app/api/reader/comment/edit/route.ts +9 -0
  48. package/app/api/reader/comment/route.ts +9 -0
  49. package/app/api/reader/handle/route.ts +9 -0
  50. package/app/api/reader/me/route.ts +9 -0
  51. package/app/api/reader/rating/route.ts +9 -0
  52. package/app/api/reader/report/route.ts +9 -0
  53. package/app/api/reader/retired/route.ts +9 -0
  54. package/app/api/reader/submit/route.ts +9 -0
  55. package/app/api/reader/vote/route.ts +9 -0
  56. package/components/moderation-panel.tsx +248 -0
  57. package/components/reference-cyclotron-priming.tsx +54 -15
  58. package/components/topic-steering-workspace.tsx +19 -1
  59. package/infra/amplify-app-shell.js +15 -1
  60. package/infra/amplify-app-shell.ts +17 -1
  61. package/infra/site-config.js +12 -2
  62. package/infra/site-config.ts +19 -2
  63. package/lib/editorial-checks.ts +6 -2
  64. package/lib/newsletter-api.ts +391 -0
  65. package/lib/newsletter-pages.ts +264 -0
  66. package/lib/prospectus-session.ts +1 -0
  67. package/lib/prospectus-sign-in-routes.ts +2 -2
  68. package/lib/prospectus-site-identity.ts +24 -1
  69. package/lib/reader-api.ts +145 -0
  70. package/lib/reader-moderation.ts +220 -0
  71. package/lib/reader-retirement-route.ts +89 -0
  72. package/lib/reader-subject.md +29 -0
  73. package/lib/reader-subject.ts +53 -0
  74. package/package.json +22 -1
  75. package/routes.manifest.json +158 -1
@@ -0,0 +1,264 @@
1
+ import { NextResponse } from "next/server";
2
+
3
+ /**
4
+ * The small server-rendered pages of the newsletter plugin (confirm prompt, confirmed, expired).
5
+ * Plain HTML, no scripts, themed from the plugin's public settings. Every dynamic value is escaped, the token never
6
+ * reaches a link, and the pages are never cached or indexed.
7
+ */
8
+ export type NewsletterPageTheme = { accent: string; text: string; background: string; surface: string; muted: string; fontFamily: string };
9
+
10
+ export const DEFAULT_PAGE_THEME: NewsletterPageTheme = {
11
+ accent: "#1a4d8f",
12
+ text: "#1b1b1b",
13
+ background: "#ffffff",
14
+ surface: "#f4f4f4",
15
+ muted: "#555555",
16
+ fontFamily: "Georgia, 'Times New Roman', serif",
17
+ };
18
+
19
+ const UNSAFE_CSS = /[<>{};"\\`]|url\(|expression\(|@import/i;
20
+ const MAXIMUM_LISTS_SHOWN = 20;
21
+
22
+ export function escapeHtml(value: string): string {
23
+ return value.replace(/[&<>"']/g, (character) => `&#${character.charCodeAt(0)};`);
24
+ }
25
+
26
+ /** Theme tokens from `custom.papyrusPlugins.newsletter.theme`; a missing or unsafe value falls back to the default. */
27
+ export function pageTheme(raw: unknown): NewsletterPageTheme {
28
+ const theme: NewsletterPageTheme = { ...DEFAULT_PAGE_THEME };
29
+ if (typeof raw !== "object" || raw === null) return theme;
30
+ for (const key of Object.keys(theme) as Array<keyof NewsletterPageTheme>) {
31
+ const value = (raw as Record<string, unknown>)[key];
32
+ if (typeof value === "string" && value.length > 0 && value.length <= 200 && !UNSAFE_CSS.test(value)) theme[key] = value;
33
+ }
34
+ return theme;
35
+ }
36
+
37
+ export type FormSpec = {
38
+ action: string;
39
+ /** Hidden fields, such as the token. */
40
+ hidden: Record<string, string>;
41
+ checkboxes?: Array<{ name: string; value: string; label: string; checked: boolean }>;
42
+ /** Every button says what it does in words. `secondary` only changes how it looks. */
43
+ buttons: Array<{ label: string; name?: string; value?: string; secondary?: boolean }>;
44
+ };
45
+
46
+ export type PageContent = {
47
+ siteName: string;
48
+ theme: NewsletterPageTheme;
49
+ heading: string;
50
+ /** A short status line shown above the text (for example "Saved"), as words. */
51
+ notice?: string;
52
+ paragraphs: string[];
53
+ lists?: string[];
54
+ forms?: FormSpec[];
55
+ links?: Array<{ href: string; label: string }>;
56
+ };
57
+
58
+ function renderForm(form: FormSpec): string {
59
+ const hidden = Object.entries(form.hidden)
60
+ .map(([name, value]) => `<input type="hidden" name="${escapeHtml(name)}" value="${escapeHtml(value)}">`)
61
+ .join("");
62
+ const boxes = (form.checkboxes ?? [])
63
+ .map(
64
+ (box, index) =>
65
+ `<p class="box"><input type="checkbox" id="c${index}" name="${escapeHtml(box.name)}" value="${escapeHtml(box.value)}"${box.checked ? " checked" : ""}> <label for="c${index}">${escapeHtml(box.label)}</label></p>`,
66
+ )
67
+ .join("");
68
+ const buttons = form.buttons
69
+ .map(
70
+ (button) =>
71
+ `<button type="submit"${button.name ? ` name="${escapeHtml(button.name)}" value="${escapeHtml(button.value ?? "")}"` : ""}${button.secondary ? ' class="secondary"' : ""}>${escapeHtml(button.label)}</button>`,
72
+ )
73
+ .join(" ");
74
+ return `<form method="post" action="${escapeHtml(form.action)}">${hidden}${boxes}<p class="actions">${buttons}</p></form>`;
75
+ }
76
+
77
+ export function renderPage(page: PageContent): string {
78
+ const { theme } = page;
79
+ const lists = (page.lists ?? []).slice(0, MAXIMUM_LISTS_SHOWN);
80
+ const links = (page.links ?? []).map((link) => `<p><a href="${escapeHtml(link.href)}">${escapeHtml(link.label)}</a></p>`).join("\n");
81
+ return `<!doctype html>
82
+ <html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
83
+ <meta name="robots" content="noindex"><meta name="referrer" content="no-referrer">
84
+ <title>${escapeHtml(page.heading)} - ${escapeHtml(page.siteName)}</title>
85
+ <style>
86
+ body{margin:0;background:${theme.background};color:${theme.text};font-family:${theme.fontFamily};line-height:1.5}
87
+ main{max-width:34rem;margin:0 auto;padding:3rem 1.25rem}
88
+ .site{color:${theme.muted};font-size:.95rem;margin:0 0 1.5rem}
89
+ h1{font-size:1.6rem;line-height:1.2;margin:0 0 1rem}
90
+ .notice{background:${theme.surface};border-left:4px solid ${theme.accent};padding:.5rem .75rem;font-weight:bold}
91
+ ul{background:${theme.surface};margin:1rem 0;padding:.75rem 1.25rem 1rem 2rem}
92
+ .box{margin:.6rem 0}
93
+ input[type=checkbox]{width:1.1rem;height:1.1rem;vertical-align:middle}
94
+ .actions{margin:1.25rem 0}
95
+ button{background:${theme.accent};color:#fff;border:2px solid ${theme.accent};font:inherit;font-weight:bold;padding:.7rem 1.4rem;cursor:pointer;border-radius:4px;margin:0 .5rem .5rem 0}
96
+ button.secondary{background:${theme.background};color:${theme.accent}}
97
+ button:focus-visible,a:focus-visible,input:focus-visible{outline:3px solid ${theme.text};outline-offset:2px}
98
+ a{color:${theme.accent}}
99
+ </style></head><body><main>
100
+ <p class="site">${escapeHtml(page.siteName)}</p>
101
+ <h1>${escapeHtml(page.heading)}</h1>
102
+ ${page.notice ? `<p class="notice" role="status">${escapeHtml(page.notice)}</p>` : ""}
103
+ ${page.paragraphs.map((paragraph) => `<p>${escapeHtml(paragraph)}</p>`).join("\n")}
104
+ ${lists.length > 0 ? `<ul>${lists.map((name) => `<li>${escapeHtml(name)}</li>`).join("")}</ul>` : ""}
105
+ ${(page.forms ?? []).map(renderForm).join("\n")}
106
+ ${links}
107
+ </main></body></html>`;
108
+ }
109
+
110
+ export const PAGE_HEADERS = {
111
+ "content-type": "text/html; charset=utf-8",
112
+ "cache-control": "no-store",
113
+ "referrer-policy": "no-referrer",
114
+ "x-content-type-options": "nosniff",
115
+ "content-security-policy": "default-src 'none'; style-src 'unsafe-inline'; form-action 'self'; base-uri 'none'; frame-ancestors 'none'",
116
+ } as const;
117
+
118
+ export function pageResponse(page: PageContent, status = 200): NextResponse {
119
+ return new NextResponse(renderPage(page), { status, headers: PAGE_HEADERS });
120
+ }
121
+
122
+ export const CONFIRM_PATH = "/api/newsletter/confirm";
123
+
124
+ type Base = Pick<PageContent, "siteName" | "theme">;
125
+ const home = (base: Base) => ({ href: "/", label: `Back to ${base.siteName}` });
126
+
127
+ export function confirmPromptPage(base: Base, lists: string[], token: string): PageContent {
128
+ return {
129
+ ...base,
130
+ heading: "Confirm your subscription",
131
+ paragraphs: [`Press the button to start getting email from ${base.siteName}.${lists.length > 0 ? " You are signing up for:" : ""}`],
132
+ lists,
133
+ forms: [{ action: CONFIRM_PATH, hidden: { t: token }, buttons: [{ label: "Confirm subscription" }] }],
134
+ };
135
+ }
136
+
137
+ export function confirmedPage(base: Base, lists: string[], preferencesHref?: string): PageContent {
138
+ return {
139
+ ...base,
140
+ heading: "You are subscribed",
141
+ paragraphs: [lists.length > 0 ? "Thank you. You will get:" : "Thank you. Your subscription is confirmed."],
142
+ lists,
143
+ links: [...(preferencesHref ? [{ href: preferencesHref, label: "Manage your subscriptions" }] : []), home(base)],
144
+ };
145
+ }
146
+
147
+ export function expiredPage(base: Base): PageContent {
148
+ return {
149
+ ...base,
150
+ heading: "This link has expired",
151
+ paragraphs: [
152
+ "This confirmation link has expired or was already used. If you already confirmed, you are all set.",
153
+ "If you did not finish signing up, go back to the site and sign up again to get a new link.",
154
+ ],
155
+ links: [home(base)],
156
+ };
157
+ }
158
+
159
+ export function unavailablePage(base: Base): PageContent {
160
+ return {
161
+ ...base,
162
+ heading: "Something went wrong",
163
+ paragraphs: ["We could not complete this just now. Open the link in your email again in a minute."],
164
+ links: [home(base)],
165
+ };
166
+ }
167
+
168
+ export const UNSUBSCRIBE_PATH = "/api/newsletter/unsubscribe";
169
+ export const PREFERENCES_PATH = "/api/newsletter/preferences";
170
+
171
+ export function preferencesHref(token: string): string {
172
+ return `${PREFERENCES_PATH}?t=${encodeURIComponent(token)}`;
173
+ }
174
+
175
+ export function invalidManagePage(base: Base): PageContent {
176
+ return {
177
+ ...base,
178
+ heading: "This link is not valid",
179
+ paragraphs: ["This link is not valid, or the subscription it belongs to has been deleted. Use the link in a recent email from us."],
180
+ links: [home(base)],
181
+ };
182
+ }
183
+
184
+ export function unsubscribePromptPage(base: Base, token: string, listId: string | null, listName: string | null): PageContent {
185
+ return {
186
+ ...base,
187
+ heading: "Unsubscribe",
188
+ paragraphs: [listName ? `Stop getting "${listName}" from ${base.siteName}?` : `Stop getting all email from ${base.siteName}?`],
189
+ forms: [
190
+ {
191
+ action: UNSUBSCRIBE_PATH,
192
+ hidden: { t: token, ...(listId ? { l: listId } : {}), page: "1" },
193
+ buttons: [{ label: listName ? "Unsubscribe from this list" : "Unsubscribe from everything" }],
194
+ },
195
+ ],
196
+ links: [{ href: preferencesHref(token), label: "Choose which lists you get instead" }],
197
+ };
198
+ }
199
+
200
+ export function unsubscribedPage(base: Base, names: string[], token: string | null): PageContent {
201
+ return {
202
+ ...base,
203
+ heading: "You are unsubscribed",
204
+ paragraphs: [names.length > 0 ? `You will no longer get these from ${base.siteName}:` : `You will no longer get this email from ${base.siteName}.`],
205
+ lists: names,
206
+ links: [...(token ? [{ href: preferencesHref(token), label: "Change your mind? Manage your subscriptions" }] : []), home(base)],
207
+ };
208
+ }
209
+
210
+ export type PreferenceList = { id: string; name: string; description: string; subscribed: boolean };
211
+
212
+ export function preferencesPage(base: Base, input: { token: string; email: string; lists: PreferenceList[]; notice?: string }): PageContent {
213
+ return {
214
+ ...base,
215
+ heading: "Your subscriptions",
216
+ notice: input.notice,
217
+ paragraphs: [`Email preferences for ${input.email}. Tick the lists you want.`],
218
+ forms: [
219
+ {
220
+ action: PREFERENCES_PATH,
221
+ hidden: { t: input.token },
222
+ checkboxes: input.lists.map((list) => ({
223
+ name: "l",
224
+ value: list.id,
225
+ label: list.description ? `${list.name}: ${list.description}` : list.name,
226
+ checked: list.subscribed,
227
+ })),
228
+ buttons: [
229
+ { label: "Save preferences", name: "action", value: "save" },
230
+ { label: "Unsubscribe from everything", name: "action", value: "unsubscribe_all", secondary: true },
231
+ { label: "Delete my data", name: "action", value: "delete", secondary: true },
232
+ ],
233
+ },
234
+ ],
235
+ links: [home(base)],
236
+ };
237
+ }
238
+
239
+ export function deleteConfirmPage(base: Base, token: string): PageContent {
240
+ return {
241
+ ...base,
242
+ heading: "Delete your data?",
243
+ paragraphs: [
244
+ `This erases your address and everything ${base.siteName} holds about your subscriptions. You will not get any more email. This cannot be undone.`,
245
+ ],
246
+ forms: [
247
+ {
248
+ action: PREFERENCES_PATH,
249
+ hidden: { t: token, action: "delete", confirm: "1" },
250
+ buttons: [{ label: "Yes, delete my data" }],
251
+ },
252
+ ],
253
+ links: [{ href: preferencesHref(token), label: "No, go back" }],
254
+ };
255
+ }
256
+
257
+ export function deletedPage(base: Base): PageContent {
258
+ return {
259
+ ...base,
260
+ heading: "Your data is deleted",
261
+ paragraphs: ["Your address and subscriptions have been erased. You will not get any more email from us."],
262
+ links: [home(base)],
263
+ };
264
+ }
@@ -39,6 +39,7 @@ export interface ProspectusSignedInSession {
39
39
  accessToken: string;
40
40
  accessTokenExpiresAt: number;
41
41
  selectedAccountId?: string;
42
+ siteSubject?: string;
42
43
  }
43
44
 
44
45
  function sealingKeyForCookie(sealingSecret: string, cookieName: string): Buffer {
@@ -166,6 +166,7 @@ async function completeCallback(
166
166
  personId: signedIn.person.id,
167
167
  accessToken: signedIn.accessToken,
168
168
  accessTokenExpiresAt: signedIn.accessTokenExpiresAt,
169
+ ...(signedIn.person.siteSubject ? { siteSubject: signedIn.person.siteSubject } : {}),
169
170
  };
170
171
  const response = NextResponse.redirect(new URL(pendingSignIn.returnPath, siteOriginFor(request, options)), { status: 303, headers: NO_STORE_HEADERS });
171
172
  response.cookies.set(
@@ -198,11 +199,10 @@ async function handleProspectusSessionReadRoute(request: NextRequest, options: P
198
199
  return response;
199
200
  }
200
201
  const publishingAccounts = publishingAccountsForSite(directory.accounts, siteProductFor(options));
202
+ // Only what the UI reads: no person id, siteSubject or account list ever goes to the browser.
201
203
  return NextResponse.json(
202
204
  {
203
205
  signedIn: true,
204
- person: directory.person,
205
- accounts: directory.accounts,
206
206
  cyclotronLabAccess: directory.accounts.some((account) =>
207
207
  account.products.some((product) => product.product === CYCLOTRON_LAB_PRODUCT && product.status === "active"),
208
208
  ),
@@ -24,6 +24,7 @@ export type ProspectusOperation = "console:read" | "console:write" | "members:re
24
24
  export interface ProspectusPerson {
25
25
  id: string;
26
26
  createdAt: string;
27
+ siteSubject?: string;
27
28
  }
28
29
 
29
30
  export interface ProspectusAccountAuthorization {
@@ -36,6 +37,20 @@ export interface ProspectusAccountAuthorization {
36
37
  revision: number;
37
38
  }
38
39
 
40
+ /** One retired reader site subject, from `GET /v1/accounts/site-subjects/retired` (reader sites only). */
41
+ export interface ProspectusRetiredSiteSubject {
42
+ siteSubject: string;
43
+ reason: "deleted" | "merged";
44
+ survivingSiteSubject?: string;
45
+ retiredAt: string;
46
+ }
47
+
48
+ export interface ProspectusRetiredSiteSubjectsPage {
49
+ retired: ProspectusRetiredSiteSubject[];
50
+ cursor: string;
51
+ more: boolean;
52
+ }
53
+
39
54
  export interface ProspectusDirectoryAccount {
40
55
  id: string;
41
56
  name: string;
@@ -100,8 +115,10 @@ export function signedProspectusTransport(options: SignedProspectusTransportOpti
100
115
  ...(payload ? { "content-type": "application/json" } : {}),
101
116
  };
102
117
  const signer = new SignatureV4({ service: "execute-api", region: options.region, credentials, sha256: Sha256 });
118
+ // The query string is part of what API Gateway verifies, so it has to be signed as well as sent.
119
+ const query: Record<string, string> = Object.fromEntries(url.searchParams.entries());
103
120
  const signed = await signer.sign(
104
- new HttpRequest({ protocol: url.protocol, hostname: url.hostname, path: url.pathname, method, headers, body: payload }),
121
+ new HttpRequest({ protocol: url.protocol, hostname: url.hostname, path: url.pathname, method, headers, body: payload, query }),
105
122
  );
106
123
  const response = await fetcher(url, { method, headers: signed.headers, body: payload, cache: "no-store", redirect: "error" });
107
124
  const responseBody = await response.json().catch(() => ({}));
@@ -156,6 +173,12 @@ export class ProspectusSiteAccounts {
156
173
  }) as Promise<{ account: { id: string; name: string; role: ProspectusRole } }>;
157
174
  }
158
175
 
176
+ /** The calling site's retired reader subjects since `since` (omit it the first time): at most 100, oldest first. */
177
+ retiredSiteSubjects(since?: string): Promise<ProspectusRetiredSiteSubjectsPage> {
178
+ const query = since ? `?since=${encodeURIComponent(since)}` : "";
179
+ return this.transport({ path: `/v1/accounts/site-subjects/retired${query}`, method: "GET" }) as Promise<ProspectusRetiredSiteSubjectsPage>;
180
+ }
181
+
159
182
  accountDirectory(humanAccessToken: string): Promise<{ person: ProspectusPerson | null; accounts: ProspectusDirectoryAccount[] }> {
160
183
  return this.transport({ path: "/v1/account-directory", method: "GET", humanAccessToken }) as Promise<{
161
184
  person: ProspectusPerson | null;
@@ -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
+ }