@anthusai/papyrus 2.0.0-next.8 → 2.0.0-next.80
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/function-handler.js +20 -0
- package/amplify/data/function-handler.ts +40 -0
- package/amplify/data/resource.js +151 -61
- package/amplify/data/resource.ts +157 -61
- package/amplify/data/schema.js +2096 -1980
- package/amplify/data/schema.ts +198 -69
- package/amplify/functions/assignment-action/data-access.js +14 -15
- package/amplify/functions/assignment-action/data-access.ts +13 -14
- package/amplify/functions/assignment-action/handler.ts +1 -1
- package/amplify/functions/automation-view/handler.py +17 -0
- package/amplify/functions/automation-view/resource.js +24 -0
- package/amplify/functions/automation-view/resource.ts +34 -0
- package/amplify/functions/automation-view/schema.js +26 -0
- package/amplify/functions/automation-view/schema.ts +28 -0
- package/amplify/functions/automation-view/wire.js +56 -0
- package/amplify/functions/automation-view/wire.ts +76 -0
- package/amplify/functions/content-actions/handler.py +61 -2
- package/amplify/functions/content-actions/requirements.txt +1 -0
- package/amplify/functions/content-actions/resource.js +1 -1
- package/amplify/functions/content-actions/resource.ts +1 -1
- package/amplify/{plugins/editorial-checks/function → functions/editorial-checks}/handler.py +3 -3
- package/amplify/functions/editorial-checks/requirements.txt +1 -0
- package/amplify/{plugins/editorial-checks/function → functions/editorial-checks}/resource.js +7 -7
- package/amplify/{plugins/editorial-checks/function → functions/editorial-checks}/resource.ts +7 -7
- package/amplify/functions/editorial-checks/schema.js +122 -0
- package/amplify/{plugins → functions}/editorial-checks/schema.ts +40 -12
- package/amplify/functions/editorial-checks/wire.js +53 -0
- package/amplify/functions/editorial-checks/wire.ts +61 -0
- package/amplify/functions/monitoring-view/handler.py +11 -0
- package/amplify/functions/monitoring-view/resource.js +24 -0
- package/amplify/functions/monitoring-view/resource.ts +34 -0
- package/amplify/functions/monitoring-view/schema.js +17 -0
- package/amplify/functions/monitoring-view/schema.ts +19 -0
- package/amplify/functions/monitoring-view/wire.js +37 -0
- package/amplify/functions/monitoring-view/wire.ts +47 -0
- package/amplify/functions/public-forms/core.js +247 -0
- package/amplify/functions/public-forms/core.ts +310 -0
- package/amplify/functions/public-forms/handler.ts +41 -0
- package/amplify/functions/public-forms/origins.js +35 -0
- package/amplify/functions/public-forms/origins.ts +43 -0
- package/amplify/functions/public-forms/resource.js +9 -0
- package/amplify/functions/public-forms/resource.ts +10 -0
- package/amplify/functions/public-forms/wire.js +25 -0
- package/amplify/functions/public-forms/wire.ts +32 -0
- package/amplify/functions/shared/editorial-context.ts +63 -4
- package/amplify/functions/shared/python-bundle.js +20 -14
- package/amplify/functions/shared/python-bundle.ts +25 -18
- package/amplify/plugins/newsletter/config.js +245 -0
- package/amplify/plugins/newsletter/config.ts +305 -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 +263 -0
- package/amplify/plugins/newsletter/papyrus.plugin.ts +311 -0
- package/amplify/plugins/newsletter/schema.js +57 -0
- package/amplify/plugins/newsletter/schema.ts +62 -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 +182 -0
- package/amplify/plugins/reader-steering/config.ts +290 -0
- package/amplify/plugins/reader-steering/function/ai_moderation.py +246 -0
- package/amplify/plugins/reader-steering/function/handler.py +1554 -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/proposals.py +255 -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 +186 -0
- package/amplify/plugins/reader-steering/papyrus.plugin.ts +212 -0
- package/amplify/plugins/reader-steering/schema.js +205 -0
- package/amplify/plugins/reader-steering/schema.ts +207 -0
- package/amplify/plugins/registry.js +4 -2
- package/amplify/plugins/registry.ts +4 -2
- package/amplify/plugins/signup/schema.js +2 -1
- package/amplify/plugins/signup/schema.ts +2 -1
- package/amplify/plugins/types.ts +5 -0
- package/amplify/plugins/wire.js +1 -0
- package/amplify/plugins/wire.ts +1 -0
- package/amplify/publication/projection-lease.js +30 -0
- package/amplify/publication/projection-lease.ts +39 -0
- package/amplify/publication/redirect-and-index.js +105 -0
- package/amplify/publication/redirect-rules.js +73 -0
- package/amplify/publication/redirect-rules.ts +93 -0
- package/amplify/publication/s3-projection.js +7 -1
- package/amplify/publication/s3-projection.ts +9 -1
- package/amplify/site-backend-config.ts +18 -1
- package/amplify/site-backend-names.js +4 -2
- package/amplify/site-backend-names.ts +4 -2
- package/amplify/site-backend.js +41 -2
- package/amplify/site-backend.ts +46 -2
- package/app/api/auth/prospectus/sign-in/route.ts +4 -0
- package/app/api/newsletter/confirm/route.ts +13 -0
- package/app/api/newsletter/preferences/route.ts +13 -0
- package/app/api/newsletter/state/route.ts +9 -0
- package/app/api/newsletter/subscribe/route.ts +9 -0
- package/app/api/newsletter/unsubscribe/route.ts +13 -0
- package/app/api/newsroom/curation/context/route.ts +9 -0
- package/app/api/newsroom/curation/priming/route.ts +9 -0
- package/app/api/newsroom/curation/publication/route.ts +9 -0
- package/app/api/reader/comment/delete/route.ts +9 -0
- package/app/api/reader/comment/edit/route.ts +9 -0
- package/app/api/reader/comment/route.ts +9 -0
- package/app/api/reader/flag/route.ts +9 -0
- package/app/api/reader/handle/route.ts +9 -0
- package/app/api/reader/me/route.ts +9 -0
- package/app/api/reader/rating/route.ts +9 -0
- package/app/api/reader/report/route.ts +9 -0
- package/app/api/reader/retired/route.ts +9 -0
- package/app/api/reader/submit/route.ts +9 -0
- package/app/api/reader/vote/route.ts +9 -0
- package/bin/papyrus-infra.mjs +20 -1
- package/components/automation-panel.tsx +49 -0
- package/components/automation-run-now.tsx +97 -0
- package/components/automation-view.tsx +192 -0
- package/components/editorial-checks-admin-panel.tsx +36 -11
- package/components/moderation-panel.tsx +248 -0
- package/components/monitoring-overview-view.tsx +241 -0
- package/components/monitoring-panel.tsx +40 -0
- package/components/news-desk-client-provider.tsx +18 -18
- package/components/news-desk-taxonomy-client.ts +28 -8
- package/components/newsletter-panel.tsx +474 -0
- package/components/newsletter-sections.tsx +163 -0
- package/components/newsroom-access-gate.tsx +15 -1
- package/components/newsroom-article-editor.tsx +6 -6
- package/components/newsroom-articles-view.tsx +6 -6
- package/components/newsroom-board-view.tsx +8 -3
- package/components/newsroom-curation-bootstrap.tsx +45 -0
- package/components/newsroom-ops-overview.tsx +22 -92
- package/components/newsroom-ops-shell.tsx +2 -2
- package/components/newsroom-organizer.css +33 -0
- package/components/newsroom-overview-skeleton.tsx +25 -0
- package/components/newsroom-references-view.tsx +192 -49
- package/components/notification-center.tsx +273 -0
- package/components/publication-cyclotron-panel.tsx +15 -10
- package/components/reader-auth-state.ts +1 -1
- package/components/reference-add-entry.tsx +75 -0
- package/components/reference-curation-controls.tsx +31 -22
- package/components/reference-cyclotron-priming.tsx +58 -19
- package/components/reference-cyclotron-suggestion.tsx +10 -6
- package/components/reference-priming.tsx +14 -10
- package/components/reference-relevance-review.tsx +3 -3
- package/components/reference-section-suggestion.tsx +1 -1
- package/components/topic-steering-workspace.tsx +223 -144
- package/components/use-editorial-checks.ts +12 -10
- package/infra/amplify-app-shell.js +94 -16
- package/infra/amplify-app-shell.ts +106 -18
- package/infra/cloud-email-config.js +32 -0
- package/infra/cloud-email-config.ts +39 -0
- package/infra/cloud-email.js +57 -0
- package/infra/cloud-email.test.js +69 -0
- package/infra/cloud-email.test.ts +66 -0
- package/infra/cloud-email.ts +60 -0
- package/infra/index.js +1 -0
- package/infra/secure-string-parameter-handler.js +41 -0
- package/infra/secure-string-parameter-handler.ts +41 -0
- package/infra/secure-string-parameter.js +79 -0
- package/infra/secure-string-parameter.ts +91 -0
- package/infra/site-config.js +88 -6
- package/infra/site-config.ts +134 -6
- package/lib/automation-client.ts +54 -0
- package/lib/automation-fixture.ts +93 -0
- package/lib/automation-overview.ts +232 -0
- package/lib/category-repository.ts +11 -9
- package/lib/curation-routes.ts +345 -0
- package/lib/cyclotron-suggestions.ts +4 -4
- package/lib/editorial-checks-backend.ts +1 -1
- package/lib/editorial-checks-client.ts +19 -1
- package/lib/editorial-checks-demo.ts +2 -2
- package/lib/editorial-checks-settings.ts +3 -0
- package/lib/editorial-checks.ts +8 -11
- package/lib/editorial-preview.ts +5 -5
- package/lib/monitoring-client.ts +29 -0
- package/lib/monitoring-overview-fixture.ts +51 -0
- package/lib/monitoring-overview.ts +145 -0
- package/lib/news-desk-session.ts +12 -21
- package/lib/newsletter-api.ts +418 -0
- package/lib/newsletter-client.ts +46 -0
- package/lib/newsletter-fixture.ts +184 -0
- package/lib/newsletter-model.ts +202 -0
- package/lib/newsroom-organizer-nav.ts +2 -1
- package/lib/prospectus-session.ts +15 -14
- package/lib/prospectus-sign-in-routes.ts +84 -19
- package/lib/prospectus-site-identity.ts +24 -1
- package/lib/reader-api.ts +145 -0
- package/lib/reader-moderation.ts +220 -0
- package/lib/reader-retirement-route.ts +89 -0
- package/lib/reader-subject.md +29 -0
- package/lib/reader-subject.ts +53 -0
- package/lib/reference-review-details.ts +71 -0
- package/lib/reference-review-request.ts +14 -0
- package/lib/relevance-decisions.ts +42 -10
- package/lib/relevance-status.ts +2 -2
- package/lib/review-client.ts +99 -0
- package/lib/review-fixture.ts +45 -0
- package/lib/review-requests.ts +238 -0
- package/lib/semantic-graph.ts +2 -0
- package/lib/site-brand-tenant.ts +11 -6
- package/lib/site-brand.ts +6 -3
- package/lib/site-origin.ts +67 -0
- package/lib/use-reference-review-data.ts +44 -0
- package/middleware.ts +14 -3
- package/package.json +56 -3
- package/routes.manifest.json +215 -2
- package/amplify/plugins/editorial-checks/function/requirements.txt +0 -1
- package/amplify/plugins/editorial-checks/papyrus.plugin.js +0 -71
- package/amplify/plugins/editorial-checks/papyrus.plugin.ts +0 -87
- package/amplify/plugins/editorial-checks/schema.js +0 -94
|
@@ -0,0 +1,418 @@
|
|
|
1
|
+
import { isIP } from "node:net";
|
|
2
|
+
import { NextResponse, type NextRequest } from "next/server";
|
|
3
|
+
import { readAmplifyOutputsFile } from "./amplify-outputs-path";
|
|
4
|
+
import { PROSPECTUS_SESSION_COOKIE, loadProspectusSessionSealingSecret } from "./prospectus-session";
|
|
5
|
+
import { signedInProspectusSession } from "./prospectus-sign-in-routes";
|
|
6
|
+
import { readerSubject } from "./reader-subject";
|
|
7
|
+
import { SiteOriginNotConfiguredError, publicSiteOrigin, siteOriginNotConfiguredResponse } from "./site-origin";
|
|
8
|
+
|
|
9
|
+
export const NEWSLETTER_FUNCTION_ENV = "PAPYRUS_NEWSLETTER_FUNCTION";
|
|
10
|
+
export const NEWSLETTER_SUBSCRIBE_MAXIMUM_BODY_BYTES = 8192;
|
|
11
|
+
export const NEWSLETTER_CONFIRM_MAXIMUM_BODY_BYTES = 4096;
|
|
12
|
+
export const NEWSLETTER_USER_AGENT_CHARACTERS = 300;
|
|
13
|
+
export const NEWSLETTER_TOKEN = /^[A-Za-z0-9._-]{1,2048}$/;
|
|
14
|
+
const MAXIMUM_LISTS = 20;
|
|
15
|
+
const LIST_ID = /^[a-z][a-z0-9-]{0,39}$/;
|
|
16
|
+
|
|
17
|
+
export type NewsletterEvent = { action: string } & Record<string, unknown>;
|
|
18
|
+
export interface NewsletterResult {
|
|
19
|
+
statusCode: number;
|
|
20
|
+
body: Record<string, unknown>;
|
|
21
|
+
}
|
|
22
|
+
export type NewsletterInvoker = (functionName: string, event: NewsletterEvent) => Promise<NewsletterResult>;
|
|
23
|
+
|
|
24
|
+
export interface NewsletterApiOptions {
|
|
25
|
+
environment?: Record<string, string | undefined>;
|
|
26
|
+
invoke?: NewsletterInvoker;
|
|
27
|
+
/** The parsed `amplify_outputs.json`; read from disk when absent. */
|
|
28
|
+
outputs?: Record<string, unknown> | null;
|
|
29
|
+
loadSealingSecret?: () => Promise<string | null>;
|
|
30
|
+
nowInSeconds?: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const NO_STORE = { "cache-control": "no-store" };
|
|
34
|
+
|
|
35
|
+
function failure(status: number, error: string, message: string): NextResponse {
|
|
36
|
+
return NextResponse.json({ error, message }, { status, headers: NO_STORE });
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export async function invokeNewsletterLambda(functionName: string, event: NewsletterEvent): Promise<NewsletterResult> {
|
|
40
|
+
const { InvokeCommand, LambdaClient } = await import("@aws-sdk/client-lambda");
|
|
41
|
+
const response = await new LambdaClient({}).send(
|
|
42
|
+
new InvokeCommand({ FunctionName: functionName, InvocationType: "RequestResponse", Payload: Buffer.from(JSON.stringify(event)) }),
|
|
43
|
+
);
|
|
44
|
+
if (response.FunctionError || !response.Payload) throw new Error("newsletter failed");
|
|
45
|
+
const result = JSON.parse(Buffer.from(response.Payload).toString("utf8")) as Partial<NewsletterResult>;
|
|
46
|
+
if (typeof result.statusCode !== "number" || typeof result.body !== "object" || result.body === null) {
|
|
47
|
+
throw new Error("newsletter returned an unexpected shape");
|
|
48
|
+
}
|
|
49
|
+
return { statusCode: result.statusCode, body: result.body as Record<string, unknown> };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
type Enabled = { settings: Record<string, unknown>; functionName: string | null };
|
|
53
|
+
|
|
54
|
+
function enabledPlugin(options: NewsletterApiOptions, environment: Record<string, string | undefined>): Enabled | null {
|
|
55
|
+
let outputs: Record<string, unknown> | null;
|
|
56
|
+
try {
|
|
57
|
+
outputs = options.outputs !== undefined ? options.outputs : readAmplifyOutputsFile();
|
|
58
|
+
} catch {
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
const custom = (outputs as { custom?: { papyrusPlugins?: Record<string, unknown>; newsletter?: { actionsFunction?: unknown } } } | null)?.custom;
|
|
62
|
+
const settings = custom?.papyrusPlugins?.newsletter;
|
|
63
|
+
if (typeof settings !== "object" || settings === null) return null;
|
|
64
|
+
const fromEnvironment = (environment[NEWSLETTER_FUNCTION_ENV] ?? "").trim();
|
|
65
|
+
const fromOutputs = custom?.newsletter?.actionsFunction;
|
|
66
|
+
const functionName = fromEnvironment || (typeof fromOutputs === "string" && fromOutputs.trim() ? fromOutputs.trim() : null);
|
|
67
|
+
return { settings: settings as Record<string, unknown>, functionName };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function notFoundJson(): NextResponse {
|
|
71
|
+
return failure(404, "not_found", "Not found.");
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function notFoundPage(): NextResponse {
|
|
75
|
+
return new NextResponse("Not found", { status: 404, headers: { ...NO_STORE, "content-type": "text/plain; charset=utf-8" } });
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** The visitor's address from CloudFront's `ip:port` header (IPv6 may be bracketed), else the first x-forwarded-for entry. */
|
|
79
|
+
export function clientAddress(headers: Pick<Headers, "get">): string {
|
|
80
|
+
const viewer = (headers.get("cloudfront-viewer-address") ?? "").trim();
|
|
81
|
+
if (viewer) {
|
|
82
|
+
let candidate: string;
|
|
83
|
+
if (viewer.startsWith("[")) candidate = viewer.slice(1, Math.max(viewer.indexOf("]"), 1));
|
|
84
|
+
else if (isIP(viewer) === 4) candidate = viewer;
|
|
85
|
+
else candidate = viewer.slice(0, Math.max(viewer.lastIndexOf(":"), 0));
|
|
86
|
+
if (isIP(candidate)) return candidate;
|
|
87
|
+
}
|
|
88
|
+
const forwarded = (headers.get("x-forwarded-for") ?? "").split(",")[0]?.trim() ?? "";
|
|
89
|
+
return isIP(forwarded) ? forwarded : "unknown";
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function readBody(request: NextRequest, maximum: number, contentTypes: string[] | null): Promise<{ text: string } | { response: NextResponse }> {
|
|
93
|
+
const contentType = (request.headers.get("content-type") ?? "").split(";")[0].trim().toLowerCase();
|
|
94
|
+
if (contentTypes !== null && !contentTypes.includes(contentType)) return { response: failure(415, "unsupported_media_type", `Send ${(contentTypes ?? []).join(" or ")}.`) };
|
|
95
|
+
const declared = Number(request.headers.get("content-length") ?? "0");
|
|
96
|
+
if (Number.isFinite(declared) && declared > maximum) return { response: failure(413, "payload_too_large", "The request body is too large.") };
|
|
97
|
+
const text = await request.text();
|
|
98
|
+
if (Buffer.byteLength(text, "utf8") > maximum) return { response: failure(413, "payload_too_large", "The request body is too large.") };
|
|
99
|
+
return { text };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function listIds(raw: unknown): string[] {
|
|
103
|
+
const entries = typeof raw === "string" ? raw.slice(0, 1000).split(",") : Array.isArray(raw) ? raw : [];
|
|
104
|
+
return entries
|
|
105
|
+
.filter((entry): entry is string => typeof entry === "string")
|
|
106
|
+
.map((entry) => entry.trim())
|
|
107
|
+
.filter((entry) => LIST_ID.test(entry))
|
|
108
|
+
.slice(0, MAXIMUM_LISTS);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function limited(value: unknown, maximum: number): string {
|
|
112
|
+
return typeof value === "string" ? value.slice(0, maximum) : "";
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** The reader's pairwise site subject when they happen to be signed in; never required, never logged, never an error. */
|
|
116
|
+
async function optionalSiteSubject(request: NextRequest, options: NewsletterApiOptions, environment: Record<string, string | undefined>): Promise<string | undefined> {
|
|
117
|
+
if (!request.cookies.has(PROSPECTUS_SESSION_COOKIE)) return undefined;
|
|
118
|
+
try {
|
|
119
|
+
const sealingSecret = await (options.loadSealingSecret ?? (() => loadProspectusSessionSealingSecret({ environment })))();
|
|
120
|
+
if (!sealingSecret) return undefined;
|
|
121
|
+
const session = signedInProspectusSession(request, sealingSecret, options.nowInSeconds);
|
|
122
|
+
return readerSubject(session, environment) ?? undefined;
|
|
123
|
+
} catch {
|
|
124
|
+
return undefined;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function siteOriginOrResponse(request: NextRequest, environment: Record<string, string | undefined>): { origin: string } | { response: NextResponse } {
|
|
129
|
+
try {
|
|
130
|
+
return { origin: publicSiteOrigin(request, environment) };
|
|
131
|
+
} catch (error) {
|
|
132
|
+
if (error instanceof SiteOriginNotConfiguredError) return { response: siteOriginNotConfiguredResponse(error) };
|
|
133
|
+
throw error;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Whether a state-changing request came from the site's own pages: its `Origin` is the public site origin or, when the browser
|
|
139
|
+
* sent none, its `Referer` is on it. `Origin: null` (what a browser sends when the page said `Referrer-Policy: no-referrer`,
|
|
140
|
+
* and what a sandboxed or cross-origin redirect chain produces) is never accepted.
|
|
141
|
+
*/
|
|
142
|
+
export function isSameSiteRequest(headers: Pick<Headers, "get">, siteOrigin: string): boolean {
|
|
143
|
+
const origin = headers.get("origin");
|
|
144
|
+
if (origin !== null) return origin === siteOrigin;
|
|
145
|
+
const referer = headers.get("referer");
|
|
146
|
+
if (!referer) return false;
|
|
147
|
+
try {
|
|
148
|
+
return new URL(referer).origin === siteOrigin;
|
|
149
|
+
} catch {
|
|
150
|
+
return false;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** The edge turns an origin answer of 403 or 404 into the site's 404 page, so failures here answer 400. */
|
|
155
|
+
const CROSS_SITE = { status: 400 } as const;
|
|
156
|
+
|
|
157
|
+
/** POST /api/newsletter/subscribe */
|
|
158
|
+
export async function handleNewsletterSubscribe(request: NextRequest, options: NewsletterApiOptions = {}): Promise<NextResponse> {
|
|
159
|
+
const environment = options.environment ?? process.env;
|
|
160
|
+
const plugin = enabledPlugin(options, environment);
|
|
161
|
+
if (!plugin) return notFoundJson();
|
|
162
|
+
const site = siteOriginOrResponse(request, environment);
|
|
163
|
+
if ("response" in site) return site.response;
|
|
164
|
+
if (!isSameSiteRequest(request.headers, site.origin)) return failure(CROSS_SITE.status, "origin_mismatch", "This request did not come from this site.");
|
|
165
|
+
const body = await readBody(request, NEWSLETTER_SUBSCRIBE_MAXIMUM_BODY_BYTES, ["application/json"]);
|
|
166
|
+
if ("response" in body) return body.response;
|
|
167
|
+
let payload: Record<string, unknown>;
|
|
168
|
+
try {
|
|
169
|
+
const parsed = JSON.parse(body.text) as unknown;
|
|
170
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw new Error("not an object");
|
|
171
|
+
payload = parsed as Record<string, unknown>;
|
|
172
|
+
} catch {
|
|
173
|
+
return failure(400, "invalid_json", "The request body is not a JSON object.");
|
|
174
|
+
}
|
|
175
|
+
if (!plugin.functionName) return failure(503, "newsletter_unavailable", "Newsletter signup is not available right now.");
|
|
176
|
+
|
|
177
|
+
const siteSubject = await optionalSiteSubject(request, options, environment);
|
|
178
|
+
const event: NewsletterEvent = {
|
|
179
|
+
action: "subscribe",
|
|
180
|
+
email: limited(payload.email, 320),
|
|
181
|
+
lists: listIds(payload.lists),
|
|
182
|
+
page: limited(payload.page, 500),
|
|
183
|
+
consent: limited(payload.consent, 500),
|
|
184
|
+
formId: limited(payload.formId, 100),
|
|
185
|
+
honeypot: limited(payload.website, 200),
|
|
186
|
+
renderedAt: typeof payload.renderedAt === "number" || typeof payload.renderedAt === "string" ? payload.renderedAt : null,
|
|
187
|
+
ip: clientAddress(request.headers),
|
|
188
|
+
userAgent: limited(request.headers.get("user-agent"), NEWSLETTER_USER_AGENT_CHARACTERS),
|
|
189
|
+
...(siteSubject ? { siteSubject } : {}),
|
|
190
|
+
};
|
|
191
|
+
try {
|
|
192
|
+
const result = await (options.invoke ?? invokeNewsletterLambda)(plugin.functionName, event);
|
|
193
|
+
const status = typeof result.body.status === "string" ? result.body.status : "error";
|
|
194
|
+
return NextResponse.json({ status }, { status: result.statusCode, headers: NO_STORE });
|
|
195
|
+
} catch (error) {
|
|
196
|
+
console.error(`Newsletter subscribe failed (${error instanceof Error ? error.name : "error"}).`);
|
|
197
|
+
return failure(502, "newsletter_unavailable", "Newsletter signup could not be reached.");
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** The site's `urls.basePath` as a path with a leading slash ("/posts"), or "" for a site at the root. Anything unusable counts as no base path. */
|
|
202
|
+
function basePathOf(plugin: Enabled): string {
|
|
203
|
+
const raw = plugin.settings.basePath;
|
|
204
|
+
return typeof raw === "string" && /^[a-z0-9][a-z0-9_-]*(\/[a-z0-9][a-z0-9_-]*){0,2}$/.test(raw) ? `/${raw}` : "";
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export type NewsletterPage = "confirm" | "confirmed" | "preferences" | "unsubscribe" | "unsubscribed" | "expired";
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* People never land on /api. Every answer a person's browser can follow is a redirect to one of the plugin's projected pages,
|
|
211
|
+
* `<base>/newsletter/<page>/` (the directory form, whatever the site's `urls.style`), which the site serves statically.
|
|
212
|
+
*/
|
|
213
|
+
function redirector(request: NextRequest, environment: Record<string, string | undefined>, plugin: Enabled): { origin: string; go: (page: NewsletterPage, query?: Record<string, string>, status?: 302 | 303) => NextResponse } | { response: NextResponse } {
|
|
214
|
+
const site = siteOriginOrResponse(request, environment);
|
|
215
|
+
if ("response" in site) return site;
|
|
216
|
+
const base = basePathOf(plugin);
|
|
217
|
+
return {
|
|
218
|
+
origin: site.origin,
|
|
219
|
+
go: (page, query = {}, status = 303) => {
|
|
220
|
+
const search = new URLSearchParams(query).toString();
|
|
221
|
+
return NextResponse.redirect(new URL(`${base}/newsletter/${page}/${search ? `?${search}` : ""}`, site.origin), {
|
|
222
|
+
status,
|
|
223
|
+
headers: { ...NO_STORE, "referrer-policy": "no-referrer" },
|
|
224
|
+
});
|
|
225
|
+
},
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function configuredLists(plugin: Enabled, ids: string[]): Array<{ id: string; name: string }> {
|
|
230
|
+
const known = Array.isArray(plugin.settings.lists) ? (plugin.settings.lists as Array<{ id?: unknown; name?: unknown }>) : [];
|
|
231
|
+
return ids.flatMap((id) => {
|
|
232
|
+
const match = known.find((entry) => entry.id === id);
|
|
233
|
+
return typeof match?.name === "string" ? [{ id, name: match.name }] : [];
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function call(options: NewsletterApiOptions, functionName: string, event: NewsletterEvent): Promise<NewsletterResult> {
|
|
238
|
+
return (options.invoke ?? invokeNewsletterLambda)(functionName, event);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Next answers HEAD by running the route's GET export, but leaves `request.method` as "HEAD". A check for `=== "GET"` therefore
|
|
243
|
+
* sends a mail scanner's HEAD down the POST path. Every read-only branch asks this instead, so HEAD redirects exactly like GET.
|
|
244
|
+
*/
|
|
245
|
+
function isReadRequest(request: Pick<NextRequest, "method">): boolean {
|
|
246
|
+
return request.method === "GET" || request.method === "HEAD";
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function textResponse(body: string, status: number): NextResponse {
|
|
250
|
+
return new NextResponse(body, { status, headers: { ...NO_STORE, "content-type": "text/plain; charset=utf-8" } });
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function manageTokenFrom(...values: Array<string | null>): string {
|
|
254
|
+
const found = values.find((value) => value !== null && value !== "") ?? "";
|
|
255
|
+
return NEWSLETTER_TOKEN.test(found) ? found : "";
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function listParameter(value: string | null): string {
|
|
259
|
+
return value !== null && LIST_ID.test(value) ? value : "";
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function idsOf(lists: unknown): string[] {
|
|
263
|
+
return (Array.isArray(lists) ? (lists as Array<{ id?: unknown }>) : []).flatMap((entry) => (typeof entry.id === "string" && LIST_ID.test(entry.id) ? [entry.id] : []));
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* /api/newsletter/confirm. POST (form field `t`, from the confirm page) confirms and answers 303 to the confirmed page, or to the
|
|
268
|
+
* expired page. GET is only for confirmation emails sent before the pages moved: it answers 302 to `<base>/newsletter/confirm/?token=`
|
|
269
|
+
* and changes nothing.
|
|
270
|
+
*/
|
|
271
|
+
export async function handleNewsletterConfirm(request: NextRequest, options: NewsletterApiOptions = {}): Promise<NextResponse> {
|
|
272
|
+
const environment = options.environment ?? process.env;
|
|
273
|
+
const plugin = enabledPlugin(options, environment);
|
|
274
|
+
if (!plugin) return notFoundPage();
|
|
275
|
+
const to = redirector(request, environment, plugin);
|
|
276
|
+
if ("response" in to) return to.response;
|
|
277
|
+
if (isReadRequest(request)) {
|
|
278
|
+
const token = manageTokenFrom(request.nextUrl.searchParams.get("t"));
|
|
279
|
+
return token ? to.go("confirm", { token }, 302) : to.go("expired", {}, 302);
|
|
280
|
+
}
|
|
281
|
+
if (!isSameSiteRequest(request.headers, to.origin)) return to.go("expired");
|
|
282
|
+
const body = await readBody(request, NEWSLETTER_CONFIRM_MAXIMUM_BODY_BYTES, ["application/x-www-form-urlencoded"]);
|
|
283
|
+
if ("response" in body) return body.response;
|
|
284
|
+
const token = manageTokenFrom(new URLSearchParams(body.text).get("t"));
|
|
285
|
+
if (!token || !plugin.functionName) return to.go("expired");
|
|
286
|
+
try {
|
|
287
|
+
const result = await call(options, plugin.functionName, {
|
|
288
|
+
action: "confirm",
|
|
289
|
+
token,
|
|
290
|
+
ip: clientAddress(request.headers),
|
|
291
|
+
userAgent: limited(request.headers.get("user-agent"), NEWSLETTER_USER_AGENT_CHARACTERS),
|
|
292
|
+
});
|
|
293
|
+
if (result.body.status !== "confirmed") return to.go("expired");
|
|
294
|
+
const manage = typeof result.body.manageToken === "string" ? manageTokenFrom(result.body.manageToken) : "";
|
|
295
|
+
return to.go("confirmed", { lists: idsOf(result.body.lists).join(","), ...(manage ? { token: manage } : {}) });
|
|
296
|
+
} catch (error) {
|
|
297
|
+
console.error(`Newsletter confirm failed (${error instanceof Error ? error.name : "error"}).`);
|
|
298
|
+
return to.go("expired");
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* /api/newsletter/unsubscribe. RFC 8058 one-click: mail providers POST `List-Unsubscribe=One-Click` to the link in the message header
|
|
304
|
+
* from their own servers, with no Origin, so there is no same-origin check; the answer is plain text, never a redirect. A POST from
|
|
305
|
+
* the unsubscribe page carries `page=1` and gets a 303 to the unsubscribed page. GET (an old email's footer link) answers 302 to
|
|
306
|
+
* `<base>/newsletter/unsubscribe/?token=&list=` and changes nothing.
|
|
307
|
+
*/
|
|
308
|
+
export async function handleNewsletterUnsubscribe(request: NextRequest, options: NewsletterApiOptions = {}): Promise<NextResponse> {
|
|
309
|
+
const environment = options.environment ?? process.env;
|
|
310
|
+
const plugin = enabledPlugin(options, environment);
|
|
311
|
+
if (!plugin) return notFoundPage();
|
|
312
|
+
const to = redirector(request, environment, plugin);
|
|
313
|
+
if ("response" in to) return to.response;
|
|
314
|
+
const query = request.nextUrl.searchParams;
|
|
315
|
+
if (isReadRequest(request)) {
|
|
316
|
+
const token = manageTokenFrom(query.get("t"));
|
|
317
|
+
const listId = listParameter(query.get("l"));
|
|
318
|
+
return token ? to.go("unsubscribe", { token, ...(listId ? { list: listId } : {}) }, 302) : to.go("expired", {}, 302);
|
|
319
|
+
}
|
|
320
|
+
const body = await readBody(request, NEWSLETTER_CONFIRM_MAXIMUM_BODY_BYTES, null);
|
|
321
|
+
if ("response" in body) return body.response;
|
|
322
|
+
const form = new URLSearchParams(body.text);
|
|
323
|
+
const oneClick = form.get("List-Unsubscribe") === "One-Click";
|
|
324
|
+
const fromPage = form.get("page") === "1";
|
|
325
|
+
if (!oneClick && !fromPage) return textResponse("Send List-Unsubscribe=One-Click.", 400);
|
|
326
|
+
const token = manageTokenFrom(query.get("t"), form.get("t"));
|
|
327
|
+
const listId = listParameter(query.get("l") ?? form.get("l"));
|
|
328
|
+
const rejected = () => (fromPage ? to.go("expired") : textResponse("Invalid link.", 400));
|
|
329
|
+
if (!token) return rejected();
|
|
330
|
+
if (!plugin.functionName) return fromPage ? to.go("expired") : textResponse("Unavailable.", 503);
|
|
331
|
+
try {
|
|
332
|
+
const result = await call(options, plugin.functionName, { action: "unsubscribe", token, ...(listId ? { list: listId } : {}) });
|
|
333
|
+
if (result.body.status !== "unsubscribed") return rejected();
|
|
334
|
+
if (!fromPage) return textResponse("Unsubscribed.", 200);
|
|
335
|
+
return to.go("unsubscribed", { lists: idsOf(result.body.lists).join(","), token });
|
|
336
|
+
} catch (error) {
|
|
337
|
+
console.error(`Newsletter unsubscribe failed (${error instanceof Error ? error.name : "error"}).`);
|
|
338
|
+
return fromPage ? to.go("expired") : textResponse("Try again later.", 502);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
const PREFERENCE_MESSAGES = new Set(["saved", "unsubscribed"]);
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* /api/newsletter/preferences. POST (same-origin form from the preferences page) saves, unsubscribes from everything, or deletes
|
|
346
|
+
* after a confirm step, and answers 303 to the preferences page with `msg=saved|unsubscribed|deleted` or `step=delete`. GET (an old
|
|
347
|
+
* email's manage link) answers 302 to `<base>/newsletter/preferences/?token=`.
|
|
348
|
+
*/
|
|
349
|
+
export async function handleNewsletterPreferences(request: NextRequest, options: NewsletterApiOptions = {}): Promise<NextResponse> {
|
|
350
|
+
const environment = options.environment ?? process.env;
|
|
351
|
+
const plugin = enabledPlugin(options, environment);
|
|
352
|
+
if (!plugin) return notFoundPage();
|
|
353
|
+
const to = redirector(request, environment, plugin);
|
|
354
|
+
if ("response" in to) return to.response;
|
|
355
|
+
if (isReadRequest(request)) {
|
|
356
|
+
const token = manageTokenFrom(request.nextUrl.searchParams.get("t"));
|
|
357
|
+
return token ? to.go("preferences", { token }, 302) : to.go("expired", {}, 302);
|
|
358
|
+
}
|
|
359
|
+
if (!isSameSiteRequest(request.headers, to.origin)) return to.go("expired");
|
|
360
|
+
const body = await readBody(request, NEWSLETTER_CONFIRM_MAXIMUM_BODY_BYTES, ["application/x-www-form-urlencoded"]);
|
|
361
|
+
if ("response" in body) return body.response;
|
|
362
|
+
const form = new URLSearchParams(body.text);
|
|
363
|
+
const token = manageTokenFrom(form.get("t"));
|
|
364
|
+
if (!token || !plugin.functionName) return to.go("expired");
|
|
365
|
+
const action = form.get("action");
|
|
366
|
+
if (action === "delete" && form.get("confirm") !== "1") return to.go("preferences", { token, step: "delete" });
|
|
367
|
+
const operation =
|
|
368
|
+
action === "save"
|
|
369
|
+
? { action: "update_preferences", token, lists: form.getAll("l").slice(0, MAXIMUM_LISTS).filter((id) => LIST_ID.test(id)) }
|
|
370
|
+
: action === "unsubscribe_all"
|
|
371
|
+
? { action: "unsubscribe", token }
|
|
372
|
+
: action === "delete"
|
|
373
|
+
? { action: "delete_subscriber", token }
|
|
374
|
+
: null;
|
|
375
|
+
if (!operation) return failure(400, "invalid_action", "Choose save, unsubscribe from everything, or delete.");
|
|
376
|
+
try {
|
|
377
|
+
const status = (await call(options, plugin.functionName, operation)).body.status;
|
|
378
|
+
if (status === "deleted") return to.go("preferences", { msg: "deleted" });
|
|
379
|
+
if (typeof status !== "string" || !PREFERENCE_MESSAGES.has(status)) return to.go("expired");
|
|
380
|
+
return to.go("preferences", { token, msg: status });
|
|
381
|
+
} catch (error) {
|
|
382
|
+
console.error(`Newsletter preferences save failed (${error instanceof Error ? error.name : "error"}).`);
|
|
383
|
+
return to.go("expired");
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* GET /api/newsletter/state: JSON for the projected pages' script, never HTML. `kind=confirm&t=` and `kind=preferences&t=` ask the
|
|
389
|
+
* function (read only); `kind=lists&lists=a,b` names lists from the site's own settings. Failures answer 400 or 502 with a status
|
|
390
|
+
* word, since the edge turns an origin 403 or 404 into the site's 404 page.
|
|
391
|
+
*/
|
|
392
|
+
export async function handleNewsletterState(request: NextRequest, options: NewsletterApiOptions = {}): Promise<NextResponse> {
|
|
393
|
+
const environment = options.environment ?? process.env;
|
|
394
|
+
const plugin = enabledPlugin(options, environment);
|
|
395
|
+
if (!plugin) return notFoundJson();
|
|
396
|
+
const params = request.nextUrl.searchParams;
|
|
397
|
+
const kind = params.get("kind");
|
|
398
|
+
const json = (status: number, body: Record<string, unknown>) => NextResponse.json(body, { status, headers: NO_STORE });
|
|
399
|
+
if (kind === "lists") return json(200, { status: "ready", lists: configuredLists(plugin, listIds(params.get("lists") ?? "")) });
|
|
400
|
+
if (kind !== "confirm" && kind !== "preferences") return json(400, { status: "invalid" });
|
|
401
|
+
const token = manageTokenFrom(params.get("t"));
|
|
402
|
+
if (!token) return json(400, { status: "invalid" });
|
|
403
|
+
if (!plugin.functionName) return json(503, { status: "unavailable" });
|
|
404
|
+
try {
|
|
405
|
+
const result = await call(options, plugin.functionName, { action: kind === "confirm" ? "confirm_page_state" : "preferences_state", token });
|
|
406
|
+
if (result.body.status !== "ready") return json(400, { status: "invalid" });
|
|
407
|
+
const lists = Array.isArray(result.body.lists) ? (result.body.lists as Array<Record<string, unknown>>) : [];
|
|
408
|
+
if (kind === "confirm") return json(200, { status: "ready", lists: lists.flatMap((entry) => (typeof entry.name === "string" ? [{ name: entry.name }] : [])) });
|
|
409
|
+
return json(200, {
|
|
410
|
+
status: "ready",
|
|
411
|
+
email: typeof result.body.email === "string" ? result.body.email : "",
|
|
412
|
+
lists: lists.map((entry) => ({ id: entry.id, name: entry.name, description: entry.description ?? "", subscribed: entry.subscribed === true })),
|
|
413
|
+
});
|
|
414
|
+
} catch (error) {
|
|
415
|
+
console.error(`Newsletter state failed (${error instanceof Error ? error.name : "error"}).`);
|
|
416
|
+
return json(502, { status: "unavailable" });
|
|
417
|
+
}
|
|
418
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { generateClient } from "aws-amplify/data";
|
|
4
|
+
import type { NewsletterFailure } from "./newsletter-model";
|
|
5
|
+
|
|
6
|
+
/** The operations the newsroom Newsletter panel calls. All take and return AWSJSON; the function behind them checks the role again. */
|
|
7
|
+
export const NEWSLETTER_QUERY_OPERATIONS = [
|
|
8
|
+
"newsletterOverview", "newsletterIssues", "newsletterIssue", "newsletterPreview", "newsletterIssueDeliveries", "newsletterSubscribers", "newsletterExport",
|
|
9
|
+
] as const;
|
|
10
|
+
export const NEWSLETTER_MUTATION_OPERATIONS = [
|
|
11
|
+
"newsletterApproveIssue", "newsletterCancelIssue", "newsletterStopIssue", "newsletterTestSend", "newsletterCreateDraft", "newsletterErase", "newsletterResume",
|
|
12
|
+
] as const;
|
|
13
|
+
export type NewsletterOperation = (typeof NEWSLETTER_QUERY_OPERATIONS)[number] | (typeof NEWSLETTER_MUTATION_OPERATIONS)[number];
|
|
14
|
+
|
|
15
|
+
type GraphQLResult = { data?: Record<string, unknown>; errors?: { message?: string; errorType?: string }[] };
|
|
16
|
+
|
|
17
|
+
export type NewsletterApi = <T extends object = Record<string, unknown>>(operation: NewsletterOperation, input?: Record<string, unknown>) => Promise<({ ok: true } & T) | NewsletterFailure>;
|
|
18
|
+
|
|
19
|
+
export function newsletterOperationDocument(operation: NewsletterOperation): string {
|
|
20
|
+
const kind = (NEWSLETTER_MUTATION_OPERATIONS as readonly string[]).includes(operation) ? "mutation" : "query";
|
|
21
|
+
const title = operation.charAt(0).toUpperCase() + operation.slice(1);
|
|
22
|
+
return `${kind} ${title}($input: AWSJSON) { ${operation}(input: $input) }`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Calls one operation through the site's authenticated API (user pool). The browser holds no table name, key or address list. */
|
|
26
|
+
export const callNewsletter: NewsletterApi = async (operation, input = {}) => {
|
|
27
|
+
const client = generateClient({ authMode: "userPool" });
|
|
28
|
+
let result: GraphQLResult;
|
|
29
|
+
try {
|
|
30
|
+
result = (await client.graphql({ query: newsletterOperationDocument(operation), variables: { input: JSON.stringify(input) } })) as GraphQLResult;
|
|
31
|
+
} catch (error) {
|
|
32
|
+
// Amplify throws the response when AppSync returns errors.
|
|
33
|
+
result = (error && typeof error === "object" && "errors" in error ? error : { errors: [{ message: "failed" }] }) as GraphQLResult;
|
|
34
|
+
}
|
|
35
|
+
if (result.errors?.some((entry) => /unauthorized|not authorized|forbidden/i.test(`${entry.errorType ?? ""} ${entry.message ?? ""}`))) {
|
|
36
|
+
return { ok: false, error: "forbidden" };
|
|
37
|
+
}
|
|
38
|
+
const raw = result.data?.[operation];
|
|
39
|
+
if (result.errors?.length || raw === undefined || raw === null) return { ok: false, error: "unavailable" };
|
|
40
|
+
try {
|
|
41
|
+
const parsed = (typeof raw === "string" ? JSON.parse(raw) : raw) as { ok?: boolean };
|
|
42
|
+
return parsed && typeof parsed === "object" && "ok" in parsed ? (parsed as never) : { ok: false, error: "unavailable" };
|
|
43
|
+
} catch {
|
|
44
|
+
return { ok: false, error: "unavailable" };
|
|
45
|
+
}
|
|
46
|
+
};
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A pretend Newsletter service for `?demo=1` and the tests: the same operations and answers as the functions, held in memory.
|
|
3
|
+
* Placeholders only (reader addresses are @example.com); nothing here is a real subscriber, issue or postal address.
|
|
4
|
+
*/
|
|
5
|
+
import type { NewsletterApi, NewsletterOperation } from "./newsletter-client";
|
|
6
|
+
import type {
|
|
7
|
+
NewsletterDelivery,
|
|
8
|
+
NewsletterEvent,
|
|
9
|
+
NewsletterExportRow,
|
|
10
|
+
NewsletterIssue,
|
|
11
|
+
NewsletterList,
|
|
12
|
+
NewsletterSubscriber,
|
|
13
|
+
} from "./newsletter-model";
|
|
14
|
+
|
|
15
|
+
export const FIXTURE_TIME_ZONE = "America/New_York";
|
|
16
|
+
|
|
17
|
+
const LISTS: NewsletterList[] = [
|
|
18
|
+
{ id: "weekly", name: "Weekly roundup", kind: "roundup", confirmed: 412, pending: 17, unsubscribed: 38, timezone: FIXTURE_TIME_ZONE },
|
|
19
|
+
{ id: "alerts-security", name: "Security alerts", kind: "alert", confirmed: 96, pending: 4, unsubscribed: 9 },
|
|
20
|
+
];
|
|
21
|
+
|
|
22
|
+
export function fixtureIssues(): NewsletterIssue[] {
|
|
23
|
+
const counts = { queued: 0, sent: 0, failed: 0, suppressed: 0, skipped: 0, delivered: 0, bounced: 0, complained: 0, softBounced: 0 };
|
|
24
|
+
return [
|
|
25
|
+
{
|
|
26
|
+
issueId: "a1", listId: "weekly", listName: "Weekly roundup", kind: "roundup", itemId: "item-weekly-0122", status: "draft",
|
|
27
|
+
subject: "Weekly roundup: Jan 15 to Jan 22, 2027", counts,
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
issueId: "a2", listId: "alerts-security", listName: "Security alerts", kind: "alert", itemId: "item-alert-1", status: "awaiting_approval",
|
|
31
|
+
subject: "A critical patch is out for the build tool", counts,
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
issueId: "a3", listId: "weekly", listName: "Weekly roundup", kind: "roundup", itemId: "item-weekly-0115", status: "scheduled",
|
|
35
|
+
subject: "Weekly roundup: Jan 8 to Jan 15, 2027", sendAt: "2027-01-15T14:00:00Z", approvedBy: "admin-user", approvedAt: "2027-01-14T18:30:00Z",
|
|
36
|
+
counts, editedSinceApproval: true,
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
issueId: "a4", listId: "weekly", listName: "Weekly roundup", kind: "roundup", itemId: "item-weekly-0108", status: "blocked",
|
|
40
|
+
subject: "Weekly roundup: Jan 1 to Jan 8, 2027", sendAt: "2027-01-08T14:00:00Z", note: "postal address missing", counts,
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
issueId: "a5", listId: "weekly", listName: "Weekly roundup", kind: "roundup", itemId: "item-weekly-0101", status: "sending",
|
|
44
|
+
subject: "Weekly roundup: Dec 25 to Jan 1, 2027", sendAt: "2027-01-01T14:00:00Z", approvedBy: "admin-user",
|
|
45
|
+
counts: { ...counts, queued: 429, sent: 240, delivered: 188 },
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
issueId: "a6", listId: "weekly", listName: "Weekly roundup", kind: "roundup", itemId: "item-weekly-1225", status: "sent",
|
|
49
|
+
subject: "Weekly roundup: Dec 18 to Dec 25, 2026", sendAt: "2026-12-25T14:00:00Z", sentAt: "2026-12-25T14:09:40Z", approvedBy: "admin-user",
|
|
50
|
+
archiveUrl: "https://news.example.com/roundup/2026-12-25.html",
|
|
51
|
+
counts: { ...counts, queued: 421, sent: 418, delivered: 409, bounced: 3, complained: 1, failed: 1, suppressed: 2, softBounced: 2 },
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
issueId: "a7", listId: "alerts-security", listName: "Security alerts", kind: "alert", itemId: "item-alert-0", status: "quarantined",
|
|
55
|
+
subject: "An alert with a status this panel does not know", counts,
|
|
56
|
+
},
|
|
57
|
+
];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function deliveriesFor(issueId: string, full: boolean): NewsletterDelivery[] {
|
|
61
|
+
const address = (index: number) => (full ? `reader${index}@example.com` : `r***@example.com`);
|
|
62
|
+
const rows: NewsletterDelivery[] = [
|
|
63
|
+
{ address: address(1), status: "delivered", at: "2026-12-25T14:00:04Z", eventAt: "2026-12-25T14:00:09Z" },
|
|
64
|
+
{ address: address(2), status: "delivered", at: "2026-12-25T14:00:05Z", eventAt: "2026-12-25T14:00:12Z" },
|
|
65
|
+
{ address: address(3), status: "bounced", at: "2026-12-25T14:00:06Z", eventAt: "2026-12-25T14:00:20Z" },
|
|
66
|
+
{ address: address(4), status: "complained", at: "2026-12-25T14:00:07Z", eventAt: "2026-12-25T14:02:31Z" },
|
|
67
|
+
{ address: address(5), status: "failed", at: "2026-12-25T14:00:08Z", error: "MessageRejected" },
|
|
68
|
+
{ address: "(erased)", status: "sent", at: "2026-12-25T14:00:09Z" },
|
|
69
|
+
];
|
|
70
|
+
return issueId === "a6" || issueId === "a5" ? rows : [];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const SUBSCRIBERS: NewsletterSubscriber[] = [
|
|
74
|
+
{ email: "reader1@example.com", status: "confirmed", lists: [{ id: "weekly", status: "subscribed" }, { id: "alerts-security", status: "subscribed" }], createdAt: "2026-11-02T10:11:00Z", confirmedAt: "2026-11-02T10:13:00Z" },
|
|
75
|
+
{ email: "reader2@example.com", status: "confirmed", lists: [{ id: "weekly", status: "subscribed" }], createdAt: "2026-11-09T08:00:00Z", confirmedAt: "2026-11-09T08:02:00Z" },
|
|
76
|
+
{ email: "reader3@example.com", status: "pending", lists: [{ id: "alerts-security", status: "pending" }], createdAt: "2026-12-30T21:45:00Z" },
|
|
77
|
+
{ email: "=reader4@example.com", status: "unsubscribed", lists: [{ id: "weekly", status: "unsubscribed" }], createdAt: "2026-10-01T09:00:00Z", confirmedAt: "2026-10-01T09:05:00Z" },
|
|
78
|
+
];
|
|
79
|
+
|
|
80
|
+
const EVENTS: NewsletterEvent[] = [
|
|
81
|
+
{ type: "Complaint", at: "2026-12-25T14:02:31Z", issue: "a6", list: "weekly" },
|
|
82
|
+
{ type: "Bounce", at: "2026-12-25T14:00:20Z", issue: "a6", list: "weekly", detail: "Permanent" },
|
|
83
|
+
{ type: "Delivery", at: "2026-12-25T14:00:12Z", issue: "a6", list: "weekly" },
|
|
84
|
+
{ type: "Delivery", at: "2026-12-25T14:00:09Z", issue: "a6", list: "weekly" },
|
|
85
|
+
{ type: "Bounce", at: "2026-12-25T13:59:50Z", issue: "a5", list: "weekly", detail: "Transient" },
|
|
86
|
+
];
|
|
87
|
+
|
|
88
|
+
const SAMPLE_HTML = `<!doctype html><html><body style="margin:0;font-family:Georgia,serif;color:#111"><div style="max-width:560px;margin:0 auto;padding:24px"><h1 style="font-size:24px;margin:0 0 12px">Weekly roundup</h1><p>A short introduction goes here.</p><h2 style="font-size:18px">Security</h2><ul><li><a href="#preview">A critical patch is out</a>: update today.</li></ul><hr><p style="font-size:12px;color:#555">You get this because you subscribed. <a href="#preview">Manage</a> · <a href="#preview">Unsubscribe</a><br>1 Example Street, Example City</p></div></body></html>`;
|
|
89
|
+
|
|
90
|
+
export type FixtureOptions = { postalAddress?: boolean; admin?: boolean };
|
|
91
|
+
|
|
92
|
+
/** One fixture service per mounted panel, so a demo session keeps its changes until reload. */
|
|
93
|
+
export function createFixtureApi(options: FixtureOptions = {}): NewsletterApi {
|
|
94
|
+
const full = options.admin !== false;
|
|
95
|
+
const issues = fixtureIssues();
|
|
96
|
+
const subscribers = [...SUBSCRIBERS];
|
|
97
|
+
const paused = { paused: true, reason: "papyrus-newsletter-example-bounce-rate: bounce rate above 4%", pausedAt: "2026-12-26T09:12:00Z" } as { paused: boolean; reason?: string; pausedAt?: string };
|
|
98
|
+
const findIssue = (input: Record<string, unknown>) => issues.find((issue) => issue.issueId === input.issueId);
|
|
99
|
+
const failure = (error: string, message = "") => ({ ok: false as const, error, message });
|
|
100
|
+
|
|
101
|
+
const handlers: Record<NewsletterOperation, (input: Record<string, unknown>) => object> = {
|
|
102
|
+
newsletterOverview: () => ({
|
|
103
|
+
ok: true,
|
|
104
|
+
lists: LISTS,
|
|
105
|
+
totals: { confirmed: 488, pending: 20, unsubscribed: 45 },
|
|
106
|
+
suppressed: 12,
|
|
107
|
+
paused: { ...paused },
|
|
108
|
+
events: EVENTS,
|
|
109
|
+
}),
|
|
110
|
+
newsletterIssues: (input) => ({
|
|
111
|
+
ok: true,
|
|
112
|
+
issues: issues.filter((issue) => (!input.status || issue.status === input.status) && (!input.listId || issue.listId === input.listId)).map(({ editedSinceApproval: _edited, ...rest }) => rest),
|
|
113
|
+
}),
|
|
114
|
+
newsletterIssue: (input) => {
|
|
115
|
+
const issue = findIssue(input);
|
|
116
|
+
return issue ? { ok: true, issue } : failure("not_found");
|
|
117
|
+
},
|
|
118
|
+
newsletterPreview: (input) => {
|
|
119
|
+
const issue = findIssue(input);
|
|
120
|
+
if (!issue) return failure("not_found");
|
|
121
|
+
return { ok: true, subject: issue.subject, html: SAMPLE_HTML, text: `${issue.subject}\n\nA short introduction goes here.\n\nSecurity\n- A critical patch is out: update today.\n\nYou get this because you subscribed.`, bytes: SAMPLE_HTML.length, postalAddress: options.postalAddress !== false };
|
|
122
|
+
},
|
|
123
|
+
newsletterIssueDeliveries: (input) => (findIssue(input) ? { ok: true, deliveries: deliveriesFor(String(input.issueId), full) } : failure("not_found")),
|
|
124
|
+
newsletterSubscribers: (input) => ({
|
|
125
|
+
ok: true,
|
|
126
|
+
subscribers: subscribers.filter((row) => (!input.status || row.status === input.status) && (!input.listId || row.lists.some((entry) => entry.id === input.listId))),
|
|
127
|
+
}),
|
|
128
|
+
newsletterExport: () => ({
|
|
129
|
+
ok: true,
|
|
130
|
+
rows: subscribers.filter((row) => row.status !== "unsubscribed").map((row): NewsletterExportRow => ({
|
|
131
|
+
email: row.email, status: row.status, lists: row.lists.map((entry) => `${entry.id}:${entry.status}`).join(";"), createdAt: row.createdAt,
|
|
132
|
+
consentRequestedAt: row.createdAt, consentConfirmedAt: row.confirmedAt ?? "", consentFormId: "newsletter",
|
|
133
|
+
consentPageUrl: "https://news.example.com/roundup/", consentWording: "Send me the weekly email.",
|
|
134
|
+
})),
|
|
135
|
+
}),
|
|
136
|
+
newsletterApproveIssue: (input) => {
|
|
137
|
+
const issue = findIssue(input);
|
|
138
|
+
if (!issue) return failure("not_found");
|
|
139
|
+
if (!["draft", "awaiting_approval", "scheduled", "blocked"].includes(issue.status)) return failure("invalid_state");
|
|
140
|
+
issue.status = "scheduled";
|
|
141
|
+
issue.sendAt = String(input.sendAt);
|
|
142
|
+
issue.approvedBy = "admin-user";
|
|
143
|
+
delete issue.note;
|
|
144
|
+
delete issue.editedSinceApproval;
|
|
145
|
+
return { ok: true, issue, adjusted: false, warnings: options.postalAddress === false ? ["postal address missing: the send will be blocked until an operator sets it"] : [] };
|
|
146
|
+
},
|
|
147
|
+
newsletterCancelIssue: (input) => {
|
|
148
|
+
const issue = findIssue(input);
|
|
149
|
+
if (!issue) return failure("not_found");
|
|
150
|
+
if (issue.status !== "scheduled") return failure("invalid_state");
|
|
151
|
+
issue.status = "draft";
|
|
152
|
+
issue.note = "cancelled by admin-user";
|
|
153
|
+
delete issue.sendAt;
|
|
154
|
+
delete issue.editedSinceApproval;
|
|
155
|
+
return { ok: true, issue };
|
|
156
|
+
},
|
|
157
|
+
newsletterStopIssue: (input) => {
|
|
158
|
+
const issue = findIssue(input);
|
|
159
|
+
if (!issue) return failure("not_found");
|
|
160
|
+
if (issue.status !== "sending") return failure("invalid_state");
|
|
161
|
+
issue.status = "stopped";
|
|
162
|
+
issue.note = "stopped by admin-user";
|
|
163
|
+
return { ok: true, issue };
|
|
164
|
+
},
|
|
165
|
+
newsletterTestSend: (input) => (findIssue(input) ? { ok: true } : failure("not_found")),
|
|
166
|
+
newsletterCreateDraft: (input) => (input.listId === "weekly" ? { ok: true, status: "exists", issueId: "a1", itemId: "item-weekly-0122" } : failure("bad_request")),
|
|
167
|
+
newsletterErase: (input) => {
|
|
168
|
+
const at = subscribers.findIndex((row) => row.email === input.email);
|
|
169
|
+
if (at >= 0) subscribers.splice(at, 1);
|
|
170
|
+
return { ok: true, removed: at >= 0 ? 3 : 2 };
|
|
171
|
+
},
|
|
172
|
+
newsletterResume: () => {
|
|
173
|
+
paused.paused = false;
|
|
174
|
+
paused.reason = undefined;
|
|
175
|
+
return { ok: true, paused: false };
|
|
176
|
+
},
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
return (async (operation: NewsletterOperation, input: Record<string, unknown> = {}) => {
|
|
180
|
+
const admin = ["newsletterApproveIssue", "newsletterCancelIssue", "newsletterStopIssue", "newsletterSubscribers", "newsletterExport", "newsletterErase", "newsletterResume"];
|
|
181
|
+
if (options.admin === false && admin.includes(operation)) return { ok: false, error: "forbidden" };
|
|
182
|
+
return handlers[operation](input);
|
|
183
|
+
}) as NewsletterApi;
|
|
184
|
+
}
|