@anthusai/papyrus 1.0.0-next.33 → 1.0.0-next.34

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,54 @@
1
+ export const DEFAULT_NEWSROOM_BASE_PATH = "/newsroom";
2
+ function parseRedirectUrl(value) {
3
+ try {
4
+ const parsed = new URL(value);
5
+ return { original: value, origin: parsed.origin, path: parsed.pathname.replace(/\/+$/, "") };
6
+ }
7
+ catch {
8
+ return null;
9
+ }
10
+ }
11
+ /**
12
+ * Expands every origin in an OAuth redirect list to both the newsroom return
13
+ * path and the site root, newsroom first. Amplify signs in with the first
14
+ * entry that matches the current origin, so a list that names only
15
+ * `https://host/` would land a signed-in editor on the public reader.
16
+ *
17
+ * With `newsroomBasePath` of `""` (a CMS-only host that mounts the newsroom at
18
+ * `/`), the root already is the newsroom and nothing is added. Explicit
19
+ * entries are kept; unparseable entries pass through unchanged.
20
+ */
21
+ export function expandNewsroomRedirectUrls(redirectUrls, newsroomBasePath = DEFAULT_NEWSROOM_BASE_PATH) {
22
+ const newsroomPath = newsroomBasePath.replace(/\/+$/, "");
23
+ const originOrder = [];
24
+ const explicitByOrigin = new Map();
25
+ const passthrough = [];
26
+ for (const value of redirectUrls) {
27
+ const parsed = parseRedirectUrl(value);
28
+ if (!parsed) {
29
+ passthrough.push(value);
30
+ continue;
31
+ }
32
+ if (!explicitByOrigin.has(parsed.origin)) {
33
+ originOrder.push(parsed.origin);
34
+ explicitByOrigin.set(parsed.origin, []);
35
+ }
36
+ explicitByOrigin.get(parsed.origin)?.push(value);
37
+ }
38
+ const expanded = [];
39
+ for (const origin of originOrder) {
40
+ const explicit = explicitByOrigin.get(origin) ?? [];
41
+ if (newsroomPath === "") {
42
+ expanded.push(...explicit);
43
+ continue;
44
+ }
45
+ const newsroomEntries = explicit.filter((value) => parseRedirectUrl(value)?.path === newsroomPath);
46
+ const otherEntries = explicit.filter((value) => {
47
+ const path = parseRedirectUrl(value)?.path;
48
+ return path !== newsroomPath && path !== "";
49
+ });
50
+ expanded.push(...(newsroomEntries.length > 0 ? newsroomEntries : [`${origin}${newsroomPath}`]), `${origin}/`, ...otherEntries);
51
+ }
52
+ expanded.push(...passthrough);
53
+ return [...new Set(expanded)];
54
+ }
@@ -0,0 +1,66 @@
1
+ export const DEFAULT_NEWSROOM_BASE_PATH = "/newsroom";
2
+
3
+ type ParsedRedirectUrl = { original: string; origin: string; path: string } | null;
4
+
5
+ function parseRedirectUrl(value: string): ParsedRedirectUrl {
6
+ try {
7
+ const parsed = new URL(value);
8
+ return { original: value, origin: parsed.origin, path: parsed.pathname.replace(/\/+$/, "") };
9
+ } catch {
10
+ return null;
11
+ }
12
+ }
13
+
14
+ /**
15
+ * Expands every origin in an OAuth redirect list to both the newsroom return
16
+ * path and the site root, newsroom first. Amplify signs in with the first
17
+ * entry that matches the current origin, so a list that names only
18
+ * `https://host/` would land a signed-in editor on the public reader.
19
+ *
20
+ * With `newsroomBasePath` of `""` (a CMS-only host that mounts the newsroom at
21
+ * `/`), the root already is the newsroom and nothing is added. Explicit
22
+ * entries are kept; unparseable entries pass through unchanged.
23
+ */
24
+ export function expandNewsroomRedirectUrls(
25
+ redirectUrls: readonly string[],
26
+ newsroomBasePath: string = DEFAULT_NEWSROOM_BASE_PATH,
27
+ ): string[] {
28
+ const newsroomPath = newsroomBasePath.replace(/\/+$/, "");
29
+ const originOrder: string[] = [];
30
+ const explicitByOrigin = new Map<string, string[]>();
31
+ const passthrough: string[] = [];
32
+
33
+ for (const value of redirectUrls) {
34
+ const parsed = parseRedirectUrl(value);
35
+ if (!parsed) {
36
+ passthrough.push(value);
37
+ continue;
38
+ }
39
+ if (!explicitByOrigin.has(parsed.origin)) {
40
+ originOrder.push(parsed.origin);
41
+ explicitByOrigin.set(parsed.origin, []);
42
+ }
43
+ explicitByOrigin.get(parsed.origin)?.push(value);
44
+ }
45
+
46
+ const expanded: string[] = [];
47
+ for (const origin of originOrder) {
48
+ const explicit = explicitByOrigin.get(origin) ?? [];
49
+ if (newsroomPath === "") {
50
+ expanded.push(...explicit);
51
+ continue;
52
+ }
53
+ const newsroomEntries = explicit.filter((value) => parseRedirectUrl(value)?.path === newsroomPath);
54
+ const otherEntries = explicit.filter((value) => {
55
+ const path = parseRedirectUrl(value)?.path;
56
+ return path !== newsroomPath && path !== "";
57
+ });
58
+ expanded.push(
59
+ ...(newsroomEntries.length > 0 ? newsroomEntries : [`${origin}${newsroomPath}`]),
60
+ `${origin}/`,
61
+ ...otherEntries,
62
+ );
63
+ }
64
+ expanded.push(...passthrough);
65
+ return [...new Set(expanded)];
66
+ }
@@ -1,9 +1,11 @@
1
1
  import { defineAuth, secret } from "@aws-amplify/backend";
