@anthusai/papyrus 1.0.0-next.9 → 1.0.0

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 (208) hide show
  1. package/amplify/auth/redirect-urls.js +54 -0
  2. package/amplify/auth/redirect-urls.ts +66 -0
  3. package/amplify/auth/resource.js +20 -5
  4. package/amplify/auth/resource.ts +27 -5
  5. package/amplify/data/resource.js +42 -80
  6. package/amplify/data/resource.ts +42 -80
  7. package/amplify/data/schema.js +45 -60
  8. package/amplify/data/schema.ts +47 -60
  9. package/amplify/functions/category-action/handler.ts +5 -3
  10. package/amplify/functions/console-chat-responder/py/execute_tactus_runner.py +0 -10
  11. package/amplify/functions/console-chat-responder/resource.js +0 -7
  12. package/amplify/functions/console-chat-responder/resource.ts +0 -8
  13. package/amplify/functions/console-chat-responder/src/main.rs +0 -9
  14. package/amplify/functions/content-actions/handler.py +143 -1
  15. package/amplify/functions/content-actions/requirements.txt +2 -1
  16. package/amplify/functions/content-actions/resource.js +6 -1
  17. package/amplify/functions/content-actions/resource.ts +9 -2
  18. package/amplify/functions/renditions/handler.py +120 -0
  19. package/amplify/functions/renditions/requirements.txt +4 -0
  20. package/amplify/functions/shared/python-bundle.js +42 -2
  21. package/amplify/functions/shared/python-bundle.ts +47 -2
  22. package/amplify/functions/shared/reference-curation-metadata.ts +29 -0
  23. package/amplify/inbound-email/ses-intake.js +86 -0
  24. package/amplify/inbound-email/ses-intake.ts +110 -0
  25. package/amplify/plugins/registry.js +19 -0
  26. package/amplify/plugins/registry.ts +23 -0
  27. package/amplify/plugins/signup/function/handler.py +200 -0
  28. package/amplify/plugins/signup/function/resource.js +27 -0
  29. package/amplify/plugins/signup/function/resource.ts +35 -0
  30. package/amplify/plugins/signup/papyrus.plugin.js +59 -0
  31. package/amplify/plugins/signup/papyrus.plugin.ts +74 -0
  32. package/amplify/plugins/signup/schema.js +40 -0
  33. package/amplify/plugins/signup/schema.ts +41 -0
  34. package/amplify/plugins/signup/waf.js +48 -0
  35. package/amplify/plugins/signup/waf.ts +51 -0
  36. package/amplify/plugins/types.js +1 -0
  37. package/amplify/plugins/types.ts +39 -0
  38. package/amplify/plugins/wire.js +24 -0
  39. package/amplify/plugins/wire.ts +26 -0
  40. package/amplify/publication/default-publication.js +18 -0
  41. package/amplify/publication/default-publication.ts +20 -0
  42. package/amplify/publication/invalidation-alarm.js +26 -0
  43. package/amplify/publication/invalidation-alarm.ts +30 -0
  44. package/amplify/publication/redirect-and-index.js +37 -0
  45. package/amplify/publication/renditions-function.js +74 -0
  46. package/amplify/publication/renditions-function.ts +112 -0
  47. package/amplify/publication/renditions-settings.js +51 -0
  48. package/amplify/publication/renditions-settings.ts +58 -0
  49. package/amplify/publication/reproject-on-deploy.js +73 -0
  50. package/amplify/publication/reproject-on-deploy.ts +78 -0
  51. package/amplify/publication/s3-projection-settings.js +67 -0
  52. package/amplify/publication/s3-projection-settings.ts +101 -0
  53. package/amplify/publication/s3-projection.js +151 -0
  54. package/amplify/publication/s3-projection.ts +201 -0
  55. package/amplify/resolver-waves.js +26 -0
  56. package/amplify/resolver-waves.ts +34 -0
  57. package/amplify/revalidate-invalidation-settings.js +30 -0
  58. package/amplify/revalidate-invalidation-settings.ts +35 -0
  59. package/amplify/revalidate-secret-policy.js +13 -0
  60. package/amplify/revalidate-secret-policy.ts +19 -0
  61. package/amplify/site-backend-config.js +65 -1
  62. package/amplify/site-backend-config.ts +153 -2
  63. package/amplify/site-backend-names.js +103 -0
  64. package/amplify/site-backend-names.ts +166 -0
  65. package/amplify/site-backend.js +143 -176
  66. package/amplify/site-backend.ts +181 -201
  67. package/amplify/storage-backups/construct.js +31 -0
  68. package/amplify/storage-backups/construct.ts +47 -0
  69. package/app/[year]/[month]/[day]/[articleSlug]/page.tsx +8 -1
  70. package/app/[year]/[month]/[day]/edition-route-page.tsx +2 -0
  71. package/app/[year]/[month]/[day]/page/[pageNumber]/page.tsx +6 -1
  72. package/app/[year]/[month]/[day]/page.tsx +1 -1
  73. package/app/[year]/[month]/[day]/section/[sectionKey]/page.tsx +6 -1
  74. package/app/api/archive/editions/route.ts +1 -1
  75. package/app/api/cyclotron/priming-proof/route.ts +11 -0
  76. package/app/api/media/[...path]/route.ts +5 -16
  77. package/app/api/revalidate/route.ts +15 -7
  78. package/app/archive/page.tsx +1 -1
  79. package/app/articles/[slug]/page.tsx +7 -5
  80. package/app/blog-rhythm.css +278 -0
  81. package/app/cyclotron/page.tsx +13 -0
  82. package/app/globals.css +2 -0
  83. package/app/layout.tsx +16 -0
  84. package/app/newsroom/[section]/[...selection]/page.tsx +9 -6
  85. package/app/newsroom/[section]/page.tsx +6 -3
  86. package/app/newsroom/layout.tsx +4 -9
  87. package/app/newsroom/settings/page.tsx +6 -0
  88. package/app/newsroom/work/page.tsx +10 -0
  89. package/app/page.tsx +22 -8
  90. package/app/robots.ts +2 -5
  91. package/app/tailwind.css +1 -0
  92. package/components/amplify-client-provider.tsx +5 -22
  93. package/components/archive-shell.tsx +2 -22
  94. package/components/article-video.tsx +152 -5
  95. package/components/blog-page-background.tsx +23 -6
  96. package/components/news-desk-page.tsx +4 -15
  97. package/components/news-desk-taxonomy-client.ts +90 -3
  98. package/components/newspaper.tsx +6 -2
  99. package/components/newsroom-access-gate.tsx +2 -1
  100. package/components/newsroom-board-view.tsx +99 -0
  101. package/components/newsroom-client-shell.tsx +12 -0
  102. package/components/newsroom-curation-provider.tsx +17 -0
  103. package/components/newsroom-fonts/playfair-display-700.ttf +0 -0
  104. package/components/newsroom-fonts/playfair-display-900.ttf +0 -0
  105. package/components/newsroom-fonts/playfair-display-OFL.txt +93 -0
  106. package/components/newsroom-fonts/plus-jakarta-sans-400.ttf +0 -0
  107. package/components/newsroom-fonts/plus-jakarta-sans-600.ttf +0 -0
  108. package/components/newsroom-fonts/plus-jakarta-sans-OFL.txt +93 -0
  109. package/components/newsroom-newsprint-board.css +48 -0
  110. package/components/newsroom-ops-overview.tsx +28 -11
  111. package/components/newsroom-ops-shell.tsx +7 -1
  112. package/components/newsroom-organizer-shell.tsx +130 -0
  113. package/components/newsroom-organizer.css +63 -0
  114. package/components/newsroom-references-view.tsx +92 -8
  115. package/components/papyrus-console-shell.tsx +4 -2
  116. package/components/pictogram-figure.tsx +9 -2
  117. package/components/presentation-header.tsx +135 -0
  118. package/components/presentation-rhythm-hrule.tsx +17 -0
  119. package/components/priming-proof-page.tsx +92 -0
  120. package/components/publication-cyclotron-panel.tsx +57 -0
  121. package/components/reader-auth-control.tsx +8 -5
  122. package/components/reference-curation-controls.tsx +77 -0
  123. package/components/reference-cyclotron-status.tsx +109 -0
  124. package/components/reference-priming.tsx +18 -0
  125. package/components/reference-relevance-review.tsx +75 -0
  126. package/components/settings-page.tsx +2 -1
  127. package/components/signup-entries-panel.tsx +94 -0
  128. package/components/topic-steering-workspace.tsx +78 -6
  129. package/components/use-blog-header-obstacles.ts +81 -0
  130. package/components/use-brand-fonts-ready.ts +28 -0
  131. package/components/use-near-viewport.ts +30 -0
  132. package/components/use-rhythm-overlay.ts +29 -0
  133. package/infra/amplify-app-shell.js +148 -27
  134. package/infra/amplify-app-shell.ts +160 -31
  135. package/infra/build-specs.js +23 -7
  136. package/infra/build-specs.ts +27 -10
  137. package/infra/site-config.js +248 -36
  138. package/infra/site-config.ts +313 -51
  139. package/lib/amplify-outputs-path.ts +5 -1
  140. package/lib/amplify-server-runtime.ts +36 -1
  141. package/lib/approved-priming-proof-reports.ts +6 -0
  142. package/lib/blog-feature-solver.ts +459 -0
  143. package/lib/blog-rhythm.ts +134 -0
  144. package/lib/brand-fonts-watch.ts +43 -0
  145. package/lib/brand-fonts.ts +112 -0
  146. package/lib/cached-content-repository.ts +12 -0
  147. package/lib/category-repository.ts +43 -93
  148. package/lib/content-item-lifecycle.ts +55 -0
  149. package/lib/content-repository.ts +3 -0
  150. package/lib/content-types.ts +4 -0
  151. package/lib/curation-client.ts +61 -0
  152. package/lib/edition-routes.ts +31 -29
  153. package/lib/graphql-content-repository.ts +56 -22
  154. package/lib/media-route-response.ts +45 -0
  155. package/lib/newspaper-layout.ts +3 -2
  156. package/lib/newsroom-base-path.ts +12 -3
  157. package/lib/newsroom-board.ts +99 -0
  158. package/lib/newsroom-category-drilldown.ts +3 -2
  159. package/lib/newsroom-forum-routes.ts +6 -4
  160. package/lib/newsroom-index-filters.ts +14 -10
  161. package/lib/newsroom-nav.ts +3 -1
  162. package/lib/newsroom-organizer-nav.ts +37 -0
  163. package/lib/newsroom-section-routing.ts +20 -0
  164. package/lib/oauth-redirect-priority.ts +40 -0
  165. package/lib/papyrus-web-locations.ts +7 -6
  166. package/lib/plugin-settings.ts +11 -0
  167. package/lib/presentation-footer.ts +3 -2
  168. package/lib/pretext-layout.ts +12 -3
  169. package/lib/priming-proof-report.ts +89 -0
  170. package/lib/prospectus-site-identity.ts +303 -0
  171. package/lib/publication-cyclotron.ts +74 -0
  172. package/lib/rate-limit-retry.ts +55 -0
  173. package/lib/reader-base-path.ts +24 -0
  174. package/lib/reader-cache-lifetime.ts +5 -0
  175. package/lib/reader-static-params.ts +85 -4
  176. package/lib/reader-storage-url.ts +9 -12
  177. package/lib/reference-policy.ts +5 -0
  178. package/lib/relevance-decisions.ts +103 -0
  179. package/lib/relevance-status.ts +63 -0
  180. package/lib/renderer.ts +7 -0
  181. package/lib/revalidate-prewarm.ts +53 -0
  182. package/lib/revalidate-secret.ts +59 -0
  183. package/lib/robots-policy.ts +14 -0
  184. package/lib/root-route-redirect.ts +16 -0
  185. package/lib/semantic-graph.ts +7 -0
  186. package/lib/shared-credentials-provider.ts +61 -0
  187. package/lib/signup-csv.ts +24 -0
  188. package/lib/site-brand.ts +59 -0
  189. package/lib/staging-gated-path.ts +23 -0
  190. package/lib/staging-preview-object.ts +21 -5
  191. package/lib/test-editor-override.ts +4 -0
  192. package/lib/video-loading.ts +22 -0
  193. package/lib/video-script.ts +14 -0
  194. package/middleware.ts +11 -19
  195. package/package.json +64 -1
  196. package/renderers/pretext/article-page.tsx +39 -3
  197. package/renderers/pretext/presentation-shared.tsx +113 -0
  198. package/renderers/pretext/presentation-shell.tsx +15 -102
  199. package/renderers/pretext/rhythm-article-page.tsx +165 -0
  200. package/renderers/pretext/rhythm-blog-presentation.tsx +279 -0
  201. package/renderers/pretext/rhythm-shell-style.ts +35 -0
  202. package/routes.manifest.json +68 -12
  203. package/src/sync.mjs +30 -6
  204. package/src/with-papyrus.d.mts +9 -0
  205. package/src/with-papyrus.mjs +48 -3
  206. package/amplify/functions/graphql-jwt-authorizer/handler.ts +0 -193
  207. package/amplify/functions/graphql-jwt-authorizer/resource.js +0 -14
  208. package/amplify/functions/graphql-jwt-authorizer/resource.ts +0 -15
