@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,202 @@
|
|
|
1
|
+
/** Shapes and wording for the newsroom Newsletter panel. `src/papyrus_content/newsletter/` (panel.py, issues.py) produces them. */
|
|
2
|
+
import { getNewsroomNavHref } from "./newsroom-nav";
|
|
3
|
+
import { csvCell } from "./signup-csv";
|
|
4
|
+
|
|
5
|
+
export type NewsletterCounts = Partial<Record<"queued" | "sent" | "failed" | "suppressed" | "skipped" | "delivered" | "bounced" | "complained" | "softBounced", number>>;
|
|
6
|
+
|
|
7
|
+
export type NewsletterList = { id: string; name: string; kind: string; confirmed: number; pending: number; unsubscribed: number; timezone?: string };
|
|
8
|
+
|
|
9
|
+
export type NewsletterEvent = { type: string; at: string; issue?: string; list?: string; detail?: string };
|
|
10
|
+
|
|
11
|
+
export type NewsletterOverview = {
|
|
12
|
+
lists: NewsletterList[];
|
|
13
|
+
totals: { confirmed: number; pending: number; unsubscribed: number };
|
|
14
|
+
suppressed: number;
|
|
15
|
+
paused: { paused: boolean; reason?: string; pausedAt?: string; resumedAt?: string };
|
|
16
|
+
events: NewsletterEvent[];
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export type NewsletterIssue = {
|
|
20
|
+
issueId: string;
|
|
21
|
+
listId: string;
|
|
22
|
+
listName?: string;
|
|
23
|
+
kind: string;
|
|
24
|
+
itemId?: string;
|
|
25
|
+
subject: string;
|
|
26
|
+
status: string;
|
|
27
|
+
sendAt?: string;
|
|
28
|
+
approvedBy?: string;
|
|
29
|
+
approvedAt?: string;
|
|
30
|
+
sentAt?: string;
|
|
31
|
+
counts: NewsletterCounts;
|
|
32
|
+
note?: string;
|
|
33
|
+
archiveUrl?: string;
|
|
34
|
+
editedSinceApproval?: boolean;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export type NewsletterPreview = { subject: string; html: string; text: string; bytes: number; postalAddress: boolean };
|
|
38
|
+
|
|
39
|
+
export type NewsletterDelivery = { address: string; status: string; at: string; eventAt?: string; error?: string };
|
|
40
|
+
|
|
41
|
+
export type NewsletterSubscriber = { email: string; status: string; lists: { id: string; status: string }[]; createdAt: string; confirmedAt?: string };
|
|
42
|
+
|
|
43
|
+
export type NewsletterExportRow = {
|
|
44
|
+
email: string;
|
|
45
|
+
status: string;
|
|
46
|
+
lists: string;
|
|
47
|
+
createdAt: string;
|
|
48
|
+
consentRequestedAt: string;
|
|
49
|
+
consentConfirmedAt: string;
|
|
50
|
+
consentFormId: string;
|
|
51
|
+
consentPageUrl: string;
|
|
52
|
+
consentWording: string;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export type NewsletterFailure = { ok: false; error: string; message?: string };
|
|
56
|
+
|
|
57
|
+
/** The newsroom words for the statuses the functions write. A status this panel does not know is shown as written, never hidden. */
|
|
58
|
+
const STATUS_LABELS: Record<string, string> = {
|
|
59
|
+
draft: "Draft",
|
|
60
|
+
awaiting_approval: "Awaiting approval",
|
|
61
|
+
scheduled: "Scheduled",
|
|
62
|
+
sending: "Sending",
|
|
63
|
+
sent: "Sent",
|
|
64
|
+
stopped: "Stopped",
|
|
65
|
+
cancelled: "Cancelled",
|
|
66
|
+
blocked: "Blocked",
|
|
67
|
+
};
|
|
68
|
+
/** A mark beside the word, so no status relies on colour alone. */
|
|
69
|
+
const STATUS_MARKS: Record<string, string> = { draft: "✎", awaiting_approval: "?", scheduled: "◷", sending: "▶", sent: "✓", stopped: "■", cancelled: "–", blocked: "!" };
|
|
70
|
+
|
|
71
|
+
export function statusLabel(status: string): string {
|
|
72
|
+
if (STATUS_LABELS[status]) return STATUS_LABELS[status];
|
|
73
|
+
const words = status.replace(/_/g, " ").trim();
|
|
74
|
+
return words ? words.charAt(0).toUpperCase() + words.slice(1) : "Unknown";
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function statusMark(status: string): string {
|
|
78
|
+
return STATUS_MARKS[status] ?? "·";
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export const ISSUE_STATUS_FILTERS = ["draft", "awaiting_approval", "scheduled", "sending", "sent", "blocked", "stopped", "cancelled"] as const;
|
|
82
|
+
|
|
83
|
+
/** Approval accepts a draft, an alert awaiting approval, a scheduled issue (a new time) and a blocked one (cause fixed). */
|
|
84
|
+
export function canApprove(status: string): boolean {
|
|
85
|
+
return status === "draft" || status === "awaiting_approval" || status === "scheduled" || status === "blocked";
|
|
86
|
+
}
|
|
87
|
+
export const canCancel = (status: string): boolean => status === "scheduled";
|
|
88
|
+
export const canStop = (status: string): boolean => status === "sending";
|
|
89
|
+
|
|
90
|
+
export function issueEditorHref(itemId: string, demo?: boolean): string {
|
|
91
|
+
return getNewsroomNavHref(`/newsroom/articles/${encodeURIComponent(itemId)}`, demo);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const ERROR_WORDS: Record<string, string> = {
|
|
95
|
+
forbidden: "Your role cannot do this.",
|
|
96
|
+
not_found: "That issue no longer exists.",
|
|
97
|
+
invalid_state: "The issue changed state. Refresh and try again.",
|
|
98
|
+
item_missing: "The issue's item no longer exists.",
|
|
99
|
+
render_failed: "The issue cannot be rendered. Open it in the editor and fix the body.",
|
|
100
|
+
bad_request: "The request was not understood.",
|
|
101
|
+
no_address: "Your account has no email address to send a test to.",
|
|
102
|
+
unavailable: "The newsletter service could not be reached. Try again in a minute.",
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
/** One sentence for a failed operation. A missing postal address is an operator task, so the panel says where it is set. */
|
|
106
|
+
export function failureText(failure: NewsletterFailure): string {
|
|
107
|
+
if (failure.error === "postal_address_missing") {
|
|
108
|
+
return "This site has no postal address for the email footer, and mail law requires one. An operator sets it in the site's AWS configuration (an SSM parameter); it cannot be set from the newsroom.";
|
|
109
|
+
}
|
|
110
|
+
return ERROR_WORDS[failure.error] ?? failure.message ?? "The request failed.";
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// -- times ---------------------------------------------------------------------------------------------------------------
|
|
114
|
+
|
|
115
|
+
export function formatUtc(iso: string | null | undefined): string {
|
|
116
|
+
if (!iso) return "never";
|
|
117
|
+
const match = /^(\d{4}-\d{2}-\d{2})T(\d{2}:\d{2})/.exec(iso);
|
|
118
|
+
return match ? `${match[1]} ${match[2]} UTC` : iso;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function validTimeZone(zone: string | null | undefined): string | null {
|
|
122
|
+
if (!zone) return null;
|
|
123
|
+
try {
|
|
124
|
+
new Intl.DateTimeFormat("en-US", { timeZone: zone });
|
|
125
|
+
return zone;
|
|
126
|
+
} catch {
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function wallParts(instant: Date, zone: string): { year: number; month: number; day: number; hour: number; minute: number } {
|
|
132
|
+
const parts = new Intl.DateTimeFormat("en-US", {
|
|
133
|
+
timeZone: zone, hourCycle: "h23", year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit",
|
|
134
|
+
}).formatToParts(instant);
|
|
135
|
+
const pick = (type: string) => Number(parts.find((part) => part.type === type)?.value ?? "0");
|
|
136
|
+
return { year: pick("year"), month: pick("month"), day: pick("day"), hour: pick("hour") % 24, minute: pick("minute") };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Minutes the zone is ahead of UTC at that instant. */
|
|
140
|
+
function offsetMinutes(instant: Date, zone: string): number {
|
|
141
|
+
const wall = wallParts(instant, zone);
|
|
142
|
+
const asUtc = Date.UTC(wall.year, wall.month - 1, wall.day, wall.hour, wall.minute);
|
|
143
|
+
return Math.round((asUtc - Math.floor(instant.getTime() / 60000) * 60000) / 60000);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const pad = (value: number) => String(value).padStart(2, "0");
|
|
147
|
+
|
|
148
|
+
/** Local wall time `YYYY-MM-DDTHH:mm` (a datetime-local value) as an ISO instant, for the given zone. */
|
|
149
|
+
export function zonedInputToIso(local: string, zone: string): string | null {
|
|
150
|
+
const match = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})$/.exec(local);
|
|
151
|
+
if (!match) return null;
|
|
152
|
+
const [, year, month, day, hour, minute] = match.map(Number) as unknown as number[];
|
|
153
|
+
const wallAsUtc = Date.UTC(year, month - 1, day, hour, minute);
|
|
154
|
+
let guess = wallAsUtc - offsetMinutes(new Date(wallAsUtc), zone) * 60000;
|
|
155
|
+
guess = wallAsUtc - offsetMinutes(new Date(guess), zone) * 60000; // once more: the offset can differ across a clock change
|
|
156
|
+
const instant = new Date(guess);
|
|
157
|
+
return Number.isNaN(instant.getTime()) ? null : instant.toISOString().replace(".000Z", "Z");
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** The next whole hour on the zone's clock, as a datetime-local value. */
|
|
161
|
+
export function nextWholeHourInput(now: Date, zone: string): string {
|
|
162
|
+
const wall = wallParts(now, zone);
|
|
163
|
+
const next = new Date(Date.UTC(wall.year, wall.month - 1, wall.day, wall.hour + 1, 0));
|
|
164
|
+
return `${next.getUTCFullYear()}-${pad(next.getUTCMonth() + 1)}-${pad(next.getUTCDate())}T${pad(next.getUTCHours())}:00`;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** An ISO instant as a datetime-local value in the zone. */
|
|
168
|
+
export function isoToZonedInput(iso: string, zone: string): string {
|
|
169
|
+
const wall = wallParts(new Date(iso), zone);
|
|
170
|
+
return `${wall.year}-${pad(wall.month)}-${pad(wall.day)}T${pad(wall.hour)}:${pad(wall.minute)}`;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** `2027-01-15 09:00 EST` in the zone. */
|
|
174
|
+
export function formatInZone(iso: string | null | undefined, zone: string): string {
|
|
175
|
+
if (!iso) return "not set";
|
|
176
|
+
const instant = new Date(iso);
|
|
177
|
+
if (Number.isNaN(instant.getTime())) return iso;
|
|
178
|
+
const label = new Intl.DateTimeFormat("en-US", { timeZone: zone, timeZoneName: "short" }).formatToParts(instant).find((part) => part.type === "timeZoneName")?.value ?? zone;
|
|
179
|
+
return `${isoToZonedInput(iso, zone).replace("T", " ")} ${label}`;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// -- counts and CSV ------------------------------------------------------------------------------------------------------
|
|
183
|
+
|
|
184
|
+
/** "12 sent, 2 delivered, 1 bounced"; only counters above zero, in reading order. */
|
|
185
|
+
export function countsText(counts: NewsletterCounts): string {
|
|
186
|
+
const order: [keyof NewsletterCounts, string][] = [
|
|
187
|
+
["queued", "queued"], ["sent", "sent"], ["delivered", "delivered"], ["bounced", "bounced"], ["complained", "complained"],
|
|
188
|
+
["failed", "failed"], ["suppressed", "suppressed"], ["skipped", "skipped"], ["softBounced", "soft bounced"],
|
|
189
|
+
];
|
|
190
|
+
const parts = order.filter(([name]) => (counts[name] ?? 0) > 0).map(([name, label]) => `${counts[name]} ${label}`);
|
|
191
|
+
return parts.length ? parts.join(", ") : "nothing sent yet";
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export const NEWSLETTER_CSV_HEADER = [
|
|
195
|
+
"email", "status", "lists", "createdAt", "consentRequestedAt", "consentConfirmedAt", "consentFormId", "consentPageUrl", "consentWording",
|
|
196
|
+
] as const;
|
|
197
|
+
|
|
198
|
+
/** Every cell goes through the formula neutraliser: addresses and consent wording are text people typed. */
|
|
199
|
+
export function subscribersToCsv(rows: NewsletterExportRow[]): string {
|
|
200
|
+
const lines = rows.map((row) => NEWSLETTER_CSV_HEADER.map((column) => csvCell(String(row[column] ?? ""))).join(","));
|
|
201
|
+
return `${[NEWSLETTER_CSV_HEADER.join(","), ...lines].join("\r\n")}\r\n`;
|
|
202
|
+
}
|
|
@@ -8,6 +8,7 @@ export type OrganizerDestination = {
|
|
|
8
8
|
};
|
|
9
9
|
export const ORGANIZER_NAV_GROUPS: readonly { title: string; items: readonly OrganizerDestination[] }[] = [
|
|
10
10
|
{ title: "Editorial work", items: [
|
|
11
|
+
{ key: "overview", label: "Overview", description: "See notifications and work needing attention.", path: "/newsroom", primary: true },
|
|
11
12
|
{ key: "articles", label: getContentItemName().plural, description: `Follow ${contentItemPluralLowercase} across their stages and open one to write or revise it.`, path: `/newsroom/${contentItemBoardPathSegment()}`, alsoActiveOnPaths: ["/newsroom/articles"], primary: true },
|
|
12
13
|
{ key: "assignments", label: "Assignments", description: "Follow research and reporting work across its statuses and claim it.", path: "/newsroom/assignments" },
|
|
13
14
|
{ key: "messages", label: "Messages", description: "Read and reply to newsroom conversations.", path: "/newsroom/messages", primary: true },
|
|
@@ -23,7 +24,7 @@ export const ORGANIZER_NAV_GROUPS: readonly { title: string; items: readonly Org
|
|
|
23
24
|
] },
|
|
24
25
|
];
|
|
25
26
|
const all = ORGANIZER_NAV_GROUPS.flatMap(group => group.items);
|
|
26
|
-
export const ORGANIZER_PRIMARY_NAV = ["articles", "references", "messages"].map(key => all.find(item => item.key === key)!);
|
|
27
|
+
export const ORGANIZER_PRIMARY_NAV = ["overview", "articles", "references", "messages"].map(key => all.find(item => item.key === key)!);
|
|
27
28
|
|
|
28
29
|
export const ORGANIZER_SETTINGS_DESTINATION = {
|
|
29
30
|
label: "Settings", path: "/newsroom/settings", description: "Theme, motion and interface preferences.",
|
|
@@ -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 {
|
|
@@ -65,10 +82,21 @@ function clearSealedCookie(response: NextResponse, cookieName: string): void {
|
|
|
65
82
|
response.cookies.set(cookieName, "", sealedCookieAttributes(0, 0));
|
|
66
83
|
}
|
|
67
84
|
|
|
85
|
+
/** A JSON failure, for the routes the page script calls with fetch (session, account selection and creation). */
|
|
68
86
|
function signInFailedResponse(status: number, code: string, message: string): NextResponse {
|
|
69
87
|
return NextResponse.json({ error: code, message }, { status, headers: NO_STORE_HEADERS });
|
|
70
88
|
}
|
|
71
89
|
|
|
90
|
+
/**
|
|
91
|
+
* A failed sign-in never leaves the browser on /api/auth/prospectus/callback: it answers 303 to the page the reader started from
|
|
92
|
+
* (or the home page when that is not known) with `sign_in_error=<code>`, which the reader plugin turns into a short message.
|
|
93
|
+
*/
|
|
94
|
+
function signInFailedRedirect(request: NextRequest, options: ProspectusSignInRouteOptions, returnPath: string | null, code: string): NextResponse {
|
|
95
|
+
const target = new URL(returnPath ?? "/", siteOriginFor(request, options));
|
|
96
|
+
target.searchParams.set("sign_in_error", /^[a-z0-9_]{1,60}$/.test(code) ? code : "sign_in_failed");
|
|
97
|
+
return NextResponse.redirect(target, { status: 303, headers: NO_STORE_HEADERS });
|
|
98
|
+
}
|
|
99
|
+
|
|
72
100
|
export function signedInProspectusSession(
|
|
73
101
|
request: NextRequest,
|
|
74
102
|
sealingSecret: string,
|
|
@@ -82,13 +110,29 @@ export function signedInProspectusSession(
|
|
|
82
110
|
);
|
|
83
111
|
}
|
|
84
112
|
|
|
85
|
-
|
|
113
|
+
/** `returnTo` from the query (a link) or, for the POST form the reader plugin's button submits, from the form body. */
|
|
114
|
+
async function requestedReturnPath(request: NextRequest): Promise<string | null> {
|
|
115
|
+
if (request.method !== "POST") return request.nextUrl.searchParams.get("returnTo");
|
|
116
|
+
try {
|
|
117
|
+
const value = (await request.formData()).get("returnTo");
|
|
118
|
+
return typeof value === "string" ? value : null;
|
|
119
|
+
} catch {
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** GET (a link) or POST (the reader plugin's "Sign in" button, so people are never sent to an /api URL) starts the Google sign-in with a 303 to Google. */
|
|
125
|
+
export function handleProspectusSignInStart(request: NextRequest, options: ProspectusSignInRouteOptions = {}): Promise<NextResponse> {
|
|
126
|
+
return failingClosedWithoutSiteOrigin(() => handleProspectusSignInStartRoute(request, options));
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async function handleProspectusSignInStartRoute(request: NextRequest, options: ProspectusSignInRouteOptions): Promise<NextResponse> {
|
|
86
130
|
const runtime = await signInRuntimeFor(request, options);
|
|
87
131
|
if (!runtime) return signInUnavailableResponse();
|
|
88
132
|
const { authorizationUrl, pendingSignIn } = await beginProspectusGoogleSignIn(runtime.accounts, runtime.cognitoClient);
|
|
89
133
|
const pendingSignInCookie: PendingProspectusSignInCookie = {
|
|
90
134
|
...pendingSignIn,
|
|
91
|
-
returnPath: sameSiteReturnPath(request
|
|
135
|
+
returnPath: sameSiteReturnPath(await requestedReturnPath(request)),
|
|
92
136
|
};
|
|
93
137
|
const response = NextResponse.redirect(authorizationUrl, { status: 303, headers: NO_STORE_HEADERS });
|
|
94
138
|
response.cookies.set(
|
|
@@ -99,7 +143,11 @@ export async function handleProspectusSignInStart(request: NextRequest, options:
|
|
|
99
143
|
return response;
|
|
100
144
|
}
|
|
101
145
|
|
|
102
|
-
export
|
|
146
|
+
export function handleProspectusSignInCallback(request: NextRequest, options: ProspectusSignInRouteOptions = {}): Promise<NextResponse> {
|
|
147
|
+
return failingClosedWithoutSiteOrigin(() => handleProspectusSignInCallbackRoute(request, options));
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async function handleProspectusSignInCallbackRoute(request: NextRequest, options: ProspectusSignInRouteOptions): Promise<NextResponse> {
|
|
103
151
|
const runtime = await signInRuntimeFor(request, options);
|
|
104
152
|
if (!runtime) return signInUnavailableResponse();
|
|
105
153
|
const nowInSeconds = currentTimeInSeconds(options);
|
|
@@ -121,12 +169,13 @@ async function completeCallback(
|
|
|
121
169
|
options: ProspectusSignInRouteOptions,
|
|
122
170
|
nowInSeconds: number,
|
|
123
171
|
): Promise<NextResponse> {
|
|
124
|
-
|
|
172
|
+
const failed = (code: string) => signInFailedRedirect(request, options, pendingSignIn?.returnPath ?? null, code);
|
|
173
|
+
if (!pendingSignIn) return failed("sign_in_expired");
|
|
125
174
|
const searchParams = request.nextUrl.searchParams;
|
|
126
|
-
if (searchParams.get("error")) return
|
|
175
|
+
if (searchParams.get("error")) return failed("sign_in_declined");
|
|
127
176
|
const authorizationCode = searchParams.get("code");
|
|
128
177
|
const returnedState = searchParams.get("state");
|
|
129
|
-
if (!authorizationCode || !returnedState) return
|
|
178
|
+
if (!authorizationCode || !returnedState) return failed("sign_in_incomplete");
|
|
130
179
|
try {
|
|
131
180
|
const signedIn = await completeProspectusGoogleSignIn({
|
|
132
181
|
accounts: runtime.accounts,
|
|
@@ -141,8 +190,9 @@ async function completeCallback(
|
|
|
141
190
|
personId: signedIn.person.id,
|
|
142
191
|
accessToken: signedIn.accessToken,
|
|
143
192
|
accessTokenExpiresAt: signedIn.accessTokenExpiresAt,
|
|
193
|
+
...(signedIn.person.siteSubject ? { siteSubject: signedIn.person.siteSubject } : {}),
|
|
144
194
|
};
|
|
145
|
-
const response = NextResponse.redirect(new URL(pendingSignIn.returnPath, request
|
|
195
|
+
const response = NextResponse.redirect(new URL(pendingSignIn.returnPath, siteOriginFor(request, options)), { status: 303, headers: NO_STORE_HEADERS });
|
|
146
196
|
response.cookies.set(
|
|
147
197
|
PROSPECTUS_SESSION_COOKIE,
|
|
148
198
|
sealProspectusCookie(runtime.sealingSecret, PROSPECTUS_SESSION_COOKIE, session, session.accessTokenExpiresAt),
|
|
@@ -150,13 +200,17 @@ async function completeCallback(
|
|
|
150
200
|
);
|
|
151
201
|
return response;
|
|
152
202
|
} catch (error) {
|
|
153
|
-
if (error instanceof ProspectusAccountsError) return
|
|
203
|
+
if (error instanceof ProspectusAccountsError) return failed(error.code);
|
|
154
204
|
console.error(`Prospectus sign-in failed (${error instanceof Error ? error.name : "error"}).`);
|
|
155
|
-
return
|
|
205
|
+
return failed("sign_in_failed");
|
|
156
206
|
}
|
|
157
207
|
}
|
|
158
208
|
|
|
159
|
-
export
|
|
209
|
+
export function handleProspectusSessionRead(request: NextRequest, options: ProspectusSignInRouteOptions = {}): Promise<NextResponse> {
|
|
210
|
+
return failingClosedWithoutSiteOrigin(() => handleProspectusSessionReadRoute(request, options));
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
async function handleProspectusSessionReadRoute(request: NextRequest, options: ProspectusSignInRouteOptions): Promise<NextResponse> {
|
|
160
214
|
const runtime = await signInRuntimeFor(request, options);
|
|
161
215
|
if (!runtime) return signInUnavailableResponse();
|
|
162
216
|
const session = signedInProspectusSession(request, runtime.sealingSecret, currentTimeInSeconds(options));
|
|
@@ -169,11 +223,10 @@ export async function handleProspectusSessionRead(request: NextRequest, options:
|
|
|
169
223
|
return response;
|
|
170
224
|
}
|
|
171
225
|
const publishingAccounts = publishingAccountsForSite(directory.accounts, siteProductFor(options));
|
|
226
|
+
// Only what the UI reads: no person id, siteSubject or account list ever goes to the browser.
|
|
172
227
|
return NextResponse.json(
|
|
173
228
|
{
|
|
174
229
|
signedIn: true,
|
|
175
|
-
person: directory.person,
|
|
176
|
-
accounts: directory.accounts,
|
|
177
230
|
cyclotronLabAccess: directory.accounts.some((account) =>
|
|
178
231
|
account.products.some((product) => product.product === CYCLOTRON_LAB_PRODUCT && product.status === "active"),
|
|
179
232
|
),
|
|
@@ -196,9 +249,13 @@ export async function handleProspectusSessionRead(request: NextRequest, options:
|
|
|
196
249
|
}
|
|
197
250
|
}
|
|
198
251
|
|
|
199
|
-
export
|
|
252
|
+
export function handleProspectusSignOut(request: NextRequest, options: ProspectusSignInRouteOptions = {}): Promise<NextResponse> {
|
|
253
|
+
return failingClosedWithoutSiteOrigin(() => handleProspectusSignOutRoute(request, options));
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
async function handleProspectusSignOutRoute(request: NextRequest, options: ProspectusSignInRouteOptions): Promise<NextResponse> {
|
|
200
257
|
const runtime = await signInRuntimeFor(request, options);
|
|
201
|
-
const signedOutDestination = new URL("/", request
|
|
258
|
+
const signedOutDestination = new URL("/", siteOriginFor(request, options));
|
|
202
259
|
const destination = runtime
|
|
203
260
|
? new URL(
|
|
204
261
|
`/logout?${new URLSearchParams({ client_id: runtime.cognitoClient.clientId, logout_uri: signedOutDestination.toString() })}`,
|
|
@@ -250,10 +307,14 @@ export async function currentProspectusPublishingSelection(
|
|
|
250
307
|
}
|
|
251
308
|
}
|
|
252
309
|
|
|
253
|
-
export
|
|
310
|
+
export function handleProspectusAccountSelection(request: NextRequest, options: ProspectusSignInRouteOptions = {}): Promise<NextResponse> {
|
|
311
|
+
return failingClosedWithoutSiteOrigin(() => handleProspectusAccountSelectionRoute(request, options));
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
async function handleProspectusAccountSelectionRoute(request: NextRequest, options: ProspectusSignInRouteOptions): Promise<NextResponse> {
|
|
254
315
|
const runtime = await signInRuntimeFor(request, options);
|
|
255
316
|
if (!runtime) return signInUnavailableResponse();
|
|
256
|
-
if (request.headers.get("origin") !== request
|
|
317
|
+
if (request.headers.get("origin") !== siteOriginFor(request, options)) {
|
|
257
318
|
return signInFailedResponse(403, "cross_origin_selection", "Choose an account from this site.");
|
|
258
319
|
}
|
|
259
320
|
const nowInSeconds = currentTimeInSeconds(options);
|
|
@@ -287,10 +348,14 @@ const MAXIMUM_PUBLISHING_ACCOUNT_NAME_LENGTH = 150;
|
|
|
287
348
|
const DEFAULT_PUBLISHING_ACCOUNT_NAME = "Publishing account";
|
|
288
349
|
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
350
|
|
|
290
|
-
export
|
|
351
|
+
export function handleProspectusPublishingAccountCreation(request: NextRequest, options: ProspectusSignInRouteOptions = {}): Promise<NextResponse> {
|
|
352
|
+
return failingClosedWithoutSiteOrigin(() => handleProspectusPublishingAccountCreationRoute(request, options));
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
async function handleProspectusPublishingAccountCreationRoute(request: NextRequest, options: ProspectusSignInRouteOptions): Promise<NextResponse> {
|
|
291
356
|
const runtime = await signInRuntimeFor(request, options);
|
|
292
357
|
if (!runtime) return signInUnavailableResponse();
|
|
293
|
-
if (request.headers.get("origin") !== request
|
|
358
|
+
if (request.headers.get("origin") !== siteOriginFor(request, options)) {
|
|
294
359
|
return signInFailedResponse(403, "cross_origin_creation", "Create an account from this site.");
|
|
295
360
|
}
|
|
296
361
|
const nowInSeconds = currentTimeInSeconds(options);
|
|
@@ -24,6 +24,7 @@ export type ProspectusOperation = "console:read" | "console:write" | "members:re
|
|
|
24
24
|
export interface ProspectusPerson {
|
|
25
25
|
id: string;
|
|
26
26
|
createdAt: string;
|
|
27
|
+
siteSubject?: string;
|
|
27
28
|
}
|
|
28
29
|
|
|
29
30
|
export interface ProspectusAccountAuthorization {
|
|
@@ -36,6 +37,20 @@ export interface ProspectusAccountAuthorization {
|
|
|
36
37
|
revision: number;
|
|
37
38
|
}
|
|
38
39
|
|
|
40
|
+
/** One retired reader site subject, from `GET /v1/accounts/site-subjects/retired` (reader sites only). */
|
|
41
|
+
export interface ProspectusRetiredSiteSubject {
|
|
42
|
+
siteSubject: string;
|
|
43
|
+
reason: "deleted" | "merged";
|
|
44
|
+
survivingSiteSubject?: string;
|
|
45
|
+
retiredAt: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface ProspectusRetiredSiteSubjectsPage {
|
|
49
|
+
retired: ProspectusRetiredSiteSubject[];
|
|
50
|
+
cursor: string;
|
|
51
|
+
more: boolean;
|
|
52
|
+
}
|
|
53
|
+
|
|
39
54
|
export interface ProspectusDirectoryAccount {
|
|
40
55
|
id: string;
|
|
41
56
|
name: string;
|
|
@@ -100,8 +115,10 @@ export function signedProspectusTransport(options: SignedProspectusTransportOpti
|
|
|
100
115
|
...(payload ? { "content-type": "application/json" } : {}),
|
|
101
116
|
};
|
|
102
117
|
const signer = new SignatureV4({ service: "execute-api", region: options.region, credentials, sha256: Sha256 });
|
|
118
|
+
// The query string is part of what API Gateway verifies, so it has to be signed as well as sent.
|
|
119
|
+
const query: Record<string, string> = Object.fromEntries(url.searchParams.entries());
|
|
103
120
|
const signed = await signer.sign(
|
|
104
|
-
new HttpRequest({ protocol: url.protocol, hostname: url.hostname, path: url.pathname, method, headers, body: payload }),
|
|
121
|
+
new HttpRequest({ protocol: url.protocol, hostname: url.hostname, path: url.pathname, method, headers, body: payload, query }),
|
|
105
122
|
);
|
|
106
123
|
const response = await fetcher(url, { method, headers: signed.headers, body: payload, cache: "no-store", redirect: "error" });
|
|
107
124
|
const responseBody = await response.json().catch(() => ({}));
|
|
@@ -156,6 +173,12 @@ export class ProspectusSiteAccounts {
|
|
|
156
173
|
}) as Promise<{ account: { id: string; name: string; role: ProspectusRole } }>;
|
|
157
174
|
}
|
|
158
175
|
|
|
176
|
+
/** The calling site's retired reader subjects since `since` (omit it the first time): at most 100, oldest first. */
|
|
177
|
+
retiredSiteSubjects(since?: string): Promise<ProspectusRetiredSiteSubjectsPage> {
|
|
178
|
+
const query = since ? `?since=${encodeURIComponent(since)}` : "";
|
|
179
|
+
return this.transport({ path: `/v1/accounts/site-subjects/retired${query}`, method: "GET" }) as Promise<ProspectusRetiredSiteSubjectsPage>;
|
|
180
|
+
}
|
|
181
|
+
|
|
159
182
|
accountDirectory(humanAccessToken: string): Promise<{ person: ProspectusPerson | null; accounts: ProspectusDirectoryAccount[] }> {
|
|
160
183
|
return this.transport({ path: "/v1/account-directory", method: "GET", humanAccessToken }) as Promise<{
|
|
161
184
|
person: ProspectusPerson | null;
|