@anthusai/papyrus 2.0.0-next.4 → 2.0.0-next.41

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 (144) hide show
  1. package/amplify/data/resource.js +23 -3
  2. package/amplify/data/resource.ts +25 -3
  3. package/amplify/data/schema.js +2030 -1967
  4. package/amplify/data/schema.ts +73 -2
  5. package/amplify/functions/assignment-action/data-access.js +88 -0
  6. package/amplify/functions/assignment-action/data-access.ts +87 -0
  7. package/amplify/functions/assignment-action/handler.ts +1 -1
  8. package/amplify/functions/content-actions/handler.py +36 -0
  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/functions/editorial-checks/handler.py +14 -0
  13. package/amplify/functions/editorial-checks/requirements.txt +1 -0
  14. package/amplify/functions/editorial-checks/resource.js +43 -0
  15. package/amplify/functions/editorial-checks/resource.ts +60 -0
  16. package/amplify/functions/editorial-checks/schema.js +121 -0
  17. package/amplify/functions/editorial-checks/schema.ts +123 -0
  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 +9 -3
  37. package/amplify/functions/shared/python-bundle.js +20 -14
  38. package/amplify/functions/shared/python-bundle.ts +25 -18
  39. package/amplify/plugins/reader-steering/config.js +110 -0
  40. package/amplify/plugins/reader-steering/config.ts +153 -0
  41. package/amplify/plugins/reader-steering/function/handler.py +544 -0
  42. package/amplify/plugins/reader-steering/function/resource.js +27 -0
  43. package/amplify/plugins/reader-steering/function/resource.ts +35 -0
  44. package/amplify/plugins/reader-steering/papyrus.plugin.js +103 -0
  45. package/amplify/plugins/reader-steering/papyrus.plugin.ts +122 -0
  46. package/amplify/plugins/reader-steering/schema.js +40 -0
  47. package/amplify/plugins/reader-steering/schema.ts +42 -0
  48. package/amplify/plugins/registry.js +2 -0
  49. package/amplify/plugins/registry.ts +2 -0
  50. package/amplify/plugins/types.ts +3 -0
  51. package/amplify/plugins/wire.js +1 -0
  52. package/amplify/plugins/wire.ts +1 -0
  53. package/amplify/publication/redirect-and-index.js +105 -0
  54. package/amplify/publication/redirect-rules.js +73 -0
  55. package/amplify/publication/redirect-rules.ts +93 -0
  56. package/amplify/publication/s3-projection.js +7 -1
  57. package/amplify/publication/s3-projection.ts +9 -1
  58. package/amplify/site-backend-config.ts +17 -0
  59. package/amplify/site-backend.js +36 -1
  60. package/amplify/site-backend.ts +44 -1
  61. package/app/api/newsroom/curation/context/route.ts +9 -0
  62. package/app/api/newsroom/curation/priming/route.ts +9 -0
  63. package/app/api/newsroom/curation/publication/route.ts +9 -0
  64. package/app/api/reader/handle/route.ts +9 -0
  65. package/app/api/reader/me/route.ts +9 -0
  66. package/app/api/reader/rating/route.ts +9 -0
  67. package/app/api/reader/retired/route.ts +9 -0
  68. package/app/api/reader/vote/route.ts +9 -0
  69. package/bin/papyrus-infra.mjs +20 -1
  70. package/components/editorial-checks-admin-panel.tsx +362 -0
  71. package/components/editorial-finding-card.tsx +162 -0
  72. package/components/editorial-findings-panel.tsx +91 -0
  73. package/components/editorial-preview-popover.tsx +103 -0
  74. package/components/markus-body-editor.tsx +213 -0
  75. package/components/markus-ir-preview.tsx +147 -6
  76. package/components/monitoring-overview-view.tsx +241 -0
  77. package/components/monitoring-panel.tsx +40 -0
  78. package/components/newsroom-article-editor.tsx +136 -11
  79. package/components/newsroom-board-view.tsx +6 -7
  80. package/components/newsroom-curation-bootstrap.tsx +45 -0
  81. package/components/newsroom-ops-shell.tsx +7 -3
  82. package/components/newsroom-organizer.css +74 -61
  83. package/components/newsroom-references-view.tsx +14 -14
  84. package/components/newsroom-theme-fonts/broadsheet-green.css +10 -0
  85. package/components/newsroom-theme-fonts/broadsheet-green.tsx +6 -0
  86. package/components/newsroom-theme-fonts/newspaper-gothic.css +9 -0
  87. package/components/newsroom-theme-fonts/newspaper-gothic.tsx +6 -0
  88. package/components/newsroom-theme-fonts/night-desk.css +10 -0
  89. package/components/newsroom-theme-fonts/night-desk.tsx +6 -0
  90. package/components/newsroom-theme-fonts/refined-newsprint.css +11 -0
  91. package/components/newsroom-theme-fonts/refined-newsprint.tsx +6 -0
  92. package/components/newsroom-theme.css +39 -0
  93. package/components/newsroom-theme.tsx +33 -0
  94. package/components/publication-cyclotron-panel.tsx +15 -10
  95. package/components/reference-add-entry.tsx +75 -0
  96. package/components/reference-curation-controls.tsx +31 -22
  97. package/components/reference-cyclotron-priming.tsx +58 -19
  98. package/components/reference-cyclotron-suggestion.tsx +1 -1
  99. package/components/reference-priming.tsx +14 -10
  100. package/components/reference-section-suggestion.tsx +1 -1
  101. package/components/topic-steering-workspace.tsx +27 -2
  102. package/components/use-editorial-checks.ts +133 -0
  103. package/infra/amplify-app-shell.js +90 -16
  104. package/infra/amplify-app-shell.ts +102 -18
  105. package/infra/index.js +1 -0
  106. package/infra/secure-string-parameter-handler.js +41 -0
  107. package/infra/secure-string-parameter-handler.ts +41 -0
  108. package/infra/secure-string-parameter.js +79 -0
  109. package/infra/secure-string-parameter.ts +91 -0
  110. package/infra/site-config.js +85 -6
  111. package/infra/site-config.ts +123 -6
  112. package/lib/category-repository.ts +11 -9
  113. package/lib/curation-routes.ts +345 -0
  114. package/lib/editorial-checks-backend.ts +16 -0
  115. package/lib/editorial-checks-client.ts +212 -0
  116. package/lib/editorial-checks-demo.ts +147 -0
  117. package/lib/editorial-checks-settings.ts +27 -0
  118. package/lib/editorial-checks.ts +173 -0
  119. package/lib/editorial-preview.ts +228 -0
  120. package/lib/monitoring-client.ts +29 -0
  121. package/lib/monitoring-overview-fixture.ts +51 -0
  122. package/lib/monitoring-overview.ts +145 -0
  123. package/lib/newsroom-board.ts +27 -2
  124. package/lib/prospectus-session.ts +15 -14
  125. package/lib/prospectus-sign-in-routes.ts +54 -13
  126. package/lib/prospectus-site-identity.ts +24 -1
  127. package/lib/reader-api.ts +143 -0
  128. package/lib/reader-retirement-route.ts +89 -0
  129. package/lib/reader-subject.md +29 -0
  130. package/lib/reader-subject.ts +53 -0
  131. package/lib/relevance-status.ts +2 -2
  132. package/lib/site-brand-tenant.ts +11 -6
  133. package/lib/site-brand.ts +23 -3
  134. package/lib/site-origin.ts +67 -0
  135. package/middleware.ts +14 -3
  136. package/package.json +56 -4
  137. package/routes.manifest.json +89 -1
  138. package/components/newsroom-fonts/playfair-display-700.ttf +0 -0
  139. package/components/newsroom-fonts/playfair-display-900.ttf +0 -0
  140. package/components/newsroom-fonts/playfair-display-OFL.txt +0 -93
  141. package/components/newsroom-fonts/plus-jakarta-sans-400.ttf +0 -0
  142. package/components/newsroom-fonts/plus-jakarta-sans-600.ttf +0 -0
  143. package/components/newsroom-fonts/plus-jakarta-sans-OFL.txt +0 -93
  144. package/components/newsroom-newsprint-board.css +0 -7
