@anthusai/papyrus 2.0.0-next.8 → 2.0.0-next.80
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.
- package/amplify/data/function-handler.js +20 -0
- package/amplify/data/function-handler.ts +40 -0
- package/amplify/data/resource.js +151 -61
- package/amplify/data/resource.ts +157 -61
- package/amplify/data/schema.js +2096 -1980
- package/amplify/data/schema.ts +198 -69
- package/amplify/functions/assignment-action/data-access.js +14 -15
- package/amplify/functions/assignment-action/data-access.ts +13 -14
- package/amplify/functions/assignment-action/handler.ts +1 -1
- package/amplify/functions/automation-view/handler.py +17 -0
- package/amplify/functions/automation-view/resource.js +24 -0
- package/amplify/functions/automation-view/resource.ts +34 -0
- package/amplify/functions/automation-view/schema.js +26 -0
- package/amplify/functions/automation-view/schema.ts +28 -0
- package/amplify/functions/automation-view/wire.js +56 -0
- package/amplify/functions/automation-view/wire.ts +76 -0
- package/amplify/functions/content-actions/handler.py +61 -2
- package/amplify/functions/content-actions/requirements.txt +1 -0
- package/amplify/functions/content-actions/resource.js +1 -1
- package/amplify/functions/content-actions/resource.ts +1 -1
- package/amplify/{plugins/editorial-checks/function → functions/editorial-checks}/handler.py +3 -3
- package/amplify/functions/editorial-checks/requirements.txt +1 -0
- package/amplify/{plugins/editorial-checks/function → functions/editorial-checks}/resource.js +7 -7
- package/amplify/{plugins/editorial-checks/function → functions/editorial-checks}/resource.ts +7 -7
- package/amplify/functions/editorial-checks/schema.js +122 -0
- package/amplify/{plugins → functions}/editorial-checks/schema.ts +40 -12
- package/amplify/functions/editorial-checks/wire.js +53 -0
- package/amplify/functions/editorial-checks/wire.ts +61 -0
- package/amplify/functions/monitoring-view/handler.py +11 -0
- package/amplify/functions/monitoring-view/resource.js +24 -0
- package/amplify/functions/monitoring-view/resource.ts +34 -0
- package/amplify/functions/monitoring-view/schema.js +17 -0
- package/amplify/functions/monitoring-view/schema.ts +19 -0
- package/amplify/functions/monitoring-view/wire.js +37 -0
- package/amplify/functions/monitoring-view/wire.ts +47 -0
- package/amplify/functions/public-forms/core.js +247 -0
- package/amplify/functions/public-forms/core.ts +310 -0
- package/amplify/functions/public-forms/handler.ts +41 -0
- package/amplify/functions/public-forms/origins.js +35 -0
- package/amplify/functions/public-forms/origins.ts +43 -0
- package/amplify/functions/public-forms/resource.js +9 -0
- package/amplify/functions/public-forms/resource.ts +10 -0
- package/amplify/functions/public-forms/wire.js +25 -0
- package/amplify/functions/public-forms/wire.ts +32 -0
- package/amplify/functions/shared/editorial-context.ts +63 -4
- package/amplify/functions/shared/python-bundle.js +20 -14
- package/amplify/functions/shared/python-bundle.ts +25 -18
- package/amplify/plugins/newsletter/config.js +245 -0
- package/amplify/plugins/newsletter/config.ts +305 -0
- package/amplify/plugins/newsletter/function/handler.py +6 -0
- package/amplify/plugins/newsletter/function/requirements.txt +5 -0
- package/amplify/plugins/newsletter/ops/handler.py +12 -0
- package/amplify/plugins/newsletter/ops/resource.js +27 -0
- package/amplify/plugins/newsletter/ops/resource.ts +37 -0
- package/amplify/plugins/newsletter/papyrus.plugin.js +263 -0
- package/amplify/plugins/newsletter/papyrus.plugin.ts +311 -0
- package/amplify/plugins/newsletter/schema.js +57 -0
- package/amplify/plugins/newsletter/schema.ts +62 -0
- package/amplify/plugins/newsletter/secure-string-parameter.js +107 -0
- package/amplify/plugins/newsletter/secure-string-parameter.ts +120 -0
- package/amplify/plugins/newsletter/sending.js +140 -0
- package/amplify/plugins/newsletter/sending.ts +186 -0
- package/amplify/plugins/reader-steering/config.js +182 -0
- package/amplify/plugins/reader-steering/config.ts +290 -0
- package/amplify/plugins/reader-steering/function/ai_moderation.py +246 -0
- package/amplify/plugins/reader-steering/function/handler.py +1554 -0
- package/amplify/plugins/reader-steering/function/moderation-prompt.md +27 -0
- package/amplify/plugins/reader-steering/function/moderation.py +316 -0
- package/amplify/plugins/reader-steering/function/proposals.py +255 -0
- package/amplify/plugins/reader-steering/function/resource.js +27 -0
- package/amplify/plugins/reader-steering/function/resource.ts +35 -0
- package/amplify/plugins/reader-steering/papyrus.plugin.js +186 -0
- package/amplify/plugins/reader-steering/papyrus.plugin.ts +212 -0
- package/amplify/plugins/reader-steering/schema.js +205 -0
- package/amplify/plugins/reader-steering/schema.ts +207 -0
- package/amplify/plugins/registry.js +4 -2
- package/amplify/plugins/registry.ts +4 -2
- package/amplify/plugins/signup/schema.js +2 -1
- package/amplify/plugins/signup/schema.ts +2 -1
- package/amplify/plugins/types.ts +5 -0
- package/amplify/plugins/wire.js +1 -0
- package/amplify/plugins/wire.ts +1 -0
- package/amplify/publication/projection-lease.js +30 -0
- package/amplify/publication/projection-lease.ts +39 -0
- package/amplify/publication/redirect-and-index.js +105 -0
- package/amplify/publication/redirect-rules.js +73 -0
- package/amplify/publication/redirect-rules.ts +93 -0
- package/amplify/publication/s3-projection.js +7 -1
- package/amplify/publication/s3-projection.ts +9 -1
- package/amplify/site-backend-config.ts +18 -1
- package/amplify/site-backend-names.js +4 -2
- package/amplify/site-backend-names.ts +4 -2
- package/amplify/site-backend.js +41 -2
- package/amplify/site-backend.ts +46 -2
- package/app/api/auth/prospectus/sign-in/route.ts +4 -0
- package/app/api/newsletter/confirm/route.ts +13 -0
- package/app/api/newsletter/preferences/route.ts +13 -0
- package/app/api/newsletter/state/route.ts +9 -0
- package/app/api/newsletter/subscribe/route.ts +9 -0
- package/app/api/newsletter/unsubscribe/route.ts +13 -0
- package/app/api/newsroom/curation/context/route.ts +9 -0
- package/app/api/newsroom/curation/priming/route.ts +9 -0
- package/app/api/newsroom/curation/publication/route.ts +9 -0
- package/app/api/reader/comment/delete/route.ts +9 -0
- package/app/api/reader/comment/edit/route.ts +9 -0
- package/app/api/reader/comment/route.ts +9 -0
- package/app/api/reader/flag/route.ts +9 -0
- package/app/api/reader/handle/route.ts +9 -0
- package/app/api/reader/me/route.ts +9 -0
- package/app/api/reader/rating/route.ts +9 -0
- package/app/api/reader/report/route.ts +9 -0
- package/app/api/reader/retired/route.ts +9 -0
- package/app/api/reader/submit/route.ts +9 -0
- package/app/api/reader/vote/route.ts +9 -0
- package/bin/papyrus-infra.mjs +20 -1
- package/components/automation-panel.tsx +49 -0
- package/components/automation-run-now.tsx +97 -0
- package/components/automation-view.tsx +192 -0
- package/components/editorial-checks-admin-panel.tsx +36 -11
- package/components/moderation-panel.tsx +248 -0
- package/components/monitoring-overview-view.tsx +241 -0
- package/components/monitoring-panel.tsx +40 -0
- package/components/news-desk-client-provider.tsx +18 -18
- package/components/news-desk-taxonomy-client.ts +28 -8
- package/components/newsletter-panel.tsx +474 -0
- package/components/newsletter-sections.tsx +163 -0
- package/components/newsroom-access-gate.tsx +15 -1
- package/components/newsroom-article-editor.tsx +6 -6
- package/components/newsroom-articles-view.tsx +6 -6
- package/components/newsroom-board-view.tsx +8 -3
- package/components/newsroom-curation-bootstrap.tsx +45 -0
- package/components/newsroom-ops-overview.tsx +22 -92
- package/components/newsroom-ops-shell.tsx +2 -2
- package/components/newsroom-organizer.css +33 -0
- package/components/newsroom-overview-skeleton.tsx +25 -0
- package/components/newsroom-references-view.tsx +192 -49
- package/components/notification-center.tsx +273 -0
- package/components/publication-cyclotron-panel.tsx +15 -10
- package/components/reader-auth-state.ts +1 -1
- package/components/reference-add-entry.tsx +75 -0
- package/components/reference-curation-controls.tsx +31 -22
- package/components/reference-cyclotron-priming.tsx +58 -19
- package/components/reference-cyclotron-suggestion.tsx +10 -6
- package/components/reference-priming.tsx +14 -10
- package/components/reference-relevance-review.tsx +3 -3
- package/components/reference-section-suggestion.tsx +1 -1
- package/components/topic-steering-workspace.tsx +223 -144
- package/components/use-editorial-checks.ts +12 -10
- package/infra/amplify-app-shell.js +94 -16
- package/infra/amplify-app-shell.ts +106 -18
- package/infra/cloud-email-config.js +32 -0
- package/infra/cloud-email-config.ts +39 -0
- package/infra/cloud-email.js +57 -0
- package/infra/cloud-email.test.js +69 -0
- package/infra/cloud-email.test.ts +66 -0
- package/infra/cloud-email.ts +60 -0
- package/infra/index.js +1 -0
- package/infra/secure-string-parameter-handler.js +41 -0
- package/infra/secure-string-parameter-handler.ts +41 -0
- package/infra/secure-string-parameter.js +79 -0
- package/infra/secure-string-parameter.ts +91 -0
- package/infra/site-config.js +88 -6
- package/infra/site-config.ts +134 -6
- package/lib/automation-client.ts +54 -0
- package/lib/automation-fixture.ts +93 -0
- package/lib/automation-overview.ts +232 -0
- package/lib/category-repository.ts +11 -9
- package/lib/curation-routes.ts +345 -0
- package/lib/cyclotron-suggestions.ts +4 -4
- package/lib/editorial-checks-backend.ts +1 -1
- package/lib/editorial-checks-client.ts +19 -1
- package/lib/editorial-checks-demo.ts +2 -2
- package/lib/editorial-checks-settings.ts +3 -0
- package/lib/editorial-checks.ts +8 -11
- package/lib/editorial-preview.ts +5 -5
- package/lib/monitoring-client.ts +29 -0
- package/lib/monitoring-overview-fixture.ts +51 -0
- package/lib/monitoring-overview.ts +145 -0
- package/lib/news-desk-session.ts +12 -21
- package/lib/newsletter-api.ts +418 -0
- package/lib/newsletter-client.ts +46 -0
- package/lib/newsletter-fixture.ts +184 -0
- package/lib/newsletter-model.ts +202 -0
- package/lib/newsroom-organizer-nav.ts +2 -1
- package/lib/prospectus-session.ts +15 -14
- package/lib/prospectus-sign-in-routes.ts +84 -19
- package/lib/prospectus-site-identity.ts +24 -1
- package/lib/reader-api.ts +145 -0
- package/lib/reader-moderation.ts +220 -0
- package/lib/reader-retirement-route.ts +89 -0
- package/lib/reader-subject.md +29 -0
- package/lib/reader-subject.ts +53 -0
- package/lib/reference-review-details.ts +71 -0
- package/lib/reference-review-request.ts +14 -0
- package/lib/relevance-decisions.ts +42 -10
- package/lib/relevance-status.ts +2 -2
- package/lib/review-client.ts +99 -0
- package/lib/review-fixture.ts +45 -0
- package/lib/review-requests.ts +238 -0
- package/lib/semantic-graph.ts +2 -0
- package/lib/site-brand-tenant.ts +11 -6
- package/lib/site-brand.ts +6 -3
- package/lib/site-origin.ts +67 -0
- package/lib/use-reference-review-data.ts +44 -0
- package/middleware.ts +14 -3
- package/package.json +56 -3
- package/routes.manifest.json +215 -2
- package/amplify/plugins/editorial-checks/function/requirements.txt +0 -1
- package/amplify/plugins/editorial-checks/papyrus.plugin.js +0 -71
- package/amplify/plugins/editorial-checks/papyrus.plugin.ts +0 -87
- package/amplify/plugins/editorial-checks/schema.js +0 -94
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { Duration, Stack } from "aws-cdk-lib";
|
|
2
|
+
import { Effect, PolicyStatement, Role, ServicePrincipal } from "aws-cdk-lib/aws-iam";
|
|
3
|
+
import { Architecture, Function as LambdaFunction, Runtime } from "aws-cdk-lib/aws-lambda";
|
|
4
|
+
import { SqsEventSource } from "aws-cdk-lib/aws-lambda-event-sources";
|
|
5
|
+
import { CfnSchedule } from "aws-cdk-lib/aws-scheduler";
|
|
6
|
+
import { Queue, QueueEncryption } from "aws-cdk-lib/aws-sqs";
|
|
7
|
+
export const NEWSLETTER_WORKER_HANDLER = "papyrus_content.newsletter.worker.handler";
|
|
8
|
+
/** The worker mails up to 50 subscribers per message at the SES send rate; the visibility timeout is six times this. */
|
|
9
|
+
export const WORKER_TIMEOUT_SECONDS = 180;
|
|
10
|
+
export const SEND_MAX_RECEIVE_COUNT = 3;
|
|
11
|
+
/** The schedule names the function may manage at run time: `papyrus-nl-<siteId>-<issue8>`. */
|
|
12
|
+
export const SCHEDULE_NAME_PREFIX = "papyrus-nl-";
|
|
13
|
+
export const newsletterFunctionName = (siteId) => `papyrus-newsletter-${siteId}`;
|
|
14
|
+
export const newsletterWorkerName = (siteId) => `papyrus-newsletter-send-${siteId}`;
|
|
15
|
+
export const newsletterSendQueueName = (siteId) => `papyrus-newsletter-send-${siteId}`;
|
|
16
|
+
/** 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). */
|
|
17
|
+
export const NEWSLETTER_APPSYNC_FIELDS = [
|
|
18
|
+
"Query/fields/getItem",
|
|
19
|
+
"Query/fields/getPublishedItem",
|
|
20
|
+
"Query/fields/listPublishedItems",
|
|
21
|
+
"Mutation/fields/createItem",
|
|
22
|
+
"Mutation/fields/updateItem",
|
|
23
|
+
];
|
|
24
|
+
/** The only two SSM parameters that hold the postal address: the site's override and the shared default. */
|
|
25
|
+
export function postalAddressParameterArns(stack, siteId) {
|
|
26
|
+
return [`/papyrus/${siteId}/email/postal-address`, "/papyrus/email/postal-address"].map((name) => `arn:${stack.partition}:ssm:${stack.region}:${stack.account}:parameter${name}`);
|
|
27
|
+
}
|
|
28
|
+
export function scheduleArnPattern(stack, siteId) {
|
|
29
|
+
return `arn:${stack.partition}:scheduler:${stack.region}:${stack.account}:schedule/default/${SCHEDULE_NAME_PREFIX}${siteId}-*`;
|
|
30
|
+
}
|
|
31
|
+
export function functionArn(stack, name) {
|
|
32
|
+
return `arn:${stack.partition}:lambda:${stack.region}:${stack.account}:function:${name}`;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Everything that sends: the queue and its dead letter queue, the send worker, the scheduler role and the per-list
|
|
36
|
+
* draft schedules, and the IAM they need. Nothing here lets the function reach anything but its own queue, its own
|
|
37
|
+
* schedules (`papyrus-nl-<siteId>-*`), two postal address parameters and five content API fields.
|
|
38
|
+
*/
|
|
39
|
+
export function wireSending(inputs) {
|
|
40
|
+
const { stack, config, context, backend, table, lambda, environment, keyParameterArn, sesResources, code } = inputs;
|
|
41
|
+
const deadLetterQueue = new Queue(stack, "SendDeadLetterQueue", {
|
|
42
|
+
queueName: `${newsletterSendQueueName(config.siteId)}-dlq`,
|
|
43
|
+
retentionPeriod: Duration.days(14),
|
|
44
|
+
encryption: QueueEncryption.SQS_MANAGED,
|
|
45
|
+
enforceSSL: true,
|
|
46
|
+
});
|
|
47
|
+
const queue = new Queue(stack, "SendQueue", {
|
|
48
|
+
queueName: newsletterSendQueueName(config.siteId),
|
|
49
|
+
visibilityTimeout: Duration.seconds(WORKER_TIMEOUT_SECONDS * 6),
|
|
50
|
+
encryption: QueueEncryption.SQS_MANAGED,
|
|
51
|
+
enforceSSL: true,
|
|
52
|
+
deadLetterQueue: { queue: deadLetterQueue, maxReceiveCount: SEND_MAX_RECEIVE_COUNT },
|
|
53
|
+
});
|
|
54
|
+
// The role EventBridge Scheduler assumes to invoke the function, for the draft schedules and the one-time send schedules.
|
|
55
|
+
const schedulerRole = new Role(stack, "SchedulerRole", {
|
|
56
|
+
assumedBy: new ServicePrincipal("scheduler.amazonaws.com", { conditions: { StringEquals: { "aws:SourceAccount": stack.account } } }),
|
|
57
|
+
description: `Lets EventBridge Scheduler invoke the ${config.siteId} newsletter function`,
|
|
58
|
+
});
|
|
59
|
+
lambda.grantInvoke(schedulerRole);
|
|
60
|
+
const contentActions = context.contentActionsLambda;
|
|
61
|
+
lambda.addEnvironment("FUNCTION_ARN", functionArn(stack, newsletterFunctionName(config.siteId)));
|
|
62
|
+
lambda.addEnvironment("SCHEDULER_ROLE_ARN", schedulerRole.roleArn);
|
|
63
|
+
lambda.addEnvironment("QUEUE_URL", queue.queueUrl);
|
|
64
|
+
lambda.addEnvironment("CONTENT_ACTIONS_FUNCTION", contentActions.functionName);
|
|
65
|
+
lambda.addEnvironment("PAPYRUS_GRAPHQL_ENDPOINT", context.graphqlEndpoint);
|
|
66
|
+
queue.grantSendMessages(lambda);
|
|
67
|
+
contentActions.grantInvoke(lambda);
|
|
68
|
+
lambda.addToRolePolicy(new PolicyStatement({ effect: Effect.ALLOW, actions: ["ssm:GetParameter"], resources: postalAddressParameterArns(stack, config.siteId) }));
|
|
69
|
+
lambda.addToRolePolicy(new PolicyStatement({
|
|
70
|
+
effect: Effect.ALLOW,
|
|
71
|
+
actions: ["scheduler:CreateSchedule", "scheduler:UpdateSchedule", "scheduler:DeleteSchedule"],
|
|
72
|
+
resources: [scheduleArnPattern(stack, config.siteId)],
|
|
73
|
+
}));
|
|
74
|
+
lambda.addToRolePolicy(new PolicyStatement({
|
|
75
|
+
effect: Effect.ALLOW,
|
|
76
|
+
actions: ["iam:PassRole"],
|
|
77
|
+
resources: [schedulerRole.roleArn],
|
|
78
|
+
conditions: { StringEquals: { "iam:PassedToService": "scheduler.amazonaws.com" } },
|
|
79
|
+
}));
|
|
80
|
+
lambda.addToRolePolicy(new PolicyStatement({
|
|
81
|
+
effect: Effect.ALLOW,
|
|
82
|
+
actions: ["appsync:GraphQL"],
|
|
83
|
+
resources: NEWSLETTER_APPSYNC_FIELDS.map((field) => `arn:${stack.partition}:appsync:${stack.region}:${stack.account}:apis/${context.graphqlApiId}/types/${field}`),
|
|
84
|
+
}));
|
|
85
|
+
const worker = new LambdaFunction(stack, "SendWorkerFunction", {
|
|
86
|
+
functionName: newsletterWorkerName(config.siteId),
|
|
87
|
+
runtime: Runtime.PYTHON_3_12,
|
|
88
|
+
architecture: Architecture.ARM_64,
|
|
89
|
+
handler: NEWSLETTER_WORKER_HANDLER,
|
|
90
|
+
timeout: Duration.seconds(WORKER_TIMEOUT_SECONDS),
|
|
91
|
+
memorySize: 512,
|
|
92
|
+
// One message at a time, so the send rate is the account's SES rate, never a multiple of it.
|
|
93
|
+
reservedConcurrentExecutions: 1,
|
|
94
|
+
description: `Mails queued newsletter batches for ${config.siteId}, paced under the SES send rate`,
|
|
95
|
+
environment: { ...environment, QUEUE_URL: queue.queueUrl, CONTENT_ACTIONS_FUNCTION: contentActions.functionName },
|
|
96
|
+
code,
|
|
97
|
+
});
|
|
98
|
+
worker.addEventSource(new SqsEventSource(queue, { batchSize: 1 }));
|
|
99
|
+
table.grantReadWriteData(worker);
|
|
100
|
+
queue.grantSendMessages(worker);
|
|
101
|
+
contentActions.grantInvoke(worker);
|
|
102
|
+
worker.addToRolePolicy(new PolicyStatement({ effect: Effect.ALLOW, actions: ["ssm:GetParameter"], resources: [keyParameterArn] }));
|
|
103
|
+
worker.addToRolePolicy(new PolicyStatement({
|
|
104
|
+
effect: Effect.ALLOW,
|
|
105
|
+
actions: ["ses:SendEmail", "ses:SendRawEmail"],
|
|
106
|
+
resources: sesResources,
|
|
107
|
+
conditions: { StringEquals: { "ses:FromAddress": config.fromAddress } },
|
|
108
|
+
}));
|
|
109
|
+
// GetAccount (the send rate) does not support resource-level permissions.
|
|
110
|
+
worker.addToRolePolicy(new PolicyStatement({ effect: Effect.ALLOW, actions: ["sesv2:GetAccount"], resources: ["*"] }));
|
|
111
|
+
// A roundup list drafts itself on its schedule, in the schedule's own time zone.
|
|
112
|
+
const roundups = config.lists.filter((list) => list.kind === "roundup");
|
|
113
|
+
for (const list of roundups) {
|
|
114
|
+
new CfnSchedule(stack, `Draft${list.id.replace(/(^|-)([a-z0-9])/g, (_match, _dash, letter) => letter.toUpperCase())}`, {
|
|
115
|
+
description: `Draft the ${list.id} roundup for ${config.siteId}`,
|
|
116
|
+
scheduleExpression: list.schedule.draft,
|
|
117
|
+
scheduleExpressionTimezone: list.schedule.timezone,
|
|
118
|
+
flexibleTimeWindow: { mode: "OFF" },
|
|
119
|
+
state: "ENABLED",
|
|
120
|
+
target: {
|
|
121
|
+
arn: lambda.functionArn,
|
|
122
|
+
roleArn: schedulerRole.roleArn,
|
|
123
|
+
input: JSON.stringify({ task: "assemble-roundup", listId: list.id }),
|
|
124
|
+
retryPolicy: { maximumRetryAttempts: 2, maximumEventAgeInSeconds: 3600 },
|
|
125
|
+
},
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
// The newsroom operations reach the function through the thin AppSync function the plugin defines.
|
|
129
|
+
const operations = backend.newsletterOps?.resources.lambda;
|
|
130
|
+
if (operations) {
|
|
131
|
+
const operationsStack = Stack.of(operations);
|
|
132
|
+
operations.addEnvironment("NEWSLETTER_FUNCTION_NAME", newsletterFunctionName(config.siteId));
|
|
133
|
+
operations.addToRolePolicy(new PolicyStatement({
|
|
134
|
+
effect: Effect.ALLOW,
|
|
135
|
+
actions: ["lambda:InvokeFunction"],
|
|
136
|
+
resources: [functionArn(operationsStack, newsletterFunctionName(config.siteId))],
|
|
137
|
+
}));
|
|
138
|
+
}
|
|
139
|
+
return { queue, deadLetterQueue, worker, schedulerRole };
|
|
140
|
+
}
|
|
@@ -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
|
+
}
|