2
2
  import { manageUserRole } from "../functions/manage-user-role/resource.js";
3
3
  import { CfnUserPoolDomain } from "aws-cdk-lib/aws-cognito";
4
+ import { expandNewsroomRedirectUrls } from "./redirect-urls.js";
4
5
  // Default OAuth redirect URLs for the p.apyr.us app. Other publications pass
5
6
  // `auth.redirectUrls` to `defineSiteBackend` (or, for Papyrus's own backend,
6
7
  // the PAPYRUS_OAUTH_REDIRECT_URLS branch env var, comma-separated).
8
+ // `defineSiteAuth` adds the newsroom and root variants of every origin listed.
7
9
  // See docs/google-oauth-setup.md.
8
10
  const DEFAULT_AUTH_REDIRECT_URLS = [
9
11
  "http://localhost:3001/",
@@ -31,7 +33,7 @@ export function authConfigFromEnv() {
31
33
  };
32
34
  }
33
35
  export function defineSiteAuth(config) {
34
- const authRedirectUrls = config.redirectUrls ?? DEFAULT_AUTH_REDIRECT_URLS;
36
+ const authRedirectUrls = expandNewsroomRedirectUrls(config.redirectUrls ?? DEFAULT_AUTH_REDIRECT_URLS, config.newsroomBasePath);
35
37
  const disableGoogleOAuth = config.disableGoogleOAuth === true;
36
38
  return defineAuth({
37
39
  loginWith: {
@@ -3,10 +3,12 @@ import { manageUserRole } from "../functions/manage-user-role/resource";
3
3
  import { Stack } from "aws-cdk-lib";
4
4
  import { CfnUserPoolDomain } from "aws-cdk-lib/aws-cognito";
5
5
  import type { PapyrusAuthConfig } from "../site-backend-config";
6
+ import { expandNewsroomRedirectUrls } from "./redirect-urls";
6
7
 
7
8
  // Default OAuth redirect URLs for the p.apyr.us app. Other publications pass
8
9
  // `auth.redirectUrls` to `defineSiteBackend` (or, for Papyrus's own backend,
9
10
  // the PAPYRUS_OAUTH_REDIRECT_URLS branch env var, comma-separated).
11
+ // `defineSiteAuth` adds the newsroom and root variants of every origin listed.
10
12
  // See docs/google-oauth-setup.md.
11
13
  const DEFAULT_AUTH_REDIRECT_URLS = [
12
14
  "http://localhost:3001/",
@@ -36,7 +38,10 @@ export function authConfigFromEnv(): PapyrusAuthConfig {
36
38
  }
37
39
 
38
40
  export function defineSiteAuth(config: PapyrusAuthConfig) {
39
- const authRedirectUrls = config.redirectUrls ?? DEFAULT_AUTH_REDIRECT_URLS;
41
+ const authRedirectUrls = expandNewsroomRedirectUrls(
42
+ config.redirectUrls ?? DEFAULT_AUTH_REDIRECT_URLS,
43
+ config.newsroomBasePath,
44
+ );
40
45
  const disableGoogleOAuth = config.disableGoogleOAuth === true;
41
46
  return defineAuth({
42
47
  loginWith: {
@@ -7,6 +7,12 @@ export type PapyrusAuthConfig = {
7
7
  /** OAuth callback/logout URLs for Google sign-in (ignored when Google is disabled). */
8
8
  redirectUrls?: string[];
9
9
  disableGoogleOAuth?: boolean;
10
+ /**
11
+ * Public newsroom path prefix for sign-in return URLs. Default `/newsroom`;
12
+ * `""` on a CMS-only host where the newsroom is the root. Filled from the
13
+ * active brand's `newsroomBasePath` by `defineSiteBackend` when unset.
14
+ */
15
+ newsroomBasePath?: string;
10
16
  /** Desired Cognito hosted-UI domain prefix. Only applied when `applyCognitoDomainPrefix` is true. */
11
17
  cognitoDomainPrefix?: string;
12
18
  /**
@@ -54,7 +54,12 @@ export function defineSiteBackend(papyrusSite) {
54
54
  const enableInboundEmail = featureFlags.inboundEmail;
55
55
  const enableSlackAgent = featureFlags.slack;
56
56
  const enableStorageBackups = featureFlags.storageBackups;
57
- const authConfig = site.auth ?? authConfigFromEnv();
57
+ const baseAuthConfig = site.auth ?? authConfigFromEnv();
58
+ const activeBrand = (papyrusSite.brands ?? []).find((brand) => brand.id.toLowerCase() === siteBackendIdentity.brandId);
59
+ const authConfig = {
60
+ ...baseAuthConfig,
61
+ newsroomBasePath: baseAuthConfig.newsroomBasePath ?? activeBrand?.newsroomBasePath,
62
+ };
58
63
  const backend = defineBackend({
59
64
  assignmentAction,
60
65
  auth: defineSiteAuth(authConfig),
@@ -71,7 +71,12 @@ export function defineSiteBackend(papyrusSite: PapyrusSite) {
71
71
  const enableSlackAgent = featureFlags.slack;
72
72
  const enableStorageBackups = featureFlags.storageBackups;
73
73
 
74
- const authConfig = site.auth ?? authConfigFromEnv();
74
+ const baseAuthConfig = site.auth ?? authConfigFromEnv();
75
+ const activeBrand = (papyrusSite.brands ?? []).find((brand) => brand.id.toLowerCase() === siteBackendIdentity.brandId);
76
+ const authConfig = {
77
+ ...baseAuthConfig,
78
+ newsroomBasePath: baseAuthConfig.newsroomBasePath ?? activeBrand?.newsroomBasePath,
79
+ };
75
80
  const backend = defineBackend({
76
81
  assignmentAction,
77
82
  auth: defineSiteAuth(authConfig),
@@ -8,6 +8,8 @@ import { CookieStorage, Hub } from "aws-amplify/utils";
8
8
  import { useEffect } from "react";
9
9
  import { assertSandboxAmplifyOutputsForDev } from "../lib/amplify-outputs-guard";
10
10
  import amplifyOutputs from "papyrus-amplify-outputs";
11
+ import { getPublicNewsroomBasePath } from "../lib/newsroom-base-path";
12
+ import { prioritizeRedirectUrlsForOrigin } from "../lib/oauth-redirect-priority";
11
13
 
12
14
  let configured = false;
13
15
 
@@ -72,14 +74,10 @@ function prioritizeCurrentOrigin(config: ResourcesConfig): ResourcesConfig {
72
74
  const auth = (config as { auth?: { oauth?: Record<string, unknown> } }).auth;
73
75
  const oauth = auth?.oauth;
74
76
  if (!auth || !oauth) return config;
75
- const origin = normalizeUrlOrigin(window.location.origin);
76
77
  const reorder = (urls: unknown) => {
77
78
  if (!Array.isArray(urls)) return urls;
78
- const normalized = urls.filter((value): value is string => typeof value === "string");
79
- const head = normalized.filter((value) => normalizeUrlOrigin(value) === origin);
80
- if (!head.length) return normalized;
81
- const tail = normalized.filter((value) => normalizeUrlOrigin(value) !== origin);
82
- return [...head, ...tail];
79
+ const strings = urls.filter((value): value is string => typeof value === "string");
80
+ return prioritizeRedirectUrlsForOrigin(strings, window.location.origin, getPublicNewsroomBasePath());
83
81
  };
84
82
  return {
85
83
  ...config,
@@ -111,22 +109,6 @@ function redirectLoopbackToLocalhost(): void {
111
109
  window.location.replace(target);
112
110
  }
113
111
 
114
- function normalizeUrlOrigin(value: string): string {
115
- const trimmed = value.replace(/\/+$/, "");
116
- try {
117
- const parsed = new URL(trimmed);
118
- const hostname = normalizeLoopbackHostname(parsed.hostname);
119
- return `${parsed.protocol}//${hostname}${parsed.port ? `:${parsed.port}` : ""}`;
120
- } catch {
121
- return trimmed;
122
- }
123
- }
124
-
125
- function normalizeLoopbackHostname(hostname: string): string {
126
- if (hostname === "127.0.0.1" || hostname === "::1") return "localhost";
127
- return hostname;
128
- }
129
-
130
112
  function extractAuthFailureDetail(error: unknown): string | null {
131
113
  if (!error) return null;
132
114
  if (typeof error === "string") return error;
@@ -13,6 +13,14 @@ const GITHUB_REPO_PATTERN = /^[A-Za-z0-9_.-]+$/;
13
13
  const COGNITO_DOMAIN_PREFIX_PATTERN = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
14
14
  const COGNITO_RESERVED_WORDS = ["aws", "amazon", "cognito"];
15
15
  const TEMPLATE_MANAGED_ENVIRONMENT_KEYS = ["PAPYRUS_COGNITO_DOMAIN_PREFIX", "PAPYRUS_APPLY_COGNITO_DOMAIN_PREFIX", "PAPYRUS_DISABLE_GOOGLE_OAUTH", "PAPYRUS_REVALIDATE_SECRET_PARAMETER"];
16
+ function originOfRedirectUrl(value) {
17
+ try {
18
+ return new URL(value).origin;
19
+ }
20
+ catch {
21
+ return value;
22
+ }
23
+ }
16
24
  export function resolveRevalidateSecretParameterName(config) {
17
25
  return `/papyrus/${config.siteId}/revalidate-secret`;
18
26
  }
@@ -259,14 +267,15 @@ export function parseSiteConfig(raw) {
259
267
  fail("cms.environment.PAPYRUS_OAUTH_REDIRECT_URLS", "is required");
260
268
  const redirectUrls = redirectValue.split(",").map((url) => url.trim());
261
269
  const stagingDomainName = resolveStagingDomainName(config);
270
+ const listedOrigins = new Set(redirectUrls.map(originOfRedirectUrl));
262
271
  const requiredOrigins = [
263
- ...(resolveCmsHostName(config) ? [`https://${resolveCmsHostName(config)}/`] : []),
264
- ...(stagingDomainName ? [`https://${stagingDomainName}/`] : []),
265
- LOCAL_DEVELOPMENT_ORIGIN,
272
+ ...(resolveCmsHostName(config) ? [`https://${resolveCmsHostName(config)}`] : []),
273
+ ...(stagingDomainName ? [`https://${stagingDomainName}`] : []),
274
+ originOfRedirectUrl(LOCAL_DEVELOPMENT_ORIGIN),
266
275
  ];
267
276
  for (const origin of requiredOrigins) {
268
- if (!redirectUrls.includes(origin)) {
269
- fail("cms.environment.PAPYRUS_OAUTH_REDIRECT_URLS", `must include ${origin}`);
277
+ if (!listedOrigins.has(origin)) {
278
+ fail("cms.environment.PAPYRUS_OAUTH_REDIRECT_URLS", `must include ${origin}/ (or just the origin: the backend also registers its newsroom path)`);
270
279
  }
271
280
  }
272
281
  return config;
@@ -57,6 +57,14 @@ const GITHUB_REPO_PATTERN = /^[A-Za-z0-9_.-]+$/;
57
57
  const COGNITO_DOMAIN_PREFIX_PATTERN = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
58
58
  const COGNITO_RESERVED_WORDS = ["aws", "amazon", "cognito"];
59
59
  const TEMPLATE_MANAGED_ENVIRONMENT_KEYS = ["PAPYRUS_COGNITO_DOMAIN_PREFIX", "PAPYRUS_APPLY_COGNITO_DOMAIN_PREFIX", "PAPYRUS_DISABLE_GOOGLE_OAUTH", "PAPYRUS_REVALIDATE_SECRET_PARAMETER"];
60
+ function originOfRedirectUrl(value: string): string {
61
+ try {
62
+ return new URL(value).origin;
63
+ } catch {
64
+ return value;
65
+ }
66
+ }
67
+
60
68
  export function resolveRevalidateSecretParameterName(config: Pick<AmplifyAppShellSiteConfig, "siteId">): string {
61
69
  return `/papyrus/${config.siteId}/revalidate-secret`;
62
70
  }
@@ -321,14 +329,15 @@ export function parseSiteConfig(raw: unknown): AmplifyAppShellSiteConfig {
321
329
  if (redirectValue === undefined) fail("cms.environment.PAPYRUS_OAUTH_REDIRECT_URLS", "is required");
322
330
  const redirectUrls = redirectValue.split(",").map((url) => url.trim());
323
331
  const stagingDomainName = resolveStagingDomainName(config);
332
+ const listedOrigins = new Set(redirectUrls.map(originOfRedirectUrl));
324
333
  const requiredOrigins = [
325
- ...(resolveCmsHostName(config) ? [`https://${resolveCmsHostName(config)}/`] : []),
326
- ...(stagingDomainName ? [`https://${stagingDomainName}/`] : []),
327
- LOCAL_DEVELOPMENT_ORIGIN,
334
+ ...(resolveCmsHostName(config) ? [`https://${resolveCmsHostName(config)}`] : []),
335
+ ...(stagingDomainName ? [`https://${stagingDomainName}`] : []),
336
+ originOfRedirectUrl(LOCAL_DEVELOPMENT_ORIGIN),
328
337
  ];
329
338
  for (const origin of requiredOrigins) {
330
- if (!redirectUrls.includes(origin)) {
331
- fail("cms.environment.PAPYRUS_OAUTH_REDIRECT_URLS", `must include ${origin}`);
339
+ if (!listedOrigins.has(origin)) {
340
+ fail("cms.environment.PAPYRUS_OAUTH_REDIRECT_URLS", `must include ${origin}/ (or just the origin: the backend also registers its newsroom path)`);
332
341
  }
333
342
  }
334
343
 
@@ -0,0 +1,40 @@
1
+ function originOf(value: string): string {
2
+ const trimmed = value.replace(/\/+$/, "");
3
+ try {
4
+ const parsed = new URL(trimmed);
5
+ const hostname = parsed.hostname === "127.0.0.1" || parsed.hostname === "::1" ? "localhost" : parsed.hostname;
6
+ return `${parsed.protocol}//${hostname}${parsed.port ? `:${parsed.port}` : ""}`;
7
+ } catch {
8
+ return trimmed;
9
+ }
10
+ }
11
+
12
+ function pathOf(value: string): string | null {
13
+ try {
14
+ return new URL(value).pathname.replace(/\/+$/, "");
15
+ } catch {
16
+ return null;
17
+ }
18
+ }
19
+
20
+ /**
21
+ * Puts the redirect URLs of the current origin first; among those, the
22
+ * newsroom return path leads when the site has one, so Amplify (which signs in
23
+ * with the first entry) returns an editor to the newsroom.
24
+ */
25
+ export function prioritizeRedirectUrlsForOrigin(
26
+ urls: readonly string[],
27
+ currentOrigin: string,
28
+ publicNewsroomBasePath: string,
29
+ ): string[] {
30
+ const origin = originOf(currentOrigin);
31
+ const newsroomPath = publicNewsroomBasePath.replace(/\/+$/, "");
32
+ const head = urls.filter((value) => originOf(value) === origin);
33
+ if (head.length === 0) return [...urls];
34
+ const tail = urls.filter((value) => originOf(value) !== origin);
35
+ const newsroomFirst =
36
+ newsroomPath === ""
37
+ ? head
38
+ : [...head.filter((value) => pathOf(value) === newsroomPath), ...head.filter((value) => pathOf(value) !== newsroomPath)];
39
+ return [...newsroomFirst, ...tail];
40
+ }
package/package.json CHANGED
@@ -186,6 +186,7 @@
186
186
  "./lib/newsroom-references": "./lib/newsroom-references.ts",
187
187
  "./lib/newsroom-sections": "./lib/newsroom-sections.ts",
188
188
  "./lib/newsroom-topics": "./lib/newsroom-topics.ts",
189
+ "./lib/oauth-redirect-priority": "./lib/oauth-redirect-priority.ts",
189
190
  "./lib/papyrus-object-kinds": "./lib/papyrus-object-kinds.ts",
190
191
  "./lib/papyrus-web-locations": "./lib/papyrus-web-locations.ts",
191
192
  "./lib/pilobol-sample": "./lib/pilobol-sample.ts",
@@ -299,7 +300,7 @@
299
300
  "optional": true
300
301
  }
301
302
  },
302
- "version": "1.0.0-next.33",
303
+ "version": "1.0.0-next.34",
303
304
  "dependencies": {
304
305
  "@aws-amplify/adapter-nextjs": "^1.7.3",
305
306
  "@aws-sdk/client-cognito-identity-provider": "3.1045.0",
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "1.0.0-next.33",
2
+ "version": "1.0.0-next.34",
3
3
  "routes": [
4
4
  {
5
5
  "file": "app/%5F_preview/[[...path]]/route.ts",