@anthusai/papyrus 1.0.0-next.62 → 1.0.0-next.64

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.
Files changed (35) hide show
  1. package/amplify/auth/prospectus-google-client.js +66 -0
  2. package/amplify/auth/prospectus-google-client.ts +71 -0
  3. package/amplify/auth/resource.js +4 -1
  4. package/amplify/auth/resource.ts +5 -1
  5. package/amplify/data/resource.js +4 -0
  6. package/amplify/data/resource.ts +4 -0
  7. package/amplify/data/schema.js +4 -0
  8. package/amplify/data/schema.ts +4 -0
  9. package/amplify/site-backend.js +8 -1
  10. package/amplify/site-backend.ts +9 -1
  11. package/app/api/auth/prospectus/callback/route.ts +9 -0
  12. package/app/api/auth/prospectus/selection/route.ts +9 -0
  13. package/app/api/auth/prospectus/session/route.ts +9 -0
  14. package/app/api/auth/prospectus/sign-in/route.ts +9 -0
  15. package/app/api/auth/prospectus/sign-out/route.ts +9 -0
  16. package/components/news-desk-taxonomy-client.ts +37 -1
  17. package/components/newsroom-board-view.tsx +7 -8
  18. package/components/priming-proof-page.tsx +7 -5
  19. package/components/topic-steering-workspace.tsx +4 -0
  20. package/infra/amplify-app-shell.js +20 -2
  21. package/infra/amplify-app-shell.ts +22 -2
  22. package/infra/site-config.js +1 -1
  23. package/infra/site-config.ts +1 -1
  24. package/lib/category-repository.ts +1 -0
  25. package/lib/content-item-lifecycle.ts +55 -0
  26. package/lib/newsroom-board.ts +60 -21
  27. package/lib/newsroom-nav.ts +2 -1
  28. package/lib/newsroom-organizer-nav.ts +5 -2
  29. package/lib/priming-proof-report.ts +1 -0
  30. package/lib/prospectus-publication-access.ts +53 -0
  31. package/lib/prospectus-session.ts +196 -0
  32. package/lib/prospectus-sign-in-routes.ts +284 -0
  33. package/lib/site-brand.ts +19 -0
  34. package/package.json +11 -1
  35. package/routes.manifest.json +56 -1
@@ -2,6 +2,7 @@ import { Construct } from "constructs";
2
2
  import { ArnFormat, CfnOutput, Duration, Fn, Stack, StackProps } from "aws-cdk-lib";
3
3
  import * as iam from "aws-cdk-lib/aws-iam";
4
4
  import * as amplify from "aws-cdk-lib/aws-amplify";
5
+ import * as secretsmanager from "aws-cdk-lib/aws-secretsmanager";
5
6
  import { GITHUB_OIDC_PROVIDER_HOST } from "./github-oidc-provider";
6
7
  import { cmsProductionBuildSpec, cmsStagingBuildSpec, readerBuildSpec } from "./build-specs";
7
8
  import { AmplifyAppShellSiteConfig, PROSPECTUS_PAPYRUS_SITE_ROUTES, ReaderCustomHeaderRule, hasCmsApp, isStagingEnabled, resolveRedirectSubDomainPrefix, resolveCmsHostName, resolveProspectusAccountsEndpoint, resolveRevalidateSecretParameterName, resolveStackName, resolveStagingDomainName, resolveStoragePreviewPrefix } from "./site-config";
@@ -97,9 +98,28 @@ export class AmplifyAppShellStack extends Stack {
97
98
  }
98
99
 
99
100
  const prospectusAccountsApiStageArn = config.cms.prospectusAccountsApiStageArn;
100
- const prospectusEnvironment = prospectusAccountsApiStageArn
101
- ? { PAPYRUS_PROSPECTUS_ACCOUNTS_ENDPOINT: resolveProspectusAccountsEndpoint(prospectusAccountsApiStageArn) }
101
+ const prospectusSessionSealingSecret = prospectusAccountsApiStageArn
102
+ ? new secretsmanager.Secret(this, "ProspectusSessionSealingSecret", {
103
+ description: `Seals the Prospectus sign-in cookies of ${cmsAppName}; replacing it signs everyone out`,
104
+ generateSecretString: { passwordLength: 64, excludePunctuation: true },
105
+ })
106
+ : undefined;
107
+ const prospectusEnvironment = prospectusAccountsApiStageArn && prospectusSessionSealingSecret
108
+ ? {
109
+ PAPYRUS_PROSPECTUS_ACCOUNTS_ENDPOINT: resolveProspectusAccountsEndpoint(prospectusAccountsApiStageArn),
110
+ PAPYRUS_PROSPECTUS_SESSION_SECRET_ARN: prospectusSessionSealingSecret.secretArn,
111
+ PAPYRUS_PROSPECTUS_SITE_PRODUCT: `papyrus-${config.siteId}`,
112
+ }
102
113
  : {};
