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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/amplify/auth/prospectus-google-client.js +66 -0
  2. package/amplify/auth/prospectus-google-client.ts +71 -0
  3. package/amplify/auth/resource.js +4 -1
  4. package/amplify/auth/resource.ts +5 -1
  5. package/amplify/data/resource.js +4 -0
  6. package/amplify/data/resource.ts +4 -0
  7. package/amplify/data/schema.js +4 -0
  8. package/amplify/data/schema.ts +4 -0
  9. package/amplify/site-backend.js +8 -1
  10. package/amplify/site-backend.ts +9 -1
  11. package/app/api/auth/prospectus/callback/route.ts +9 -0
  12. package/app/api/auth/prospectus/selection/route.ts +9 -0
  13. package/app/api/auth/prospectus/session/route.ts +9 -0
  14. package/app/api/auth/prospectus/sign-in/route.ts +9 -0
  15. package/app/api/auth/prospectus/sign-out/route.ts +9 -0
  16. package/components/news-desk-taxonomy-client.ts +37 -1
  17. package/components/newsroom-board-view.tsx +7 -8
  18. package/components/priming-proof-page.tsx +7 -5
  19. package/components/topic-steering-workspace.tsx +4 -0
  20. package/infra/amplify-app-shell.js +20 -2
  21. package/infra/amplify-app-shell.ts +22 -2
  22. package/infra/site-config.js +1 -1
  23. package/infra/site-config.ts +1 -1
  24. package/lib/category-repository.ts +1 -0
  25. package/lib/content-item-lifecycle.ts +55 -0
  26. package/lib/newsroom-board.ts +60 -21
  27. package/lib/newsroom-nav.ts +2 -1
  28. package/lib/newsroom-organizer-nav.ts +5 -2
  29. package/lib/priming-proof-report.ts +1 -0
  30. package/lib/prospectus-publication-access.ts +53 -0
  31. package/lib/prospectus-session.ts +196 -0
  32. package/lib/prospectus-sign-in-routes.ts +284 -0
  33. package/lib/site-brand.ts +19 -0
  34. package/package.json +11 -1
  35. package/routes.manifest.json +56 -1
@@ -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;
@@ -893,9 +893,11 @@ const schema = a.schema({
893
893
  createdAt: a.datetime().required(),
894
894
  updatedAt: a.datetime().required(),
895
895
  newsroomFeedKey: a.string(),
896
+ itemLineageId: a.id(),
896
897
  })
