@anthusai/papyrus 5.43.0 → 5.45.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 (61) hide show
  1. package/amplify/functions/category-action/handler.ts +2 -1
  2. package/amplify/functions/model-attachment-upload/handler.ts +1 -0
  3. package/amplify/functions/monitoring-view/schema.js +16 -0
  4. package/amplify/functions/monitoring-view/schema.ts +16 -0
  5. package/amplify/functions/procedure-action/handler.ts +2 -10
  6. package/amplify/functions/proposal-actions/proposals.py +20 -3
  7. package/amplify/plugins/reader-steering/function/handler.py +1 -3
  8. package/amplify/plugins/reader-steering/function/moderation.py +19 -0
  9. package/bin/papyrus-infra.mjs +21 -1
  10. package/components/dev-sandbox-editor-sign-in.tsx +2 -2
  11. package/components/monitoring-overview-view.tsx +143 -21
  12. package/components/monitoring-panel.tsx +64 -4
  13. package/components/news-desk-client-provider.tsx +24 -3
  14. package/components/news-desk-page.tsx +2 -1
  15. package/components/news-desk-taxonomy-client.ts +2 -1
  16. package/components/newsroom-access-gate.tsx +7 -5
  17. package/components/newsroom-client-shell.tsx +2 -1
  18. package/components/newsroom-ops-overview.tsx +6 -1
  19. package/components/newsroom-ops-shell.tsx +1 -1
  20. package/components/newsroom-organizer.css +48 -0
  21. package/components/newsroom-references-view.tsx +183 -24
  22. package/components/notification-center.tsx +345 -63
  23. package/components/papyrus-console-shell.tsx +2 -1
  24. package/components/proposals-panel.tsx +21 -24
  25. package/components/proposals-view.tsx +4 -2
  26. package/components/reference-feed.tsx +469 -0
  27. package/components/reference-review-card.tsx +90 -76
  28. package/components/references-page-loading.tsx +61 -0
  29. package/components/references-prefetch.ts +47 -0
  30. package/components/review-rating-grid.tsx +164 -0
  31. package/components/topic-steering-workspace.tsx +32 -8
  32. package/infra/amplify-app-shell.js +18 -2
  33. package/infra/amplify-app-shell.ts +18 -2
  34. package/infra/chat-stack.js +222 -0
  35. package/infra/chat-stack.ts +250 -0
  36. package/infra/chat.test.js +142 -0
  37. package/infra/chat.test.ts +151 -0
  38. package/infra/index.js +1 -0
  39. package/infra/site-config.js +58 -1
  40. package/infra/site-config.ts +78 -1
  41. package/lib/category-repository.ts +4 -1
  42. package/lib/local-preview-feed-fixture.ts +43 -0
  43. package/lib/local-preview-synthetic-rejects.ts +41 -0
  44. package/lib/monitoring-client.ts +29 -2
  45. package/lib/monitoring-overview.ts +52 -1
  46. package/lib/monitoring-paging.ts +58 -0
  47. package/lib/newsroom-chat-identity.ts +2 -1
  48. package/lib/newsroom-organizer-nav.ts +1 -0
  49. package/lib/proposals-client.ts +29 -0
  50. package/lib/reader-subject.ts +3 -2
  51. package/lib/reference-feed-cache.ts +133 -0
  52. package/lib/reference-feed-ranking.ts +191 -0
  53. package/lib/reference-preview.ts +112 -0
  54. package/lib/review-client.ts +38 -1
  55. package/lib/review-drafts.ts +231 -0
  56. package/lib/review-fixture.ts +36 -2
  57. package/lib/review-requests.ts +64 -24
  58. package/lib/staff-groups.ts +23 -0
  59. package/lib/staging-gate.ts +2 -2
  60. package/package.json +14 -1
  61. package/routes.manifest.json +1 -1