@@ -0,0 +1,9 @@
1
+ import { defineFunction } from "@aws-amplify/backend";
2
+ /** The public form receiving endpoint. Wired (table, Function URL, origins, output) by `wire.ts`. */
3
+ export const publicForms = defineFunction({
4
+ name: "papyrus-public-forms",
5
+ entry: "./handler.ts",
6
+ timeoutSeconds: 10,
7
+ memoryMB: 256,
8
+ resourceGroupName: "data",
9
+ });
@@ -0,0 +1,10 @@
1
+ import { defineFunction } from "@aws-amplify/backend";
2
+
3
+ /** The public form receiving endpoint. Wired (table, Function URL, origins, output) by `wire.ts`. */
4
+ export const publicForms = defineFunction({
5
+ name: "papyrus-public-forms",
6
+ entry: "./handler.ts",
7
+ timeoutSeconds: 10,
8
+ memoryMB: 256,
9
+ resourceGroupName: "data",
10
+ });
@@ -0,0 +1,25 @@
1
+ import { RemovalPolicy, Stack } from "aws-cdk-lib";
2
+ import * as dynamodb from "aws-cdk-lib/aws-dynamodb";
3
+ import { Effect, PolicyStatement } from "aws-cdk-lib/aws-iam";
4
+ import { FunctionUrlAuthType } from "aws-cdk-lib/aws-lambda";
5
+ export const PUBLIC_FORMS_OUTPUT_KEY = "publicForms";
6
+ /**
7
+ * Wires the endpoint: the rate-limit counter table (TTL), the Function URL and the function's settings, and returns
8
+ * the URL for the `custom.publicForms.endpoint` output. Everything lives in the function's own stack.
9
+ */
10
+ export function wirePublicForms(backend, wiring) {
11
+ const lambda = backend.publicForms.resources.lambda;
12
+ const stack = Stack.of(lambda);
13
+ const table = new dynamodb.Table(stack, "PublicFormsLimits", {
14
+ partitionKey: { name: "pk", type: dynamodb.AttributeType.STRING },
15
+ billingMode: dynamodb.BillingMode.PAY_PER_REQUEST,
16
+ timeToLiveAttribute: "expiresAt",
17
+ removalPolicy: RemovalPolicy.DESTROY,
18
+ });
19
+ lambda.addToRolePolicy(new PolicyStatement({ effect: Effect.ALLOW, actions: ["dynamodb:UpdateItem"], resources: [table.tableArn] }));
20
+ backend.publicForms.addEnvironment("PAPYRUS_PUBLIC_FORMS_TABLE", table.tableName);
21
+ backend.publicForms.addEnvironment("PAPYRUS_PUBLIC_FORMS_ORIGINS", wiring.allowedOrigins.join(","));
22
+ backend.publicForms.addEnvironment("PAPYRUS_PUBLIC_FORMS_HONEYPOTS", (wiring.honeypotFields ?? []).join(","));
23
+ // CORS is answered by the handler (it must echo exactly one allowed origin), so the URL carries no CORS block.
24
+ return lambda.addFunctionUrl({ authType: FunctionUrlAuthType.NONE }).url;
25
+ }
@@ -0,0 +1,32 @@
1
+ import { RemovalPolicy, Stack } from "aws-cdk-lib";
2
+ import * as dynamodb from "aws-cdk-lib/aws-dynamodb";
3
+ import { Effect, PolicyStatement } from "aws-cdk-lib/aws-iam";
4
+ import { type Function as LambdaFunction, FunctionUrlAuthType } from "aws-cdk-lib/aws-lambda";
5
+
6
+ export const PUBLIC_FORMS_OUTPUT_KEY = "publicForms";
7
+
8
+ export type PublicFormsWiring = {
9
+ allowedOrigins: string[];
10
+ honeypotFields?: string[];
11
+ };
12
+
13
+ /**
14
+ * Wires the endpoint: the rate-limit counter table (TTL), the Function URL and the function's settings, and returns
15
+ * the URL for the `custom.publicForms.endpoint` output. Everything lives in the function's own stack.
16
+ */
17
+ export function wirePublicForms(backend: { publicForms: { resources: { lambda: unknown }; addEnvironment: (name: string, value: string) => void } }, wiring: PublicFormsWiring): string {
18
+ const lambda = backend.publicForms.resources.lambda as LambdaFunction;
19
+ const stack = Stack.of(lambda);
20
+ const table = new dynamodb.Table(stack, "PublicFormsLimits", {
21
+ partitionKey: { name: "pk", type: dynamodb.AttributeType.STRING },
22
+ billingMode: dynamodb.BillingMode.PAY_PER_REQUEST,
23
+ timeToLiveAttribute: "expiresAt",
24
+ removalPolicy: RemovalPolicy.DESTROY,
25
+ });
26
+ lambda.addToRolePolicy(new PolicyStatement({ effect: Effect.ALLOW, actions: ["dynamodb:UpdateItem"], resources: [table.tableArn] }));
27
+ backend.publicForms.addEnvironment("PAPYRUS_PUBLIC_FORMS_TABLE", table.tableName);
28
+ backend.publicForms.addEnvironment("PAPYRUS_PUBLIC_FORMS_ORIGINS", wiring.allowedOrigins.join(","));
29
+ backend.publicForms.addEnvironment("PAPYRUS_PUBLIC_FORMS_HONEYPOTS", (wiring.honeypotFields ?? []).join(","));
30
+ // CORS is answered by the handler (it must echo exactly one allowed origin), so the URL carries no CORS block.
31
+ return lambda.addFunctionUrl({ authType: FunctionUrlAuthType.NONE }).url;
32
+ }
@@ -13,6 +13,9 @@ export type EditorialOptions = {
13
13
  storyLineageId?: string | null;
14
14
  assignmentId?: string | null;
15
15
  maxCharacters?: number | null;
16
+ /** A publication without a current mission or policy record yields missingPublicationDoctrine instead of an error.
17
+ * Only the editorial check asks for this (its rule lane needs no doctrine); more than one current record is still an error. */
18
+ optionalPublicationDoctrine?: boolean | null;
16
19
  readAttachment: (attachment: EditorialRecord) => Promise<string>;
17
20
  };
