@anthusai/papyrus 5.30.0 → 5.32.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 (31) hide show
  1. package/amplify/data/resource.js +19 -0
  2. package/amplify/data/resource.ts +20 -0
  3. package/amplify/data/schema.js +21 -0
  4. package/amplify/data/schema.ts +22 -0
  5. package/amplify/functions/automation-view/handler.py +11 -0
  6. package/amplify/functions/automation-view/resource.js +24 -0
  7. package/amplify/functions/automation-view/resource.ts +34 -0
  8. package/amplify/functions/automation-view/schema.js +16 -0
  9. package/amplify/functions/automation-view/schema.ts +18 -0
  10. package/amplify/functions/automation-view/wire.js +32 -0
  11. package/amplify/functions/automation-view/wire.ts +46 -0
  12. package/amplify/plugins/reader-steering/config.js +8 -1
  13. package/amplify/plugins/reader-steering/config.ts +13 -1
  14. package/amplify/plugins/reader-steering/function/handler.py +209 -18
  15. package/amplify/plugins/reader-steering/function/proposals.py +255 -0
  16. package/amplify/plugins/reader-steering/papyrus.plugin.js +10 -2
  17. package/amplify/plugins/reader-steering/papyrus.plugin.ts +10 -2
  18. package/amplify/plugins/reader-steering/schema.js +65 -1
  19. package/amplify/plugins/reader-steering/schema.ts +65 -1
  20. package/amplify/site-backend.js +4 -0
  21. package/amplify/site-backend.ts +4 -0
  22. package/app/api/reader/flag/route.ts +9 -0
  23. package/components/automation-panel.tsx +39 -0
  24. package/components/automation-view.tsx +149 -0
  25. package/components/topic-steering-workspace.tsx +13 -2
  26. package/lib/automation-client.ts +29 -0
  27. package/lib/automation-fixture.ts +65 -0
  28. package/lib/automation-overview.ts +138 -0
  29. package/lib/reader-api.ts +1 -1
  30. package/package.json +7 -1
  31. package/routes.manifest.json +12 -1
@@ -1163,6 +1163,25 @@ const schema = a.schema({
1163
1163
  allow.group("editor").to(["read"]),
1164
1164
  allow.group(adminGroup).to(authoringOperations),
1165
1165
  ]),
1166
+ Automation: a
1167
+ .model({
1168
+ id: a.id().required(),
1169
+ title: a.string().required(),
1170
+ description: a.string(),
1171
+ enabled: a.boolean().required(),
1172
+ trigger: a.json().required(),
1173
+ procedureKey: a.string().required(),
1174
+ inputTemplate: a.json(),
1175
+ limits: a.json(),
1176
+ createdBy: a.string(),
1177
+ createdAt: a.datetime().required(),
1178
+ updatedBy: a.string(),
1179
+ updatedAt: a.datetime().required(),
1180
+ })
1181
+ .authorization((allow) => [
1182
+ allow.group("editor").to(["read", "create", "update"]),
1183
+ allow.group(adminGroup).to(authoringOperations),
1184
+ ]),
1166
1185
  NewsroomSection: a
