@anthusai/papyrus 5.15.0 → 5.15.1

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.
@@ -158,6 +158,9 @@ export class AmplifyAppShellStack extends Stack {
158
158
  const stagingOrigin = stagingDomainName
159
159
  ? `https://${stagingDomainName}/`
160
160
  : Fn.join("", ["https://staging.", cmsApp.attrDefaultDomain, "/"]);
161
+ // The compute adapter's request URL is https://localhost:3000, so the site names its own public origin.
162
+ const productionSiteOrigin = cmsHostName ? `https://${cmsHostName}` : Fn.join("", ["https://main.", cmsApp.attrDefaultDomain]);
163
+ const stagingSiteOrigin = stagingDomainName ? `https://${stagingDomainName}` : Fn.join("", ["https://staging.", cmsApp.attrDefaultDomain]);
161
164
  const sharedRootDomain = config.cms.domainPrefix !== undefined;
162
165
  const defaultOriginsToAllow = [
163
166
  ...(config.cms.domainName ? [] : [productionOrigin]),
@@ -180,6 +183,7 @@ export class AmplifyAppShellStack extends Stack {
180
183
  PAPYRUS_OAUTH_REDIRECT_URLS: oauthRedirectUrls,
181
184
  PAPYRUS_SITE_BRAND: config.brand,
182
185
  SITE_ENV: "production",
186
+ PAPYRUS_SITE_ORIGIN: productionSiteOrigin,
183
187
  PAPYRUS_CONTENT_SOURCE: "published",
184
188
  ...(revalidateSecretParameterName ? { PAPYRUS_REVALIDATE_SECRET_PARAMETER: revalidateSecretParameterName } : {}),
185
189
  ...prospectusEnvironment,
@@ -201,6 +205,7 @@ export class AmplifyAppShellStack extends Stack {
201
205
  PAPYRUS_OAUTH_REDIRECT_URLS: oauthRedirectUrls,
202
206
  PAPYRUS_SITE_BRAND: config.brand,
203
207
  SITE_ENV: "staging",
208
+ PAPYRUS_SITE_ORIGIN: stagingSiteOrigin,
204
209
  ...stagingMode,
205
210
  ...prospectusEnvironment,
206
211
  NEXT_PUBLIC_PAPYRUS_STAGING_URL: stagingDomainName
@@ -177,6 +177,9 @@ export class AmplifyAppShellStack extends Stack {
177
177
  const stagingOrigin = stagingDomainName
178
178
  ? `https://${stagingDomainName}/`
179
179
  : Fn.join("", ["https://staging.", cmsApp.attrDefaultDomain, "/"]);
180
+ // The compute adapter's request URL is https://localhost:3000, so the site names its own public origin.
181
+ const productionSiteOrigin = cmsHostName ? `https://${cmsHostName}` : Fn.join("", ["https://main.", cmsApp.attrDefaultDomain]);
182
+ const stagingSiteOrigin = stagingDomainName ? `https://${stagingDomainName}` : Fn.join("", ["https://staging.", cmsApp.attrDefaultDomain]);
180
183
  const sharedRootDomain = config.cms.domainPrefix !== undefined;
181
184
  const defaultOriginsToAllow = [
182
185
  ...(config.cms.domainName ? [] : [productionOrigin]),
@@ -200,6 +203,7 @@ export class AmplifyAppShellStack extends Stack {
200
203
  PAPYRUS_OAUTH_REDIRECT_URLS: oauthRedirectUrls,
201
204
  PAPYRUS_SITE_BRAND: config.brand,
202
205
  SITE_ENV: "production",
206
+ PAPYRUS_SITE_ORIGIN: productionSiteOrigin,
203
207
  PAPYRUS_CONTENT_SOURCE: "published",
204
208
  ...(revalidateSecretParameterName ? { PAPYRUS_REVALIDATE_SECRET_PARAMETER: revalidateSecretParameterName } : {}),
205
209
  ...prospectusEnvironment,
@@ -222,6 +226,7 @@ export class AmplifyAppShellStack extends Stack {
222
226
  PAPYRUS_OAUTH_REDIRECT_URLS: oauthRedirectUrls,
223
227
  PAPYRUS_SITE_BRAND: config.brand,
224
228
  SITE_ENV: "staging",
229
+ PAPYRUS_SITE_ORIGIN: stagingSiteOrigin,
225
230
  ...stagingMode,
226
231
  ...prospectusEnvironment,
227
232
  NEXT_PUBLIC_PAPYRUS_STAGING_URL: stagingDomainName
@@ -34,7 +34,7 @@ const GITHUB_REPO_PATTERN = /^[A-Za-z0-9_.-]+$/;
34
34
  const COGNITO_DOMAIN_PREFIX_PATTERN = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
35
35
  const COGNITO_RESERVED_WORDS = ["aws", "amazon", "cognito"];
36
36
  const PROSPECTUS_ACCOUNTS_API_STAGE_ARN_PATTERN = /^arn:aws:execute-api:[a-z0-9-]+:\d{12}:[a-z0-9]+\/api$/;
37
- 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_PARAMETER", "PAPYRUS_PROSPECTUS_SITE_PRODUCT"];
37
+ 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_PARAMETER", "PAPYRUS_PROSPECTUS_SITE_PRODUCT", "PAPYRUS_SITE_ORIGIN"];
38
38
  /** Routes Prospectus admits for a registered Papyrus site (Prospectus accounts/papyrus-sites.ts). */
39
39
  export const PROSPECTUS_PAPYRUS_SITE_ROUTES = [
40
40
  "GET/v1/identities/me",
@@ -254,7 +254,7 @@ function parseCms(value) {
254
254
  }
255
255
  for (const key of TEMPLATE_MANAGED_ENVIRONMENT_KEYS) {
256
256
  if (key in cms.environment) {
257
- fail(`cms.environment.${key}`, "is managed by the template: Google sign-in is always on, the Cognito domain comes from cms.cognitoDomainPrefix, the revalidation secret parameter is derived from siteId and the Prospectus endpoint comes from cms.prospectusAccountsApiStageArn");
257
+ fail(`cms.environment.${key}`, "is managed by the template: Google sign-in is always on, the Cognito domain comes from cms.cognitoDomainPrefix, the revalidation secret parameter is derived from siteId and the Prospectus endpoint comes from cms.prospectusAccountsApiStageArn and the public site origin comes from the site host");
258
258
  }
259
259
  }
260
260
  if (cms.staging === false && cms.stagingDomainName) {
@@ -143,7 +143,7 @@ const GITHUB_REPO_PATTERN = /^[A-Za-z0-9_.-]+$/;
143
143
  const COGNITO_DOMAIN_PREFIX_PATTERN = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
144
144
  const COGNITO_RESERVED_WORDS = ["aws", "amazon", "cognito"];
145
145
  const PROSPECTUS_ACCOUNTS_API_STAGE_ARN_PATTERN = /^arn:aws:execute-api:[a-z0-9-]+:\d{12}:[a-z0-9]+\/api$/;
146
- 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_PARAMETER", "PAPYRUS_PROSPECTUS_SITE_PRODUCT"];
146
+ 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_PARAMETER", "PAPYRUS_PROSPECTUS_SITE_PRODUCT", "PAPYRUS_SITE_ORIGIN"];
147
147
 
148
148
  /** Routes Prospectus admits for a registered Papyrus site (Prospectus accounts/papyrus-sites.ts). */
149
149
  export const PROSPECTUS_PAPYRUS_SITE_ROUTES = [
@@ -376,7 +376,7 @@ function parseCms(value: unknown): CmsConfig {
376
376
  }
377
377
  for (const key of TEMPLATE_MANAGED_ENVIRONMENT_KEYS) {
378
378
  if (key in cms.environment) {
379
- fail(`cms.environment.${key}`, "is managed by the template: Google sign-in is always on, the Cognito domain comes from cms.cognitoDomainPrefix, the revalidation secret parameter is derived from siteId and the Prospectus endpoint comes from cms.prospectusAccountsApiStageArn");
379
+ fail(`cms.environment.${key}`, "is managed by the template: Google sign-in is always on, the Cognito domain comes from cms.cognitoDomainPrefix, the revalidation secret parameter is derived from siteId and the Prospectus endpoint comes from cms.prospectusAccountsApiStageArn and the public site origin comes from the site host");
380
380
  }
381
381
  }
382
382
 
@@ -8,6 +8,7 @@ import {
8
8
  type ProspectusSignInRouteOptions,
9
9
  } from "./prospectus-sign-in-routes";
10
10
  import { resolveProspectusSignInRuntime } from "./prospectus-session";
11
+ import { SiteOriginNotConfiguredError, publicSiteOrigin } from "./site-origin";
11
12
 
12
13
  /**
13
14
  * Same-origin curation routes for the References tab. Each is an authenticated
@@ -270,7 +271,8 @@ export async function handleCurationRoute(request: NextRequest, route: CurationR
270
271
  if (!base) return jsonResponse({ error: "curation_not_configured", message: "Curation is not configured for this site." }, 503);
271
272
 
272
273
  const origin = request.headers.get("origin");
273
- if (route !== "context" ? origin !== request.nextUrl.origin : origin !== null && origin !== request.nextUrl.origin) {
274
+ const siteOrigin = publicSiteOrigin(request, options.environment);
275
+ if (route !== "context" ? origin !== siteOrigin : origin !== null && origin !== siteOrigin) {
274
276
  throw refusal(403, "cross_origin", "Curation requests must come from this site.");
275
277
  }
276
278
  let text: string | null = null;
@@ -279,7 +281,7 @@ export async function handleCurationRoute(request: NextRequest, route: CurationR
279
281
  text = await boundedText(request);
280
282
  }
281
283
 
282
- const runtime = await (options.resolveRuntime ?? resolveProspectusSignInRuntime)(request.nextUrl.origin);
284
+ const runtime = await (options.resolveRuntime ?? resolveProspectusSignInRuntime)(siteOrigin);
283
285
  if (!runtime) return jsonResponse({ error: "prospectus_sign_in_unavailable", message: "Sign-in through Prospectus is not configured for this site." }, 503);
284
286
  const nowInSeconds = options.nowInSeconds?.();
285
287
  const cookieSession = signedInProspectusSession(request, runtime.sealingSecret, nowInSeconds);
@@ -336,6 +338,7 @@ export async function handleCurationRoute(request: NextRequest, route: CurationR
336
338
  });
337
339
  } catch (error) {
338
340
  if (error instanceof CurationRefusal) return jsonResponse({ error: error.message, code: error.code }, error.status);
341
+ if (error instanceof SiteOriginNotConfiguredError) return jsonResponse({ error: error.message, code: error.code }, error.status);
339
342
  console.error(`Curation route failed (${error instanceof Error ? error.name : "error"}).`);
340
343
  return jsonResponse({ error: "Curation is unavailable. Check saved history before retrying.", code: "curation_unavailable" }, 503);
341
344
  }
@@ -25,6 +25,7 @@ import {
25
25
  publishingAccountsForSite,
26
26
  type PublicationCapability,
27
27
  } from "./prospectus-publication-access";
28
+ import { SiteOriginNotConfiguredError, publicSiteOrigin, siteOriginNotConfiguredResponse } from "./site-origin";
28
29
 
29
30
  /**
30
31
  * The site's sign-in routes for Prospectus accounts: start (challenge, then the
@@ -38,6 +39,8 @@ export interface ProspectusSignInRouteOptions {
38
39
  siteProduct?: string | null;
39
40
  completionOverrides?: Pick<CompleteProspectusGoogleSignInInput, "verifyCognitoTokens" | "fetcher">;
40
41
  nowInSeconds?: () => number;
42
+ /** Trusted configuration the public site origin is read from; defaults to process.env. */
43
+ environment?: Record<string, string | undefined>;
41
44
  }
42
45
 
43
46
  const NO_STORE_HEADERS = { "cache-control": "no-store" };
@@ -50,8 +53,22 @@ function currentTimeInSeconds(options: ProspectusSignInRouteOptions): number {
50
53
  return options.nowInSeconds?.() ?? Math.floor(Date.now() / 1000);
51
54
  }
52
55
 
56
+ function siteOriginFor(request: NextRequest, options: ProspectusSignInRouteOptions): string {
57
+ return publicSiteOrigin(request, options.environment);
58
+ }
59
+
53
60
  async function signInRuntimeFor(request: NextRequest, options: ProspectusSignInRouteOptions): Promise<ProspectusSignInRuntime | null> {
54
- return (options.resolveRuntime ?? resolveProspectusSignInRuntime)(request.nextUrl.origin);
61
+ return (options.resolveRuntime ?? resolveProspectusSignInRuntime)(siteOriginFor(request, options));
62
+ }
63
+
64
+ /** Runs a route and answers a missing site origin with 503 site_origin_not_configured, never a localhost redirect. */
65
+ async function failingClosedWithoutSiteOrigin(route: () => Promise<NextResponse>): Promise<NextResponse> {
66
+ try {
67
+ return await route();
68
+ } catch (error) {
69
+ if (error instanceof SiteOriginNotConfiguredError) return siteOriginNotConfiguredResponse(error);
70
+ throw error;
71
+ }
55
72
  }
56
73
 
57
74
  function signInUnavailableResponse(): NextResponse {
@@ -82,7 +99,11 @@ export function signedInProspectusSession(
82
99
  );
83
100
  }
84
101
 
85
- export async function handleProspectusSignInStart(request: NextRequest, options: ProspectusSignInRouteOptions = {}): Promise<NextResponse> {
102
+ export function handleProspectusSignInStart(request: NextRequest, options: ProspectusSignInRouteOptions = {}): Promise<NextResponse> {
103
+ return failingClosedWithoutSiteOrigin(() => handleProspectusSignInStartRoute(request, options));
104
+ }
105
+
106
+ async function handleProspectusSignInStartRoute(request: NextRequest, options: ProspectusSignInRouteOptions): Promise<NextResponse> {
86
107
  const runtime = await signInRuntimeFor(request, options);
87
108
  if (!runtime) return signInUnavailableResponse();
88
109
  const { authorizationUrl, pendingSignIn } = await beginProspectusGoogleSignIn(runtime.accounts, runtime.cognitoClient);
@@ -99,7 +120,11 @@ export async function handleProspectusSignInStart(request: NextRequest, options:
99
120
  return response;
100
121
  }
101
122
 
102
- export async function handleProspectusSignInCallback(request: NextRequest, options: ProspectusSignInRouteOptions = {}): Promise<NextResponse> {
123
+ export function handleProspectusSignInCallback(request: NextRequest, options: ProspectusSignInRouteOptions = {}): Promise<NextResponse> {
124
+ return failingClosedWithoutSiteOrigin(() => handleProspectusSignInCallbackRoute(request, options));
125
+ }
126
+
127
+ async function handleProspectusSignInCallbackRoute(request: NextRequest, options: ProspectusSignInRouteOptions): Promise<NextResponse> {
103
128
  const runtime = await signInRuntimeFor(request, options);
104
129
  if (!runtime) return signInUnavailableResponse();
105
130
  const nowInSeconds = currentTimeInSeconds(options);
@@ -142,7 +167,7 @@ async function completeCallback(
142
167
  accessToken: signedIn.accessToken,
143
168
  accessTokenExpiresAt: signedIn.accessTokenExpiresAt,
144
169
  };
145
- const response = NextResponse.redirect(new URL(pendingSignIn.returnPath, request.nextUrl.origin), { status: 303, headers: NO_STORE_HEADERS });
170
+ const response = NextResponse.redirect(new URL(pendingSignIn.returnPath, siteOriginFor(request, options)), { status: 303, headers: NO_STORE_HEADERS });
146
171
  response.cookies.set(
147
172
  PROSPECTUS_SESSION_COOKIE,
148
173
  sealProspectusCookie(runtime.sealingSecret, PROSPECTUS_SESSION_COOKIE, session, session.accessTokenExpiresAt),
@@ -156,7 +181,11 @@ async function completeCallback(
156
181
  }
157
182
  }
158
183
 
159
- export async function handleProspectusSessionRead(request: NextRequest, options: ProspectusSignInRouteOptions = {}): Promise<NextResponse> {
184
+ export function handleProspectusSessionRead(request: NextRequest, options: ProspectusSignInRouteOptions = {}): Promise<NextResponse> {
185
+ return failingClosedWithoutSiteOrigin(() => handleProspectusSessionReadRoute(request, options));
186
+ }
187
+
188
+ async function handleProspectusSessionReadRoute(request: NextRequest, options: ProspectusSignInRouteOptions): Promise<NextResponse> {
160
189
  const runtime = await signInRuntimeFor(request, options);
161
190
  if (!runtime) return signInUnavailableResponse();
162
191
  const session = signedInProspectusSession(request, runtime.sealingSecret, currentTimeInSeconds(options));
@@ -196,9 +225,13 @@ export async function handleProspectusSessionRead(request: NextRequest, options:
196
225
  }
197
226
  }
198
227
 
199
- export async function handleProspectusSignOut(request: NextRequest, options: ProspectusSignInRouteOptions = {}): Promise<NextResponse> {
228
+ export function handleProspectusSignOut(request: NextRequest, options: ProspectusSignInRouteOptions = {}): Promise<NextResponse> {
229
+ return failingClosedWithoutSiteOrigin(() => handleProspectusSignOutRoute(request, options));
230
+ }
231
+
232
+ async function handleProspectusSignOutRoute(request: NextRequest, options: ProspectusSignInRouteOptions): Promise<NextResponse> {
200
233
  const runtime = await signInRuntimeFor(request, options);
201
- const signedOutDestination = new URL("/", request.nextUrl.origin);
234
+ const signedOutDestination = new URL("/", siteOriginFor(request, options));
202
235
  const destination = runtime
203
236
  ? new URL(
204
237
  `/logout?${new URLSearchParams({ client_id: runtime.cognitoClient.clientId, logout_uri: signedOutDestination.toString() })}`,
@@ -250,10 +283,14 @@ export async function currentProspectusPublishingSelection(
250
283
  }
251
284
  }
252
285
 
253
- export async function handleProspectusAccountSelection(request: NextRequest, options: ProspectusSignInRouteOptions = {}): Promise<NextResponse> {
286
+ export function handleProspectusAccountSelection(request: NextRequest, options: ProspectusSignInRouteOptions = {}): Promise<NextResponse> {
287
+ return failingClosedWithoutSiteOrigin(() => handleProspectusAccountSelectionRoute(request, options));
288
+ }
289
+
290
+ async function handleProspectusAccountSelectionRoute(request: NextRequest, options: ProspectusSignInRouteOptions): Promise<NextResponse> {
254
291
  const runtime = await signInRuntimeFor(request, options);
255
292
  if (!runtime) return signInUnavailableResponse();
256
- if (request.headers.get("origin") !== request.nextUrl.origin) {
293
+ if (request.headers.get("origin") !== siteOriginFor(request, options)) {
257
294
  return signInFailedResponse(403, "cross_origin_selection", "Choose an account from this site.");
258
295
  }
259
296
  const nowInSeconds = currentTimeInSeconds(options);
@@ -287,10 +324,14 @@ const MAXIMUM_PUBLISHING_ACCOUNT_NAME_LENGTH = 150;
287
324
  const DEFAULT_PUBLISHING_ACCOUNT_NAME = "Publishing account";
288
325
  const IDEMPOTENCY_KEY_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
289
326
 
290
- export async function handleProspectusPublishingAccountCreation(request: NextRequest, options: ProspectusSignInRouteOptions = {}): Promise<NextResponse> {
327
+ export function handleProspectusPublishingAccountCreation(request: NextRequest, options: ProspectusSignInRouteOptions = {}): Promise<NextResponse> {
328
+ return failingClosedWithoutSiteOrigin(() => handleProspectusPublishingAccountCreationRoute(request, options));
329
+ }
330
+
331
+ async function handleProspectusPublishingAccountCreationRoute(request: NextRequest, options: ProspectusSignInRouteOptions): Promise<NextResponse> {
291
332
  const runtime = await signInRuntimeFor(request, options);
292
333
  if (!runtime) return signInUnavailableResponse();
293
- if (request.headers.get("origin") !== request.nextUrl.origin) {
334
+ if (request.headers.get("origin") !== siteOriginFor(request, options)) {
294
335
  return signInFailedResponse(403, "cross_origin_creation", "Create an account from this site.");
295
336
  }
296
337
  const nowInSeconds = currentTimeInSeconds(options);
@@ -0,0 +1,67 @@
1
+ import { NextResponse, type NextRequest } from "next/server";
2
+ import { getSiteEnv } from "./site-env";
3
+
4
+ type EnvironmentVariables = Record<string, string | undefined>;
5
+
6
+ export const SITE_ORIGIN_ENV = "PAPYRUS_SITE_ORIGIN";
7
+
8
+ /**
9
+ * The site's public origin cannot be read from the request on Amplify SSR (WEB_COMPUTE):
10
+ * there `request.nextUrl.origin` is the compute adapter's internal https://localhost:3000.
11
+ * It also must never be derived from Host or X-Forwarded-* headers, which a client can set.
12
+ * The one trusted source is PAPYRUS_SITE_ORIGIN, set by the app shell from the site's host.
13
+ */
14
+ export class SiteOriginNotConfiguredError extends Error {
15
+ readonly status = 503;
16
+ readonly code = "site_origin_not_configured";
17
+ }
18
+
19
+ export function siteOriginNotConfiguredResponse(error: SiteOriginNotConfiguredError): NextResponse {
20
+ return NextResponse.json({ error: error.code, message: error.message }, { status: error.status, headers: { "cache-control": "no-store" } });
21
+ }
22
+
23
+ function isLoopbackHostname(hostname: string): boolean {
24
+ return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "[::1]";
25
+ }
26
+
27
+ function configuredOrigin(value: string): string {
28
+ const invalid = () => new SiteOriginNotConfiguredError(`${SITE_ORIGIN_ENV} must be an https origin such as https://example.com with no path.`);
29
+ let parsed: URL;
30
+ try {
31
+ parsed = new URL(value);
32
+ } catch {
33
+ throw invalid();
34
+ }
35
+ if (parsed.protocol !== "https:" || parsed.origin !== value) throw invalid();
36
+ return parsed.origin;
37
+ }
38
+
39
+ function siteEnvOrNull(environment: EnvironmentVariables): string | null {
40
+ try {
41
+ return getSiteEnv(environment);
42
+ } catch {
43
+ return null;
44
+ }
45
+ }
46
+
47
+ /**
48
+ * The site's public origin, from trusted configuration.
49
+ * - PAPYRUS_SITE_ORIGIN set: it must be an https origin with no path; it is used as is.
50
+ * - Unset: `request.nextUrl.origin` only when it is loopback, SITE_ENV resolves to development
51
+ * and NODE_ENV is not production (a local `next dev` server). Anything else, production and staging included, throws
52
+ * SiteOriginNotConfiguredError so routes answer 503 instead of redirecting to localhost.
53
+ */
54
+ export function publicSiteOrigin(request: Pick<NextRequest, "nextUrl">, environment: EnvironmentVariables = process.env): string {
55
+ const configured = (environment[SITE_ORIGIN_ENV] ?? "").trim();
56
+ if (configured) return configuredOrigin(configured);
57
+ const { hostname, origin } = request.nextUrl;
58
+ // SITE_ENV defaults to development when a runtime never received it, so NODE_ENV=production also closes the fallback.
59
+ const developing = siteEnvOrNull(environment) === "development" && environment.NODE_ENV !== "production";
60
+ if (isLoopbackHostname(hostname) && developing) return origin;
61
+ throw new SiteOriginNotConfiguredError(`${SITE_ORIGIN_ENV} is not set; this site cannot tell its public origin.`);
62
+ }
63
+
64
+ /** An absolute URL on the site's public origin, for redirects. Fails closed like publicSiteOrigin. */
65
+ export function publicSiteUrl(path: string, request: Pick<NextRequest, "nextUrl">, environment: EnvironmentVariables = process.env): URL {
66
+ return new URL(path, publicSiteOrigin(request, environment));
67
+ }
package/middleware.ts CHANGED
@@ -5,6 +5,7 @@ import { newsroomHref } from "./lib/newsroom-base-path";
5
5
  import { getReaderBasePath } from "./lib/reader-base-path";
6
6
  import { isStagingGatedPath, isStaticOrApiPath } from "./lib/staging-gated-path";
7
7
  import { getSiteEnv, isIndexable } from "./lib/site-env";
8
+ import { SiteOriginNotConfiguredError, publicSiteUrl, siteOriginNotConfiguredResponse } from "./lib/site-origin";
8
9
  import { getStagingAccess } from "./lib/staging-gate";
9
10
  import {
10
11
  isPreviewGatedPath,
@@ -38,11 +39,11 @@ function routeRequest(request: NextRequest): NextResponse {
38
39
  }
39
40
 
40
41
  if (pathname === "/newsroom" || pathname === "/newsroom/") {
41
- return NextResponse.redirect(new URL(`/${request.nextUrl.search}`, request.url));
42
+ return NextResponse.redirect(publicSiteUrl(`/${request.nextUrl.search}`, request));
42
43
  }
43
44
  if (pathname.startsWith("/newsroom/")) {
44
45
  const target = pathname.slice("/newsroom".length) || "/";
45
- return NextResponse.redirect(new URL(`${target}${request.nextUrl.search}`, request.url));
46
+ return NextResponse.redirect(publicSiteUrl(`${target}${request.nextUrl.search}`, request));
46
47
  }
47
48
 
48
49
  if (pathname === "/" || pathname === "") {
@@ -59,6 +60,16 @@ function routeRequest(request: NextRequest): NextResponse {
59
60
  }
60
61
 
61
62
  export async function middleware(request: NextRequest): Promise<NextResponse> {
63
+ try {
64
+ return await handleRequest(request);
65
+ } catch (error) {
66
+ // Redirects name the site's public origin (request.url is localhost on Amplify); without it, fail closed.
67
+ if (error instanceof SiteOriginNotConfiguredError) return withRobotsHeader(siteOriginNotConfiguredResponse(error));
68
+ throw error;
69
+ }
70
+ }
71
+
72
+ async function handleRequest(request: NextRequest): Promise<NextResponse> {
62
73
  let sessionResponse: NextResponse | null = null;
63
74
  const { pathname } = request.nextUrl;
64
75
  const gated = isStaticPreviewEnabled()
@@ -68,7 +79,7 @@ export async function middleware(request: NextRequest): Promise<NextResponse> {
68
79
  sessionResponse = NextResponse.next();
69
80
  const access = await getStagingAccess(request, sessionResponse);
70
81
  if (access === "anonymous") {
71
- return withRobotsHeader(NextResponse.redirect(new URL(newsroomHref(), request.url)));
82
+ return withRobotsHeader(NextResponse.redirect(publicSiteUrl(newsroomHref(), request)));
72
83
  }
73
84
  if (access === "forbidden") {
74
85
  return withRobotsHeader(new NextResponse("Staging is limited to editors and admins.", { status: 403 }));
package/package.json CHANGED
@@ -296,6 +296,7 @@
296
296
  "./lib/site-brand-tenant": "./lib/site-brand-tenant.ts",
297
297
  "./lib/site-brand": "./lib/site-brand.ts",
298
298
  "./lib/site-env": "./lib/site-env.ts",
299
+ "./lib/site-origin": "./lib/site-origin.ts",
299
300
  "./lib/site-renderer": "./lib/site-renderer.ts",
300
301
  "./lib/site-section-suggestions": "./lib/site-section-suggestions.ts",
301
302
  "./lib/site-stack": "./lib/site-stack.ts",
@@ -406,7 +407,7 @@
406
407
  "optional": true
407
408
  }
408
409
  },
409
- "version": "5.15.0",
410
+ "version": "5.15.1",
410
411
  "dependencies": {
411
412
  "@aws-amplify/adapter-nextjs": "^1.7.3",
412
413
  "@aws-crypto/sha256-js": "5.2.0",
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "5.15.0",
2
+ "version": "5.15.1",
3
3
  "routes": [
4
4
  {
5
5
  "file": "app/%5F_preview/[[...path]]/route.ts",