@@ -0,0 +1,11 @@
1
+ export type PluginOutputs = { custom?: { papyrusPlugins?: Record<string, Record<string, unknown>> } };
2
+
3
+ /** The settings a deployed plugin published in `amplify_outputs.json`, or null when the site did not enable it. */
4
+ export function pluginSettings(outputs: PluginOutputs | null | undefined, name: string): Record<string, unknown> | null {
5
+ return outputs?.custom?.papyrusPlugins?.[name] ?? null;
6
+ }
7
+
8
+ export function signupForms(outputs: PluginOutputs | null | undefined): string[] {
9
+ const forms = pluginSettings(outputs, "signup")?.forms;
10
+ return Array.isArray(forms) ? forms.filter((form): form is string => typeof form === "string") : [];
11
+ }
@@ -1,6 +1,7 @@
1
1
  import { getEditionSectionItems } from "./edition-sections";
2
2
  import type { EditionContent } from "./content-types";
3
3
  import type { PublicationItem } from "./publication-items";
4
+ import { toPublicReaderPath } from "./reader-base-path";
4
5
 
5
6
  export type PresentationFooterEntry = {
6
7
  section: string;
@@ -43,9 +44,9 @@ export type PresentationFooterGeometry = {
43
44
  };
44
45
 
45
46
  export const PRESENTATION_FOOTER_UTILITIES: PresentationFooterUtilityEntry[] = [
46
- { id: "archive", label: "Archive", href: "/archive", disabled: false },
47
+ { id: "archive", label: "Archive", href: toPublicReaderPath("/archive"), disabled: false },
47
48
  { id: "newsDesk", label: "Newsroom", href: "/newsroom", disabled: false },
48
- { id: "settings", label: "Settings", href: "/settings", disabled: false },
49
+ { id: "settings", label: "Settings", href: toPublicReaderPath("/settings"), disabled: false },
49
50
  { id: "login", label: "LOGIN", disabled: true },
50
51
  ];
51
52
 
@@ -1,4 +1,5 @@
1
1
  import {
2
+ clearCache,
2
3
  layoutNextLine,
3
4
  prepareWithSegments,
4
5
  type LayoutCursor,
@@ -78,14 +79,18 @@ export function layoutAllTextLines({
78
79
  linePaintHeight,
79
80
  fontSize,
80
81
  fontFamily,
81
- }: Omit<LayoutTextLinesOptions, "cursor" | "maxHeight" | "obstacles">): TextLine[] {
82
+ obstacles = [],
83
+ }: Omit<LayoutTextLinesOptions, "cursor" | "maxHeight" | "obstacles"> & { obstacles?: TextObstacle[] }): TextLine[] {
82
84
  const lines: TextLine[] = [];
83
85
  let cursor: LayoutCursor = { segmentIndex: 0, graphemeIndex: 0 };
84
86
 
85
87
  for (let lineIndex = 0; lineIndex < 2000; lineIndex += 1) {
86
- const line = layoutNextLine(prepared, cursor, maxWidth);
88
+ const y = lineIndex * lineHeight;
89
+ const slot = getAvailableSlot(maxWidth, y, linePaintHeight, obstacles);
90
+ if (!slot) continue;
91
+ const line = layoutNextLine(prepared, cursor, slot.width);
87
92
  if (!line) return lines;
88
- lines.push(toTextLine(line, 0, lineIndex * lineHeight, fontSize, fontFamily, lineHeight, linePaintHeight));
93
+ lines.push(toTextLine(line, slot.x, y, fontSize, fontFamily, lineHeight, linePaintHeight));
89
94
  cursor = line.end;
90
95
  }
91
96
 
@@ -177,3 +182,7 @@ function toTextLine(line: LayoutLine, x: number, y: number, fontSize: number, fo
177
182
  function clamp(value: number, min: number, max: number): number {
178
183
  return Math.max(min, Math.min(max, value));
179
184
  }
185
+
186
+ export function clearPretextMeasurementCache(): void {
187
+ clearCache();
188
+ }
@@ -0,0 +1,89 @@
1
+ import { z } from "zod";
2
+ import { APPROVED_PRIMING_PROOF_REPORTS } from "./approved-priming-proof-reports";
3
+
4
+ export const PRIMING_PROOF_REPORT_VERSION = "cyclotron-priming-proof/v1";
5
+
6
+ const nullableRatio = z.number().min(0).max(1).nullable();
7
+
8
+ const primingProofPointSchema = z.object({
9
+ labels: z.number().int().min(0),
10
+ labelsByClass: z.record(z.string(), z.number().int().min(0)),
11
+ decided: z.number().int().min(0),
12
+ agreement: nullableRatio,
13
+ precision: nullableRatio,
14
+ recall: nullableRatio,
15
+ confusion: z.object({
16
+ truePositives: z.number().int().min(0),
17
+ falsePositives: z.number().int().min(0),
18
+ trueNegatives: z.number().int().min(0),
19
+ falseNegatives: z.number().int().min(0),
20
+ }),
21
+ });
22
+
23
+ export const primingProofReportSchema = z.object({
24
+ version: z.literal(PRIMING_PROOF_REPORT_VERSION),
25
+ proofId: z.string().min(1),
26
+ publication: z.string().min(1),
27
+ cyclotronId: z.string().min(1),
28
+ question: z.string().min(1),
29
+ labels: z.array(z.string().min(1)).length(2),
30
+ positiveLabel: z.string().min(1),
31
+ decisionModel: z.string().nullable(),
32
+ ranAt: z.string().min(1),
33
+ heldOut: z.object({
34
+ size: z.number().int().min(1),
35
+ byLabel: z.record(z.string(), z.number().int().min(0)),
36
+ fingerprint: z.string().min(1),
37
+ frozenAt: z.string().min(1),
38
+ source: z.string().min(1),
39
+ }),
40
+ priming: z.object({
41
+ acceptedExamples: z.number().int().min(0),
42
+ excludedBecauseInHeldOut: z.number().int().min(0),
43
+ fingerprint: z.string().nullable(),
44
+ order: z.string().min(1),
45
+ }),
46
+ conditions: z.object({
47
+ freshStorePerPoint: z.boolean(),
48
+ rubric: z.string().min(1),
49
+ optimizer: z.boolean(),
50
+ reviewProgram: z.boolean(),
51
+ }),
52
+ points: z.array(primingProofPointSchema).min(1),
53
+ });
54
+
55
+ export type PrimingProofReport = z.infer<typeof primingProofReportSchema>;
56
+ export type PrimingProofPoint = z.infer<typeof primingProofPointSchema>;
57
+
58
+ export type PrimingProofHeadline = {
59
+ baseline: PrimingProofPoint;
60
+ latest: PrimingProofPoint;
61
+ agreementChangeInPoints: number | null;
62
+ };
63
+
64
+ export function parsePrimingProofReport(value: unknown): PrimingProofReport {
65
+ const report = primingProofReportSchema.parse(value);
66
+ if (report.points[0].labels !== 0) {
67
+ throw new Error("A priming proof report must start with the zero-label baseline.");
68
+ }
69
+ return report;
70
+ }
71
+
72
+ export function approvedPrimingProofReport(brandId: string): PrimingProofReport | null {
73
+ const approvedReport = APPROVED_PRIMING_PROOF_REPORTS[brandId];
74
+ return approvedReport === undefined ? null : parsePrimingProofReport(approvedReport);
75
+ }
76
+
77
+ export function primingProofHeadline(report: PrimingProofReport): PrimingProofHeadline {
78
+ const baseline = report.points[0];
79
+ const latest = report.points[report.points.length - 1];
80
+ const agreementChangeInPoints =
81
+ baseline.agreement === null || latest.agreement === null
82
+ ? null
83
+ : Math.round((latest.agreement - baseline.agreement) * 1000) / 10;
84
+ return { baseline, latest, agreementChangeInPoints };
85
+ }
86
+
87
+ export function formatProofPercentage(ratio: number | null): string {
88
+ return ratio === null ? "n/a" : `${Math.round(ratio * 1000) / 10}%`;
89
+ }
@@ -0,0 +1,303 @@
1
+
2
+ import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
3
+ import { defaultProvider } from "@aws-sdk/credential-provider-node";
4
+ import { SignatureV4 } from "@smithy/signature-v4";
5
+ import { HttpRequest } from "@smithy/protocol-http";
6
+ import { Sha256 } from "@aws-crypto/sha256-js";
7
+ import { CognitoJwtVerifier } from "aws-jwt-verify";
8
+ import type { JwksCache } from "aws-jwt-verify/jwk";
9
+
10
+ /**
11
+ * A Papyrus site's server-side view of Prospectus, the single account service.
12
+ *
13
+ * The site signs requests with its own runtime role (the Amplify SSR compute role
14
+ * registered in Prospectus). People sign in through the site's Google-only Cognito
15
+ * client; Prospectus binds that sign-in to a canonical person through a one-time
16
+ * challenge nonce. Papyrus keeps no account or membership tables: every answer here
17
+ * comes from Prospectus, and publication-level grants are never inferred from an
18
+ * account role.
19
+ */
20
+
21
+ export type ProspectusRole = "owner" | "admin" | "member" | "viewer";
22
+ export type ProspectusOperation = "console:read" | "console:write" | "members:read" | "members:write";
23
+
24
+ export interface ProspectusPerson {
25
+ id: string;
26
+ createdAt: string;
27
+ }
28
+
29
+ export interface ProspectusAccountAuthorization {
30
+ allowed: true;
31
+ accountId: string;
32
+ personId: string;
33
+ product: string;
34
+ role: ProspectusRole;
35
+ operation: ProspectusOperation;
36
+ revision: number;
37
+ }
38
+
39
+ export interface ProspectusDirectoryAccount {
40
+ id: string;
41
+ name: string;
42
+ role: ProspectusRole;
43
+ products: Array<{ product: string; status: "active" | "disabled" }>;
44
+ }
45
+
46
+ export interface ProspectusRequest {
47
+ path: string;
48
+ method: "GET" | "POST";
49
+ body?: unknown;
50
+ humanAccessToken?: string;
51
+ identityToken?: string;
52
+ }
53
+
54
+ export type ProspectusTransport = (request: ProspectusRequest) => Promise<unknown>;
55
+
56
+ export class ProspectusAccountsError extends Error {
57
+ constructor(
58
+ readonly status: number,
59
+ readonly code: string,
60
+ message: string,
61
+ ) {
62
+ super(message);
63
+ }
64
+ }
65
+
66
+ export interface TemporaryRuntimeCredentials {
67
+ accessKeyId: string;
68
+ secretAccessKey: string;
69
+ sessionToken?: string;
70
+ expiration?: Date;
71
+ }
72
+
73
+ export interface SignedProspectusTransportOptions {
74
+ endpoint: string;
75
+ region: string;
76
+ runtimeCredentials?: () => Promise<TemporaryRuntimeCredentials>;
77
+ fetcher?: typeof fetch;
78
+ }
79
+
80
+ export function signedProspectusTransport(options: SignedProspectusTransportOptions): ProspectusTransport {
81
+ const endpoint = new URL(options.endpoint);
82
+ if (endpoint.protocol !== "https:" || endpoint.username || endpoint.password || endpoint.search || endpoint.hash) {
83
+ throw new Error("Use an HTTPS Prospectus accounts endpoint.");
84
+ }
85
+ const runtimeCredentials = options.runtimeCredentials ?? defaultProvider();
86
+ const fetcher = options.fetcher ?? fetch;
87
+ return async ({ path, method, body, humanAccessToken, identityToken }) => {
88
+ const credentials = await runtimeCredentials();
89
+ if (!credentials.sessionToken || (credentials.expiration && credentials.expiration.getTime() <= Date.now())) {
90
+ throw new Error("Prospectus calls require the site's temporary runtime credentials; static access keys are refused.");
91
+ }
92
+ const url = new URL(`${endpoint.toString().replace(/\/$/, "")}${path}`);
93
+ const payload = body === undefined ? undefined : JSON.stringify(body);
94
+ const headers: Record<string, string> = {
95
+ host: url.host,
96
+ ...(humanAccessToken ? { "x-prospectus-human-token": humanAccessToken } : {}),
97
+ ...(identityToken ? { "x-prospectus-identity-token": identityToken } : {}),
98
+ ...(payload ? { "content-type": "application/json" } : {}),
99
+ };
100
+ const signer = new SignatureV4({ service: "execute-api", region: options.region, credentials, sha256: Sha256 });
101
+ const signed = await signer.sign(
102
+ new HttpRequest({ protocol: url.protocol, hostname: url.hostname, path: url.pathname, method, headers, body: payload }),
103
+ );
104
+ const response = await fetcher(url, { method, headers: signed.headers, body: payload, cache: "no-store", redirect: "error" });
105
+ const responseBody = await response.json().catch(() => ({}));
106
+ if (!response.ok) {
107
+ throw new ProspectusAccountsError(response.status, responseBody.error ?? "request_failed", responseBody.message ?? "Prospectus request failed.");
108
+ }
109
+ return responseBody;
110
+ };
111
+ }
112
+
113
+ export class ProspectusSiteAccounts {
114
+ constructor(private readonly transport: ProspectusTransport) {}
115
+
116
+ requestGoogleSignInChallenge(): Promise<{ challengeId: string; nonce: string; expiresAt: number }> {
117
+ return this.transport({ path: "/v1/identities/google/challenge", method: "POST", body: {} }) as Promise<{
118
+ challengeId: string;
119
+ nonce: string;
120
+ expiresAt: number;
121
+ }>;
122
+ }
123
+
124
+ resolveGoogleSignIn(challengeId: string, humanAccessToken: string, identityToken: string): Promise<{ person: ProspectusPerson }> {
125
+ return this.transport({
126
+ path: "/v1/identities/google/resolve",
127
+ method: "POST",
128
+ body: { challengeId },
129
+ humanAccessToken,
130
+ identityToken,
131
+ }) as Promise<{ person: ProspectusPerson }>;
132
+ }
133
+
134
+ currentPerson(humanAccessToken: string): Promise<{ person: ProspectusPerson | null }> {
135
+ return this.transport({ path: "/v1/identities/me", method: "GET", humanAccessToken }) as Promise<{ person: ProspectusPerson | null }>;
136
+ }
137
+
138
+ authorizeAccountOperation(humanAccessToken: string, accountId: string, operation: ProspectusOperation): Promise<ProspectusAccountAuthorization> {
139
+ return this.transport({
140
+ path: `/v1/accounts/${encodeURIComponent(accountId)}/authorize`,
141
+ method: "POST",
142
+ body: { operation },
143
+ humanAccessToken,
144
+ }) as Promise<ProspectusAccountAuthorization>;
145
+ }
146
+
147
+ accountDirectory(humanAccessToken: string): Promise<{ person: ProspectusPerson | null; accounts: ProspectusDirectoryAccount[] }> {
148
+ return this.transport({ path: "/v1/account-directory", method: "GET", humanAccessToken }) as Promise<{
149
+ person: ProspectusPerson | null;
150
+ accounts: ProspectusDirectoryAccount[];
151
+ }>;
152
+ }
153
+ }
154
+
155
+ export interface GoogleOnlyCognitoClient {
156
+ hostedUiOrigin: string;
157
+ userPoolId: string;
158
+ clientId: string;
159
+ redirectUri: string;
160
+ }
161
+
162
+ export interface PendingProspectusSignIn {
163
+ challengeId: string;
164
+ nonce: string;
165
+ state: string;
166
+ codeVerifier: string;
167
+ expiresAt: number;
168
+ }
169
+
170
+ export interface CognitoTokens {
171
+ accessToken: string;
172
+ idToken: string;
173
+ refreshToken?: string;
174
+ expiresInSeconds: number;
175
+ }
176
+
177
+ export type CognitoTokenVerifier = (tokens: CognitoTokens, expectedNonce: string) => Promise<void>;
178
+
179
+ function base64UrlRandom(byteLength: number): string {
180
+ return randomBytes(byteLength).toString("base64url");
181
+ }
182
+
183
+ function constantTimeEquals(left: string, right: string): boolean {
184
+ const leftBytes = Buffer.from(left);
185
+ const rightBytes = Buffer.from(right);
186
+ return leftBytes.length === rightBytes.length && timingSafeEqual(leftBytes, rightBytes);
187
+ }
188
+
189
+ export async function beginProspectusGoogleSignIn(
190
+ accounts: ProspectusSiteAccounts,
191
+ cognitoClient: GoogleOnlyCognitoClient,
192
+ ): Promise<{ authorizationUrl: string; pendingSignIn: PendingProspectusSignIn }> {
193
+ const challenge = await accounts.requestGoogleSignInChallenge();
194
+ const state = base64UrlRandom(32);
195
+ const codeVerifier = base64UrlRandom(48);
196
+ const authorizationUrl = new URL("/oauth2/authorize", cognitoClient.hostedUiOrigin);
197
+ authorizationUrl.search = new URLSearchParams({
198
+ response_type: "code",
199
+ client_id: cognitoClient.clientId,
200
+ redirect_uri: cognitoClient.redirectUri,
201
+ scope: "openid email profile",
202
+ identity_provider: "Google",
203
+ state,
204
+ nonce: challenge.nonce,
205
+ code_challenge: createHash("sha256").update(codeVerifier).digest("base64url"),
206
+ code_challenge_method: "S256",
207
+ }).toString();
208
+ return {
209
+ authorizationUrl: authorizationUrl.toString(),
210
+ pendingSignIn: { challengeId: challenge.challengeId, nonce: challenge.nonce, state, codeVerifier, expiresAt: challenge.expiresAt },
211
+ };
212
+ }
213
+
214
+ export function cognitoTokenVerifier(cognitoClient: GoogleOnlyCognitoClient, signingKeyCache?: JwksCache): CognitoTokenVerifier {
215
+ const verifierOptions = signingKeyCache ? { jwksCache: signingKeyCache } : undefined;
216
+ const accessTokenVerifier = CognitoJwtVerifier.create({ userPoolId: cognitoClient.userPoolId, tokenUse: "access", clientId: cognitoClient.clientId }, verifierOptions);
217
+ const identityTokenVerifier = CognitoJwtVerifier.create({ userPoolId: cognitoClient.userPoolId, tokenUse: "id", clientId: cognitoClient.clientId }, verifierOptions);
218
+ return async (tokens, expectedNonce) => {
219
+ const accessClaims = await accessTokenVerifier.verify(tokens.accessToken);
220
+ const identityClaims = await identityTokenVerifier.verify(tokens.idToken);
221
+ if (identityClaims.nonce !== expectedNonce) throw new ProspectusAccountsError(401, "nonce_mismatch", "Sign-in did not carry this site's Prospectus challenge.");
222
+ if (identityClaims.sub !== accessClaims.sub) throw new ProspectusAccountsError(401, "token_pair_mismatch", "Access and identity tokens describe different people.");
223
+ };
224
+ }
225
+
226
+ export interface CompleteProspectusGoogleSignInInput {
227
+ accounts: ProspectusSiteAccounts;
228
+ cognitoClient: GoogleOnlyCognitoClient;
229
+ pendingSignIn: PendingProspectusSignIn;
230
+ returnedState: string;
231
+ authorizationCode: string;
232
+ verifyCognitoTokens?: CognitoTokenVerifier;
233
+ fetcher?: typeof fetch;
234
+ nowInSeconds?: number;
235
+ }
236
+
237
+ export async function completeProspectusGoogleSignIn(
238
+ input: CompleteProspectusGoogleSignInInput,
239
+ ): Promise<{ person: ProspectusPerson; accessToken: string; refreshToken?: string; accessTokenExpiresAt: number }> {
240
+ const nowInSeconds = input.nowInSeconds ?? Math.floor(Date.now() / 1000);
241
+ if (nowInSeconds >= input.pendingSignIn.expiresAt) {
242
+ throw new ProspectusAccountsError(401, "sign_in_expired", "Sign-in took too long. Start again.");
243
+ }
244
+ if (!constantTimeEquals(input.returnedState, input.pendingSignIn.state)) {
245
+ throw new ProspectusAccountsError(401, "state_mismatch", "Sign-in response does not belong to this browser's sign-in.");
246
+ }
247
+ const tokenResponse = await (input.fetcher ?? fetch)(new URL("/oauth2/token", input.cognitoClient.hostedUiOrigin), {
248
+ method: "POST",
249
+ headers: { "content-type": "application/x-www-form-urlencoded" },
250
+ body: new URLSearchParams({
251
+ grant_type: "authorization_code",
252
+ client_id: input.cognitoClient.clientId,
253
+ code: input.authorizationCode,
254
+ redirect_uri: input.cognitoClient.redirectUri,
255
+ code_verifier: input.pendingSignIn.codeVerifier,
256
+ }).toString(),
257
+ cache: "no-store",
258
+ redirect: "error",
259
+ });
260
+ const tokenBody = await tokenResponse.json().catch(() => ({}));
261
+ if (!tokenResponse.ok || typeof tokenBody.access_token !== "string" || typeof tokenBody.id_token !== "string") {
262
+ throw new ProspectusAccountsError(401, "code_exchange_failed", "Cognito did not complete the sign-in.");
263
+ }
264
+ const tokens: CognitoTokens = {
265
+ accessToken: tokenBody.access_token,
266
+ idToken: tokenBody.id_token,
267
+ refreshToken: typeof tokenBody.refresh_token === "string" ? tokenBody.refresh_token : undefined,
268
+ expiresInSeconds: Number(tokenBody.expires_in) || 0,
269
+ };
270
+ await (input.verifyCognitoTokens ?? cognitoTokenVerifier(input.cognitoClient))(tokens, input.pendingSignIn.nonce);
271
+ const { person } = await input.accounts.resolveGoogleSignIn(input.pendingSignIn.challengeId, tokens.accessToken, tokens.idToken);
272
+ return {
273
+ person,
274
+ accessToken: tokens.accessToken,
275
+ refreshToken: tokens.refreshToken,
276
+ accessTokenExpiresAt: nowInSeconds + tokens.expiresInSeconds,
277
+ };
278
+ }
279
+
280
+ export async function authorizeSignedInPersonForAccount(
281
+ accounts: ProspectusSiteAccounts,
282
+ humanAccessToken: string,
283
+ accountId: string,
284
+ operation: ProspectusOperation,
285
+ ): Promise<ProspectusAccountAuthorization> {
286
+ const { person } = await accounts.currentPerson(humanAccessToken);
287
+ if (!person) throw new ProspectusAccountsError(401, "unknown_person", "Sign in again: Prospectus does not recognize this session.");
288
+ const authorization = await accounts.authorizeAccountOperation(humanAccessToken, accountId, operation);
289
+ if (authorization.allowed !== true || authorization.personId !== person.id || authorization.accountId !== accountId || authorization.operation !== operation) {
290
+ throw new ProspectusAccountsError(403, "not_authorized", "Prospectus did not authorize this account operation.");
291
+ }
292
+ return authorization;
293
+ }
294
+
295
+ export const CYCLOTRON_LAB_PRODUCT = "cyclotron";
296
+
297
+ export async function signedInPersonHasCyclotronLabAccess(accounts: ProspectusSiteAccounts, humanAccessToken: string): Promise<boolean> {
298
+ const directory = await accounts.accountDirectory(humanAccessToken);
299
+ if (!directory.person) return false;
300
+ return directory.accounts.some((account) =>
301
+ account.products.some((product) => product.product === CYCLOTRON_LAB_PRODUCT && product.status === "active"),
302
+ );
303
+ }
@@ -0,0 +1,74 @@
1
+ import type {CyclotronDecision,CyclotronReview} from 'cyclotron/sdk-types';
2
+ import type {CyclotronStatus} from 'cyclotron/cyclotron-status';
3
+ import type {ReviewControlReview} from 'cyclotron/components/review-control';
4
+
5
+ export type PublicationScope={accountId:string;publicationId:string;bindingRevision:string};
6
+ export type PublicationEnvelope=PublicationScope&{contractVersion:'cyclotron-publication/v1';publicationId:string;bindingRevision:string;features?:{frozenContext:boolean;replayExport:boolean;steering:boolean};outputs:Array<{id:string;question:string;classes:string[];positiveLabel:string|null}>};
7
+ export type PublicationStatus=PublicationEnvelope&{classifiers:Record<string,CyclotronStatus>};
8
+ export type PublicationRevisionRef={listId:string;itemId:string;revision:number;contentHash:string};
9
+ export type PublicationContext={contextId:string;decisionId:string;publicationId:string;sourceRef:PublicationRevisionRef;bindingRef:PublicationRevisionRef;source:{id:string;values:Record<string,unknown>};binding:{values:{accountId:string;publicationId:string;revision:string;outputs:string[]}}};
10
+ export type PublicationHistoryRow={decision:CyclotronDecision;item:Record<string,unknown>;state?:string;reviews:Record<string,CyclotronReview>;context?:PublicationContext;historicalContext?:boolean;provenanceUnavailable?:boolean;feedbackReconciliationRequired?:boolean};
11
+ export type PublicationHistory=PublicationEnvelope&{history:PublicationHistoryRow[]};
12
+ export type PublicationReviewPayload={bindingRevision:string;decisionId:string;classifier:string;predictionVersion:number;predictionFingerprint:string;requestId:string;label:string;explanation:string;reasonCode:string|null;contextId?:string};
13
+ export type PublicationSteeringInput={requestId:string;explanation:string}&({action:'evaluate';proposal:Record<string,unknown>}|{action:'promote';trialFingerprint:string});
14
+ export type PublicationSteeringPayload=PublicationSteeringInput&{bindingRevision:string;bindingRef:PublicationRevisionRef;classifier:string;expectedFingerprint:string};
15
+
16
+ function ordered(value:unknown):unknown{
17
+ if(Array.isArray(value))return value.map(ordered);
18
+ if(value&&typeof value==='object')return Object.fromEntries(Object.entries(value).sort(([a],[b])=>a.localeCompare(b)).map(([key,item])=>[key,ordered(item)]));
19
+ return value;
20
+ }
21
+
22
+ /** Stable cache/component identity; length-safe even when IDs contain delimiters. */
23
+ export function publicationScopeKey(scope:PublicationScope):string{
24
+ if([scope.accountId,scope.publicationId,scope.bindingRevision].some(value=>typeof value!=='string'||!value.trim()))throw new Error('Complete publication account scope required');
25
+ return JSON.stringify([scope.accountId,scope.publicationId,scope.bindingRevision]);
26
+ }
27
+
28
+ export function assertPublicationScope(status:PublicationStatus,history:PublicationHistory){
29
+ if(status.contractVersion!=='cyclotron-publication/v1'||history.contractVersion!==status.contractVersion||publicationScopeKey(status)!==publicationScopeKey(history)||status.publicationId!==history.publicationId||status.bindingRevision!==history.bindingRevision)throw new Error('Publication snapshot binding mismatch');
30
+ const outputs=new Set(status.outputs.map(output=>output.id));
31
+ if(!outputs.size||outputs.size!==status.outputs.length||status.outputs.some(output=>status.classifiers[output.id]?.cyclotron.classifier!==output.id)||JSON.stringify(history.outputs)!==JSON.stringify(status.outputs))throw new Error('Publication snapshot is incomplete');
32
+ if(Object.keys(status.classifiers).some(id=>!outputs.has(id))||history.outputs.some(output=>!outputs.has(output.id))||history.history.some(row=>[...Object.keys(row.decision.classifiers),...Object.keys(row.reviews)].some(id=>!outputs.has(id))))throw new Error('Snapshot includes an output outside this publication');
33
+ if(history.history.some(row=>row.context&&(row.context.binding.values.accountId!==status.accountId||row.context.publicationId!==status.publicationId||row.context.decisionId!==row.decision.decisionId||JSON.stringify(ordered(row.context.source.values))!==JSON.stringify(ordered(row.item)))))throw new Error('Historical source context mismatch');
34
+ }
35
+
36
+ /** Explicit output target and incumbent pin; never a global review-rate mutation. */
37
+ export function publicationSteeringPayload(status:PublicationStatus,classifier:string,bindingRef:PublicationRevisionRef,command:PublicationSteeringInput):PublicationSteeringPayload{
38
+ if(!status.outputs.some(output=>output.id===classifier)||!status.classifiers[classifier])throw new Error('Steering output is outside this publication');
39
+ if(!command.requestId.trim()||!command.explanation.trim())throw new Error('Steering needs a stable identity and explicit explanation');
40
+ const fingerprint=status.classifiers[classifier].cyclotron.fingerprint;
41
+ if(!fingerprint)throw new Error('Active classifier fingerprint unavailable');
42
+ if(command.action==='evaluate'){
43
+ if(!Object.keys(command.proposal).length||Object.keys(command.proposal).some(key=>!['rubric','tasks','example_ids','dynamic_elements'].includes(key)))throw new Error('Native proposal fields required');
44
+ }else if(command.action!=='promote'||!command.trialFingerprint.trim())throw new Error('An explicitly evaluated trial is required');
45
+ return {...command,explanation:command.explanation.trim(),bindingRevision:status.bindingRevision,bindingRef,classifier,expectedFingerprint:fingerprint};
46
+ }
47
+
48
+ export function publicationReviewPayload(binding:PublicationEnvelope,row:PublicationHistoryRow,classifier:string,review:ReviewControlReview,requestId:string):PublicationReviewPayload{
49
+ if(row.context&&row.context.binding.values.accountId!==binding.accountId)throw new Error('Review account context mismatch');
50
+ if(row.historicalContext||row.provenanceUnavailable||row.feedbackReconciliationRequired||row.context&&row.context.binding.values.revision!==binding.bindingRevision)throw new Error('Historical source or binding revision is read-only');
51
+ const output=binding.outputs.find(output=>output.id===classifier);const prediction=row.decision.classifiers[classifier];
52
+ if(!output||!prediction||review.decisionId!==row.decision.decisionId||review.itemId!==row.decision.itemId)throw new Error('Review does not match the displayed publication decision');
53
+ if(!review.label||!output.classes.includes(review.label))throw new Error('Choose an explicit classification label');
54
+ if(!requestId.trim())throw new Error('A stable review request identity is required');
55
+ return {bindingRevision:binding.bindingRevision,decisionId:row.decision.decisionId,classifier,predictionVersion:prediction.version,predictionFingerprint:prediction.fingerprint,requestId,label:review.label,explanation:review.explanation?.trim()??'',reasonCode:review.reasonCode,...(row.context?{contextId:row.context.contextId}:{})};
56
+ }
57
+
58
+ /** Host chooses an authenticated same-origin BFF; storage mode never changes UI semantics. */
59
+ export function createPublicationClient(scope:PublicationScope,request:(operation:string,payload:Record<string,unknown>,scope:Readonly<PublicationScope>)=>Promise<PublicationEnvelope&Record<string,unknown>>,currentScope:()=>PublicationScope|null){
60
+ const pinned=Object.freeze({accountId:scope.accountId,publicationId:scope.publicationId,bindingRevision:scope.bindingRevision});
61
+ const key=publicationScopeKey(pinned);
62
+ const assertSelected=()=>{const current=currentScope();if(!current||publicationScopeKey(current)!==key)throw new Error('Publication account selection changed; refresh context');};
63
+ const command=async(operation:string,payload:Record<string,unknown>)=>{
64
+ assertSelected();
65
+ if('bindingRevision' in payload&&payload.bindingRevision!==pinned.bindingRevision)throw new Error('Publication binding changed; refresh context');
66
+ const value=await request(operation,payload,pinned);
67
+ assertSelected();
68
+ if(value.contractVersion!=='cyclotron-publication/v1'||publicationScopeKey(value)!==key)throw new Error('Publication response contract mismatch');
69
+ return value;
70
+ };
71
+ return {scope:pinned,cacheKey:key,command,status:async()=>await command('status',{}) as PublicationStatus,history:async()=>await command('history',{}) as PublicationHistory,
72
+ review:async(payload:PublicationReviewPayload)=>command('review',payload),
73
+ steer:async(payload:PublicationSteeringPayload)=>command('steer',payload),replayRecords:async()=>command('replay',{})};
74
+ }
@@ -0,0 +1,55 @@
1
+ export const RATE_LIMIT_RETRY_ATTEMPTS = 14;
2
+ export const RATE_LIMIT_RETRY_BASE_MILLISECONDS = 500;
3
+ export const RATE_LIMIT_RETRY_CAP_MILLISECONDS = 20_000;
4
+ export const RATE_LIMIT_RETRY_BUDGET_MILLISECONDS = 120_000;
5
+
6
+ export function isRateLimitError(error: unknown): boolean {
7
+ if (!error || typeof error !== "object") return false;
8
+ const { name, message } = error as { name?: unknown; message?: unknown };
9
+ const text = `${typeof name === "string" ? name : ""} ${typeof message === "string" ? message : ""}`;
10
+ return /TooManyRequests|ThrottlingException|Rate exceeded|Unauthorized[\s\S]*set the \\?"authMode\\?"/i.test(text);
11
+ }
12
+
13
+ export type RateLimitRetryOptions = {
14
+ attempts?: number;
15
+ baseMilliseconds?: number;
16
+ capMilliseconds?: number;
17
+ budgetMilliseconds?: number;
18
+ sleep?: (milliseconds: number) => Promise<void>;
19
+ random?: () => number;
20
+ now?: () => number;
21
+ };
22
+
23
+ export function fullJitterDelayMilliseconds(
24
+ attempt: number,
25
+ baseMilliseconds: number,
26
+ capMilliseconds: number,
27
+ random: () => number,
28
+ ): number {
29
+ const ceiling = Math.min(capMilliseconds, baseMilliseconds * 2 ** (attempt - 1));
30
+ return random() * ceiling;
31
+ }
32
+
33
+ export async function withRateLimitRetry<T>(
34
+ operation: () => Promise<T>,
35
+ options: RateLimitRetryOptions = {},
36
+ ): Promise<T> {
37
+ const attempts = options.attempts ?? RATE_LIMIT_RETRY_ATTEMPTS;
38
+ const baseMilliseconds = options.baseMilliseconds ?? RATE_LIMIT_RETRY_BASE_MILLISECONDS;
39
+ const capMilliseconds = options.capMilliseconds ?? RATE_LIMIT_RETRY_CAP_MILLISECONDS;
40
+ const budgetMilliseconds = options.budgetMilliseconds ?? RATE_LIMIT_RETRY_BUDGET_MILLISECONDS;
41
+ const sleep = options.sleep ?? ((milliseconds: number) => new Promise<void>((resolve) => setTimeout(resolve, milliseconds)));
42
+ const random = options.random ?? Math.random;
43
+ const now = options.now ?? Date.now;
44
+ const startedAt = now();
45
+ for (let attempt = 1; ; attempt += 1) {
46
+ try {
47
+ return await operation();
48
+ } catch (error) {
49
+ if (attempt >= attempts || !isRateLimitError(error)) throw error;
50
+ const delay = fullJitterDelayMilliseconds(attempt, baseMilliseconds, capMilliseconds, random);
51
+ if (now() - startedAt + delay > budgetMilliseconds) throw error;
52
+ await sleep(delay);
53
+ }
54
+ }
55
+ }
@@ -0,0 +1,24 @@
1
+ import { SITE_BRAND } from "./site-brand";
2
+
3
+ export function normalizeReaderBasePath(value: string | undefined | null): string {
4
+ const trimmed = (value ?? "").trim().replace(/\/+$/, "");
5
+ if (!trimmed) return "";
6
+ return trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
7
+ }
8
+
9
+ export function getReaderBasePath(): string {
10
+ return normalizeReaderBasePath(SITE_BRAND.readerBasePath);
11
+ }
12
+
13
+ export function toPublicReaderPath(path: string, readerBasePath: string = getReaderBasePath()): string {
14
+ if (!readerBasePath) return path;
15
+ if (path === "/" || path === "") return readerBasePath;
16
+ return `${readerBasePath}${path.startsWith("/") ? path : `/${path}`}`;
17
+ }
18
+
19
+ export function toInternalReaderPath(path: string, readerBasePath: string = getReaderBasePath()): string {
20
+ if (!readerBasePath) return path;
21
+ if (path === readerBasePath || path === `${readerBasePath}/`) return "/";
22
+ if (path.startsWith(`${readerBasePath}/`)) return path.slice(readerBasePath.length);
23
+ return path;
24
+ }
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Seconds a Pretext reader page may be served from a shared cache (Next sends `s-maxage` equal to its `revalidate`).
3
+ * Next needs a literal in each route's `export const revalidate`, so scripts/test-reader-cache-lifetime.ts keeps them equal to this.
4
+ */
5
+ export const READER_PAGE_REVALIDATE_SECONDS = 60;