@anthusai/papyrus 1.0.0-next.63 → 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.
@@ -0,0 +1,66 @@
1
+ import { CfnOutput } from "aws-cdk-lib";
2
+ import { CfnUserPoolClient, CfnUserPoolIdentityProvider } from "aws-cdk-lib/aws-cognito";
3
+ /**
4
+ * The Google-only app client a Papyrus site registers with Prospectus
5
+ * (Prospectus docs/accounts/GOOGLE_IDENTITY.md, policy google-only-unlinked-v1).
6
+ * It lives on the site's existing user pool next to the email + Google web
7
+ * client, but admits only the built-in Google provider through code + PKCE from
8
+ * the site server, with every native, SRP, custom and client-credentials path
9
+ * left off. Sessions last one access token: the server keeps no refresh token.
10
+ */
11
+ export const PROSPECTUS_SIGN_IN_CALLBACK_PATH = "/api/auth/prospectus/callback";
12
+ export const PROSPECTUS_SIGN_IN_TOKEN_VALIDITY_MINUTES = 60;
13
+ export function prospectusSignInCallbackUrls(redirectUrls) {
14
+ const origins = [];
15
+ for (const redirectUrl of redirectUrls) {
16
+ try {
17
+ const origin = new URL(redirectUrl).origin;
18
+ if (origin !== "null" && !origins.includes(origin))
19
+ origins.push(origin);
20
+ }
21
+ catch {
22
+ continue;
23
+ }
24
+ }
25
+ return origins.map((origin) => `${origin}${PROSPECTUS_SIGN_IN_CALLBACK_PATH}`);
26
+ }
27
+ export function prospectusSignInLogoutUrls(redirectUrls) {
28
+ return prospectusSignInCallbackUrls(redirectUrls).map((callbackUrl) => `${new URL(callbackUrl).origin}/`);
29
+ }
30
+ export function addProspectusGoogleOnlyClient(authStack, userPool, redirectUrls) {
31
+ const callbackUrls = prospectusSignInCallbackUrls(redirectUrls);
32
+ if (callbackUrls.length === 0)
33
+ throw new Error("The Prospectus Google-only client needs at least one site origin.");
34
+ const googleOnlyClient = new CfnUserPoolClient(authStack, "ProspectusGoogleOnlyClient", {
35
+ userPoolId: userPool.userPoolId,
36
+ clientName: "prospectus-google-only",
37
+ generateSecret: false,
38
+ supportedIdentityProviders: ["Google"],
39
+ allowedOAuthFlowsUserPoolClient: true,
40
+ allowedOAuthFlows: ["code"],
41
+ allowedOAuthScopes: ["openid", "email", "profile"],
42
+ callbackUrLs: callbackUrls,
43
+ logoutUrLs: prospectusSignInLogoutUrls(redirectUrls),
44
+ explicitAuthFlows: ["ALLOW_REFRESH_TOKEN_AUTH"],
45
+ enableTokenRevocation: true,
46
+ preventUserExistenceErrors: "ENABLED",
47
+ accessTokenValidity: PROSPECTUS_SIGN_IN_TOKEN_VALIDITY_MINUTES,
48
+ idTokenValidity: PROSPECTUS_SIGN_IN_TOKEN_VALIDITY_MINUTES,
49
+ refreshTokenValidity: PROSPECTUS_SIGN_IN_TOKEN_VALIDITY_MINUTES,
50
+ tokenValidityUnits: { accessToken: "minutes", idToken: "minutes", refreshToken: "minutes" },
51
+ });
52
+ const googleIdentityProviders = authStack.node
53
+ .findAll()
54
+ .filter((construct) => construct instanceof CfnUserPoolIdentityProvider && construct.providerName === "Google");
55
+ for (const googleIdentityProvider of googleIdentityProviders)
56
+ googleOnlyClient.addDependency(googleIdentityProvider);
57
+ new CfnOutput(authStack, "ProspectusGoogleOnlyClientId", {
58
+ value: googleOnlyClient.ref,
59
+ description: "clientId for this site's Prospectus registration (accounts/papyrus-sites.json)",
60
+ });
61
+ new CfnOutput(authStack, "ProspectusGoogleOnlyIssuer", {
62
+ value: `https://cognito-idp.${authStack.region}.amazonaws.com/${userPool.userPoolId}`,
63
+ description: "issuer for this site's Prospectus registration (accounts/papyrus-sites.json)",
64
+ });
65
+ return googleOnlyClient;
66
+ }
@@ -0,0 +1,71 @@
1
+ import { CfnOutput, type Stack } from "aws-cdk-lib";
2
+ import { CfnUserPoolClient, CfnUserPoolIdentityProvider, type IUserPool } from "aws-cdk-lib/aws-cognito";
3
+
4
+ /**
5
+ * The Google-only app client a Papyrus site registers with Prospectus
6
+ * (Prospectus docs/accounts/GOOGLE_IDENTITY.md, policy google-only-unlinked-v1).
7
+ * It lives on the site's existing user pool next to the email + Google web
8
+ * client, but admits only the built-in Google provider through code + PKCE from
9
+ * the site server, with every native, SRP, custom and client-credentials path
10
+ * left off. Sessions last one access token: the server keeps no refresh token.
11
+ */
12
+ export const PROSPECTUS_SIGN_IN_CALLBACK_PATH = "/api/auth/prospectus/callback";
13
+ export const PROSPECTUS_SIGN_IN_TOKEN_VALIDITY_MINUTES = 60;
14
+
15
+ export function prospectusSignInCallbackUrls(redirectUrls: readonly string[]): string[] {
16
+ const origins: string[] = [];
17
+ for (const redirectUrl of redirectUrls) {
18
+ try {
19
+ const origin = new URL(redirectUrl).origin;
20
+ if (origin !== "null" && !origins.includes(origin)) origins.push(origin);
21
+ } catch {
22
+ continue;
23
+ }
24
+ }
25
+ return origins.map((origin) => `${origin}${PROSPECTUS_SIGN_IN_CALLBACK_PATH}`);
26
+ }
27
+
28
+ export function prospectusSignInLogoutUrls(redirectUrls: readonly string[]): string[] {
29
+ return prospectusSignInCallbackUrls(redirectUrls).map((callbackUrl) => `${new URL(callbackUrl).origin}/`);
30
+ }
31
+
32
+ export function addProspectusGoogleOnlyClient(
33
+ authStack: Stack,
34
+ userPool: IUserPool,
35
+ redirectUrls: readonly string[],
36
+ ): CfnUserPoolClient {
37
+ const callbackUrls = prospectusSignInCallbackUrls(redirectUrls);
38
+ if (callbackUrls.length === 0) throw new Error("The Prospectus Google-only client needs at least one site origin.");
39
+ const googleOnlyClient = new CfnUserPoolClient(authStack, "ProspectusGoogleOnlyClient", {
40
+ userPoolId: userPool.userPoolId,
41
+ clientName: "prospectus-google-only",
42
+ generateSecret: false,
43
+ supportedIdentityProviders: ["Google"],
44
+ allowedOAuthFlowsUserPoolClient: true,
45
+ allowedOAuthFlows: ["code"],
46
+ allowedOAuthScopes: ["openid", "email", "profile"],
47
+ callbackUrLs: callbackUrls,
48
+ logoutUrLs: prospectusSignInLogoutUrls(redirectUrls),
49
+ explicitAuthFlows: ["ALLOW_REFRESH_TOKEN_AUTH"],
50
+ enableTokenRevocation: true,
51
+ preventUserExistenceErrors: "ENABLED",
52
+ accessTokenValidity: PROSPECTUS_SIGN_IN_TOKEN_VALIDITY_MINUTES,
53
+ idTokenValidity: PROSPECTUS_SIGN_IN_TOKEN_VALIDITY_MINUTES,
54
+ refreshTokenValidity: PROSPECTUS_SIGN_IN_TOKEN_VALIDITY_MINUTES,
55
+ tokenValidityUnits: { accessToken: "minutes", idToken: "minutes", refreshToken: "minutes" },
56
+ });
57
+ const googleIdentityProviders = authStack.node
58
+ .findAll()
59
+ .filter((construct): construct is CfnUserPoolIdentityProvider => construct instanceof CfnUserPoolIdentityProvider && construct.providerName === "Google");
60
+ for (const googleIdentityProvider of googleIdentityProviders) googleOnlyClient.addDependency(googleIdentityProvider);
61
+
62
+ new CfnOutput(authStack, "ProspectusGoogleOnlyClientId", {
63
+ value: googleOnlyClient.ref,
64
+ description: "clientId for this site's Prospectus registration (accounts/papyrus-sites.json)",
65
+ });
66
+ new CfnOutput(authStack, "ProspectusGoogleOnlyIssuer", {
67
+ value: `https://cognito-idp.${authStack.region}.amazonaws.com/${userPool.userPoolId}`,
68
+ description: "issuer for this site's Prospectus registration (accounts/papyrus-sites.json)",
69
+ });
70
+ return googleOnlyClient;
71
+ }
@@ -32,8 +32,11 @@ export function authConfigFromEnv() {
32
32
  applyCognitoDomainPrefix: process.env.PAPYRUS_APPLY_COGNITO_DOMAIN_PREFIX === "true",
33
33
  };