@@ -0,0 +1,151 @@
1
+ import assert from "node:assert/strict";
2
+ import fs from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { App } from "aws-cdk-lib";
6
+ import { AmplifyAppShellStack } from "./amplify-app-shell";
7
+ import { PapyrusChatStack } from "./chat-stack";
8
+ import { parseSiteConfig, resolveChatLogGroupPrefix, resolveChatStackName, resolveStackName } from "./site-config";
9
+
10
+ // Executes infra/amplify-app-shell/features/per-site-private-chat.feature (run through scripts/test-infra-chat.ts).
11
+ const examples = path.resolve(__dirname, "../examples");
12
+ const example = (name: string, change: (config: any) => void = () => {}): any => {
13
+ const config = JSON.parse(fs.readFileSync(path.join(examples, `${name}.site.json`), "utf8"));
14
+ change(config);
15
+ return config;
16
+ };
17
+ const siteNames = fs.readdirSync(examples).filter((name) => name.endsWith(".site.json")).map((name) => name.replace(/\.site\.json$/, ""));
18
+ const lambdaDirectory = fs.mkdtempSync(path.join(os.tmpdir(), "papyrus-chat-test-"));
19
+ for (const file of ["run-worker.js", "probe-worker.js"]) fs.writeFileSync(path.join(lambdaDirectory, file), "exports.handler = async () => ({});\n");
20
+
21
+ const resourcesOf = (template: any, type: string): any[] => Object.values<any>(template.Resources).filter((resource) => resource.Type === type);
22
+ function shellTemplate(raw: unknown): any {
23
+ const config = parseSiteConfig(raw);
24
+ const stack = new AmplifyAppShellStack(new App(), "Shell", config, { env: { account: "712236451410", region: "us-east-1" }, stackName: resolveStackName(config) });
25
+ return JSON.parse(JSON.stringify((App.of(stack) as App).synth().getStackByName(stack.stackName).template));
26
+ }
27
+ function chatTemplate(raw: unknown): any {
28
+ const config = parseSiteConfig(raw);
29
+ const app = new App();
30
+ const stack = new PapyrusChatStack(app, { config, env: { account: "712236451410", region: "us-east-1" }, lambdaAssetDirectory: lambdaDirectory });
31
+ return JSON.parse(JSON.stringify(app.synth().getStackByName(stack.stackName).template));
32
+ }
33
+ const branchEnvironment = (template: any, branchName: string): Record<string, unknown> => {
34
+ const branch = resourcesOf(template, "AWS::Amplify::Branch").find((resource) => resource.Properties.BranchName === branchName);
35
+ return Object.fromEntries(branch.Properties.EnvironmentVariables.map((variable: any) => [variable.Name, variable.Value]));
36
+ };
37
+ const CHAT_NAMES = ["PAPYRUS_CHAT_MESSAGING_TABLE", "PAPYRUS_CHAT_CONVERSATIONS_TABLE", "PAPYRUS_CHAT_SESSION_BUCKET", "PAPYRUS_CHAT_TURN_RUNS_QUEUE_URL", "PAPYRUS_CHAT_TURN_PROBES_QUEUE_URL", "PAPYRUS_CHAT_PRIVATE_BOT_ID", "PAPYRUS_CHAT_PRIVATE_BOT_NAME"];
38
+
39
+ // Scenario: a site with chat enabled gets the chat environment and a data stack
40
+ {
41
+ const raw = example("anth-us");
42
+ assert.equal(raw.chat.modelKeyParameter, "/papyrus/shipped/openai-api-key");
43
+ const config = parseSiteConfig(raw);
44
+ assert.equal(resolveChatStackName(config), "papyrus-chat-anth-us");
45
+ const production = branchEnvironment(shellTemplate(raw), "main");
46
+ assert.equal(production.PAPYRUS_PRIVATE_CHAT_ENABLED, "true");
47
+ assert.deepEqual(production.PAPYRUS_CHAT_MESSAGING_TABLE, { "Fn::ImportValue": "papyrus-chat-anth-us:MessagingTableName" });
48
+ assert.deepEqual(production.PAPYRUS_CHAT_SESSION_BUCKET, { "Fn::ImportValue": "papyrus-chat-anth-us:SessionBucketName" });
49
+ assert.deepEqual(production.PAPYRUS_CHAT_TURN_RUNS_QUEUE_URL, { "Fn::ImportValue": "papyrus-chat-anth-us:TurnRunsQueueUrl" });
50
+ assert.equal(production.PAPYRUS_CHAT_PRIVATE_BOT_ID, "papyrus-private-assistant");
51
+
52
+ const chat = chatTemplate(raw);
53
+ assert.equal(resourcesOf(chat, "AWS::DynamoDB::Table").length, 2);
54
+ const [bucket] = resourcesOf(chat, "AWS::S3::Bucket");
55
+ assert.equal(bucket.Properties.VersioningConfiguration.Status, "Enabled");
56
+ assert.equal(bucket.Properties.PublicAccessBlockConfiguration.BlockPublicPolicy, true);
57
+ assert.equal(resourcesOf(chat, "AWS::SQS::Queue").length, 4);
58
+ assert.equal(resourcesOf(chat, "AWS::Lambda::Function").length, 2);
59
+ const mappings = resourcesOf(chat, "AWS::Lambda::EventSourceMapping");
60
+ assert.equal(mappings.length, 2);
61
+ assert.ok(mappings.every((mapping) => mapping.Properties.Enabled === true));
62
+ for (const key of ["MessagingTableName", "ConversationIndexTableName", "SessionBucketName", "TurnRunsQueueUrl", "TurnProbesQueueUrl"]) {
63
+ assert.equal(chat.Outputs[key].Export.Name, `papyrus-chat-anth-us:${key}`);
64
+ }
65
+ assert.deepEqual(Object.keys(chat.Parameters).filter((name) => name !== "BootstrapVersion").sort(), ["GatewayRoleArn", "PrivateAssistantBotId", "PrivateAssistantBotName"]);
66
+
67
+ // Only the run worker may read the model key parameter; the gateway policy never names it.
68
+ const parameterGrants = resourcesOf(chat, "AWS::IAM::Policy").filter((policy) => JSON.stringify(policy.Properties.PolicyDocument).includes("openai-api-key"));
69
+ assert.equal(parameterGrants.length, 1);
70
+ assert.ok(parameterGrants[0].Properties.Roles.some((role: any) => JSON.stringify(role).includes("RunWorkerRole")));
71
+ assert.equal(JSON.stringify(chat).split("/papyrus/shipped/openai-api-key").length - 1, 3, "the parameter appears in the run worker grant, the run worker environment and one output only");
72
+ const gateway = resourcesOf(chat, "AWS::IAM::Policy").find((policy) => policy.Properties.Roles.some((role: any) => JSON.stringify(role).includes("GatewayRoleArn")));
73
+ assert.ok(gateway, "the gateway policy attaches to the role named by GatewayRoleArn");
74
+ const gatewayActions = gateway.Properties.PolicyDocument.Statement.flatMap((statement: any) => [statement.Action].flat());
75
+ assert.ok(!gatewayActions.some((action: string) => action.startsWith("ssm:") || action.endsWith("*")), "the gateway has no SSM access and no wildcard action");
76
+ }
77
+
78
+ // Scenario: the staging branch never gets private chat
79
+ {
80
+ const staging = branchEnvironment(shellTemplate(example("p-apyr-us")), "staging");
81
+ assert.equal(staging.PAPYRUS_PRIVATE_CHAT_ENABLED, "false");
82
+ assert.deepEqual(CHAT_NAMES.filter((name) => name in staging), []);
83
+ }
84
+
85
+ // Scenario: a site without chat gets neither
86
+ {
87
+ const withoutBlock = example("anth-us", (config) => delete config.chat);
88
+ const disabled = example("anth-us", (config) => (config.chat.enabled = false));
89
+ for (const raw of [withoutBlock, disabled]) {
90
+ const production = branchEnvironment(shellTemplate(raw), "main");
91
+ assert.equal(production.PAPYRUS_PRIVATE_CHAT_ENABLED, "false");
92
+ assert.deepEqual(CHAT_NAMES.filter((name) => name in production), []);
93
+ assert.equal(resolveChatStackName(parseSiteConfig(raw)), undefined);
94
+ assert.throws(() => chatTemplate(raw), /does not enable chat/);
95
+ }
96
+ }
97
+
98
+ // Scenario: the chat block cannot be contradicted by hand-set variables
99
+ assert.throws(() => parseSiteConfig(example("anth-us", (config) => (config.cms.environment.PAPYRUS_PRIVATE_CHAT_ENABLED = "false"))), /cms\.environment\.PAPYRUS_PRIVATE_CHAT_ENABLED/);
100
+ assert.throws(() => parseSiteConfig(example("anth-us", (config) => (config.cms.environment.PAPYRUS_CHAT_SESSION_BUCKET = "x"))), /cms\.environment\.PAPYRUS_CHAT_SESSION_BUCKET/);
101
+ // Without chat, the existing hand-managed flag keeps working.
102
+ assert.equal(branchEnvironment(shellTemplate(example("anth-us", (config) => { delete config.chat; config.cms.environment.PAPYRUS_PRIVATE_CHAT_ENABLED = "true"; })), "main").PAPYRUS_PRIVATE_CHAT_ENABLED, "true");
103
+
104
+ // Scenario: chat needs a CMS and a model key parameter
105
+ assert.throws(() => parseSiteConfig(example("anth-us", (config) => delete config.chat.modelKeyParameter)), /chat\.modelKeyParameter/);
106
+ assert.throws(() => parseSiteConfig(example("anth-us", (config) => (config.chat.modelKeyParameter = "not-a-path"))), /chat\.modelKeyParameter/);
107
+ assert.throws(() => parseSiteConfig(example("anth-us", (config) => (config.chat.enabled = "yes"))), /chat\.enabled/);
108
+ assert.throws(() => parseSiteConfig(example("anth-us", (config) => (config.chat.extra = 1))), /chat\.extra/);
109
+ assert.throws(() => parseSiteConfig(example("anth-us", (config) => (config.chat.workerCodeS3Key = "abc"))), /chat\.workerCodeS3Key/);
110
+ assert.throws(() => parseSiteConfig({ siteId: "reader-only", repository: "https://github.com/example/x", brand: "x", frontend: "markus-static", papyrusVersion: "5.42.1", hostedZoneId: "Z10285921B1G7MVRV06W9", reader: { domainName: "x.example.test", buildCommand: "npm run build", baseDirectory: "out" }, chat: { enabled: true, modelKeyParameter: "/papyrus/x/openai-api-key" } }), /chat\.enabled.*requires cms/);
111
+ assert.throws(() => new PapyrusChatStack(new App(), { config: parseSiteConfig(example("p-apyr-us", (config) => delete config.chat.workerCodeS3Key)), env: { account: "712236451410", region: "us-east-1" } }), /bundle the chat workers first/);
112
+
113
+ // Scenario: two sites in one account do not share chat storage or log groups
114
+ {
115
+ const enabled = siteNames.map((name) => parseSiteConfig(example(name))).filter((config) => config.chat?.enabled);
116
+ assert.deepEqual(enabled.map((config) => config.siteId).sort(), ["anth-us", "dataparade", "distinctive", "p-apyr-us", "pilobol-us", "shipped", "threat-intelligence"], "every Papyrus site example enables chat");
117
+ const names = enabled.map((config) => resolveChatStackName(config));
118
+ const prefixes = enabled.map((config) => resolveChatLogGroupPrefix(config));
119
+ assert.equal(new Set(names).size, names.length, "stack names are unique");
120
+ assert.equal(new Set(prefixes).size, prefixes.length, "worker log group prefixes are unique");
121
+ for (const config of enabled) {
122
+ assert.ok(/^\/(papyrus\/[a-z0-9-]+\/openai-api-key|amplify\/shared\/[a-z0-9]+\/OPENAI_API_KEY)$/.test(config.chat!.modelKeyParameter), `${config.siteId} reuses an existing model key parameter`);
123
+ }
124
+ }
125
+
126
+ // Scenario: the activated p.apyr.us stack is adopted without replacing anything
127
+ {
128
+ const activated = JSON.parse(fs.readFileSync(path.resolve(__dirname, "../../shared-chat-runtime/cdk/PapyrusChatDataCandidate.template.json"), "utf8"));
129
+ const generated = chatTemplate(example("p-apyr-us"));
130
+ assert.equal(resolveChatStackName(parseSiteConfig(example("p-apyr-us"))), "PapyrusChatDataCandidate");
131
+ const normalize = (resource: any) => {
132
+ const copy = JSON.parse(JSON.stringify(resource));
133
+ delete copy.Metadata;
134
+ const statements = copy.Properties?.PolicyDocument?.Statement;
135
+ if (statements) copy.Properties.PolicyDocument.Statement = [...statements].sort((a: any, b: any) => JSON.stringify(a).localeCompare(JSON.stringify(b)));
136
+ return copy;
137
+ };
138
+ assert.deepEqual(Object.keys(generated.Resources).sort(), Object.keys(activated.Resources).sort(), "no resource is added, removed or renamed (a changed logical id would replace it)");
139
+ for (const [logicalId, resource] of Object.entries<any>(activated.Resources)) {
140
+ const expected = normalize(resource);
141
+ // The activated template is the reviewed candidate; the queue mappings were enabled live afterwards.
142
+ if (resource.Type === "AWS::Lambda::EventSourceMapping") expected.Properties.Enabled = true;
143
+ assert.deepEqual(normalize(generated.Resources[logicalId]), expected, `${logicalId} is unchanged`);
144
+ }
145
+ for (const key of Object.keys(activated.Outputs)) {
146
+ assert.deepEqual({ ...generated.Outputs[key], Export: undefined }, { ...activated.Outputs[key], Export: undefined, ...(key === "FeatureDefault" ? { Value: generated.Outputs[key].Value } : {}) }, `output ${key} keeps its value`);
147
+ }
148
+ assert.deepEqual(Object.keys(generated.Outputs).sort(), Object.keys(activated.Outputs).sort());
149
+ }
150
+
151
+ console.log("per-site private chat infra tests passed");
package/infra/index.js CHANGED
@@ -10,6 +10,7 @@ try {
10
10
  );