114
+ if (prospectusSessionSealingSecret) {
115
+ computeRole.addToPolicy(
116
+ new iam.PolicyStatement({
117
+ sid: "ReadProspectusSessionSealingSecret",
118
+ actions: ["secretsmanager:GetSecretValue"],
119
+ resources: [prospectusSessionSealingSecret.secretArn],
120
+ }),
121
+ );
122
+ }
103
123
  if (prospectusAccountsApiStageArn) {
104
124
  computeRole.addToPolicy(
105
125
  new iam.PolicyStatement({
@@ -17,7 +17,7 @@ const GITHUB_REPO_PATTERN = /^[A-Za-z0-9_.-]+$/;
17
17
  const COGNITO_DOMAIN_PREFIX_PATTERN = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
18
18
  const COGNITO_RESERVED_WORDS = ["aws", "amazon", "cognito"];
19
19
  const PROSPECTUS_ACCOUNTS_API_STAGE_ARN_PATTERN = /^arn:aws:execute-api:[a-z0-9-]+:\d{12}:[a-z0-9]+\/api$/;
20
- const TEMPLATE_MANAGED_ENVIRONMENT_KEYS = ["PAPYRUS_COGNITO_DOMAIN_PREFIX", "PAPYRUS_APPLY_COGNITO_DOMAIN_PREFIX", "PAPYRUS_DISABLE_GOOGLE_OAUTH", "PAPYRUS_REVALIDATE_SECRET_PARAMETER", "PAPYRUS_PROSPECTUS_ACCOUNTS_ENDPOINT"];
20
+ const TEMPLATE_MANAGED_ENVIRONMENT_KEYS = ["PAPYRUS_COGNITO_DOMAIN_PREFIX", "PAPYRUS_APPLY_COGNITO_DOMAIN_PREFIX", "PAPYRUS_DISABLE_GOOGLE_OAUTH", "PAPYRUS_REVALIDATE_SECRET_PARAMETER", "PAPYRUS_PROSPECTUS_ACCOUNTS_ENDPOINT", "PAPYRUS_PROSPECTUS_SESSION_SECRET_ARN", "PAPYRUS_PROSPECTUS_SITE_PRODUCT"];
21
21
  /** Routes Prospectus admits for a registered Papyrus site (Prospectus accounts/papyrus-sites.ts). */
22
22
  export const PROSPECTUS_PAPYRUS_SITE_ROUTES = [
23
23
  "GET/v1/identities/me",
@@ -97,7 +97,7 @@ const GITHUB_REPO_PATTERN = /^[A-Za-z0-9_.-]+$/;
97
97
  const COGNITO_DOMAIN_PREFIX_PATTERN = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
98
98
  const COGNITO_RESERVED_WORDS = ["aws", "amazon", "cognito"];
99
99
  const PROSPECTUS_ACCOUNTS_API_STAGE_ARN_PATTERN = /^arn:aws:execute-api:[a-z0-9-]+:\d{12}:[a-z0-9]+\/api$/;
100
- const TEMPLATE_MANAGED_ENVIRONMENT_KEYS = ["PAPYRUS_COGNITO_DOMAIN_PREFIX", "PAPYRUS_APPLY_COGNITO_DOMAIN_PREFIX", "PAPYRUS_DISABLE_GOOGLE_OAUTH", "PAPYRUS_REVALIDATE_SECRET_PARAMETER", "PAPYRUS_PROSPECTUS_ACCOUNTS_ENDPOINT"];
100
+ const TEMPLATE_MANAGED_ENVIRONMENT_KEYS = ["PAPYRUS_COGNITO_DOMAIN_PREFIX", "PAPYRUS_APPLY_COGNITO_DOMAIN_PREFIX", "PAPYRUS_DISABLE_GOOGLE_OAUTH", "PAPYRUS_REVALIDATE_SECRET_PARAMETER", "PAPYRUS_PROSPECTUS_ACCOUNTS_ENDPOINT", "PAPYRUS_PROSPECTUS_SESSION_SECRET_ARN", "PAPYRUS_PROSPECTUS_SITE_PRODUCT"];
101
101
 
102
102
  /** Routes Prospectus admits for a registered Papyrus site (Prospectus accounts/papyrus-sites.ts). */
103
103
  export const PROSPECTUS_PAPYRUS_SITE_ROUTES = [
@@ -494,6 +494,7 @@ export type AssignmentRecord = {
494
494
  createdAt: string;
495
495
  updatedAt: string;
496
496
  newsroomFeedKey?: string | null;
497
+ itemLineageId?: string | null;
497
498
  metadata?: unknown;
498
499
  };
499
500
 
@@ -0,0 +1,55 @@
1
+ /**
2
+ * One content item, one lifecycle: the stages an Item lineage moves through.
3
+ * Mirrors CONTENT_ITEM_STAGES in src/papyrus_content/content_item_lifecycle.py.
4
+ * See docs/content-item-lifecycle.md.
5
+ */
6
+ export const CONTENT_ITEM_STAGES = [
7
+ "idea",
8
+ "assignment",
9
+ "research",
10
+ "report",
11
+ "editor_select",
12
+ "copywriting",
13
+ "published",
14
+ ] as const;
15
+
16
+ export type ContentItemStage = (typeof CONTENT_ITEM_STAGES)[number];
17
+
18
+ const CONTENT_ITEM_STAGE_LABELS: Record<ContentItemStage, string> = {
19
+ idea: "Idea",
20
+ assignment: "Assignment",
21
+ research: "Research",
22
+ report: "Report",
23
+ editor_select: "Editor select",
24
+ copywriting: "Copywriting",
25
+ published: "Published",
26
+ };
27
+
28
+ export function contentItemStageLabel(stage: string): string {
29
+ return (CONTENT_ITEM_STAGE_LABELS as Record<string, string>)[stage] ?? stage;
30
+ }
31
+
32
+ export type ContentItemVersionRecord = {
33
+ id: string;
34
+ lineageId: string;
35
+ versionNumber: number;
36
+ stage?: string | null;
37
+ type: string;
38
+ status: string;
39
+ title?: string | null;
40
+ headline?: string | null;
41
+ slug: string;
42
+ section?: string | null;
43
+ updatedAt?: string | null;
44
+ };
45
+
46
+ export function latestVersionPerContentItem(
47
+ versions: readonly ContentItemVersionRecord[],
48
+ ): ContentItemVersionRecord[] {
49
+ const latestByLineage = new Map<string, ContentItemVersionRecord>();
50
+ for (const version of versions) {
51
+ const current = latestByLineage.get(version.lineageId);
52
+ if (!current || version.versionNumber > current.versionNumber) latestByLineage.set(version.lineageId, version);
53
+ }
54
+ return [...latestByLineage.values()];
55
+ }
@@ -1,38 +1,67 @@
1
1
  import type { KanbanConfig, KanbanIssue } from "@kanbus/ui";
2
2
  import type { AssignmentRecord, ReferenceRecord } from "./category-repository";
3
- import type { NewsroomArticleRow } from "./newsroom-articles";
3
+ import {
4
+ CONTENT_ITEM_STAGES,
5
+ contentItemStageLabel,
6
+ latestVersionPerContentItem,
7
+ type ContentItemVersionRecord,
8
+ } from "./content-item-lifecycle";
9
+ import { getContentItemName } from "./site-brand";
4
10
 
5
- export type NativeBoardKind = "article" | "reference" | "assignment";
11
+ export type NativeBoardKind = "contentItem" | "reference" | "assignment";
6
12
  export interface NativeBoardIssue extends KanbanIssue {
7
- type: NativeBoardKind;
13
+ kind: NativeBoardKind;
8
14
  nativeId: string;
9
15
  nativeStatus: string;
10
16
  href: string;
11
17
  }
12
18
 
13
- /** Native records keep their own identity, status and mutation authority. No Story linkage is inferred. */
19
+ export function contentItemBoardType(): string {
20
+ return getContentItemName().singular.toLowerCase();
21
+ }
22
+
23
+ function boardTypeForKind(kind: NativeBoardKind): string {
24
+ return kind === "contentItem" ? contentItemBoardType() : kind;
25
+ }
26
+
27
+ /**
28
+ * One card per content item, in the column for its lifecycle stage. Assignments that are work on a content item
29
+ * are counted on that item's card rather than shown as separate cards. References and other work keep their own
30
+ * identity and native status.
31
+ */
14
32
  export function projectNewsroomBoard(records: {
15
- articles?: readonly NewsroomArticleRow[];
33
+ contentItemVersions?: readonly ContentItemVersionRecord[];
16
34
  references?: readonly ReferenceRecord[];
17
35
  assignments?: readonly AssignmentRecord[];
18
36
  }): NativeBoardIssue[] {
37
+ const assignmentCountByContentItem = new Map<string, number>();
38
+ for (const assignment of records.assignments ?? []) {
39
+ if (assignment.itemLineageId) {
40
+ assignmentCountByContentItem.set(assignment.itemLineageId, (assignmentCountByContentItem.get(assignment.itemLineageId) ?? 0) + 1);
41
+ }
42
+ }
43
+ const contentItemType = contentItemBoardType();
19
44
  return [
20
- ...(records.articles ?? []).map(row => ({
21
- id: `article:${row.id}`, nativeId: row.id, type: "article" as const,
22
- title: row.title, nativeStatus: row.status, status: `article/${row.status}`,
23
- priority: 2, description: row.pending ? "Published copy has pending draft changes." : undefined,
24
- updated_at: row.updatedAt, href: `/newsroom/articles/${encodeURIComponent(row.id)}`,
25
- })),
45
+ ...latestVersionPerContentItem(records.contentItemVersions ?? []).map(version => {
46
+ const stage = version.stage ?? "idea";
47
+ const workCount = assignmentCountByContentItem.get(version.lineageId) ?? 0;
48
+ return {
49
+ id: `contentItem:${version.lineageId}`, nativeId: version.lineageId, kind: "contentItem" as const, type: contentItemType,
50
+ title: version.headline || version.title || version.slug, nativeStatus: stage, status: `${contentItemType}/${stage}`,
51
+ priority: 2, description: workCount ? `${workCount} assignment${workCount === 1 ? "" : "s"} on this ${contentItemType}.` : undefined,
52
+ updated_at: version.updatedAt ?? undefined, href: `/newsroom/articles/${encodeURIComponent(version.id)}`,
53
+ };
54
+ }),
26
55
  ...(records.references ?? []).filter(row => !row.versionState || row.versionState === "current").map(row => ({
27
- id: `reference:${row.id}`, nativeId: row.id, type: "reference" as const,
56
+ id: `reference:${row.id}`, nativeId: row.id, kind: "reference" as const, type: "reference",
28
57
  title: row.title || row.externalItemId || row.id,
29
58
  nativeStatus: row.curationStatus || "pending", status: `reference/${row.curationStatus || "pending"}`,
30
59
  priority: 2, description: row.curationStatusReason || undefined,
31
60
  updated_at: row.updatedAt || row.importedAt || undefined,
32
61
  href: `/newsroom/references/${encodeURIComponent(row.lineageId || row.id)}`,
33
62
  })),
34
- ...(records.assignments ?? []).map(row => ({
35
- id: `assignment:${row.id}`, nativeId: row.id, type: "assignment" as const,
63
+ ...(records.assignments ?? []).filter(row => !row.itemLineageId).map(row => ({
64
+ id: `assignment:${row.id}`, nativeId: row.id, kind: "assignment" as const, type: "assignment",
36
65
  title: row.title, nativeStatus: row.status, status: `assignment/${row.status}`,
37
66
  priority: row.priority ?? 2, assignee: row.assigneeId || undefined,
38
67
  description: row.summary || row.brief || undefined,
@@ -42,16 +71,26 @@ export function projectNewsroomBoard(records: {
42
71
  ];
43
72
  }
44
73
 
45
- /** Unknown native statuses remain visible instead of being translated into invented stages. */
74
+ /** Content items always show every lifecycle stage in order; unknown native statuses of other records stay visible. */
46
75
  export function newsroomBoardConfig(issues: readonly NativeBoardIssue[]): KanbanConfig {
47
- const keys = [...new Set(issues.map(issue => issue.status))];
76
+ const contentItemType = contentItemBoardType();
77
+ const kinds: NativeBoardKind[] = ["contentItem", "reference", "assignment"];
78
+ const contentItemStatuses = [
79
+ ...CONTENT_ITEM_STAGES,
80
+ ...new Set(issues.filter(issue => issue.kind === "contentItem").map(issue => issue.nativeStatus)),
81
+ ].filter((stage, index, stages) => stages.indexOf(stage) === index)
82
+ .map(stage => ({ key: `${contentItemType}/${stage}`, name: contentItemStageLabel(stage), category: "native" }));
83
+ const otherStatuses = [...new Set(issues.filter(issue => issue.kind !== "contentItem").map(issue => issue.status))]
84
+ .map(key => ({ key, name: key.replace("/", " · "), category: "native" }));
85
+ const statuses = [...contentItemStatuses, ...otherStatuses];
48
86
  return {
49
- hierarchy: [], types: ["article", "reference", "assignment"],
50
- statuses: keys.map(key => ({ key, name: key.replace("/", " · "), category: "native" })),
87
+ hierarchy: [], types: kinds.map(boardTypeForKind),
88
+ statuses,
51
89
  categories: [{ name: "native" }], priorities: {}, type_colors: {},
52
- workflows: Object.fromEntries(["article", "reference", "assignment"].map(kind => [kind,
53
- Object.fromEntries(keys.filter(key => key.startsWith(`${kind}/`)).map(key => [key, []])),
54
- ])),
90
+ workflows: Object.fromEntries(kinds.map(kind => {
91
+ const type = boardTypeForKind(kind);
92
+ return [type, Object.fromEntries(statuses.filter(status => status.key.startsWith(`${type}/`)).map(status => [status.key, []]))];
93
+ })),
55
94
  };
56
95
  }
57
96
 
@@ -1,4 +1,5 @@
1
1
  import { newsroomHref, toPublicNewsroomPath } from "./newsroom-base-path";
2
+ import { getContentItemName } from "./site-brand";
2
3
 
3
4
  export type NewsroomNavTabId =
4
5
  | "overview"
@@ -23,7 +24,7 @@ export type NewsroomNavItem = {
23
24
 
24
25
  export const NEWSROOM_OPS_NAV: NewsroomNavItem[] = [
25
26
  { id: "overview", label: "Overview", detail: "Desk home", href: newsroomHref(), mobilePrimary: true },
26
- { id: "articles", label: "Articles", detail: "Drafts & published", href: newsroomHref("articles"), mobilePrimary: true },
27
+ { id: "articles", label: getContentItemName().plural, detail: "Drafts & published", href: newsroomHref("articles"), mobilePrimary: true },
27
28
  { id: "assignments", label: "Assignments", detail: "Work queue", href: newsroomHref("assignments"), mobilePrimary: true },
28
29
  { id: "references", label: "References", detail: "Knowledge base", href: newsroomHref("references"), mobilePrimary: true },
29
30
  { id: "messages", label: "Messages", detail: "Forum", href: newsroomHref("messages") },
@@ -1,3 +1,6 @@
1
+ import { getContentItemName } from "./site-brand";
2
+
3
+ const contentItemPluralLowercase = getContentItemName().plural.toLowerCase();
1
4
  export type OrganizerDestination = {
2
5
  key: string; label: string; description: string; path: string;
3
6
  access?: "admin"; primary?: boolean; unavailable?: string;
@@ -5,8 +8,8 @@ export type OrganizerDestination = {
5
8
  export const ORGANIZER_NAV_GROUPS: readonly { title: string; items: readonly OrganizerDestination[] }[] = [
6
9
  { title: "Editorial work", items: [
7
10
  { key: "overview", label: "Overview", description: "See the desk and choose where to begin.", path: "/newsroom", primary: true },
8
- { key: "board", label: "Board", description: "Follow articles, sources and assignments across their stages.", path: "/newsroom/work", primary: true },
9
- { key: "articles", label: "Articles", description: "Write, revise and prepare copy for publication.", path: "/newsroom/articles" },
11
+ { key: "board", label: "Board", description: `Follow ${contentItemPluralLowercase}, sources and assignments across their stages.`, path: "/newsroom/work", primary: true },
12
+ { key: "articles", label: getContentItemName().plural, description: "Write, revise and prepare copy for publication.", path: "/newsroom/articles" },
10
13
  { key: "assignments", label: "Assignments", description: "Claim work and follow research and reporting progress.", path: "/newsroom/assignments" },
11
14
  { key: "messages", label: "Messages", description: "Read and reply to newsroom conversations.", path: "/newsroom/messages", primary: true },
12
15
  { key: "insights", label: "Insights", description: "Discuss findings and research threads.", path: "/newsroom/insights" },
@@ -41,6 +41,7 @@ export const primingProofReportSchema = z.object({
41
41
  acceptedExamples: z.number().int().min(0),
42
42
  excludedBecauseInHeldOut: z.number().int().min(0),
43
43
  fingerprint: z.string().nullable(),
44
+ source: z.string().min(1),
44
45
  order: z.string().min(1),
45
46
  }),
46
47
  conditions: z.object({
@@ -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
+ }