@anthusai/papyrus 1.0.0-next.9 → 1.1.0
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/auth/prospectus-google-client.js +66 -0
- package/amplify/auth/prospectus-google-client.ts +71 -0
- package/amplify/auth/redirect-urls.js +54 -0
- package/amplify/auth/redirect-urls.ts +66 -0
- package/amplify/auth/resource.js +23 -5
- package/amplify/auth/resource.ts +31 -5
- package/amplify/data/resource.js +42 -80
- package/amplify/data/resource.ts +42 -80
- package/amplify/data/schema.js +45 -62
- package/amplify/data/schema.ts +47 -62
- package/amplify/functions/category-action/handler.ts +5 -3
- package/amplify/functions/console-chat-responder/py/execute_tactus_runner.py +0 -10
- package/amplify/functions/console-chat-responder/resource.js +0 -7
- package/amplify/functions/console-chat-responder/resource.ts +0 -8
- package/amplify/functions/console-chat-responder/src/main.rs +0 -9
- package/amplify/functions/content-actions/handler.py +143 -1
- package/amplify/functions/content-actions/requirements.txt +2 -1
- package/amplify/functions/content-actions/resource.js +6 -1
- package/amplify/functions/content-actions/resource.ts +9 -2
- package/amplify/functions/email-submission-processor/handler.py +3 -1
- package/amplify/functions/renditions/handler.py +120 -0
- package/amplify/functions/renditions/requirements.txt +4 -0
- package/amplify/functions/ses-inbound-receive/handler.ts +32 -4
- package/amplify/functions/shared/email-submission.ts +109 -1
- package/amplify/functions/shared/python-bundle.js +42 -2
- package/amplify/functions/shared/python-bundle.ts +47 -2
- package/amplify/functions/shared/reference-curation-metadata.ts +29 -0
- package/amplify/inbound-email/ses-intake.js +86 -0
- package/amplify/inbound-email/ses-intake.ts +110 -0
- package/amplify/plugins/registry.js +19 -0
- package/amplify/plugins/registry.ts +23 -0
- package/amplify/plugins/signup/function/handler.py +200 -0
- package/amplify/plugins/signup/function/resource.js +27 -0
- package/amplify/plugins/signup/function/resource.ts +35 -0
- package/amplify/plugins/signup/papyrus.plugin.js +59 -0
- package/amplify/plugins/signup/papyrus.plugin.ts +74 -0
- package/amplify/plugins/signup/schema.js +40 -0
- package/amplify/plugins/signup/schema.ts +41 -0
- package/amplify/plugins/signup/waf.js +48 -0
- package/amplify/plugins/signup/waf.ts +51 -0
- package/amplify/plugins/types.js +1 -0
- package/amplify/plugins/types.ts +39 -0
- package/amplify/plugins/wire.js +24 -0
- package/amplify/plugins/wire.ts +26 -0
- package/amplify/publication/default-publication.js +18 -0
- package/amplify/publication/default-publication.ts +20 -0
- package/amplify/publication/invalidation-alarm.js +26 -0
- package/amplify/publication/invalidation-alarm.ts +30 -0
- package/amplify/publication/redirect-and-index.js +37 -0
- package/amplify/publication/renditions-function.js +74 -0
- package/amplify/publication/renditions-function.ts +112 -0
- package/amplify/publication/renditions-settings.js +51 -0
- package/amplify/publication/renditions-settings.ts +58 -0
- package/amplify/publication/reproject-on-deploy.js +73 -0
- package/amplify/publication/reproject-on-deploy.ts +78 -0
- package/amplify/publication/s3-projection-settings.js +67 -0
- package/amplify/publication/s3-projection-settings.ts +101 -0
- package/amplify/publication/s3-projection.js +151 -0
- package/amplify/publication/s3-projection.ts +201 -0
- package/amplify/resolver-waves.js +26 -0
- package/amplify/resolver-waves.ts +34 -0
- package/amplify/revalidate-invalidation-settings.js +30 -0
- package/amplify/revalidate-invalidation-settings.ts +35 -0
- package/amplify/revalidate-secret-policy.js +13 -0
- package/amplify/revalidate-secret-policy.ts +19 -0
- package/amplify/site-backend-config.js +65 -1
- package/amplify/site-backend-config.ts +153 -2
- package/amplify/site-backend-names.js +103 -0
- package/amplify/site-backend-names.ts +166 -0
- package/amplify/site-backend.js +150 -176
- package/amplify/site-backend.ts +189 -201
- package/amplify/storage-backups/construct.js +31 -0
- package/amplify/storage-backups/construct.ts +47 -0
- package/app/[year]/[month]/[day]/[articleSlug]/page.tsx +8 -1
- package/app/[year]/[month]/[day]/edition-route-page.tsx +2 -0
- package/app/[year]/[month]/[day]/page/[pageNumber]/page.tsx +6 -1
- package/app/[year]/[month]/[day]/page.tsx +1 -1
- package/app/[year]/[month]/[day]/section/[sectionKey]/page.tsx +6 -1
- package/app/api/archive/editions/route.ts +1 -1
- package/app/api/auth/prospectus/callback/route.ts +9 -0
- package/app/api/auth/prospectus/selection/route.ts +9 -0
- package/app/api/auth/prospectus/session/route.ts +9 -0
- package/app/api/auth/prospectus/sign-in/route.ts +9 -0
- package/app/api/auth/prospectus/sign-out/route.ts +9 -0
- package/app/api/cyclotron/priming-proof/route.ts +11 -0
- package/app/api/media/[...path]/route.ts +5 -16
- package/app/api/revalidate/route.ts +15 -7
- package/app/archive/page.tsx +1 -1
- package/app/articles/[slug]/page.tsx +7 -5
- package/app/blog-rhythm.css +278 -0
- package/app/cyclotron/page.tsx +13 -0
- package/app/globals.css +2 -0
- package/app/layout.tsx +16 -0
- package/app/newsroom/[section]/[...selection]/page.tsx +9 -6
- package/app/newsroom/[section]/page.tsx +6 -3
- package/app/newsroom/layout.tsx +4 -9
- package/app/newsroom/settings/page.tsx +6 -0
- package/app/newsroom/work/page.tsx +10 -0
- package/app/page.tsx +22 -8
- package/app/robots.ts +2 -5
- package/app/tailwind.css +1 -6
- package/components/amplify-client-provider.tsx +5 -22
- package/components/archive-shell.tsx +2 -22
- package/components/article-video.tsx +152 -5
- package/components/blog-page-background.tsx +23 -6
- package/components/news-desk-page.tsx +4 -15
- package/components/news-desk-taxonomy-client.ts +90 -3
- package/components/newspaper.tsx +6 -2
- package/components/newsroom-access-gate.tsx +2 -1
- package/components/newsroom-board-view.tsx +99 -0
- package/components/newsroom-client-shell.tsx +12 -0
- package/components/newsroom-curation-provider.tsx +17 -0
- 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 +93 -0
- 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 +93 -0
- package/components/newsroom-newsprint-board.css +48 -0
- package/components/newsroom-ops-overview.tsx +28 -11
- package/components/newsroom-ops-shell.tsx +7 -1
- package/components/newsroom-organizer-shell.tsx +130 -0
- package/components/newsroom-organizer.css +63 -0
- package/components/newsroom-references-view.tsx +92 -8
- package/components/papyrus-console-shell.tsx +4 -2
- package/components/pictogram-figure.tsx +9 -2
- package/components/presentation-header.tsx +135 -0
- package/components/presentation-rhythm-hrule.tsx +17 -0
- package/components/priming-proof-page.tsx +94 -0
- package/components/publication-cyclotron-panel.tsx +57 -0
- package/components/reader-auth-control.tsx +8 -5
- package/components/reference-curation-controls.tsx +77 -0
- package/components/reference-cyclotron-status.tsx +109 -0
- package/components/reference-priming.tsx +18 -0
- package/components/reference-relevance-review.tsx +75 -0
- package/components/settings-page.tsx +2 -1
- package/components/signup-entries-panel.tsx +94 -0
- package/components/topic-steering-workspace.tsx +78 -8
- package/components/use-blog-header-obstacles.ts +81 -0
- package/components/use-brand-fonts-ready.ts +28 -0
- package/components/use-near-viewport.ts +30 -0
- package/components/use-rhythm-overlay.ts +29 -0
- package/infra/amplify-app-shell.js +166 -27
- package/infra/amplify-app-shell.ts +180 -31
- package/infra/build-specs.js +23 -7
- package/infra/build-specs.ts +27 -10
- package/infra/site-config.js +248 -36
- package/infra/site-config.ts +313 -51
- package/lib/amplify-outputs-path.ts +5 -1
- package/lib/amplify-server-runtime.ts +36 -1
- package/lib/approved-priming-proof-reports.ts +6 -0
- package/lib/blog-feature-solver.ts +459 -0
- package/lib/blog-rhythm.ts +134 -0
- package/lib/brand-fonts-watch.ts +43 -0
- package/lib/brand-fonts.ts +112 -0
- package/lib/cached-content-repository.ts +12 -0
- package/lib/category-repository.ts +43 -93
- package/lib/content-item-lifecycle.ts +55 -0
- package/lib/content-repository.ts +3 -0
- package/lib/content-types.ts +4 -0
- package/lib/curation-client.ts +61 -0
- package/lib/edition-routes.ts +31 -29
- package/lib/graphql-content-repository.ts +56 -22
- package/lib/media-route-response.ts +45 -0
- package/lib/newspaper-layout.ts +3 -2
- package/lib/newsroom-base-path.ts +12 -3
- package/lib/newsroom-board.ts +99 -0
- package/lib/newsroom-category-drilldown.ts +3 -2
- package/lib/newsroom-forum-routes.ts +6 -4
- package/lib/newsroom-index-filters.ts +14 -10
- package/lib/newsroom-nav.ts +3 -1
- package/lib/newsroom-organizer-nav.ts +37 -0
- package/lib/newsroom-section-routing.ts +20 -0
- package/lib/oauth-redirect-priority.ts +40 -0
- package/lib/papyrus-web-locations.ts +7 -6
- package/lib/plugin-settings.ts +11 -0
- package/lib/presentation-footer.ts +3 -2
- package/lib/pretext-layout.ts +12 -3
- package/lib/priming-proof-report.ts +90 -0
- package/lib/prospectus-publication-access.ts +53 -0
- package/lib/prospectus-session.ts +196 -0
- package/lib/prospectus-sign-in-routes.ts +284 -0
- package/lib/prospectus-site-identity.ts +303 -0
- package/lib/publication-cyclotron.ts +74 -0
- package/lib/rate-limit-retry.ts +55 -0
- package/lib/reader-base-path.ts +24 -0
- package/lib/reader-cache-lifetime.ts +5 -0
- package/lib/reader-static-params.ts +85 -4
- package/lib/reader-storage-url.ts +9 -12
- package/lib/reference-policy.ts +5 -0
- package/lib/relevance-decisions.ts +103 -0
- package/lib/relevance-status.ts +63 -0
- package/lib/renderer.ts +7 -0
- package/lib/revalidate-prewarm.ts +53 -0
- package/lib/revalidate-secret.ts +59 -0
- package/lib/robots-policy.ts +14 -0
- package/lib/root-route-redirect.ts +16 -0
- package/lib/semantic-graph.ts +7 -0
- package/lib/shared-credentials-provider.ts +61 -0
- package/lib/signup-csv.ts +24 -0
- package/lib/site-brand.ts +59 -0
- package/lib/staging-gated-path.ts +23 -0
- package/lib/staging-preview-object.ts +21 -5
- package/lib/test-editor-override.ts +4 -0
- package/lib/video-loading.ts +22 -0
- package/lib/video-script.ts +14 -0
- package/middleware.ts +11 -19
- package/package.json +73 -8
- package/renderers/pretext/article-page.tsx +39 -3
- package/renderers/pretext/presentation-shared.tsx +113 -0
- package/renderers/pretext/presentation-shell.tsx +15 -102
- package/renderers/pretext/rhythm-article-page.tsx +165 -0
- package/renderers/pretext/rhythm-blog-presentation.tsx +279 -0
- package/renderers/pretext/rhythm-shell-style.ts +35 -0
- package/routes.manifest.json +123 -12
- package/src/sync.mjs +30 -6
- package/src/with-papyrus.d.mts +9 -0
- package/src/with-papyrus.mjs +48 -3
- package/amplify/functions/graphql-jwt-authorizer/handler.ts +0 -193
- package/amplify/functions/graphql-jwt-authorizer/resource.js +0 -14
- package/amplify/functions/graphql-jwt-authorizer/resource.ts +0 -15
- package/components/ai-elements/message.tsx +0 -360
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export type PluginOutputs = { custom?: { papyrusPlugins?: Record<string, Record<string, unknown>> } };
|
|
2
|
+
|
|
3
|
+
/** The settings a deployed plugin published in `amplify_outputs.json`, or null when the site did not enable it. */
|
|
4
|
+
export function pluginSettings(outputs: PluginOutputs | null | undefined, name: string): Record<string, unknown> | null {
|
|
5
|
+
return outputs?.custom?.papyrusPlugins?.[name] ?? null;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function signupForms(outputs: PluginOutputs | null | undefined): string[] {
|
|
9
|
+
const forms = pluginSettings(outputs, "signup")?.forms;
|
|
10
|
+
return Array.isArray(forms) ? forms.filter((form): form is string => typeof form === "string") : [];
|
|
11
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { getEditionSectionItems } from "./edition-sections";
|
|
2
2
|
import type { EditionContent } from "./content-types";
|
|
3
3
|
import type { PublicationItem } from "./publication-items";
|
|
4
|
+
import { toPublicReaderPath } from "./reader-base-path";
|
|
4
5
|
|
|
5
6
|
export type PresentationFooterEntry = {
|
|
6
7
|
section: string;
|
|
@@ -43,9 +44,9 @@ export type PresentationFooterGeometry = {
|
|
|
43
44
|
};
|
|
44
45
|
|
|
45
46
|
export const PRESENTATION_FOOTER_UTILITIES: PresentationFooterUtilityEntry[] = [
|
|
46
|
-
{ id: "archive", label: "Archive", href: "/archive", disabled: false },
|
|
47
|
+
{ id: "archive", label: "Archive", href: toPublicReaderPath("/archive"), disabled: false },
|
|
47
48
|
{ id: "newsDesk", label: "Newsroom", href: "/newsroom", disabled: false },
|
|
48
|
-
{ id: "settings", label: "Settings", href: "/settings", disabled: false },
|
|
49
|
+
{ id: "settings", label: "Settings", href: toPublicReaderPath("/settings"), disabled: false },
|
|
49
50
|
{ id: "login", label: "LOGIN", disabled: true },
|
|
50
51
|
];
|
|
51
52
|
|
package/lib/pretext-layout.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import {
|
|
2
|
+
clearCache,
|
|
2
3
|
layoutNextLine,
|
|
3
4
|
prepareWithSegments,
|
|
4
5
|
type LayoutCursor,
|
|
@@ -78,14 +79,18 @@ export function layoutAllTextLines({
|
|
|
78
79
|
linePaintHeight,
|
|
79
80
|
fontSize,
|
|
80
81
|
fontFamily,
|
|
81
|
-
|
|
82
|
+
obstacles = [],
|
|
83
|
+
}: Omit<LayoutTextLinesOptions, "cursor" | "maxHeight" | "obstacles"> & { obstacles?: TextObstacle[] }): TextLine[] {
|
|
82
84
|
const lines: TextLine[] = [];
|
|
83
85
|
let cursor: LayoutCursor = { segmentIndex: 0, graphemeIndex: 0 };
|
|
84
86
|
|
|
85
87
|
for (let lineIndex = 0; lineIndex < 2000; lineIndex += 1) {
|
|
86
|
-
const
|
|
88
|
+
const y = lineIndex * lineHeight;
|
|
89
|
+
const slot = getAvailableSlot(maxWidth, y, linePaintHeight, obstacles);
|
|
90
|
+
if (!slot) continue;
|
|
91
|
+
const line = layoutNextLine(prepared, cursor, slot.width);
|
|
87
92
|
if (!line) return lines;
|
|
88
|
-
lines.push(toTextLine(line,
|
|
93
|
+
lines.push(toTextLine(line, slot.x, y, fontSize, fontFamily, lineHeight, linePaintHeight));
|
|
89
94
|
cursor = line.end;
|
|
90
95
|
}
|
|
91
96
|
|
|
@@ -177,3 +182,7 @@ function toTextLine(line: LayoutLine, x: number, y: number, fontSize: number, fo
|
|
|
177
182
|
function clamp(value: number, min: number, max: number): number {
|
|
178
183
|
return Math.max(min, Math.min(max, value));
|
|
179
184
|
}
|
|
185
|
+
|
|
186
|
+
export function clearPretextMeasurementCache(): void {
|
|
187
|
+
clearCache();
|
|
188
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { APPROVED_PRIMING_PROOF_REPORTS } from "./approved-priming-proof-reports";
|
|
3
|
+
|
|
4
|
+
export const PRIMING_PROOF_REPORT_VERSION = "cyclotron-priming-proof/v1";
|
|
5
|
+
|
|
6
|
+
const nullableRatio = z.number().min(0).max(1).nullable();
|
|
7
|
+
|
|
8
|
+
const primingProofPointSchema = z.object({
|
|
9
|
+
labels: z.number().int().min(0),
|
|
10
|
+
labelsByClass: z.record(z.string(), z.number().int().min(0)),
|
|
11
|
+
decided: z.number().int().min(0),
|
|
12
|
+
agreement: nullableRatio,
|
|
13
|
+
precision: nullableRatio,
|
|
14
|
+
recall: nullableRatio,
|
|
15
|
+
confusion: z.object({
|
|
16
|
+
truePositives: z.number().int().min(0),
|
|
17
|
+
falsePositives: z.number().int().min(0),
|
|
18
|
+
trueNegatives: z.number().int().min(0),
|
|
19
|
+
falseNegatives: z.number().int().min(0),
|
|
20
|
+
}),
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
export const primingProofReportSchema = z.object({
|
|
24
|
+
version: z.literal(PRIMING_PROOF_REPORT_VERSION),
|
|
25
|
+
proofId: z.string().min(1),
|
|
26
|
+
publication: z.string().min(1),
|
|
27
|
+
cyclotronId: z.string().min(1),
|
|
28
|
+
question: z.string().min(1),
|
|
29
|
+
labels: z.array(z.string().min(1)).length(2),
|
|
30
|
+
positiveLabel: z.string().min(1),
|
|
31
|
+
decisionModel: z.string().nullable(),
|
|
32
|
+
ranAt: z.string().min(1),
|
|
33
|
+
heldOut: z.object({
|
|
34
|
+
size: z.number().int().min(1),
|
|
35
|
+
byLabel: z.record(z.string(), z.number().int().min(0)),
|
|
36
|
+
fingerprint: z.string().min(1),
|
|
37
|
+
frozenAt: z.string().min(1),
|
|
38
|
+
source: z.string().min(1),
|
|
39
|
+
}),
|
|
40
|
+
priming: z.object({
|
|
41
|
+
acceptedExamples: z.number().int().min(0),
|
|
42
|
+
excludedBecauseInHeldOut: z.number().int().min(0),
|
|
43
|
+
fingerprint: z.string().nullable(),
|
|
44
|
+
source: z.string().min(1),
|
|
45
|
+
order: z.string().min(1),
|
|
46
|
+
}),
|
|
47
|
+
conditions: z.object({
|
|
48
|
+
freshStorePerPoint: z.boolean(),
|
|
49
|
+
rubric: z.string().min(1),
|
|
50
|
+
optimizer: z.boolean(),
|
|
51
|
+
reviewProgram: z.boolean(),
|
|
52
|
+
}),
|
|
53
|
+
points: z.array(primingProofPointSchema).min(1),
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
export type PrimingProofReport = z.infer<typeof primingProofReportSchema>;
|
|
57
|
+
export type PrimingProofPoint = z.infer<typeof primingProofPointSchema>;
|
|
58
|
+
|
|
59
|
+
export type PrimingProofHeadline = {
|
|
60
|
+
baseline: PrimingProofPoint;
|
|
61
|
+
latest: PrimingProofPoint;
|
|
62
|
+
agreementChangeInPoints: number | null;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
export function parsePrimingProofReport(value: unknown): PrimingProofReport {
|
|
66
|
+
const report = primingProofReportSchema.parse(value);
|
|
67
|
+
if (report.points[0].labels !== 0) {
|
|
68
|
+
throw new Error("A priming proof report must start with the zero-label baseline.");
|
|
69
|
+
}
|
|
70
|
+
return report;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function approvedPrimingProofReport(brandId: string): PrimingProofReport | null {
|
|
74
|
+
const approvedReport = APPROVED_PRIMING_PROOF_REPORTS[brandId];
|
|
75
|
+
return approvedReport === undefined ? null : parsePrimingProofReport(approvedReport);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function primingProofHeadline(report: PrimingProofReport): PrimingProofHeadline {
|
|
79
|
+
const baseline = report.points[0];
|
|
80
|
+
const latest = report.points[report.points.length - 1];
|
|
81
|
+
const agreementChangeInPoints =
|
|
82
|
+
baseline.agreement === null || latest.agreement === null
|
|
83
|
+
? null
|
|
84
|
+
: Math.round((latest.agreement - baseline.agreement) * 1000) / 10;
|
|
85
|
+
return { baseline, latest, agreementChangeInPoints };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function formatProofPercentage(ratio: number | null): string {
|
|
89
|
+
return ratio === null ? "n/a" : `${Math.round(ratio * 1000) / 10}%`;
|
|
90
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type { ProspectusDirectoryAccount, ProspectusRole } from "./prospectus-site-identity";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* What a person may do on this site's publications, decided by Papyrus from the
|
|
5
|
+
* role Prospectus gives them on an account, never more than that role allows.
|
|
6
|
+
* An account publishes on this site when its `papyrus-<siteId>` product is
|
|
7
|
+
* active. For now every member gets their role's capabilities on all of the
|
|
8
|
+
* account's publications (Ryan, 2026-10-17: role now, per-person grants later);
|
|
9
|
+
* src/papyrus_content/prospectus_publication_access.py applies the same table
|
|
10
|
+
* where the curation tools run.
|
|
11
|
+
*/
|
|
12
|
+
export type PublicationCapability = "read" | "review" | "decide" | "steer";
|
|
13
|
+
|
|
14
|
+
export const PROSPECTUS_ROLE_PUBLICATION_CAPABILITIES: Record<ProspectusRole, readonly PublicationCapability[]> = {
|
|
15
|
+
viewer: ["read"],
|
|
16
|
+
member: ["read", "review", "decide"],
|
|
17
|
+
admin: ["read", "review", "decide", "steer"],
|
|
18
|
+
owner: ["read", "review", "decide", "steer"],
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export const PROSPECTUS_SITE_PRODUCT_ENV = "PAPYRUS_PROSPECTUS_SITE_PRODUCT";
|
|
22
|
+
|
|
23
|
+
export interface PublishingAccountAccess {
|
|
24
|
+
accountId: string;
|
|
25
|
+
accountName: string;
|
|
26
|
+
role: ProspectusRole;
|
|
27
|
+
publicationCapabilities: PublicationCapability[];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function publicationCapabilitiesForProspectusRole(role: string): PublicationCapability[] {
|
|
31
|
+
return [...(PROSPECTUS_ROLE_PUBLICATION_CAPABILITIES[role as ProspectusRole] ?? [])];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function prospectusSiteProduct(environment: Record<string, string | undefined> = process.env): string | null {
|
|
35
|
+
const siteProduct = environment[PROSPECTUS_SITE_PRODUCT_ENV]?.trim();
|
|
36
|
+
return siteProduct && siteProduct.startsWith("papyrus-") ? siteProduct : null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function publishingAccountsForSite(
|
|
40
|
+
accounts: readonly ProspectusDirectoryAccount[],
|
|
41
|
+
siteProduct: string | null,
|
|
42
|
+
): PublishingAccountAccess[] {
|
|
43
|
+
if (!siteProduct) return [];
|
|
44
|
+
return accounts
|
|
45
|
+
.filter((account) => account.products.some((product) => product.product === siteProduct && product.status === "active"))
|
|
46
|
+
.map((account) => ({
|
|
47
|
+
accountId: account.id,
|
|
48
|
+
accountName: account.name,
|
|
49
|
+
role: account.role,
|
|
50
|
+
publicationCapabilities: publicationCapabilitiesForProspectusRole(account.role),
|
|
51
|
+
}))
|
|
52
|
+
.filter((access) => access.publicationCapabilities.length > 0);
|
|
53
|
+
}
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { createCipheriv, createDecipheriv, hkdfSync, randomBytes } from "node:crypto";
|
|
2
|
+
import { GetSecretValueCommand, SecretsManagerClient } from "@aws-sdk/client-secrets-manager";
|
|
3
|
+
import { readAmplifyOutputsFile } from "./amplify-outputs-path";
|
|
4
|
+
import {
|
|
5
|
+
ProspectusSiteAccounts,
|
|
6
|
+
signedProspectusTransport,
|
|
7
|
+
type GoogleOnlyCognitoClient,
|
|
8
|
+
type PendingProspectusSignIn,
|
|
9
|
+
type ProspectusTransport,
|
|
10
|
+
} from "./prospectus-site-identity";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Browser state for signing in through Prospectus, kept in two sealed cookies so
|
|
14
|
+
* the site holds no session table. The pending cookie carries one sign-in
|
|
15
|
+
* attempt (challenge, state, PKCE verifier) from the redirect to the callback;
|
|
16
|
+
* the session cookie carries the signed-in person and their Cognito access token
|
|
17
|
+
* until that token expires. Both are AES-256-GCM sealed with a per-cookie key
|
|
18
|
+
* derived from the site's generated sealing secret, and bound to the cookie name
|
|
19
|
+
* so one cannot be replayed as the other.
|
|
20
|
+
*/
|
|
21
|
+
export const PROSPECTUS_PENDING_SIGN_IN_COOKIE = "__Host-papyrus-prospectus-pending";
|
|
22
|
+
export const PROSPECTUS_SESSION_COOKIE = "__Host-papyrus-prospectus-session";
|
|
23
|
+
export const PROSPECTUS_ACCOUNTS_ENDPOINT_ENV = "PAPYRUS_PROSPECTUS_ACCOUNTS_ENDPOINT";
|
|
24
|
+
export const PROSPECTUS_SESSION_SECRET_ARN_ENV = "PAPYRUS_PROSPECTUS_SESSION_SECRET_ARN";
|
|
25
|
+
export const PROSPECTUS_SESSION_SECRET_CACHE_MILLISECONDS = 5 * 60 * 1000;
|
|
26
|
+
|
|
27
|
+
const SEALED_COOKIE_FORMAT_VERSION = "v1";
|
|
28
|
+
const SEALED_COOKIE_KEY_DERIVATION_SALT = "papyrus-prospectus-cookie";
|
|
29
|
+
const GCM_INITIALIZATION_VECTOR_BYTES = 12;
|
|
30
|
+
const GCM_AUTHENTICATION_TAG_BYTES = 16;
|
|
31
|
+
const MINIMUM_SEALING_SECRET_LENGTH = 32;
|
|
32
|
+
|
|
33
|
+
export interface PendingProspectusSignInCookie extends PendingProspectusSignIn {
|
|
34
|
+
returnPath: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface ProspectusSignedInSession {
|
|
38
|
+
personId: string;
|
|
39
|
+
accessToken: string;
|
|
40
|
+
accessTokenExpiresAt: number;
|
|
41
|
+
selectedAccountId?: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function sealingKeyForCookie(sealingSecret: string, cookieName: string): Buffer {
|
|
45
|
+
if (sealingSecret.length < MINIMUM_SEALING_SECRET_LENGTH) throw new Error("The Prospectus session sealing secret is too short.");
|
|
46
|
+
return Buffer.from(hkdfSync("sha256", sealingSecret, SEALED_COOKIE_KEY_DERIVATION_SALT, cookieName, 32));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function sealProspectusCookie(sealingSecret: string, cookieName: string, payload: object, expiresAtSeconds: number): string {
|
|
50
|
+
const initializationVector = randomBytes(GCM_INITIALIZATION_VECTOR_BYTES);
|
|
51
|
+
const cipher = createCipheriv("aes-256-gcm", sealingKeyForCookie(sealingSecret, cookieName), initializationVector);
|
|
52
|
+
cipher.setAAD(Buffer.from(cookieName));
|
|
53
|
+
const ciphertext = Buffer.concat([cipher.update(JSON.stringify({ payload, expiresAtSeconds })), cipher.final()]);
|
|
54
|
+
return `${SEALED_COOKIE_FORMAT_VERSION}.${Buffer.concat([initializationVector, ciphertext, cipher.getAuthTag()]).toString("base64url")}`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function openProspectusCookie<Payload>(
|
|
58
|
+
sealingSecret: string,
|
|
59
|
+
cookieName: string,
|
|
60
|
+
sealedValue: string | undefined,
|
|
61
|
+
nowInSeconds: number = Math.floor(Date.now() / 1000),
|
|
62
|
+
): Payload | null {
|
|
63
|
+
if (!sealedValue?.startsWith(`${SEALED_COOKIE_FORMAT_VERSION}.`)) return null;
|
|
64
|
+
const sealedBytes = Buffer.from(sealedValue.slice(SEALED_COOKIE_FORMAT_VERSION.length + 1), "base64url");
|
|
65
|
+
if (sealedBytes.length <= GCM_INITIALIZATION_VECTOR_BYTES + GCM_AUTHENTICATION_TAG_BYTES) return null;
|
|
66
|
+
try {
|
|
67
|
+
const decipher = createDecipheriv(
|
|
68
|
+
"aes-256-gcm",
|
|
69
|
+
sealingKeyForCookie(sealingSecret, cookieName),
|
|
70
|
+
sealedBytes.subarray(0, GCM_INITIALIZATION_VECTOR_BYTES),
|
|
71
|
+
);
|
|
72
|
+
decipher.setAAD(Buffer.from(cookieName));
|
|
73
|
+
decipher.setAuthTag(sealedBytes.subarray(sealedBytes.length - GCM_AUTHENTICATION_TAG_BYTES));
|
|
74
|
+
const plaintext = Buffer.concat([
|
|
75
|
+
decipher.update(sealedBytes.subarray(GCM_INITIALIZATION_VECTOR_BYTES, sealedBytes.length - GCM_AUTHENTICATION_TAG_BYTES)),
|
|
76
|
+
decipher.final(),
|
|
77
|
+
]).toString("utf8");
|
|
78
|
+
const { payload, expiresAtSeconds } = JSON.parse(plaintext) as { payload: Payload; expiresAtSeconds: number };
|
|
79
|
+
if (typeof expiresAtSeconds !== "number" || nowInSeconds >= expiresAtSeconds) return null;
|
|
80
|
+
return payload;
|
|
81
|
+
} catch {
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function sealedCookieAttributes(expiresAtSeconds: number, nowInSeconds: number = Math.floor(Date.now() / 1000)) {
|
|
87
|
+
return {
|
|
88
|
+
httpOnly: true,
|
|
89
|
+
secure: true,
|
|
90
|
+
sameSite: "lax" as const,
|
|
91
|
+
path: "/",
|
|
92
|
+
maxAge: Math.max(0, expiresAtSeconds - nowInSeconds),
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function sameSiteReturnPath(requestedReturnPath: string | null | undefined): string {
|
|
97
|
+
if (!requestedReturnPath || !requestedReturnPath.startsWith("/") || requestedReturnPath.startsWith("//")) return "/";
|
|
98
|
+
if (/[\\\u0000-\u001f]/.test(requestedReturnPath)) return "/";
|
|
99
|
+
const parsed = new URL(requestedReturnPath, "https://papyrus.invalid");
|
|
100
|
+
if (parsed.origin !== "https://papyrus.invalid") return "/";
|
|
101
|
+
return `${parsed.pathname}${parsed.search}${parsed.hash}`;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export type ProspectusSessionSecretReader = (secretArn: string) => Promise<string | null>;
|
|
105
|
+
|
|
106
|
+
type CachedSealingSecret = { secretArn: string; value: string; expiresAt: number };
|
|
107
|
+
|
|
108
|
+
let cachedSealingSecret: CachedSealingSecret | null = null;
|
|
109
|
+
let defaultSecretsManagerClient: SecretsManagerClient | null = null;
|
|
110
|
+
|
|
111
|
+
export function resetProspectusSessionSecretCache(): void {
|
|
112
|
+
cachedSealingSecret = null;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async function readSealingSecretFromSecretsManager(secretArn: string): Promise<string | null> {
|
|
116
|
+
defaultSecretsManagerClient ??= new SecretsManagerClient({});
|
|
117
|
+
const response = await defaultSecretsManagerClient.send(new GetSecretValueCommand({ SecretId: secretArn }));
|
|
118
|
+
return response.SecretString?.trim() || null;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export async function loadProspectusSessionSealingSecret(
|
|
122
|
+
options: { environment?: Record<string, string | undefined>; readSecret?: ProspectusSessionSecretReader; now?: number } = {},
|
|
123
|
+
): Promise<string | null> {
|
|
124
|
+
const environment = options.environment ?? process.env;
|
|
125
|
+
const secretArn = environment[PROSPECTUS_SESSION_SECRET_ARN_ENV]?.trim();
|
|
126
|
+
if (!secretArn) return null;
|
|
127
|
+
const now = options.now ?? Date.now();
|
|
128
|
+
if (cachedSealingSecret && cachedSealingSecret.secretArn === secretArn && cachedSealingSecret.expiresAt > now) {
|
|
129
|
+
return cachedSealingSecret.value;
|
|
130
|
+
}
|
|
131
|
+
try {
|
|
132
|
+
const value = (await (options.readSecret ?? readSealingSecretFromSecretsManager)(secretArn))?.trim();
|
|
133
|
+
if (!value || value.length < MINIMUM_SEALING_SECRET_LENGTH) return null;
|
|
134
|
+
cachedSealingSecret = { secretArn, value, expiresAt: now + PROSPECTUS_SESSION_SECRET_CACHE_MILLISECONDS };
|
|
135
|
+
return value;
|
|
136
|
+
} catch (error) {
|
|
137
|
+
console.error(`Prospectus session sealing secret could not be read (${error instanceof Error ? error.name : "error"}).`);
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export interface ProspectusSignInRuntime {
|
|
143
|
+
accounts: ProspectusSiteAccounts;
|
|
144
|
+
cognitoClient: GoogleOnlyCognitoClient;
|
|
145
|
+
sealingSecret: string;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
interface AmplifyOutputsForProspectusSignIn {
|
|
149
|
+
auth?: { user_pool_id?: string; oauth?: { domain?: string } };
|
|
150
|
+
custom?: { prospectusSignIn?: { clientId?: string; callbackPath?: string } | null };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export function regionOfProspectusAccountsEndpoint(endpoint: string): string {
|
|
154
|
+
const match = /^[a-z0-9]+\.execute-api\.([a-z0-9-]+)\.amazonaws\.com$/.exec(new URL(endpoint).hostname);
|
|
155
|
+
if (!match) throw new Error("The Prospectus accounts endpoint is not an API Gateway execute-api host.");
|
|
156
|
+
return match[1];
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export function googleOnlyCognitoClientFromOutputs(
|
|
160
|
+
amplifyOutputs: AmplifyOutputsForProspectusSignIn | null,
|
|
161
|
+
siteOrigin: string,
|
|
162
|
+
): GoogleOnlyCognitoClient | null {
|
|
163
|
+
const userPoolId = amplifyOutputs?.auth?.user_pool_id;
|
|
164
|
+
const hostedUiDomain = amplifyOutputs?.auth?.oauth?.domain;
|
|
165
|
+
const prospectusSignIn = amplifyOutputs?.custom?.prospectusSignIn;
|
|
166
|
+
if (!userPoolId || !hostedUiDomain || !prospectusSignIn?.clientId || !prospectusSignIn.callbackPath) return null;
|
|
167
|
+
return {
|
|
168
|
+
hostedUiOrigin: `https://${hostedUiDomain}`,
|
|
169
|
+
userPoolId,
|
|
170
|
+
clientId: prospectusSignIn.clientId,
|
|
171
|
+
redirectUri: new URL(prospectusSignIn.callbackPath, siteOrigin).toString(),
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export async function resolveProspectusSignInRuntime(
|
|
176
|
+
siteOrigin: string,
|
|
177
|
+
options: {
|
|
178
|
+
environment?: Record<string, string | undefined>;
|
|
179
|
+
amplifyOutputs?: AmplifyOutputsForProspectusSignIn | null;
|
|
180
|
+
readSecret?: ProspectusSessionSecretReader;
|
|
181
|
+
transport?: ProspectusTransport;
|
|
182
|
+
} = {},
|
|
183
|
+
): Promise<ProspectusSignInRuntime | null> {
|
|
184
|
+
const environment = options.environment ?? process.env;
|
|
185
|
+
const endpoint = environment[PROSPECTUS_ACCOUNTS_ENDPOINT_ENV]?.trim();
|
|
186
|
+
if (!endpoint) return null;
|
|
187
|
+
const amplifyOutputs = options.amplifyOutputs !== undefined
|
|
188
|
+
? options.amplifyOutputs
|
|
189
|
+
: (readAmplifyOutputsFile() as AmplifyOutputsForProspectusSignIn | null);
|
|
190
|
+
const cognitoClient = googleOnlyCognitoClientFromOutputs(amplifyOutputs, siteOrigin);
|
|
191
|
+
if (!cognitoClient) return null;
|
|
192
|
+
const sealingSecret = await loadProspectusSessionSealingSecret({ environment, readSecret: options.readSecret });
|
|
193
|
+
if (!sealingSecret) return null;
|
|
194
|
+
const transport = options.transport ?? signedProspectusTransport({ endpoint, region: regionOfProspectusAccountsEndpoint(endpoint) });
|
|
195
|
+
return { accounts: new ProspectusSiteAccounts(transport), cognitoClient, sealingSecret };
|
|
196
|
+
}
|