11
11
  }
12
12
  export const { AmplifyAppShellStack } = await import("./amplify-app-shell.js");
13
+ export const { PapyrusChatStack } = await import("./chat-stack.js");
13
14
  export const { SecureStringParameter } = await import("./secure-string-parameter.js");
14
15
  export const { GithubOidcProviderStack } = await import("./github-oidc-provider.js");
15
16
  export { parseSiteConfig } from "./site-config.js";
@@ -28,6 +28,9 @@ const HOST_NAME_PATTERN = /^([a-z0-9]([a-z0-9-]*[a-z0-9])?\.)+[a-z]{2,}$/;
28
28
  const SITE_ORIGIN_PATTERN = /^https:\/\/([a-z0-9]([a-z0-9-]*[a-z0-9])?\.)+[a-z]{2,}(:\d{1,5})?$/;
29
29
  const DOMAIN_PREFIX_PATTERN = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/;
30
30
  const APP_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9 _.-]*$/;
31
+ const CHAT_MODEL_KEY_PARAMETER_PATTERN = /^\/[A-Za-z0-9_.-]+(\/[A-Za-z0-9_.-]+)+$/;
32
+ const CHAT_LOG_GROUP_PREFIX_PATTERN = /^\/aws\/lambda\/[A-Za-z0-9_.-]{1,100}$/;
33
+ const CHAT_WORKER_CODE_KEY_PATTERN = /^[0-9a-f]{64}\.zip$/;
31
34
  const STACK_NAME_PATTERN = /^[A-Za-z][A-Za-z0-9-]{0,127}$/;