1167
1186
  .model({
1168
1187
  id: a.id().required(),
@@ -1259,6 +1259,26 @@ const schema = a.schema({
1259
1259
  allow.group(adminGroup).to(authoringOperations),
1260
1260
  ]),
1261
1261
 
1262
+ Automation: a
1263
+ .model({
1264
+ id: a.id().required(),
1265
+ title: a.string().required(),
1266
+ description: a.string(),
1267
+ enabled: a.boolean().required(),
1268
+ trigger: a.json().required(),
1269
+ procedureKey: a.string().required(),
1270
+ inputTemplate: a.json(),
1271
+ limits: a.json(),
1272
+ createdBy: a.string(),
1273
+ createdAt: a.datetime().required(),
1274
+ updatedBy: a.string(),
1275
+ updatedAt: a.datetime().required(),
1276
+ })
1277
+ .authorization((allow) => [
1278
+ allow.group("editor").to(["read", "create", "update"]),
1279
+ allow.group(adminGroup).to(authoringOperations),
1280
+ ]),
1281
+
1262
1282
  NewsroomSection: a
1263
1283
  .model({
1264
1284
  id: a.id().required(),
@@ -2,6 +2,7 @@ import { a } from "@aws-amplify/backend";
2
2
  import { assignmentAction } from "../functions/assignment-action/resource.js";
3
3
  import { categoryAction } from "../functions/category-action/resource.js";
4
4
  import { editorialChecksSchemaDefinitions } from "../functions/editorial-checks/schema.js";
5
+ import { automationViewSchemaDefinitions } from "../functions/automation-view/schema.js";
5
6
  import { monitoringViewSchemaDefinitions } from "../functions/monitoring-view/schema.js";
6
7
  import { contentActions } from "../functions/content-actions/resource.js";
7
8
  import { knowledgeQuery } from "../functions/knowledge-query/resource.js";
@@ -1082,6 +1083,25 @@ function buildBaseDefinitions(includeEditions) {
1082
1083
  allow.group("editor").to(["read"]),
1083
1084
  allow.group(adminGroup).to(authoringOperations),
1084
1085
  ]),
1086
+ /** A named "when X happens, run procedure Y" (PPY-9ff95b). Step 1 mirrors the monitoring router's procedureTriggers and starts nothing itself. */
1087
+ Automation: modelWithoutSubscriptions({
1088
+ id: a.id().required(),
1089
+ title: a.string().required(),
1090
+ description: a.string(),
1091
+ enabled: a.boolean().required(),
1092
+ trigger: a.json().required(),
1093
+ procedureKey: a.string().required(),
1094
+ inputTemplate: a.json(),
1095
+ limits: a.json(),
1096
+ createdBy: a.string(),
1097
+ createdAt: a.datetime().required(),
1098
+ updatedBy: a.string(),
1099
+ updatedAt: a.datetime().required(),
1100
+ })
1101
+ .authorization((allow) => [
1102
+ allow.group("editor").to(["read", "create", "update"]),
1103
+ allow.group(adminGroup).to(authoringOperations),
1104
+ ]),
1085
1105
  NewsroomSection: modelWithoutSubscriptions({
1086
1106
  id: a.id().required(),
1087
1107
  tenantId: a.string(),
@@ -2021,6 +2041,7 @@ function buildBaseDefinitions(includeEditions) {
2021
2041
  ]),
2022
2042
  ...editorialChecksSchemaDefinitions,
2023
2043
  ...monitoringViewSchemaDefinitions,
2044
+ ...automationViewSchemaDefinitions,
2024
2045
  };
2025
2046
  if (!includeEditions) {
2026
2047
  const mutableDefinitions = definitions;
@@ -2,6 +2,7 @@ import { type ClientSchema, a } from "@aws-amplify/backend";
2
2
  import { assignmentAction } from "../functions/assignment-action/resource";
3
3
  import { categoryAction } from "../functions/category-action/resource";
4
4
  import { editorialChecksSchemaDefinitions } from "../functions/editorial-checks/schema";
5
+ import { automationViewSchemaDefinitions } from "../functions/automation-view/schema";
5
6
  import { monitoringViewSchemaDefinitions } from "../functions/monitoring-view/schema";
6
7
  import { contentActions } from "../functions/content-actions/resource";
7
8
  import { knowledgeQuery } from "../functions/knowledge-query/resource";
@@ -1176,6 +1177,26 @@ const definitions = {
1176
1177
  allow.group(adminGroup).to(authoringOperations),
1177
1178
  ]),
1178
1179
 
1180
+ /** A named "when X happens, run procedure Y" (PPY-9ff95b). Step 1 mirrors the monitoring router's procedureTriggers and starts nothing itself. */
1181
+ Automation: modelWithoutSubscriptions({
1182
+ id: a.id().required(),
1183
+ title: a.string().required(),
1184
+ description: a.string(),
1185
+ enabled: a.boolean().required(),
1186
+ trigger: a.json().required(),
1187
+ procedureKey: a.string().required(),
1188
+ inputTemplate: a.json(),
1189
+ limits: a.json(),
1190
+ createdBy: a.string(),
1191
+ createdAt: a.datetime().required(),
1192
+ updatedBy: a.string(),
1193
+ updatedAt: a.datetime().required(),
1194
+ })
1195
+ .authorization((allow) => [
1196
+ allow.group("editor").to(["read", "create", "update"]),
1197
+ allow.group(adminGroup).to(authoringOperations),
1198
+ ]),
1199
+
1179
1200
  NewsroomSection: modelWithoutSubscriptions({
1180
1201
  id: a.id().required(),
1181
1202
  tenantId: a.string(),
@@ -2145,6 +2166,7 @@ const definitions = {
2145
2166
  ]),
2146
2167
  ...editorialChecksSchemaDefinitions,
2147
2168
  ...monitoringViewSchemaDefinitions,
2169
+ ...automationViewSchemaDefinitions,
2148
2170
  };
2149
2171
  if (!includeEditions) {
2150
2172
  const mutableDefinitions = definitions as Record<string, unknown>;
@@ -0,0 +1,11 @@
1
+ """The automation-view function: `automationOverview` for editors and admins, read-only."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from papyrus_content.automation_view import handle
8
+
9
+
10
+ def handler(event: dict[str, Any], _context: Any = None) -> dict[str, Any]:
11
+ return handle(event)
@@ -0,0 +1,24 @@
1
+ import { defineFunction } from "@aws-amplify/backend";
2
+ import { Duration } from "aws-cdk-lib";
3
+ import { Architecture, Function, Runtime } from "aws-cdk-lib/aws-lambda";
4
+ import { pythonLambdaCode } from "../shared/python-bundle.js";
5
+ /** AppSync gives a resolver 30 seconds; a table read and ten run lookups need a few. */
6
+ export const AUTOMATION_VIEW_TIMEOUT_SECONDS = 25;
7
+ /** stdlib plus the runtime's boto3: the bundle is `papyrus_content` and nothing else. */
8
+ export const automationView = defineFunction((scope) => new Function(scope, "papyrus-automation-view", {
9
+ runtime: Runtime.PYTHON_3_12,
10
+ architecture: Architecture.ARM_64,
11
+ handler: "handler.handler",
12
+ timeout: Duration.seconds(AUTOMATION_VIEW_TIMEOUT_SECONDS),
13
+ memorySize: 256,
14
+ code: pythonLambdaCode({ functionDir: "automation-view", modules: ["papyrus_content"], noDeps: true }, [
15
+ "bash",
16
+ "-c",
17
+ [
18
+ "set -euo pipefail",
19
+ "mkdir -p /asset-output/papyrus_content",
20
+ "cp amplify/functions/automation-view/handler.py /asset-output/handler.py",
21
+ "cp -R src/papyrus_content/. /asset-output/papyrus_content/",
22
+ ].join(" && "),
23
+ ]),
24
+ }), { resourceGroupName: "data" });
@@ -0,0 +1,34 @@
1
+ import { defineFunction } from "@aws-amplify/backend";
2
+ import { Duration } from "aws-cdk-lib";
3
+ import { Architecture, Function, Runtime } from "aws-cdk-lib/aws-lambda";
4
+ import type { Construct } from "constructs";
5
+ import { pythonLambdaCode } from "../shared/python-bundle";
6
+
7
+ /** AppSync gives a resolver 30 seconds; a table read and ten run lookups need a few. */
8
+ export const AUTOMATION_VIEW_TIMEOUT_SECONDS = 25;
9
+
10
+ /** stdlib plus the runtime's boto3: the bundle is `papyrus_content` and nothing else. */
11
+ export const automationView = defineFunction(
12
+ (scope: Construct) =>
13
+ new Function(scope, "papyrus-automation-view", {
14
+ runtime: Runtime.PYTHON_3_12,
15
+ architecture: Architecture.ARM_64,
16
+ handler: "handler.handler",
17
+ timeout: Duration.seconds(AUTOMATION_VIEW_TIMEOUT_SECONDS),
18
+ memorySize: 256,
19
+ code: pythonLambdaCode(
20
+ { functionDir: "automation-view", modules: ["papyrus_content"], noDeps: true },
21
+ [
22
+ "bash",
23
+ "-c",
24
+ [
25
+ "set -euo pipefail",
26
+ "mkdir -p /asset-output/papyrus_content",
27
+ "cp amplify/functions/automation-view/handler.py /asset-output/handler.py",
28
+ "cp -R src/papyrus_content/. /asset-output/papyrus_content/",
29
+ ].join(" && "),
30
+ ],
31
+ ),
32
+ }),
33
+ { resourceGroupName: "data" },
34
+ );
@@ -0,0 +1,16 @@
1
+ import { a } from "@aws-amplify/backend";
2
+ import { automationView } from "./resource.js";
3
+ const editors = ["editor", "admin"];
4
+ /**
5
+ * Core to every site's data API. The Automation page reads the automation records, the monitoring
6
+ * trigger rows they link to, and the procedure runs through this one query. AppSync admits editors
7
+ * and admins only; the function checks the groups again.
8
+ */
9
+ export const automationViewSchemaDefinitions = {
10
+ automationOverview: a
11
+ .query()
12
+ .arguments({ automationId: a.string() })
13
+ .returns(a.json())
14
+ .authorization((allow) => [allow.groups(editors)])
15
+ .handler(a.handler.function(automationView)),
16
+ };
@@ -0,0 +1,18 @@
1
+ import { a } from "@aws-amplify/backend";
2
+ import { automationView } from "./resource";
3
+
4
+ const editors = ["editor", "admin"];
5
+
6
+ /**
7
+ * Core to every site's data API. The Automation page reads the automation records, the monitoring
8
+ * trigger rows they link to, and the procedure runs through this one query. AppSync admits editors
9
+ * and admins only; the function checks the groups again.
10
+ */
11
+ export const automationViewSchemaDefinitions = {
12
+ automationOverview: a
13
+ .query()
14
+ .arguments({ automationId: a.string() })
15
+ .returns(a.json())
16
+ .authorization((allow) => [allow.groups(editors)])
17
+ .handler(a.handler.function(automationView)),
18
+ };
@@ -0,0 +1,32 @@
1
+ import { Stack } from "aws-cdk-lib";
2
+ import { Effect, PolicyStatement } from "aws-cdk-lib/aws-iam";
3
+ import { MONITORING_TABLE_PARAMETER, MONITORING_VIEW_TABLE_ACTIONS, monitoringViewParameterArn, monitoringViewTableArns, } from "../monitoring-view/wire.js";
4
+ /** The two reads the function makes of its own site's API with its own role. It writes nothing. */
5
+ export const AUTOMATION_VIEW_FUNCTION_FIELDS = ["Query/fields/listAutomations", "Query/fields/getProcedureRun"];
6
+ /**
7
+ * Environment and least-privilege IAM for the automation-view function: two fields of the site's own API,
8
+ * and the same read-only grant on the monitoring table that the Monitoring page has. A site outside the
9
+ * monitoring account finds no parameter and the page lists its records without runs.
10
+ */
11
+ export function wireAutomationView(backend) {
12
+ const lambda = backend.automationView.resources.lambda;
13
+ const stack = Stack.of(lambda);
14
+ lambda.addEnvironment("PAPYRUS_GRAPHQL_ENDPOINT", backend.data.resources.cfnResources.cfnGraphqlApi.attrGraphQlUrl);
15
+ lambda.addEnvironment("PAPYRUS_MONITORING_TABLE_PARAMETER", MONITORING_TABLE_PARAMETER);
16
+ const apiId = backend.data.resources.graphqlApi.apiId;
17
+ lambda.addToRolePolicy(new PolicyStatement({
18
+ effect: Effect.ALLOW,
19
+ actions: ["appsync:GraphQL"],
20
+ resources: AUTOMATION_VIEW_FUNCTION_FIELDS.map((field) => `arn:aws:appsync:${stack.region}:${stack.account}:apis/${apiId}/types/${field}`),
21
+ }));
22
+ lambda.addToRolePolicy(new PolicyStatement({
23
+ effect: Effect.ALLOW,
24
+ actions: ["ssm:GetParameter"],
25
+ resources: [monitoringViewParameterArn(stack.region, stack.account)],
26
+ }));
27
+ lambda.addToRolePolicy(new PolicyStatement({
28
+ effect: Effect.ALLOW,
29
+ actions: MONITORING_VIEW_TABLE_ACTIONS,
30
+ resources: monitoringViewTableArns(stack.region, stack.account),
31
+ }));
32
+ }
@@ -0,0 +1,46 @@
1
+ import { Stack } from "aws-cdk-lib";
2
+ import { Effect, PolicyStatement } from "aws-cdk-lib/aws-iam";
3
+ import type { Function as LambdaFunction } from "aws-cdk-lib/aws-lambda";
4
+ import {
5
+ MONITORING_TABLE_PARAMETER,
6
+ MONITORING_VIEW_TABLE_ACTIONS,
7
+ monitoringViewParameterArn,
8
+ monitoringViewTableArns,
9
+ } from "../monitoring-view/wire";
10
+
11
+ /** The two reads the function makes of its own site's API with its own role. It writes nothing. */
12
+ export const AUTOMATION_VIEW_FUNCTION_FIELDS = ["Query/fields/listAutomations", "Query/fields/getProcedureRun"];
13
+
14
+ /**
15
+ * Environment and least-privilege IAM for the automation-view function: two fields of the site's own API,
16
+ * and the same read-only grant on the monitoring table that the Monitoring page has. A site outside the
17
+ * monitoring account finds no parameter and the page lists its records without runs.
18
+ */
19
+ export function wireAutomationView(backend: any): void {
20
+ const lambda = backend.automationView.resources.lambda as LambdaFunction;
21
+ const stack = Stack.of(lambda);
22
+ lambda.addEnvironment("PAPYRUS_GRAPHQL_ENDPOINT", backend.data.resources.cfnResources.cfnGraphqlApi.attrGraphQlUrl as string);
23
+ lambda.addEnvironment("PAPYRUS_MONITORING_TABLE_PARAMETER", MONITORING_TABLE_PARAMETER);
24
+ const apiId = backend.data.resources.graphqlApi.apiId as string;
25
+ lambda.addToRolePolicy(
26
+ new PolicyStatement({
27
+ effect: Effect.ALLOW,
28
+ actions: ["appsync:GraphQL"],
29
+ resources: AUTOMATION_VIEW_FUNCTION_FIELDS.map((field) => `arn:aws:appsync:${stack.region}:${stack.account}:apis/${apiId}/types/${field}`),
30
+ }),
31
+ );
32
+ lambda.addToRolePolicy(
33
+ new PolicyStatement({
34
+ effect: Effect.ALLOW,
35
+ actions: ["ssm:GetParameter"],
36
+ resources: [monitoringViewParameterArn(stack.region, stack.account)],
37
+ }),
38
+ );
39
+ lambda.addToRolePolicy(
40
+ new PolicyStatement({
41
+ effect: Effect.ALLOW,
42
+ actions: MONITORING_VIEW_TABLE_ACTIONS,
43
+ resources: monitoringViewTableArns(stack.region, stack.account),
44
+ }),
45
+ );
46
+ }
@@ -5,8 +5,9 @@ export const MODERATION_MODES = ["off", "human", "ai"];
5
5
  export const AI_KEY_PARAMETER = /^\/papyrus\/[a-z0-9][a-z0-9-]{0,62}\/openai-api-key$/;
6
6
  export const AI_MODEL_NAME = /^[a-z0-9][a-z0-9.-]{0,63}$/;
7
7
  export const SUBMISSION_KIND_NAME = /^[a-z][a-z0-9-]{0,23}$/;
8
+ export const SUBJECT_TYPES = ["item", "submission", "story", "proposal", "concept"];
8
9
  export const COMPUTE_ROLE_ARN = /^arn:aws:iam::[0-9]{12}:role\/[A-Za-z0-9+=,.@_/-]{1,512}$/;
9
- const KNOWN_KEYS = new Set(["name", "computeRoleArn", "dimensions", "limits", "reservedHandles", "karma", "ranking", "snapshot", "retirementPoll", "reservedConcurrency", "hypeGap", "submissions", "comments", "moderation"]);
10
+ const KNOWN_KEYS = new Set(["name", "computeRoleArn", "dimensions", "limits", "reservedHandles", "karma", "ranking", "snapshot", "retirementPoll", "reservedConcurrency", "hypeGap", "submissions", "comments", "moderation", "subjects"]);
10
11
  function object(raw, label, known) {
11
12
  if (raw === undefined)
12
13
  return {};
@@ -111,6 +112,11 @@ export function resolveReaderSteeringConfig(request) {
111
112
  throw new Error(`reader-steering plugin moderation.ai.model must be a model name matching ${AI_MODEL_NAME}`);
112
113
  }
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
+ }
114
120
  const kinds = names(submissions.kinds, "submissions.kinds", SUBMISSION_KIND_NAME);
115
121
  const comments = object(raw.comments, "comments", ["perHour", "editWindowMinutes", "maximumLength", "maximumLinks"]);
116
122
  const hypeGap = resolveHypeGap(raw.hypeGap, dimensions);
@@ -154,6 +160,7 @@ export function resolveReaderSteeringConfig(request) {
154
160
  itemType: lowercaseName(submissions.itemType, "submissions.itemType", "brief"),
155
161
  section: lowercaseName(submissions.section, "submissions.section", "unsectioned"),
156
162
  },
163
+ subjects: { types: [...new Set(subjectTypes)] },
157
164
  moderation: {
158
165
  mode: mode,
159
166
  preModeration,
@@ -7,6 +7,7 @@ export const MODERATION_MODES = ["off", "human", "ai"] as const;
7
7
  export const AI_KEY_PARAMETER = /^\/papyrus\/[a-z0-9][a-z0-9-]{0,62}\/openai-api-key$/;
8
8
  export const AI_MODEL_NAME = /^[a-z0-9][a-z0-9.-]{0,63}$/;
9
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;
10
11
  export const COMPUTE_ROLE_ARN = /^arn:aws:iam::[0-9]{12}:role\/[A-Za-z0-9+=,.@_/-]{1,512}$/;
11
12
 
12
13
  export type ReaderSteeringConfig = {
@@ -63,6 +64,11 @@ export type ReaderSteeringConfig = {
63
64
  /** The section of that draft. */
64
65
  section: string;
65
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][] };
66
72
  moderation: {
67
73
  /**
68
74
  * `human`: new submissions and comments are public at once and wait in the Administration queue (the default for a pilot).
@@ -109,7 +115,7 @@ export type ReaderSteeringConfig = {
109
115
  retirementPoll: boolean;
110
116
  };
111
117
 
112
- const KNOWN_KEYS = new Set(["name", "computeRoleArn", "dimensions", "limits", "reservedHandles", "karma", "ranking", "snapshot", "retirementPoll", "reservedConcurrency", "hypeGap", "submissions", "comments", "moderation"]);
118
+ const KNOWN_KEYS = new Set(["name", "computeRoleArn", "dimensions", "limits", "reservedHandles", "karma", "ranking", "snapshot", "retirementPoll", "reservedConcurrency", "hypeGap", "submissions", "comments", "moderation", "subjects"]);
113
119
 
114
120
  function object(raw: unknown, label: string, known: string[]): Record<string, unknown> {
115
121
  if (raw === undefined) return {};
@@ -206,6 +212,11 @@ export function resolveReaderSteeringConfig(request: PluginRequest): ReaderSteer
206
212
  throw new Error(`reader-steering plugin moderation.ai.model must be a model name matching ${AI_MODEL_NAME}`);
207
213
  }
208
214
  }
215
+ const subjects = object(raw.subjects, "subjects", ["types"]);
216
+ const subjectTypes = subjects.types === undefined ? ["item", "submission"] : subjects.types;
217
+ if (!Array.isArray(subjectTypes) || subjectTypes.length === 0 || !subjectTypes.every((type) => (SUBJECT_TYPES as readonly unknown[]).includes(type))) {
218
+ throw new Error(`reader-steering plugin subjects.types must be a non-empty list of: ${SUBJECT_TYPES.join(", ")}`);
219
+ }
209
220
  const kinds = names(submissions.kinds, "submissions.kinds", SUBMISSION_KIND_NAME);
210
221
  const comments = object(raw.comments, "comments", ["perHour", "editWindowMinutes", "maximumLength", "maximumLinks"]);
211
222
  const hypeGap = resolveHypeGap(raw.hypeGap, dimensions);
@@ -249,6 +260,7 @@ export function resolveReaderSteeringConfig(request: PluginRequest): ReaderSteer
249
260
  itemType: lowercaseName(submissions.itemType, "submissions.itemType", "brief"),
250
261
  section: lowercaseName(submissions.section, "submissions.section", "unsectioned"),
251
262
  },
263
+ subjects: { types: [...new Set(subjectTypes)] as (typeof SUBJECT_TYPES)[number][] },
252
264
  moderation: {
253
265
  mode: mode as (typeof MODERATION_MODES)[number],
254
266
  preModeration,