@anthusai/papyrus 2.0.0-next.7 → 2.0.0-next.70

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 (165) hide show
  1. package/amplify/data/resource.js +23 -3
  2. package/amplify/data/resource.ts +25 -3
  3. package/amplify/data/schema.js +2028 -1979
  4. package/amplify/data/schema.ts +72 -14
  5. package/amplify/functions/assignment-action/data-access.js +90 -0
  6. package/amplify/functions/assignment-action/data-access.ts +89 -0
  7. package/amplify/functions/assignment-action/handler.ts +1 -1
  8. package/amplify/functions/content-actions/handler.py +61 -2
  9. package/amplify/functions/content-actions/requirements.txt +1 -0
  10. package/amplify/functions/content-actions/resource.js +1 -1
  11. package/amplify/functions/content-actions/resource.ts +1 -1
  12. package/amplify/{plugins/editorial-checks/function → functions/editorial-checks}/handler.py +3 -3
  13. package/amplify/functions/editorial-checks/requirements.txt +1 -0
  14. package/amplify/{plugins/editorial-checks/function → functions/editorial-checks}/resource.js +7 -7
  15. package/amplify/{plugins/editorial-checks/function → functions/editorial-checks}/resource.ts +7 -7
  16. package/amplify/functions/editorial-checks/schema.js +121 -0
  17. package/amplify/{plugins → functions}/editorial-checks/schema.ts +33 -6
  18. package/amplify/functions/editorial-checks/wire.js +53 -0
  19. package/amplify/functions/editorial-checks/wire.ts +61 -0
  20. package/amplify/functions/monitoring-view/handler.py +11 -0
  21. package/amplify/functions/monitoring-view/resource.js +24 -0
  22. package/amplify/functions/monitoring-view/resource.ts +34 -0
  23. package/amplify/functions/monitoring-view/schema.js +16 -0
  24. package/amplify/functions/monitoring-view/schema.ts +18 -0
  25. package/amplify/functions/monitoring-view/wire.js +37 -0
  26. package/amplify/functions/monitoring-view/wire.ts +47 -0
  27. package/amplify/functions/public-forms/core.js +247 -0
  28. package/amplify/functions/public-forms/core.ts +310 -0
  29. package/amplify/functions/public-forms/handler.ts +41 -0
  30. package/amplify/functions/public-forms/origins.js +35 -0
  31. package/amplify/functions/public-forms/origins.ts +43 -0
  32. package/amplify/functions/public-forms/resource.js +9 -0
  33. package/amplify/functions/public-forms/resource.ts +10 -0
  34. package/amplify/functions/public-forms/wire.js +25 -0
  35. package/amplify/functions/public-forms/wire.ts +32 -0
  36. package/amplify/functions/shared/editorial-context.ts +63 -4
  37. package/amplify/functions/shared/python-bundle.js +20 -14
  38. package/amplify/functions/shared/python-bundle.ts +25 -18
  39. package/amplify/plugins/newsletter/config.js +245 -0
  40. package/amplify/plugins/newsletter/config.ts +305 -0
  41. package/amplify/plugins/newsletter/function/handler.py +6 -0
  42. package/amplify/plugins/newsletter/function/requirements.txt +5 -0
  43. package/amplify/plugins/newsletter/ops/handler.py +12 -0
  44. package/amplify/plugins/newsletter/ops/resource.js +27 -0
  45. package/amplify/plugins/newsletter/ops/resource.ts +37 -0
  46. package/amplify/plugins/newsletter/papyrus.plugin.js +263 -0
  47. package/amplify/plugins/newsletter/papyrus.plugin.ts +311 -0
  48. package/amplify/plugins/newsletter/schema.js +56 -0
  49. package/amplify/plugins/newsletter/schema.ts +61 -0
  50. package/amplify/plugins/newsletter/secure-string-parameter.js +107 -0
  51. package/amplify/plugins/newsletter/secure-string-parameter.ts +120 -0
  52. package/amplify/plugins/newsletter/sending.js +140 -0
  53. package/amplify/plugins/newsletter/sending.ts +186 -0
  54. package/amplify/plugins/reader-steering/config.js +180 -0
  55. package/amplify/plugins/reader-steering/config.ts +280 -0
  56. package/amplify/plugins/reader-steering/function/ai_moderation.py +246 -0
  57. package/amplify/plugins/reader-steering/function/handler.py +1554 -0
  58. package/amplify/plugins/reader-steering/function/moderation-prompt.md +27 -0
  59. package/amplify/plugins/reader-steering/function/moderation.py +316 -0
  60. package/amplify/plugins/reader-steering/function/proposals.py +255 -0
  61. package/amplify/plugins/reader-steering/function/resource.js +27 -0
  62. package/amplify/plugins/reader-steering/function/resource.ts +35 -0
  63. package/amplify/plugins/reader-steering/papyrus.plugin.js +184 -0
  64. package/amplify/plugins/reader-steering/papyrus.plugin.ts +210 -0
  65. package/amplify/plugins/reader-steering/schema.js +204 -0
  66. package/amplify/plugins/reader-steering/schema.ts +206 -0
  67. package/amplify/plugins/registry.js +4 -2
  68. package/amplify/plugins/registry.ts +4 -2
  69. package/amplify/plugins/types.ts +5 -0
  70. package/amplify/plugins/wire.js +1 -0
  71. package/amplify/plugins/wire.ts +1 -0
  72. package/amplify/publication/projection-lease.js +30 -0
  73. package/amplify/publication/projection-lease.ts +39 -0
  74. package/amplify/publication/redirect-and-index.js +105 -0
  75. package/amplify/publication/redirect-rules.js +73 -0
  76. package/amplify/publication/redirect-rules.ts +93 -0
  77. package/amplify/publication/s3-projection.js +7 -1
  78. package/amplify/publication/s3-projection.ts +9 -1
  79. package/amplify/site-backend-config.ts +17 -0
  80. package/amplify/site-backend.js +38 -1
  81. package/amplify/site-backend.ts +46 -1
  82. package/app/api/auth/prospectus/sign-in/route.ts +4 -0
  83. package/app/api/newsletter/confirm/route.ts +13 -0
  84. package/app/api/newsletter/preferences/route.ts +13 -0
  85. package/app/api/newsletter/state/route.ts +9 -0
  86. package/app/api/newsletter/subscribe/route.ts +9 -0
  87. package/app/api/newsletter/unsubscribe/route.ts +13 -0
  88. package/app/api/newsroom/curation/context/route.ts +9 -0
  89. package/app/api/newsroom/curation/priming/route.ts +9 -0
  90. package/app/api/newsroom/curation/publication/route.ts +9 -0
  91. package/app/api/reader/comment/delete/route.ts +9 -0
  92. package/app/api/reader/comment/edit/route.ts +9 -0
  93. package/app/api/reader/comment/route.ts +9 -0
  94. package/app/api/reader/flag/route.ts +9 -0
  95. package/app/api/reader/handle/route.ts +9 -0
  96. package/app/api/reader/me/route.ts +9 -0
  97. package/app/api/reader/rating/route.ts +9 -0
  98. package/app/api/reader/report/route.ts +9 -0
  99. package/app/api/reader/retired/route.ts +9 -0
  100. package/app/api/reader/submit/route.ts +9 -0
  101. package/app/api/reader/vote/route.ts +9 -0
  102. package/bin/papyrus-infra.mjs +20 -1
  103. package/components/editorial-checks-admin-panel.tsx +36 -11
  104. package/components/moderation-panel.tsx +248 -0
  105. package/components/monitoring-overview-view.tsx +241 -0
  106. package/components/monitoring-panel.tsx +40 -0
  107. package/components/newsletter-panel.tsx +474 -0
  108. package/components/newsletter-sections.tsx +163 -0
  109. package/components/newsroom-article-editor.tsx +2 -4
  110. package/components/newsroom-curation-bootstrap.tsx +45 -0
  111. package/components/newsroom-organizer.css +31 -0
  112. package/components/newsroom-references-view.tsx +14 -14
  113. package/components/publication-cyclotron-panel.tsx +15 -10
  114. package/components/reference-add-entry.tsx +75 -0
  115. package/components/reference-curation-controls.tsx +31 -22
  116. package/components/reference-cyclotron-priming.tsx +58 -19
  117. package/components/reference-cyclotron-suggestion.tsx +1 -1
  118. package/components/reference-priming.tsx +14 -10
  119. package/components/reference-section-suggestion.tsx +1 -1
  120. package/components/topic-steering-workspace.tsx +64 -20
  121. package/components/use-editorial-checks.ts +12 -10
  122. package/infra/amplify-app-shell.js +90 -16
  123. package/infra/amplify-app-shell.ts +102 -18
  124. package/infra/index.js +1 -0
  125. package/infra/secure-string-parameter-handler.js +41 -0
  126. package/infra/secure-string-parameter-handler.ts +41 -0
  127. package/infra/secure-string-parameter.js +79 -0
  128. package/infra/secure-string-parameter.ts +91 -0
  129. package/infra/site-config.js +86 -6
  130. package/infra/site-config.ts +129 -6
  131. package/lib/category-repository.ts +11 -9
  132. package/lib/curation-routes.ts +345 -0
  133. package/lib/editorial-checks-backend.ts +1 -1
  134. package/lib/editorial-checks-client.ts +19 -1
  135. package/lib/editorial-checks-demo.ts +2 -2
  136. package/lib/editorial-checks-settings.ts +3 -0
  137. package/lib/editorial-checks.ts +8 -11
  138. package/lib/editorial-preview.ts +5 -5
  139. package/lib/monitoring-client.ts +29 -0
  140. package/lib/monitoring-overview-fixture.ts +51 -0
  141. package/lib/monitoring-overview.ts +145 -0
  142. package/lib/newsletter-api.ts +418 -0
  143. package/lib/newsletter-client.ts +46 -0
  144. package/lib/newsletter-fixture.ts +184 -0
  145. package/lib/newsletter-model.ts +202 -0
  146. package/lib/prospectus-session.ts +15 -14
  147. package/lib/prospectus-sign-in-routes.ts +84 -19
  148. package/lib/prospectus-site-identity.ts +24 -1
  149. package/lib/reader-api.ts +145 -0
  150. package/lib/reader-moderation.ts +220 -0
  151. package/lib/reader-retirement-route.ts +89 -0
  152. package/lib/reader-subject.md +29 -0
  153. package/lib/reader-subject.ts +53 -0
  154. package/lib/relevance-status.ts +2 -2
  155. package/lib/semantic-graph.ts +2 -0
  156. package/lib/site-brand-tenant.ts +11 -6
  157. package/lib/site-brand.ts +6 -3
  158. package/lib/site-origin.ts +67 -0
  159. package/middleware.ts +14 -3
  160. package/package.json +42 -3
  161. package/routes.manifest.json +215 -2
  162. package/amplify/plugins/editorial-checks/function/requirements.txt +0 -1
  163. package/amplify/plugins/editorial-checks/papyrus.plugin.js +0 -69
  164. package/amplify/plugins/editorial-checks/papyrus.plugin.ts +0 -85
  165. package/amplify/plugins/editorial-checks/schema.js +0 -94
