@anthusai/papyrus 2.0.0-next.11 → 2.0.0-next.110

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 (227) hide show
  1. package/amplify/auth/resource.js +16 -1
  2. package/amplify/auth/resource.ts +19 -1
  3. package/amplify/data/function-handler.js +20 -0
  4. package/amplify/data/function-handler.ts +40 -0
  5. package/amplify/data/resource.js +152 -62
  6. package/amplify/data/resource.ts +158 -62
  7. package/amplify/data/schema.js +2101 -1987
  8. package/amplify/data/schema.ts +197 -70
  9. package/amplify/functions/assignment-action/data-access.js +2 -0
  10. package/amplify/functions/assignment-action/data-access.ts +2 -0
  11. package/amplify/functions/assignment-action/handler.ts +1 -1
  12. package/amplify/functions/automation-view/handler.py +17 -0
  13. package/amplify/functions/automation-view/resource.js +24 -0
  14. package/amplify/functions/automation-view/resource.ts +34 -0
  15. package/amplify/functions/automation-view/schema.js +26 -0
  16. package/amplify/functions/automation-view/schema.ts +28 -0
  17. package/amplify/functions/automation-view/wire.js +56 -0
  18. package/amplify/functions/automation-view/wire.ts +76 -0
  19. package/amplify/functions/content-actions/handler.py +64 -3
  20. package/amplify/functions/content-actions/requirements.txt +1 -0
  21. package/amplify/functions/content-actions/resource.js +1 -1
  22. package/amplify/functions/content-actions/resource.ts +1 -1
  23. package/amplify/functions/editorial-checks/handler.py +2 -2
  24. package/amplify/functions/editorial-checks/requirements.txt +1 -1
  25. package/amplify/functions/editorial-checks/resource.js +3 -3
  26. package/amplify/functions/editorial-checks/resource.ts +3 -3
  27. package/amplify/functions/editorial-checks/schema.js +38 -8
  28. package/amplify/functions/editorial-checks/schema.ts +38 -8
  29. package/amplify/functions/editorial-checks/wire.js +4 -0
  30. package/amplify/functions/editorial-checks/wire.ts +4 -0
  31. package/amplify/functions/monitoring-view/handler.py +11 -0
  32. package/amplify/functions/monitoring-view/resource.js +24 -0
  33. package/amplify/functions/monitoring-view/resource.ts +34 -0
  34. package/amplify/functions/monitoring-view/schema.js +29 -0
  35. package/amplify/functions/monitoring-view/schema.ts +31 -0
  36. package/amplify/functions/monitoring-view/wire.js +64 -0
  37. package/amplify/functions/monitoring-view/wire.ts +81 -0
  38. package/amplify/functions/public-forms/core.js +247 -0
  39. package/amplify/functions/public-forms/core.ts +310 -0
  40. package/amplify/functions/public-forms/handler.ts +41 -0
  41. package/amplify/functions/public-forms/origins.js +35 -0
  42. package/amplify/functions/public-forms/origins.ts +43 -0
  43. package/amplify/functions/public-forms/resource.js +9 -0
  44. package/amplify/functions/public-forms/resource.ts +10 -0
  45. package/amplify/functions/public-forms/wire.js +25 -0
  46. package/amplify/functions/public-forms/wire.ts +32 -0
  47. package/amplify/functions/shared/editorial-context.ts +63 -4
  48. package/amplify/functions/shared/python-bundle.js +20 -14
  49. package/amplify/functions/shared/python-bundle.ts +25 -18
  50. package/amplify/plugins/newsletter/config.js +245 -0
  51. package/amplify/plugins/newsletter/config.ts +305 -0
  52. package/amplify/plugins/newsletter/function/handler.py +6 -0
  53. package/amplify/plugins/newsletter/function/requirements.txt +5 -0
  54. package/amplify/plugins/newsletter/ops/handler.py +12 -0
  55. package/amplify/plugins/newsletter/ops/resource.js +27 -0
  56. package/amplify/plugins/newsletter/ops/resource.ts +37 -0
  57. package/amplify/plugins/newsletter/papyrus.plugin.js +263 -0
  58. package/amplify/plugins/newsletter/papyrus.plugin.ts +311 -0
  59. package/amplify/plugins/newsletter/schema.js +57 -0
  60. package/amplify/plugins/newsletter/schema.ts +62 -0
  61. package/amplify/plugins/newsletter/secure-string-parameter.js +107 -0
  62. package/amplify/plugins/newsletter/secure-string-parameter.ts +120 -0
  63. package/amplify/plugins/newsletter/sending.js +140 -0
  64. package/amplify/plugins/newsletter/sending.ts +186 -0
  65. package/amplify/plugins/reader-steering/config.js +182 -0
  66. package/amplify/plugins/reader-steering/config.ts +290 -0
  67. package/amplify/plugins/reader-steering/function/ai_moderation.py +246 -0
  68. package/amplify/plugins/reader-steering/function/handler.py +1589 -0
  69. package/amplify/plugins/reader-steering/function/moderation-prompt.md +27 -0
  70. package/amplify/plugins/reader-steering/function/moderation.py +316 -0
  71. package/amplify/plugins/reader-steering/function/proposals.py +487 -0
  72. package/amplify/plugins/reader-steering/function/resource.js +27 -0
  73. package/amplify/plugins/reader-steering/function/resource.ts +35 -0
  74. package/amplify/plugins/reader-steering/papyrus.plugin.js +187 -0
  75. package/amplify/plugins/reader-steering/papyrus.plugin.ts +213 -0
  76. package/amplify/plugins/reader-steering/schema.js +252 -0
  77. package/amplify/plugins/reader-steering/schema.ts +254 -0
  78. package/amplify/plugins/registry.js +4 -0
  79. package/amplify/plugins/registry.ts +4 -0
  80. package/amplify/plugins/signup/schema.js +2 -1
  81. package/amplify/plugins/signup/schema.ts +2 -1
  82. package/amplify/plugins/types.ts +5 -0
  83. package/amplify/plugins/wire.js +1 -0
  84. package/amplify/plugins/wire.ts +1 -0
  85. package/amplify/publication/projection-lease.js +30 -0
  86. package/amplify/publication/projection-lease.ts +39 -0
  87. package/amplify/publication/redirect-and-index.js +105 -0
  88. package/amplify/publication/redirect-rules.js +73 -0
  89. package/amplify/publication/redirect-rules.ts +93 -0
  90. package/amplify/publication/s3-projection.js +7 -1
  91. package/amplify/publication/s3-projection.ts +9 -1
  92. package/amplify/site-backend-config.js +19 -0
  93. package/amplify/site-backend-config.ts +45 -1
  94. package/amplify/site-backend-names.js +4 -2
  95. package/amplify/site-backend-names.ts +4 -2
  96. package/amplify/site-backend.js +42 -4
  97. package/amplify/site-backend.ts +45 -4
  98. package/app/.well-known/papyrus-cli.json/route.ts +9 -0
  99. package/app/api/auth/prospectus/sign-in/route.ts +4 -0
  100. package/app/api/newsletter/confirm/route.ts +13 -0
  101. package/app/api/newsletter/preferences/route.ts +13 -0
  102. package/app/api/newsletter/state/route.ts +9 -0
  103. package/app/api/newsletter/subscribe/route.ts +9 -0
  104. package/app/api/newsletter/unsubscribe/route.ts +13 -0
  105. package/app/api/newsroom/chat/[...path]/route.ts +17 -0
  106. package/app/api/newsroom/curation/context/route.ts +9 -0
  107. package/app/api/newsroom/curation/priming/route.ts +9 -0
  108. package/app/api/newsroom/curation/publication/route.ts +9 -0
  109. package/app/api/reader/comment/delete/route.ts +9 -0
  110. package/app/api/reader/comment/edit/route.ts +9 -0
  111. package/app/api/reader/comment/route.ts +9 -0
  112. package/app/api/reader/flag/route.ts +9 -0
  113. package/app/api/reader/handle/route.ts +9 -0
  114. package/app/api/reader/me/route.ts +9 -0
  115. package/app/api/reader/rating/route.ts +9 -0
  116. package/app/api/reader/report/route.ts +9 -0
  117. package/app/api/reader/retired/route.ts +9 -0
  118. package/app/api/reader/submit/route.ts +9 -0
  119. package/app/api/reader/vote/route.ts +9 -0
  120. package/app/layout.tsx +5 -1
  121. package/bin/papyrus-infra.mjs +20 -1
  122. package/components/automation-panel.tsx +49 -0
  123. package/components/automation-run-now.tsx +97 -0
  124. package/components/automation-view.tsx +195 -0
  125. package/components/editorial-checks-admin-panel.tsx +32 -17
  126. package/components/moderation-panel.tsx +248 -0
  127. package/components/monitoring-overview-view.tsx +422 -0
  128. package/components/monitoring-panel.tsx +60 -0
  129. package/components/news-desk-client-provider.tsx +18 -18
  130. package/components/news-desk-taxonomy-client.ts +28 -8
  131. package/components/newsletter-panel.tsx +474 -0
  132. package/components/newsletter-sections.tsx +163 -0
  133. package/components/newsroom-access-gate.tsx +16 -2
  134. package/components/newsroom-article-editor.tsx +9 -7
  135. package/components/newsroom-articles-view.tsx +6 -6
  136. package/components/newsroom-assignment-detail.tsx +6 -6
  137. package/components/newsroom-board-view.tsx +8 -3
  138. package/components/newsroom-curation-bootstrap.tsx +45 -0
  139. package/components/newsroom-ops-overview.tsx +26 -96
  140. package/components/newsroom-ops-shell.tsx +2 -2
  141. package/components/newsroom-organizer.css +33 -0
  142. package/components/newsroom-overview-skeleton.tsx +25 -0
  143. package/components/newsroom-references-view.tsx +192 -49
  144. package/components/notification-center.tsx +281 -0
  145. package/components/papyrus-console-conversation.css +14 -0
  146. package/components/papyrus-console-conversation.tsx +55 -0
  147. package/components/papyrus-console-shell.tsx +62 -420
  148. package/components/papyrus-private-chat-panel.tsx +134 -0
  149. package/components/publication-cyclotron-panel.tsx +15 -10
  150. package/components/reader-auth-state.ts +1 -1
  151. package/components/reference-add-entry.tsx +75 -0
  152. package/components/reference-curation-controls.tsx +31 -22
  153. package/components/reference-cyclotron-priming.tsx +58 -19
  154. package/components/reference-cyclotron-suggestion.tsx +10 -6
  155. package/components/reference-priming.tsx +14 -10
  156. package/components/reference-relevance-review.tsx +3 -3
  157. package/components/reference-section-suggestion.tsx +1 -1
  158. package/components/topic-steering-workspace.tsx +230 -144
  159. package/infra/amplify-app-shell.js +96 -16
  160. package/infra/amplify-app-shell.ts +108 -18
  161. package/infra/cloud-email-config.js +32 -0
  162. package/infra/cloud-email-config.ts +39 -0
  163. package/infra/cloud-email.js +57 -0
  164. package/infra/cloud-email.test.js +69 -0
  165. package/infra/cloud-email.test.ts +66 -0
  166. package/infra/cloud-email.ts +60 -0
  167. package/infra/index.js +1 -0
  168. package/infra/secure-string-parameter-handler.js +41 -0
  169. package/infra/secure-string-parameter-handler.ts +41 -0
  170. package/infra/secure-string-parameter.js +79 -0
  171. package/infra/secure-string-parameter.ts +91 -0
  172. package/infra/site-config.js +92 -6
  173. package/infra/site-config.ts +138 -6
  174. package/lib/automation-client.ts +54 -0
  175. package/lib/automation-fixture.ts +93 -0
  176. package/lib/automation-overview.ts +252 -0
  177. package/lib/category-repository.ts +14 -12
  178. package/lib/cli-login-discovery.ts +78 -0
  179. package/lib/console-web-context.ts +1 -1
  180. package/lib/curation-routes.ts +345 -0
  181. package/lib/cyclotron-suggestions.ts +4 -4
  182. package/lib/editorial-checks-client.ts +18 -0
  183. package/lib/editorial-checks-demo.ts +1 -1
  184. package/lib/editorial-checks.ts +8 -4
  185. package/lib/editorial-preview.ts +5 -5
  186. package/lib/monitoring-client.ts +83 -0
  187. package/lib/monitoring-overview-fixture.ts +51 -0
  188. package/lib/monitoring-overview.ts +191 -0
  189. package/lib/news-desk-session.ts +12 -21
  190. package/lib/newsletter-api.ts +418 -0
  191. package/lib/newsletter-client.ts +46 -0
  192. package/lib/newsletter-fixture.ts +184 -0
  193. package/lib/newsletter-model.ts +202 -0
  194. package/lib/newsroom-demo-dashboard.ts +1 -1
  195. package/lib/newsroom-nav.ts +1 -1
  196. package/lib/newsroom-organizer-nav.ts +2 -1
  197. package/lib/papyrus-chat-admission.ts +65 -0
  198. package/lib/papyrus-hosted-chat.ts +217 -0
  199. package/lib/papyrus-private-chat-client.ts +81 -0
  200. package/lib/prospectus-session.ts +30 -14
  201. package/lib/prospectus-sign-in-routes.ts +106 -31
  202. package/lib/prospectus-site-identity.ts +46 -6
  203. package/lib/reader-api.ts +145 -0
  204. package/lib/reader-moderation.ts +220 -0
  205. package/lib/reader-retirement-route.ts +89 -0
  206. package/lib/reader-subject.md +29 -0
  207. package/lib/reader-subject.ts +53 -0
  208. package/lib/reference-review-details.ts +71 -0
  209. package/lib/reference-review-request.ts +14 -0
  210. package/lib/relevance-decisions.ts +42 -10
  211. package/lib/relevance-status.ts +2 -2
  212. package/lib/review-client.ts +120 -0
  213. package/lib/review-fixture.ts +45 -0
  214. package/lib/review-requests.ts +246 -0
  215. package/lib/semantic-graph.ts +2 -0
  216. package/lib/shared-chat-admission.ts +182 -0
  217. package/lib/site-brand-tenant.ts +11 -6
  218. package/lib/site-brand.ts +6 -3
  219. package/lib/site-origin-value.ts +50 -0
  220. package/lib/site-origin.ts +8 -0
  221. package/lib/use-reference-review-data.ts +44 -0
  222. package/middleware.ts +14 -3
  223. package/package.json +67 -3
  224. package/routes.manifest.json +238 -2
  225. package/src/sync.mjs +11 -2
  226. package/src/with-papyrus.d.mts +1 -0
  227. package/src/with-papyrus.mjs +17 -1
