@anthusai/papyrus 2.0.0-next.5 → 2.0.0-next.51
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/amplify/data/resource.js +23 -3
- package/amplify/data/resource.ts +25 -3
- package/amplify/data/schema.js +2028 -1979
- package/amplify/data/schema.ts +72 -14
- package/amplify/functions/assignment-action/data-access.js +90 -0
- package/amplify/functions/assignment-action/data-access.ts +89 -0
- package/amplify/functions/assignment-action/handler.ts +1 -1
- package/amplify/functions/content-actions/handler.py +53 -2
- package/amplify/functions/content-actions/requirements.txt +1 -0
- package/amplify/functions/content-actions/resource.js +1 -1
- package/amplify/functions/content-actions/resource.ts +1 -1
- package/amplify/functions/editorial-checks/handler.py +14 -0
- package/amplify/functions/editorial-checks/requirements.txt +1 -0
- package/amplify/{plugins/editorial-checks/function → functions/editorial-checks}/resource.js +7 -7
- package/amplify/{plugins/editorial-checks/function → functions/editorial-checks}/resource.ts +7 -7
- package/amplify/functions/editorial-checks/schema.js +121 -0
- package/amplify/{plugins → functions}/editorial-checks/schema.ts +33 -6
- package/amplify/functions/editorial-checks/wire.js +53 -0
- package/amplify/functions/editorial-checks/wire.ts +61 -0
- package/amplify/functions/monitoring-view/handler.py +11 -0
- package/amplify/functions/monitoring-view/resource.js +24 -0
- package/amplify/functions/monitoring-view/resource.ts +34 -0
- package/amplify/functions/monitoring-view/schema.js +16 -0
- package/amplify/functions/monitoring-view/schema.ts +18 -0
- package/amplify/functions/monitoring-view/wire.js +37 -0
- package/amplify/functions/monitoring-view/wire.ts +47 -0
- package/amplify/functions/public-forms/core.js +247 -0
- package/amplify/functions/public-forms/core.ts +310 -0
- package/amplify/functions/public-forms/handler.ts +41 -0
- package/amplify/functions/public-forms/origins.js +35 -0
- package/amplify/functions/public-forms/origins.ts +43 -0
- package/amplify/functions/public-forms/resource.js +9 -0
- package/amplify/functions/public-forms/resource.ts +10 -0
- package/amplify/functions/public-forms/wire.js +25 -0
- package/amplify/functions/public-forms/wire.ts +32 -0
- package/amplify/functions/shared/editorial-context.ts +63 -4
- package/amplify/functions/shared/python-bundle.js +20 -14
- package/amplify/functions/shared/python-bundle.ts +25 -18
- package/amplify/plugins/newsletter/config.js +195 -0
- package/amplify/plugins/newsletter/config.ts +240 -0
- package/amplify/plugins/newsletter/function/handler.py +6 -0
- package/amplify/plugins/newsletter/function/requirements.txt +5 -0
- package/amplify/plugins/newsletter/ops/handler.py +12 -0
- package/amplify/plugins/newsletter/ops/resource.js +27 -0
- package/amplify/plugins/newsletter/ops/resource.ts +37 -0
- package/amplify/plugins/newsletter/papyrus.plugin.js +260 -0
- package/amplify/plugins/newsletter/papyrus.plugin.ts +308 -0
- package/amplify/plugins/newsletter/schema.js +56 -0
- package/amplify/plugins/newsletter/schema.ts +61 -0
- package/amplify/plugins/newsletter/secure-string-parameter.js +107 -0
- package/amplify/plugins/newsletter/secure-string-parameter.ts +120 -0
- package/amplify/plugins/newsletter/sending.js +140 -0
- package/amplify/plugins/newsletter/sending.ts +186 -0
- package/amplify/plugins/reader-steering/config.js +162 -0
- package/amplify/plugins/reader-steering/config.ts +248 -0
- package/amplify/plugins/reader-steering/function/ai_moderation.py +246 -0
- package/amplify/plugins/reader-steering/function/handler.py +1363 -0
- package/amplify/plugins/reader-steering/function/moderation-prompt.md +27 -0
- package/amplify/plugins/reader-steering/function/moderation.py +316 -0
- package/amplify/plugins/reader-steering/function/resource.js +27 -0
- package/amplify/plugins/reader-steering/function/resource.ts +35 -0
- package/amplify/plugins/reader-steering/papyrus.plugin.js +175 -0
- package/amplify/plugins/reader-steering/papyrus.plugin.ts +201 -0
- package/amplify/plugins/reader-steering/schema.js +140 -0
- package/amplify/plugins/reader-steering/schema.ts +142 -0
- package/amplify/plugins/registry.js +4 -2
- package/amplify/plugins/registry.ts +4 -2
- package/amplify/plugins/types.ts +5 -0
- package/amplify/plugins/wire.js +1 -0
- package/amplify/plugins/wire.ts +1 -0
- package/amplify/publication/redirect-and-index.js +105 -0
- package/amplify/publication/redirect-rules.js +73 -0
- package/amplify/publication/redirect-rules.ts +93 -0
- package/amplify/publication/s3-projection.js +7 -1
- package/amplify/publication/s3-projection.ts +9 -1
- package/amplify/site-backend-config.ts +17 -0
- package/amplify/site-backend.js +36 -1
- package/amplify/site-backend.ts +44 -1
- package/app/api/newsletter/confirm/route.ts +13 -0
- package/app/api/newsletter/preferences/route.ts +13 -0
- package/app/api/newsletter/subscribe/route.ts +9 -0
- package/app/api/newsletter/unsubscribe/route.ts +13 -0
- package/app/api/newsroom/curation/context/route.ts +9 -0
- package/app/api/newsroom/curation/priming/route.ts +9 -0
- package/app/api/newsroom/curation/publication/route.ts +9 -0
- package/app/api/reader/comment/delete/route.ts +9 -0
- package/app/api/reader/comment/edit/route.ts +9 -0
- package/app/api/reader/comment/route.ts +9 -0
- package/app/api/reader/handle/route.ts +9 -0
- package/app/api/reader/me/route.ts +9 -0
- package/app/api/reader/rating/route.ts +9 -0
- package/app/api/reader/report/route.ts +9 -0
- package/app/api/reader/retired/route.ts +9 -0
- package/app/api/reader/submit/route.ts +9 -0
- package/app/api/reader/vote/route.ts +9 -0
- package/bin/papyrus-infra.mjs +20 -1
- package/components/editorial-checks-admin-panel.tsx +36 -11
- package/components/moderation-panel.tsx +248 -0
- package/components/monitoring-overview-view.tsx +241 -0
- package/components/monitoring-panel.tsx +40 -0
- package/components/newsletter-panel.tsx +474 -0
- package/components/newsletter-sections.tsx +163 -0
- package/components/newsroom-article-editor.tsx +2 -4
- package/components/newsroom-board-view.tsx +4 -1
- package/components/newsroom-curation-bootstrap.tsx +45 -0
- package/components/newsroom-organizer.css +31 -0
- package/components/newsroom-references-view.tsx +14 -14
- package/components/publication-cyclotron-panel.tsx +15 -10
- package/components/reference-add-entry.tsx +75 -0
- package/components/reference-curation-controls.tsx +31 -22
- package/components/reference-cyclotron-priming.tsx +58 -19
- package/components/reference-cyclotron-suggestion.tsx +1 -1
- package/components/reference-priming.tsx +14 -10
- package/components/reference-section-suggestion.tsx +1 -1
- package/components/topic-steering-workspace.tsx +64 -20
- package/components/use-editorial-checks.ts +12 -10
- package/infra/amplify-app-shell.js +90 -16
- package/infra/amplify-app-shell.ts +102 -18
- package/infra/index.js +1 -0
- package/infra/secure-string-parameter-handler.js +41 -0
- package/infra/secure-string-parameter-handler.ts +41 -0
- package/infra/secure-string-parameter.js +79 -0
- package/infra/secure-string-parameter.ts +91 -0
- package/infra/site-config.js +85 -6
- package/infra/site-config.ts +123 -6
- package/lib/category-repository.ts +11 -9
- package/lib/curation-routes.ts +345 -0
- package/lib/editorial-checks-backend.ts +1 -1
- package/lib/editorial-checks-client.ts +19 -1
- package/lib/editorial-checks-demo.ts +2 -2
- package/lib/editorial-checks-settings.ts +3 -0
- package/lib/editorial-checks.ts +8 -11
- package/lib/editorial-preview.ts +5 -5
- package/lib/monitoring-client.ts +29 -0
- package/lib/monitoring-overview-fixture.ts +51 -0
- package/lib/monitoring-overview.ts +145 -0
- package/lib/newsletter-api.ts +435 -0
- package/lib/newsletter-client.ts +46 -0
- package/lib/newsletter-fixture.ts +184 -0
- package/lib/newsletter-model.ts +202 -0
- package/lib/newsletter-pages.ts +364 -0
- package/lib/newsroom-board.ts +27 -2
- package/lib/prospectus-session.ts +15 -14
- package/lib/prospectus-sign-in-routes.ts +54 -13
- package/lib/prospectus-site-identity.ts +24 -1
- package/lib/reader-api.ts +145 -0
- package/lib/reader-moderation.ts +220 -0
- package/lib/reader-retirement-route.ts +89 -0
- package/lib/reader-subject.md +29 -0
- package/lib/reader-subject.ts +53 -0
- package/lib/relevance-status.ts +2 -2
- package/lib/site-brand-tenant.ts +11 -6
- package/lib/site-brand.ts +6 -3
- package/lib/site-origin.ts +67 -0
- package/middleware.ts +14 -3
- package/package.json +42 -4
- package/routes.manifest.json +191 -1
- package/amplify/plugins/editorial-checks/function/handler.py +0 -11
- package/amplify/plugins/editorial-checks/function/requirements.txt +0 -1
- package/amplify/plugins/editorial-checks/papyrus.plugin.js +0 -50
- package/amplify/plugins/editorial-checks/papyrus.plugin.ts +0 -63
- package/amplify/plugins/editorial-checks/schema.js +0 -94
|
@@ -0,0 +1,364 @@
|
|
|
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. They are drawn inside the site's own chrome (the projected newsletter shell: the site's head,
|
|
6
|
+
* stylesheets, header and footer) and fall back to a self-contained page themed from the plugin's public settings. Every
|
|
7
|
+
* dynamic value is escaped, the token never reaches a link, and the pages are never cached or indexed.
|
|
8
|
+
*/
|
|
9
|
+
export type NewsletterPageTheme = { accent: string; text: string; background: string; surface: string; muted: string; fontFamily: string };
|
|
10
|
+
|
|
11
|
+
export const DEFAULT_PAGE_THEME: NewsletterPageTheme = {
|
|
12
|
+
accent: "#1a4d8f",
|
|
13
|
+
text: "#1b1b1b",
|
|
14
|
+
background: "#ffffff",
|
|
15
|
+
surface: "#f4f4f4",
|
|
16
|
+
muted: "#555555",
|
|
17
|
+
fontFamily: "Georgia, 'Times New Roman', serif",
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const UNSAFE_CSS = /[<>{};"\\`]|url\(|expression\(|@import/i;
|
|
21
|
+
const MAXIMUM_LISTS_SHOWN = 20;
|
|
22
|
+
|
|
23
|
+
export function escapeHtml(value: string): string {
|
|
24
|
+
return value.replace(/[&<>"']/g, (character) => `&#${character.charCodeAt(0)};`);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Theme tokens from `custom.papyrusPlugins.newsletter.theme`; a missing or unsafe value falls back to the default. */
|
|
28
|
+
export function pageTheme(raw: unknown): NewsletterPageTheme {
|
|
29
|
+
const theme: NewsletterPageTheme = { ...DEFAULT_PAGE_THEME };
|
|
30
|
+
if (typeof raw !== "object" || raw === null) return theme;
|
|
31
|
+
for (const key of Object.keys(theme) as Array<keyof NewsletterPageTheme>) {
|
|
32
|
+
const value = (raw as Record<string, unknown>)[key];
|
|
33
|
+
if (typeof value === "string" && value.length > 0 && value.length <= 200 && !UNSAFE_CSS.test(value)) theme[key] = value;
|
|
34
|
+
}
|
|
35
|
+
return theme;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export type FormSpec = {
|
|
39
|
+
action: string;
|
|
40
|
+
/** Hidden fields, such as the token. */
|
|
41
|
+
hidden: Record<string, string>;
|
|
42
|
+
checkboxes?: Array<{ name: string; value: string; label: string; checked: boolean }>;
|
|
43
|
+
/** Every button says what it does in words. `secondary` only changes how it looks. */
|
|
44
|
+
buttons: Array<{ label: string; name?: string; value?: string; secondary?: boolean }>;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
export type PageContent = {
|
|
48
|
+
siteName: string;
|
|
49
|
+
theme: NewsletterPageTheme;
|
|
50
|
+
heading: string;
|
|
51
|
+
/** A short status line shown above the text (for example "Saved"), as words. */
|
|
52
|
+
notice?: string;
|
|
53
|
+
paragraphs: string[];
|
|
54
|
+
lists?: string[];
|
|
55
|
+
forms?: FormSpec[];
|
|
56
|
+
links?: Array<{ href: string; label: string }>;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
function renderForm(form: FormSpec): string {
|
|
60
|
+
const hidden = Object.entries(form.hidden)
|
|
61
|
+
.map(([name, value]) => `<input type="hidden" name="${escapeHtml(name)}" value="${escapeHtml(value)}">`)
|
|
62
|
+
.join("");
|
|
63
|
+
const boxes = (form.checkboxes ?? [])
|
|
64
|
+
.map(
|
|
65
|
+
(box, index) =>
|
|
66
|
+
`<p class="markus-newsletter-list"><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>`,
|
|
67
|
+
)
|
|
68
|
+
.join("");
|
|
69
|
+
const buttons = form.buttons
|
|
70
|
+
.map(
|
|
71
|
+
(button) =>
|
|
72
|
+
`<button type="submit" class="markus-button"${button.secondary ? ' data-variant="secondary"' : ""}${button.name ? ` name="${escapeHtml(button.name)}" value="${escapeHtml(button.value ?? "")}"` : ""}>${escapeHtml(button.label)}</button>`,
|
|
73
|
+
)
|
|
74
|
+
.join(" ");
|
|
75
|
+
return `<form class="markus-newsletter-form" method="post" action="${escapeHtml(form.action)}">${hidden}${boxes}<p class="markus-actions">${buttons}</p></form>`;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* The content of a page, in the standard front end's own classes (`markus-document`, `markus-button`, `markus-actions`), so
|
|
80
|
+
* inside the site's chrome it takes the site's typography, colours, buttons and focus rings from the site's stylesheets.
|
|
81
|
+
*/
|
|
82
|
+
export function renderBody(page: PageContent): string {
|
|
83
|
+
const lists = (page.lists ?? []).slice(0, MAXIMUM_LISTS_SHOWN);
|
|
84
|
+
const links = (page.links ?? []).map((link) => `<p><a href="${escapeHtml(link.href)}">${escapeHtml(link.label)}</a></p>`).join("\n");
|
|
85
|
+
return `<div class="markus-document markus-newsletter-page" data-newsletter-page>
|
|
86
|
+
<h1>${escapeHtml(page.heading)}</h1>
|
|
87
|
+
${page.notice ? `<p class="markus-newsletter-notice" role="status">${escapeHtml(page.notice)}</p>` : ""}
|
|
88
|
+
${page.paragraphs.map((paragraph) => `<p>${escapeHtml(paragraph)}</p>`).join("\n")}
|
|
89
|
+
${lists.length > 0 ? `<ul>${lists.map((name) => `<li>${escapeHtml(name)}</li>`).join("")}</ul>` : ""}
|
|
90
|
+
${(page.forms ?? []).map(renderForm).join("\n")}
|
|
91
|
+
${links}
|
|
92
|
+
</div>`;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** The self-contained page, for a site whose chrome shell cannot be fetched. Themed from the plugin's settings. */
|
|
96
|
+
export function renderPage(page: PageContent): string {
|
|
97
|
+
const { theme } = page;
|
|
98
|
+
return `<!doctype html>
|
|
99
|
+
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
|
|
100
|
+
<meta name="robots" content="noindex">
|
|
101
|
+
<title>${escapeHtml(page.heading)} - ${escapeHtml(page.siteName)}</title>
|
|
102
|
+
<style>
|
|
103
|
+
body{margin:0;background:${theme.background};color:${theme.text};font-family:${theme.fontFamily};line-height:1.5}
|
|
104
|
+
main{max-width:34rem;margin:0 auto;padding:3rem 1.25rem}
|
|
105
|
+
.site{color:${theme.muted};font-size:.95rem;margin:0 0 1.5rem}
|
|
106
|
+
h1{font-size:1.6rem;line-height:1.2;margin:0 0 1rem}
|
|
107
|
+
.markus-newsletter-notice{background:${theme.surface};border-left:4px solid ${theme.accent};padding:.5rem .75rem;font-weight:bold}
|
|
108
|
+
ul{background:${theme.surface};margin:1rem 0;padding:.75rem 1.25rem 1rem 2rem}
|
|
109
|
+
.markus-newsletter-list{margin:.6rem 0}
|
|
110
|
+
input[type=checkbox]{width:1.1rem;height:1.1rem;vertical-align:middle}
|
|
111
|
+
.markus-actions{margin:1.25rem 0}
|
|
112
|
+
.markus-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}
|
|
113
|
+
.markus-button[data-variant=secondary]{background:${theme.background};color:${theme.accent}}
|
|
114
|
+
.markus-button:focus-visible,a:focus-visible,input:focus-visible{outline:3px solid ${theme.text};outline-offset:2px}
|
|
115
|
+
a{color:${theme.accent}}
|
|
116
|
+
</style></head><body><main>
|
|
117
|
+
<p class="site">${escapeHtml(page.siteName)}</p>
|
|
118
|
+
${renderBody(page)}
|
|
119
|
+
</main></body></html>`;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Where the standard projector writes the chrome shell when the newsletter plugin is enabled. */
|
|
123
|
+
export const SHELL_PATH = "/_plugins/newsletter-shell/index.html";
|
|
124
|
+
export const SHELL_MARKER = "<!--papyrus:newsletter-content-->";
|
|
125
|
+
export const SHELL_TTL_MS = 5 * 60 * 1000;
|
|
126
|
+
const SHELL_FAILURE_TTL_MS = 30 * 1000;
|
|
127
|
+
const SHELL_MAXIMUM_CHARACTERS = 512 * 1024;
|
|
128
|
+
const SHELL_TIMEOUT_MS = 3000;
|
|
129
|
+
|
|
130
|
+
export type ShellFetcher = (url: string) => Promise<{ status: number; contentType: string; text: string }>;
|
|
131
|
+
|
|
132
|
+
export interface ShellOptions {
|
|
133
|
+
fetchShell?: ShellFetcher;
|
|
134
|
+
/** Milliseconds; for tests. */
|
|
135
|
+
now?: () => number;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async function fetchOverHttp(url: string): Promise<{ status: number; contentType: string; text: string }> {
|
|
139
|
+
const response = await fetch(url, { headers: { accept: "text/html" }, redirect: "manual", signal: AbortSignal.timeout(SHELL_TIMEOUT_MS) });
|
|
140
|
+
return { status: response.status, contentType: response.headers.get("content-type") ?? "", text: await response.text() };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* The shell as a template: the site's own page (full head, header, footer) with one content marker. Scripts are removed
|
|
145
|
+
* (these pages run none, and their content security policy forbids them), and the canonical and og:url links, which name
|
|
146
|
+
* the shell, are removed. Null when the page is not a usable shell.
|
|
147
|
+
*/
|
|
148
|
+
export function prepareShell(html: string): string | null {
|
|
149
|
+
if (html.length > SHELL_MAXIMUM_CHARACTERS || html.split(SHELL_MARKER).length !== 2) return null;
|
|
150
|
+
return html
|
|
151
|
+
.replace(/<script\b[\s\S]*?<\/script\s*>/gi, "")
|
|
152
|
+
.replace(/<link\b[^>]*\brel=["']canonical["'][^>]*>\s*/gi, "")
|
|
153
|
+
.replace(/<meta\b[^>]*\bproperty=["']og:url["'][^>]*>\s*/gi, "");
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
type ShellEntry = { template: string | null; at: number };
|
|
157
|
+
const shells = new Map<string, ShellEntry>();
|
|
158
|
+
const fetching = new Map<string, Promise<string | null>>();
|
|
159
|
+
|
|
160
|
+
export function clearShellCache(): void {
|
|
161
|
+
shells.clear();
|
|
162
|
+
fetching.clear();
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* The prepared shell of the site at `origin`, cached in memory for five minutes. A failed fetch keeps serving the last good
|
|
167
|
+
* shell, or answers null (the caller falls back to the self-contained page), and is not retried for 30 seconds.
|
|
168
|
+
*/
|
|
169
|
+
export async function loadShell(origin: string, options: ShellOptions = {}): Promise<string | null> {
|
|
170
|
+
const now = (options.now ?? Date.now)();
|
|
171
|
+
const cached = shells.get(origin);
|
|
172
|
+
if (cached && now - cached.at < (cached.template ? SHELL_TTL_MS : SHELL_FAILURE_TTL_MS)) return cached.template;
|
|
173
|
+
const running = fetching.get(origin);
|
|
174
|
+
if (running) return running;
|
|
175
|
+
const attempt = (async () => {
|
|
176
|
+
let template: string | null = null;
|
|
177
|
+
try {
|
|
178
|
+
const response = await (options.fetchShell ?? fetchOverHttp)(new URL(SHELL_PATH, origin).toString());
|
|
179
|
+
if (response.status === 200 && /html/i.test(response.contentType)) template = prepareShell(response.text);
|
|
180
|
+
} catch {
|
|
181
|
+
template = null;
|
|
182
|
+
}
|
|
183
|
+
// A failure keeps the last good shell; the retry clock restarts either way.
|
|
184
|
+
const kept = template ?? cached?.template ?? null;
|
|
185
|
+
shells.set(origin, { template: kept, at: now });
|
|
186
|
+
return kept;
|
|
187
|
+
})().finally(() => fetching.delete(origin));
|
|
188
|
+
fetching.set(origin, attempt);
|
|
189
|
+
return attempt;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** The page inside the site's chrome: the shell's title replaced and its marker replaced by the page content. */
|
|
193
|
+
export function renderShellPage(template: string, page: PageContent): string {
|
|
194
|
+
const title = `${escapeHtml(page.heading)} - ${escapeHtml(page.siteName)}`;
|
|
195
|
+
return template.replace(/<title>[\s\S]*?<\/title>/i, () => `<title>${title}</title>`).replace(SHELL_MARKER, () => renderBody(page));
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export const PAGE_HEADERS = {
|
|
199
|
+
"content-type": "text/html; charset=utf-8",
|
|
200
|
+
"cache-control": "no-store",
|
|
201
|
+
"referrer-policy": "same-origin",
|
|
202
|
+
"x-content-type-options": "nosniff",
|
|
203
|
+
"x-robots-tag": "noindex",
|
|
204
|
+
"content-security-policy":
|
|
205
|
+
"default-src 'none'; style-src 'self' 'unsafe-inline' https:; font-src 'self' data: https:; img-src 'self' data: https:; form-action 'self'; base-uri 'none'; frame-ancestors 'none'",
|
|
206
|
+
} as const;
|
|
207
|
+
|
|
208
|
+
/** `shell` is the prepared shell of the site (see `loadShell`); without one the self-contained page is sent. */
|
|
209
|
+
export function pageResponse(page: PageContent, status = 200, shell: string | null = null): NextResponse {
|
|
210
|
+
return new NextResponse(shell ? renderShellPage(shell, page) : renderPage(page), { status, headers: PAGE_HEADERS });
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export const CONFIRM_PATH = "/api/newsletter/confirm";
|
|
214
|
+
|
|
215
|
+
type Base = Pick<PageContent, "siteName" | "theme">;
|
|
216
|
+
const home = (base: Base) => ({ href: "/", label: `Back to ${base.siteName}` });
|
|
217
|
+
|
|
218
|
+
export function confirmPromptPage(base: Base, lists: string[], token: string): PageContent {
|
|
219
|
+
return {
|
|
220
|
+
...base,
|
|
221
|
+
heading: "Confirm your subscription",
|
|
222
|
+
paragraphs: [`Press the button to start getting email from ${base.siteName}.${lists.length > 0 ? " You are signing up for:" : ""}`],
|
|
223
|
+
lists,
|
|
224
|
+
forms: [{ action: CONFIRM_PATH, hidden: { t: token }, buttons: [{ label: "Confirm subscription" }] }],
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export function confirmedPage(base: Base, lists: string[], preferencesHref?: string): PageContent {
|
|
229
|
+
return {
|
|
230
|
+
...base,
|
|
231
|
+
heading: "You are subscribed",
|
|
232
|
+
paragraphs: [lists.length > 0 ? "Thank you. You will get:" : "Thank you. Your subscription is confirmed."],
|
|
233
|
+
lists,
|
|
234
|
+
links: [...(preferencesHref ? [{ href: preferencesHref, label: "Manage your subscriptions" }] : []), home(base)],
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
export function expiredPage(base: Base): PageContent {
|
|
239
|
+
return {
|
|
240
|
+
...base,
|
|
241
|
+
heading: "This link has expired",
|
|
242
|
+
paragraphs: [
|
|
243
|
+
"This confirmation link has expired or was already used. If you already confirmed, you are all set.",
|
|
244
|
+
"If you did not finish signing up, go back to the site and sign up again to get a new link.",
|
|
245
|
+
],
|
|
246
|
+
links: [home(base)],
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
export function crossSitePage(base: Base): PageContent {
|
|
251
|
+
return {
|
|
252
|
+
...base,
|
|
253
|
+
heading: "That did not go through",
|
|
254
|
+
paragraphs: ["This form was not sent from this site, so nothing was changed. Open the link in your email again and use the button on the page."],
|
|
255
|
+
links: [home(base)],
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
export function unavailablePage(base: Base): PageContent {
|
|
260
|
+
return {
|
|
261
|
+
...base,
|
|
262
|
+
heading: "Something went wrong",
|
|
263
|
+
paragraphs: ["We could not complete this just now. Open the link in your email again in a minute."],
|
|
264
|
+
links: [home(base)],
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
export const UNSUBSCRIBE_PATH = "/api/newsletter/unsubscribe";
|
|
269
|
+
export const PREFERENCES_PATH = "/api/newsletter/preferences";
|
|
270
|
+
|
|
271
|
+
export function preferencesHref(token: string): string {
|
|
272
|
+
return `${PREFERENCES_PATH}?t=${encodeURIComponent(token)}`;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
export function invalidManagePage(base: Base): PageContent {
|
|
276
|
+
return {
|
|
277
|
+
...base,
|
|
278
|
+
heading: "This link is not valid",
|
|
279
|
+
paragraphs: ["This link is not valid, or the subscription it belongs to has been deleted. Use the link in a recent email from us."],
|
|
280
|
+
links: [home(base)],
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
export function unsubscribePromptPage(base: Base, token: string, listId: string | null, listName: string | null): PageContent {
|
|
285
|
+
return {
|
|
286
|
+
...base,
|
|
287
|
+
heading: "Unsubscribe",
|
|
288
|
+
paragraphs: [listName ? `Stop getting "${listName}" from ${base.siteName}?` : `Stop getting all email from ${base.siteName}?`],
|
|
289
|
+
forms: [
|
|
290
|
+
{
|
|
291
|
+
action: UNSUBSCRIBE_PATH,
|
|
292
|
+
hidden: { t: token, ...(listId ? { l: listId } : {}), page: "1" },
|
|
293
|
+
buttons: [{ label: listName ? "Unsubscribe from this list" : "Unsubscribe from everything" }],
|
|
294
|
+
},
|
|
295
|
+
],
|
|
296
|
+
links: [{ href: preferencesHref(token), label: "Choose which lists you get instead" }],
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
export function unsubscribedPage(base: Base, names: string[], token: string | null): PageContent {
|
|
301
|
+
return {
|
|
302
|
+
...base,
|
|
303
|
+
heading: "You are unsubscribed",
|
|
304
|
+
paragraphs: [names.length > 0 ? `You will no longer get these from ${base.siteName}:` : `You will no longer get this email from ${base.siteName}.`],
|
|
305
|
+
lists: names,
|
|
306
|
+
links: [...(token ? [{ href: preferencesHref(token), label: "Change your mind? Manage your subscriptions" }] : []), home(base)],
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
export type PreferenceList = { id: string; name: string; description: string; subscribed: boolean };
|
|
311
|
+
|
|
312
|
+
export function preferencesPage(base: Base, input: { token: string; email: string; lists: PreferenceList[]; notice?: string }): PageContent {
|
|
313
|
+
return {
|
|
314
|
+
...base,
|
|
315
|
+
heading: "Your subscriptions",
|
|
316
|
+
notice: input.notice,
|
|
317
|
+
paragraphs: [`Email preferences for ${input.email}. Tick the lists you want.`],
|
|
318
|
+
forms: [
|
|
319
|
+
{
|
|
320
|
+
action: PREFERENCES_PATH,
|
|
321
|
+
hidden: { t: input.token },
|
|
322
|
+
checkboxes: input.lists.map((list) => ({
|
|
323
|
+
name: "l",
|
|
324
|
+
value: list.id,
|
|
325
|
+
label: list.description ? `${list.name}: ${list.description}` : list.name,
|
|
326
|
+
checked: list.subscribed,
|
|
327
|
+
})),
|
|
328
|
+
buttons: [
|
|
329
|
+
{ label: "Save preferences", name: "action", value: "save" },
|
|
330
|
+
{ label: "Unsubscribe from everything", name: "action", value: "unsubscribe_all", secondary: true },
|
|
331
|
+
{ label: "Delete my data", name: "action", value: "delete", secondary: true },
|
|
332
|
+
],
|
|
333
|
+
},
|
|
334
|
+
],
|
|
335
|
+
links: [home(base)],
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
export function deleteConfirmPage(base: Base, token: string): PageContent {
|
|
340
|
+
return {
|
|
341
|
+
...base,
|
|
342
|
+
heading: "Delete your data?",
|
|
343
|
+
paragraphs: [
|
|
344
|
+
`This erases your address and everything ${base.siteName} holds about your subscriptions. You will not get any more email. This cannot be undone.`,
|
|
345
|
+
],
|
|
346
|
+
forms: [
|
|
347
|
+
{
|
|
348
|
+
action: PREFERENCES_PATH,
|
|
349
|
+
hidden: { t: token, action: "delete", confirm: "1" },
|
|
350
|
+
buttons: [{ label: "Yes, delete my data" }],
|
|
351
|
+
},
|
|
352
|
+
],
|
|
353
|
+
links: [{ href: preferencesHref(token), label: "No, go back" }],
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
export function deletedPage(base: Base): PageContent {
|
|
358
|
+
return {
|
|
359
|
+
...base,
|
|
360
|
+
heading: "Your data is deleted",
|
|
361
|
+
paragraphs: ["Your address and subscriptions have been erased. You will not get any more email from us."],
|
|
362
|
+
links: [home(base)],
|
|
363
|
+
};
|
|
364
|
+
}
|
package/lib/newsroom-board.ts
CHANGED
|
@@ -21,6 +21,31 @@ export interface NativeBoardIssue extends KanbanIssue {
|
|
|
21
21
|
href: string;
|
|
22
22
|
/** A few lines to show under the headline: the blurb, else the subtitle. */
|
|
23
23
|
excerpt?: string;
|
|
24
|
+
/** The quiet path shown where a board would print the ID, e.g. `articles/my-slug` or `assignments/write-it`. */
|
|
25
|
+
displayId: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function slugify(text: string): string {
|
|
29
|
+
return text.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Section and slug for a content item, falling back to the publication's board path segment. */
|
|
33
|
+
export function contentItemDisplayId(version: { section?: string | null; slug?: string | null; lineageId: string }): string {
|
|
34
|
+
const section = slugify(version.section ?? "") || contentItemBoardPathSegment();
|
|
35
|
+
return `${section}/${slugify(version.slug ?? "") || version.lineageId.slice(0, 8)}`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Assignments have no slug: use the title as one, else a short id. */
|
|
39
|
+
export function assignmentDisplayId(assignment: { id: string; title?: string | null }): string {
|
|
40
|
+
const titleSlug = slugify(assignment.title ?? "").slice(0, 48).replace(/-+$/g, "");
|
|
41
|
+
return `assignments/${titleSlug || assignment.id.slice(0, 8)}`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** `formatIssueId` for the Kanbus UI: the host-provided path, never the raw `kind:id` prefix. */
|
|
45
|
+
export function formatNativeIssueId(issue: KanbanIssue): string {
|
|
46
|
+
const displayId = (issue as Partial<NativeBoardIssue>).displayId;
|
|
47
|
+
if (displayId) return displayId;
|
|
48
|
+
return issue.id.replace(/^[A-Za-z]+:/, "").slice(0, 12);
|
|
24
49
|
}
|
|
25
50
|
|
|
26
51
|
export function contentItemBoardPathSegment(): string {
|
|
@@ -60,7 +85,7 @@ export function projectContentItemBoard(records: {
|
|
|
60
85
|
title: version.headline || version.title || version.slug, nativeStatus: stage, status: `${contentItemType}/${stage}`,
|
|
61
86
|
priority: 2, description: workCount ? `${workCount} assignment${workCount === 1 ? "" : "s"} on this ${contentItemType}.` : undefined,
|
|
62
87
|
updated_at: version.updatedAt ?? undefined, href: `/newsroom/articles/${encodeURIComponent(version.id)}`,
|
|
63
|
-
excerpt: contentItemExcerpt(version),
|
|
88
|
+
excerpt: contentItemExcerpt(version), displayId: contentItemDisplayId(version),
|
|
64
89
|
};
|
|
65
90
|
});
|
|
66
91
|
}
|
|
@@ -73,7 +98,7 @@ export function projectAssignmentBoard(assignments: readonly AssignmentRecord[])
|
|
|
73
98
|
priority: row.priority ?? 2, assignee: row.assigneeId || undefined,
|
|
74
99
|
description: row.summary || row.brief || undefined,
|
|
75
100
|
created_at: row.createdAt, updated_at: row.updatedAt,
|
|
76
|
-
href: `/newsroom/assignments/${encodeURIComponent(row.id)}`,
|
|
101
|
+
href: `/newsroom/assignments/${encodeURIComponent(row.id)}`, displayId: assignmentDisplayId(row),
|
|
77
102
|
}));
|
|
78
103
|
}
|
|
79
104
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createCipheriv, createDecipheriv, hkdfSync, randomBytes } from "node:crypto";
|
|
2
|
-
import {
|
|
2
|
+
import { GetParameterCommand, SSMClient } from "@aws-sdk/client-ssm";
|
|
3
3
|
import { readAmplifyOutputsFile } from "./amplify-outputs-path";
|
|
4
4
|
import {
|
|
5
5
|
ProspectusSiteAccounts,
|
|
@@ -21,7 +21,7 @@ import {
|
|
|
21
21
|
export const PROSPECTUS_PENDING_SIGN_IN_COOKIE = "__Host-papyrus-prospectus-pending";
|
|
22
22
|
export const PROSPECTUS_SESSION_COOKIE = "__Host-papyrus-prospectus-session";
|
|
23
23
|
export const PROSPECTUS_ACCOUNTS_ENDPOINT_ENV = "PAPYRUS_PROSPECTUS_ACCOUNTS_ENDPOINT";
|
|
24
|
-
export const
|
|
24
|
+
export const PROSPECTUS_SESSION_SECRET_PARAMETER_ENV = "PAPYRUS_PROSPECTUS_SESSION_SECRET_PARAMETER";
|
|
25
25
|
export const PROSPECTUS_SESSION_SECRET_CACHE_MILLISECONDS = 5 * 60 * 1000;
|
|
26
26
|
|
|
27
27
|
const SEALED_COOKIE_FORMAT_VERSION = "v1";
|
|
@@ -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 {
|
|
@@ -101,37 +102,37 @@ export function sameSiteReturnPath(requestedReturnPath: string | null | undefine
|
|
|
101
102
|
return `${parsed.pathname}${parsed.search}${parsed.hash}`;
|
|
102
103
|
}
|
|
103
104
|
|
|
104
|
-
export type ProspectusSessionSecretReader = (
|
|
105
|
+
export type ProspectusSessionSecretReader = (parameterName: string) => Promise<string | null>;
|
|
105
106
|
|
|
106
|
-
type CachedSealingSecret = {
|
|
107
|
+
type CachedSealingSecret = { parameterName: string; value: string; expiresAt: number };
|
|
107
108
|
|
|
108
109
|
let cachedSealingSecret: CachedSealingSecret | null = null;
|
|
109
|
-
let
|
|
110
|
+
let defaultSsmClient: SSMClient | null = null;
|
|
110
111
|
|
|
111
112
|
export function resetProspectusSessionSecretCache(): void {
|
|
112
113
|
cachedSealingSecret = null;
|
|
113
114
|
}
|
|
114
115
|
|
|
115
|
-
async function
|
|
116
|
-
|
|
117
|
-
const response = await
|
|
118
|
-
return response.
|
|
116
|
+
export async function readSealingSecretFromSsm(parameterName: string, client?: Pick<SSMClient, "send">): Promise<string | null> {
|
|
117
|
+
defaultSsmClient ??= new SSMClient({});
|
|
118
|
+
const response = await (client ?? defaultSsmClient).send(new GetParameterCommand({ Name: parameterName, WithDecryption: true }));
|
|
119
|
+
return response.Parameter?.Value?.trim() || null;
|
|
119
120
|
}
|
|
120
121
|
|
|
121
122
|
export async function loadProspectusSessionSealingSecret(
|
|
122
123
|
options: { environment?: Record<string, string | undefined>; readSecret?: ProspectusSessionSecretReader; now?: number } = {},
|
|
123
124
|
): Promise<string | null> {
|
|
124
125
|
const environment = options.environment ?? process.env;
|
|
125
|
-
const
|
|
126
|
-
if (!
|
|
126
|
+
const parameterName = environment[PROSPECTUS_SESSION_SECRET_PARAMETER_ENV]?.trim();
|
|
127
|
+
if (!parameterName) return null;
|
|
127
128
|
const now = options.now ?? Date.now();
|
|
128
|
-
if (cachedSealingSecret && cachedSealingSecret.
|
|
129
|
+
if (cachedSealingSecret && cachedSealingSecret.parameterName === parameterName && cachedSealingSecret.expiresAt > now) {
|
|
129
130
|
return cachedSealingSecret.value;
|
|
130
131
|
}
|
|
131
132
|
try {
|
|
132
|
-
const value = (await (options.readSecret ??
|
|
133
|
+
const value = (await (options.readSecret ?? readSealingSecretFromSsm)(parameterName))?.trim();
|
|
133
134
|
if (!value || value.length < MINIMUM_SEALING_SECRET_LENGTH) return null;
|
|
134
|
-
cachedSealingSecret = {
|
|
135
|
+
cachedSealingSecret = { parameterName, value, expiresAt: now + PROSPECTUS_SESSION_SECRET_CACHE_MILLISECONDS };
|
|
135
136
|
return value;
|
|
136
137
|
} catch (error) {
|
|
137
138
|
console.error(`Prospectus session sealing secret could not be read (${error instanceof Error ? error.name : "error"}).`);
|
|
@@ -25,6 +25,7 @@ import {
|
|
|
25
25
|
publishingAccountsForSite,
|
|
26
26
|
type PublicationCapability,
|
|
27
27
|
} from "./prospectus-publication-access";
|
|
28
|
+
import { SiteOriginNotConfiguredError, publicSiteOrigin, siteOriginNotConfiguredResponse } from "./site-origin";
|
|
28
29
|
|
|
29
30
|
/**
|
|
30
31
|
* The site's sign-in routes for Prospectus accounts: start (challenge, then the
|
|
@@ -38,6 +39,8 @@ export interface ProspectusSignInRouteOptions {
|
|
|
38
39
|
siteProduct?: string | null;
|
|
39
40
|
completionOverrides?: Pick<CompleteProspectusGoogleSignInInput, "verifyCognitoTokens" | "fetcher">;
|
|
40
41
|
nowInSeconds?: () => number;
|
|
42
|
+
/** Trusted configuration the public site origin is read from; defaults to process.env. */
|
|
43
|
+
environment?: Record<string, string | undefined>;
|
|
41
44
|
}
|
|
42
45
|
|
|
43
46
|
const NO_STORE_HEADERS = { "cache-control": "no-store" };
|
|
@@ -50,8 +53,22 @@ function currentTimeInSeconds(options: ProspectusSignInRouteOptions): number {
|
|
|
50
53
|
return options.nowInSeconds?.() ?? Math.floor(Date.now() / 1000);
|
|
51
54
|
}
|
|
52
55
|
|
|
56
|
+
function siteOriginFor(request: NextRequest, options: ProspectusSignInRouteOptions): string {
|
|
57
|
+
return publicSiteOrigin(request, options.environment);
|
|
58
|
+
}
|
|
59
|
+
|
|
53
60
|
async function signInRuntimeFor(request: NextRequest, options: ProspectusSignInRouteOptions): Promise<ProspectusSignInRuntime | null> {
|
|
54
|
-
return (options.resolveRuntime ?? resolveProspectusSignInRuntime)(request
|
|
61
|
+
return (options.resolveRuntime ?? resolveProspectusSignInRuntime)(siteOriginFor(request, options));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Runs a route and answers a missing site origin with 503 site_origin_not_configured, never a localhost redirect. */
|
|
65
|
+
async function failingClosedWithoutSiteOrigin(route: () => Promise<NextResponse>): Promise<NextResponse> {
|
|
66
|
+
try {
|
|
67
|
+
return await route();
|
|
68
|
+
} catch (error) {
|
|
69
|
+
if (error instanceof SiteOriginNotConfiguredError) return siteOriginNotConfiguredResponse(error);
|
|
70
|
+
throw error;
|
|
71
|
+
}
|
|
55
72
|
}
|
|
56
73
|
|
|
57
74
|
function signInUnavailableResponse(): NextResponse {
|
|
@@ -82,7 +99,11 @@ export function signedInProspectusSession(
|
|
|
82
99
|
);
|
|
83
100
|
}
|
|
84
101
|
|
|
85
|
-
export
|
|
102
|
+
export function handleProspectusSignInStart(request: NextRequest, options: ProspectusSignInRouteOptions = {}): Promise<NextResponse> {
|
|
103
|
+
return failingClosedWithoutSiteOrigin(() => handleProspectusSignInStartRoute(request, options));
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function handleProspectusSignInStartRoute(request: NextRequest, options: ProspectusSignInRouteOptions): Promise<NextResponse> {
|
|
86
107
|
const runtime = await signInRuntimeFor(request, options);
|
|
87
108
|
if (!runtime) return signInUnavailableResponse();
|
|
88
109
|
const { authorizationUrl, pendingSignIn } = await beginProspectusGoogleSignIn(runtime.accounts, runtime.cognitoClient);
|
|
@@ -99,7 +120,11 @@ export async function handleProspectusSignInStart(request: NextRequest, options:
|
|
|
99
120
|
return response;
|
|
100
121
|
}
|
|
101
122
|
|
|
102
|
-
export
|
|
123
|
+
export function handleProspectusSignInCallback(request: NextRequest, options: ProspectusSignInRouteOptions = {}): Promise<NextResponse> {
|
|
124
|
+
return failingClosedWithoutSiteOrigin(() => handleProspectusSignInCallbackRoute(request, options));
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async function handleProspectusSignInCallbackRoute(request: NextRequest, options: ProspectusSignInRouteOptions): Promise<NextResponse> {
|
|
103
128
|
const runtime = await signInRuntimeFor(request, options);
|
|
104
129
|
if (!runtime) return signInUnavailableResponse();
|
|
105
130
|
const nowInSeconds = currentTimeInSeconds(options);
|
|
@@ -141,8 +166,9 @@ async function completeCallback(
|
|
|
141
166
|
personId: signedIn.person.id,
|
|
142
167
|
accessToken: signedIn.accessToken,
|
|
143
168
|
accessTokenExpiresAt: signedIn.accessTokenExpiresAt,
|
|
169
|
+
...(signedIn.person.siteSubject ? { siteSubject: signedIn.person.siteSubject } : {}),
|
|
144
170
|
};
|
|
145
|
-
const response = NextResponse.redirect(new URL(pendingSignIn.returnPath, request
|
|
171
|
+
const response = NextResponse.redirect(new URL(pendingSignIn.returnPath, siteOriginFor(request, options)), { status: 303, headers: NO_STORE_HEADERS });
|
|
146
172
|
response.cookies.set(
|
|
147
173
|
PROSPECTUS_SESSION_COOKIE,
|
|
148
174
|
sealProspectusCookie(runtime.sealingSecret, PROSPECTUS_SESSION_COOKIE, session, session.accessTokenExpiresAt),
|
|
@@ -156,7 +182,11 @@ async function completeCallback(
|
|
|
156
182
|
}
|
|
157
183
|
}
|
|
158
184
|
|
|
159
|
-
export
|
|
185
|
+
export function handleProspectusSessionRead(request: NextRequest, options: ProspectusSignInRouteOptions = {}): Promise<NextResponse> {
|
|
186
|
+
return failingClosedWithoutSiteOrigin(() => handleProspectusSessionReadRoute(request, options));
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
async function handleProspectusSessionReadRoute(request: NextRequest, options: ProspectusSignInRouteOptions): Promise<NextResponse> {
|
|
160
190
|
const runtime = await signInRuntimeFor(request, options);
|
|
161
191
|
if (!runtime) return signInUnavailableResponse();
|
|
162
192
|
const session = signedInProspectusSession(request, runtime.sealingSecret, currentTimeInSeconds(options));
|
|
@@ -169,11 +199,10 @@ export async function handleProspectusSessionRead(request: NextRequest, options:
|
|
|
169
199
|
return response;
|
|
170
200
|
}
|
|
171
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.
|
|
172
203
|
return NextResponse.json(
|
|
173
204
|
{
|
|
174
205
|
signedIn: true,
|
|
175
|
-
person: directory.person,
|
|
176
|
-
accounts: directory.accounts,
|
|
177
206
|
cyclotronLabAccess: directory.accounts.some((account) =>
|
|
178
207
|
account.products.some((product) => product.product === CYCLOTRON_LAB_PRODUCT && product.status === "active"),
|
|
179
208
|
),
|
|
@@ -196,9 +225,13 @@ export async function handleProspectusSessionRead(request: NextRequest, options:
|
|
|
196
225
|
}
|
|
197
226
|
}
|
|
198
227
|
|
|
199
|
-
export
|
|
228
|
+
export function handleProspectusSignOut(request: NextRequest, options: ProspectusSignInRouteOptions = {}): Promise<NextResponse> {
|
|
229
|
+
return failingClosedWithoutSiteOrigin(() => handleProspectusSignOutRoute(request, options));
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
async function handleProspectusSignOutRoute(request: NextRequest, options: ProspectusSignInRouteOptions): Promise<NextResponse> {
|
|
200
233
|
const runtime = await signInRuntimeFor(request, options);
|
|
201
|
-
const signedOutDestination = new URL("/", request
|
|
234
|
+
const signedOutDestination = new URL("/", siteOriginFor(request, options));
|
|
202
235
|
const destination = runtime
|
|
203
236
|
? new URL(
|
|
204
237
|
`/logout?${new URLSearchParams({ client_id: runtime.cognitoClient.clientId, logout_uri: signedOutDestination.toString() })}`,
|
|
@@ -250,10 +283,14 @@ export async function currentProspectusPublishingSelection(
|
|
|
250
283
|
}
|
|
251
284
|
}
|
|
252
285
|
|
|
253
|
-
export
|
|
286
|
+
export function handleProspectusAccountSelection(request: NextRequest, options: ProspectusSignInRouteOptions = {}): Promise<NextResponse> {
|
|
287
|
+
return failingClosedWithoutSiteOrigin(() => handleProspectusAccountSelectionRoute(request, options));
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
async function handleProspectusAccountSelectionRoute(request: NextRequest, options: ProspectusSignInRouteOptions): Promise<NextResponse> {
|
|
254
291
|
const runtime = await signInRuntimeFor(request, options);
|
|
255
292
|
if (!runtime) return signInUnavailableResponse();
|
|
256
|
-
if (request.headers.get("origin") !== request
|
|
293
|
+
if (request.headers.get("origin") !== siteOriginFor(request, options)) {
|
|
257
294
|
return signInFailedResponse(403, "cross_origin_selection", "Choose an account from this site.");
|
|
258
295
|
}
|
|
259
296
|
const nowInSeconds = currentTimeInSeconds(options);
|
|
@@ -287,10 +324,14 @@ const MAXIMUM_PUBLISHING_ACCOUNT_NAME_LENGTH = 150;
|
|
|
287
324
|
const DEFAULT_PUBLISHING_ACCOUNT_NAME = "Publishing account";
|
|
288
325
|
const IDEMPOTENCY_KEY_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
289
326
|
|
|
290
|
-
export
|
|
327
|
+
export function handleProspectusPublishingAccountCreation(request: NextRequest, options: ProspectusSignInRouteOptions = {}): Promise<NextResponse> {
|
|
328
|
+
return failingClosedWithoutSiteOrigin(() => handleProspectusPublishingAccountCreationRoute(request, options));
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
async function handleProspectusPublishingAccountCreationRoute(request: NextRequest, options: ProspectusSignInRouteOptions): Promise<NextResponse> {
|
|
291
332
|
const runtime = await signInRuntimeFor(request, options);
|
|
292
333
|
if (!runtime) return signInUnavailableResponse();
|
|
293
|
-
if (request.headers.get("origin") !== request
|
|
334
|
+
if (request.headers.get("origin") !== siteOriginFor(request, options)) {
|
|
294
335
|
return signInFailedResponse(403, "cross_origin_creation", "Create an account from this site.");
|
|
295
336
|
}
|
|
296
337
|
const nowInSeconds = currentTimeInSeconds(options);
|