@@ -0,0 +1,89 @@
1
+ import { NextResponse, type NextRequest } from "next/server";
2
+ import { GetParameterCommand, SSMClient } from "@aws-sdk/client-ssm";
3
+ import { SiteOriginNotConfiguredError, publicSiteOrigin, siteOriginNotConfiguredResponse } from "./site-origin";
4
+ import { resolveProspectusSignInRuntime, type ProspectusSignInRuntime } from "./prospectus-session";
5
+ import { ProspectusAccountsError } from "./prospectus-site-identity";
6
+ import { secretsMatch } from "./revalidate-secret";
7
+
8
+ export const READER_POLL_SECRET_PARAMETER_ENV = "PAPYRUS_READER_POLL_SECRET_PARAMETER";
9
+ export const READER_POLL_SECRET_HEADER = "x-papyrus-reader-poll-secret";
10
+ export const READER_POLL_SECRET_CACHE_MILLISECONDS = 5 * 60 * 1000;
11
+ const CURSOR_PATTERN = /^[A-Za-z0-9._~=+/-]{1,512}$/;
12
+ const NO_STORE_HEADERS = { "cache-control": "no-store" };
13
+
14
+ export interface ReaderRetirementRouteOptions {
15
+ environment?: Record<string, string | undefined>;
16
+ readSecret?: (parameterName: string) => Promise<string | null>;
17
+ resolveRuntime?: (siteOrigin: string) => Promise<Pick<ProspectusSignInRuntime, "accounts"> | null>;
18
+ now?: number;
19
+ }
20
+
21
+ type CachedSecret = { parameterName: string; value: string; expiresAt: number };
22
+ let cachedSecret: CachedSecret | null = null;
23
+ let defaultSsmClient: SSMClient | null = null;
24
+
25
+ export function resetReaderPollSecretCache(): void {
26
+ cachedSecret = null;
27
+ }
28
+
29
+ async function readSecretFromSsm(parameterName: string): Promise<string | null> {
30
+ defaultSsmClient ??= new SSMClient({});
31
+ const response = await defaultSsmClient.send(new GetParameterCommand({ Name: parameterName, WithDecryption: true }));
32
+ return response.Parameter?.Value?.trim() || null;
33
+ }
34
+
35
+ async function loadReaderPollSecret(options: ReaderRetirementRouteOptions, environment: Record<string, string | undefined>): Promise<string | null> {
36
+ const parameterName = environment[READER_POLL_SECRET_PARAMETER_ENV]?.trim();
37
+ if (!parameterName) return null;
38
+ const now = options.now ?? Date.now();
39
+ if (cachedSecret && cachedSecret.parameterName === parameterName && cachedSecret.expiresAt > now) return cachedSecret.value;
40
+ try {
41
+ const value = (await (options.readSecret ?? readSecretFromSsm)(parameterName))?.trim();
42
+ if (!value) return null;
43
+ cachedSecret = { parameterName, value, expiresAt: now + READER_POLL_SECRET_CACHE_MILLISECONDS };
44
+ return value;
45
+ } catch (error) {
46
+ console.error(`Reader poll secret could not be read (${error instanceof Error ? error.name : "error"}).`);
47
+ return null;
48
+ }
49
+ }
50
+
51
+ function failure(status: number, error: string, message: string): NextResponse {
52
+ return NextResponse.json({ error, message }, { status, headers: NO_STORE_HEADERS });
53
+ }
54
+
55
+ /**
56
+ * The scheduled snapshot run's way to Prospectus's retired-subject route. Prospectus admits one caller per site, this
57
+ * app's compute role, so the run cannot call it directly: it calls this route with a shared secret from SSM, and the route
58
+ * calls Prospectus as the compute role and passes the answer through. Anyone without the secret gets 401, and a site
59
+ * without the secret parameter gets 503, so this is never open.
60
+ */
61
+ export async function handleReaderRetiredSubjects(request: NextRequest, options: ReaderRetirementRouteOptions = {}): Promise<NextResponse> {
62
+ const environment = options.environment ?? process.env;
63
+ const expected = await loadReaderPollSecret(options, environment);
64
+ if (!expected) return failure(503, "reader_poll_unavailable", "The retirement poll is not configured for this site.");
65
+ if (!secretsMatch(request.headers.get(READER_POLL_SECRET_HEADER), expected)) return failure(401, "unauthorized", "Missing or wrong poll secret.");
66
+
67
+ const since = request.nextUrl.searchParams.get("since") ?? undefined;
68
+ const unknownParameters = [...request.nextUrl.searchParams.keys()].filter((key) => key !== "since");
69
+ if (unknownParameters.length > 0 || (since !== undefined && !CURSOR_PATTERN.test(since))) {
70
+ return failure(400, "invalid_since", "Send only a since cursor.");
71
+ }
72
+ let siteOrigin: string;
73
+ try {
74
+ siteOrigin = publicSiteOrigin(request, environment);
75
+ } catch (error) {
76
+ if (error instanceof SiteOriginNotConfiguredError) return siteOriginNotConfiguredResponse(error);
77
+ throw error;
78
+ }
79
+ const runtime = await (options.resolveRuntime ?? ((origin) => resolveProspectusSignInRuntime(origin, { environment })))(siteOrigin);
80
+ if (!runtime) return failure(503, "prospectus_sign_in_unavailable", "Prospectus is not configured for this site.");
81
+ try {
82
+ const page = await runtime.accounts.retiredSiteSubjects(since);
83
+ return NextResponse.json({ retired: page.retired, cursor: page.cursor, more: page.more }, { headers: NO_STORE_HEADERS });
84
+ } catch (error) {
85
+ if (error instanceof ProspectusAccountsError) return failure(error.status === 400 ? 400 : 502, error.code, error.message);
86
+ console.error(`Prospectus retired subjects failed (${error instanceof Error ? error.name : "error"}).`);
87
+ return failure(502, "prospectus_unavailable", "Prospectus could not be reached.");
88
+ }
89
+ }
@@ -0,0 +1,29 @@
1
+ # Reader subject
2
+
3
+ `readerSubject(session)` in `reader-subject.ts` is the only place a reader's identity for the reader API is
4
+ decided. It returns the per-site pairwise `siteSubject` (`ss1_` plus 43 base64url characters) that Prospectus
5
+ puts on `person.siteSubject`, or `null`.
6
+
7
+ ## Production
8
+
9
+ Only the `siteSubject` the sealed session cookie carries counts. While Prospectus does not return one, the
10
+ accessor returns `null`, every reader route answers `503 reader_identity_unavailable`, and the reader features
11
+ stay off. There is no other flag. The global Prospectus person id is never used as a reader key.
12
+
13
+ ## Development and test only
14
+
15
+ When `SITE_ENV` is `development` or `test`, `NODE_ENV` is not `production` and `PAPYRUS_READER_DEV_SECRET`
16
+ holds at least 16 characters, a session without a `siteSubject` gets the interim value
17
+ `ssdev_` plus `base64url(HMAC-SHA-256(devSecret, personId))`.
18
+
19
+ That path never runs in production. No remap from `ssdev_` values to `ss1_` values is ever needed: production
20
+ reader writes stay closed until `siteSubject` is live, so no production row is ever keyed on an interim value.
21
+ The `reader-actions` function also refuses `ssdev_` subjects unless the site was synthesized with a
22
+ development or test `SITE_ENV`.
23
+
24
+ ## Staff
25
+
26
+ `readerIsStaff(session)` reads the `cognito:groups` claim from the access token inside the sealed session
27
+ cookie (`editor` or `admin`). The cookie is written by the site's own callback route, so the token is trusted
28
+ the way the rest of the session is. The reader API passes the result to the function as `isStaff`; clients never
29
+ send it.
@@ -0,0 +1,53 @@
1
+ import { createHmac } from "node:crypto";
2
+ import type { ProspectusSignedInSession } from "./prospectus-session";
3
+
4
+ export const SITE_SUBJECT_PATTERN = /^ss1_[A-Za-z0-9_-]{43}$/;
5
+ export const INTERIM_SUBJECT_PATTERN = /^ssdev_[A-Za-z0-9_-]{43}$/;
6
+ export const READER_DEV_SECRET_ENV = "PAPYRUS_READER_DEV_SECRET";
7
+ export const READER_STAFF_GROUPS = ["editor", "admin"] as const;
8
+
9
+ const MINIMUM_DEV_SECRET_LENGTH = 16;
10
+ const INTERIM_SUBJECT_PREFIX = "ssdev_";
11
+ const INTERIM_ENVIRONMENTS = ["development", "test"];
12
+
13
+ type EnvironmentVariables = Record<string, string | undefined>;
14
+
15
+ export function interimSubjectsAllowed(environment: EnvironmentVariables = process.env): boolean {
16
+ if (environment.NODE_ENV === "production") return false;
17
+ const siteEnvironment = (environment.SITE_ENV ?? "").trim();
18
+ if (!INTERIM_ENVIRONMENTS.includes(siteEnvironment)) return false;
19
+ return (environment[READER_DEV_SECRET_ENV] ?? "").length >= MINIMUM_DEV_SECRET_LENGTH;
20
+ }
21
+
22
+ export function interimSubject(personId: string, devSecret: string): string {
23
+ return INTERIM_SUBJECT_PREFIX + createHmac("sha256", devSecret).update(personId).digest("base64url");
24
+ }
25
+
26
+ /**
27
+ * The reader's per-site pairwise id, or null. Production reads only the siteSubject Prospectus returned;
28
+ * the interim `ssdev_` value exists in development and test only (lib/reader-subject.md).
29
+ */
30
+ export function readerSubject(
31
+ session: Pick<ProspectusSignedInSession, "personId" | "siteSubject"> | null | undefined,
32
+ environment: EnvironmentVariables = process.env,
33
+ ): string | null {
34
+ if (!session) return null;
35
+ if (typeof session.siteSubject === "string" && SITE_SUBJECT_PATTERN.test(session.siteSubject)) return session.siteSubject;
36
+ if (!interimSubjectsAllowed(environment) || !session.personId) return null;
37
+ return interimSubject(session.personId, environment[READER_DEV_SECRET_ENV] as string);
38
+ }
39
+
40
+ /** Staff membership from the Cognito groups claim of the access token that the sealed session cookie carries. */
41
+ export function readerIsStaff(session: Pick<ProspectusSignedInSession, "accessToken"> | null | undefined): boolean {
42
+ const token = session?.accessToken;
43
+ if (typeof token !== "string") return false;
44
+ const payload = token.split(".")[1];
45
+ if (!payload) return false;
46
+ try {
47
+ const claims = JSON.parse(Buffer.from(payload, "base64url").toString("utf8")) as { "cognito:groups"?: unknown };
48
+ const groups = claims["cognito:groups"];
49
+ return Array.isArray(groups) && groups.some((group) => (READER_STAFF_GROUPS as readonly unknown[]).includes(group));
50
+ } catch {
51
+ return false;
52
+ }
53
+ }
@@ -52,10 +52,10 @@ export const DEMO_CYCLOTRON_STATUS: CyclotronStatus = {
52
52
  },
