@deployfoundation/foundation-deploy 0.2.19 → 0.2.21
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/agent-image/security-overlay.yml +4 -0
- package/dist/bin/app.js +2 -2
- package/dist/bin/foundation-deploy.js +127 -45
- package/dist/bin/release-account.js +2 -2
- package/dist/{chunk-rhg6yfht.js → chunk-r6djsvkj.js} +52 -20
- package/dist/{chunk-k2rxb77g.js → chunk-xb5k71tp.js} +31 -2
- package/dist/{chunk-fjbc0ydk.js → chunk-zpjm151m.js} +1 -1
- package/dist/src/index.js +2 -2
- package/package.json +1 -1
- package/src/stacks/agent-stack.ts +37 -14
- package/src/stacks/api-stack.ts +66 -29
|
@@ -7,6 +7,10 @@
|
|
|
7
7
|
# Arrays in omp settings REPLACE — ship complete lists only here or in the
|
|
8
8
|
# per-session project config, never split across layers.
|
|
9
9
|
tools:
|
|
10
|
+
# Foundation intentionally defers a small set of authorized admin read tools.
|
|
11
|
+
# Fail hard if OMP cannot mount them; never expose their schemas top-level.
|
|
12
|
+
xdev: true
|
|
13
|
+
xdevDocs: catalog
|
|
10
14
|
approvalMode: yolo
|
|
11
15
|
# Global timeout ceiling in seconds — caps every resolved bash/eval timeout
|
|
12
16
|
# (defaults included). NOTE: omp treats `timeout: 0` ("no deadline") as
|
package/dist/bin/app.js
CHANGED
|
@@ -8,13 +8,13 @@ import {
|
|
|
8
8
|
FoundationPages,
|
|
9
9
|
FoundationPipeline,
|
|
10
10
|
NewsletterStack
|
|
11
|
-
} from "../chunk-
|
|
11
|
+
} from "../chunk-r6djsvkj.js";
|
|
12
12
|
import {
|
|
13
13
|
adminsFor,
|
|
14
14
|
instanceNames,
|
|
15
15
|
loadInstanceFile,
|
|
16
16
|
provisionsIntegration
|
|
17
|
-
} from "../chunk-
|
|
17
|
+
} from "../chunk-xb5k71tp.js";
|
|
18
18
|
|
|
19
19
|
// bin/app.ts
|
|
20
20
|
import { execFileSync } from "node:child_process";
|
|
@@ -29,7 +29,7 @@ import {
|
|
|
29
29
|
stackExists,
|
|
30
30
|
stackOutput,
|
|
31
31
|
toolVersion
|
|
32
|
-
} from "../chunk-
|
|
32
|
+
} from "../chunk-zpjm151m.js";
|
|
33
33
|
import {
|
|
34
34
|
BASE_SLACK_EVENTS,
|
|
35
35
|
CAPABILITY_IDS,
|
|
@@ -45,7 +45,7 @@ import {
|
|
|
45
45
|
requiredSlackScopes,
|
|
46
46
|
skillsKey,
|
|
47
47
|
slackCommandPrefix
|
|
48
|
-
} from "../chunk-
|
|
48
|
+
} from "../chunk-xb5k71tp.js";
|
|
49
49
|
|
|
50
50
|
// src/deploy/config-sync.ts
|
|
51
51
|
import { existsSync, mkdirSync, mkdtempSync, readdirSync } from "node:fs";
|
|
@@ -102,6 +102,8 @@ var MAX_GOOGLE_TOKEN_RESPONSE_BYTES = 128 * 1024;
|
|
|
102
102
|
|
|
103
103
|
// ../connectors/src/gmail.ts
|
|
104
104
|
var GMAIL_LIST_SUMMARY_MAX_BYTES = 1024 * 1024;
|
|
105
|
+
var MAX_GMAIL_ATTACHMENT_BYTES = 3.5 * 1024 * 1024;
|
|
106
|
+
var MAX_GMAIL_ATTACHMENT_RESPONSE_BYTES = 5 * 1024 * 1024;
|
|
105
107
|
var GMAIL_SCOPES = [
|
|
106
108
|
"https://www.googleapis.com/auth/gmail.compose",
|
|
107
109
|
"https://www.googleapis.com/auth/gmail.modify"
|
|
@@ -119,18 +121,132 @@ var GOOGLE_CALENDAR_EVENTS_SCOPE = [
|
|
|
119
121
|
var GOOGLE_CALENDAR_JSON_MAX_BYTES = 2 * 1024 * 1024;
|
|
120
122
|
// ../connectors/src/otter.ts
|
|
121
123
|
var OTTER_MAX_RESPONSE_BYTES = 2 * 1024 * 1024;
|
|
124
|
+
// ../connectors/src/remote-mcp.ts
|
|
125
|
+
var REMOTE_MCP_PROTOCOL_VERSIONS = ["2025-11-25", "2025-06-18", "2025-03-26"];
|
|
126
|
+
var REMOTE_MCP_MAX_RESPONSE_BYTES = 1024 * 1024;
|
|
127
|
+
var REMOTE_MCP_MAX_REQUEST_BYTES = 256 * 1024;
|
|
128
|
+
var MAX_URL_CHARS = 2048;
|
|
129
|
+
var MAX_TOKEN_TTL_SECONDS = 31 * 24 * 60 * 60;
|
|
130
|
+
class RemoteMcpResponseTooLargeError extends Error {
|
|
131
|
+
constructor() {
|
|
132
|
+
super(`remote MCP response exceeded ${REMOTE_MCP_MAX_RESPONSE_BYTES} bytes`);
|
|
133
|
+
this.name = "RemoteMcpResponseTooLargeError";
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
class RemoteMcpTimeoutError extends Error {
|
|
137
|
+
constructor() {
|
|
138
|
+
super("remote MCP did not answer in time");
|
|
139
|
+
this.name = "RemoteMcpTimeoutError";
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
function defineRemoteMcpProvider(input) {
|
|
143
|
+
const protocolVersions = [...input.protocolVersions ?? REMOTE_MCP_PROTOCOL_VERSIONS];
|
|
144
|
+
if (protocolVersions.length === 0 || new Set(protocolVersions).size !== protocolVersions.length || !protocolVersions.every((version) => /^\d{4}-\d{2}-\d{2}$/.test(version)))
|
|
145
|
+
throw new Error("remote MCP provider: invalid protocolVersions");
|
|
146
|
+
if (!/^[A-Za-z0-9_.-]{1,128}$/.test(input.clientName))
|
|
147
|
+
throw new Error("remote MCP provider: invalid clientName");
|
|
148
|
+
return Object.freeze({
|
|
149
|
+
endpoint: httpsUrl(input.endpoint, "remote MCP provider: endpoint"),
|
|
150
|
+
issuer: httpsUrl(input.issuer, "remote MCP provider: issuer"),
|
|
151
|
+
tokenUrl: httpsUrl(input.tokenUrl, "remote MCP provider: tokenUrl"),
|
|
152
|
+
clientName: input.clientName,
|
|
153
|
+
protocolVersions: Object.freeze(protocolVersions)
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
async function readBoundedRemoteMcpResponse(response, maxBytes = REMOTE_MCP_MAX_RESPONSE_BYTES, signal) {
|
|
157
|
+
const chunks = [];
|
|
158
|
+
let total = 0;
|
|
159
|
+
await consumeBody(response, maxBytes, signal, (chunk) => {
|
|
160
|
+
chunks.push(chunk);
|
|
161
|
+
total += chunk.byteLength;
|
|
162
|
+
return false;
|
|
163
|
+
});
|
|
164
|
+
const bytes = new Uint8Array(total);
|
|
165
|
+
let offset = 0;
|
|
166
|
+
for (const chunk of chunks) {
|
|
167
|
+
bytes.set(chunk, offset);
|
|
168
|
+
offset += chunk.byteLength;
|
|
169
|
+
}
|
|
170
|
+
return new TextDecoder().decode(bytes);
|
|
171
|
+
}
|
|
172
|
+
async function consumeBody(response, maxBytes, signal, onChunk) {
|
|
173
|
+
const declaredBytes = Number(response.headers.get("content-length") ?? "");
|
|
174
|
+
if (Number.isFinite(declaredBytes) && declaredBytes > maxBytes) {
|
|
175
|
+
await response.body?.cancel().catch(() => {
|
|
176
|
+
return;
|
|
177
|
+
});
|
|
178
|
+
throw new RemoteMcpResponseTooLargeError;
|
|
179
|
+
}
|
|
180
|
+
const reader = response.body?.getReader();
|
|
181
|
+
if (reader === undefined)
|
|
182
|
+
return 0;
|
|
183
|
+
const cancel = () => {
|
|
184
|
+
reader.cancel().catch(() => {
|
|
185
|
+
return;
|
|
186
|
+
});
|
|
187
|
+
};
|
|
188
|
+
const aborted = () => signal?.aborted === true;
|
|
189
|
+
if (aborted()) {
|
|
190
|
+
cancel();
|
|
191
|
+
throw new RemoteMcpTimeoutError;
|
|
192
|
+
}
|
|
193
|
+
signal?.addEventListener("abort", cancel, { once: true });
|
|
194
|
+
let total = 0;
|
|
195
|
+
try {
|
|
196
|
+
while (true) {
|
|
197
|
+
let next;
|
|
198
|
+
try {
|
|
199
|
+
next = await reader.read();
|
|
200
|
+
} catch {
|
|
201
|
+
if (aborted())
|
|
202
|
+
throw new RemoteMcpTimeoutError;
|
|
203
|
+
throw new Error("remote MCP response could not be read");
|
|
204
|
+
}
|
|
205
|
+
if (aborted())
|
|
206
|
+
throw new RemoteMcpTimeoutError;
|
|
207
|
+
if (next.done)
|
|
208
|
+
return total;
|
|
209
|
+
total += next.value.byteLength;
|
|
210
|
+
if (total > maxBytes) {
|
|
211
|
+
cancel();
|
|
212
|
+
throw new RemoteMcpResponseTooLargeError;
|
|
213
|
+
}
|
|
214
|
+
if (onChunk(next.value)) {
|
|
215
|
+
cancel();
|
|
216
|
+
return total;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
} finally {
|
|
220
|
+
signal?.removeEventListener("abort", cancel);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
function httpsUrl(value, label) {
|
|
224
|
+
let url;
|
|
225
|
+
try {
|
|
226
|
+
url = new URL(value);
|
|
227
|
+
} catch {
|
|
228
|
+
throw new Error(`${label} must be an HTTPS URL`);
|
|
229
|
+
}
|
|
230
|
+
if (value.length > MAX_URL_CHARS || url.protocol !== "https:" || url.username !== "" || url.password !== "" || url.hash !== "" || url.search !== "")
|
|
231
|
+
throw new Error(`${label} must be an HTTPS URL`);
|
|
232
|
+
return value;
|
|
233
|
+
}
|
|
234
|
+
|
|
122
235
|
// ../connectors/src/knock.ts
|
|
236
|
+
var KNOCK_MCP_ENDPOINT = "https://mcp.knock.app/mcp";
|
|
237
|
+
var KNOCK_OAUTH_ISSUER = "https://mcp.knock.app";
|
|
238
|
+
var KNOCK_TOKEN_URL = "https://mcp.knock.app/token";
|
|
123
239
|
var KNOCK_REGISTER_URL = "https://mcp.knock.app/register";
|
|
124
|
-
var
|
|
125
|
-
var
|
|
240
|
+
var KNOCK_MCP_PROTOCOL_VERSION = "2025-06-18";
|
|
241
|
+
var KNOCK_MCP_PROVIDER = defineRemoteMcpProvider({
|
|
242
|
+
endpoint: KNOCK_MCP_ENDPOINT,
|
|
243
|
+
issuer: KNOCK_OAUTH_ISSUER,
|
|
244
|
+
tokenUrl: KNOCK_TOKEN_URL,
|
|
245
|
+
clientName: "foundation-knock-proxy",
|
|
246
|
+
protocolVersions: [KNOCK_MCP_PROTOCOL_VERSION]
|
|
247
|
+
});
|
|
126
248
|
var MAX_CLIENT_ID_CHARS = 2048;
|
|
127
249
|
var MAX_REDIRECT_URI_CHARS = 2048;
|
|
128
|
-
class KnockResponseTooLargeError extends Error {
|
|
129
|
-
constructor() {
|
|
130
|
-
super(`Knock response exceeded ${KNOCK_MAX_RESPONSE_BYTES} bytes`);
|
|
131
|
-
this.name = "KnockResponseTooLargeError";
|
|
132
|
-
}
|
|
133
|
-
}
|
|
134
250
|
function parseKnockOAuthClient(raw) {
|
|
135
251
|
const value = parseJsonObject(raw, "Knock OAuth client");
|
|
136
252
|
const keys = Object.keys(value);
|
|
@@ -162,46 +278,12 @@ async function registerKnockPublicClient(args) {
|
|
|
162
278
|
}
|
|
163
279
|
if (!response.ok)
|
|
164
280
|
throw new Error("Knock OAuth registration was rejected");
|
|
165
|
-
const body = parseJsonObject(await
|
|
281
|
+
const body = parseJsonObject(await readBoundedRemoteMcpResponse(response), "Knock OAuth registration");
|
|
166
282
|
return {
|
|
167
283
|
client_id: requiredOpaqueString(body.client_id, "Knock OAuth registration: missing client_id", MAX_CLIENT_ID_CHARS),
|
|
168
284
|
redirect_uri: redirectUri
|
|
169
285
|
};
|
|
170
286
|
}
|
|
171
|
-
async function readBoundedKnockResponse(response, maxBytes = KNOCK_MAX_RESPONSE_BYTES) {
|
|
172
|
-
const declaredBytes = Number(response.headers.get("content-length") ?? "");
|
|
173
|
-
if (Number.isFinite(declaredBytes) && declaredBytes > maxBytes) {
|
|
174
|
-
await response.body?.cancel().catch(() => {
|
|
175
|
-
return;
|
|
176
|
-
});
|
|
177
|
-
throw new KnockResponseTooLargeError;
|
|
178
|
-
}
|
|
179
|
-
const reader = response.body?.getReader();
|
|
180
|
-
if (reader === undefined)
|
|
181
|
-
return "";
|
|
182
|
-
const chunks = [];
|
|
183
|
-
let total = 0;
|
|
184
|
-
while (true) {
|
|
185
|
-
const { done, value } = await reader.read();
|
|
186
|
-
if (done)
|
|
187
|
-
break;
|
|
188
|
-
total += value.byteLength;
|
|
189
|
-
if (total > maxBytes) {
|
|
190
|
-
await reader.cancel().catch(() => {
|
|
191
|
-
return;
|
|
192
|
-
});
|
|
193
|
-
throw new KnockResponseTooLargeError;
|
|
194
|
-
}
|
|
195
|
-
chunks.push(value);
|
|
196
|
-
}
|
|
197
|
-
const bytes = new Uint8Array(total);
|
|
198
|
-
let offset = 0;
|
|
199
|
-
for (const chunk of chunks) {
|
|
200
|
-
bytes.set(chunk, offset);
|
|
201
|
-
offset += chunk.byteLength;
|
|
202
|
-
}
|
|
203
|
-
return new TextDecoder().decode(bytes);
|
|
204
|
-
}
|
|
205
287
|
function parseJsonObject(raw, label) {
|
|
206
288
|
let value;
|
|
207
289
|
try {
|
|
@@ -19,7 +19,7 @@ import {
|
|
|
19
19
|
scheduledJobBindings,
|
|
20
20
|
slackCommandPrefixes,
|
|
21
21
|
webhookJobBindings
|
|
22
|
-
} from "./chunk-
|
|
22
|
+
} from "./chunk-xb5k71tp.js";
|
|
23
23
|
|
|
24
24
|
// src/stacks/agent-stack.ts
|
|
25
25
|
import * as cdk from "aws-cdk-lib";
|
|
@@ -240,6 +240,21 @@ class FoundationAgent extends cdk.Stack {
|
|
|
240
240
|
resources: [secretArnPattern(knockOauthClient), secretArnPattern(knockCredential)]
|
|
241
241
|
}));
|
|
242
242
|
}
|
|
243
|
+
denyDirectEmailSecretAccess(role, sharedMailboxSecret, personalSecretPrefix) {
|
|
244
|
+
const stack = cdk.Stack.of(this);
|
|
245
|
+
const personalSecrets = stack.formatArn({
|
|
246
|
+
service: "secretsmanager",
|
|
247
|
+
resource: "secret",
|
|
248
|
+
resourceName: `${personalSecretPrefix}/personal/*-??????`,
|
|
249
|
+
arnFormat: cdk.ArnFormat.COLON_RESOURCE_NAME
|
|
250
|
+
});
|
|
251
|
+
role.addToPolicy(new iam.PolicyStatement({
|
|
252
|
+
sid: "DenyEmailSecret",
|
|
253
|
+
effect: iam.Effect.DENY,
|
|
254
|
+
actions: ["secretsmanager:*"],
|
|
255
|
+
resources: [secretArnPattern(sharedMailboxSecret), personalSecrets]
|
|
256
|
+
}));
|
|
257
|
+
}
|
|
243
258
|
pagesPolicy(id, role, instance, options) {
|
|
244
259
|
if (!provisionsIntegration(instance, "pages"))
|
|
245
260
|
return;
|
|
@@ -342,6 +357,7 @@ class FoundationAgent extends cdk.Stack {
|
|
|
342
357
|
});
|
|
343
358
|
this.denyDirectCrmTableAccess(role, props.instance, props.crmTable);
|
|
344
359
|
this.denyDirectKnockSecretAccess(role, props);
|
|
360
|
+
this.denyDirectEmailSecretAccess(role, props.secrets.googleEmail, instanceNames(props.instance).secretGoogleEmail);
|
|
345
361
|
this.denyDirectUpworkApprovalAccess(role, props.upworkApprovalTable);
|
|
346
362
|
this.denyDirectEtradeAccess(role, props);
|
|
347
363
|
this.pagesPolicy("AgentReadOnlyPagesPolicy", role, props.instance, { invoke: false });
|
|
@@ -404,6 +420,7 @@ class FoundationAgent extends cdk.Stack {
|
|
|
404
420
|
this.denyDirectCrmTableAccess(role, props.instance, props.crmTable);
|
|
405
421
|
this.denyDirectKnockSecretAccess(role, props);
|
|
406
422
|
this.denyDirectUpworkApprovalAccess(role, props.upworkApprovalTable);
|
|
423
|
+
this.denyDirectEmailSecretAccess(role, props.secrets.googleEmail, instanceNames(props.instance).secretGoogleEmail);
|
|
407
424
|
this.denyDirectEtradeAccess(role, props);
|
|
408
425
|
this.pagesPolicy("AgentExecutionPagesPolicy", role, props.instance, { invoke: true });
|
|
409
426
|
const releaseRepositoryArn = releaseImageRepositoryArn(this);
|
|
@@ -472,12 +489,6 @@ class FoundationAgent extends cdk.Stack {
|
|
|
472
489
|
actions: ["secretsmanager:GetSecretValue"],
|
|
473
490
|
resources: [secretArnPattern(props.secrets.signing)]
|
|
474
491
|
}));
|
|
475
|
-
role.addToPolicy(new iam.PolicyStatement({
|
|
476
|
-
sid: "DenyEmailSecret",
|
|
477
|
-
effect: iam.Effect.DENY,
|
|
478
|
-
actions: ["secretsmanager:GetSecretValue"],
|
|
479
|
-
resources: [secretArnPattern(props.secrets.googleEmail)]
|
|
480
|
-
}));
|
|
481
492
|
role.addToPolicy(new iam.PolicyStatement({
|
|
482
493
|
sid: "EmailProxyInvoke",
|
|
483
494
|
actions: ["lambda:InvokeFunction"],
|
|
@@ -771,7 +782,6 @@ import * as scheduler from "aws-cdk-lib/aws-scheduler";
|
|
|
771
782
|
import * as secretsmanager from "aws-cdk-lib/aws-secretsmanager";
|
|
772
783
|
import * as sns from "aws-cdk-lib/aws-sns";
|
|
773
784
|
import * as sqs from "aws-cdk-lib/aws-sqs";
|
|
774
|
-
import { parse as parseYaml } from "yaml";
|
|
775
785
|
class FoundationApi extends cdk2.Stack {
|
|
776
786
|
api;
|
|
777
787
|
gatewayDlq;
|
|
@@ -788,6 +798,8 @@ class FoundationApi extends cdk2.Stack {
|
|
|
788
798
|
super(scope, id, props);
|
|
789
799
|
const names = instanceNames(props.instance);
|
|
790
800
|
const display = props.instance.displayName;
|
|
801
|
+
const emailIdentities = emailIdentitiesFor(props.configPath);
|
|
802
|
+
const personalEmailIdentities = emailIdentities.filter((identity) => identity.owner !== undefined);
|
|
791
803
|
const upwork = provisionsIntegration(props.instance, "upwork") ? upworkConfigForProvisioning(props.configPath) : undefined;
|
|
792
804
|
const upworkSecret = upwork === undefined ? undefined : props.secrets.upwork;
|
|
793
805
|
const upworkApprovalTable = upwork === undefined ? undefined : props.upworkApprovalTable;
|
|
@@ -825,6 +837,15 @@ class FoundationApi extends cdk2.Stack {
|
|
|
825
837
|
retentionPeriod: cdk2.Duration.hours(6),
|
|
826
838
|
deadLetterQueue: { queue: this.invokeDlq, maxReceiveCount: 1 }
|
|
827
839
|
});
|
|
840
|
+
const personalEmailSecrets = personalEmailIdentities.map((identity) => new secretsmanager.Secret(this, `PersonalEmailSecret${identity.owner}`, {
|
|
841
|
+
secretName: `${instanceNames(props.instance).secretGoogleEmail}/personal/${identity.owner}`,
|
|
842
|
+
description: `${display} personal Gmail credential for Slack ${identity.owner}; gateway writes and email proxy reads`,
|
|
843
|
+
removalPolicy: cdk2.RemovalPolicy.DESTROY,
|
|
844
|
+
generateSecretString: {
|
|
845
|
+
secretStringTemplate: "{}",
|
|
846
|
+
generateStringKey: "placeholder"
|
|
847
|
+
}
|
|
848
|
+
}));
|
|
828
849
|
const gatewayRole = new iam2.Role(this, "GatewayRole", {
|
|
829
850
|
assumedBy: new iam2.ServicePrincipal("lambda.amazonaws.com"),
|
|
830
851
|
description: `${display} gateway Lambda role (least privilege)`,
|
|
@@ -851,7 +872,10 @@ class FoundationApi extends cdk2.Stack {
|
|
|
851
872
|
gatewayRole.addToPolicy(new iam2.PolicyStatement({
|
|
852
873
|
sid: "EmailIdentityConnect",
|
|
853
874
|
actions: ["secretsmanager:PutSecretValue"],
|
|
854
|
-
resources: [
|
|
875
|
+
resources: [
|
|
876
|
+
secretArnPattern2(props.secrets.googleEmail),
|
|
877
|
+
...personalEmailSecrets.map(secretArnPattern2)
|
|
878
|
+
]
|
|
855
879
|
}));
|
|
856
880
|
if (props.secrets.knockCredential !== undefined) {
|
|
857
881
|
gatewayRole.addToPolicy(new iam2.PolicyStatement({
|
|
@@ -948,9 +972,16 @@ class FoundationApi extends cdk2.Stack {
|
|
|
948
972
|
FOUNDATION_DISPLAY_NAME: props.instance.displayName,
|
|
949
973
|
FOUNDATION_ITEMS_TABLE_NAME: props.itemsTable.tableName,
|
|
950
974
|
FOUNDATION_GOOGLE_CALENDAR_SECRET_ID: props.secrets.googleCalendar.secretName,
|
|
951
|
-
FOUNDATION_GOOGLE_OAUTH_SECRET_ID: props.
|
|
975
|
+
FOUNDATION_GOOGLE_OAUTH_SECRET_ID: instanceNames(props.instance).secretGoogleOauth,
|
|
952
976
|
FOUNDATION_GOOGLE_DRIVE_SECRET_ID: props.secrets.googleDrive.secretName,
|
|
953
|
-
FOUNDATION_GOOGLE_EMAIL_SECRET_ID: props.
|
|
977
|
+
FOUNDATION_GOOGLE_EMAIL_SECRET_ID: instanceNames(props.instance).secretGoogleEmail,
|
|
978
|
+
...personalEmailIdentities.length === 0 ? {} : {
|
|
979
|
+
FOUNDATION_PERSONAL_EMAIL_IDENTITIES: JSON.stringify(personalEmailIdentities.map(({ address, channel, owner }) => ({
|
|
980
|
+
address,
|
|
981
|
+
channel,
|
|
982
|
+
owner
|
|
983
|
+
})))
|
|
984
|
+
},
|
|
954
985
|
...props.secrets.knockOauthClient === undefined ? {} : { FOUNDATION_KNOCK_OAUTH_CLIENT_SECRET_ID: props.secrets.knockOauthClient.secretName },
|
|
955
986
|
...props.secrets.knockCredential === undefined ? {} : { FOUNDATION_KNOCK_CREDENTIAL_SECRET_ID: props.secrets.knockCredential.secretName },
|
|
956
987
|
...upworkSecret === undefined ? {} : { FOUNDATION_UPWORK_SECRET_ID: upworkSecret.secretName },
|
|
@@ -1095,7 +1126,9 @@ class FoundationApi extends cdk2.Stack {
|
|
|
1095
1126
|
actions: ["secretsmanager:GetSecretValue"],
|
|
1096
1127
|
resources: [
|
|
1097
1128
|
secretArnPattern2(props.secrets.googleEmail),
|
|
1098
|
-
secretArnPattern2(props.secrets.googleOauth)
|
|
1129
|
+
secretArnPattern2(props.secrets.googleOauth),
|
|
1130
|
+
secretArnPattern2(props.secrets.signing),
|
|
1131
|
+
...personalEmailSecrets.map(secretArnPattern2)
|
|
1099
1132
|
]
|
|
1100
1133
|
}));
|
|
1101
1134
|
emailProxyRole.addToPolicy(new iam2.PolicyStatement({
|
|
@@ -1112,9 +1145,11 @@ class FoundationApi extends cdk2.Stack {
|
|
|
1112
1145
|
timeout: cdk2.Duration.seconds(30),
|
|
1113
1146
|
memorySize: 256,
|
|
1114
1147
|
environment: {
|
|
1115
|
-
FOUNDATION_GOOGLE_EMAIL_SECRET_ID: props.
|
|
1116
|
-
FOUNDATION_GOOGLE_OAUTH_SECRET_ID: props.
|
|
1117
|
-
|
|
1148
|
+
FOUNDATION_GOOGLE_EMAIL_SECRET_ID: instanceNames(props.instance).secretGoogleEmail,
|
|
1149
|
+
FOUNDATION_GOOGLE_OAUTH_SECRET_ID: instanceNames(props.instance).secretGoogleOauth,
|
|
1150
|
+
FOUNDATION_SLACK_SIGNING_SECRET_ID: props.secrets.signing.secretName,
|
|
1151
|
+
FOUNDATION_SLACK_TEAM_ID: props.instance.slack.teamId,
|
|
1152
|
+
FOUNDATION_EMAIL_IDENTITIES: JSON.stringify(emailIdentities)
|
|
1118
1153
|
}
|
|
1119
1154
|
});
|
|
1120
1155
|
const browserDomains = provisionsIntegration(props.instance, "browser") ? browserDomainsFor(props.configPath) : [];
|
|
@@ -1622,11 +1657,8 @@ function configTimezoneFor(configPath) {
|
|
|
1622
1657
|
return parseInstanceConfig(readFileSync(configPath, "utf8")).timezone;
|
|
1623
1658
|
}
|
|
1624
1659
|
function emailIdentitiesFor(configPath) {
|
|
1625
|
-
const
|
|
1626
|
-
|
|
1627
|
-
if (email?.enabled !== true || !Array.isArray(email.identities))
|
|
1628
|
-
return [];
|
|
1629
|
-
return email.identities;
|
|
1660
|
+
const email = parseInstanceConfig(readFileSync(configPath, "utf8")).capabilities.email;
|
|
1661
|
+
return email.enabled ? email.identities : [];
|
|
1630
1662
|
}
|
|
1631
1663
|
var WEBHOOK_JOBS_MAX_ENV_BYTES = 2048;
|
|
1632
1664
|
function webhookJobsFor(configPath) {
|
|
@@ -223,10 +223,26 @@ var RawAwsReadonlyCapability = z.object({
|
|
|
223
223
|
}).strict().default({});
|
|
224
224
|
var RawEmailIdentity = z.object({
|
|
225
225
|
address: z.string().regex(/^[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}$/i),
|
|
226
|
-
channel: z.string().regex(/^[
|
|
226
|
+
channel: z.string().regex(/^[CGD][A-Z0-9]{8,}$/),
|
|
227
|
+
owner: z.string().regex(/^U[A-Z0-9]{8,}$/, "owner must be a Slack U id").optional(),
|
|
227
228
|
signature: z.string().min(1).max(2000),
|
|
228
229
|
sendAsAddresses: z.array(z.string().regex(/^[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}$/i)).max(20).default([]).refine((addresses) => new Set(addresses.map((address) => address.toLowerCase())).size === addresses.length, "duplicate send-as address")
|
|
229
|
-
}).strict()
|
|
230
|
+
}).strict().superRefine((identity, ctx) => {
|
|
231
|
+
if (identity.channel.startsWith("D") && identity.owner === undefined) {
|
|
232
|
+
ctx.addIssue({
|
|
233
|
+
code: z.ZodIssueCode.custom,
|
|
234
|
+
path: ["owner"],
|
|
235
|
+
message: "personal email identities in D channels require an owner"
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
if (!identity.channel.startsWith("D") && identity.owner !== undefined) {
|
|
239
|
+
ctx.addIssue({
|
|
240
|
+
code: z.ZodIssueCode.custom,
|
|
241
|
+
path: ["owner"],
|
|
242
|
+
message: "shared email identities in C/G channels cannot have an owner"
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
});
|
|
230
246
|
var RawEmailCapability = z.object({
|
|
231
247
|
enabled: z.boolean().default(false),
|
|
232
248
|
identities: z.array(RawEmailIdentity).default([]),
|
|
@@ -246,6 +262,19 @@ var RawEmailCapability = z.object({
|
|
|
246
262
|
seen.add(key);
|
|
247
263
|
}
|
|
248
264
|
}
|
|
265
|
+
const personalOwners = new Set;
|
|
266
|
+
for (const [index, identity] of value.identities.entries()) {
|
|
267
|
+
if (identity.owner === undefined)
|
|
268
|
+
continue;
|
|
269
|
+
if (personalOwners.has(identity.owner)) {
|
|
270
|
+
ctx.addIssue({
|
|
271
|
+
code: z.ZodIssueCode.custom,
|
|
272
|
+
path: ["identities", index, "owner"],
|
|
273
|
+
message: `duplicate personal email identity owner: ${identity.owner}`
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
personalOwners.add(identity.owner);
|
|
277
|
+
}
|
|
249
278
|
const owners = new Map;
|
|
250
279
|
for (const [index, identity] of value.identities.entries()) {
|
|
251
280
|
for (const address of [identity.address, ...identity.sendAsAddresses]) {
|
package/dist/src/index.js
CHANGED
|
@@ -10,7 +10,7 @@ import {
|
|
|
10
10
|
FoundationPipeline,
|
|
11
11
|
NewsletterStack,
|
|
12
12
|
deployStatements
|
|
13
|
-
} from "../chunk-
|
|
13
|
+
} from "../chunk-r6djsvkj.js";
|
|
14
14
|
import {
|
|
15
15
|
BUN_VERSION,
|
|
16
16
|
LAMBDA_ENTRY_POINTS,
|
|
@@ -43,7 +43,7 @@ import {
|
|
|
43
43
|
skillsKey,
|
|
44
44
|
skipBundle,
|
|
45
45
|
verifyManifest
|
|
46
|
-
} from "../chunk-
|
|
46
|
+
} from "../chunk-xb5k71tp.js";
|
|
47
47
|
export {
|
|
48
48
|
BUILD_TIMEOUT,
|
|
49
49
|
BUN_VERSION,
|
package/package.json
CHANGED
|
@@ -400,6 +400,33 @@ export class FoundationAgent extends cdk.Stack {
|
|
|
400
400
|
}),
|
|
401
401
|
);
|
|
402
402
|
}
|
|
403
|
+
/**
|
|
404
|
+
* The execution role and its ReadOnlyAccess escape hatch are both
|
|
405
|
+
* shell-reachable. Keep Gmail credentials behind the proxy even if a future
|
|
406
|
+
* broad grant is added: the shared secret and all owner-derived personal
|
|
407
|
+
* secret names are explicitly denied here.
|
|
408
|
+
*/
|
|
409
|
+
private denyDirectEmailSecretAccess(
|
|
410
|
+
role: iam.Role,
|
|
411
|
+
sharedMailboxSecret: secretsmanager.ISecret,
|
|
412
|
+
personalSecretPrefix: string,
|
|
413
|
+
): void {
|
|
414
|
+
const stack = cdk.Stack.of(this);
|
|
415
|
+
const personalSecrets = stack.formatArn({
|
|
416
|
+
service: "secretsmanager",
|
|
417
|
+
resource: "secret",
|
|
418
|
+
resourceName: `${personalSecretPrefix}/personal/*-??????`,
|
|
419
|
+
arnFormat: cdk.ArnFormat.COLON_RESOURCE_NAME,
|
|
420
|
+
});
|
|
421
|
+
role.addToPolicy(
|
|
422
|
+
new iam.PolicyStatement({
|
|
423
|
+
sid: "DenyEmailSecret",
|
|
424
|
+
effect: iam.Effect.DENY,
|
|
425
|
+
actions: ["secretsmanager:*"],
|
|
426
|
+
resources: [secretArnPattern(sharedMailboxSecret), personalSecrets],
|
|
427
|
+
}),
|
|
428
|
+
);
|
|
429
|
+
}
|
|
403
430
|
|
|
404
431
|
/**
|
|
405
432
|
* Hosted pages belong to the conversations that made them, and every read
|
|
@@ -558,6 +585,11 @@ export class FoundationAgent extends cdk.Stack {
|
|
|
558
585
|
});
|
|
559
586
|
this.denyDirectCrmTableAccess(role, props.instance, props.crmTable);
|
|
560
587
|
this.denyDirectKnockSecretAccess(role, props);
|
|
588
|
+
this.denyDirectEmailSecretAccess(
|
|
589
|
+
role,
|
|
590
|
+
props.secrets.googleEmail,
|
|
591
|
+
namesFor(props.instance).secretGoogleEmail,
|
|
592
|
+
);
|
|
561
593
|
this.denyDirectUpworkApprovalAccess(role, props.upworkApprovalTable);
|
|
562
594
|
this.denyDirectEtradeAccess(role, props);
|
|
563
595
|
this.pagesPolicy("AgentReadOnlyPagesPolicy", role, props.instance, { invoke: false });
|
|
@@ -652,6 +684,11 @@ export class FoundationAgent extends cdk.Stack {
|
|
|
652
684
|
this.denyDirectCrmTableAccess(role, props.instance, props.crmTable);
|
|
653
685
|
this.denyDirectKnockSecretAccess(role, props);
|
|
654
686
|
this.denyDirectUpworkApprovalAccess(role, props.upworkApprovalTable);
|
|
687
|
+
this.denyDirectEmailSecretAccess(
|
|
688
|
+
role,
|
|
689
|
+
props.secrets.googleEmail,
|
|
690
|
+
namesFor(props.instance).secretGoogleEmail,
|
|
691
|
+
);
|
|
655
692
|
this.denyDirectEtradeAccess(role, props);
|
|
656
693
|
this.pagesPolicy("AgentExecutionPagesPolicy", role, props.instance, { invoke: true });
|
|
657
694
|
|
|
@@ -749,20 +786,6 @@ export class FoundationAgent extends cdk.Stack {
|
|
|
749
786
|
resources: [secretArnPattern(props.secrets.signing)],
|
|
750
787
|
}),
|
|
751
788
|
);
|
|
752
|
-
// Email is the sharpest version of the same argument as the signing
|
|
753
|
-
// secret, and the reason the proxy is a separate Lambda. The scope Google
|
|
754
|
-
// grants the mailbox (`gmail.compose`) permits SENDING, so a container
|
|
755
|
-
// that could read this secret could send mail as the company — and the
|
|
756
|
-
// model's shell can reach the execution role's credentials. Denying the
|
|
757
|
-
// read is what makes "the teammate cannot send" true rather than merely intended.
|
|
758
|
-
role.addToPolicy(
|
|
759
|
-
new iam.PolicyStatement({
|
|
760
|
-
sid: "DenyEmailSecret",
|
|
761
|
-
effect: iam.Effect.DENY,
|
|
762
|
-
actions: ["secretsmanager:GetSecretValue"],
|
|
763
|
-
resources: [secretArnPattern(props.secrets.googleEmail)],
|
|
764
|
-
}),
|
|
765
|
-
);
|
|
766
789
|
// The whole of the agent's email permission: invoke the proxy. Not the
|
|
767
790
|
// Gmail API, not the secret, not the identity list.
|
|
768
791
|
// Built from the NAME rather than from FoundationApi's function object: FoundationApi
|
package/src/stacks/api-stack.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { readFileSync } from "node:fs";
|
|
2
2
|
import {
|
|
3
3
|
type CrmOutreachConfig,
|
|
4
|
+
type EmailIdentity,
|
|
4
5
|
type ScheduledJobBinding,
|
|
5
6
|
type ScheduledJobConfig,
|
|
6
7
|
type WebhookJobBinding,
|
|
@@ -132,6 +133,10 @@ export class FoundationApi extends cdk.Stack {
|
|
|
132
133
|
super(scope, id, props);
|
|
133
134
|
const names = namesFor(props.instance);
|
|
134
135
|
const display = props.instance.displayName;
|
|
136
|
+
const emailIdentities = emailIdentitiesFor(props.configPath);
|
|
137
|
+
const personalEmailIdentities = emailIdentities.filter(
|
|
138
|
+
(identity): identity is EmailIdentity & { owner: string } => identity.owner !== undefined,
|
|
139
|
+
);
|
|
135
140
|
const upwork = provisionsIntegration(props.instance, "upwork")
|
|
136
141
|
? upworkConfigForProvisioning(props.configPath)
|
|
137
142
|
: undefined;
|
|
@@ -190,6 +195,23 @@ export class FoundationApi extends cdk.Stack {
|
|
|
190
195
|
deadLetterQueue: { queue: this.invokeDlq, maxReceiveCount: 1 },
|
|
191
196
|
});
|
|
192
197
|
|
|
198
|
+
// Personal grants are separate deployment-owned shells with exact proxy
|
|
199
|
+
// reads. Removing a personal identity deletes its stored refresh token;
|
|
200
|
+
// re-enabling it requires fresh Google consent instead of adopting a stale
|
|
201
|
+
// retained credential. Google-side revocation remains the account owner's.
|
|
202
|
+
const personalEmailSecrets = personalEmailIdentities.map(
|
|
203
|
+
(identity) =>
|
|
204
|
+
new secretsmanager.Secret(this, `PersonalEmailSecret${identity.owner}`, {
|
|
205
|
+
secretName: `${namesFor(props.instance).secretGoogleEmail}/personal/${identity.owner}`,
|
|
206
|
+
description: `${display} personal Gmail credential for Slack ${identity.owner}; gateway writes and email proxy reads`,
|
|
207
|
+
removalPolicy: cdk.RemovalPolicy.DESTROY,
|
|
208
|
+
generateSecretString: {
|
|
209
|
+
secretStringTemplate: "{}",
|
|
210
|
+
generateStringKey: "placeholder",
|
|
211
|
+
},
|
|
212
|
+
}),
|
|
213
|
+
);
|
|
214
|
+
|
|
193
215
|
const gatewayRole = new iam.Role(this, "GatewayRole", {
|
|
194
216
|
assumedBy: new iam.ServicePrincipal("lambda.amazonaws.com"),
|
|
195
217
|
description: `${display} gateway Lambda role (least privilege)`,
|
|
@@ -229,11 +251,15 @@ export class FoundationApi extends cdk.Stack {
|
|
|
229
251
|
gatewayRole.addToPolicy(
|
|
230
252
|
new iam.PolicyStatement({
|
|
231
253
|
sid: "EmailIdentityConnect",
|
|
232
|
-
// The same shape, and here it matters more:
|
|
233
|
-
// the one thing in the system that could SEND mail.
|
|
234
|
-
//
|
|
254
|
+
// The same shape, and here it matters more: a mailbox credential is
|
|
255
|
+
// the one thing in the system that could SEND mail. The gateway may
|
|
256
|
+
// write the shared mailbox and these exact configured personal shells,
|
|
257
|
+
// but never read either one.
|
|
235
258
|
actions: ["secretsmanager:PutSecretValue"],
|
|
236
|
-
resources: [
|
|
259
|
+
resources: [
|
|
260
|
+
secretArnPattern(props.secrets.googleEmail),
|
|
261
|
+
...personalEmailSecrets.map(secretArnPattern),
|
|
262
|
+
],
|
|
237
263
|
}),
|
|
238
264
|
);
|
|
239
265
|
if (props.secrets.knockCredential !== undefined) {
|
|
@@ -376,9 +402,22 @@ export class FoundationApi extends cdk.Stack {
|
|
|
376
402
|
FOUNDATION_DISPLAY_NAME: props.instance.displayName,
|
|
377
403
|
FOUNDATION_ITEMS_TABLE_NAME: props.itemsTable.tableName,
|
|
378
404
|
FOUNDATION_GOOGLE_CALENDAR_SECRET_ID: props.secrets.googleCalendar.secretName,
|
|
379
|
-
FOUNDATION_GOOGLE_OAUTH_SECRET_ID: props.
|
|
405
|
+
FOUNDATION_GOOGLE_OAUTH_SECRET_ID: namesFor(props.instance).secretGoogleOauth,
|
|
380
406
|
FOUNDATION_GOOGLE_DRIVE_SECRET_ID: props.secrets.googleDrive.secretName,
|
|
381
|
-
FOUNDATION_GOOGLE_EMAIL_SECRET_ID: props.
|
|
407
|
+
FOUNDATION_GOOGLE_EMAIL_SECRET_ID: namesFor(props.instance).secretGoogleEmail,
|
|
408
|
+
...(personalEmailIdentities.length === 0
|
|
409
|
+
? {}
|
|
410
|
+
: {
|
|
411
|
+
// The gateway needs no signatures or aliases; this compact mapping
|
|
412
|
+
// is enough to bind a slash command and callback to one owner/DM.
|
|
413
|
+
FOUNDATION_PERSONAL_EMAIL_IDENTITIES: JSON.stringify(
|
|
414
|
+
personalEmailIdentities.map(({ address, channel, owner }) => ({
|
|
415
|
+
address,
|
|
416
|
+
channel,
|
|
417
|
+
owner,
|
|
418
|
+
})),
|
|
419
|
+
),
|
|
420
|
+
}),
|
|
382
421
|
...(props.secrets.knockOauthClient === undefined
|
|
383
422
|
? {}
|
|
384
423
|
: { FOUNDATION_KNOCK_OAUTH_CLIENT_SECRET_ID: props.secrets.knockOauthClient.secretName }),
|
|
@@ -593,11 +632,10 @@ export class FoundationApi extends cdk.Stack {
|
|
|
593
632
|
}
|
|
594
633
|
|
|
595
634
|
// --- email proxy -----------------------------------------------------
|
|
596
|
-
// The permission barrier for Gmail
|
|
597
|
-
//
|
|
598
|
-
//
|
|
599
|
-
//
|
|
600
|
-
// hijacked session can invoke the four operations and nothing more.
|
|
635
|
+
// The permission barrier for Gmail. Only this role can read the mailbox
|
|
636
|
+
// grants; the agent's execution and assumed read-only roles explicitly
|
|
637
|
+
// deny those secrets. Personal access also verifies a short-lived signed
|
|
638
|
+
// owner/DM/session claim before reading its separate credential.
|
|
601
639
|
const emailProxyRole = new iam.Role(this, "EmailProxyRole", {
|
|
602
640
|
assumedBy: new iam.ServicePrincipal("lambda.amazonaws.com"),
|
|
603
641
|
description: `${display} email proxy Lambda role (the only reader of the mailbox secret)`,
|
|
@@ -609,11 +647,13 @@ export class FoundationApi extends cdk.Stack {
|
|
|
609
647
|
new iam.PolicyStatement({
|
|
610
648
|
sid: "EmailProxyCredentials",
|
|
611
649
|
actions: ["secretsmanager:GetSecretValue"],
|
|
612
|
-
//
|
|
613
|
-
//
|
|
650
|
+
// Exact shared, OAuth, signing, and configured personal secret ARNs:
|
|
651
|
+
// a config sync cannot grant a future owner's mailbox.
|
|
614
652
|
resources: [
|
|
615
653
|
secretArnPattern(props.secrets.googleEmail),
|
|
616
654
|
secretArnPattern(props.secrets.googleOauth),
|
|
655
|
+
secretArnPattern(props.secrets.signing),
|
|
656
|
+
...personalEmailSecrets.map(secretArnPattern),
|
|
617
657
|
],
|
|
618
658
|
}),
|
|
619
659
|
);
|
|
@@ -638,12 +678,14 @@ export class FoundationApi extends cdk.Stack {
|
|
|
638
678
|
// below its minimum", 2026-09-06). Gmail-side rate is bounded
|
|
639
679
|
// by the hourly routine and one turn per channel at a time instead.
|
|
640
680
|
environment: {
|
|
641
|
-
FOUNDATION_GOOGLE_EMAIL_SECRET_ID: props.
|
|
642
|
-
FOUNDATION_GOOGLE_OAUTH_SECRET_ID: props.
|
|
681
|
+
FOUNDATION_GOOGLE_EMAIL_SECRET_ID: namesFor(props.instance).secretGoogleEmail,
|
|
682
|
+
FOUNDATION_GOOGLE_OAUTH_SECRET_ID: namesFor(props.instance).secretGoogleOauth,
|
|
683
|
+
FOUNDATION_SLACK_SIGNING_SECRET_ID: props.secrets.signing.secretName,
|
|
684
|
+
FOUNDATION_SLACK_TEAM_ID: props.instance.slack.teamId,
|
|
643
685
|
// Read at SYNTH from the instance's config file. The identity list a
|
|
644
686
|
// turn can draft as is therefore fixed at deploy time and cannot be
|
|
645
687
|
// widened by anything the agent sends or by a config sync alone.
|
|
646
|
-
FOUNDATION_EMAIL_IDENTITIES: JSON.stringify(
|
|
688
|
+
FOUNDATION_EMAIL_IDENTITIES: JSON.stringify(emailIdentities),
|
|
647
689
|
},
|
|
648
690
|
});
|
|
649
691
|
|
|
@@ -1297,21 +1339,16 @@ function configTimezoneFor(configPath: string): string {
|
|
|
1297
1339
|
}
|
|
1298
1340
|
|
|
1299
1341
|
/**
|
|
1300
|
-
* The mailbox identities to bake into the proxy
|
|
1301
|
-
*
|
|
1342
|
+
* The mailbox identities to bake into the proxy and personal OAuth gateway
|
|
1343
|
+
* policy, parsed with the runtime schema at synth.
|
|
1302
1344
|
*
|
|
1303
|
-
* A
|
|
1304
|
-
*
|
|
1305
|
-
*
|
|
1306
|
-
* which is the correct behaviour for a capability nobody has turned on.
|
|
1345
|
+
* A disabled capability yields an empty list. Parsing here deliberately makes
|
|
1346
|
+
* malformed owner/channel bindings a deployment failure rather than allowing a
|
|
1347
|
+
* looser raw YAML shape into an IAM policy or Lambda environment.
|
|
1307
1348
|
*/
|
|
1308
|
-
export function emailIdentitiesFor(configPath: string):
|
|
1309
|
-
const
|
|
1310
|
-
|
|
1311
|
-
};
|
|
1312
|
-
const email = doc.capabilities?.email;
|
|
1313
|
-
if (email?.enabled !== true || !Array.isArray(email.identities)) return [];
|
|
1314
|
-
return email.identities;
|
|
1349
|
+
export function emailIdentitiesFor(configPath: string): EmailIdentity[] {
|
|
1350
|
+
const email = parseInstanceConfig(readFileSync(configPath, "utf8")).capabilities.email;
|
|
1351
|
+
return email.enabled ? email.identities : [];
|
|
1315
1352
|
}
|
|
1316
1353
|
|
|
1317
1354
|
/**
|