18
21
 
@@ -55,7 +58,7 @@ function entry(scope: string, kind: string, text: string, record: EditorialRecor
55
58
  } };
56
59
  }
57
60
 
58
- export function buildEditorialContext(publication: EditorialRecord, scope: EditorialRecord, entries: EditorialRecord[], warnings: string[], maxCharacters = 0) {
61
+ export function buildEditorialContext(publication: EditorialRecord, scope: EditorialRecord, entries: EditorialRecord[], warnings: string[], maxCharacters = 0, missingPublicationDoctrine: string[] = []) {
59
62
  const lines = [`Editorial context: ${publication.key}`, "Source: cloud",
60
63
  "Precedence: publication > section > assignment. Surface conflicts; narrower instructions do not silently override publication policy.",
61
64
  "Private operating guidance; not evidence or reader-facing copy."];
@@ -65,7 +68,8 @@ export function buildEditorialContext(publication: EditorialRecord, scope: Edito
65
68
  if (!Number.isInteger(maxCharacters) || maxCharacters < 0) throw new Error("Editorial context budget must be nonnegative.");
66
69
  if (maxCharacters && totalCharacters > maxCharacters) throw new Error(`Editorial context budget ${maxCharacters} cannot hold ${totalCharacters} required characters; policy was not truncated.`);
67
70
  const payload = { schemaVersion: 1, rendererVersion: "editorial-context/v1", publication, scope, sourceMode: "cloud",
68
- precedence: ["publication", "section", "assignment"], entries, sources: entries.map(value => value.source), warnings, text, totalCharacters };
71
+ precedence: ["publication", "section", "assignment"], entries, sources: entries.map(value => value.source), warnings, text, totalCharacters,
72
+ ...(missingPublicationDoctrine.length ? { missingPublicationDoctrine } : {}) };
69
73
  return { ...payload, contextHash: digest(JSON.stringify(canonical(payload))), resolvedAt: new Date().toISOString() };
70
74
  }
71
75
 
@@ -83,6 +87,7 @@ export async function resolveEditorialContext(client: EditorialClient, options:
83
87
  corpusId: corpora[0].id, tenantId: options.tenantId ?? null, endpoint: options.endpoint };
84
88
  const entries: EditorialRecord[] = [];
85
89
  const warnings: string[] = [];
90
+ const missingPublicationDoctrine: string[] = [];
86
91
  let sectionId = options.sectionId || null;
87
92
  let storyLineageId = options.storyLineageId || null;
88
93
  const assignmentId = options.assignmentId || null;
@@ -110,6 +115,7 @@ export async function resolveEditorialContext(client: EditorialClient, options:
110
115
  const rows = await pages(client, "Item", options.tenantId ? "itemByTenantSlug" : "itemBySlug",
111
116
  options.tenantId ? { tenantSlug: tenantScopedValue(options.tenantId, slug) } : { slug });
112
117
  const current = rows.filter(row => row.type === "doctrine" && row.status === "private" && row.versionState === "current");
118
+ if (current.length === 0 && options.optionalPublicationDoctrine) { missingPublicationDoctrine.push(kind); continue; }
113
119
  if (current.length !== 1) throw new Error(`Publication ${kind}: expected exactly one current record, found ${current.length}.`);
114
120
  const record = current[0];
115
121
  tenant(record, options.tenantId);
@@ -147,7 +153,7 @@ export async function resolveEditorialContext(client: EditorialClient, options:
147
153
  warnings.push("No assignment brief attachment exists; the Assignment summary is supplied as its brief.");
148
154
  }
149
155
  }
150
- return buildEditorialContext(publication, { sectionId, storyLineageId, assignmentId }, entries, warnings, options.maxCharacters ?? 0);
156
+ return buildEditorialContext(publication, { sectionId, storyLineageId, assignmentId }, entries, warnings, options.maxCharacters ?? 0, missingPublicationDoctrine);
151
157
  }