53
53
  calibration: { saysSure: null, isRight: null, gapPoints: null, curve: [] },
54
54
  reviewRate: {
55
- state: "onboarding", rate: 1, reason: "Onboarding: every decision is reviewed until the cyclotron earns a lower rate.",
55
+ state: "onboarding", rate: 1, reason: "Getting started: every suggestion is reviewed until suggestions earn a lower review rate.",
56
56
  auditFloor: 0.05, confidenceThreshold: 0.7, override: null,
57
57
  nextCheckAfterDecisions: 99,
58
- nextStep: "Steps down to 50% after 2 more windows of 100 decisions with accuracy on confident decisions of at least 85% (at least 20 reviewed) and a calibration gap under 5 points. Next check in 99 decisions.",
58
+ nextStep: "Steps down to 50% after 2 more windows of 100 decisions with accuracy on confident decisions of at least 85% (at least 20 reviewed) and a gap under 5 points between how sure suggestions say they are and how often they are right. Next check in 99 decisions.",
59
59
  expectedReviewsPerWeek: 1,
60
60
  },
61
61
  lastChange: null,
@@ -57,6 +57,7 @@ export type SemanticPredicateId =
57
57
  | "related_to"
58
58
  | "broader_than"
59
59
  | "narrower_than"
60
+ | "member_of"
60
61
  | "supports"