32
35
  const BRANCH_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9/_.-]*$/;
33
36
  const PAPYRUS_VERSION_PATTERN = /^\d+\.\d+\.\d+(-[0-9A-Za-z-]+\.\d+)?$/;
@@ -211,6 +214,58 @@ export function resolveStagingDomainName(config) {
211
214
  export function resolveStackName(config) {
212
215
  return config.stackName ?? `amplify-app-shell-${config.siteId}`;
213
216
  }
217
+ /** The chat data stack of a site; its construct id and CloudFormation name. Nothing when chat is not enabled. */
218
+ export function resolveChatStackName(config) {
219
+ if (!config.chat?.enabled)
220
+ return undefined;
221
+ return config.chat.stackName ?? `papyrus-chat-${config.siteId}`;
222
+ }
223
+ export function resolveChatLogGroupPrefix(config) {
224
+ return config.chat?.logGroupPrefix ?? `/aws/lambda/papyrus-chat-${config.siteId}`;
225
+ }
226
+ /** CloudFormation export names the chat stack publishes and the app shell imports (no stack ever references the other directly). */
227
+ export const CHAT_EXPORTED_RESOURCES = ["MessagingTableName", "ConversationIndexTableName", "SessionBucketName", "TurnRunsQueueUrl", "TurnProbesQueueUrl"];
228
+ export const CHAT_ENVIRONMENT_NAMES = {
229
+ MessagingTableName: "PAPYRUS_CHAT_MESSAGING_TABLE",
230
+ ConversationIndexTableName: "PAPYRUS_CHAT_CONVERSATIONS_TABLE",
231
+ SessionBucketName: "PAPYRUS_CHAT_SESSION_BUCKET",
232
+ TurnRunsQueueUrl: "PAPYRUS_CHAT_TURN_RUNS_QUEUE_URL",
233
+ TurnProbesQueueUrl: "PAPYRUS_CHAT_TURN_PROBES_QUEUE_URL",
234
+ };
235
+ export const CHAT_PRIVATE_ASSISTANT_ID = "papyrus-private-assistant";
236
+ export const CHAT_PRIVATE_ASSISTANT_NAME = "Papyrus Assistant";
237
+ export function chatExportName(stackName, resource) {
238
+ return `${stackName}:${resource}`;
239
+ }
240
+ function parseChat(value, cms) {
241
+ if (value === undefined)
242
+ return undefined;
243
+ const record = requireObject(value, "chat");
244
+ rejectUnknownKeys(record, ["enabled", "modelKeyParameter", "stackName", "logGroupPrefix", "workerCodeS3Key"], "chat");
245
+ if (typeof record.enabled !== "boolean")
246
+ fail("chat.enabled", "must be a boolean");
247
+ const chat = {
248
+ enabled: record.enabled,
249
+ modelKeyParameter: requireString(record.modelKeyParameter, "chat.modelKeyParameter", CHAT_MODEL_KEY_PARAMETER_PATTERN, "SSM parameter name such as /papyrus/<site>/openai-api-key"),
250
+ stackName: optionalString(record.stackName, "chat.stackName", STACK_NAME_PATTERN, "CloudFormation stack name"),
251
+ logGroupPrefix: optionalString(record.logGroupPrefix, "chat.logGroupPrefix", CHAT_LOG_GROUP_PREFIX_PATTERN, "/aws/lambda/<name>"),
252
+ workerCodeS3Key: optionalString(record.workerCodeS3Key, "chat.workerCodeS3Key", CHAT_WORKER_CODE_KEY_PATTERN, "<64 hex>.zip CDK asset key"),
253
+ };
254
+ for (const key of ["stackName", "logGroupPrefix", "workerCodeS3Key"]) {
255
+ if (chat[key] === undefined)
256
+ delete chat[key];
257
+ }
258
+ if (chat.enabled && cms === undefined)
259
+ fail("chat.enabled", "requires cms (the chat gateway runs in the CMS compute role)");
260
+ if (chat.enabled && cms) {
261
+ for (const key of Object.keys(cms.environment)) {
262
+ if (key === "PAPYRUS_PRIVATE_CHAT_ENABLED" || key.startsWith("PAPYRUS_CHAT_")) {
263
+ fail(`cms.environment.${key}`, "is managed by the chat block when chat is enabled: remove it and use chat.enabled");
264
+ }
265
+ }
266
+ }
267
+ return chat;
268
+ }
214
269
  export function resolveStoragePreviewPrefix(config) {
215
270
  return config.storagePreviewPrefix ?? "preview/";
216
271
  }
@@ -355,7 +410,7 @@ function parseAws(value) {
355
410
  }
356
411
  export function parseSiteConfig(raw) {
357
412
  const record = requireObject(raw, "site");
358
- rejectUnknownKeys(record, ["siteId", "repository", "repositoryAccessTokenSecretArn", "brand", "frontend", "hostedZoneId", "stackName", "aws", "cms", "email", "reader", "papyrusVersion", "storagePreviewPrefix", "knowledgeRun", "autoDeploy", "github"], "site");
413
+ rejectUnknownKeys(record, ["siteId", "repository", "repositoryAccessTokenSecretArn", "brand", "frontend", "hostedZoneId", "stackName", "aws", "cms", "email", "reader", "papyrusVersion", "storagePreviewPrefix", "chat", "knowledgeRun", "autoDeploy", "github"], "site");
359
414
  const siteId = requireString(record.siteId, "siteId", SITE_ID_PATTERN, "kebab-case");
360
415
  const repository = requireString(record.repository, "repository", REPOSITORY_PATTERN, "https://github.com/<org>/<repo>");
361
416
  const repositoryAccessTokenSecretArn = record.repositoryAccessTokenSecretArn === undefined
@@ -448,6 +503,7 @@ export function parseSiteConfig(raw) {
448
503
  fail("github", "ownerId and repoId must be set together");
449
504
  github = { owner, repo, branches, ciCanDeployInfra: ciCanDeployInfra, ...(ownerId !== undefined ? { ownerId, repoId } : {}) };
450
505
  }
506
+ const chat = parseChat(record.chat, cms);
451
507
  const hasDomain = cms?.domainName !== undefined || reader?.domainName !== undefined;
452
508
  if (hasDomain && hostedZoneId === undefined) {
453
509
  fail("hostedZoneId", "is required when cms.domainName or reader.domainName is set");
@@ -469,6 +525,7 @@ export function parseSiteConfig(raw) {
469
525
  reader,
470
526
  papyrusVersion,
471
527
  storagePreviewPrefix,
528
+ ...(chat ? { chat } : {}),
472
529
  autoDeploy: autoDeploy,
473
530
  github,
474
531
  ...(record.knowledgeRun !== undefined ? { knowledgeRun: record.knowledgeRun } : {}),
@@ -55,6 +55,21 @@ export type CmsConfig = {
55
55
  deployStackInBuild?: boolean;
56
56
  };
57
57
 
58
+ export type ChatConfig = {
59
+ enabled: boolean;
60
+ /** SSM SecureString parameter holding the site's OpenAI key; only the chat run worker may read it. */
61
+ modelKeyParameter: string;
62
+ /** Chat data stack name (also its CDK construct id). Default `papyrus-chat-<siteId>`. */
63
+ stackName?: string;
64
+ /** CloudWatch log group prefix of the two workers (`<prefix>-run-worker`, `<prefix>-probe-worker`). Default `/aws/lambda/papyrus-chat-<siteId>`. */
65
+ logGroupPrefix?: string;
66
+ /**
67
+ * Pins the worker code to an already uploaded CDK asset object (key in the CDK assets bucket) instead of
68
+ * bundling and uploading new bytes. Only for adopting a stack whose workers are already verified live.
69
+ */
70
+ workerCodeS3Key?: string;
71
+ };
72
+
58
73
  export type AmplifyAppShellSiteConfig = {
59
74
  siteId: string;
60
75
  repository: string;
@@ -82,6 +97,11 @@ export type AmplifyAppShellSiteConfig = {
82
97
  };
83
98
  papyrusVersion: string;
84
99
  storagePreviewPrefix?: string;
100
+ /**
101
+ * Private newsroom chat for this publication (docs/private-newsroom-chat-per-site.md). Absent or
102
+ * `enabled: false` means the site gets neither the chat data stack nor the chat environment on its CMS.
103
+ */
104
+ chat?: ChatConfig;
85
105
  /**
86
106
  * The scheduled cloud knowledge run for this publication. The app-shell stack ignores it; it is validated by
87
107
  * infra/scheduled-jobs/lib/knowledge-run-config.ts, which owns the schema.
@@ -153,6 +173,9 @@ const HOST_NAME_PATTERN = /^([a-z0-9]([a-z0-9-]*[a-z0-9])?\.)+[a-z]{2,}$/;
153
173
  const SITE_ORIGIN_PATTERN = /^https:\/\/([a-z0-9]([a-z0-9-]*[a-z0-9])?\.)+[a-z]{2,}(:\d{1,5})?$/;
154
174
  const DOMAIN_PREFIX_PATTERN = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/;
155
175
  const APP_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9 _.-]*$/;
176
+ const CHAT_MODEL_KEY_PARAMETER_PATTERN = /^\/[A-Za-z0-9_.-]+(\/[A-Za-z0-9_.-]+)+$/;
177
+ const CHAT_LOG_GROUP_PREFIX_PATTERN = /^\/aws\/lambda\/[A-Za-z0-9_.-]{1,100}$/;
178
+ const CHAT_WORKER_CODE_KEY_PATTERN = /^[0-9a-f]{64}\.zip$/;
156
179
  const STACK_NAME_PATTERN = /^[A-Za-z][A-Za-z0-9-]{0,127}$/;
157
180
  const BRANCH_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9/_.-]*$/;
158
181
  const PAPYRUS_VERSION_PATTERN = /^\d+\.\d+\.\d+(-[0-9A-Za-z-]+\.\d+)?$/;
@@ -339,6 +362,57 @@ export function resolveStackName(config: Pick<AmplifyAppShellSiteConfig, "siteId
339
362
  return config.stackName ?? `amplify-app-shell-${config.siteId}`;
340
363
  }
341
364
 
365
+ /** The chat data stack of a site; its construct id and CloudFormation name. Nothing when chat is not enabled. */
366
+ export function resolveChatStackName(config: Pick<AmplifyAppShellSiteConfig, "siteId" | "chat">): string | undefined {
367
+ if (!config.chat?.enabled) return undefined;
368
+ return config.chat.stackName ?? `papyrus-chat-${config.siteId}`;
369
+ }
370
+
371
+ export function resolveChatLogGroupPrefix(config: Pick<AmplifyAppShellSiteConfig, "siteId" | "chat">): string {
372
+ return config.chat?.logGroupPrefix ?? `/aws/lambda/papyrus-chat-${config.siteId}`;
373
+ }
374
+
375
+ /** CloudFormation export names the chat stack publishes and the app shell imports (no stack ever references the other directly). */
376
+ export const CHAT_EXPORTED_RESOURCES = ["MessagingTableName", "ConversationIndexTableName", "SessionBucketName", "TurnRunsQueueUrl", "TurnProbesQueueUrl"] as const;
377
+ export const CHAT_ENVIRONMENT_NAMES: Record<(typeof CHAT_EXPORTED_RESOURCES)[number], string> = {
378
+ MessagingTableName: "PAPYRUS_CHAT_MESSAGING_TABLE",
379
+ ConversationIndexTableName: "PAPYRUS_CHAT_CONVERSATIONS_TABLE",
380
+ SessionBucketName: "PAPYRUS_CHAT_SESSION_BUCKET",
381
+ TurnRunsQueueUrl: "PAPYRUS_CHAT_TURN_RUNS_QUEUE_URL",
382
+ TurnProbesQueueUrl: "PAPYRUS_CHAT_TURN_PROBES_QUEUE_URL",
383
+ };
384
+ export const CHAT_PRIVATE_ASSISTANT_ID = "papyrus-private-assistant";
385
+ export const CHAT_PRIVATE_ASSISTANT_NAME = "Papyrus Assistant";
386
+ export function chatExportName(stackName: string, resource: (typeof CHAT_EXPORTED_RESOURCES)[number]): string {
387
+ return `${stackName}:${resource}`;
388
+ }
389
+
390
+ function parseChat(value: unknown, cms: CmsConfig | undefined): ChatConfig | undefined {
391
+ if (value === undefined) return undefined;
392
+ const record = requireObject(value, "chat");
393
+ rejectUnknownKeys(record, ["enabled", "modelKeyParameter", "stackName", "logGroupPrefix", "workerCodeS3Key"], "chat");
394
+ if (typeof record.enabled !== "boolean") fail("chat.enabled", "must be a boolean");
395
+ const chat: ChatConfig = {
396
+ enabled: record.enabled,
397
+ modelKeyParameter: requireString(record.modelKeyParameter, "chat.modelKeyParameter", CHAT_MODEL_KEY_PARAMETER_PATTERN, "SSM parameter name such as /papyrus/<site>/openai-api-key"),
398
+ stackName: optionalString(record.stackName, "chat.stackName", STACK_NAME_PATTERN, "CloudFormation stack name"),
399
+ logGroupPrefix: optionalString(record.logGroupPrefix, "chat.logGroupPrefix", CHAT_LOG_GROUP_PREFIX_PATTERN, "/aws/lambda/<name>"),
400
+ workerCodeS3Key: optionalString(record.workerCodeS3Key, "chat.workerCodeS3Key", CHAT_WORKER_CODE_KEY_PATTERN, "<64 hex>.zip CDK asset key"),
401
+ };
402
+ for (const key of ["stackName", "logGroupPrefix", "workerCodeS3Key"] as const) {
403
+ if (chat[key] === undefined) delete chat[key];
404
+ }
405
+ if (chat.enabled && cms === undefined) fail("chat.enabled", "requires cms (the chat gateway runs in the CMS compute role)");
406
+ if (chat.enabled && cms) {
407
+ for (const key of Object.keys(cms.environment)) {
408
+ if (key === "PAPYRUS_PRIVATE_CHAT_ENABLED" || key.startsWith("PAPYRUS_CHAT_")) {
409
+ fail(`cms.environment.${key}`, "is managed by the chat block when chat is enabled: remove it and use chat.enabled");
410
+ }
411
+ }
412
+ }
413
+ return chat;
414
+ }
415
+
342
416
  export function resolveStoragePreviewPrefix(config: Pick<AmplifyAppShellSiteConfig, "storagePreviewPrefix">): string {
343
417
  return config.storagePreviewPrefix ?? "preview/";
344
418
  }
@@ -498,7 +572,7 @@ export function parseSiteConfig(raw: unknown): AmplifyAppShellSiteConfig {
498
572
  const record = requireObject(raw, "site");
499
573
  rejectUnknownKeys(
500
574
  record,
501
- ["siteId", "repository", "repositoryAccessTokenSecretArn", "brand", "frontend", "hostedZoneId", "stackName", "aws", "cms", "email", "reader", "papyrusVersion", "storagePreviewPrefix", "knowledgeRun", "autoDeploy", "github"],
575
+ ["siteId", "repository", "repositoryAccessTokenSecretArn", "brand", "frontend", "hostedZoneId", "stackName", "aws", "cms", "email", "reader", "papyrusVersion", "storagePreviewPrefix", "chat", "knowledgeRun", "autoDeploy", "github"],
502
576
  "site",
503
577
  );
504
578
 
@@ -590,6 +664,8 @@ export function parseSiteConfig(raw: unknown): AmplifyAppShellSiteConfig {
590
664
  github = { owner, repo, branches, ciCanDeployInfra: ciCanDeployInfra as boolean, ...(ownerId !== undefined ? { ownerId, repoId } : {}) };
591
665
  }
592
666
 
667
+ const chat = parseChat(record.chat, cms);
668
+
593
669
  const hasDomain = cms?.domainName !== undefined || reader?.domainName !== undefined;
594
670
  if (hasDomain && hostedZoneId === undefined) {
595
671
  fail("hostedZoneId", "is required when cms.domainName or reader.domainName is set");
@@ -612,6 +688,7 @@ export function parseSiteConfig(raw: unknown): AmplifyAppShellSiteConfig {
612
688
  reader,
613
689
  papyrusVersion,
614
690
  storagePreviewPrefix,
691
+ ...(chat ? { chat } : {}),
615
692
  autoDeploy: autoDeploy as boolean,
616
693
  github,
617
694
  ...(record.knowledgeRun !== undefined ? { knowledgeRun: record.knowledgeRun } : {}),
@@ -656,6 +656,8 @@ export type NewsroomSummaryFacets = {
656
656
 
657
657
  export type CategorySteeringDashboard = {
658
658
  isDemo?: boolean;
659
+ /** Local preview only: the demo newsroom is showing a real export (scripts/export-reference-feed-fixture.py), so it must not say "synthetic". */
660
+ realDataPreview?: boolean;
659
661
  isPublicSkeleton?: boolean;
660
662
  summaryStatus?: "loading" | "missing" | "ready";
661
663
  summary?: NewsroomSummaryRecord | null;
@@ -820,7 +822,8 @@ function sortCategorys(categorys: CategorySteeringCategory[]): CategorySteeringC
820
822
  }
821
823
 
822
824
  function defaultNewsroomSections(importedAt: string, sectionsConfigPath?: string): NewsroomSectionRecord[] {
823
- if (!sectionsConfigPath) return [];
825
+ // A data-driven site names a seed file it does not ship: its Sections live in the backend, so there is nothing to seed.
826
+ if (!sectionsConfigPath || !fs.existsSync(sectionsConfigPath)) return [];
824
827
  return loadNewsroomSectionSeedRows(sectionsConfigPath).map((row, index) => ({
825
828
  ...row,
826
829
  enabledStatus: "enabled",
@@ -0,0 +1,43 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import type { CategorySteeringDashboard, ReferenceAttachmentRecord, ReferenceRecord, SemanticRelationRecord } from "./category-repository";
4
+
5
+ /**
6
+ * Local preview only (server side): when PAPYRUS_FEED_FIXTURE names a JSON file written by
7
+ * scripts/export-reference-feed-fixture.py, the demo newsroom shows those References, their previews and the Cyclotron's
8
+ * decisions instead of the invented sample. Without the variable, or without the file, nothing changes.
9
+ */
10
+ export type ReferenceFeedFixture = {
11
+ references: ReferenceRecord[];
12
+ referenceAttachments: ReferenceAttachmentRecord[];
13
+ semanticRelations: SemanticRelationRecord[];
14
+ };
15
+
16
+ export function loadReferenceFeedFixture(env: NodeJS.ProcessEnv = process.env): ReferenceFeedFixture | null {
17
+ const configured = env.PAPYRUS_FEED_FIXTURE?.trim();
18
+ if (!configured || env.NEXT_PUBLIC_PAPYRUS_LOCAL_PREVIEW !== "1") return null;
19
+ try {
20
+ const parsed = JSON.parse(fs.readFileSync(path.resolve(process.cwd(), configured), "utf8"));
21
+ if (parsed?.schema !== "papyrus-reference-feed-fixture/1" || !Array.isArray(parsed.references)) return null;
22
+ return {
23
+ references: parsed.references,
24
+ referenceAttachments: Array.isArray(parsed.referenceAttachments) ? parsed.referenceAttachments : [],
25
+ semanticRelations: Array.isArray(parsed.semanticRelations) ? parsed.semanticRelations : [],
26
+ };
27
+ } catch {
28
+ return null;
29
+ }
30
+ }
31
+
32
+ export function withReferenceFeedFixture(dashboard: CategorySteeringDashboard): CategorySteeringDashboard {
33
+ const fixture = loadReferenceFeedFixture();
34
+ if (!fixture) return dashboard;
35
+ return {
36
+ ...dashboard,
37
+ references: fixture.references,
38
+ referenceAttachments: fixture.referenceAttachments,
39
+ semanticRelations: fixture.semanticRelations,
40
+ messages: [],
41
+ realDataPreview: true,
42
+ };
43
+ }
@@ -0,0 +1,41 @@
1
+ import type { RelevanceDecisionView } from "./relevance-decisions";
2
+
3
+ /** Strong Reject confidences, strongest first, for the preview's made-up predictions. */
4
+ const SYNTHETIC_REJECT_CONFIDENCE = [0.95, 0.91, 0.87, 0.83, 0.79, 0.75, 0.71, 0.67];
5
+
6
+ /**
7
+ * Local preview only. A young corpus can have no Reject predictions at all, so the Reject end of the feed cannot be reviewed on real
8
+ * data. `?feedSynthRejects=N` turns the N weakest Accept predictions of pending items into made-up Rejects, and the page says so
9
+ * in a banner. Never reachable outside local preview.
10
+ */
11
+ export function chooseSyntheticRejects(
12
+ decisions: ReadonlyMap<string, RelevanceDecisionView> | undefined,
13
+ pendingLineageIds: ReadonlySet<string>,
14
+ count: number,
15
+ ): string[] {
16
+ return [...(decisions ?? new Map()).entries()]
17
+ .filter(([lineageId, decision]) => pendingLineageIds.has(lineageId) && decision.label === (decision.positiveLabel ?? "include"))
18
+ .sort((left, right) => (left[1].confidence ?? 0) - (right[1].confidence ?? 0))
19
+ .slice(0, Math.max(0, Math.min(count, SYNTHETIC_REJECT_CONFIDENCE.length)))
20
+ .map(([lineageId]) => lineageId);
21
+ }
22
+
23
+ /** Applies the chosen ids, weakest first getting the lowest of the made-up confidences. The choice is made once, so rating a card never moves it. */
24
+ export function withSyntheticRejects(
25
+ decisions: ReadonlyMap<string, RelevanceDecisionView> | undefined,
26
+ chosen: readonly string[],
27
+ ): { decisions: Map<string, RelevanceDecisionView>; changed: number } {
28
+ const result = new Map(decisions ?? []);
29
+ let changed = 0;
30
+ chosen.forEach((lineageId, index) => {
31
+ const decision = result.get(lineageId);
32
+ if (!decision) return;
33
+ result.set(lineageId, {
34
+ ...decision,
35
+ label: decision.classes.find((label) => label !== (decision.positiveLabel ?? "include")) ?? "exclude",
36
+ confidence: SYNTHETIC_REJECT_CONFIDENCE[index],
37
+ });
38
+ changed += 1;
39
+ });
40
+ return { decisions: result, changed };
41
+ }
@@ -3,6 +3,9 @@
3
3
  import { generateClient } from "aws-amplify/data";
4
4
  import { emailReferenceBatches } from "./monitoring-overview";
5
5
  import type {
6
+ EmailDecisionResult,
7
+ MonitoringListName,
8
+ MonitoringPage,
6
9
  EmailReferenceImportItem,
7
10
  EmailReferenceImportOutcome,
8
11
  MonitoringEmailArticleResolution,
@@ -10,10 +13,10 @@ import type {
10
13
  MonitoringResult,
11
14
  } from "./monitoring-overview";
12
15
 
13
- type QueryField = "monitoringOverview" | "monitoringEmailSubmission" | "monitoringResolveEmailArticleLink" | "intakeEmailSubmissionReferences" | "monitoringQueueEmailReference";
16
+ type QueryField = "monitoringOverview" | "monitoringPage" | "monitoringEmailSubmission" | "monitoringDecideEmailSubmission" | "monitoringResolveEmailArticleLink" | "intakeEmailSubmissionReferences" | "monitoringQueueEmailReference";
14
17
  type GraphQLResult = { data?: Partial<Record<QueryField, unknown>>; errors?: { message?: string; errorType?: string }[] };
15
18
 
16
- async function query<T>(field: QueryField, queryText: string, variables: Record<string, string | number>): Promise<T | { error: "forbidden" } | { state: "unavailable" }> {
19
+ async function query<T>(field: QueryField, queryText: string, variables: Record<string, string | number | null>): Promise<T | { error: "forbidden" } | { state: "unavailable" }> {
17
20
  const client = generateClient({ authMode: "userPool" });
18
21
  let result: GraphQLResult;
19
22
  try {
@@ -34,12 +37,36 @@ export async function loadMonitoringOverview(scope: "publication" | "all"): Prom
34
37
  `query MonitoringOverview($scope: String) { monitoringOverview(scope: $scope) }`, { scope });
35
38
  }
36
39
 
40
+ /**
41
+ * One more page of one list after `cursor` (null starts the list from its newest row). `heldOnly` lists only held
42
+ * email submissions, so a held message is findable however old it is.
43
+ */
44
+ export async function loadMonitoringPage(
45
+ list: MonitoringListName, cursor: string | null, scope: "publication" | "all", heldOnly = false,
46
+ ): Promise<MonitoringPage> {
47
+ return query<MonitoringPage>("monitoringPage",
48
+ `query MonitoringPage($list: String!, $cursor: String, $filter: String, $scope: String) { monitoringPage(list: $list, cursor: $cursor, filter: $filter, scope: $scope) }`,
49
+ { list, cursor, filter: heldOnly ? "held" : null, scope });
50
+ }
51
+
37
52
  /** Loads one receipt's safe plain-text rendering and its already extracted candidate links. */
38
53
  export async function loadMonitoringEmailSubmission(receiptId: string): Promise<MonitoringEmailDetails> {
39
54
  return query<MonitoringEmailDetails>("monitoringEmailSubmission",
40
55
  `query MonitoringEmailSubmission($receiptId: String!) { monitoringEmailSubmission(receiptId: $receiptId) }`, { receiptId });
41
56
  }
42
57
 
58
+ /**
59
+ * Accept or Reject one held email submission. "Accept" releases it into the normal intake and
60
+ * "Reject" discards it; the editor and the optional reason are recorded server-side.
61
+ */
62
+ export async function decideMonitoringEmailSubmission(
63
+ receiptId: string, decision: "accept" | "reject", reason: string,
64
+ ): Promise<EmailDecisionResult> {
65
+ return query<EmailDecisionResult>("monitoringDecideEmailSubmission",
66
+ `mutation MonitoringDecideEmailSubmission($receiptId: String!, $decision: String!, $reason: String) { monitoringDecideEmailSubmission(receiptId: $receiptId, decision: $decision, reason: $reason) }`,
67
+ { receiptId, decision: decision === "accept" ? "release" : "discard", reason });
68
+ }
69
+
43
70
  /** Resolve one explicitly selected Beehiiv article redirect; this may register a click with the sender. */
44
71
  export async function resolveMonitoringEmailArticleLink(receiptId: string, candidateIndex: number): Promise<MonitoringEmailArticleResolution> {
45
72
  return query<MonitoringEmailArticleResolution>("monitoringResolveEmailArticleLink",
@@ -37,9 +37,45 @@ export type MonitoringEmailSummary = {
37
37
  receiptId: string; observedAt: string | null; status: string; subject: string | null;
38
38
  envelopeSender: string | null; headerFrom: string | null; warnings: string[];
39
39
  verdicts: Record<string, string>; linkCount: number; processedCount: number; unresolvedLinkCount: number;
40
- reviewRequired: boolean; errorCode: string | null;
40
+ reviewRequired: boolean; errorCode: string | null; quarantine: EmailQuarantine | null;
41
+ /** Present when the same email was received more than once: one item, every copy listed. One decision covers all copies. */
42
+ group?: EmailCopyGroup;
43
+ };
44
+ export type EmailCopyGroup = {
45
+ fingerprint: string; count: number;
46
+ copies: { receiptId: string; observedAt: string | null; status: string; linkCount: number; processedCount: number; quarantine: EmailQuarantine | null }[];
47
+ };
48
+
49
+ /** Where a held email submission stands. `null` means it never needed review. */
50
+ export type EmailQuarantineState =
51
+ | "quarantined" | "release_requested" | "released" | "discard_requested" | "discarded"
52
+ | "expired" | "decision_failed" | "release_failed";
53
+ export type EmailQuarantine = {
54
+ state: EmailQuarantineState;
55
+ decision: { action: "accept" | "reject"; by: string; at: string | null; reason: string; viaGroup?: boolean } | null;
56
+ failure: string | null;
57
+ };
58
+ export type EmailDecisionResult =
59
+ | { ok: true; already: boolean; quarantine: EmailQuarantine }
60
+ | { error: "forbidden" | "bad_request" | "already_decided" | "not_quarantined" | "expired"; quarantine?: EmailQuarantine }
61
+ | { state: "unavailable" | "not_configured" };
62
+
63
+ export const QUARANTINE_LABELS: Record<EmailQuarantineState, string> = {
64
+ quarantined: "Held for review",
65
+ release_requested: "Accepted, being queued",
66
+ released: "Accepted",
67
+ discard_requested: "Rejected, being closed",
68
+ discarded: "Rejected",
69
+ expired: "Expired",
70
+ decision_failed: "Decision could not be applied",
71
+ release_failed: "Accepted, retrying",
41
72
  };
42
73
 
74
+ /** Links and import are available only once a message is accepted (or never needed review). */
75
+ export function emailSubmissionAccepted(quarantine: EmailQuarantine | null): boolean {
76
+ return quarantine === null || quarantine.state === "released";
77
+ }
78
+
43
79
  export type MonitoringEmailDetails = {
44
80
  submission: MonitoringEmailSummary & { corpusId: string | null }; safeBodyText: string;
45
81
  candidateLinks: { url: string; title: string; resolutionStatus?: "unresolved" }[]; sourceMaterialIsUntrusted: true;
@@ -47,6 +83,8 @@ export type MonitoringEmailDetails = {
47
83
 
48
84
  export type MonitoringEmailArticleResolution = {
49
85
  url: string; title: string; resolved: true;
86
+ /** Present when the original link could not be followed and the destination was found by searching the headline. */
87
+ foundVia?: "search"; search?: { query: string; matchScore: number; provider: string; matchedTitle?: string };
50
88
  } | { error: "forbidden" | "bad_request" | "not_resolvable" | "resolution_failed"; reason?: string } | { state: "unavailable" };
51
89
 
52
90
  export type EmailReferenceImportItem = { candidateIndex: number; source_uri: string };
@@ -112,6 +150,17 @@ export type MonitoringFeed = {
112
150
  nextDueAt: string | null;
113
151
  };
114
152
 
153
+ /** The lists on the page that are paged. Page sizes are the server's; there is no total. */
154
+ export type MonitoringListName = "runs" | "routerRuns" | "items" | "emailSubmissions";
155
+ export type MonitoringCursors = Partial<Record<MonitoringListName, string | null>>;
156
+ export type MonitoringListRows = {
157
+ runs: MonitoringRun; routerRuns: MonitoringRouterRun; items: MonitoringItem; emailSubmissions: MonitoringEmailSummary;
158
+ };
159
+ /** One more page of one list. `cursor` is null when the list has ended. */
160
+ export type MonitoringPage<L extends MonitoringListName = MonitoringListName> =
161
+ | { list: L; rows: MonitoringListRows[L][]; cursor: string | null; hiddenNotRelevant?: number }
162
+ | { error: "forbidden" | "bad_request" } | { state: "unavailable" | "not_configured" };
163
+
115
164
  export type MonitoringOverview = {
116
165
  state: "ready" | "empty";
117
166
  asOf: string;
@@ -132,6 +181,8 @@ export type MonitoringOverview = {
132
181
  otherMonitors?: string[];
133
182
  emailSubmissions?: MonitoringEmailSummary[];
134
183
  hiddenNotRelevant: number;
184
+ /** Where each list continues; null or missing when it has no more rows. */
185
+ cursors?: MonitoringCursors;
135
186
  };
136
187
 
137
188
  export type MonitoringResult =