152
158
 
153
159
  export function assignmentDoctrine(pack: Awaited<ReturnType<typeof resolveEditorialContext>>) {
@@ -78,23 +78,29 @@ export function siteFilesContentHash(siteFiles) {
78
78
  function pipSpec() {
79
79
  return (process.env.PAPYRUS_PIP_SPEC ?? "").trim() || `papyrus-newsroom==${toPep440(papyrusVersion())}`;
80
80
  }
81
+ /** What the first pip install gets: `papyrus-newsroom` with its dependencies, plus the function's own requirements. Empty means skip it. */
82
+ export function firstPipRequirements(options) {
83
+ return [...(options.noDeps ? [] : [pipSpec()]), ...(options.requirements ?? [])];
84
+ }
81
85
  function bundleFromPip(outputDir, options) {
82
86
  const extra = (process.env.PAPYRUS_PIP_EXTRA_ARGS ?? "").trim().split(/\s+/).filter(Boolean);
83
87
  const python = (process.env.PAPYRUS_PIP_PYTHON ?? "python3").trim();
84
- execFileSync(python, [
85
- "-m", "pip", "install",
86
- "--target", outputDir,
87
- "--no-cache-dir",
88
- "--platform", "manylinux2014_aarch64",
89
- "--implementation", "cp",
90
- "--python-version", "3.12",
91
- "--only-binary=:all:",
92
- // Older pip (e.g. a Python 3.9 build image) checks Requires-Python against the host interpreter, not --python-version.
93
- "--ignore-requires-python",
94
- ...extra,
95
- ...(options.noDeps ? [] : [pipSpec()]),
96
- ...(options.requirements ?? []),
97
- ], { stdio: "inherit" });
88
+ const requirements = firstPipRequirements(options);
89
+ if (requirements.length > 0) {
90
+ execFileSync(python, [
91
+ "-m", "pip", "install",
92
+ "--target", outputDir,
93
+ "--no-cache-dir",
94
+ "--platform", "manylinux2014_aarch64",
95
+ "--implementation", "cp",
96
+ "--python-version", "3.12",
97
+ "--only-binary=:all:",
98
+ // Older pip (e.g. a Python 3.9 build image) checks Requires-Python against the host interpreter, not --python-version.
99
+ "--ignore-requires-python",
100
+ ...extra,
101
+ ...requirements,
102
+ ], { stdio: "inherit" });
103
+ }
98
104
  if (options.noDeps) {
99
105
  execFileSync(python, [
100
106
  "-m", "pip", "install",
@@ -102,27 +102,34 @@ function pipSpec(): string {
102
102
  return (process.env.PAPYRUS_PIP_SPEC ?? "").trim() || `papyrus-newsroom==${toPep440(papyrusVersion())}`;
103
103
  }
104
104
 
105
+ /** What the first pip install gets: `papyrus-newsroom` with its dependencies, plus the function's own requirements. Empty means skip it. */
106
+ export function firstPipRequirements(options: Pick<PythonBundleOptions, "noDeps" | "requirements">): string[] {
107
+ return [...(options.noDeps ? [] : [pipSpec()]), ...(options.requirements ?? [])];
108
+ }
109
+
105
110
  function bundleFromPip(outputDir: string, options: PythonBundleOptions): void {
106
111
  const extra = (process.env.PAPYRUS_PIP_EXTRA_ARGS ?? "").trim().split(/\s+/).filter(Boolean);
107
112
  const python = (process.env.PAPYRUS_PIP_PYTHON ?? "python3").trim();
108
- execFileSync(
109
- python,
110
- [
111
- "-m", "pip", "install",
112
- "--target", outputDir,
113
- "--no-cache-dir",
114
- "--platform", "manylinux2014_aarch64",
115
- "--implementation", "cp",
116
- "--python-version", "3.12",
117
- "--only-binary=:all:",
118
- // Older pip (e.g. a Python 3.9 build image) checks Requires-Python against the host interpreter, not --python-version.
119
- "--ignore-requires-python",
120
- ...extra,
121
- ...(options.noDeps ? [] : [pipSpec()]),
122
- ...(options.requirements ?? []),
123
- ],
124
- { stdio: "inherit" },
125
- );
113
+ const requirements = firstPipRequirements(options);
114
+ if (requirements.length > 0) {
115
+ execFileSync(
116
+ python,
117
+ [
118
+ "-m", "pip", "install",
119
+ "--target", outputDir,
120
+ "--no-cache-dir",
121
+ "--platform", "manylinux2014_aarch64",
122
+ "--implementation", "cp",
123
+ "--python-version", "3.12",
124
+ "--only-binary=:all:",
125
+ // Older pip (e.g. a Python 3.9 build image) checks Requires-Python against the host interpreter, not --python-version.
126
+ "--ignore-requires-python",
127
+ ...extra,
128
+ ...requirements,
129
+ ],
130
+ { stdio: "inherit" },
131
+ );
132
+ }
126
133
  if (options.noDeps) {
127
134
  execFileSync(
128
135
  python,
@@ -0,0 +1,110 @@
1
+ export const READER_DIMENSION_NAME = /^[a-z][a-z0-9-]{0,23}$/;
2
+ export const RESERVED_DIMENSIONS = ["up", "used-it"];
3
+ export const READER_HANDLE_NAME = /^[a-z0-9_-]{3,24}$/;
4
+ export const COMPUTE_ROLE_ARN = /^arn:aws:iam::[0-9]{12}:role\/[A-Za-z0-9+=,.@_/-]{1,512}$/;
5
+ const KNOWN_KEYS = new Set(["name", "computeRoleArn", "dimensions", "limits", "reservedHandles", "karma", "ranking", "snapshot", "retirementPoll", "reservedConcurrency", "hypeGap"]);
6
+ function object(raw, label, known) {
7
+ if (raw === undefined)
8
+ return {};
9
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw))
10
+ throw new Error(`reader-steering plugin ${label} must be an object`);
11
+ const unknown = Object.keys(raw).filter((key) => !known.includes(key));
12
+ if (unknown.length > 0)
13
+ throw new Error(`Unknown reader-steering plugin ${label} settings: ${unknown.join(", ")}`);
14
+ return raw;
15
+ }
16
+ function integer(raw, label, fallback, minimum, maximum) {
17
+ const value = raw === undefined ? fallback : raw;
18
+ if (!Number.isInteger(value) || value < minimum || value > maximum) {
19
+ throw new Error(`reader-steering plugin ${label} must be an integer from ${minimum} to ${maximum}`);
20
+ }
21
+ return value;
22
+ }
23
+ function number(raw, label, fallback, minimum, maximum) {
24
+ const value = raw === undefined ? fallback : raw;
25
+ if (typeof value !== "number" || !Number.isFinite(value) || value < minimum || value > maximum) {
26
+ throw new Error(`reader-steering plugin ${label} must be a number from ${minimum} to ${maximum}`);
27
+ }
28
+ return value;
29
+ }
30
+ function flag(raw, label) {
31
+ if (raw === undefined)
32
+ return false;
33
+ if (typeof raw !== "boolean")
34
+ throw new Error(`reader-steering plugin ${label} must be a boolean`);
35
+ return raw;
36
+ }
37
+ function names(raw, label, pattern) {
38
+ if (raw === undefined)
39
+ return [];
40
+ if (!Array.isArray(raw) || !raw.every((entry) => typeof entry === "string" && pattern.test(entry))) {
41
+ throw new Error(`reader-steering plugin ${label} must be a list of lowercase names matching ${pattern}`);
42
+ }
43
+ return [...new Set(raw)];
44
+ }
45
+ function resolveHypeGap(raw, dimensions) {
46
+ if (raw === undefined)
47
+ return undefined;
48
+ const groups = object(raw, "hypeGap", ["plus", "minus"]);
49
+ const plus = names(groups.plus, "hypeGap.plus", READER_DIMENSION_NAME);
50
+ const minus = names(groups.minus, "hypeGap.minus", READER_DIMENSION_NAME);
51
+ if (plus.length === 0 || minus.length === 0)
52
+ throw new Error("reader-steering plugin hypeGap needs at least one plus and one minus dimension");
53
+ const unknown = [...plus, ...minus].filter((dimension) => !dimensions.includes(dimension));
54
+ if (unknown.length > 0)
55
+ throw new Error(`reader-steering plugin hypeGap uses dimensions the site does not rate: ${unknown.join(", ")}`);
56
+ const both = plus.filter((dimension) => minus.includes(dimension));
57
+ if (both.length > 0)
58
+ throw new Error(`reader-steering plugin hypeGap dimensions cannot be both plus and minus: ${both.join(", ")}`);
59
+ return { plus, minus };
60
+ }
61
+ export function resolveReaderSteeringConfig(request) {
62
+ const raw = typeof request === "string" ? { name: request } : request;
63
+ const unknown = Object.keys(raw).filter((key) => !KNOWN_KEYS.has(key));
64
+ if (unknown.length > 0)
65
+ throw new Error(`Unknown reader-steering plugin settings: ${unknown.join(", ")}`);
66
+ const computeRoleArn = raw.computeRoleArn;
67
+ if (typeof computeRoleArn !== "string" || !COMPUTE_ROLE_ARN.test(computeRoleArn)) {
68
+ throw new Error("reader-steering plugin computeRoleArn must be the IAM role ARN of the CMS app's compute role (the CmsComputeRoleArn output)");
69
+ }
70
+ const dimensions = names(raw.dimensions, "dimensions", READER_DIMENSION_NAME);
71
+ const reserved = dimensions.filter((dimension) => RESERVED_DIMENSIONS.includes(dimension));
72
+ if (reserved.length > 0)
73
+ throw new Error(`reader-steering plugin dimensions cannot use the reserved names: ${reserved.join(", ")}`);
74
+ const limits = object(raw.limits, "limits", ["actionsPerHour", "newAccountHours", "newAccountDivisor"]);
75
+ const karma = object(raw.karma, "karma", ["upvoteWeight", "usedItWeight"]);
76
+ const ranking = object(raw.ranking, "ranking", ["halfLifeHours", "risingWindowHours", "readerMinimum", "editorWeight"]);
77
+ const snapshot = object(raw.snapshot, "snapshot", ["schedule", "intervalHours", "hypeMinimum"]);
78
+ const hypeGap = resolveHypeGap(raw.hypeGap, dimensions);
79
+ const retirementPoll = flag(raw.retirementPoll, "retirementPoll");
80
+ if (retirementPoll)
81
+ throw new Error("reader-steering plugin retirementPoll is not available yet: Prospectus does not serve the retired-subject route");
82
+ return {
83
+ computeRoleArn,
84
+ dimensions,
85
+ limits: {
86
+ actionsPerHour: integer(limits.actionsPerHour, "limits.actionsPerHour", 120, 1, 10000),
87
+ newAccountHours: integer(limits.newAccountHours, "limits.newAccountHours", 24, 0, 24 * 90),
88
+ newAccountDivisor: integer(limits.newAccountDivisor, "limits.newAccountDivisor", 3, 1, 100),
89
+ },
90
+ reservedHandles: names(raw.reservedHandles, "reservedHandles", READER_HANDLE_NAME),
91
+ karma: {
92
+ upvoteWeight: number(karma.upvoteWeight, "karma.upvoteWeight", 1, 0, 1000),
93
+ usedItWeight: number(karma.usedItWeight, "karma.usedItWeight", 2, 0, 1000),
94
+ },
95
+ ranking: {
96
+ halfLifeHours: number(ranking.halfLifeHours, "ranking.halfLifeHours", 48, 1, 24 * 365),
97
+ risingWindowHours: number(ranking.risingWindowHours, "ranking.risingWindowHours", 6, 1, 24 * 30),
98
+ readerMinimum: integer(ranking.readerMinimum, "ranking.readerMinimum", 5, 1, 10000),
99
+ editorWeight: number(ranking.editorWeight, "ranking.editorWeight", 0.5, 0, 1),
100
+ },
101
+ snapshot: {
102
+ schedule: flag(snapshot.schedule, "snapshot.schedule"),
103
+ intervalHours: integer(snapshot.intervalHours, "snapshot.intervalHours", 6, 1, 24),
104
+ hypeMinimum: integer(snapshot.hypeMinimum, "snapshot.hypeMinimum", 5, 1, 10000),
105
+ },
106
+ retirementPoll,
107
+ ...(hypeGap ? { hypeGap } : {}),
108
+ ...(raw.reservedConcurrency === undefined ? {} : { reservedConcurrency: integer(raw.reservedConcurrency, "reservedConcurrency", 1, 1, 1000) }),
109
+ };
110
+ }
@@ -0,0 +1,153 @@
1
+ import type { PluginRequest } from "../types";
2
+
3
+ export const READER_DIMENSION_NAME = /^[a-z][a-z0-9-]{0,23}$/;
4
+ export const RESERVED_DIMENSIONS = ["up", "used-it"];
5
+ export const READER_HANDLE_NAME = /^[a-z0-9_-]{3,24}$/;
6
+ export const COMPUTE_ROLE_ARN = /^arn:aws:iam::[0-9]{12}:role\/[A-Za-z0-9+=,.@_/-]{1,512}$/;
7
+
8
+ export type ReaderSteeringConfig = {
9
+ /** The IAM role of the CMS app's server runtime (the `CmsComputeRoleArn` output of the app-shell stack): the only caller of reader-actions. */
10
+ computeRoleArn: string;
11
+ /** Rating dimensions of the publication, such as `real` and `useful`. `up` and `used-it` are always available. */
12
+ dimensions: string[];
13
+ limits: {
14
+ /** Votes and ratings one reader may make in a clock hour. */
15
+ actionsPerHour: number;
16
+ /** Readers whose profile is younger than this many hours get `actionsPerHour / newAccountDivisor`. */
17
+ newAccountHours: number;
18
+ newAccountDivisor: number;
19
+ };
20
+ /** Handles nobody may take, on top of the built-in list. Lowercase. */
21
+ reservedHandles: string[];
22
+ karma: { upvoteWeight: number; usedItWeight: number };
23
+ ranking: {
24
+ /** Front: the editor composite loses half its weight every this many hours. */
25
+ halfLifeHours: number;
26
+ /** Rising counts upvotes in this window. */
27
+ risingWindowHours: number;
28
+ /** Reader ratings per item needed before the reader composite counts in Front. */
29
+ readerMinimum: number;
30
+ /** Weight of the editor composite in Front once readers have rated (the reader composite gets the rest). */
31
+ editorWeight: number;
32
+ };
33
+ snapshot: {
34
+ /** An EventBridge rule runs the snapshot every `intervalHours`. Off by default. */
35
+ schedule: boolean;
36
+ intervalHours: number;
37
+ /** A hype gap needs at least this many ratings on every dimension involved. */
38
+ hypeMinimum: number;
39
+ };
40
+ /**
41
+ * The hype gap is `mean(plus dimensions) - mean(minus dimensions)`, computed for reader means and for editor scores.
42
+ * Absent means no hype gap is published. Shipped sets plus = notable, novel and minus = real, useful.
43
+ */
44
+ hypeGap?: { plus: string[]; minus: string[] };
45
+ /**
46
+ * Reserved concurrency for reader-actions. Unset by default, which reserves nothing: an account with a small total
47
+ * Lambda concurrency quota (papyrus-production has 10) cannot reserve any without dropping below its unreserved minimum.
48
+ */
49
+ reservedConcurrency?: number;
50
+ /** The Prospectus retired-subject poll is not live: this flag stays false and the run's step is a no-op. */
51
+ retirementPoll: boolean;
52
+ };
53
+
54
+ const KNOWN_KEYS = new Set(["name", "computeRoleArn", "dimensions", "limits", "reservedHandles", "karma", "ranking", "snapshot", "retirementPoll", "reservedConcurrency", "hypeGap"]);
55
+
56
+ function object(raw: unknown, label: string, known: string[]): Record<string, unknown> {
57
+ if (raw === undefined) return {};
58
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) throw new Error(`reader-steering plugin ${label} must be an object`);
59
+ const unknown = Object.keys(raw).filter((key) => !known.includes(key));
60
+ if (unknown.length > 0) throw new Error(`Unknown reader-steering plugin ${label} settings: ${unknown.join(", ")}`);
61
+ return raw as Record<string, unknown>;
62
+ }
63
+
64
+ function integer(raw: unknown, label: string, fallback: number, minimum: number, maximum: number): number {
65
+ const value = raw === undefined ? fallback : raw;
66
+ if (!Number.isInteger(value) || (value as number) < minimum || (value as number) > maximum) {
67
+ throw new Error(`reader-steering plugin ${label} must be an integer from ${minimum} to ${maximum}`);
68
+ }
69
+ return value as number;
70
+ }
71
+
72
+ function number(raw: unknown, label: string, fallback: number, minimum: number, maximum: number): number {
73
+ const value = raw === undefined ? fallback : raw;
74
+ if (typeof value !== "number" || !Number.isFinite(value) || value < minimum || value > maximum) {
75
+ throw new Error(`reader-steering plugin ${label} must be a number from ${minimum} to ${maximum}`);
76
+ }
77
+ return value;
78
+ }
79
+
80
+ function flag(raw: unknown, label: string): boolean {
81
+ if (raw === undefined) return false;
82
+ if (typeof raw !== "boolean") throw new Error(`reader-steering plugin ${label} must be a boolean`);
83
+ return raw;
84
+ }
85
+
86
+ function names(raw: unknown, label: string, pattern: RegExp): string[] {
87
+ if (raw === undefined) return [];
88
+ if (!Array.isArray(raw) || !raw.every((entry) => typeof entry === "string" && pattern.test(entry))) {
89
+ throw new Error(`reader-steering plugin ${label} must be a list of lowercase names matching ${pattern}`);
90
+ }
91
+ return [...new Set(raw as string[])];
92
+ }
93
+
94
+ function resolveHypeGap(raw: unknown, dimensions: string[]): { plus: string[]; minus: string[] } | undefined {
95
+ if (raw === undefined) return undefined;
96
+ const groups = object(raw, "hypeGap", ["plus", "minus"]);
97
+ const plus = names(groups.plus, "hypeGap.plus", READER_DIMENSION_NAME);
98
+ const minus = names(groups.minus, "hypeGap.minus", READER_DIMENSION_NAME);
99
+ if (plus.length === 0 || minus.length === 0) throw new Error("reader-steering plugin hypeGap needs at least one plus and one minus dimension");
100
+ const unknown = [...plus, ...minus].filter((dimension) => !dimensions.includes(dimension));
101
+ if (unknown.length > 0) throw new Error(`reader-steering plugin hypeGap uses dimensions the site does not rate: ${unknown.join(", ")}`);
102
+ const both = plus.filter((dimension) => minus.includes(dimension));
103
+ if (both.length > 0) throw new Error(`reader-steering plugin hypeGap dimensions cannot be both plus and minus: ${both.join(", ")}`);
104
+ return { plus, minus };
105
+ }
106
+
107
+ export function resolveReaderSteeringConfig(request: PluginRequest): ReaderSteeringConfig {
108
+ const raw = typeof request === "string" ? { name: request } : request;
109
+ const unknown = Object.keys(raw).filter((key) => !KNOWN_KEYS.has(key));
110
+ if (unknown.length > 0) throw new Error(`Unknown reader-steering plugin settings: ${unknown.join(", ")}`);
111
+ const computeRoleArn = raw.computeRoleArn;
112
+ if (typeof computeRoleArn !== "string" || !COMPUTE_ROLE_ARN.test(computeRoleArn)) {
113
+ throw new Error("reader-steering plugin computeRoleArn must be the IAM role ARN of the CMS app's compute role (the CmsComputeRoleArn output)");
114
+ }
115
+ const dimensions = names(raw.dimensions, "dimensions", READER_DIMENSION_NAME);
116
+ const reserved = dimensions.filter((dimension) => RESERVED_DIMENSIONS.includes(dimension));
117
+ if (reserved.length > 0) throw new Error(`reader-steering plugin dimensions cannot use the reserved names: ${reserved.join(", ")}`);
118
+ const limits = object(raw.limits, "limits", ["actionsPerHour", "newAccountHours", "newAccountDivisor"]);
119
+ const karma = object(raw.karma, "karma", ["upvoteWeight", "usedItWeight"]);
120
+ const ranking = object(raw.ranking, "ranking", ["halfLifeHours", "risingWindowHours", "readerMinimum", "editorWeight"]);
121
+ const snapshot = object(raw.snapshot, "snapshot", ["schedule", "intervalHours", "hypeMinimum"]);
122
+ const hypeGap = resolveHypeGap(raw.hypeGap, dimensions);
123
+ const retirementPoll = flag(raw.retirementPoll, "retirementPoll");
124
+ if (retirementPoll) throw new Error("reader-steering plugin retirementPoll is not available yet: Prospectus does not serve the retired-subject route");
125
+ return {
126
+ computeRoleArn,
127
+ dimensions,
128
+ limits: {
129
+ actionsPerHour: integer(limits.actionsPerHour, "limits.actionsPerHour", 120, 1, 10000),
130
+ newAccountHours: integer(limits.newAccountHours, "limits.newAccountHours", 24, 0, 24 * 90),
131
+ newAccountDivisor: integer(limits.newAccountDivisor, "limits.newAccountDivisor", 3, 1, 100),
132
+ },
133
+ reservedHandles: names(raw.reservedHandles, "reservedHandles", READER_HANDLE_NAME),
134
+ karma: {
135
+ upvoteWeight: number(karma.upvoteWeight, "karma.upvoteWeight", 1, 0, 1000),
136
+ usedItWeight: number(karma.usedItWeight, "karma.usedItWeight", 2, 0, 1000),
137
+ },
138
+ ranking: {
139
+ halfLifeHours: number(ranking.halfLifeHours, "ranking.halfLifeHours", 48, 1, 24 * 365),
140
+ risingWindowHours: number(ranking.risingWindowHours, "ranking.risingWindowHours", 6, 1, 24 * 30),
141
+ readerMinimum: integer(ranking.readerMinimum, "ranking.readerMinimum", 5, 1, 10000),
142
+ editorWeight: number(ranking.editorWeight, "ranking.editorWeight", 0.5, 0, 1),
143
+ },
144
+ snapshot: {
145
+ schedule: flag(snapshot.schedule, "snapshot.schedule"),
146
+ intervalHours: integer(snapshot.intervalHours, "snapshot.intervalHours", 6, 1, 24),
147
+ hypeMinimum: integer(snapshot.hypeMinimum, "snapshot.hypeMinimum", 5, 1, 10000),
148
+ },
149
+ retirementPoll,
150
+ ...(hypeGap ? { hypeGap } : {}),
151
+ ...(raw.reservedConcurrency === undefined ? {} : { reservedConcurrency: integer(raw.reservedConcurrency, "reservedConcurrency", 1, 1, 1000) }),
152
+ };
153
+ }