34
34
  }
35
+ export function siteAuthRedirectUrls(config) {
36
+ return config.redirectUrls ?? DEFAULT_AUTH_REDIRECT_URLS;
37
+ }
35
38
  export function defineSiteAuth(config) {
36
- const authRedirectUrls = expandNewsroomRedirectUrls(config.redirectUrls ?? DEFAULT_AUTH_REDIRECT_URLS, config.newsroomBasePath);
39
+ const authRedirectUrls = expandNewsroomRedirectUrls(siteAuthRedirectUrls(config), config.newsroomBasePath);
37
40
  const disableGoogleOAuth = config.disableGoogleOAuth === true;
38
41
  return defineAuth({
39
42
  loginWith: {
@@ -37,9 +37,13 @@ export function authConfigFromEnv(): PapyrusAuthConfig {
37
37
  };
38
38
  }
39
39
 
40
+ export function siteAuthRedirectUrls(config: PapyrusAuthConfig): string[] {
41
+ return config.redirectUrls ?? DEFAULT_AUTH_REDIRECT_URLS;
42
+ }
43
+
40
44
  export function defineSiteAuth(config: PapyrusAuthConfig) {
41
45
  const authRedirectUrls = expandNewsroomRedirectUrls(
42
- config.redirectUrls ?? DEFAULT_AUTH_REDIRECT_URLS,
46
+ siteAuthRedirectUrls(config),
43
47
  config.newsroomBasePath,
44
48
  );
45
49
  const disableGoogleOAuth = config.disableGoogleOAuth === true;
@@ -17,7 +17,8 @@ import { assertValidPublicationConfig } from "./site-backend-config.js";
17
17
  import { revalidateSecretReadStatement } from "./revalidate-secret-policy.js";
18
18
  import { revalidateDistributionEnvironment, revalidateInvalidationStatement, validateRevalidateDistributionIds, } from "./revalidate-invalidation-settings.js";
19
19
  import { addInvalidationPathAlarm } from "./publication/invalidation-alarm.js";
20
- import { applyCognitoDomainPrefix, authConfigFromEnv, defineSiteAuth } from "./auth/resource.js";
20
+ import { applyCognitoDomainPrefix, authConfigFromEnv, defineSiteAuth, siteAuthRedirectUrls } from "./auth/resource.js";
21
+ import { PROSPECTUS_SIGN_IN_CALLBACK_PATH, addProspectusGoogleOnlyClient } from "./auth/prospectus-google-client.js";
21
22
  import { defineSiteData } from "./data/resource.js";
22
23
  import { resolvePlugins } from "./plugins/registry.js";
23
24
  import { wirePlugins } from "./plugins/wire.js";
@@ -107,6 +108,9 @@ export function defineSiteBackend(papyrusSite) {
107
108
  && authConfig.disableGoogleOAuth !== true) {
108
109
  applyCognitoDomainPrefix(backend.auth.stack, requestedCognitoDomainPrefix);
109
110
  }
111
+ const prospectusGoogleOnlyClient = authConfig.disableGoogleOAuth === true
112
+ ? undefined
113
+ : addProspectusGoogleOnlyClient(backend.auth.stack, backend.auth.resources.userPool, siteAuthRedirectUrls(authConfig));
110
114
  const amplifyBackendDir = dirname(fileURLToPath(import.meta.url));
111
115
  // Repo root in a Papyrus checkout; package root when installed from npm.
112
116
  const projectRoot = resolve(amplifyBackendDir, "..");
@@ -546,6 +550,9 @@ export function defineSiteBackend(papyrusSite) {
546
550
  }));
547
551
  backend.addOutput({
548
552
  custom: {
553
+ prospectusSignIn: prospectusGoogleOnlyClient
554
+ ? { clientId: prospectusGoogleOnlyClient.ref, callbackPath: PROSPECTUS_SIGN_IN_CALLBACK_PATH }
555
+ : null,
549
556
  inboundEmail: enableInboundEmail
550
557
  ? {
551
558
  domain: inboundEmailDomain,
@@ -24,7 +24,8 @@ import {
24
24
  validateRevalidateDistributionIds,
25
25
  } from "./revalidate-invalidation-settings";
26
26
  import { addInvalidationPathAlarm } from "./publication/invalidation-alarm";
27
- import { applyCognitoDomainPrefix, authConfigFromEnv, defineSiteAuth } from "./auth/resource";
27
+ import { applyCognitoDomainPrefix, authConfigFromEnv, defineSiteAuth, siteAuthRedirectUrls } from "./auth/resource";
28
+ import { PROSPECTUS_SIGN_IN_CALLBACK_PATH, addProspectusGoogleOnlyClient } from "./auth/prospectus-google-client";
28
29
  import { defineSiteData } from "./data/resource";
29
30
  import { resolvePlugins } from "./plugins/registry";
30
31
  import { wirePlugins } from "./plugins/wire";
@@ -137,6 +138,10 @@ export function defineSiteBackend(papyrusSite: PapyrusSite) {
137
138
  applyCognitoDomainPrefix(backend.auth.stack, requestedCognitoDomainPrefix);
138
139
  }
139
140
 
141
+ const prospectusGoogleOnlyClient = authConfig.disableGoogleOAuth === true
142
+ ? undefined
143
+ : addProspectusGoogleOnlyClient(backend.auth.stack, backend.auth.resources.userPool, siteAuthRedirectUrls(authConfig));
144
+
140
145
  const amplifyBackendDir = dirname(fileURLToPath(import.meta.url));
141
146
  // Repo root in a Papyrus checkout; package root when installed from npm.
142
147
  const projectRoot = resolve(amplifyBackendDir, "..");
@@ -682,6 +687,9 @@ export function defineSiteBackend(papyrusSite: PapyrusSite) {
682
687
 
683
688
  backend.addOutput({
684
689
  custom: {
690
+ prospectusSignIn: prospectusGoogleOnlyClient
691
+ ? { clientId: prospectusGoogleOnlyClient.ref, callbackPath: PROSPECTUS_SIGN_IN_CALLBACK_PATH }
692
+ : null,
685
693
  inboundEmail: enableInboundEmail
686
694
  ? {
687
695
  domain: inboundEmailDomain,
@@ -0,0 +1,9 @@
1
+ import type { NextRequest } from "next/server";
2
+ import { handleProspectusSignInCallback } from "../../../../../lib/prospectus-sign-in-routes";
3
+
4
+ export const dynamic = "force-dynamic";
5
+ export const runtime = "nodejs";
6
+
7
+ export function GET(request: NextRequest) {
8
+ return handleProspectusSignInCallback(request);
9
+ }
@@ -0,0 +1,9 @@
1
+ import type { NextRequest } from "next/server";
2
+ import { handleProspectusAccountSelection } from "../../../../../lib/prospectus-sign-in-routes";
3
+
4
+ export const dynamic = "force-dynamic";
5
+ export const runtime = "nodejs";
6
+
7
+ export function POST(request: NextRequest) {
8
+ return handleProspectusAccountSelection(request);
9
+ }
@@ -0,0 +1,9 @@
1
+ import type { NextRequest } from "next/server";
2
+ import { handleProspectusSessionRead } from "../../../../../lib/prospectus-sign-in-routes";
3
+
4
+ export const dynamic = "force-dynamic";
5
+ export const runtime = "nodejs";
6
+
7
+ export function GET(request: NextRequest) {
8
+ return handleProspectusSessionRead(request);
9
+ }
@@ -0,0 +1,9 @@
1
+ import type { NextRequest } from "next/server";
2
+ import { handleProspectusSignInStart } from "../../../../../lib/prospectus-sign-in-routes";
3
+
4
+ export const dynamic = "force-dynamic";
5
+ export const runtime = "nodejs";
6
+
7
+ export function GET(request: NextRequest) {
8
+ return handleProspectusSignInStart(request);
9
+ }
@@ -0,0 +1,9 @@
1
+ import type { NextRequest } from "next/server";
2
+ import { handleProspectusSignOut } from "../../../../../lib/prospectus-sign-in-routes";
3
+
4
+ export const dynamic = "force-dynamic";
5
+ export const runtime = "nodejs";
6
+
7
+ export function POST(request: NextRequest) {
8
+ return handleProspectusSignOut(request);
9
+ }
@@ -1,6 +1,7 @@
1
1
  import { ArnFormat, CfnOutput, Duration, Fn, Stack } from "aws-cdk-lib";
2
2
  import * as iam from "aws-cdk-lib/aws-iam";
3
3
  import * as amplify from "aws-cdk-lib/aws-amplify";
4
+ import * as secretsmanager from "aws-cdk-lib/aws-secretsmanager";
4
5
  import { GITHUB_OIDC_PROVIDER_HOST } from "./github-oidc-provider.js";
5
6
  import { cmsProductionBuildSpec, cmsStagingBuildSpec, readerBuildSpec } from "./build-specs.js";
6
7
  import { PROSPECTUS_PAPYRUS_SITE_ROUTES, hasCmsApp, isStagingEnabled, resolveRedirectSubDomainPrefix, resolveCmsHostName, resolveProspectusAccountsEndpoint, resolveRevalidateSecretParameterName, resolveStackName, resolveStagingDomainName, resolveStoragePreviewPrefix } from "./site-config.js";
@@ -86,9 +87,26 @@ export class AmplifyAppShellStack extends Stack {
86
87
  }));
87
88
  }
88
89
  const prospectusAccountsApiStageArn = config.cms.prospectusAccountsApiStageArn;
89
- const prospectusEnvironment = prospectusAccountsApiStageArn
90
- ? { PAPYRUS_PROSPECTUS_ACCOUNTS_ENDPOINT: resolveProspectusAccountsEndpoint(prospectusAccountsApiStageArn) }
90
+ const prospectusSessionSealingSecret = prospectusAccountsApiStageArn
91
+ ? new secretsmanager.Secret(this, "ProspectusSessionSealingSecret", {
92
+ description: `Seals the Prospectus sign-in cookies of ${cmsAppName}; replacing it signs everyone out`,
93
+ generateSecretString: { passwordLength: 64, excludePunctuation: true },
94
+ })
95
+ : undefined;
96
+ const prospectusEnvironment = prospectusAccountsApiStageArn && prospectusSessionSealingSecret
97
+ ? {
98
+ PAPYRUS_PROSPECTUS_ACCOUNTS_ENDPOINT: resolveProspectusAccountsEndpoint(prospectusAccountsApiStageArn),
99
+ PAPYRUS_PROSPECTUS_SESSION_SECRET_ARN: prospectusSessionSealingSecret.secretArn,
100
+ PAPYRUS_PROSPECTUS_SITE_PRODUCT: `papyrus-${config.siteId}`,
101
+ }
91
102
  : {};
103
+ if (prospectusSessionSealingSecret) {
104
+ computeRole.addToPolicy(new iam.PolicyStatement({
105
+ sid: "ReadProspectusSessionSealingSecret",
106
+ actions: ["secretsmanager:GetSecretValue"],
107
+ resources: [prospectusSessionSealingSecret.secretArn],
108
+ }));
109
+ }
92
110
  if (prospectusAccountsApiStageArn) {
93
111
  computeRole.addToPolicy(new iam.PolicyStatement({
94
112
  sid: "InvokeProspectusAccountsAsRegisteredSite",
@@ -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 = [
@@ -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
+ }
@@ -0,0 +1,284 @@
1
+ import { NextResponse, type NextRequest } from "next/server";
2
+ import {
3
+ CYCLOTRON_LAB_PRODUCT,
4
+ ProspectusAccountsError,
5
+ authorizeSignedInPersonForAccount,
6
+ beginProspectusGoogleSignIn,
7
+ completeProspectusGoogleSignIn,
8
+ type CompleteProspectusGoogleSignInInput,
9
+ } from "./prospectus-site-identity";
10
+ import {
11
+ PROSPECTUS_PENDING_SIGN_IN_COOKIE,
12
+ PROSPECTUS_SESSION_COOKIE,
13
+ openProspectusCookie,
14
+ resolveProspectusSignInRuntime,
15
+ sameSiteReturnPath,
16
+ sealProspectusCookie,
17
+ sealedCookieAttributes,
18
+ type PendingProspectusSignInCookie,
19
+ type ProspectusSignInRuntime,
20
+ type ProspectusSignedInSession,
21
+ } from "./prospectus-session";
22
+ import {
23
+ prospectusSiteProduct,
24
+ publicationCapabilitiesForProspectusRole,
25
+ publishingAccountsForSite,
26
+ type PublicationCapability,
27
+ } from "./prospectus-publication-access";
28
+
29
+ /**
30
+ * The site's sign-in routes for Prospectus accounts: start (challenge, then the
31
+ * Google-only Cognito redirect), callback (verify, resolve, seal the session),
32
+ * session (who is signed in and which accounts they belong to) and sign-out.
33
+ * Every route answers 503 until the site is registered and configured, so an
34
+ * unregistered deployment never half-signs anyone in.
35
+ */
36
+ export interface ProspectusSignInRouteOptions {
37
+ resolveRuntime?: (siteOrigin: string) => Promise<ProspectusSignInRuntime | null>;
38
+ siteProduct?: string | null;
39
+ completionOverrides?: Pick<CompleteProspectusGoogleSignInInput, "verifyCognitoTokens" | "fetcher">;
40
+ nowInSeconds?: () => number;
41
+ }
42
+
43
+ const NO_STORE_HEADERS = { "cache-control": "no-store" };
44
+
45
+ function siteProductFor(options: ProspectusSignInRouteOptions): string | null {
46
+ return options.siteProduct !== undefined ? options.siteProduct : prospectusSiteProduct();
47
+ }
48
+
49
+ function currentTimeInSeconds(options: ProspectusSignInRouteOptions): number {
50
+ return options.nowInSeconds?.() ?? Math.floor(Date.now() / 1000);
51
+ }
52
+
53
+ async function signInRuntimeFor(request: NextRequest, options: ProspectusSignInRouteOptions): Promise<ProspectusSignInRuntime | null> {
54
+ return (options.resolveRuntime ?? resolveProspectusSignInRuntime)(request.nextUrl.origin);
55
+ }
56
+
57
+ function signInUnavailableResponse(): NextResponse {
58
+ return NextResponse.json(
59
+ { error: "prospectus_sign_in_unavailable", message: "Sign-in through Prospectus is not configured for this site." },
60
+ { status: 503, headers: NO_STORE_HEADERS },
61
+ );
62
+ }
63
+
64
+ function clearSealedCookie(response: NextResponse, cookieName: string): void {
65
+ response.cookies.set(cookieName, "", sealedCookieAttributes(0, 0));
66
+ }
67
+
68
+ function signInFailedResponse(status: number, code: string, message: string): NextResponse {
69
+ return NextResponse.json({ error: code, message }, { status, headers: NO_STORE_HEADERS });
70
+ }
71
+
72
+ export function signedInProspectusSession(
73
+ request: NextRequest,
74
+ sealingSecret: string,
75
+ nowInSeconds?: number,
76
+ ): ProspectusSignedInSession | null {
77
+ return openProspectusCookie<ProspectusSignedInSession>(
78
+ sealingSecret,
79
+ PROSPECTUS_SESSION_COOKIE,
80
+ request.cookies.get(PROSPECTUS_SESSION_COOKIE)?.value,
81
+ nowInSeconds,
82
+ );
83
+ }
84
+
85
+ export async function handleProspectusSignInStart(request: NextRequest, options: ProspectusSignInRouteOptions = {}): Promise<NextResponse> {
86
+ const runtime = await signInRuntimeFor(request, options);
87
+ if (!runtime) return signInUnavailableResponse();
88
+ const { authorizationUrl, pendingSignIn } = await beginProspectusGoogleSignIn(runtime.accounts, runtime.cognitoClient);
89
+ const pendingSignInCookie: PendingProspectusSignInCookie = {
90
+ ...pendingSignIn,
91
+ returnPath: sameSiteReturnPath(request.nextUrl.searchParams.get("returnTo")),
92
+ };
93
+ const response = NextResponse.redirect(authorizationUrl, { status: 303, headers: NO_STORE_HEADERS });
94
+ response.cookies.set(
95
+ PROSPECTUS_PENDING_SIGN_IN_COOKIE,
96
+ sealProspectusCookie(runtime.sealingSecret, PROSPECTUS_PENDING_SIGN_IN_COOKIE, pendingSignInCookie, pendingSignIn.expiresAt),
97
+ sealedCookieAttributes(pendingSignIn.expiresAt, currentTimeInSeconds(options)),
98
+ );
99
+ return response;
100
+ }
101
+
102
+ export async function handleProspectusSignInCallback(request: NextRequest, options: ProspectusSignInRouteOptions = {}): Promise<NextResponse> {
103
+ const runtime = await signInRuntimeFor(request, options);
104
+ if (!runtime) return signInUnavailableResponse();
105
+ const nowInSeconds = currentTimeInSeconds(options);
106
+ const pendingSignIn = openProspectusCookie<PendingProspectusSignInCookie>(
107
+ runtime.sealingSecret,
108
+ PROSPECTUS_PENDING_SIGN_IN_COOKIE,
109
+ request.cookies.get(PROSPECTUS_PENDING_SIGN_IN_COOKIE)?.value,
110
+ nowInSeconds,
111
+ );
112
+ const response = await completeCallback(request, runtime, pendingSignIn, options, nowInSeconds);
113
+ clearSealedCookie(response, PROSPECTUS_PENDING_SIGN_IN_COOKIE);
114
+ return response;
115
+ }
116
+
117
+ async function completeCallback(
118
+ request: NextRequest,
119
+ runtime: ProspectusSignInRuntime,
120
+ pendingSignIn: PendingProspectusSignInCookie | null,
121
+ options: ProspectusSignInRouteOptions,
122
+ nowInSeconds: number,
123
+ ): Promise<NextResponse> {
124
+ if (!pendingSignIn) return signInFailedResponse(401, "sign_in_expired", "This sign-in is no longer pending. Start again.");
125
+ const searchParams = request.nextUrl.searchParams;
126
+ if (searchParams.get("error")) return signInFailedResponse(401, "sign_in_declined", "Google sign-in did not complete.");
127
+ const authorizationCode = searchParams.get("code");
128
+ const returnedState = searchParams.get("state");
129
+ if (!authorizationCode || !returnedState) return signInFailedResponse(400, "sign_in_incomplete", "The sign-in response is missing its code or state.");
130
+ try {
131
+ const signedIn = await completeProspectusGoogleSignIn({
132
+ accounts: runtime.accounts,
133
+ cognitoClient: runtime.cognitoClient,
134
+ pendingSignIn,
135
+ returnedState,
136
+ authorizationCode,
137
+ nowInSeconds,
138
+ ...options.completionOverrides,
139
+ });
140
+ const session: ProspectusSignedInSession = {
141
+ personId: signedIn.person.id,
142
+ accessToken: signedIn.accessToken,
143
+ accessTokenExpiresAt: signedIn.accessTokenExpiresAt,
144
+ };
145
+ const response = NextResponse.redirect(new URL(pendingSignIn.returnPath, request.nextUrl.origin), { status: 303, headers: NO_STORE_HEADERS });
146
+ response.cookies.set(
147
+ PROSPECTUS_SESSION_COOKIE,
148
+ sealProspectusCookie(runtime.sealingSecret, PROSPECTUS_SESSION_COOKIE, session, session.accessTokenExpiresAt),
149
+ sealedCookieAttributes(session.accessTokenExpiresAt, nowInSeconds),
150
+ );
151
+ return response;
152
+ } catch (error) {
153
+ if (error instanceof ProspectusAccountsError) return signInFailedResponse(error.status, error.code, error.message);
154
+ console.error(`Prospectus sign-in failed (${error instanceof Error ? error.name : "error"}).`);
155
+ return signInFailedResponse(401, "sign_in_failed", "Sign-in could not be completed.");
156
+ }
157
+ }
158
+
159
+ export async function handleProspectusSessionRead(request: NextRequest, options: ProspectusSignInRouteOptions = {}): Promise<NextResponse> {
160
+ const runtime = await signInRuntimeFor(request, options);
161
+ if (!runtime) return signInUnavailableResponse();
162
+ const session = signedInProspectusSession(request, runtime.sealingSecret, currentTimeInSeconds(options));
163
+ if (!session) return NextResponse.json({ signedIn: false }, { headers: NO_STORE_HEADERS });
164
+ try {
165
+ const directory = await runtime.accounts.accountDirectory(session.accessToken);
166
+ if (!directory.person || directory.person.id !== session.personId) {
167
+ const response = NextResponse.json({ signedIn: false }, { headers: NO_STORE_HEADERS });
168
+ clearSealedCookie(response, PROSPECTUS_SESSION_COOKIE);
169
+ return response;
170
+ }
171
+ const publishingAccounts = publishingAccountsForSite(directory.accounts, siteProductFor(options));
172
+ return NextResponse.json(
173
+ {
174
+ signedIn: true,
175
+ person: directory.person,
176
+ accounts: directory.accounts,
177
+ cyclotronLabAccess: directory.accounts.some((account) =>
178
+ account.products.some((product) => product.product === CYCLOTRON_LAB_PRODUCT && product.status === "active"),
179
+ ),
180
+ publishingAccounts,
181
+ selectedAccountId: publishingAccounts.some((account) => account.accountId === session.selectedAccountId)
182
+ ? session.selectedAccountId
183
+ : null,
184
+ },
185
+ { headers: NO_STORE_HEADERS },
186
+ );
187
+ } catch (error) {
188
+ if (error instanceof ProspectusAccountsError && error.status === 401) {
189
+ const response = NextResponse.json({ signedIn: false }, { headers: NO_STORE_HEADERS });
190
+ clearSealedCookie(response, PROSPECTUS_SESSION_COOKIE);
191
+ return response;
192
+ }
193
+ if (error instanceof ProspectusAccountsError) return signInFailedResponse(error.status, error.code, error.message);
194
+ console.error(`Prospectus account directory failed (${error instanceof Error ? error.name : "error"}).`);
195
+ return signInFailedResponse(503, "prospectus_unavailable", "Prospectus could not be reached.");
196
+ }
197
+ }
198
+
199
+ export async function handleProspectusSignOut(request: NextRequest, options: ProspectusSignInRouteOptions = {}): Promise<NextResponse> {
200
+ const runtime = await signInRuntimeFor(request, options);
201
+ const signedOutDestination = new URL("/", request.nextUrl.origin);
202
+ const destination = runtime
203
+ ? new URL(
204
+ `/logout?${new URLSearchParams({ client_id: runtime.cognitoClient.clientId, logout_uri: signedOutDestination.toString() })}`,
205
+ runtime.cognitoClient.hostedUiOrigin,
206
+ )
207
+ : signedOutDestination;
208
+ const response = NextResponse.redirect(destination, { status: 303, headers: NO_STORE_HEADERS });
209
+ clearSealedCookie(response, PROSPECTUS_SESSION_COOKIE);
210
+ clearSealedCookie(response, PROSPECTUS_PENDING_SIGN_IN_COOKIE);
211
+ return response;
212
+ }
213
+
214
+ export interface ProspectusPublishingSelection {
215
+ personId: string;
216
+ accountId: string;
217
+ role: string;
218
+ publicationCapabilities: PublicationCapability[];
219
+ }
220
+
221
+ async function freshlyAuthorizedPublishingSelection(
222
+ runtime: ProspectusSignInRuntime,
223
+ session: ProspectusSignedInSession,
224
+ accountId: string,
225
+ siteProduct: string | null,
226
+ ): Promise<ProspectusPublishingSelection | null> {
227
+ const directory = await runtime.accounts.accountDirectory(session.accessToken);
228
+ if (!directory.person || directory.person.id !== session.personId) return null;
229
+ if (!publishingAccountsForSite(directory.accounts, siteProduct).some((account) => account.accountId === accountId)) return null;
230
+ const authorization = await authorizeSignedInPersonForAccount(runtime.accounts, session.accessToken, accountId, "console:read");
231
+ if (authorization.personId !== session.personId) return null;
232
+ const publicationCapabilities = publicationCapabilitiesForProspectusRole(authorization.role);
233
+ if (publicationCapabilities.length === 0) return null;
234
+ return { personId: session.personId, accountId, role: authorization.role, publicationCapabilities };
235
+ }
236
+
237
+ export async function currentProspectusPublishingSelection(
238
+ request: NextRequest,
239
+ options: ProspectusSignInRouteOptions = {},
240
+ ): Promise<ProspectusPublishingSelection | null> {
241
+ const runtime = await signInRuntimeFor(request, options);
242
+ if (!runtime) return null;
243
+ const session = signedInProspectusSession(request, runtime.sealingSecret, currentTimeInSeconds(options));
244
+ if (!session?.selectedAccountId) return null;
245
+ try {
246
+ return await freshlyAuthorizedPublishingSelection(runtime, session, session.selectedAccountId, siteProductFor(options));
247
+ } catch (error) {
248
+ if (error instanceof ProspectusAccountsError) return null;
249
+ throw error;
250
+ }
251
+ }
252
+
253
+ export async function handleProspectusAccountSelection(request: NextRequest, options: ProspectusSignInRouteOptions = {}): Promise<NextResponse> {
254
+ const runtime = await signInRuntimeFor(request, options);
255
+ if (!runtime) return signInUnavailableResponse();
256
+ if (request.headers.get("origin") !== request.nextUrl.origin) {
257
+ return signInFailedResponse(403, "cross_origin_selection", "Choose an account from this site.");
258
+ }
259
+ const nowInSeconds = currentTimeInSeconds(options);
260
+ const session = signedInProspectusSession(request, runtime.sealingSecret, nowInSeconds);
261
+ if (!session) return signInFailedResponse(401, "signed_out", "Sign in first.");
262
+ const body = await request.json().catch(() => null) as { accountId?: unknown } | null;
263
+ const accountId = typeof body?.accountId === "string" ? body.accountId.trim() : "";
264
+ if (!accountId) return signInFailedResponse(400, "account_required", "Choose an account.");
265
+ try {
266
+ const selection = await freshlyAuthorizedPublishingSelection(runtime, session, accountId, siteProductFor(options));
267
+ if (!selection) return signInFailedResponse(403, "not_a_publishing_account", "You cannot work on this site's publications in that account.");
268
+ const response = NextResponse.json(
269
+ { selectedAccountId: selection.accountId, role: selection.role, publicationCapabilities: selection.publicationCapabilities },
270
+ { headers: NO_STORE_HEADERS },
271
+ );
272
+ const selectedSession: ProspectusSignedInSession = { ...session, selectedAccountId: selection.accountId };
273
+ response.cookies.set(
274
+ PROSPECTUS_SESSION_COOKIE,
275
+ sealProspectusCookie(runtime.sealingSecret, PROSPECTUS_SESSION_COOKIE, selectedSession, session.accessTokenExpiresAt),
276
+ sealedCookieAttributes(session.accessTokenExpiresAt, nowInSeconds),
277
+ );
278
+ return response;
279
+ } catch (error) {
280
+ if (error instanceof ProspectusAccountsError) return signInFailedResponse(error.status, error.code, error.message);
281
+ console.error(`Prospectus account selection failed (${error instanceof Error ? error.name : "error"}).`);
282
+ return signInFailedResponse(503, "prospectus_unavailable", "Prospectus could not be reached.");
283
+ }
284
+ }
package/package.json CHANGED
@@ -209,6 +209,9 @@
209
209
  "./lib/presentation-footer": "./lib/presentation-footer.ts",
210
210
  "./lib/pretext-layout": "./lib/pretext-layout.ts",
211
211
  "./lib/priming-proof-report": "./lib/priming-proof-report.ts",
212
+ "./lib/prospectus-publication-access": "./lib/prospectus-publication-access.ts",
213
+ "./lib/prospectus-session": "./lib/prospectus-session.ts",
214
+ "./lib/prospectus-sign-in-routes": "./lib/prospectus-sign-in-routes.ts",
212
215
  "./lib/prospectus-site-identity": "./lib/prospectus-site-identity.ts",
213
216
  "./lib/public-placeholder-config": "./lib/public-placeholder-config.ts",
214
217
  "./lib/publication-cyclotron": "./lib/publication-cyclotron.ts",
@@ -264,6 +267,11 @@
264
267
  "./app/[year]/[month]/[day]/page/[pageNumber]/page": "./app/[year]/[month]/[day]/page/[pageNumber]/page.tsx",
265
268
  "./app/[year]/[month]/[day]/section/[sectionKey]/page": "./app/[year]/[month]/[day]/section/[sectionKey]/page.tsx",
266
269
  "./app/api/archive/editions/route": "./app/api/archive/editions/route.ts",
270
+ "./app/api/auth/prospectus/callback/route": "./app/api/auth/prospectus/callback/route.ts",
271
+ "./app/api/auth/prospectus/selection/route": "./app/api/auth/prospectus/selection/route.ts",
272
+ "./app/api/auth/prospectus/session/route": "./app/api/auth/prospectus/session/route.ts",
273
+ "./app/api/auth/prospectus/sign-in/route": "./app/api/auth/prospectus/sign-in/route.ts",
274
+ "./app/api/auth/prospectus/sign-out/route": "./app/api/auth/prospectus/sign-out/route.ts",
267
275
  "./app/api/cyclotron/priming-proof/route": "./app/api/cyclotron/priming-proof/route.ts",
268
276
  "./app/api/media/[...path]/route": "./app/api/media/[...path]/route.ts",
269
277
  "./app/api/revalidate/route": "./app/api/revalidate/route.ts",
@@ -332,13 +340,14 @@
332
340
  "optional": true
333
341
  }
334
342
  },
335
- "version": "1.0.0-next.63",
343
+ "version": "1.0.0-next.64",
336
344
  "dependencies": {
337
345
  "@aws-amplify/adapter-nextjs": "^1.7.3",
338
346
  "@aws-crypto/sha256-js": "5.2.0",
339
347
  "@aws-sdk/client-cognito-identity-provider": "3.1045.0",
340
348
  "@aws-sdk/client-lambda": "^3.1045.0",
341
349
  "@aws-sdk/client-s3": "^3.1045.0",
350
+ "@aws-sdk/client-secrets-manager": "3.1045.0",
342
351
  "@aws-sdk/client-ssm": "^3.1045.0",
343
352
  "@aws-sdk/credential-provider-node": "3.972.84",
344
353
  "@aws-sdk/s3-request-presigner": "^3.1045.0",
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "1.0.0-next.63",
2
+ "version": "1.0.0-next.64",
3
3
  "routes": [
4
4
  {
5
5
  "file": "app/%5F_preview/[[...path]]/route.ts",
@@ -66,6 +66,61 @@
66
66
  "revalidate": "60"
67
67
  }
68
68
  },
69
+ {
70
+ "file": "app/api/auth/prospectus/callback/route.ts",
71
+ "hasDefault": false,
72
+ "exports": [
73
+ "GET"
74
+ ],
75
+ "config": {
76
+ "dynamic": "\"force-dynamic\"",
77
+ "runtime": "\"nodejs\""
78
+ }
79
+ },
80
+ {
81
+ "file": "app/api/auth/prospectus/selection/route.ts",
82
+ "hasDefault": false,
83
+ "exports": [
84
+ "POST"
85
+ ],
86
+ "config": {
87
+ "dynamic": "\"force-dynamic\"",
88
+ "runtime": "\"nodejs\""
89
+ }
90
+ },
91
+ {
92
+ "file": "app/api/auth/prospectus/session/route.ts",
93
+ "hasDefault": false,
94
+ "exports": [
95
+ "GET"
96
+ ],
97
+ "config": {
98
+ "dynamic": "\"force-dynamic\"",
99
+ "runtime": "\"nodejs\""
100
+ }
101
+ },
102
+ {
103
+ "file": "app/api/auth/prospectus/sign-in/route.ts",
104
+ "hasDefault": false,
105
+ "exports": [
106
+ "GET"
107
+ ],
108
+ "config": {
109
+ "dynamic": "\"force-dynamic\"",
110
+ "runtime": "\"nodejs\""
111
+ }
112
+ },
113
+ {
114
+ "file": "app/api/auth/prospectus/sign-out/route.ts",
115
+ "hasDefault": false,
116
+ "exports": [
117
+ "POST"
118
+ ],
119
+ "config": {
120
+ "dynamic": "\"force-dynamic\"",
121
+ "runtime": "\"nodejs\""
122
+ }
123
+ },
69
124
  {
70
125
  "file": "app/api/cyclotron/priming-proof/route.ts",
71
126
  "hasDefault": false,