@@ -0,0 +1,186 @@
1
+ import { Duration, Stack } from "aws-cdk-lib";
2
+ import type { Table } from "aws-cdk-lib/aws-dynamodb";
3
+ import { Effect, PolicyStatement, Role, ServicePrincipal } from "aws-cdk-lib/aws-iam";
4
+ import { Architecture, type Code, Function as LambdaFunction, Runtime } from "aws-cdk-lib/aws-lambda";
5
+ import { SqsEventSource } from "aws-cdk-lib/aws-lambda-event-sources";
6
+ import { CfnSchedule } from "aws-cdk-lib/aws-scheduler";
7
+ import { Queue, QueueEncryption } from "aws-cdk-lib/aws-sqs";
8
+ import type { PluginWiringContext } from "../types";
9
+ import type { NewsletterConfig, RoundupList } from "./config";
10
+
11
+ export const NEWSLETTER_WORKER_HANDLER = "papyrus_content.newsletter.worker.handler";
12
+ /** The worker mails up to 50 subscribers per message at the SES send rate; the visibility timeout is six times this. */
13
+ export const WORKER_TIMEOUT_SECONDS = 180;
14
+ export const SEND_MAX_RECEIVE_COUNT = 3;
15
+ /** The schedule names the function may manage at run time: `papyrus-nl-<siteId>-<issue8>`. */
16
+ export const SCHEDULE_NAME_PREFIX = "papyrus-nl-";
17
+
18
+ export const newsletterFunctionName = (siteId: string) => `papyrus-newsletter-${siteId}`;
19
+ export const newsletterWorkerName = (siteId: string) => `papyrus-newsletter-send-${siteId}`;
20
+ export const newsletterSendQueueName = (siteId: string) => `papyrus-newsletter-send-${siteId}`;
21
+
22
+ /** The AppSync fields the `newsletter` function reads and writes through the content API (the roundup draft, the issue's Item, and the published item an alert is about). */
23
+ export const NEWSLETTER_APPSYNC_FIELDS = [
24
+ "Query/fields/getItem",
25
+ "Query/fields/getPublishedItem",
26
+ "Query/fields/listPublishedItems",
27
+ "Mutation/fields/createItem",
28
+ "Mutation/fields/updateItem",
29
+ ];
30
+
31
+ /** The only two SSM parameters that hold the postal address: the site's override and the shared default. */
32
+ export function postalAddressParameterArns(stack: Stack, siteId: string): string[] {
33
+ return [`/papyrus/${siteId}/email/postal-address`, "/papyrus/email/postal-address"].map(
34
+ (name) => `arn:${stack.partition}:ssm:${stack.region}:${stack.account}:parameter${name}`,
35
+ );
36
+ }
37
+
38
+ export function scheduleArnPattern(stack: Stack, siteId: string): string {
39
+ return `arn:${stack.partition}:scheduler:${stack.region}:${stack.account}:schedule/default/${SCHEDULE_NAME_PREFIX}${siteId}-*`;
40
+ }
41
+
42
+ export function functionArn(stack: Stack, name: string): string {
43
+ return `arn:${stack.partition}:lambda:${stack.region}:${stack.account}:function:${name}`;
44
+ }
45
+
46
+ type SendingInputs = {
47
+ stack: Stack;
48
+ config: NewsletterConfig;
49
+ context: PluginWiringContext;
50
+ backend: any;
51
+ table: Table;
52
+ lambda: LambdaFunction;
53
+ environment: Record<string, string>;
54
+ keyParameterArn: string;
55
+ sesResources: string[];
56
+ code: Code;
57
+ };
58
+
59
+ /**
60
+ * Everything that sends: the queue and its dead letter queue, the send worker, the scheduler role and the per-list
61
+ * draft schedules, and the IAM they need. Nothing here lets the function reach anything but its own queue, its own
62
+ * schedules (`papyrus-nl-<siteId>-*`), two postal address parameters and five content API fields.
63
+ */
64
+ export function wireSending(inputs: SendingInputs): { queue: Queue; deadLetterQueue: Queue; worker: LambdaFunction; schedulerRole: Role } {
65
+ const { stack, config, context, backend, table, lambda, environment, keyParameterArn, sesResources, code } = inputs;
66
+
67
+ const deadLetterQueue = new Queue(stack, "SendDeadLetterQueue", {
68
+ queueName: `${newsletterSendQueueName(config.siteId)}-dlq`,
69
+ retentionPeriod: Duration.days(14),
70
+ encryption: QueueEncryption.SQS_MANAGED,
71
+ enforceSSL: true,
72
+ });
73
+ const queue = new Queue(stack, "SendQueue", {
74
+ queueName: newsletterSendQueueName(config.siteId),
75
+ visibilityTimeout: Duration.seconds(WORKER_TIMEOUT_SECONDS * 6),
76
+ encryption: QueueEncryption.SQS_MANAGED,
77
+ enforceSSL: true,
78
+ deadLetterQueue: { queue: deadLetterQueue, maxReceiveCount: SEND_MAX_RECEIVE_COUNT },
79
+ });
80
+
81
+ // The role EventBridge Scheduler assumes to invoke the function, for the draft schedules and the one-time send schedules.
82
+ const schedulerRole = new Role(stack, "SchedulerRole", {
83
+ assumedBy: new ServicePrincipal("scheduler.amazonaws.com", { conditions: { StringEquals: { "aws:SourceAccount": stack.account } } }),
84
+ description: `Lets EventBridge Scheduler invoke the ${config.siteId} newsletter function`,
85
+ });
86
+ lambda.grantInvoke(schedulerRole);
87
+
88
+ const contentActions = context.contentActionsLambda;
89
+ lambda.addEnvironment("FUNCTION_ARN", functionArn(stack, newsletterFunctionName(config.siteId)));
90
+ lambda.addEnvironment("SCHEDULER_ROLE_ARN", schedulerRole.roleArn);
91
+ lambda.addEnvironment("QUEUE_URL", queue.queueUrl);
92
+ lambda.addEnvironment("CONTENT_ACTIONS_FUNCTION", contentActions.functionName);
93
+ lambda.addEnvironment("PAPYRUS_GRAPHQL_ENDPOINT", context.graphqlEndpoint);
94
+
95
+ queue.grantSendMessages(lambda);
96
+ contentActions.grantInvoke(lambda);
97
+ lambda.addToRolePolicy(
98
+ new PolicyStatement({ effect: Effect.ALLOW, actions: ["ssm:GetParameter"], resources: postalAddressParameterArns(stack, config.siteId) }),
99
+ );
100
+ lambda.addToRolePolicy(
101
+ new PolicyStatement({
102
+ effect: Effect.ALLOW,
103
+ actions: ["scheduler:CreateSchedule", "scheduler:UpdateSchedule", "scheduler:DeleteSchedule"],
104
+ resources: [scheduleArnPattern(stack, config.siteId)],
105
+ }),
106
+ );
107
+ lambda.addToRolePolicy(
108
+ new PolicyStatement({
109
+ effect: Effect.ALLOW,
110
+ actions: ["iam:PassRole"],
111
+ resources: [schedulerRole.roleArn],
112
+ conditions: { StringEquals: { "iam:PassedToService": "scheduler.amazonaws.com" } },
113
+ }),
114
+ );
115
+ lambda.addToRolePolicy(
116
+ new PolicyStatement({
117
+ effect: Effect.ALLOW,
118
+ actions: ["appsync:GraphQL"],
119
+ resources: NEWSLETTER_APPSYNC_FIELDS.map(
120
+ (field) => `arn:${stack.partition}:appsync:${stack.region}:${stack.account}:apis/${context.graphqlApiId}/types/${field}`,
121
+ ),
122
+ }),
123
+ );
124
+
125
+ const worker = new LambdaFunction(stack, "SendWorkerFunction", {
126
+ functionName: newsletterWorkerName(config.siteId),
127
+ runtime: Runtime.PYTHON_3_12,
128
+ architecture: Architecture.ARM_64,
129
+ handler: NEWSLETTER_WORKER_HANDLER,
130
+ timeout: Duration.seconds(WORKER_TIMEOUT_SECONDS),
131
+ memorySize: 512,
132
+ // One message at a time, so the send rate is the account's SES rate, never a multiple of it.
133
+ reservedConcurrentExecutions: 1,
134
+ description: `Mails queued newsletter batches for ${config.siteId}, paced under the SES send rate`,
135
+ environment: { ...environment, QUEUE_URL: queue.queueUrl, CONTENT_ACTIONS_FUNCTION: contentActions.functionName },
136
+ code,
137
+ });
138
+ worker.addEventSource(new SqsEventSource(queue, { batchSize: 1 }));
139
+ table.grantReadWriteData(worker);
140
+ queue.grantSendMessages(worker);
141
+ contentActions.grantInvoke(worker);
142
+ worker.addToRolePolicy(new PolicyStatement({ effect: Effect.ALLOW, actions: ["ssm:GetParameter"], resources: [keyParameterArn] }));
143
+ worker.addToRolePolicy(
144
+ new PolicyStatement({
145
+ effect: Effect.ALLOW,
146
+ actions: ["ses:SendEmail", "ses:SendRawEmail"],
147
+ resources: sesResources,
148
+ conditions: { StringEquals: { "ses:FromAddress": config.fromAddress } },
149
+ }),
150
+ );
151
+ // GetAccount (the send rate) does not support resource-level permissions.
152
+ worker.addToRolePolicy(new PolicyStatement({ effect: Effect.ALLOW, actions: ["sesv2:GetAccount"], resources: ["*"] }));
153
+
154
+ // A roundup list drafts itself on its schedule, in the schedule's own time zone.
155
+ const roundups = config.lists.filter((list): list is RoundupList => list.kind === "roundup");
156
+ for (const list of roundups) {
157
+ new CfnSchedule(stack, `Draft${list.id.replace(/(^|-)([a-z0-9])/g, (_match, _dash, letter: string) => letter.toUpperCase())}`, {
158
+ description: `Draft the ${list.id} roundup for ${config.siteId}`,
159
+ scheduleExpression: list.schedule.draft,
160
+ scheduleExpressionTimezone: list.schedule.timezone,
161
+ flexibleTimeWindow: { mode: "OFF" },
162
+ state: "ENABLED",
163
+ target: {
164
+ arn: lambda.functionArn,
165
+ roleArn: schedulerRole.roleArn,
166
+ input: JSON.stringify({ task: "assemble-roundup", listId: list.id }),
167
+ retryPolicy: { maximumRetryAttempts: 2, maximumEventAgeInSeconds: 3600 },
168
+ },
169
+ });
170
+ }
171
+
172
+ // The newsroom operations reach the function through the thin AppSync function the plugin defines.
173
+ const operations = backend.newsletterOps?.resources.lambda as LambdaFunction | undefined;
174
+ if (operations) {
175
+ const operationsStack = Stack.of(operations);
176
+ operations.addEnvironment("NEWSLETTER_FUNCTION_NAME", newsletterFunctionName(config.siteId));
177
+ operations.addToRolePolicy(
178
+ new PolicyStatement({
179
+ effect: Effect.ALLOW,
180
+ actions: ["lambda:InvokeFunction"],
181
+ resources: [functionArn(operationsStack, newsletterFunctionName(config.siteId))],
182
+ }),
183
+ );
184
+ }
185
+ return { queue, deadLetterQueue, worker, schedulerRole };
186
+ }
@@ -0,0 +1,182 @@
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 MODERATION_MODES = ["off", "human", "ai"];
5
+ export const AI_KEY_PARAMETER = /^\/papyrus\/[a-z0-9][a-z0-9-]{0,62}\/openai-api-key$/;
6
+ export const AI_MODEL_NAME = /^[a-z0-9][a-z0-9.-]{0,63}$/;
7
+ export const SUBMISSION_KIND_NAME = /^[a-z][a-z0-9-]{0,23}$/;
8
+ export const SUBJECT_TYPES = ["item", "submission", "story", "proposal", "concept"];
9
+ export const COMPUTE_ROLE_ARN = /^arn:aws:iam::[0-9]{12}:role\/[A-Za-z0-9+=,.@_/-]{1,512}$/;
10
+ const KNOWN_KEYS = new Set(["name", "computeRoleArn", "dimensions", "limits", "reservedHandles", "karma", "ranking", "snapshot", "retirementPoll", "reservedConcurrency", "hypeGap", "submissions", "comments", "moderation", "subjects", "proposals"]);
11
+ function object(raw, label, known) {
12
+ if (raw === undefined)
13
+ return {};
14
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw))
15
+ throw new Error(`reader-steering plugin ${label} must be an object`);
16
+ const unknown = Object.keys(raw).filter((key) => !known.includes(key));
17
+ if (unknown.length > 0)
18
+ throw new Error(`Unknown reader-steering plugin ${label} settings: ${unknown.join(", ")}`);
19
+ return raw;
20
+ }
21
+ function integer(raw, label, fallback, minimum, maximum) {
22
+ const value = raw === undefined ? fallback : raw;
23
+ if (!Number.isInteger(value) || value < minimum || value > maximum) {
24
+ throw new Error(`reader-steering plugin ${label} must be an integer from ${minimum} to ${maximum}`);
25
+ }
26
+ return value;
27
+ }
28
+ function number(raw, label, fallback, minimum, maximum) {
29
+ const value = raw === undefined ? fallback : raw;
30
+ if (typeof value !== "number" || !Number.isFinite(value) || value < minimum || value > maximum) {
31
+ throw new Error(`reader-steering plugin ${label} must be a number from ${minimum} to ${maximum}`);
32
+ }
33
+ return value;
34
+ }
35
+ function flag(raw, label) {
36
+ if (raw === undefined)
37
+ return false;
38
+ if (typeof raw !== "boolean")
39
+ throw new Error(`reader-steering plugin ${label} must be a boolean`);
40
+ return raw;
41
+ }
42
+ function names(raw, label, pattern) {
43
+ if (raw === undefined)
44
+ return [];
45
+ if (!Array.isArray(raw) || !raw.every((entry) => typeof entry === "string" && pattern.test(entry))) {
46
+ throw new Error(`reader-steering plugin ${label} must be a list of lowercase names matching ${pattern}`);
47
+ }
48
+ return [...new Set(raw)];
49
+ }
50
+ function lowercaseName(raw, label, fallback) {
51
+ const value = raw === undefined ? fallback : raw;
52
+ if (typeof value !== "string" || !SUBMISSION_KIND_NAME.test(value))
53
+ throw new Error(`reader-steering plugin ${label} must be a lowercase name matching ${SUBMISSION_KIND_NAME}`);
54
+ return value;
55
+ }
56
+ function resolveHypeGap(raw, dimensions) {
57
+ if (raw === undefined)
58
+ return undefined;
59
+ const groups = object(raw, "hypeGap", ["plus", "minus"]);
60
+ const plus = names(groups.plus, "hypeGap.plus", READER_DIMENSION_NAME);
61
+ const minus = names(groups.minus, "hypeGap.minus", READER_DIMENSION_NAME);
62
+ if (plus.length === 0 || minus.length === 0)
63
+ throw new Error("reader-steering plugin hypeGap needs at least one plus and one minus dimension");
64
+ const unknown = [...plus, ...minus].filter((dimension) => !dimensions.includes(dimension));
65
+ if (unknown.length > 0)
66
+ throw new Error(`reader-steering plugin hypeGap uses dimensions the site does not rate: ${unknown.join(", ")}`);
67
+ const both = plus.filter((dimension) => minus.includes(dimension));
68
+ if (both.length > 0)
69
+ throw new Error(`reader-steering plugin hypeGap dimensions cannot be both plus and minus: ${both.join(", ")}`);
70
+ return { plus, minus };
71
+ }
72
+ export function resolveReaderSteeringConfig(request) {
73
+ const raw = typeof request === "string" ? { name: request } : request;
74
+ const unknown = Object.keys(raw).filter((key) => !KNOWN_KEYS.has(key));
75
+ if (unknown.length > 0)
76
+ throw new Error(`Unknown reader-steering plugin settings: ${unknown.join(", ")}`);
77
+ const computeRoleArn = raw.computeRoleArn;
78
+ if (typeof computeRoleArn !== "string" || !COMPUTE_ROLE_ARN.test(computeRoleArn)) {
79
+ throw new Error("reader-steering plugin computeRoleArn must be the IAM role ARN of the CMS app's compute role (the CmsComputeRoleArn output)");
80
+ }
81
+ const dimensions = names(raw.dimensions, "dimensions", READER_DIMENSION_NAME);
82
+ const reserved = dimensions.filter((dimension) => RESERVED_DIMENSIONS.includes(dimension));
83
+ if (reserved.length > 0)
84
+ throw new Error(`reader-steering plugin dimensions cannot use the reserved names: ${reserved.join(", ")}`);
85
+ const limits = object(raw.limits, "limits", ["actionsPerHour", "newAccountHours", "newAccountDivisor"]);
86
+ const karma = object(raw.karma, "karma", ["upvoteWeight", "usedItWeight"]);
87
+ const ranking = object(raw.ranking, "ranking", [
88
+ "halfLifeHours", "risingWindowHours", "readerMinimum", "editorWeight", "combination", "shrinkageK", "readerShareCap", "newAccountWeight", "risingMinimumAccountHours",
89
+ ]);
90
+ const combination = ranking.combination === undefined ? "blend" : ranking.combination;
91
+ if (combination !== "blend" && combination !== "shrinkage")
92
+ throw new Error("reader-steering plugin ranking.combination must be blend or shrinkage");
93
+ const shrinking = combination === "shrinkage";
94
+ const snapshot = object(raw.snapshot, "snapshot", ["schedule", "intervalHours", "hypeMinimum"]);
95
+ const submissions = object(raw.submissions, "submissions", ["perDay", "similarHours", "kinds", "itemType", "section"]);
96
+ const moderation = object(raw.moderation, "moderation", ["mode", "preModeration", "reportsPerDay", "ai"]);
97
+ const mode = moderation.mode === undefined ? "human" : moderation.mode;
98
+ if (!MODERATION_MODES.includes(mode)) {
99
+ throw new Error(`reader-steering plugin moderation.mode must be one of: ${MODERATION_MODES.join(", ")}`);
100
+ }
101
+ const preModeration = flag(moderation.preModeration, "moderation.preModeration");
102
+ if (preModeration && mode === "off")
103
+ throw new Error("reader-steering plugin moderation.preModeration needs a moderation.mode other than off");
104
+ const ai = object(moderation.ai, "moderation.ai", ["model", "apiKeyParameter"]);
105
+ if (mode !== "ai" && moderation.ai !== undefined)
106
+ throw new Error("reader-steering plugin moderation.ai needs moderation.mode: ai");
107
+ if (mode === "ai") {
108
+ if (typeof ai.apiKeyParameter !== "string" || !AI_KEY_PARAMETER.test(ai.apiKeyParameter)) {
109
+ throw new Error("reader-steering plugin moderation.ai.apiKeyParameter must be the SSM SecureString name /papyrus/<site>/openai-api-key");
110
+ }
111
+ if (ai.model !== undefined && (typeof ai.model !== "string" || !AI_MODEL_NAME.test(ai.model))) {
112
+ throw new Error(`reader-steering plugin moderation.ai.model must be a model name matching ${AI_MODEL_NAME}`);
113
+ }
114
+ }
115
+ const subjects = object(raw.subjects, "subjects", ["types"]);
116
+ const subjectTypes = subjects.types === undefined ? ["item", "submission"] : subjects.types;
117
+ if (!Array.isArray(subjectTypes) || subjectTypes.length === 0 || !subjectTypes.every((type) => SUBJECT_TYPES.includes(type))) {
118
+ throw new Error(`reader-steering plugin subjects.types must be a non-empty list of: ${SUBJECT_TYPES.join(", ")}`);
119
+ }
120
+ const proposals = object(raw.proposals, "proposals", ["publish"]);
121
+ const kinds = names(submissions.kinds, "submissions.kinds", SUBMISSION_KIND_NAME);
122
+ const comments = object(raw.comments, "comments", ["perHour", "editWindowMinutes", "maximumLength", "maximumLinks"]);
123
+ const hypeGap = resolveHypeGap(raw.hypeGap, dimensions);
124
+ const retirementPoll = flag(raw.retirementPoll, "retirementPoll");
125
+ if (retirementPoll && !flag(snapshot.schedule, "snapshot.schedule")) {
126
+ throw new Error("reader-steering plugin retirementPoll runs inside the scheduled snapshot, so it needs snapshot.schedule: true");
127
+ }
128
+ return {
129
+ computeRoleArn,
130
+ dimensions,
131
+ limits: {
132
+ actionsPerHour: integer(limits.actionsPerHour, "limits.actionsPerHour", 120, 1, 10000),
133
+ newAccountHours: integer(limits.newAccountHours, "limits.newAccountHours", 24, 0, 24 * 90),
134
+ newAccountDivisor: integer(limits.newAccountDivisor, "limits.newAccountDivisor", 3, 1, 100),
135
+ },
136
+ reservedHandles: names(raw.reservedHandles, "reservedHandles", READER_HANDLE_NAME),
137
+ karma: {
138
+ upvoteWeight: number(karma.upvoteWeight, "karma.upvoteWeight", 1, 0, 1000),
139
+ usedItWeight: number(karma.usedItWeight, "karma.usedItWeight", 2, 0, 1000),
140
+ },
141
+ ranking: {
142
+ halfLifeHours: number(ranking.halfLifeHours, "ranking.halfLifeHours", 48, 1, 24 * 365),
143
+ risingWindowHours: number(ranking.risingWindowHours, "ranking.risingWindowHours", 6, 1, 24 * 30),
144
+ readerMinimum: integer(ranking.readerMinimum, "ranking.readerMinimum", 5, 1, 10000),
145
+ editorWeight: number(ranking.editorWeight, "ranking.editorWeight", 0.5, 0, 1),
146
+ combination,
147
+ shrinkageK: number(ranking.shrinkageK, "ranking.shrinkageK", 20, 0.01, 10000),
148
+ readerShareCap: number(ranking.readerShareCap, "ranking.readerShareCap", 0.4, 0, 1),
149
+ newAccountWeight: number(ranking.newAccountWeight, "ranking.newAccountWeight", shrinking ? 1 / 3 : 1, 0, 1),
150
+ risingMinimumAccountHours: number(ranking.risingMinimumAccountHours, "ranking.risingMinimumAccountHours", shrinking ? 24 : 0, 0, 24 * 90),
151
+ },
152
+ snapshot: {
153
+ schedule: flag(snapshot.schedule, "snapshot.schedule"),
154
+ intervalHours: integer(snapshot.intervalHours, "snapshot.intervalHours", 6, 1, 24),
155
+ hypeMinimum: integer(snapshot.hypeMinimum, "snapshot.hypeMinimum", 5, 1, 10000),
156
+ },
157
+ submissions: {
158
+ perDay: integer(submissions.perDay, "submissions.perDay", 10, 1, 1000),
159
+ similarHours: integer(submissions.similarHours, "submissions.similarHours", 48, 1, 24 * 30),
160
+ kinds: kinds.length > 0 ? kinds : ["link"],
161
+ itemType: lowercaseName(submissions.itemType, "submissions.itemType", "brief"),
162
+ section: lowercaseName(submissions.section, "submissions.section", "unsectioned"),
163
+ },
164
+ subjects: { types: [...new Set(subjectTypes)] },
165
+ moderation: {
166
+ mode: mode,
167
+ preModeration,
168
+ reportsPerDay: integer(moderation.reportsPerDay, "moderation.reportsPerDay", 20, 1, 1000),
169
+ ...(mode === "ai" ? { ai: { model: ai.model ?? "gpt-6-luna", apiKeyParameter: ai.apiKeyParameter } } : {}),
170
+ },
171
+ comments: {
172
+ perHour: integer(comments.perHour, "comments.perHour", 30, 1, 1000),
173
+ editWindowMinutes: integer(comments.editWindowMinutes, "comments.editWindowMinutes", 10, 0, 24 * 60),
174
+ maximumLength: integer(comments.maximumLength, "comments.maximumLength", 2000, 1, 10000),
175
+ maximumLinks: integer(comments.maximumLinks, "comments.maximumLinks", 3, 0, 50),
176
+ },
177
+ proposals: { publish: flag(proposals.publish, "proposals.publish") },
178
+ retirementPoll,
179
+ ...(hypeGap ? { hypeGap } : {}),
180
+ ...(raw.reservedConcurrency === undefined ? {} : { reservedConcurrency: integer(raw.reservedConcurrency, "reservedConcurrency", 1, 1, 1000) }),
181
+ };
182
+ }
@@ -0,0 +1,290 @@
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 MODERATION_MODES = ["off", "human", "ai"] as const;
7
+ export const AI_KEY_PARAMETER = /^\/papyrus\/[a-z0-9][a-z0-9-]{0,62}\/openai-api-key$/;
8
+ export const AI_MODEL_NAME = /^[a-z0-9][a-z0-9.-]{0,63}$/;
9
+ export const SUBMISSION_KIND_NAME = /^[a-z][a-z0-9-]{0,23}$/;
10
+ export const SUBJECT_TYPES = ["item", "submission", "story", "proposal", "concept"] as const;
11
+ export const COMPUTE_ROLE_ARN = /^arn:aws:iam::[0-9]{12}:role\/[A-Za-z0-9+=,.@_/-]{1,512}$/;
12
+
13
+ export type ReaderSteeringConfig = {
14
+ /** The IAM role of the CMS app's server runtime (the `CmsComputeRoleArn` output of the app-shell stack): the only caller of reader-actions. */
15
+ computeRoleArn: string;
16
+ /** Rating dimensions of the publication, named by the site (lowercase). `up` and `used-it` are always available. */
17
+ dimensions: string[];
18
+ limits: {
19
+ /** Votes and ratings one reader may make in a clock hour. */
20
+ actionsPerHour: number;
21
+ /** Readers whose profile is younger than this many hours get `actionsPerHour / newAccountDivisor`. */
22
+ newAccountHours: number;
23
+ newAccountDivisor: number;
24
+ };
25
+ /** Handles nobody may take, on top of the built-in list. Lowercase. */
26
+ reservedHandles: string[];
27
+ karma: { upvoteWeight: number; usedItWeight: number };
28
+ ranking: {
29
+ /** Front: the editor composite loses half its weight every this many hours. */
30
+ halfLifeHours: number;
31
+ /** Rising counts upvotes in this window. */
32
+ risingWindowHours: number;
33
+ /** Reader ratings per item needed before the reader composite counts in Front. */
34
+ readerMinimum: number;
35
+ /** Weight of the editor composite in Front once readers have rated (the reader composite gets the rest). Used by `blend` only. */
36
+ editorWeight: number;
37
+ /** `blend`: editor composite, then a fixed-weight blend after `readerMinimum` ratings (the original rule). `shrinkage`: per dimension, the editor score is a prior worth `shrinkageK` ratings and the reader share is capped. */
38
+ combination: "blend" | "shrinkage";
39
+ /** Shrinkage: the editor score counts as this many reader ratings. */
40
+ shrinkageK: number;
41
+ /** Shrinkage: the reader mean never gets more than this share of the distance from the editor score. */
42
+ readerShareCap: number;
43
+ /** A rating from a profile younger than `limits.newAccountHours` counts for this fraction in the reader mean and count. Default 1/3 under `shrinkage`, 1 under `blend`. */
44
+ newAccountWeight: number;
45
+ /** Rising and the used-it reporter count only take profiles at least this many hours old; 0 takes all. Default 24 under `shrinkage`, 0 under `blend`. */
46
+ risingMinimumAccountHours: number;
47
+ };
48
+ snapshot: {
49
+ /** An EventBridge rule runs the snapshot every `intervalHours`. Off by default. */
50
+ schedule: boolean;
51
+ intervalHours: number;
52
+ /** A hype gap needs at least this many ratings on every dimension involved. */
53
+ hypeMinimum: number;
54
+ };
55
+ submissions: {
56
+ /** Submissions one reader may make in a UTC day. Accounts younger than `limits.newAccountHours` get `perDay / limits.newAccountDivisor`. */
57
+ perDay: number;
58
+ /** Another submission from the same domain inside this many hours is returned as a `similar` warning. */
59
+ similarHours: number;
60
+ /** The kinds a reader may pick (a radio on the form). A single kind hides the radio. */
61
+ kinds: string[];
62
+ /** The item type of the draft an editor makes by promoting a submission. */
63
+ itemType: string;
64
+ /** The section of that draft. */
65
+ section: string;
66
+ };
67
+ /**
68
+ * The kinds of subject a signed-in reader may vote on and flag, by `{type, id}`. Default: items and submissions, which is
69
+ * what readers could vote on before subject types existed. `story`, `proposal` and `concept` are opt-in.
70
+ */
71
+ subjects: { types: (typeof SUBJECT_TYPES)[number][] };
72
+ moderation: {
73
+ /**
74
+ * `human`: new submissions and comments are public at once and wait in the Administration queue (the default for a pilot).
75
+ * `off`: no queue; moderators can still hide, ban and undo. Turning human review off is the whole of "trust graduation".
76
+ * `ai`: a worker asks a model to allow or hide each new item and logs it as the AI moderator; a human decision always stands.
77
+ */
78
+ mode: (typeof MODERATION_MODES)[number];
79
+ /** Hold new submissions and comments hidden until a moderator approves them. Needs a mode other than `off`. */
80
+ preModeration: boolean;
81
+ /** Reports one reader may file in a UTC day (a third of it for a new account). */
82
+ reportsPerDay: number;
83
+ /** Present when `mode` is `ai`. */
84
+ ai?: {
85
+ /** The model that judges each new item. */
86
+ model: string;
87
+ /** The SSM SecureString that holds the API key: `/papyrus/<site>/openai-api-key`. The plugin only grants read access to it. */
88
+ apiKeyParameter: string;
89
+ };
90
+ };
91
+ comments: {
92
+ /** Comments, replies and edits one reader may post in a clock hour (a third of it for new accounts). */
93
+ perHour: number;
94
+ /** The author may edit a comment for this many minutes after posting it. */
95
+ editWindowMinutes: number;
96
+ /** Most characters in a comment. */
97
+ maximumLength: number;
98
+ /** Most links (http, https or www) in a comment. */
99
+ maximumLinks: number;
100
+ };
101
+ proposals: {
102
+ /**
103
+ * Publish every proposal, in every state (drafts included), as public `_data/proposals/` files in each snapshot run.
104
+ * Off by default: proposals stay private records until a site opts in. Turning it off stops further updates; it does not
105
+ * delete files already published.
106
+ */
107
+ publish: boolean;
108
+ };
109
+ /**
110
+ * The hype gap is `mean(plus dimensions) - mean(minus dimensions)`, computed for reader means and for editor scores.
111
+ * Absent means no hype gap is published. The site names the dimensions in each group.
112
+ */
113
+ hypeGap?: { plus: string[]; minus: string[] };
114
+ /**
115
+ * Reserved concurrency for reader-actions. Unset by default, which reserves nothing: an account with a small total
116
+ * Lambda concurrency quota (papyrus-production has 10) cannot reserve any without dropping below its unreserved minimum.
117
+ */
118
+ reservedConcurrency?: number;
119
+ /**
120
+ * Poll Prospectus for retired reader subjects in each scheduled snapshot run (requires `snapshot.schedule`, and a site
121
+ * whose app-shell sets `cms.readerRetirementPoll`). Off by default.
122
+ */
123
+ retirementPoll: boolean;
124
+ };
125
+
126
+ const KNOWN_KEYS = new Set(["name", "computeRoleArn", "dimensions", "limits", "reservedHandles", "karma", "ranking", "snapshot", "retirementPoll", "reservedConcurrency", "hypeGap", "submissions", "comments", "moderation", "subjects", "proposals"]);
127
+
128
+ function object(raw: unknown, label: string, known: string[]): Record<string, unknown> {
129
+ if (raw === undefined) return {};
130
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) throw new Error(`reader-steering plugin ${label} must be an object`);
131
+ const unknown = Object.keys(raw).filter((key) => !known.includes(key));
132
+ if (unknown.length > 0) throw new Error(`Unknown reader-steering plugin ${label} settings: ${unknown.join(", ")}`);
133
+ return raw as Record<string, unknown>;
134
+ }
135
+
136
+ function integer(raw: unknown, label: string, fallback: number, minimum: number, maximum: number): number {
137
+ const value = raw === undefined ? fallback : raw;
138
+ if (!Number.isInteger(value) || (value as number) < minimum || (value as number) > maximum) {
139
+ throw new Error(`reader-steering plugin ${label} must be an integer from ${minimum} to ${maximum}`);
140
+ }
141
+ return value as number;
142
+ }
143
+
144
+ function number(raw: unknown, label: string, fallback: number, minimum: number, maximum: number): number {
145
+ const value = raw === undefined ? fallback : raw;
146
+ if (typeof value !== "number" || !Number.isFinite(value) || value < minimum || value > maximum) {
147
+ throw new Error(`reader-steering plugin ${label} must be a number from ${minimum} to ${maximum}`);
148
+ }
149
+ return value;
150
+ }
151
+
152
+ function flag(raw: unknown, label: string): boolean {
153
+ if (raw === undefined) return false;
154
+ if (typeof raw !== "boolean") throw new Error(`reader-steering plugin ${label} must be a boolean`);
155
+ return raw;
156
+ }
157
+
158
+ function names(raw: unknown, label: string, pattern: RegExp): string[] {
159
+ if (raw === undefined) return [];
160
+ if (!Array.isArray(raw) || !raw.every((entry) => typeof entry === "string" && pattern.test(entry))) {
161
+ throw new Error(`reader-steering plugin ${label} must be a list of lowercase names matching ${pattern}`);
162
+ }
163
+ return [...new Set(raw as string[])];
164
+ }
165
+
166
+ function lowercaseName(raw: unknown, label: string, fallback: string): string {
167
+ const value = raw === undefined ? fallback : raw;
168
+ if (typeof value !== "string" || !SUBMISSION_KIND_NAME.test(value)) throw new Error(`reader-steering plugin ${label} must be a lowercase name matching ${SUBMISSION_KIND_NAME}`);
169
+ return value;
170
+ }
171
+
172
+ function resolveHypeGap(raw: unknown, dimensions: string[]): { plus: string[]; minus: string[] } | undefined {
173
+ if (raw === undefined) return undefined;
174
+ const groups = object(raw, "hypeGap", ["plus", "minus"]);
175
+ const plus = names(groups.plus, "hypeGap.plus", READER_DIMENSION_NAME);
176
+ const minus = names(groups.minus, "hypeGap.minus", READER_DIMENSION_NAME);
177
+ if (plus.length === 0 || minus.length === 0) throw new Error("reader-steering plugin hypeGap needs at least one plus and one minus dimension");
178
+ const unknown = [...plus, ...minus].filter((dimension) => !dimensions.includes(dimension));
179
+ if (unknown.length > 0) throw new Error(`reader-steering plugin hypeGap uses dimensions the site does not rate: ${unknown.join(", ")}`);
180
+ const both = plus.filter((dimension) => minus.includes(dimension));
181
+ if (both.length > 0) throw new Error(`reader-steering plugin hypeGap dimensions cannot be both plus and minus: ${both.join(", ")}`);
182
+ return { plus, minus };
183
+ }
184
+
185
+ export function resolveReaderSteeringConfig(request: PluginRequest): ReaderSteeringConfig {
186
+ const raw = typeof request === "string" ? { name: request } : request;
187
+ const unknown = Object.keys(raw).filter((key) => !KNOWN_KEYS.has(key));
188
+ if (unknown.length > 0) throw new Error(`Unknown reader-steering plugin settings: ${unknown.join(", ")}`);
189
+ const computeRoleArn = raw.computeRoleArn;
190
+ if (typeof computeRoleArn !== "string" || !COMPUTE_ROLE_ARN.test(computeRoleArn)) {
191
+ throw new Error("reader-steering plugin computeRoleArn must be the IAM role ARN of the CMS app's compute role (the CmsComputeRoleArn output)");
192
+ }
193
+ const dimensions = names(raw.dimensions, "dimensions", READER_DIMENSION_NAME);
194
+ const reserved = dimensions.filter((dimension) => RESERVED_DIMENSIONS.includes(dimension));
195
+ if (reserved.length > 0) throw new Error(`reader-steering plugin dimensions cannot use the reserved names: ${reserved.join(", ")}`);
196
+ const limits = object(raw.limits, "limits", ["actionsPerHour", "newAccountHours", "newAccountDivisor"]);
197
+ const karma = object(raw.karma, "karma", ["upvoteWeight", "usedItWeight"]);
198
+ const ranking = object(raw.ranking, "ranking", [
199
+ "halfLifeHours", "risingWindowHours", "readerMinimum", "editorWeight", "combination", "shrinkageK", "readerShareCap", "newAccountWeight", "risingMinimumAccountHours",
200
+ ]);
201
+ const combination = ranking.combination === undefined ? "blend" : ranking.combination;
202
+ if (combination !== "blend" && combination !== "shrinkage") throw new Error("reader-steering plugin ranking.combination must be blend or shrinkage");
203
+ const shrinking = combination === "shrinkage";
204
+ const snapshot = object(raw.snapshot, "snapshot", ["schedule", "intervalHours", "hypeMinimum"]);
205
+ const submissions = object(raw.submissions, "submissions", ["perDay", "similarHours", "kinds", "itemType", "section"]);
206
+ const moderation = object(raw.moderation, "moderation", ["mode", "preModeration", "reportsPerDay", "ai"]);
207
+ const mode = moderation.mode === undefined ? "human" : moderation.mode;
208
+ if (!MODERATION_MODES.includes(mode as (typeof MODERATION_MODES)[number])) {
209
+ throw new Error(`reader-steering plugin moderation.mode must be one of: ${MODERATION_MODES.join(", ")}`);
210
+ }
211
+ const preModeration = flag(moderation.preModeration, "moderation.preModeration");
212
+ if (preModeration && mode === "off") throw new Error("reader-steering plugin moderation.preModeration needs a moderation.mode other than off");
213
+ const ai = object(moderation.ai, "moderation.ai", ["model", "apiKeyParameter"]);
214
+ if (mode !== "ai" && moderation.ai !== undefined) throw new Error("reader-steering plugin moderation.ai needs moderation.mode: ai");
215
+ if (mode === "ai") {
216
+ if (typeof ai.apiKeyParameter !== "string" || !AI_KEY_PARAMETER.test(ai.apiKeyParameter)) {
217
+ throw new Error("reader-steering plugin moderation.ai.apiKeyParameter must be the SSM SecureString name /papyrus/<site>/openai-api-key");
218
+ }
219
+ if (ai.model !== undefined && (typeof ai.model !== "string" || !AI_MODEL_NAME.test(ai.model))) {
220
+ throw new Error(`reader-steering plugin moderation.ai.model must be a model name matching ${AI_MODEL_NAME}`);
221
+ }
222
+ }
223
+ const subjects = object(raw.subjects, "subjects", ["types"]);
224
+ const subjectTypes = subjects.types === undefined ? ["item", "submission"] : subjects.types;
225
+ if (!Array.isArray(subjectTypes) || subjectTypes.length === 0 || !subjectTypes.every((type) => (SUBJECT_TYPES as readonly unknown[]).includes(type))) {
226
+ throw new Error(`reader-steering plugin subjects.types must be a non-empty list of: ${SUBJECT_TYPES.join(", ")}`);
227
+ }
228
+ const proposals = object(raw.proposals, "proposals", ["publish"]);
229
+ const kinds = names(submissions.kinds, "submissions.kinds", SUBMISSION_KIND_NAME);
230
+ const comments = object(raw.comments, "comments", ["perHour", "editWindowMinutes", "maximumLength", "maximumLinks"]);
231
+ const hypeGap = resolveHypeGap(raw.hypeGap, dimensions);
232
+ const retirementPoll = flag(raw.retirementPoll, "retirementPoll");
233
+ if (retirementPoll && !flag(snapshot.schedule, "snapshot.schedule")) {
234
+ throw new Error("reader-steering plugin retirementPoll runs inside the scheduled snapshot, so it needs snapshot.schedule: true");
235
+ }
236
+ return {
237
+ computeRoleArn,
238
+ dimensions,
239
+ limits: {
240
+ actionsPerHour: integer(limits.actionsPerHour, "limits.actionsPerHour", 120, 1, 10000),
241
+ newAccountHours: integer(limits.newAccountHours, "limits.newAccountHours", 24, 0, 24 * 90),
242
+ newAccountDivisor: integer(limits.newAccountDivisor, "limits.newAccountDivisor", 3, 1, 100),
243
+ },
244
+ reservedHandles: names(raw.reservedHandles, "reservedHandles", READER_HANDLE_NAME),
245
+ karma: {
246
+ upvoteWeight: number(karma.upvoteWeight, "karma.upvoteWeight", 1, 0, 1000),
247
+ usedItWeight: number(karma.usedItWeight, "karma.usedItWeight", 2, 0, 1000),
248
+ },
249
+ ranking: {
250
+ halfLifeHours: number(ranking.halfLifeHours, "ranking.halfLifeHours", 48, 1, 24 * 365),
251
+ risingWindowHours: number(ranking.risingWindowHours, "ranking.risingWindowHours", 6, 1, 24 * 30),
252
+ readerMinimum: integer(ranking.readerMinimum, "ranking.readerMinimum", 5, 1, 10000),
253
+ editorWeight: number(ranking.editorWeight, "ranking.editorWeight", 0.5, 0, 1),
254
+ combination,
255
+ shrinkageK: number(ranking.shrinkageK, "ranking.shrinkageK", 20, 0.01, 10000),
256
+ readerShareCap: number(ranking.readerShareCap, "ranking.readerShareCap", 0.4, 0, 1),
257
+ newAccountWeight: number(ranking.newAccountWeight, "ranking.newAccountWeight", shrinking ? 1 / 3 : 1, 0, 1),
258
+ risingMinimumAccountHours: number(ranking.risingMinimumAccountHours, "ranking.risingMinimumAccountHours", shrinking ? 24 : 0, 0, 24 * 90),
259
+ },
260
+ snapshot: {
261
+ schedule: flag(snapshot.schedule, "snapshot.schedule"),
262
+ intervalHours: integer(snapshot.intervalHours, "snapshot.intervalHours", 6, 1, 24),
263
+ hypeMinimum: integer(snapshot.hypeMinimum, "snapshot.hypeMinimum", 5, 1, 10000),
264
+ },
265
+ submissions: {
266
+ perDay: integer(submissions.perDay, "submissions.perDay", 10, 1, 1000),
267
+ similarHours: integer(submissions.similarHours, "submissions.similarHours", 48, 1, 24 * 30),
268
+ kinds: kinds.length > 0 ? kinds : ["link"],
269
+ itemType: lowercaseName(submissions.itemType, "submissions.itemType", "brief"),
270
+ section: lowercaseName(submissions.section, "submissions.section", "unsectioned"),
271
+ },
272
+ subjects: { types: [...new Set(subjectTypes)] as (typeof SUBJECT_TYPES)[number][] },
273
+ moderation: {
274
+ mode: mode as (typeof MODERATION_MODES)[number],
275
+ preModeration,
276
+ reportsPerDay: integer(moderation.reportsPerDay, "moderation.reportsPerDay", 20, 1, 1000),
277
+ ...(mode === "ai" ? { ai: { model: (ai.model as string | undefined) ?? "gpt-6-luna", apiKeyParameter: ai.apiKeyParameter as string } } : {}),
278
+ },
279
+ comments: {
280
+ perHour: integer(comments.perHour, "comments.perHour", 30, 1, 1000),
281
+ editWindowMinutes: integer(comments.editWindowMinutes, "comments.editWindowMinutes", 10, 0, 24 * 60),
282
+ maximumLength: integer(comments.maximumLength, "comments.maximumLength", 2000, 1, 10000),
283
+ maximumLinks: integer(comments.maximumLinks, "comments.maximumLinks", 3, 0, 50),
284
+ },
285
+ proposals: { publish: flag(proposals.publish, "proposals.publish") },
286
+ retirementPoll,
287
+ ...(hypeGap ? { hypeGap } : {}),
288
+ ...(raw.reservedConcurrency === undefined ? {} : { reservedConcurrency: integer(raw.reservedConcurrency, "reservedConcurrency", 1, 1, 1000) }),
289
+ };
290
+ }