@anthusai/papyrus 2.0.0-next.4 → 2.0.0-next.41
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 +2030 -1967
- package/amplify/data/schema.ts +73 -2
- package/amplify/functions/assignment-action/data-access.js +88 -0
- package/amplify/functions/assignment-action/data-access.ts +87 -0
- package/amplify/functions/assignment-action/handler.ts +1 -1
- package/amplify/functions/content-actions/handler.py +36 -0
- 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/functions/editorial-checks/resource.js +43 -0
- package/amplify/functions/editorial-checks/resource.ts +60 -0
- package/amplify/functions/editorial-checks/schema.js +121 -0
- package/amplify/functions/editorial-checks/schema.ts +123 -0
- 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 +9 -3
- package/amplify/functions/shared/python-bundle.js +20 -14
- package/amplify/functions/shared/python-bundle.ts +25 -18
- package/amplify/plugins/reader-steering/config.js +110 -0
- package/amplify/plugins/reader-steering/config.ts +153 -0
- package/amplify/plugins/reader-steering/function/handler.py +544 -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 +103 -0
- package/amplify/plugins/reader-steering/papyrus.plugin.ts +122 -0
- package/amplify/plugins/reader-steering/schema.js +40 -0
- package/amplify/plugins/reader-steering/schema.ts +42 -0
- package/amplify/plugins/registry.js +2 -0
- package/amplify/plugins/registry.ts +2 -0
- package/amplify/plugins/types.ts +3 -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/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/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/retired/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 +362 -0
- package/components/editorial-finding-card.tsx +162 -0
- package/components/editorial-findings-panel.tsx +91 -0
- package/components/editorial-preview-popover.tsx +103 -0
- package/components/markus-body-editor.tsx +213 -0
- package/components/markus-ir-preview.tsx +147 -6
- package/components/monitoring-overview-view.tsx +241 -0
- package/components/monitoring-panel.tsx +40 -0
- package/components/newsroom-article-editor.tsx +136 -11
- package/components/newsroom-board-view.tsx +6 -7
- package/components/newsroom-curation-bootstrap.tsx +45 -0
- package/components/newsroom-ops-shell.tsx +7 -3
- package/components/newsroom-organizer.css +74 -61
- package/components/newsroom-references-view.tsx +14 -14
- package/components/newsroom-theme-fonts/broadsheet-green.css +10 -0
- package/components/newsroom-theme-fonts/broadsheet-green.tsx +6 -0
- package/components/newsroom-theme-fonts/newspaper-gothic.css +9 -0
- package/components/newsroom-theme-fonts/newspaper-gothic.tsx +6 -0
- package/components/newsroom-theme-fonts/night-desk.css +10 -0
- package/components/newsroom-theme-fonts/night-desk.tsx +6 -0
- package/components/newsroom-theme-fonts/refined-newsprint.css +11 -0
- package/components/newsroom-theme-fonts/refined-newsprint.tsx +6 -0
- package/components/newsroom-theme.css +39 -0
- package/components/newsroom-theme.tsx +33 -0
- 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 +27 -2
- package/components/use-editorial-checks.ts +133 -0
- 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 +16 -0
- package/lib/editorial-checks-client.ts +212 -0
- package/lib/editorial-checks-demo.ts +147 -0
- package/lib/editorial-checks-settings.ts +27 -0
- package/lib/editorial-checks.ts +173 -0
- package/lib/editorial-preview.ts +228 -0
- package/lib/monitoring-client.ts +29 -0
- package/lib/monitoring-overview-fixture.ts +51 -0
- package/lib/monitoring-overview.ts +145 -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 +143 -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 +23 -3
- package/lib/site-origin.ts +67 -0
- package/middleware.ts +14 -3
- package/package.json +56 -4
- package/routes.manifest.json +89 -1
- package/components/newsroom-fonts/playfair-display-700.ttf +0 -0
- package/components/newsroom-fonts/playfair-display-900.ttf +0 -0
- package/components/newsroom-fonts/playfair-display-OFL.txt +0 -93
- package/components/newsroom-fonts/plus-jakarta-sans-400.ttf +0 -0
- package/components/newsroom-fonts/plus-jakarta-sans-600.ttf +0 -0
- package/components/newsroom-fonts/plus-jakarta-sans-OFL.txt +0 -93
- package/components/newsroom-newsprint-board.css +0 -7
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/** Shapes and wording for the newsroom Monitoring page. The function in src/papyrus_content/monitoring_view.py produces them. */
|
|
2
|
+
import { getNewsroomNavHref } from "./newsroom-nav";
|
|
3
|
+
|
|
4
|
+
export type MonitoringDeliveryState = "delivered" | "failed" | "pending" | "not_applicable" | null;
|
|
5
|
+
|
|
6
|
+
export type MonitoringRelevance = {
|
|
7
|
+
publicationId: string;
|
|
8
|
+
site: string | null;
|
|
9
|
+
relevant: boolean | null;
|
|
10
|
+
label: string | null;
|
|
11
|
+
confidence: number | null;
|
|
12
|
+
delivery: {
|
|
13
|
+
state: MonitoringDeliveryState;
|
|
14
|
+
referenceId?: string | null;
|
|
15
|
+
referenceLineageId?: string | null;
|
|
16
|
+
deliveredAt?: string | null;
|
|
17
|
+
error?: string | null;
|
|
18
|
+
attempts?: number | null;
|
|
19
|
+
};
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export type MonitoringItem = {
|
|
23
|
+
sourceId: string;
|
|
24
|
+
revision: number;
|
|
25
|
+
title: string;
|
|
26
|
+
url: string | null;
|
|
27
|
+
discussionUrl: string | null;
|
|
28
|
+
observedAt: string | null;
|
|
29
|
+
routing: string;
|
|
30
|
+
routingNote: string | null;
|
|
31
|
+
decidedAt: string | null;
|
|
32
|
+
relevance: MonitoringRelevance[];
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export type MonitoringRun = {
|
|
36
|
+
runId: string;
|
|
37
|
+
startedAt: string;
|
|
38
|
+
status: string;
|
|
39
|
+
new: number;
|
|
40
|
+
revised: number;
|
|
41
|
+
unchanged: number;
|
|
42
|
+
itemCount: number | null;
|
|
43
|
+
errorCount: number;
|
|
44
|
+
errors: (string | null)[];
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
export type MonitoringRouterRun = {
|
|
48
|
+
runId: string;
|
|
49
|
+
startedAt: string;
|
|
50
|
+
mode: string;
|
|
51
|
+
status: string;
|
|
52
|
+
stoppedBy: string | null;
|
|
53
|
+
waitingForClassifier?: string[];
|
|
54
|
+
counts: Record<string, number>;
|
|
55
|
+
errorCount: number;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
export type MonitoringFeed = {
|
|
59
|
+
id: string;
|
|
60
|
+
sourceUrl: string | null;
|
|
61
|
+
intervalSeconds: number | null;
|
|
62
|
+
limit: number | null;
|
|
63
|
+
enabled: boolean;
|
|
64
|
+
lastStatus: string | null;
|
|
65
|
+
lastFinishedAt: string | null;
|
|
66
|
+
nextDueAt: string | null;
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
export type MonitoringOverview = {
|
|
70
|
+
state: "ready" | "empty";
|
|
71
|
+
asOf: string;
|
|
72
|
+
notice?: "publication_not_routed";
|
|
73
|
+
scope: { mode: "publication" | "all"; publicationId: string | null; site: string | null; canViewAll: boolean };
|
|
74
|
+
feeds: MonitoringFeed[];
|
|
75
|
+
runs: MonitoringRun[];
|
|
76
|
+
routerRuns: MonitoringRouterRun[];
|
|
77
|
+
router: { mode: string; feedback: boolean; enabled: boolean; maxItemsPerRun: number | null } | null;
|
|
78
|
+
usage: {
|
|
79
|
+
day: string;
|
|
80
|
+
decisionsToday: number;
|
|
81
|
+
decisionsTotal: number;
|
|
82
|
+
ceilings: { decisionsTotal?: number; decisionsPerDay?: number; items?: number } | null;
|
|
83
|
+
} | null;
|
|
84
|
+
items: MonitoringItem[];
|
|
85
|
+
hiddenNotRelevant: number;
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
export type MonitoringResult =
|
|
89
|
+
| MonitoringOverview
|
|
90
|
+
| { state: "not_configured" }
|
|
91
|
+
| { state: "unavailable" }
|
|
92
|
+
| { error: "forbidden" | "bad_request" };
|
|
93
|
+
|
|
94
|
+
const ROUTING_WORDS: Record<string, string> = {
|
|
95
|
+
collected: "Collected, waiting for the router",
|
|
96
|
+
"dry-run-validated": "Checked in a dry run, not sent",
|
|
97
|
+
decided: "Judged, delivery not finished",
|
|
98
|
+
routed: "Routed",
|
|
99
|
+
refused: "Refused by the shared Cyclotron",
|
|
100
|
+
uncertain: "Uncertain, needs a person",
|
|
101
|
+
error: "Error",
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
export function routingLabel(routing: string): string {
|
|
105
|
+
return ROUTING_WORDS[routing] ?? routing;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Fixed-format UTC time so the page reads the same everywhere. */
|
|
109
|
+
export function formatUtc(iso: string | null | undefined): string {
|
|
110
|
+
if (!iso) return "never";
|
|
111
|
+
const match = /^(\d{4}-\d{2}-\d{2})T(\d{2}:\d{2})/.exec(iso);
|
|
112
|
+
return match ? `${match[1]} ${match[2]} UTC` : iso;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function confidenceText(confidence: number | null): string {
|
|
116
|
+
return confidence === null ? "confidence not reported" : `${Math.round(confidence * 100)}% confidence`;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function usageText(used: number, ceiling: number | undefined): string {
|
|
120
|
+
return ceiling === undefined ? `${used} used, no ceiling set` : `${used} of ${ceiling}`;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function everyText(seconds: number | null): string {
|
|
124
|
+
if (!seconds) return "on a schedule";
|
|
125
|
+
return seconds % 3600 === 0 ? `every ${seconds / 3600} h` : seconds % 60 === 0 ? `every ${seconds / 60} min` : `every ${seconds} s`;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function referenceHref(lineageId: string, demo?: boolean): string {
|
|
129
|
+
return getNewsroomNavHref(`/newsroom/references/${encodeURIComponent(lineageId)}`, demo);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function describeRelevance(entry: MonitoringRelevance): string {
|
|
133
|
+
if (entry.relevant === null) return "Not judged yet";
|
|
134
|
+
return entry.relevant ? "Relevant" : "Not relevant";
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function describeDelivery(entry: MonitoringRelevance): string {
|
|
138
|
+
switch (entry.delivery.state) {
|
|
139
|
+
case "delivered": return "In the References queue";
|
|
140
|
+
case "failed": return "Not in the References queue: delivery failed";
|
|
141
|
+
case "pending": return "Not in the References queue yet";
|
|
142
|
+
case "not_applicable": return "Nothing to deliver";
|
|
143
|
+
default: return "";
|
|
144
|
+
}
|
|
145
|
+
}
|
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);
|
|
@@ -24,6 +24,7 @@ export type ProspectusOperation = "console:read" | "console:write" | "members:re
|
|
|
24
24
|
export interface ProspectusPerson {
|
|
25
25
|
id: string;
|
|
26
26
|
createdAt: string;
|
|
27
|
+
siteSubject?: string;
|
|
27
28
|
}
|
|
28
29
|
|
|
29
30
|
export interface ProspectusAccountAuthorization {
|
|
@@ -36,6 +37,20 @@ export interface ProspectusAccountAuthorization {
|
|
|
36
37
|
revision: number;
|
|
37
38
|
}
|
|
38
39
|
|
|
40
|
+
/** One retired reader site subject, from `GET /v1/accounts/site-subjects/retired` (reader sites only). */
|
|
41
|
+
export interface ProspectusRetiredSiteSubject {
|
|
42
|
+
siteSubject: string;
|
|
43
|
+
reason: "deleted" | "merged";
|
|
44
|
+
survivingSiteSubject?: string;
|
|
45
|
+
retiredAt: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface ProspectusRetiredSiteSubjectsPage {
|
|
49
|
+
retired: ProspectusRetiredSiteSubject[];
|
|
50
|
+
cursor: string;
|
|
51
|
+
more: boolean;
|
|
52
|
+
}
|
|
53
|
+
|
|
39
54
|
export interface ProspectusDirectoryAccount {
|
|
40
55
|
id: string;
|
|
41
56
|
name: string;
|
|
@@ -100,8 +115,10 @@ export function signedProspectusTransport(options: SignedProspectusTransportOpti
|
|
|
100
115
|
...(payload ? { "content-type": "application/json" } : {}),
|
|
101
116
|
};
|
|
102
117
|
const signer = new SignatureV4({ service: "execute-api", region: options.region, credentials, sha256: Sha256 });
|
|
118
|
+
// The query string is part of what API Gateway verifies, so it has to be signed as well as sent.
|
|
119
|
+
const query: Record<string, string> = Object.fromEntries(url.searchParams.entries());
|
|
103
120
|
const signed = await signer.sign(
|
|
104
|
-
new HttpRequest({ protocol: url.protocol, hostname: url.hostname, path: url.pathname, method, headers, body: payload }),
|
|
121
|
+
new HttpRequest({ protocol: url.protocol, hostname: url.hostname, path: url.pathname, method, headers, body: payload, query }),
|
|
105
122
|
);
|
|
106
123
|
const response = await fetcher(url, { method, headers: signed.headers, body: payload, cache: "no-store", redirect: "error" });
|
|
107
124
|
const responseBody = await response.json().catch(() => ({}));
|
|
@@ -156,6 +173,12 @@ export class ProspectusSiteAccounts {
|
|
|
156
173
|
}) as Promise<{ account: { id: string; name: string; role: ProspectusRole } }>;
|
|
157
174
|
}
|
|
158
175
|
|
|
176
|
+
/** The calling site's retired reader subjects since `since` (omit it the first time): at most 100, oldest first. */
|
|
177
|
+
retiredSiteSubjects(since?: string): Promise<ProspectusRetiredSiteSubjectsPage> {
|
|
178
|
+
const query = since ? `?since=${encodeURIComponent(since)}` : "";
|
|
179
|
+
return this.transport({ path: `/v1/accounts/site-subjects/retired${query}`, method: "GET" }) as Promise<ProspectusRetiredSiteSubjectsPage>;
|
|
180
|
+
}
|
|
181
|
+
|
|
159
182
|
accountDirectory(humanAccessToken: string): Promise<{ person: ProspectusPerson | null; accounts: ProspectusDirectoryAccount[] }> {
|
|
160
183
|
return this.transport({ path: "/v1/account-directory", method: "GET", humanAccessToken }) as Promise<{
|
|
161
184
|
person: ProspectusPerson | null;
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { NextResponse, type NextRequest } from "next/server";
|
|
2
|
+
import { readAmplifyOutputsFile } from "./amplify-outputs-path";
|
|
3
|
+
import { loadProspectusSessionSealingSecret, type ProspectusSignedInSession } from "./prospectus-session";
|
|
4
|
+
import { signedInProspectusSession } from "./prospectus-sign-in-routes";
|
|
5
|
+
import { readerIsStaff, readerSubject } from "./reader-subject";
|
|
6
|
+
import { SiteOriginNotConfiguredError, publicSiteOrigin, siteOriginNotConfiguredResponse } from "./site-origin";
|
|
7
|
+
|
|
8
|
+
export const READER_ACTIONS_FUNCTION_ENV = "PAPYRUS_READER_ACTIONS_FUNCTION";
|
|
9
|
+
export const READER_MAXIMUM_BODY_BYTES = 4096;
|
|
10
|
+
export const READER_MAXIMUM_SUBJECTS = 50;
|
|
11
|
+
export const READER_SUBJECT_KEY = /^[A-Za-z0-9._:-]{1,200}$/;
|
|
12
|
+
|
|
13
|
+
export type ReaderAction = "me" | "handle" | "vote" | "rating";
|
|
14
|
+
|
|
15
|
+
export interface ReaderActionEvent {
|
|
16
|
+
publication: string;
|
|
17
|
+
siteSubject: string;
|
|
18
|
+
isStaff: boolean;
|
|
19
|
+
action: ReaderAction;
|
|
20
|
+
payload: Record<string, unknown>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface ReaderActionResult {
|
|
24
|
+
statusCode: number;
|
|
25
|
+
body: Record<string, unknown>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export type ReaderActionInvoker = (functionName: string, event: ReaderActionEvent) => Promise<ReaderActionResult>;
|
|
29
|
+
|
|
30
|
+
export interface ReaderApiOptions {
|
|
31
|
+
environment?: Record<string, string | undefined>;
|
|
32
|
+
invoke?: ReaderActionInvoker;
|
|
33
|
+
actionsFunctionName?: string | null;
|
|
34
|
+
loadSealingSecret?: () => Promise<string | null>;
|
|
35
|
+
nowInSeconds?: number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const NO_STORE_HEADERS = { "cache-control": "no-store" };
|
|
39
|
+
|
|
40
|
+
function failure(status: number, error: string, message: string, extra: Record<string, unknown> = {}, headers: Record<string, string> = {}): NextResponse {
|
|
41
|
+
return NextResponse.json({ error, message, ...extra }, { status, headers: { ...NO_STORE_HEADERS, ...headers } });
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function identityUnavailableResponse(): NextResponse {
|
|
45
|
+
return failure(503, "reader_identity_unavailable", "Reader sign-in is not open yet.");
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function actionsFunctionNameFrom(options: ReaderApiOptions, environment: Record<string, string | undefined>): string | null {
|
|
49
|
+
if (options.actionsFunctionName !== undefined) return options.actionsFunctionName;
|
|
50
|
+
const fromEnvironment = (environment[READER_ACTIONS_FUNCTION_ENV] ?? "").trim();
|
|
51
|
+
if (fromEnvironment) return fromEnvironment;
|
|
52
|
+
const outputs = readAmplifyOutputsFile() as { custom?: { readerSteering?: { actionsFunction?: unknown } | null } } | null;
|
|
53
|
+
const fromOutputs = outputs?.custom?.readerSteering?.actionsFunction;
|
|
54
|
+
return typeof fromOutputs === "string" && fromOutputs.trim() ? fromOutputs.trim() : null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export async function invokeReaderActionsLambda(functionName: string, event: ReaderActionEvent): Promise<ReaderActionResult> {
|
|
58
|
+
const { InvokeCommand, LambdaClient } = await import("@aws-sdk/client-lambda");
|
|
59
|
+
const response = await new LambdaClient({}).send(
|
|
60
|
+
new InvokeCommand({ FunctionName: functionName, InvocationType: "RequestResponse", Payload: Buffer.from(JSON.stringify(event)) }),
|
|
61
|
+
);
|
|
62
|
+
if (response.FunctionError || !response.Payload) throw new Error("reader-actions failed");
|
|
63
|
+
const result = JSON.parse(Buffer.from(response.Payload).toString("utf8")) as Partial<ReaderActionResult>;
|
|
64
|
+
if (typeof result.statusCode !== "number" || typeof result.body !== "object" || result.body === null) throw new Error("reader-actions returned an unexpected shape");
|
|
65
|
+
return { statusCode: result.statusCode, body: result.body as Record<string, unknown> };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function readJsonBody(request: NextRequest): Promise<{ payload: Record<string, unknown> } | { response: NextResponse }> {
|
|
69
|
+
const contentType = (request.headers.get("content-type") ?? "").split(";")[0].trim().toLowerCase();
|
|
70
|
+
if (contentType !== "application/json") return { response: failure(415, "unsupported_media_type", "Send application/json.") };
|
|
71
|
+
const declared = Number(request.headers.get("content-length") ?? "0");
|
|
72
|
+
if (Number.isFinite(declared) && declared > READER_MAXIMUM_BODY_BYTES) return { response: failure(413, "payload_too_large", "The request body is too large.") };
|
|
73
|
+
const text = await request.text();
|
|
74
|
+
if (Buffer.byteLength(text, "utf8") > READER_MAXIMUM_BODY_BYTES) return { response: failure(413, "payload_too_large", "The request body is too large.") };
|
|
75
|
+
try {
|
|
76
|
+
const parsed = JSON.parse(text) as unknown;
|
|
77
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw new Error("not an object");
|
|
78
|
+
return { payload: parsed as Record<string, unknown> };
|
|
79
|
+
} catch {
|
|
80
|
+
return { response: failure(400, "invalid_json", "The request body is not a JSON object.") };
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function subjectsFromQuery(request: NextRequest): { payload: Record<string, unknown> } | { response: NextResponse } {
|
|
85
|
+
const raw = request.nextUrl.searchParams.get("subjects") ?? "";
|
|
86
|
+
const subjects = raw === "" ? [] : raw.split(",");
|
|
87
|
+
if (subjects.length > READER_MAXIMUM_SUBJECTS || !subjects.every((subject) => READER_SUBJECT_KEY.test(subject))) {
|
|
88
|
+
return { response: failure(400, "invalid_subjects", `Send at most ${READER_MAXIMUM_SUBJECTS} comma separated item keys.`) };
|
|
89
|
+
}
|
|
90
|
+
return { payload: { subjects } };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async function signedInReaderSession(request: NextRequest, options: ReaderApiOptions): Promise<ProspectusSignedInSession | null | "unavailable"> {
|
|
94
|
+
const sealingSecret = await (options.loadSealingSecret ?? (() => loadProspectusSessionSealingSecret({ environment: options.environment })))();
|
|
95
|
+
if (!sealingSecret) return "unavailable";
|
|
96
|
+
return signedInProspectusSession(request, sealingSecret, options.nowInSeconds);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* One reader route: session, readerSubject (503 while Prospectus has no siteSubject), Origin, JSON size, then the
|
|
101
|
+
* reader-actions function. The siteSubject only travels to the function and never into a response.
|
|
102
|
+
*/
|
|
103
|
+
export async function handleReaderRequest(request: NextRequest, action: ReaderAction, options: ReaderApiOptions = {}): Promise<NextResponse> {
|
|
104
|
+
const environment = options.environment ?? process.env;
|
|
105
|
+
let origin: string;
|
|
106
|
+
try {
|
|
107
|
+
origin = publicSiteOrigin(request, environment);
|
|
108
|
+
} catch (error) {
|
|
109
|
+
if (error instanceof SiteOriginNotConfiguredError) return siteOriginNotConfiguredResponse(error);
|
|
110
|
+
throw error;
|
|
111
|
+
}
|
|
112
|
+
const session = await signedInReaderSession(request, options);
|
|
113
|
+
if (session === "unavailable") return identityUnavailableResponse();
|
|
114
|
+
if (!session) return failure(401, "signed_out", "Sign in to continue.");
|
|
115
|
+
const siteSubject = readerSubject(session, environment);
|
|
116
|
+
if (siteSubject === null) return identityUnavailableResponse();
|
|
117
|
+
|
|
118
|
+
let parsed: { payload: Record<string, unknown> } | { response: NextResponse };
|
|
119
|
+
if (action === "me") {
|
|
120
|
+
parsed = subjectsFromQuery(request);
|
|
121
|
+
} else {
|
|
122
|
+
if (request.headers.get("origin") !== origin) return failure(403, "origin_mismatch", "This request did not come from this site.");
|
|
123
|
+
parsed = await readJsonBody(request);
|
|
124
|
+
}
|
|
125
|
+
if ("response" in parsed) return parsed.response;
|
|
126
|
+
|
|
127
|
+
const functionName = actionsFunctionNameFrom(options, environment);
|
|
128
|
+
if (!functionName) return failure(503, "reader_actions_unavailable", "Reader features are not enabled for this site.");
|
|
129
|
+
try {
|
|
130
|
+
const result = await (options.invoke ?? invokeReaderActionsLambda)(functionName, {
|
|
131
|
+
publication: new URL(origin).host,
|
|
132
|
+
siteSubject,
|
|
133
|
+
isStaff: readerIsStaff(session),
|
|
134
|
+
action,
|
|
135
|
+
payload: parsed.payload,
|
|
136
|
+
});
|
|
137
|
+
const retryAfter: Record<string, string> = typeof result.body.retryAfter === "number" ? { "retry-after": String(result.body.retryAfter) } : {};
|
|
138
|
+
return NextResponse.json(result.body, { status: result.statusCode, headers: { ...NO_STORE_HEADERS, ...retryAfter } });
|
|
139
|
+
} catch (error) {
|
|
140
|
+
console.error(`Reader action ${action} failed (${error instanceof Error ? error.name : "error"}).`);
|
|
141
|
+
return failure(502, "reader_actions_failed", "Reader features could not be reached.");
|
|
142
|
+
}
|
|
143
|
+
}
|