897
898
  .secondaryIndexes((index) => [
898
899
  index("newsroomFeedKey").sortKeys(["createdAt"]).queryField("listAssignmentsByNewsroomFeedAndCreatedAt"),
900
+ index("itemLineageId").sortKeys(["createdAt"]).queryField("listAssignmentsByItemLineageAndCreatedAt"),
899
901
  index("queueStatusKey").sortKeys(["priority", "createdAt"]).queryField("listAssignmentsByQueueStatusAndPriority"),
900
902
  index("assignmentTypeKey").sortKeys(["status", "createdAt"]).queryField("listAssignmentsByTypeStatusAndCreatedAt"),
901
903
  index("status").sortKeys(["updatedAt"]).queryField("listAssignmentsByStatusAndUpdatedAt"),
@@ -1632,6 +1634,7 @@ const schema = a.schema({
1632
1634
  type: a.string().required(),
1633
1635
  status: a.string().required(),
1634
1636
  typeStatus: a.string().required(),
1637
+ stage: a.string(),
1635
1638
  slug: a.string().required(),
1636
1639
  shortSlug: a.string(),
1637
1640
  section: a.string(),
@@ -1663,6 +1666,7 @@ const schema = a.schema({
1663
1666
  index("typeStatus").sortKeys(["publishedAt"]).queryField("listItemsByTypeStatusAndPublishedAt"),
1664
1667
  index("sectionStatus").sortKeys(["publishedAt"]).queryField("listItemsBySectionStatusAndPublishedAt"),
1665
1668
  index("versionState").sortKeys(["updatedAt"]).queryField("listItemsByVersionStateAndUpdatedAt"),
1669
+ index("stage").sortKeys(["updatedAt"]).queryField("listItemsByStageAndUpdatedAt"),
1666
1670
  ])
1667
1671
  .authorization((allow) => [
1668
1672
  allow.groups(contentWriteGroups),
@@ -970,9 +970,11 @@ const schema = a.schema({
970
970
  createdAt: a.datetime().required(),
971
971
  updatedAt: a.datetime().required(),
972
972
  newsroomFeedKey: a.string(),
973
+ itemLineageId: a.id(),
973
974
  })
974
975
  .secondaryIndexes((index) => [
975
976
  index("newsroomFeedKey").sortKeys(["createdAt"]).queryField("listAssignmentsByNewsroomFeedAndCreatedAt"),
977
+ index("itemLineageId").sortKeys(["createdAt"]).queryField("listAssignmentsByItemLineageAndCreatedAt"),
976
978
  index("queueStatusKey").sortKeys(["priority", "createdAt"]).queryField("listAssignmentsByQueueStatusAndPriority"),
977
979
  index("assignmentTypeKey").sortKeys(["status", "createdAt"]).queryField("listAssignmentsByTypeStatusAndCreatedAt"),
978
980
  index("status").sortKeys(["updatedAt"]).queryField("listAssignmentsByStatusAndUpdatedAt"),
@@ -1733,6 +1735,7 @@ const schema = a.schema({
1733
1735
  type: a.string().required(),
1734
1736
  status: a.string().required(),
1735
1737
  typeStatus: a.string().required(),
1738
+ stage: a.string(),
1736
1739
  slug: a.string().required(),
1737
1740
  shortSlug: a.string(),
1738
1741
  section: a.string(),
@@ -1764,6 +1767,7 @@ const schema = a.schema({
1764
1767
  index("typeStatus").sortKeys(["publishedAt"]).queryField("listItemsByTypeStatusAndPublishedAt"),
1765
1768
  index("sectionStatus").sortKeys(["publishedAt"]).queryField("listItemsBySectionStatusAndPublishedAt"),
1766
1769
  index("versionState").sortKeys(["updatedAt"]).queryField("listItemsByVersionStateAndUpdatedAt"),
1770
+ index("stage").sortKeys(["updatedAt"]).queryField("listItemsByStageAndUpdatedAt"),
1767
1771
  ])
1768
1772
  .authorization((allow) => [
1769
1773
  allow.groups(contentWriteGroups),
@@ -799,9 +799,11 @@ const baseDefinitions = {
799
799
  createdAt: a.datetime().required(),
800
800
  updatedAt: a.datetime().required(),
801
801
  newsroomFeedKey: a.string(),
802
+ itemLineageId: a.id(),
802
803
  })
803
804
  .secondaryIndexes((index) => [
804
805
  index("newsroomFeedKey").sortKeys(["createdAt"]).queryField("listAssignmentsByNewsroomFeedAndCreatedAt"),
806
+ index("itemLineageId").sortKeys(["createdAt"]).queryField("listAssignmentsByItemLineageAndCreatedAt"),
805
807
  index("queueStatusKey").sortKeys(["priority", "createdAt"]).queryField("listAssignmentsByQueueStatusAndPriority"),
806
808
  index("assignmentTypeKey").sortKeys(["status", "createdAt"]).queryField("listAssignmentsByTypeStatusAndCreatedAt"),
807
809
  index("status").sortKeys(["updatedAt"]).queryField("listAssignmentsByStatusAndUpdatedAt"),
@@ -1511,6 +1513,7 @@ const baseDefinitions = {
1511
1513
  changeReason: a.string(),
1512
1514
  contentHash: a.string(),
1513
1515
  type: a.string().required(),
1516
+ stage: a.string(),
1514
1517
  status: a.string().required(),
1515
1518
  typeStatus: a.string().required(),
1516
1519
  slug: a.string().required(),
@@ -1544,6 +1547,7 @@ const baseDefinitions = {
1544
1547
  index("typeStatus").sortKeys(["publishedAt"]).queryField("listItemsByTypeStatusAndPublishedAt"),
1545
1548
  index("sectionStatus").sortKeys(["publishedAt"]).queryField("listItemsBySectionStatusAndPublishedAt"),
1546
1549
  index("versionState").sortKeys(["updatedAt"]).queryField("listItemsByVersionStateAndUpdatedAt"),
1550
+ index("stage").sortKeys(["updatedAt"]).queryField("listItemsByStageAndUpdatedAt"),
1547
1551
  ])
1548
1552
  .authorization((allow) => [
1549
1553
  allow.groups(contentWriteGroups),
@@ -870,9 +870,11 @@ const baseDefinitions = {
870
870
  createdAt: a.datetime().required(),
871
871
  updatedAt: a.datetime().required(),
872
872
  newsroomFeedKey: a.string(),
873
+ itemLineageId: a.id(),
873
874
  })
874
875
  .secondaryIndexes((index) => [
875
876
  index("newsroomFeedKey").sortKeys(["createdAt"]).queryField("listAssignmentsByNewsroomFeedAndCreatedAt"),
877
+ index("itemLineageId").sortKeys(["createdAt"]).queryField("listAssignmentsByItemLineageAndCreatedAt"),
876
878
  index("queueStatusKey").sortKeys(["priority", "createdAt"]).queryField("listAssignmentsByQueueStatusAndPriority"),
877
879
  index("assignmentTypeKey").sortKeys(["status", "createdAt"]).queryField("listAssignmentsByTypeStatusAndCreatedAt"),
878
880
  index("status").sortKeys(["updatedAt"]).queryField("listAssignmentsByStatusAndUpdatedAt"),
@@ -1606,6 +1608,7 @@ const baseDefinitions = {
1606
1608
  changeReason: a.string(),
1607
1609
  contentHash: a.string(),
1608
1610
  type: a.string().required(),
1611
+ stage: a.string(),
1609
1612
  status: a.string().required(),
1610
1613
  typeStatus: a.string().required(),
1611
1614
  slug: a.string().required(),
@@ -1639,6 +1642,7 @@ const baseDefinitions = {
1639
1642
  index("typeStatus").sortKeys(["publishedAt"]).queryField("listItemsByTypeStatusAndPublishedAt"),
1640
1643
  index("sectionStatus").sortKeys(["publishedAt"]).queryField("listItemsBySectionStatusAndPublishedAt"),
1641
1644
  index("versionState").sortKeys(["updatedAt"]).queryField("listItemsByVersionStateAndUpdatedAt"),
1645
+ index("stage").sortKeys(["updatedAt"]).queryField("listItemsByStageAndUpdatedAt"),
1642
1646
  ])
1643
1647
  .authorization((allow) => [
1644
1648
  allow.groups(contentWriteGroups),
@@ -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
+ }
@@ -39,6 +39,7 @@ import type {
39
39
  UserDirectoryEntry,
40
40
  } from "../lib/category-repository";
41
41
  import { relationTypeKey } from "../lib/semantic-graph";
42
+ import { CONTENT_ITEM_STAGES, type ContentItemVersionRecord } from "../lib/content-item-lifecycle";
42
43
  import { DOCTRINE_DEFINITIONS, getCategoryDoctrineDefinitions, type DoctrineCategory } from "../lib/doctrine";
43
44
  import { configureAmplifyClient } from "./amplify-client-provider";
44
45
  import { isUnauthenticatedError, loadReaderSessionSnapshot, type ReaderAuthSnapshot } from "./reader-auth-state";
@@ -253,7 +254,7 @@ const UPDATE_MESSAGE_MUTATION = `
253
254
  const NEWSROOM_ASSIGNMENT_FEED_QUERY = `
254
255
  query ListAssignmentsByNewsroomFeedAndCreatedAt($newsroomFeedKey: String!, $sortDirection: ModelSortDirection, $limit: Int, $nextToken: String, $filter: ModelAssignmentFilterInput) {
255
256
  listAssignmentsByNewsroomFeedAndCreatedAt(newsroomFeedKey: $newsroomFeedKey, sortDirection: $sortDirection, limit: $limit, nextToken: $nextToken, filter: $filter) {
256
- items { id assignmentTypeKey queueKey queueStatusKey status priority title summary assigneeType assigneeId assigneeKey claimedAt claimExpiresAt completedAt canceledAt corpusId categorySetId classifierId sectionId sectionKey sectionType sectionStatusKey sectionQueueStatusKey primaryFocusCategoryKey topicScopeCategoryKeys sourceSnapshotId importRunId createdBy createdAt updatedAt newsroomFeedKey }
257
+ items { id assignmentTypeKey queueKey queueStatusKey status priority title summary assigneeType assigneeId assigneeKey claimedAt claimExpiresAt completedAt canceledAt corpusId categorySetId classifierId sectionId sectionKey sectionType sectionStatusKey sectionQueueStatusKey primaryFocusCategoryKey topicScopeCategoryKeys sourceSnapshotId importRunId createdBy createdAt updatedAt newsroomFeedKey itemLineageId }
257
258
  nextToken
258
259
  }
259
260
  }
@@ -2157,6 +2158,41 @@ export async function loadNewsroomAssignmentPage(options: NewsroomAssignmentPage
2157
2158
  });
2158
2159
  }
2159
2160
 
2161
+ const CONTENT_ITEMS_BY_STAGE_QUERY = `
2162
+ query ListItemsByStageAndUpdatedAt($stage: String!, $sortDirection: ModelSortDirection, $limit: Int, $nextToken: String) {
2163
+ listItemsByStageAndUpdatedAt(stage: $stage, sortDirection: $sortDirection, limit: $limit, nextToken: $nextToken) {
2164
+ items { id lineageId versionNumber stage type status title headline slug section updatedAt }
2165
+ nextToken
2166
+ }
2167
+ }
2168
+ `;
2169
+
2170
+ export async function loadNewsroomContentItemVersions(): Promise<ContentItemVersionRecord[]> {
2171
+ configureAmplifyClient();
2172
+ const client = generateClient<Schema>() as unknown as {
2173
+ graphql: (options: Record<string, unknown>) => Promise<GraphQLConnectionResponse<ContentItemVersionRecord>>;
2174
+ };
2175
+ const field = "listItemsByStageAndUpdatedAt";
2176
+ const versionsPerStage = await Promise.all(CONTENT_ITEM_STAGES.map(async (stage) => {
2177
+ const versions: ContentItemVersionRecord[] = [];
2178
+ let nextToken: string | null = null;
2179
+ do {
2180
+ const response: GraphQLConnectionResponse<ContentItemVersionRecord> = await client.graphql({
2181
+ query: CONTENT_ITEMS_BY_STAGE_QUERY,
2182
+ variables: { stage, sortDirection: "DESC", limit: NEWSROOM_PAGE_LIMIT, nextToken },
2183
+ authMode: USER_POOL_AUTH_MODE,
2184
+ });
2185
+ assertNoGraphQLErrors(response.errors);
2186
+ const connection = response.data?.[field];
2187
+ if (!connection) throw new Error(`Missing GraphQL connection payload for ${field}.`);
2188
+ versions.push(...((connection.items ?? []).filter(Boolean) as ContentItemVersionRecord[]));
2189
+ nextToken = connection.nextToken ?? null;
2190
+ } while (nextToken);
2191
+ return versions;
2192
+ }));
2193
+ return versionsPerStage.flat();
2194
+ }
2195
+
2160
2196
  export async function loadModelPayloadsForOwner(
2161
2197
  ownerKind: string,
2162
2198
  ownerId: string,
@@ -6,10 +6,9 @@ import { IssueCollection, IssueDetailPanel, IssueTypeFilter, issueTypeFromFilter
6
6
  import "@kanbus/ui/styles/kanban.css";
7
7
  import "./newsroom-newsprint-board.css";
8
8
  import { canReadNewsroomBoard, newsroomBoardConfig, projectNewsroomBoard, type NativeBoardIssue } from "../lib/newsroom-board";
9
- import { resolveArticlesBackend } from "../lib/newsroom-articles";
10
- import { SITE_BRAND } from "../lib/site-brand";
9
+ import { getContentItemName, SITE_BRAND } from "../lib/site-brand";
11
10
  import { getNewsroomNavHref } from "../lib/newsroom-nav";
12
- import { loadNewsroomAssignmentPage, loadNewsroomReferencePage } from "./news-desk-taxonomy-client";
11
+ import { loadNewsroomAssignmentPage, loadNewsroomContentItemVersions, loadNewsroomReferencePage } from "./news-desk-taxonomy-client";
13
12
  import { useOptionalNewsDeskClient } from "./news-desk-client-provider";
14
13
  import { NewsDeskAccessGate } from "./newsroom-access-gate";
15
14
  import { NewsroomOpsShell, NewsroomOpsStatusBanner } from "./newsroom-ops-shell";
@@ -45,12 +44,12 @@ export function NewsroomBoardView() {
45
44
  if (!allowed) { setLoading(false); return; }
46
45
  setLoading(true); busy.current = true;
47
46
  Promise.all([
48
- resolveArticlesBackend(false).listRows(),
47
+ loadNewsroomContentItemVersions(),
49
48
  loadNewsroomReferencePage({ limit: 50 }),
50
49
  loadNewsroomAssignmentPage({ limit: 50 }),
51
- ]).then(([articles, references, assignments]) => {
50
+ ]).then(([contentItemVersions, references, assignments]) => {
52
51
  if (generation.current !== request) return;
53
- setIssues(projectNewsroomBoard({ articles, references: references.items, assignments: assignments.items }));
52
+ setIssues(projectNewsroomBoard({ contentItemVersions, references: references.items, assignments: assignments.items }));
54
53
  setTokens({ reference: references.nextToken, assignment: assignments.nextToken });
55
54
  }).catch((reason: unknown) => {
56
55
  if (generation.current === request) setError(reason instanceof Error ? reason.message : "Could not load newsroom records.");
@@ -81,7 +80,7 @@ export function NewsroomBoardView() {
81
80
  <IssueTypeFilter config={config} value={filter} onChange={value => { setFilter(value); setSelected(null); }} name="Record type" />
82
81
  <div role="group" aria-label="Record presentation"><button type="button" aria-pressed={view === "board"} onClick={() => setView("board")}>Board</button><button type="button" aria-pressed={view === "list"} onClick={() => setView("list")}>List</button><button type="button" disabled={loading} onClick={() => setRevision(current => current + 1)}>Refresh records</button></div>
83
82
  </div>
84
- <p className="organizer-board-note">Native CMS records and statuses. Open a record to use its existing editor or workflow controls. Story linking is not configured.</p>
83
+ <p className="organizer-board-note">{`Each ${getContentItemName().singular.toLowerCase()} appears once, in the column for its stage. Open a record to use its existing editor or workflow controls.`}</p>
85
84
  {error && <NewsroomOpsStatusBanner tone="error">{error}</NewsroomOpsStatusBanner>}
86
85
  <p role="status" className="organizer-board-note">{loading ? "Loading records…" : `${issues.length} records loaded. References and assignments load in pages.`}</p>
87
86
  <div className="organizer-board-body" data-detail-open={Boolean(issue)}>
@@ -91,7 +90,7 @@ export function NewsroomBoardView() {
91
90
  selectedIssueId={selected} detailOpen={Boolean(issue)} onSelectIssue={item => setSelected(item.id)} ariaLabel="Newsroom records"
92
91
  emptyMessage={loading ? "Loading records…" : error ? "Records could not be loaded." : "No records in this selection."} />
93
92
  {issue && <IssueDetailPanel issue={issue} onClose={() => setSelected(null)} fields={[{ label: "CMS record ID", value: issue.nativeId }, { label: "Status", value: issue.nativeStatus }]}>
94
- <Link href={getNewsroomNavHref(issue.href)}>Open {issue.type} workspace</Link>
93
+ <Link href={getNewsroomNavHref(issue.href)}>Open {issue.type}</Link>
95
94
  </IssueDetailPanel>}
96
95
  </div>
97
96
  <div className="organizer-board-pages">{tokens.reference && <button disabled={loading} type="button" onClick={() => void loadMore("reference")}>Load more references</button>}{tokens.assignment && <button disabled={loading} type="button" onClick={() => void loadMore("assignment")}>Load more assignments</button>}</div>
@@ -4,7 +4,7 @@ import {
4
4
  type PrimingProofReport,
5
5
  } from "../lib/priming-proof-report";
6
6
 
7
- function ProofMethod({ heldOutSize }: { heldOutSize: number | null }) {
7
+ function ProofMethod({ heldOutSize, teachingSource }: { heldOutSize: number | null; teachingSource: string | null }) {
8
8
  return (
9
9
  <section aria-labelledby="priming-proof-method" className="space-y-2 text-sm leading-relaxed">
10
10
  <h2 id="priming-proof-method" className="text-base font-semibold uppercase tracking-wide">How the number is made</h2>
@@ -14,8 +14,10 @@ function ProofMethod({ heldOutSize }: { heldOutSize: number | null }) {
14
14
  for teaching. A classifier that always gives the same answer agrees on half of it.
15
15
  </p>
16
16
  <p>
17
- Editors teach the classifier by pasting a source URL, reviewing what Papyrus extracted, and choosing publish or
18
- reject. Each point below starts a fresh classifier that sees only the first N of those labels, then decides
17
+ Editors teach the classifier with labels: their other reviews of candidate sources, and sources they paste,
18
+ check after Papyrus extracts them, and mark publish or reject.
19
+ {teachingSource === null ? "" : ` This run taught from ${teachingSource}.`} Each point below starts a fresh
20
+ classifier that sees only the first N of those labels, then decides
19
21
  every held-out source once. Nothing else changes between points: the same decision model, the publication&apos;s
20
22
  mission and policies as the only rubric, and no automatic rubric rewriting.
21
23
  </p>
@@ -31,7 +33,7 @@ export function PrimingProofPage({ report }: { report: PrimingProofReport | null
31
33
  <h1 className="text-3xl font-bold">Does teaching the classifier work?</h1>
32
34
  <p className="text-muted-foreground">No approved proof run has been published for this publication yet.</p>
33
35
  </header>
34
- <ProofMethod heldOutSize={null} />
36
+ <ProofMethod heldOutSize={null} teachingSource={null} />
35
37
  </main>
36
38
  );
37
39
  }
@@ -82,7 +84,7 @@ export function PrimingProofPage({ report }: { report: PrimingProofReport | null
82
84
  </table>
83
85
  </div>
84
86
  </section>
85
- <ProofMethod heldOutSize={report.heldOut.size} />
87
+ <ProofMethod heldOutSize={report.heldOut.size} teachingSource={report.priming.source} />
86
88
  <p className="break-all text-xs text-muted-foreground">
87
89
  Held-out fingerprint {report.heldOut.fingerprint}
88
90
  {report.priming.fingerprint ? ` · labels fingerprint ${report.priming.fingerprint}` : ""}
@@ -15042,6 +15042,9 @@ function buildUiCopywritingAssignment({ actorLabel, assignment, decision, messag
15042
15042
  message: MessageRecord;
15043
15043
  now: string;
15044
15044
  }): AssignmentRecord {
15045
+ if (!assignment.itemLineageId) {
15046
+ throw new Error(`Assignment ${assignment.id} has no content item lineage; copywriting must continue the same content item.`);
15047
+ }
15045
15048
  const type = decision === "brief" ? "brief" : "article";
15046
15049
  const assignmentTypeKey = type === "brief" ? "copywriting.brief-draft" : "copywriting.article-draft";
15047
15050
  const section = assignment.sectionKey ?? assignment.sectionId ?? "unsectioned";
@@ -15094,6 +15097,7 @@ function buildUiCopywritingAssignment({ actorLabel, assignment, decision, messag
15094
15097
  createdAt: now,
15095
15098
  updatedAt: now,
15096
15099
  newsroomFeedKey: "assignment#open",
15100
+ itemLineageId: assignment.itemLineageId,
15097
15101
  metadata,
15098
15102
  };
15099
15103
  }
@@ -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",