61
62
  | "contradicts";
62
63
 
@@ -96,6 +97,7 @@ export const SEMANTIC_PREDICATES: SemanticPredicateDefinition[] = [
96
97
  { id: "related_to", label: "related to", group: "generic", inverseLabel: "related from" },
97
98
  { id: "broader_than", label: "broader than", group: "ontology", inverseLabel: "narrower concept" },
98
99
  { id: "narrower_than", label: "narrower than", group: "ontology", inverseLabel: "broader concept" },
100
+ { id: "member_of", label: "member of", group: "ontology", inverseLabel: "members" },
99
101
  { id: "supports", label: "supports", group: "evidence", inverseLabel: "supported by" },
100
102
  { id: "contradicts", label: "contradicts", group: "evidence", inverseLabel: "contradicted by" },
101
103
  ];
@@ -9,14 +9,19 @@ export function resolveBrandConfigPath(relativePath: string): string {
9
9
  return path.isAbsolute(relativePath) ? relativePath : path.join(process.cwd(), relativePath);
10
10
  }
11
11
 
12
- export function getBrandSteeringConfigPath(brand: SiteBrand = resolveActiveSiteBrand()): string {
13
- return resolveBrandConfigPath(brand.steeringConfigPath);
12
+ /** Optional repo files a brand may still name; undefined means the site keeps that configuration as backend data and nothing is looked up. */
13
+ function optionalBrandConfigPath(relativePath: string | undefined): string | undefined {
14
+ return relativePath ? resolveBrandConfigPath(relativePath) : undefined;
14
15
  }
15
16
 
16
- export function getBrandNewsroomSectionsConfigPath(brand: SiteBrand = resolveActiveSiteBrand()): string {
17
- return resolveBrandConfigPath(brand.newsroomSectionsConfigPath);
17
+ export function getBrandSteeringConfigPath(brand: SiteBrand = resolveActiveSiteBrand()): string | undefined {
18
+ return optionalBrandConfigPath(brand.steeringConfigPath);
18
19
  }
19
20
 
20
- export function getBrandAnalysisProfilesPath(brand: SiteBrand = resolveActiveSiteBrand()): string {
21
- return resolveBrandConfigPath(brand.analysisProfilesPath);
21
+ export function getBrandNewsroomSectionsConfigPath(brand: SiteBrand = resolveActiveSiteBrand()): string | undefined {
22
+ return optionalBrandConfigPath(brand.newsroomSectionsConfigPath);
23
+ }
24
+
25
+ export function getBrandAnalysisProfilesPath(brand: SiteBrand = resolveActiveSiteBrand()): string | undefined {
26
+ return optionalBrandConfigPath(brand.analysisProfilesPath);
22
27
  }
package/lib/site-brand.ts CHANGED
@@ -142,9 +142,12 @@ export type SiteBrand = {
142
142
  readerBasePath?: string;
143
143
  opsChrome: OpsChrome;
144
144
  corpusKey: string;
145
- steeringConfigPath: string;
146
- newsroomSectionsConfigPath: string;
147
- analysisProfilesPath: string;
145
+ /** Optional repo file for topic steering. Editorial and steering configuration is backend data; a data-driven site sets none and nothing is looked up. */
146
+ steeringConfigPath?: string;
147
+ /** Optional repo file for the local-preview demo sections only. Sections live in the backend. */
148
+ newsroomSectionsConfigPath?: string;
149
+ /** Optional repo file listing analysis profiles. */
150
+ analysisProfilesPath?: string;
148
151
  publicationName: string;
149
152
  components?: SiteBrandComponents;
150
153
  /**
@@ -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
@@ -85,9 +85,14 @@
85
85
  "./components/item-stream-view": "./components/item-stream-view.tsx",
86
86
  "./components/markus-body-editor": "./components/markus-body-editor.tsx",
87
87
  "./components/markus-ir-preview": "./components/markus-ir-preview.tsx",
88
+ "./components/moderation-panel": "./components/moderation-panel.tsx",
89
+ "./components/monitoring-overview-view": "./components/monitoring-overview-view.tsx",
90
+ "./components/monitoring-panel": "./components/monitoring-panel.tsx",
88
91
  "./components/news-desk-client-provider": "./components/news-desk-client-provider.tsx",
89
92
  "./components/news-desk-page": "./components/news-desk-page.tsx",
90
93
  "./components/news-desk-taxonomy-client": "./components/news-desk-taxonomy-client.ts",
94
+ "./components/newsletter-panel": "./components/newsletter-panel.tsx",
95
+ "./components/newsletter-sections": "./components/newsletter-sections.tsx",
91
96
  "./components/newspaper": "./components/newspaper.tsx",
92
97
  "./components/newsroom-access-gate": "./components/newsroom-access-gate.tsx",
93
98
  "./components/newsroom-article-editor": "./components/newsroom-article-editor.tsx",
@@ -96,6 +101,7 @@
96
101
  "./components/newsroom-assignments-view": "./components/newsroom-assignments-view.tsx",
97
102
  "./components/newsroom-board-view": "./components/newsroom-board-view.tsx",
98
103
  "./components/newsroom-client-shell": "./components/newsroom-client-shell.tsx",
104
+ "./components/newsroom-curation-bootstrap": "./components/newsroom-curation-bootstrap.tsx",
99
105
  "./components/newsroom-curation-provider": "./components/newsroom-curation-provider.tsx",
100
106
  "./components/newsroom-ops-overview": "./components/newsroom-ops-overview.tsx",
101
107
  "./components/newsroom-ops-shell": "./components/newsroom-ops-shell.tsx",
@@ -118,6 +124,7 @@
118
124
  "./components/reader-auth-state": "./components/reader-auth-state.ts",
119
125
  "./components/reader-settings": "./components/reader-settings.ts",
120
126
  "./components/reader-theme-control": "./components/reader-theme-control.tsx",
127
+ "./components/reference-add-entry": "./components/reference-add-entry.tsx",
121
128
  "./components/reference-curation-controls": "./components/reference-curation-controls.tsx",
122
129
  "./components/reference-cyclotron-priming": "./components/reference-cyclotron-priming.tsx",
123
130
  "./components/reference-cyclotron-status": "./components/reference-cyclotron-status.tsx",
@@ -188,6 +195,7 @@
188
195
  "./lib/content-types": "./lib/content-types.ts",
189
196
  "./lib/curation-client": "./lib/curation-client.ts",
190
197
  "./lib/curation-delegation-helpers": "./lib/curation-delegation-helpers.ts",
198
+ "./lib/curation-routes": "./lib/curation-routes.ts",
191
199
  "./lib/cyclotron-suggestions": "./lib/cyclotron-suggestions.ts",
192
200
  "./lib/define-site": "./lib/define-site.ts",
193
201
  "./lib/demo-amplify-outputs": "./lib/demo-amplify-outputs.ts",
@@ -224,7 +232,14 @@
224
232
  "./lib/markus-projection": "./lib/markus-projection.ts",
225
233
  "./lib/markus-to-article": "./lib/markus-to-article.ts",
226
234
  "./lib/media-route-response": "./lib/media-route-response.ts",
235
+ "./lib/monitoring-client": "./lib/monitoring-client.ts",
236
+ "./lib/monitoring-overview-fixture": "./lib/monitoring-overview-fixture.ts",
237
+ "./lib/monitoring-overview": "./lib/monitoring-overview.ts",
227
238
  "./lib/news-desk-session": "./lib/news-desk-session.ts",
239
+ "./lib/newsletter-api": "./lib/newsletter-api.ts",
240
+ "./lib/newsletter-client": "./lib/newsletter-client.ts",
241
+ "./lib/newsletter-fixture": "./lib/newsletter-fixture.ts",
242
+ "./lib/newsletter-model": "./lib/newsletter-model.ts",
228
243
  "./lib/newspaper-layout": "./lib/newspaper-layout.ts",
229
244
  "./lib/newsroom-articles": "./lib/newsroom-articles.ts",
230
245
  "./lib/newsroom-assignments": "./lib/newsroom-assignments.ts",
@@ -265,11 +280,15 @@
265
280
  "./lib/publication-cyclotron": "./lib/publication-cyclotron.ts",
266
281
  "./lib/publication-items": "./lib/publication-items.ts",
267
282
  "./lib/rate-limit-retry": "./lib/rate-limit-retry.ts",
283
+ "./lib/reader-api": "./lib/reader-api.ts",
268
284
  "./lib/reader-base-path": "./lib/reader-base-path.ts",
269
285
  "./lib/reader-cache-lifetime": "./lib/reader-cache-lifetime.ts",
286
+ "./lib/reader-moderation": "./lib/reader-moderation.ts",
287
+ "./lib/reader-retirement-route": "./lib/reader-retirement-route.ts",
270
288
  "./lib/reader-route-config": "./lib/reader-route-config.ts",
271
289
  "./lib/reader-static-params": "./lib/reader-static-params.ts",
272
290
  "./lib/reader-storage-url": "./lib/reader-storage-url.ts",
291
+ "./lib/reader-subject": "./lib/reader-subject.ts",
273
292
  "./lib/reference-display": "./lib/reference-display.ts",
274
293
  "./lib/reference-policy": "./lib/reference-policy.ts",
275
294
  "./lib/reference-source-preview": "./lib/reference-source-preview.ts",
@@ -288,6 +307,7 @@
288
307
  "./lib/site-brand-tenant": "./lib/site-brand-tenant.ts",
289
308
  "./lib/site-brand": "./lib/site-brand.ts",
290
309
  "./lib/site-env": "./lib/site-env.ts",
310
+ "./lib/site-origin": "./lib/site-origin.ts",
291
311
  "./lib/site-renderer": "./lib/site-renderer.ts",
292
312
  "./lib/site-section-suggestions": "./lib/site-section-suggestions.ts",
293
313
  "./lib/site-stack": "./lib/site-stack.ts",
@@ -330,6 +350,25 @@
330
350
  "./app/api/local-preview/derive/route": "./app/api/local-preview/derive/route.ts",
331
351
  "./app/api/local-preview/media/route": "./app/api/local-preview/media/route.ts",
332
352
  "./app/api/media/[...path]/route": "./app/api/media/[...path]/route.ts",
353
+ "./app/api/newsletter/confirm/route": "./app/api/newsletter/confirm/route.ts",
354
+ "./app/api/newsletter/preferences/route": "./app/api/newsletter/preferences/route.ts",
355
+ "./app/api/newsletter/state/route": "./app/api/newsletter/state/route.ts",
356
+ "./app/api/newsletter/subscribe/route": "./app/api/newsletter/subscribe/route.ts",
357
+ "./app/api/newsletter/unsubscribe/route": "./app/api/newsletter/unsubscribe/route.ts",
358
+ "./app/api/newsroom/curation/context/route": "./app/api/newsroom/curation/context/route.ts",
359
+ "./app/api/newsroom/curation/priming/route": "./app/api/newsroom/curation/priming/route.ts",
360
+ "./app/api/newsroom/curation/publication/route": "./app/api/newsroom/curation/publication/route.ts",
361
+ "./app/api/reader/comment/delete/route": "./app/api/reader/comment/delete/route.ts",
362
+ "./app/api/reader/comment/edit/route": "./app/api/reader/comment/edit/route.ts",
363
+ "./app/api/reader/comment/route": "./app/api/reader/comment/route.ts",
364
+ "./app/api/reader/flag/route": "./app/api/reader/flag/route.ts",
365
+ "./app/api/reader/handle/route": "./app/api/reader/handle/route.ts",
366
+ "./app/api/reader/me/route": "./app/api/reader/me/route.ts",
367
+ "./app/api/reader/rating/route": "./app/api/reader/rating/route.ts",
368
+ "./app/api/reader/report/route": "./app/api/reader/report/route.ts",
369
+ "./app/api/reader/retired/route": "./app/api/reader/retired/route.ts",
370
+ "./app/api/reader/submit/route": "./app/api/reader/submit/route.ts",
371
+ "./app/api/reader/vote/route": "./app/api/reader/vote/route.ts",
333
372
  "./app/api/revalidate/route": "./app/api/revalidate/route.ts",
334
373
  "./app/archive/page": "./app/archive/page.tsx",
335
374
  "./app/articles/[slug]/page": "./app/articles/[slug]/page.tsx",
@@ -395,14 +434,14 @@
395
434
  "optional": true
396
435
  }
397
436
  },
398
- "version": "2.0.0-next.7",
437
+ "version": "2.0.0-next.70",
399
438
  "dependencies": {
400
439
  "@aws-amplify/adapter-nextjs": "^1.7.3",
401
440
  "@aws-crypto/sha256-js": "5.2.0",
402
441
  "@aws-sdk/client-cognito-identity-provider": "3.1045.0",
442
+ "@aws-sdk/client-dynamodb": "^3.1045.0",
403
443
  "@aws-sdk/client-lambda": "^3.1045.0",
404
444
  "@aws-sdk/client-s3": "^3.1045.0",
405
- "@aws-sdk/client-secrets-manager": "3.1045.0",
406
445
  "@aws-sdk/client-ssm": "^3.1045.0",
407
446
  "@aws-sdk/credential-provider-node": "3.972.84",
408
447
  "@aws-sdk/s3-request-presigner": "^3.1045.0",
@@ -435,7 +474,7 @@
435
474
  "class-variance-authority": "^0.7.1",
436
475
  "clsx": "^2.1.1",
437
476
  "cmdk": "^1.1.1",
438
- "cyclotron": "https://github.com/AnthusAI/Cyclotron/releases/download/ui-v0.2.2/cyclotron-0.2.2.tgz",
477
+ "cyclotron": "https://github.com/AnthusAI/Cyclotron/releases/download/ui-v0.2.3/cyclotron-0.2.3.tgz",
439
478
  "d3": "^7.9.0",
440
479
  "esbuild": "^0.28.2",
441
480
  "framer-motion": "^14.3.0",