@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.
- package/amplify/functions/category-action/handler.ts +2 -1
- package/amplify/functions/model-attachment-upload/handler.ts +1 -0
- package/amplify/functions/monitoring-view/schema.js +16 -0
- package/amplify/functions/monitoring-view/schema.ts +16 -0
- package/amplify/functions/procedure-action/handler.ts +2 -10
- package/amplify/functions/proposal-actions/proposals.py +20 -3
- package/amplify/plugins/reader-steering/function/handler.py +1 -3
- package/amplify/plugins/reader-steering/function/moderation.py +19 -0
- package/bin/papyrus-infra.mjs +21 -1
- package/components/dev-sandbox-editor-sign-in.tsx +2 -2
- package/components/monitoring-overview-view.tsx +143 -21
- package/components/monitoring-panel.tsx +64 -4
- package/components/news-desk-client-provider.tsx +24 -3
- package/components/news-desk-page.tsx +2 -1
- package/components/news-desk-taxonomy-client.ts +2 -1
- package/components/newsroom-access-gate.tsx +7 -5
- package/components/newsroom-client-shell.tsx +2 -1
- package/components/newsroom-ops-overview.tsx +6 -1
- package/components/newsroom-ops-shell.tsx +1 -1
- package/components/newsroom-organizer.css +48 -0
- package/components/newsroom-references-view.tsx +183 -24
- package/components/notification-center.tsx +345 -63
- package/components/papyrus-console-shell.tsx +2 -1
- package/components/proposals-panel.tsx +21 -24
- package/components/proposals-view.tsx +4 -2
- package/components/reference-feed.tsx +469 -0
- package/components/reference-review-card.tsx +90 -76
- package/components/references-page-loading.tsx +61 -0
- package/components/references-prefetch.ts +47 -0
- package/components/review-rating-grid.tsx +164 -0
- package/components/topic-steering-workspace.tsx +32 -8
- package/infra/amplify-app-shell.js +18 -2
- package/infra/amplify-app-shell.ts +18 -2
- package/infra/chat-stack.js +222 -0
- package/infra/chat-stack.ts +250 -0
- package/infra/chat.test.js +142 -0
- package/infra/chat.test.ts +151 -0
- package/infra/index.js +1 -0
- package/infra/site-config.js +58 -1
- package/infra/site-config.ts +78 -1
- package/lib/category-repository.ts +4 -1
- package/lib/local-preview-feed-fixture.ts +43 -0
- package/lib/local-preview-synthetic-rejects.ts +41 -0
- package/lib/monitoring-client.ts +29 -2
- package/lib/monitoring-overview.ts +52 -1
- package/lib/monitoring-paging.ts +58 -0
- package/lib/newsroom-chat-identity.ts +2 -1
- package/lib/newsroom-organizer-nav.ts +1 -0
- package/lib/proposals-client.ts +29 -0
- package/lib/reader-subject.ts +3 -2
- package/lib/reference-feed-cache.ts +133 -0
- package/lib/reference-feed-ranking.ts +191 -0
- package/lib/reference-preview.ts +112 -0
- package/lib/review-client.ts +38 -1
- package/lib/review-drafts.ts +231 -0
- package/lib/review-fixture.ts +36 -2
- package/lib/review-requests.ts +64 -24
- package/lib/staff-groups.ts +23 -0
- package/lib/staging-gate.ts +2 -2
- package/package.json +14 -1
- package/routes.manifest.json +1 -1
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
import { Annotations, Arn, ArnFormat, Aws, CfnOutput, CfnParameter, Duration, Fn, RemovalPolicy, Stack } from "aws-cdk-lib";
|
|
2
|
+
import * as dynamodb from "aws-cdk-lib/aws-dynamodb";
|
|
3
|
+
import * as iam from "aws-cdk-lib/aws-iam";
|
|
4
|
+
import * as lambda from "aws-cdk-lib/aws-lambda";
|
|
5
|
+
import * as eventSources from "aws-cdk-lib/aws-lambda-event-sources";
|
|
6
|
+
import * as logs from "aws-cdk-lib/aws-logs";
|
|
7
|
+
import * as s3 from "aws-cdk-lib/aws-s3";
|
|
8
|
+
import * as sqs from "aws-cdk-lib/aws-sqs";
|
|
9
|
+
import { CHAT_PRIVATE_ASSISTANT_ID, CHAT_PRIVATE_ASSISTANT_NAME, chatExportName, resolveChatLogGroupPrefix, resolveChatStackName, } from "./site-config.js";
|
|
10
|
+
/**
|
|
11
|
+
* The private newsroom chat data plane of one publication: two DynamoDB tables, a private versioned S3 bucket, run
|
|
12
|
+
* and probe queues with DLQs, and the two worker Lambdas with their event source mappings. The CMS compute role
|
|
13
|
+
* receives the scoped gateway policy through the `GatewayRoleArn` parameter (the app shell's `CmsComputeRoleArn`
|
|
14
|
+
* output), and the app shell reads the resource names back through this stack's exports.
|
|
15
|
+
*
|
|
16
|
+
* The resource definitions are the ones the first activated stack `PapyrusChatDataCandidate` was activated from
|
|
17
|
+
* (infra/shared-chat-runtime/cdk/synth.cjs); logical ids and construct order are kept so that stack is adopted by an
|
|
18
|
+
* in-place update (scripts/test-infra-chat.ts compares the two templates).
|
|
19
|
+
*/
|
|
20
|
+
export class PapyrusChatStack extends Stack {
|
|
21
|
+
constructor(scope, props) {
|
|
22
|
+
const { config, lambdaAssetDirectory, ...stackProps } = props;
|
|
23
|
+
const chat = config.chat;
|
|
24
|
+
const stackName = resolveChatStackName(config);
|
|
25
|
+
if (!chat || !stackName)
|
|
26
|
+
throw new Error(`site ${config.siteId} does not enable chat`);
|
|
27
|
+
// The construct id is the stack name: the SQS event source mapping logical ids derive from it.
|
|
28
|
+
super(scope, stackName, { ...stackProps, stackName });
|
|
29
|
+
const modelKeyParameter = chat.modelKeyParameter;
|
|
30
|
+
const logGroupPrefix = resolveChatLogGroupPrefix(config);
|
|
31
|
+
const gatewayRoleArn = new CfnParameter(this, "GatewayRoleArn", {
|
|
32
|
+
type: "String",
|
|
33
|
+
description: "The site's CMS compute role ARN (CmsComputeRoleArn output of the app-shell stack); receives the scoped gateway policy.",
|
|
34
|
+
allowedPattern: "^arn:aws:iam::[0-9]{12}:role/.+$",
|
|
35
|
+
});
|
|
36
|
+
const privateBotId = new CfnParameter(this, "PrivateAssistantBotId", {
|
|
37
|
+
type: "String",
|
|
38
|
+
default: CHAT_PRIVATE_ASSISTANT_ID,
|
|
39
|
+
description: "Stable Papyrus-owned assistant ID; stored per derived account tenant on first private-channel open.",
|
|
40
|
+
allowedPattern: "^[A-Za-z0-9][A-Za-z0-9_-]{1,63}$",
|
|
41
|
+
});
|
|
42
|
+
const privateBotName = new CfnParameter(this, "PrivateAssistantBotName", {
|
|
43
|
+
type: "String",
|
|
44
|
+
default: CHAT_PRIVATE_ASSISTANT_NAME,
|
|
45
|
+
description: "Visible name for the private Papyrus assistant.",
|
|
46
|
+
allowedPattern: "^[A-Za-z0-9][A-Za-z0-9 ._-]{1,63}$",
|
|
47
|
+
});
|
|
48
|
+
const messaging = new dynamodb.Table(this, "PrivateMessaging", {
|
|
49
|
+
partitionKey: { name: "pk", type: dynamodb.AttributeType.STRING },
|
|
50
|
+
sortKey: { name: "sk", type: dynamodb.AttributeType.STRING },
|
|
51
|
+
billingMode: dynamodb.BillingMode.PAY_PER_REQUEST,
|
|
52
|
+
timeToLiveAttribute: "expires_at",
|
|
53
|
+
pointInTimeRecoverySpecification: { pointInTimeRecoveryEnabled: true },
|
|
54
|
+
deletionProtection: true,
|
|
55
|
+
removalPolicy: RemovalPolicy.RETAIN,
|
|
56
|
+
});
|
|
57
|
+
const conversations = new dynamodb.Table(this, "PrivateConversationIndex", {
|
|
58
|
+
partitionKey: { name: "pk", type: dynamodb.AttributeType.STRING },
|
|
59
|
+
sortKey: { name: "sk", type: dynamodb.AttributeType.STRING },
|
|
60
|
+
billingMode: dynamodb.BillingMode.PAY_PER_REQUEST,
|
|
61
|
+
pointInTimeRecoverySpecification: { pointInTimeRecoveryEnabled: true },
|
|
62
|
+
deletionProtection: true,
|
|
63
|
+
removalPolicy: RemovalPolicy.RETAIN,
|
|
64
|
+
});
|
|
65
|
+
for (const name of ["l1", "l2", "l3"]) {
|
|
66
|
+
conversations.addLocalSecondaryIndex({
|
|
67
|
+
indexName: `${name}-index`,
|
|
68
|
+
sortKey: { name, type: dynamodb.AttributeType.STRING },
|
|
69
|
+
projectionType: dynamodb.ProjectionType.ALL,
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
const objects = new s3.Bucket(this, "PrivateSessionObjects", {
|
|
73
|
+
blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
|
|
74
|
+
encryption: s3.BucketEncryption.S3_MANAGED,
|
|
75
|
+
enforceSSL: true,
|
|
76
|
+
versioned: true,
|
|
77
|
+
removalPolicy: RemovalPolicy.RETAIN,
|
|
78
|
+
objectOwnership: s3.ObjectOwnership.BUCKET_OWNER_ENFORCED,
|
|
79
|
+
});
|
|
80
|
+
const queue = (id, extra = {}) => new sqs.Queue(this, id, { encryption: sqs.QueueEncryption.SQS_MANAGED, enforceSSL: true, ...extra });
|
|
81
|
+
const runDlq = queue("TurnRunDlq", { retentionPeriod: Duration.days(14) });
|
|
82
|
+
const probeDlq = queue("TurnProbeDlq", { retentionPeriod: Duration.days(14) });
|
|
83
|
+
const runs = queue("TurnRuns", {
|
|
84
|
+
visibilityTimeout: Duration.seconds(1800),
|
|
85
|
+
retentionPeriod: Duration.days(4),
|
|
86
|
+
deadLetterQueue: { queue: runDlq, maxReceiveCount: 5 },
|
|
87
|
+
});
|
|
88
|
+
const probes = queue("TurnProbes", {
|
|
89
|
+
visibilityTimeout: Duration.minutes(2),
|
|
90
|
+
retentionPeriod: Duration.days(4),
|
|
91
|
+
deadLetterQueue: { queue: probeDlq, maxReceiveCount: 5 },
|
|
92
|
+
});
|
|
93
|
+
const gateway = iam.Role.fromRoleArn(this, "PapyrusChatGatewayRole", gatewayRoleArn.valueAsString, { mutable: true });
|
|
94
|
+
gateway.addToPrincipalPolicy(new iam.PolicyStatement({
|
|
95
|
+
actions: ["dynamodb:GetItem", "dynamodb:PutItem", "dynamodb:UpdateItem", "dynamodb:TransactWriteItems", "dynamodb:Query", "dynamodb:BatchGetItem"],
|
|
96
|
+
resources: [messaging.tableArn],
|
|
97
|
+
}));
|
|
98
|
+
gateway.addToPrincipalPolicy(new iam.PolicyStatement({
|
|
99
|
+
actions: ["dynamodb:GetItem", "dynamodb:Query", "dynamodb:BatchGetItem"],
|
|
100
|
+
resources: [conversations.tableArn, `${conversations.tableArn}/index/*`],
|
|
101
|
+
}));
|
|
102
|
+
gateway.addToPrincipalPolicy(new iam.PolicyStatement({ actions: ["s3:GetObject"], resources: [objects.arnForObjects("conversations/*")] }));
|
|
103
|
+
gateway.addToPrincipalPolicy(new iam.PolicyStatement({ actions: ["sqs:SendMessage"], resources: [runs.queueArn, probes.queueArn] }));
|
|
104
|
+
// Appended last, as in the activated stack's policy (the transaction-check correction was added to it afterwards).
|
|
105
|
+
gateway.addToPrincipalPolicy(new iam.PolicyStatement({ actions: ["dynamodb:ConditionCheckItem"], resources: [messaging.tableArn] }));
|
|
106
|
+
const hostedEnvironment = {
|
|
107
|
+
PAPYRUS_CHAT_MESSAGING_TABLE: messaging.tableName,
|
|
108
|
+
PAPYRUS_CHAT_CONVERSATIONS_TABLE: conversations.tableName,
|
|
109
|
+
PAPYRUS_CHAT_SESSION_BUCKET: objects.bucketName,
|
|
110
|
+
PAPYRUS_CHAT_TURN_RUNS_QUEUE_URL: runs.queueUrl,
|
|
111
|
+
PAPYRUS_CHAT_TURN_PROBES_QUEUE_URL: probes.queueUrl,
|
|
112
|
+
PAPYRUS_CHAT_PRIVATE_BOT_ID: privateBotId.valueAsString,
|
|
113
|
+
PAPYRUS_CHAT_PRIVATE_BOT_NAME: privateBotName.valueAsString,
|
|
114
|
+
};
|
|
115
|
+
const runLogGroup = new logs.LogGroup(this, "RunWorkerLogs", {
|
|
116
|
+
logGroupName: `${logGroupPrefix}-run-worker`,
|
|
117
|
+
retention: logs.RetentionDays.ONE_MONTH,
|
|
118
|
+
removalPolicy: RemovalPolicy.RETAIN,
|
|
119
|
+
});
|
|
120
|
+
const probeLogGroup = new logs.LogGroup(this, "ProbeWorkerLogs", {
|
|
121
|
+
logGroupName: `${logGroupPrefix}-probe-worker`,
|
|
122
|
+
retention: logs.RetentionDays.ONE_MONTH,
|
|
123
|
+
removalPolicy: RemovalPolicy.RETAIN,
|
|
124
|
+
});
|
|
125
|
+
const runWorker = new iam.Role(this, "RunWorkerRole", {
|
|
126
|
+
assumedBy: new iam.ServicePrincipal("lambda.amazonaws.com"),
|
|
127
|
+
description: "Private Papyrus computerless chat run worker.",
|
|
128
|
+
});
|
|
129
|
+
runWorker.addToPolicy(new iam.PolicyStatement({
|
|
130
|
+
actions: ["dynamodb:GetItem", "dynamodb:PutItem", "dynamodb:UpdateItem", "dynamodb:DeleteItem", "dynamodb:Query", "dynamodb:BatchGetItem", "dynamodb:TransactWriteItems"],
|
|
131
|
+
resources: [messaging.tableArn, conversations.tableArn, `${conversations.tableArn}/index/*`],
|
|
132
|
+
}));
|
|
133
|
+
runWorker.addToPolicy(new iam.PolicyStatement({
|
|
134
|
+
actions: ["ssm:GetParameter"],
|
|
135
|
+
// Pseudo parameters, not the literal account: the template stays environment-agnostic like the activated stack.
|
|
136
|
+
resources: [Arn.format({ partition: Aws.PARTITION, region: Aws.REGION, account: Aws.ACCOUNT_ID, service: "ssm", resource: "parameter", resourceName: modelKeyParameter.slice(1), arnFormat: ArnFormat.SLASH_RESOURCE_NAME })],
|
|
137
|
+
}));
|
|
138
|
+
runWorker.addToPolicy(new iam.PolicyStatement({ actions: ["logs:CreateLogStream", "logs:PutLogEvents"], resources: [`${runLogGroup.logGroupArn}:*`] }));
|
|
139
|
+
runWorker.addToPolicy(new iam.PolicyStatement({
|
|
140
|
+
actions: ["s3:GetObject", "s3:PutObject", "s3:DeleteObject"],
|
|
141
|
+
resources: [objects.arnForObjects("conversations/*")],
|
|
142
|
+
}));
|
|
143
|
+
runWorker.addToPolicy(new iam.PolicyStatement({ actions: ["sqs:ChangeMessageVisibility", "sqs:SendMessage"], resources: [runs.queueArn, probes.queueArn] }));
|
|
144
|
+
const probeWorker = new iam.Role(this, "ProbeWorkerRole", {
|
|
145
|
+
assumedBy: new iam.ServicePrincipal("lambda.amazonaws.com"),
|
|
146
|
+
description: "Private Papyrus chat deadline/recovery worker.",
|
|
147
|
+
});
|
|
148
|
+
probeWorker.addToPolicy(new iam.PolicyStatement({
|
|
149
|
+
actions: ["dynamodb:GetItem", "dynamodb:PutItem", "dynamodb:UpdateItem", "dynamodb:DeleteItem", "dynamodb:Query", "dynamodb:TransactWriteItems", "dynamodb:BatchGetItem"],
|
|
150
|
+
resources: [messaging.tableArn],
|
|
151
|
+
}));
|
|
152
|
+
probeWorker.addToPolicy(new iam.PolicyStatement({
|
|
153
|
+
actions: ["dynamodb:GetItem", "dynamodb:Query", "dynamodb:BatchGetItem"],
|
|
154
|
+
resources: [conversations.tableArn, `${conversations.tableArn}/index/*`],
|
|
155
|
+
}));
|
|
156
|
+
probeWorker.addToPolicy(new iam.PolicyStatement({ actions: ["s3:GetObject"], resources: [objects.arnForObjects("conversations/*")] }));
|
|
157
|
+
probeWorker.addToPolicy(new iam.PolicyStatement({ actions: ["sqs:SendMessage"], resources: [runs.queueArn, probes.queueArn] }));
|
|
158
|
+
probeWorker.addToPolicy(new iam.PolicyStatement({ actions: ["logs:CreateLogStream", "logs:PutLogEvents"], resources: [`${probeLogGroup.logGroupArn}:*`] }));
|
|
159
|
+
let code;
|
|
160
|
+
if (chat.workerCodeS3Key) {
|
|
161
|
+
// Already uploaded and verified bytes: the template names the object, nothing is bundled or uploaded.
|
|
162
|
+
const assets = s3.Bucket.fromBucketName(this, "WorkerCodeAssets", Fn.sub("cdk-hnb659fds-assets-${AWS::AccountId}-${AWS::Region}"));
|
|
163
|
+
code = lambda.Code.fromBucket(assets, chat.workerCodeS3Key);
|
|
164
|
+
Annotations.of(assets).acknowledgeWarning("@aws-cdk/aws-lambda:codeFromBucketObjectVersionNotSpecified", "the key is a content hash; it is never overwritten");
|
|
165
|
+
}
|
|
166
|
+
else {
|
|
167
|
+
if (!lambdaAssetDirectory)
|
|
168
|
+
throw new Error(`site ${config.siteId}: bundle the chat workers first (scripts/bundle-chat-lambdas.mjs) and pass -c chatLambdas=<dir>, or pin chat.workerCodeS3Key`);
|
|
169
|
+
code = lambda.Code.fromAsset(lambdaAssetDirectory);
|
|
170
|
+
}
|
|
171
|
+
const runWorkerFunction = new lambda.Function(this, "RunWorker", {
|
|
172
|
+
runtime: lambda.Runtime.NODEJS_22_X,
|
|
173
|
+
handler: "run-worker.handler",
|
|
174
|
+
code,
|
|
175
|
+
role: runWorker,
|
|
176
|
+
memorySize: 1024,
|
|
177
|
+
timeout: Duration.seconds(900),
|
|
178
|
+
logGroup: runLogGroup,
|
|
179
|
+
environment: { ...hostedEnvironment, OPENAI_API_KEY_PARAMETER: modelKeyParameter },
|
|
180
|
+
description: "Runs Papyrus private chat turns with the computerless Chatticus execution profile.",
|
|
181
|
+
});
|
|
182
|
+
const probeWorkerFunction = new lambda.Function(this, "ProbeWorker", {
|
|
183
|
+
runtime: lambda.Runtime.NODEJS_22_X,
|
|
184
|
+
handler: "probe-worker.handler",
|
|
185
|
+
code,
|
|
186
|
+
role: probeWorker,
|
|
187
|
+
memorySize: 512,
|
|
188
|
+
timeout: Duration.seconds(60),
|
|
189
|
+
logGroup: probeLogGroup,
|
|
190
|
+
environment: hostedEnvironment,
|
|
191
|
+
description: "Recovers and finalizes Papyrus private chat turns.",
|
|
192
|
+
});
|
|
193
|
+
// The model key parameter exists for every configured site, so the workers run from the first deploy.
|
|
194
|
+
runWorkerFunction.addEventSource(new eventSources.SqsEventSource(runs, { enabled: true, batchSize: 1 }));
|
|
195
|
+
probeWorkerFunction.addEventSource(new eventSources.SqsEventSource(probes, { enabled: true, batchSize: 1 }));
|
|
196
|
+
// Last, as in the activated stack (the storage-owner fence was added after the event source grants).
|
|
197
|
+
runWorker.addToPolicy(new iam.PolicyStatement({ actions: ["dynamodb:ConditionCheckItem"], resources: [conversations.tableArn] }));
|
|
198
|
+
const exported = (id, value, exportKey) => new CfnOutput(this, id, { value, ...(exportKey ? { exportName: chatExportName(stackName, exportKey) } : {}) });
|
|
199
|
+
exported("MessagingTableName", messaging.tableName, "MessagingTableName");
|
|
200
|
+
exported("ConversationIndexTableName", conversations.tableName, "ConversationIndexTableName");
|
|
201
|
+
exported("SessionBucketName", objects.bucketName, "SessionBucketName");
|
|
202
|
+
exported("TurnRunsQueueUrl", runs.queueUrl, "TurnRunsQueueUrl");
|
|
203
|
+
exported("TurnProbesQueueUrl", probes.queueUrl, "TurnProbesQueueUrl");
|
|
204
|
+
exported("FeatureDefault", "enabled per site by the chat block of its site.json (PAPYRUS_PRIVATE_CHAT_ENABLED on the CMS production branch)");
|
|
205
|
+
exported("SyntheticAdapterTestOnly", "false");
|
|
206
|
+
exported("GatewayMessagingTableEnvironmentName", "PAPYRUS_CHAT_MESSAGING_TABLE");
|
|
207
|
+
exported("GatewayMessagingTableName", messaging.tableName);
|
|
208
|
+
exported("GatewayConversationTableEnvironmentName", "PAPYRUS_CHAT_CONVERSATIONS_TABLE");
|
|
209
|
+
exported("GatewayConversationTableName", conversations.tableName);
|
|
210
|
+
exported("GatewaySessionBucketEnvironmentName", "PAPYRUS_CHAT_SESSION_BUCKET");
|
|
211
|
+
exported("GatewaySessionBucketName", objects.bucketName);
|
|
212
|
+
exported("GatewayRunQueueEnvironmentName", "PAPYRUS_CHAT_TURN_RUNS_QUEUE_URL");
|
|
213
|
+
exported("GatewayProbeQueueEnvironmentName", "PAPYRUS_CHAT_TURN_PROBES_QUEUE_URL");
|
|
214
|
+
exported("GatewayPrivateBotIdEnvironmentName", "PAPYRUS_CHAT_PRIVATE_BOT_ID");
|
|
215
|
+
exported("PrivateAssistantBotIdValue", privateBotId.valueAsString);
|
|
216
|
+
exported("GatewayPrivateBotNameEnvironmentName", "PAPYRUS_CHAT_PRIVATE_BOT_NAME");
|
|
217
|
+
exported("PrivateAssistantBotNameValue", privateBotName.valueAsString);
|
|
218
|
+
exported("WorkerModelKeyParameterName", modelKeyParameter);
|
|
219
|
+
exported("RunWorkerFunctionName", runWorkerFunction.functionName);
|
|
220
|
+
exported("ProbeWorkerFunctionName", probeWorkerFunction.functionName);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
import { Construct } from "constructs";
|
|
2
|
+
import { Annotations, Arn, ArnFormat, Aws, CfnOutput, CfnParameter, Duration, Fn, RemovalPolicy, Stack, StackProps } from "aws-cdk-lib";
|
|
3
|
+
import * as dynamodb from "aws-cdk-lib/aws-dynamodb";
|
|
4
|
+
import * as iam from "aws-cdk-lib/aws-iam";
|
|
5
|
+
import * as lambda from "aws-cdk-lib/aws-lambda";
|
|
6
|
+
import * as eventSources from "aws-cdk-lib/aws-lambda-event-sources";
|
|
7
|
+
import * as logs from "aws-cdk-lib/aws-logs";
|
|
8
|
+
import * as s3 from "aws-cdk-lib/aws-s3";
|
|
9
|
+
import * as sqs from "aws-cdk-lib/aws-sqs";
|
|
10
|
+
import {
|
|
11
|
+
AmplifyAppShellSiteConfig,
|
|
12
|
+
CHAT_PRIVATE_ASSISTANT_ID,
|
|
13
|
+
CHAT_PRIVATE_ASSISTANT_NAME,
|
|
14
|
+
chatExportName,
|
|
15
|
+
resolveChatLogGroupPrefix,
|
|
16
|
+
resolveChatStackName,
|
|
17
|
+
} from "./site-config";
|
|
18
|
+
|
|
19
|
+
export type PapyrusChatStackProps = StackProps & {
|
|
20
|
+
config: AmplifyAppShellSiteConfig;
|
|
21
|
+
/** Directory holding the bundled `run-worker.js` and `probe-worker.js`. Not needed when `chat.workerCodeS3Key` pins the code. */
|
|
22
|
+
lambdaAssetDirectory?: string;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The private newsroom chat data plane of one publication: two DynamoDB tables, a private versioned S3 bucket, run
|
|
27
|
+
* and probe queues with DLQs, and the two worker Lambdas with their event source mappings. The CMS compute role
|
|
28
|
+
* receives the scoped gateway policy through the `GatewayRoleArn` parameter (the app shell's `CmsComputeRoleArn`
|
|
29
|
+
* output), and the app shell reads the resource names back through this stack's exports.
|
|
30
|
+
*
|
|
31
|
+
* The resource definitions are the ones the first activated stack `PapyrusChatDataCandidate` was activated from
|
|
32
|
+
* (infra/shared-chat-runtime/cdk/synth.cjs); logical ids and construct order are kept so that stack is adopted by an
|
|
33
|
+
* in-place update (scripts/test-infra-chat.ts compares the two templates).
|
|
34
|
+
*/
|
|
35
|
+
export class PapyrusChatStack extends Stack {
|
|
36
|
+
constructor(scope: Construct, props: PapyrusChatStackProps) {
|
|
37
|
+
const { config, lambdaAssetDirectory, ...stackProps } = props;
|
|
38
|
+
const chat = config.chat;
|
|
39
|
+
const stackName = resolveChatStackName(config);
|
|
40
|
+
if (!chat || !stackName) throw new Error(`site ${config.siteId} does not enable chat`);
|
|
41
|
+
// The construct id is the stack name: the SQS event source mapping logical ids derive from it.
|
|
42
|
+
super(scope, stackName, { ...stackProps, stackName });
|
|
43
|
+
|
|
44
|
+
const modelKeyParameter = chat.modelKeyParameter;
|
|
45
|
+
const logGroupPrefix = resolveChatLogGroupPrefix(config);
|
|
46
|
+
|
|
47
|
+
const gatewayRoleArn = new CfnParameter(this, "GatewayRoleArn", {
|
|
48
|
+
type: "String",
|
|
49
|
+
description: "The site's CMS compute role ARN (CmsComputeRoleArn output of the app-shell stack); receives the scoped gateway policy.",
|
|
50
|
+
allowedPattern: "^arn:aws:iam::[0-9]{12}:role/.+$",
|
|
51
|
+
});
|
|
52
|
+
const privateBotId = new CfnParameter(this, "PrivateAssistantBotId", {
|
|
53
|
+
type: "String",
|
|
54
|
+
default: CHAT_PRIVATE_ASSISTANT_ID,
|
|
55
|
+
description: "Stable Papyrus-owned assistant ID; stored per derived account tenant on first private-channel open.",
|
|
56
|
+
allowedPattern: "^[A-Za-z0-9][A-Za-z0-9_-]{1,63}$",
|
|
57
|
+
});
|
|
58
|
+
const privateBotName = new CfnParameter(this, "PrivateAssistantBotName", {
|
|
59
|
+
type: "String",
|
|
60
|
+
default: CHAT_PRIVATE_ASSISTANT_NAME,
|
|
61
|
+
description: "Visible name for the private Papyrus assistant.",
|
|
62
|
+
allowedPattern: "^[A-Za-z0-9][A-Za-z0-9 ._-]{1,63}$",
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
const messaging = new dynamodb.Table(this, "PrivateMessaging", {
|
|
66
|
+
partitionKey: { name: "pk", type: dynamodb.AttributeType.STRING },
|
|
67
|
+
sortKey: { name: "sk", type: dynamodb.AttributeType.STRING },
|
|
68
|
+
billingMode: dynamodb.BillingMode.PAY_PER_REQUEST,
|
|
69
|
+
timeToLiveAttribute: "expires_at",
|
|
70
|
+
pointInTimeRecoverySpecification: { pointInTimeRecoveryEnabled: true },
|
|
71
|
+
deletionProtection: true,
|
|
72
|
+
removalPolicy: RemovalPolicy.RETAIN,
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
const conversations = new dynamodb.Table(this, "PrivateConversationIndex", {
|
|
76
|
+
partitionKey: { name: "pk", type: dynamodb.AttributeType.STRING },
|
|
77
|
+
sortKey: { name: "sk", type: dynamodb.AttributeType.STRING },
|
|
78
|
+
billingMode: dynamodb.BillingMode.PAY_PER_REQUEST,
|
|
79
|
+
pointInTimeRecoverySpecification: { pointInTimeRecoveryEnabled: true },
|
|
80
|
+
deletionProtection: true,
|
|
81
|
+
removalPolicy: RemovalPolicy.RETAIN,
|
|
82
|
+
});
|
|
83
|
+
for (const name of ["l1", "l2", "l3"]) {
|
|
84
|
+
conversations.addLocalSecondaryIndex({
|
|
85
|
+
indexName: `${name}-index`,
|
|
86
|
+
sortKey: { name, type: dynamodb.AttributeType.STRING },
|
|
87
|
+
projectionType: dynamodb.ProjectionType.ALL,
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const objects = new s3.Bucket(this, "PrivateSessionObjects", {
|
|
92
|
+
blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
|
|
93
|
+
encryption: s3.BucketEncryption.S3_MANAGED,
|
|
94
|
+
enforceSSL: true,
|
|
95
|
+
versioned: true,
|
|
96
|
+
removalPolicy: RemovalPolicy.RETAIN,
|
|
97
|
+
objectOwnership: s3.ObjectOwnership.BUCKET_OWNER_ENFORCED,
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
const queue = (id: string, extra: Partial<sqs.QueueProps> = {}) =>
|
|
101
|
+
new sqs.Queue(this, id, { encryption: sqs.QueueEncryption.SQS_MANAGED, enforceSSL: true, ...extra });
|
|
102
|
+
const runDlq = queue("TurnRunDlq", { retentionPeriod: Duration.days(14) });
|
|
103
|
+
const probeDlq = queue("TurnProbeDlq", { retentionPeriod: Duration.days(14) });
|
|
104
|
+
const runs = queue("TurnRuns", {
|
|
105
|
+
visibilityTimeout: Duration.seconds(1800),
|
|
106
|
+
retentionPeriod: Duration.days(4),
|
|
107
|
+
deadLetterQueue: { queue: runDlq, maxReceiveCount: 5 },
|
|
108
|
+
});
|
|
109
|
+
const probes = queue("TurnProbes", {
|
|
110
|
+
visibilityTimeout: Duration.minutes(2),
|
|
111
|
+
retentionPeriod: Duration.days(4),
|
|
112
|
+
deadLetterQueue: { queue: probeDlq, maxReceiveCount: 5 },
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
const gateway = iam.Role.fromRoleArn(this, "PapyrusChatGatewayRole", gatewayRoleArn.valueAsString, { mutable: true });
|
|
116
|
+
gateway.addToPrincipalPolicy(new iam.PolicyStatement({
|
|
117
|
+
actions: ["dynamodb:GetItem", "dynamodb:PutItem", "dynamodb:UpdateItem", "dynamodb:TransactWriteItems", "dynamodb:Query", "dynamodb:BatchGetItem"],
|
|
118
|
+
resources: [messaging.tableArn],
|
|
119
|
+
}));
|
|
120
|
+
gateway.addToPrincipalPolicy(new iam.PolicyStatement({
|
|
121
|
+
actions: ["dynamodb:GetItem", "dynamodb:Query", "dynamodb:BatchGetItem"],
|
|
122
|
+
resources: [conversations.tableArn, `${conversations.tableArn}/index/*`],
|
|
123
|
+
}));
|
|
124
|
+
gateway.addToPrincipalPolicy(new iam.PolicyStatement({ actions: ["s3:GetObject"], resources: [objects.arnForObjects("conversations/*")] }));
|
|
125
|
+
gateway.addToPrincipalPolicy(new iam.PolicyStatement({ actions: ["sqs:SendMessage"], resources: [runs.queueArn, probes.queueArn] }));
|
|
126
|
+
// Appended last, as in the activated stack's policy (the transaction-check correction was added to it afterwards).
|
|
127
|
+
gateway.addToPrincipalPolicy(new iam.PolicyStatement({ actions: ["dynamodb:ConditionCheckItem"], resources: [messaging.tableArn] }));
|
|
128
|
+
|
|
129
|
+
const hostedEnvironment = {
|
|
130
|
+
PAPYRUS_CHAT_MESSAGING_TABLE: messaging.tableName,
|
|
131
|
+
PAPYRUS_CHAT_CONVERSATIONS_TABLE: conversations.tableName,
|
|
132
|
+
PAPYRUS_CHAT_SESSION_BUCKET: objects.bucketName,
|
|
133
|
+
PAPYRUS_CHAT_TURN_RUNS_QUEUE_URL: runs.queueUrl,
|
|
134
|
+
PAPYRUS_CHAT_TURN_PROBES_QUEUE_URL: probes.queueUrl,
|
|
135
|
+
PAPYRUS_CHAT_PRIVATE_BOT_ID: privateBotId.valueAsString,
|
|
136
|
+
PAPYRUS_CHAT_PRIVATE_BOT_NAME: privateBotName.valueAsString,
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
const runLogGroup = new logs.LogGroup(this, "RunWorkerLogs", {
|
|
140
|
+
logGroupName: `${logGroupPrefix}-run-worker`,
|
|
141
|
+
retention: logs.RetentionDays.ONE_MONTH,
|
|
142
|
+
removalPolicy: RemovalPolicy.RETAIN,
|
|
143
|
+
});
|
|
144
|
+
const probeLogGroup = new logs.LogGroup(this, "ProbeWorkerLogs", {
|
|
145
|
+
logGroupName: `${logGroupPrefix}-probe-worker`,
|
|
146
|
+
retention: logs.RetentionDays.ONE_MONTH,
|
|
147
|
+
removalPolicy: RemovalPolicy.RETAIN,
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
const runWorker = new iam.Role(this, "RunWorkerRole", {
|
|
151
|
+
assumedBy: new iam.ServicePrincipal("lambda.amazonaws.com"),
|
|
152
|
+
description: "Private Papyrus computerless chat run worker.",
|
|
153
|
+
});
|
|
154
|
+
runWorker.addToPolicy(new iam.PolicyStatement({
|
|
155
|
+
actions: ["dynamodb:GetItem", "dynamodb:PutItem", "dynamodb:UpdateItem", "dynamodb:DeleteItem", "dynamodb:Query", "dynamodb:BatchGetItem", "dynamodb:TransactWriteItems"],
|
|
156
|
+
resources: [messaging.tableArn, conversations.tableArn, `${conversations.tableArn}/index/*`],
|
|
157
|
+
}));
|
|
158
|
+
runWorker.addToPolicy(new iam.PolicyStatement({
|
|
159
|
+
actions: ["ssm:GetParameter"],
|
|
160
|
+
// Pseudo parameters, not the literal account: the template stays environment-agnostic like the activated stack.
|
|
161
|
+
resources: [Arn.format({ partition: Aws.PARTITION, region: Aws.REGION, account: Aws.ACCOUNT_ID, service: "ssm", resource: "parameter", resourceName: modelKeyParameter.slice(1), arnFormat: ArnFormat.SLASH_RESOURCE_NAME })],
|
|
162
|
+
}));
|
|
163
|
+
runWorker.addToPolicy(new iam.PolicyStatement({ actions: ["logs:CreateLogStream", "logs:PutLogEvents"], resources: [`${runLogGroup.logGroupArn}:*`] }));
|
|
164
|
+
runWorker.addToPolicy(new iam.PolicyStatement({
|
|
165
|
+
actions: ["s3:GetObject", "s3:PutObject", "s3:DeleteObject"],
|
|
166
|
+
resources: [objects.arnForObjects("conversations/*")],
|
|
167
|
+
}));
|
|
168
|
+
runWorker.addToPolicy(new iam.PolicyStatement({ actions: ["sqs:ChangeMessageVisibility", "sqs:SendMessage"], resources: [runs.queueArn, probes.queueArn] }));
|
|
169
|
+
|
|
170
|
+
const probeWorker = new iam.Role(this, "ProbeWorkerRole", {
|
|
171
|
+
assumedBy: new iam.ServicePrincipal("lambda.amazonaws.com"),
|
|
172
|
+
description: "Private Papyrus chat deadline/recovery worker.",
|
|
173
|
+
});
|
|
174
|
+
probeWorker.addToPolicy(new iam.PolicyStatement({
|
|
175
|
+
actions: ["dynamodb:GetItem", "dynamodb:PutItem", "dynamodb:UpdateItem", "dynamodb:DeleteItem", "dynamodb:Query", "dynamodb:TransactWriteItems", "dynamodb:BatchGetItem"],
|
|
176
|
+
resources: [messaging.tableArn],
|
|
177
|
+
}));
|
|
178
|
+
probeWorker.addToPolicy(new iam.PolicyStatement({
|
|
179
|
+
actions: ["dynamodb:GetItem", "dynamodb:Query", "dynamodb:BatchGetItem"],
|
|
180
|
+
resources: [conversations.tableArn, `${conversations.tableArn}/index/*`],
|
|
181
|
+
}));
|
|
182
|
+
probeWorker.addToPolicy(new iam.PolicyStatement({ actions: ["s3:GetObject"], resources: [objects.arnForObjects("conversations/*")] }));
|
|
183
|
+
probeWorker.addToPolicy(new iam.PolicyStatement({ actions: ["sqs:SendMessage"], resources: [runs.queueArn, probes.queueArn] }));
|
|
184
|
+
probeWorker.addToPolicy(new iam.PolicyStatement({ actions: ["logs:CreateLogStream", "logs:PutLogEvents"], resources: [`${probeLogGroup.logGroupArn}:*`] }));
|
|
185
|
+
|
|
186
|
+
let code: lambda.Code;
|
|
187
|
+
if (chat.workerCodeS3Key) {
|
|
188
|
+
// Already uploaded and verified bytes: the template names the object, nothing is bundled or uploaded.
|
|
189
|
+
const assets = s3.Bucket.fromBucketName(this, "WorkerCodeAssets", Fn.sub("cdk-hnb659fds-assets-${AWS::AccountId}-${AWS::Region}"));
|
|
190
|
+
code = lambda.Code.fromBucket(assets, chat.workerCodeS3Key);
|
|
191
|
+
Annotations.of(assets).acknowledgeWarning("@aws-cdk/aws-lambda:codeFromBucketObjectVersionNotSpecified", "the key is a content hash; it is never overwritten");
|
|
192
|
+
} else {
|
|
193
|
+
if (!lambdaAssetDirectory) throw new Error(`site ${config.siteId}: bundle the chat workers first (scripts/bundle-chat-lambdas.mjs) and pass -c chatLambdas=<dir>, or pin chat.workerCodeS3Key`);
|
|
194
|
+
code = lambda.Code.fromAsset(lambdaAssetDirectory);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const runWorkerFunction = new lambda.Function(this, "RunWorker", {
|
|
198
|
+
runtime: lambda.Runtime.NODEJS_22_X,
|
|
199
|
+
handler: "run-worker.handler",
|
|
200
|
+
code,
|
|
201
|
+
role: runWorker,
|
|
202
|
+
memorySize: 1024,
|
|
203
|
+
timeout: Duration.seconds(900),
|
|
204
|
+
logGroup: runLogGroup,
|
|
205
|
+
environment: { ...hostedEnvironment, OPENAI_API_KEY_PARAMETER: modelKeyParameter },
|
|
206
|
+
description: "Runs Papyrus private chat turns with the computerless Chatticus execution profile.",
|
|
207
|
+
});
|
|
208
|
+
const probeWorkerFunction = new lambda.Function(this, "ProbeWorker", {
|
|
209
|
+
runtime: lambda.Runtime.NODEJS_22_X,
|
|
210
|
+
handler: "probe-worker.handler",
|
|
211
|
+
code,
|
|
212
|
+
role: probeWorker,
|
|
213
|
+
memorySize: 512,
|
|
214
|
+
timeout: Duration.seconds(60),
|
|
215
|
+
logGroup: probeLogGroup,
|
|
216
|
+
environment: hostedEnvironment,
|
|
217
|
+
description: "Recovers and finalizes Papyrus private chat turns.",
|
|
218
|
+
});
|
|
219
|
+
// The model key parameter exists for every configured site, so the workers run from the first deploy.
|
|
220
|
+
runWorkerFunction.addEventSource(new eventSources.SqsEventSource(runs, { enabled: true, batchSize: 1 }));
|
|
221
|
+
probeWorkerFunction.addEventSource(new eventSources.SqsEventSource(probes, { enabled: true, batchSize: 1 }));
|
|
222
|
+
// Last, as in the activated stack (the storage-owner fence was added after the event source grants).
|
|
223
|
+
runWorker.addToPolicy(new iam.PolicyStatement({ actions: ["dynamodb:ConditionCheckItem"], resources: [conversations.tableArn] }));
|
|
224
|
+
|
|
225
|
+
const exported = (id: string, value: string, exportKey?: Parameters<typeof chatExportName>[1]) =>
|
|
226
|
+
new CfnOutput(this, id, { value, ...(exportKey ? { exportName: chatExportName(stackName, exportKey) } : {}) });
|
|
227
|
+
exported("MessagingTableName", messaging.tableName, "MessagingTableName");
|
|
228
|
+
exported("ConversationIndexTableName", conversations.tableName, "ConversationIndexTableName");
|
|
229
|
+
exported("SessionBucketName", objects.bucketName, "SessionBucketName");
|
|
230
|
+
exported("TurnRunsQueueUrl", runs.queueUrl, "TurnRunsQueueUrl");
|
|
231
|
+
exported("TurnProbesQueueUrl", probes.queueUrl, "TurnProbesQueueUrl");
|
|
232
|
+
exported("FeatureDefault", "enabled per site by the chat block of its site.json (PAPYRUS_PRIVATE_CHAT_ENABLED on the CMS production branch)");
|
|
233
|
+
exported("SyntheticAdapterTestOnly", "false");
|
|
234
|
+
exported("GatewayMessagingTableEnvironmentName", "PAPYRUS_CHAT_MESSAGING_TABLE");
|
|
235
|
+
exported("GatewayMessagingTableName", messaging.tableName);
|
|
236
|
+
exported("GatewayConversationTableEnvironmentName", "PAPYRUS_CHAT_CONVERSATIONS_TABLE");
|
|
237
|
+
exported("GatewayConversationTableName", conversations.tableName);
|
|
238
|
+
exported("GatewaySessionBucketEnvironmentName", "PAPYRUS_CHAT_SESSION_BUCKET");
|
|
239
|
+
exported("GatewaySessionBucketName", objects.bucketName);
|
|
240
|
+
exported("GatewayRunQueueEnvironmentName", "PAPYRUS_CHAT_TURN_RUNS_QUEUE_URL");
|
|
241
|
+
exported("GatewayProbeQueueEnvironmentName", "PAPYRUS_CHAT_TURN_PROBES_QUEUE_URL");
|
|
242
|
+
exported("GatewayPrivateBotIdEnvironmentName", "PAPYRUS_CHAT_PRIVATE_BOT_ID");
|
|
243
|
+
exported("PrivateAssistantBotIdValue", privateBotId.valueAsString);
|
|
244
|
+
exported("GatewayPrivateBotNameEnvironmentName", "PAPYRUS_CHAT_PRIVATE_BOT_NAME");
|
|
245
|
+
exported("PrivateAssistantBotNameValue", privateBotName.valueAsString);
|
|
246
|
+
exported("WorkerModelKeyParameterName", modelKeyParameter);
|
|
247
|
+
exported("RunWorkerFunctionName", runWorkerFunction.functionName);
|
|
248
|
+
exported("ProbeWorkerFunctionName", probeWorkerFunction.functionName);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
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.js";
|
|
7
|
+
import { PapyrusChatStack } from "./chat-stack.js";
|
|
8
|
+
import { parseSiteConfig, resolveChatLogGroupPrefix, resolveChatStackName, resolveStackName } from "./site-config.js";
|
|
9
|
+
// Executes infra/amplify-app-shell/features/per-site-private-chat.feature (run through scripts/test-infra-chat.ts).
|
|
10
|
+
const examples = path.resolve(__dirname, "../examples");
|
|
11
|
+
const example = (name, change = () => { }) => {
|
|
12
|
+
const config = JSON.parse(fs.readFileSync(path.join(examples, `${name}.site.json`), "utf8"));
|
|
13
|
+
change(config);
|
|
14
|
+
return config;
|
|
15
|
+
};
|
|
16
|
+
const siteNames = fs.readdirSync(examples).filter((name) => name.endsWith(".site.json")).map((name) => name.replace(/\.site\.json$/, ""));
|
|
17
|
+
const lambdaDirectory = fs.mkdtempSync(path.join(os.tmpdir(), "papyrus-chat-test-"));
|
|
18
|
+
for (const file of ["run-worker.js", "probe-worker.js"])
|
|
19
|
+
fs.writeFileSync(path.join(lambdaDirectory, file), "exports.handler = async () => ({});\n");
|
|
20
|
+
const resourcesOf = (template, type) => Object.values(template.Resources).filter((resource) => resource.Type === type);
|
|
21
|
+
function shellTemplate(raw) {
|
|
22
|
+
const config = parseSiteConfig(raw);
|
|
23
|
+
const stack = new AmplifyAppShellStack(new App(), "Shell", config, { env: { account: "712236451410", region: "us-east-1" }, stackName: resolveStackName(config) });
|
|
24
|
+
return JSON.parse(JSON.stringify(App.of(stack).synth().getStackByName(stack.stackName).template));
|
|
25
|
+
}
|
|
26
|
+
function chatTemplate(raw) {
|
|
27
|
+
const config = parseSiteConfig(raw);
|
|
28
|
+
const app = new App();
|
|
29
|
+
const stack = new PapyrusChatStack(app, { config, env: { account: "712236451410", region: "us-east-1" }, lambdaAssetDirectory: lambdaDirectory });
|
|
30
|
+
return JSON.parse(JSON.stringify(app.synth().getStackByName(stack.stackName).template));
|
|
31
|
+
}
|
|
32
|
+
const branchEnvironment = (template, branchName) => {
|
|
33
|
+
const branch = resourcesOf(template, "AWS::Amplify::Branch").find((resource) => resource.Properties.BranchName === branchName);
|
|
34
|
+
return Object.fromEntries(branch.Properties.EnvironmentVariables.map((variable) => [variable.Name, variable.Value]));
|
|
35
|
+
};
|
|
36
|
+
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"];
|
|
37
|
+
// Scenario: a site with chat enabled gets the chat environment and a data stack
|
|
38
|
+
{
|
|
39
|
+
const raw = example("anth-us");
|
|
40
|
+
assert.equal(raw.chat.modelKeyParameter, "/papyrus/shipped/openai-api-key");
|
|
41
|
+
const config = parseSiteConfig(raw);
|
|
42
|
+
assert.equal(resolveChatStackName(config), "papyrus-chat-anth-us");
|
|
43
|
+
const production = branchEnvironment(shellTemplate(raw), "main");
|
|
44
|
+
assert.equal(production.PAPYRUS_PRIVATE_CHAT_ENABLED, "true");
|
|
45
|
+
assert.deepEqual(production.PAPYRUS_CHAT_MESSAGING_TABLE, { "Fn::ImportValue": "papyrus-chat-anth-us:MessagingTableName" });
|
|
46
|
+
assert.deepEqual(production.PAPYRUS_CHAT_SESSION_BUCKET, { "Fn::ImportValue": "papyrus-chat-anth-us:SessionBucketName" });
|
|
47
|
+
assert.deepEqual(production.PAPYRUS_CHAT_TURN_RUNS_QUEUE_URL, { "Fn::ImportValue": "papyrus-chat-anth-us:TurnRunsQueueUrl" });
|
|
48
|
+
assert.equal(production.PAPYRUS_CHAT_PRIVATE_BOT_ID, "papyrus-private-assistant");
|
|
49
|
+
const chat = chatTemplate(raw);
|
|
50
|
+
assert.equal(resourcesOf(chat, "AWS::DynamoDB::Table").length, 2);
|
|
51
|
+
const [bucket] = resourcesOf(chat, "AWS::S3::Bucket");
|
|
52
|
+
assert.equal(bucket.Properties.VersioningConfiguration.Status, "Enabled");
|
|
53
|
+
assert.equal(bucket.Properties.PublicAccessBlockConfiguration.BlockPublicPolicy, true);
|
|
54
|
+
assert.equal(resourcesOf(chat, "AWS::SQS::Queue").length, 4);
|
|
55
|
+
assert.equal(resourcesOf(chat, "AWS::Lambda::Function").length, 2);
|
|
56
|
+
const mappings = resourcesOf(chat, "AWS::Lambda::EventSourceMapping");
|
|
57
|
+
assert.equal(mappings.length, 2);
|
|
58
|
+
assert.ok(mappings.every((mapping) => mapping.Properties.Enabled === true));
|
|
59
|
+
for (const key of ["MessagingTableName", "ConversationIndexTableName", "SessionBucketName", "TurnRunsQueueUrl", "TurnProbesQueueUrl"]) {
|
|
60
|
+
assert.equal(chat.Outputs[key].Export.Name, `papyrus-chat-anth-us:${key}`);
|
|
61
|
+
}
|
|
62
|
+
assert.deepEqual(Object.keys(chat.Parameters).filter((name) => name !== "BootstrapVersion").sort(), ["GatewayRoleArn", "PrivateAssistantBotId", "PrivateAssistantBotName"]);
|
|
63
|
+
// Only the run worker may read the model key parameter; the gateway policy never names it.
|
|
64
|
+
const parameterGrants = resourcesOf(chat, "AWS::IAM::Policy").filter((policy) => JSON.stringify(policy.Properties.PolicyDocument).includes("openai-api-key"));
|
|
65
|
+
assert.equal(parameterGrants.length, 1);
|
|
66
|
+
assert.ok(parameterGrants[0].Properties.Roles.some((role) => JSON.stringify(role).includes("RunWorkerRole")));
|
|
67
|
+
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");
|
|
68
|
+
const gateway = resourcesOf(chat, "AWS::IAM::Policy").find((policy) => policy.Properties.Roles.some((role) => JSON.stringify(role).includes("GatewayRoleArn")));
|
|
69
|
+
assert.ok(gateway, "the gateway policy attaches to the role named by GatewayRoleArn");
|
|
70
|
+
const gatewayActions = gateway.Properties.PolicyDocument.Statement.flatMap((statement) => [statement.Action].flat());
|
|
71
|
+
assert.ok(!gatewayActions.some((action) => action.startsWith("ssm:") || action.endsWith("*")), "the gateway has no SSM access and no wildcard action");
|
|
72
|
+
}
|
|
73
|
+
// Scenario: the staging branch never gets private chat
|
|
74
|
+
{
|
|
75
|
+
const staging = branchEnvironment(shellTemplate(example("p-apyr-us")), "staging");
|
|
76
|
+
assert.equal(staging.PAPYRUS_PRIVATE_CHAT_ENABLED, "false");
|
|
77
|
+
assert.deepEqual(CHAT_NAMES.filter((name) => name in staging), []);
|
|
78
|
+
}
|
|
79
|
+
// Scenario: a site without chat gets neither
|
|
80
|
+
{
|
|
81
|
+
const withoutBlock = example("anth-us", (config) => delete config.chat);
|
|
82
|
+
const disabled = example("anth-us", (config) => (config.chat.enabled = false));
|
|
83
|
+
for (const raw of [withoutBlock, disabled]) {
|
|
84
|
+
const production = branchEnvironment(shellTemplate(raw), "main");
|
|
85
|
+
assert.equal(production.PAPYRUS_PRIVATE_CHAT_ENABLED, "false");
|
|
86
|
+
assert.deepEqual(CHAT_NAMES.filter((name) => name in production), []);
|
|
87
|
+
assert.equal(resolveChatStackName(parseSiteConfig(raw)), undefined);
|
|
88
|
+
assert.throws(() => chatTemplate(raw), /does not enable chat/);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
// Scenario: the chat block cannot be contradicted by hand-set variables
|
|
92
|
+
assert.throws(() => parseSiteConfig(example("anth-us", (config) => (config.cms.environment.PAPYRUS_PRIVATE_CHAT_ENABLED = "false"))), /cms\.environment\.PAPYRUS_PRIVATE_CHAT_ENABLED/);
|
|
93
|
+
assert.throws(() => parseSiteConfig(example("anth-us", (config) => (config.cms.environment.PAPYRUS_CHAT_SESSION_BUCKET = "x"))), /cms\.environment\.PAPYRUS_CHAT_SESSION_BUCKET/);
|
|
94
|
+
// Without chat, the existing hand-managed flag keeps working.
|
|
95
|
+
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");
|
|
96
|
+
// Scenario: chat needs a CMS and a model key parameter
|
|
97
|
+
assert.throws(() => parseSiteConfig(example("anth-us", (config) => delete config.chat.modelKeyParameter)), /chat\.modelKeyParameter/);
|
|
98
|
+
assert.throws(() => parseSiteConfig(example("anth-us", (config) => (config.chat.modelKeyParameter = "not-a-path"))), /chat\.modelKeyParameter/);
|
|
99
|
+
assert.throws(() => parseSiteConfig(example("anth-us", (config) => (config.chat.enabled = "yes"))), /chat\.enabled/);
|
|
100
|
+
assert.throws(() => parseSiteConfig(example("anth-us", (config) => (config.chat.extra = 1))), /chat\.extra/);
|
|
101
|
+
assert.throws(() => parseSiteConfig(example("anth-us", (config) => (config.chat.workerCodeS3Key = "abc"))), /chat\.workerCodeS3Key/);
|
|
102
|
+
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/);
|
|
103
|
+
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/);
|
|
104
|
+
// Scenario: two sites in one account do not share chat storage or log groups
|
|
105
|
+
{
|
|
106
|
+
const enabled = siteNames.map((name) => parseSiteConfig(example(name))).filter((config) => config.chat?.enabled);
|
|
107
|
+
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");
|
|
108
|
+
const names = enabled.map((config) => resolveChatStackName(config));
|
|
109
|
+
const prefixes = enabled.map((config) => resolveChatLogGroupPrefix(config));
|
|
110
|
+
assert.equal(new Set(names).size, names.length, "stack names are unique");
|
|
111
|
+
assert.equal(new Set(prefixes).size, prefixes.length, "worker log group prefixes are unique");
|
|
112
|
+
for (const config of enabled) {
|
|
113
|
+
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`);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
// Scenario: the activated p.apyr.us stack is adopted without replacing anything
|
|
117
|
+
{
|
|
118
|
+
const activated = JSON.parse(fs.readFileSync(path.resolve(__dirname, "../../shared-chat-runtime/cdk/PapyrusChatDataCandidate.template.json"), "utf8"));
|
|
119
|
+
const generated = chatTemplate(example("p-apyr-us"));
|
|
120
|
+
assert.equal(resolveChatStackName(parseSiteConfig(example("p-apyr-us"))), "PapyrusChatDataCandidate");
|
|
121
|
+
const normalize = (resource) => {
|
|
122
|
+
const copy = JSON.parse(JSON.stringify(resource));
|
|
123
|
+
delete copy.Metadata;
|
|
124
|
+
const statements = copy.Properties?.PolicyDocument?.Statement;
|
|
125
|
+
if (statements)
|
|
126
|
+
copy.Properties.PolicyDocument.Statement = [...statements].sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b)));
|
|
127
|
+
return copy;
|
|
128
|
+
};
|
|
129
|
+
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)");
|
|
130
|
+
for (const [logicalId, resource] of Object.entries(activated.Resources)) {
|
|
131
|
+
const expected = normalize(resource);
|
|
132
|
+
// The activated template is the reviewed candidate; the queue mappings were enabled live afterwards.
|
|
133
|
+
if (resource.Type === "AWS::Lambda::EventSourceMapping")
|
|
134
|
+
expected.Properties.Enabled = true;
|
|
135
|
+
assert.deepEqual(normalize(generated.Resources[logicalId]), expected, `${logicalId} is unchanged`);
|
|
136
|
+
}
|
|
137
|
+
for (const key of Object.keys(activated.Outputs)) {
|
|
138
|
+
assert.deepEqual({ ...generated.Outputs[key], Export: undefined }, { ...activated.Outputs[key], Export: undefined, ...(key === "FeatureDefault" ? { Value: generated.Outputs[key].Value } : {}) }, `output ${key} keeps its value`);
|
|
139
|
+
}
|
|
140
|
+
assert.deepEqual(Object.keys(generated.Outputs).sort(), Object.keys(activated.Outputs).sort());
|
|
141
|
+
}
|
|
142
|
+
console.log("per-site private chat infra tests passed");
|