@ampless/backend 1.0.0-alpha.45 → 1.0.0-alpha.47

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.
@@ -21,6 +21,12 @@ interface CreateProcessorTrustedHandlerOpts {
21
21
  */
22
22
  site: Config['site'];
23
23
  }
24
+ /**
25
+ * Decrypt one AES-256-GCM ciphertext blob.
26
+ * @param rawKey 32-byte Buffer
27
+ * @param b64 base64( IV[12] || ciphertext || authTag[16] )
28
+ */
29
+ declare function decryptSecret(rawKey: Buffer, b64: string): string;
24
30
  /**
25
31
  * SQS-driven trusted plugin executor. Trusted plugins get a runtime
26
32
  * context with `listPublishedPosts` (one Query against the byStatus
@@ -37,4 +43,4 @@ interface CreateProcessorTrustedHandlerOpts {
37
43
  */
38
44
  declare function createProcessorTrustedHandler(opts: CreateProcessorTrustedHandlerOpts): SQSHandler;
39
45
 
40
- export { type CreateProcessorTrustedHandlerOpts, createProcessorTrustedHandler };
46
+ export { type CreateProcessorTrustedHandlerOpts, createProcessorTrustedHandler, decryptSecret };
@@ -1,6 +1,8 @@
1
1
  // src/events/processor-trusted.ts
2
+ import { createDecipheriv } from "crypto";
2
3
  import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
3
- import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
4
+ import { DynamoDBClient, GetItemCommand } from "@aws-sdk/client-dynamodb";
5
+ import { marshall, unmarshall } from "@aws-sdk/util-dynamodb";
4
6
  import {
5
7
  DynamoDBDocumentClient,
6
8
  DeleteCommand,
@@ -47,6 +49,21 @@ function requireEnv(name) {
47
49
  return v;
48
50
  }
49
51
  var POST_BY_STATUS_INDEX = "byStatus";
52
+ function decryptSecret(rawKey, b64) {
53
+ const combined = Buffer.from(b64, "base64");
54
+ if (combined.byteLength < 12 + 16) {
55
+ throw new Error(
56
+ `[trusted-processor] decryptSecret: ciphertext blob too short (${combined.byteLength} bytes)`
57
+ );
58
+ }
59
+ const iv = combined.subarray(0, 12);
60
+ const authTag = combined.subarray(combined.byteLength - 16);
61
+ const ciphertext = combined.subarray(12, combined.byteLength - 16);
62
+ const decipher = createDecipheriv("aes-256-gcm", rawKey, iv);
63
+ decipher.setAuthTag(authTag);
64
+ const plaintext = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
65
+ return plaintext.toString("utf8");
66
+ }
50
67
  function safeParse(s) {
51
68
  try {
52
69
  return JSON.parse(s);
@@ -85,7 +102,24 @@ function createProcessorTrustedHandler(opts) {
85
102
  const POST_TABLE = requireEnv("AMPLESS_POST_TABLE");
86
103
  const KV_TABLE = requireEnv("AMPLESS_KV_TABLE");
87
104
  const POSTTAG_TABLE = requireEnv("AMPLESS_POSTTAG_TABLE");
105
+ const PLUGIN_SECRET_TABLE = requireEnv("AMPLESS_PLUGIN_SECRET_TABLE");
88
106
  const REGION = requireEnv("AWS_REGION");
107
+ const rawDdb = new DynamoDBClient({});
108
+ const _encKeyB64 = process.env.PLUGIN_SECRET_ENCRYPTION_KEY;
109
+ const ENCRYPTION_KEY = (() => {
110
+ if (!_encKeyB64) return null;
111
+ const buf = Buffer.from(_encKeyB64, "base64");
112
+ if (buf.byteLength !== 32) {
113
+ console.error(
114
+ `[trusted-processor] PLUGIN_SECRET_ENCRYPTION_KEY must encode 32 bytes (got ${buf.byteLength}); secrets will not be decryptable.`
115
+ );
116
+ return null;
117
+ }
118
+ return buf;
119
+ })();
120
+ function getEncryptionKey() {
121
+ return ENCRYPTION_KEY;
122
+ }
89
123
  async function listPublished() {
90
124
  const items = [];
91
125
  let exclusiveStartKey;
@@ -121,6 +155,7 @@ function createProcessorTrustedHandler(opts) {
121
155
  function makeContext(plugin) {
122
156
  const namespace = plugin.instanceId ?? plugin.name;
123
157
  const label = plugin.instanceId ? `${plugin.name}#${plugin.instanceId}` : plugin.name;
158
+ const secretCache = /* @__PURE__ */ new Map();
124
159
  return {
125
160
  site: opts.site,
126
161
  listPublishedPosts: () => listPublished(),
@@ -146,6 +181,58 @@ function createProcessorTrustedHandler(opts) {
146
181
  })
147
182
  );
148
183
  return formatPublicAssetUrl(BUCKET, REGION, objectKey);
184
+ },
185
+ async secret(key) {
186
+ const cacheKey = `${namespace}:${key}`;
187
+ if (secretCache.has(cacheKey)) {
188
+ return secretCache.get(cacheKey);
189
+ }
190
+ const sk = `plugins.${namespace}.${key}`;
191
+ try {
192
+ const result = await rawDdb.send(
193
+ new GetItemCommand({
194
+ TableName: PLUGIN_SECRET_TABLE,
195
+ Key: marshall({ siteId: "default", sk })
196
+ })
197
+ );
198
+ if (!result.Item) {
199
+ secretCache.set(cacheKey, void 0);
200
+ return void 0;
201
+ }
202
+ const storedValue = unmarshall(result.Item).value;
203
+ if (!storedValue) {
204
+ secretCache.set(cacheKey, void 0);
205
+ return void 0;
206
+ }
207
+ const encryptionKey = getEncryptionKey();
208
+ let plaintext;
209
+ if (encryptionKey) {
210
+ try {
211
+ plaintext = decryptSecret(encryptionKey, storedValue);
212
+ } catch (decErr) {
213
+ console.error(
214
+ `[trusted-processor] ${label}: ctx.secret("${key}") decryption failed`,
215
+ decErr
216
+ );
217
+ secretCache.set(cacheKey, void 0);
218
+ return void 0;
219
+ }
220
+ } else {
221
+ console.warn(
222
+ `[trusted-processor] ${label}: ctx.secret("${key}") \u2014 no encryption key found; returning undefined. Run \`npx create-ampless setup-encryption-key\` and rotate secrets.`
223
+ );
224
+ secretCache.set(cacheKey, void 0);
225
+ return void 0;
226
+ }
227
+ secretCache.set(cacheKey, plaintext);
228
+ return plaintext;
229
+ } catch (err) {
230
+ console.error(
231
+ `[trusted-processor] ${label}: ctx.secret("${key}") DDB read failed`,
232
+ err
233
+ );
234
+ return void 0;
235
+ }
149
236
  }
150
237
  };
151
238
  }
@@ -250,5 +337,6 @@ function createProcessorTrustedHandler(opts) {
250
337
  };
251
338
  }
252
339
  export {
253
- createProcessorTrustedHandler
340
+ createProcessorTrustedHandler,
341
+ decryptSecret
254
342
  };
@@ -0,0 +1,32 @@
1
+ import { Handler } from 'aws-lambda';
2
+
3
+ /**
4
+ * Encrypt `plaintext` with AES-256-GCM.
5
+ * Returns base64( IV[12] || ciphertext || authTag[16] ).
6
+ * Node.js `createCipheriv` produces ciphertext and authTag separately;
7
+ * we concatenate them after the IV.
8
+ */
9
+ declare function encryptSecret(rawKey: Buffer, plaintext: string): string;
10
+ interface SetArgs {
11
+ fieldKey: string;
12
+ instanceId: string;
13
+ value: string;
14
+ }
15
+ interface ClearArgs {
16
+ fieldKey: string;
17
+ instanceId: string;
18
+ }
19
+ interface PluginSecretHandlerEvent {
20
+ typeName: string;
21
+ fieldName: string;
22
+ arguments: Partial<SetArgs & ClearArgs>;
23
+ identity?: {
24
+ sub?: string;
25
+ username?: string;
26
+ groups?: string[];
27
+ [k: string]: unknown;
28
+ };
29
+ }
30
+ declare const handler: Handler<PluginSecretHandlerEvent, string | null>;
31
+
32
+ export { encryptSecret, handler };
@@ -0,0 +1,147 @@
1
+ // src/functions/plugin-secret-handler.ts
2
+ import { createCipheriv, randomBytes } from "crypto";
3
+ import { DynamoDBClient, PutItemCommand, DeleteItemCommand } from "@aws-sdk/client-dynamodb";
4
+ import { marshall } from "@aws-sdk/util-dynamodb";
5
+ import { isValidPluginKey, validatePluginSettingValue } from "ampless";
6
+ var ddb = new DynamoDBClient({});
7
+ var ADMIN_GROUP = "ampless-admin";
8
+ var EDITOR_GROUP = "ampless-editor";
9
+ function requireEnv(name) {
10
+ const v = process.env[name];
11
+ if (!v) throw new Error(`plugin-secret-handler: missing required env var ${name}`);
12
+ return v;
13
+ }
14
+ var _encKeyB64 = process.env.PLUGIN_SECRET_ENCRYPTION_KEY;
15
+ if (!_encKeyB64) {
16
+ throw new Error(
17
+ "[plugin-secret-handler] PLUGIN_SECRET_ENCRYPTION_KEY env var is not set. Run `npx create-ampless setup-encryption-key` and configure pluginSecretEncryptionKey in defineAmplessBackend()."
18
+ );
19
+ }
20
+ var _encKeyBuf = Buffer.from(_encKeyB64, "base64");
21
+ if (_encKeyBuf.byteLength !== 32) {
22
+ throw new Error(
23
+ `[plugin-secret-handler] PLUGIN_SECRET_ENCRYPTION_KEY must encode 32 bytes (got ${_encKeyBuf.byteLength}). Re-run \`npx create-ampless setup-encryption-key\`.`
24
+ );
25
+ }
26
+ var ENCRYPTION_KEY = _encKeyBuf;
27
+ function getEncryptionKey() {
28
+ return ENCRYPTION_KEY;
29
+ }
30
+ function encryptSecret(rawKey, plaintext) {
31
+ const iv = randomBytes(12);
32
+ const cipher = createCipheriv("aes-256-gcm", rawKey, iv);
33
+ const encrypted = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
34
+ const authTag = cipher.getAuthTag();
35
+ const combined = Buffer.concat([iv, encrypted, authTag]);
36
+ return combined.toString("base64");
37
+ }
38
+ function pluginSecretSk(instanceId, fieldKey) {
39
+ return `plugins.${instanceId}.${fieldKey}`;
40
+ }
41
+ function isAllowedGroup(identity) {
42
+ const groups = identity?.groups ?? [];
43
+ return groups.includes(ADMIN_GROUP) || groups.includes(EDITOR_GROUP);
44
+ }
45
+ async function handleSet(args) {
46
+ const { fieldKey, instanceId, value } = args;
47
+ if (!isValidPluginKey(instanceId)) {
48
+ throw new Error(`[plugin-secret-handler] Invalid instanceId: "${instanceId}"`);
49
+ }
50
+ if (!isValidPluginKey(fieldKey)) {
51
+ throw new Error(`[plugin-secret-handler] Invalid fieldKey: "${fieldKey}"`);
52
+ }
53
+ if (typeof value !== "string") {
54
+ throw new Error("[plugin-secret-handler] value must be a string");
55
+ }
56
+ const validated = validatePluginSettingValue(
57
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
58
+ { type: "text", key: fieldKey, label: fieldKey, maxLength: 1e4 },
59
+ value,
60
+ "strict"
61
+ );
62
+ if (validated === null) {
63
+ throw new Error(
64
+ `[plugin-secret-handler] Value for field "${fieldKey}" failed validation (too long or contains invalid characters).`
65
+ );
66
+ }
67
+ const sanitizedValue = validated;
68
+ const encKey = getEncryptionKey();
69
+ const ciphertext = encryptSecret(encKey, sanitizedValue);
70
+ const sk = pluginSecretSk(instanceId, fieldKey);
71
+ const secretTable = requireEnv("AMPLESS_PLUGIN_SECRET_TABLE");
72
+ const indicatorTable = requireEnv("AMPLESS_PLUGIN_SECRET_INDICATOR_TABLE");
73
+ const now = (/* @__PURE__ */ new Date()).toISOString();
74
+ await ddb.send(
75
+ new PutItemCommand({
76
+ TableName: secretTable,
77
+ Item: marshall({ siteId: "default", sk, value: ciphertext })
78
+ })
79
+ );
80
+ await ddb.send(
81
+ new PutItemCommand({
82
+ TableName: indicatorTable,
83
+ Item: marshall({ siteId: "default", sk, lastSetAt: now })
84
+ })
85
+ );
86
+ return "ok";
87
+ }
88
+ async function handleClear(args) {
89
+ const { fieldKey, instanceId } = args;
90
+ if (!isValidPluginKey(instanceId)) {
91
+ throw new Error(`[plugin-secret-handler] Invalid instanceId: "${instanceId}"`);
92
+ }
93
+ if (!isValidPluginKey(fieldKey)) {
94
+ throw new Error(`[plugin-secret-handler] Invalid fieldKey: "${fieldKey}"`);
95
+ }
96
+ const sk = pluginSecretSk(instanceId, fieldKey);
97
+ const secretTable = requireEnv("AMPLESS_PLUGIN_SECRET_TABLE");
98
+ const indicatorTable = requireEnv("AMPLESS_PLUGIN_SECRET_INDICATOR_TABLE");
99
+ await ddb.send(
100
+ new DeleteItemCommand({
101
+ TableName: secretTable,
102
+ Key: marshall({ siteId: "default", sk })
103
+ })
104
+ );
105
+ await ddb.send(
106
+ new DeleteItemCommand({
107
+ TableName: indicatorTable,
108
+ Key: marshall({ siteId: "default", sk })
109
+ })
110
+ );
111
+ return "ok";
112
+ }
113
+ var handler = async (event) => {
114
+ const field = event.fieldName;
115
+ if (!isAllowedGroup(event.identity)) {
116
+ console.error(
117
+ `[plugin-secret-handler] ${field}: caller is not in admin or editor group`,
118
+ { identity: event.identity }
119
+ );
120
+ throw new Error("Unauthorized: caller must be in ampless-admin or ampless-editor group");
121
+ }
122
+ try {
123
+ if (field === "setPluginSecret") {
124
+ const { fieldKey, instanceId, value } = event.arguments;
125
+ if (!fieldKey || !instanceId || value === void 0) {
126
+ throw new Error("[plugin-secret-handler] setPluginSecret: missing required arguments");
127
+ }
128
+ return await handleSet({ fieldKey, instanceId, value });
129
+ }
130
+ if (field === "clearPluginSecret") {
131
+ const { fieldKey, instanceId } = event.arguments;
132
+ if (!fieldKey || !instanceId) {
133
+ throw new Error("[plugin-secret-handler] clearPluginSecret: missing required arguments");
134
+ }
135
+ return await handleClear({ fieldKey, instanceId });
136
+ }
137
+ console.error(`[plugin-secret-handler] unsupported fieldName: ${field}`);
138
+ throw new Error(`Unsupported fieldName: ${field}`);
139
+ } catch (err) {
140
+ console.error(`[plugin-secret-handler] ${field} failed:`, err);
141
+ throw err;
142
+ }
143
+ };
144
+ export {
145
+ encryptSecret,
146
+ handler
147
+ };
package/dist/index.d.ts CHANGED
@@ -15,6 +15,44 @@ interface DefineAmplessBackendOpts {
15
15
  apiKeyRenewer: FunctionResource;
16
16
  userAdmin: FunctionResource;
17
17
  mcpHandler: FunctionResource;
18
+ /**
19
+ * The plugin-secret-handler Lambda resource.
20
+ *
21
+ * Backs the `setPluginSecret` and `clearPluginSecret` AppSync mutations.
22
+ * Receives plaintext from admin browser, validates, encrypts with the
23
+ * AES-256-GCM key (injected as `PLUGIN_SECRET_ENCRYPTION_KEY` env var),
24
+ * and writes only ciphertext to DDB. The admin browser never touches
25
+ * PluginSecret directly.
26
+ *
27
+ * Generate and commit the key with `npx create-ampless setup-encryption-key`,
28
+ * then pass it via `pluginSecretEncryptionKey` below. No AWS credentials
29
+ * required for key provisioning.
30
+ *
31
+ * Optional so that existing stacks that haven't added the function yet
32
+ * don't break at TypeScript level. The function must be wired in for
33
+ * the mutations to be reachable.
34
+ */
35
+ pluginSecretHandler?: FunctionResource;
36
+ /**
37
+ * AES-256-GCM encryption key for plugin secret storage (v2.2).
38
+ *
39
+ * A base64-encoded 32-byte key generated by
40
+ * `npx create-ampless setup-encryption-key` and stored in
41
+ * `amplify/secrets/encryption-key.ts` (adjacent to `amplify/backend.ts`).
42
+ *
43
+ * Example wiring in `amplify/backend.ts`:
44
+ * ```ts
45
+ * import { PLUGIN_SECRET_ENCRYPTION_KEY } from './secrets/encryption-key.js'
46
+ * defineAmplessBackend({ ..., pluginSecretEncryptionKey: PLUGIN_SECRET_ENCRYPTION_KEY })
47
+ * ```
48
+ *
49
+ * When provided, the value is injected as the `PLUGIN_SECRET_ENCRYPTION_KEY`
50
+ * env var into both the `processorTrusted` Lambda (for decryption via
51
+ * `ctx.secret()`) and the `pluginSecretHandler` Lambda (for encryption on
52
+ * set). If omitted, the env var is not set and both Lambdas will log a
53
+ * warning and return `undefined` from `ctx.secret()`.
54
+ */
55
+ pluginSecretEncryptionKey?: string;
18
56
  }
19
57
  type AmplessBackend = any;
20
58
  /**
@@ -148,6 +186,19 @@ interface AmplessSchemaModelsOpts {
148
186
  * pattern as `AmplessAuthConfigOpts.postConfirmation`.
149
187
  */
150
188
  userAdminFunction?: unknown;
189
+ /**
190
+ * Optional Amplify `defineFunction` ref backing the plugin-secret
191
+ * mutation ops (`setPluginSecret`, `clearPluginSecret`).
192
+ *
193
+ * When supplied, the mutations are added to the schema and routed to
194
+ * this Lambda. The Lambda receives the plaintext from admin browser,
195
+ * encrypts with the env-var key, and writes ciphertext to the
196
+ * PluginSecret table. Admin/editor Cognito users never touch
197
+ * PluginSecret directly — only the Lambda (via IAM) does.
198
+ *
199
+ * Typed as `unknown` for the same reason as `userAdminFunction`.
200
+ */
201
+ pluginSecretHandlerFunction?: unknown;
151
202
  }
152
203
  /**
153
204
  * The Ampless model + custom query definitions, returned as a plain
@@ -168,6 +219,8 @@ declare function amplessSchemaModels(a: any, opts?: AmplessSchemaModelsOpts): {
168
219
  AdminUser?: any;
169
220
  listAdminUsers?: any;
170
221
  setAdminUserRole?: any;
222
+ setPluginSecret?: any;
223
+ clearPluginSecret?: any;
171
224
  Post: any;
172
225
  Page: any;
173
226
  Media: any;
@@ -175,6 +228,8 @@ declare function amplessSchemaModels(a: any, opts?: AmplessSchemaModelsOpts): {
175
228
  PostTag: any;
176
229
  KvStore: any;
177
230
  McpToken: any;
231
+ PluginSecret: any;
232
+ PluginSecretIndicator: any;
178
233
  PublicPost: any;
179
234
  PublicPostConnection: any;
180
235
  PublicMedia: any;
package/dist/index.js CHANGED
@@ -18,7 +18,8 @@ function defineAmplessBackend(opts) {
18
18
  processorUntrusted: opts.processorUntrusted,
19
19
  apiKeyRenewer: opts.apiKeyRenewer,
20
20
  userAdmin: opts.userAdmin,
21
- mcpHandler: opts.mcpHandler
21
+ mcpHandler: opts.mcpHandler,
22
+ ...opts.pluginSecretHandler ? { pluginSecretHandler: opts.pluginSecretHandler } : {}
22
23
  });
23
24
  const cfnBucket = backend.storage.resources.cfnResources.cfnBucket;
24
25
  cfnBucket.publicAccessBlockConfiguration = {
@@ -79,6 +80,7 @@ function defineAmplessBackend(opts) {
79
80
  "AMPLESS_USER_POOL_ID",
80
81
  backend.auth.resources.userPool.userPoolId
81
82
  );
83
+ const graphqlApi = backend.data.resources.cfnResources.cfnGraphqlApi;
82
84
  const postTable = backend.data.resources.tables["Post"];
83
85
  const cfnPostTable = backend.data.resources.cfnResources.amplifyDynamoDbTables["Post"];
84
86
  cfnPostTable.streamSpecification = { streamViewType: "NEW_AND_OLD_IMAGES" };
@@ -150,13 +152,32 @@ function defineAmplessBackend(opts) {
150
152
  ]
151
153
  })
152
154
  );
155
+ const pluginSecretTable = backend.data.resources.tables["PluginSecret"];
156
+ pluginSecretTable.grantReadData(trustedFn);
157
+ const pluginSecretIndicatorTable = backend.data.resources.tables["PluginSecretIndicator"];
153
158
  trustedFn.addEnvironment("AMPLESS_BUCKET_NAME", backend.storage.resources.bucket.bucketName);
154
159
  trustedFn.addEnvironment("AMPLESS_POST_TABLE", postTable.tableName);
155
160
  trustedFn.addEnvironment("AMPLESS_KV_TABLE", kvTable.tableName);
156
161
  trustedFn.addEnvironment("AMPLESS_POSTTAG_TABLE", postTagTable.tableName);
162
+ trustedFn.addEnvironment("AMPLESS_PLUGIN_SECRET_TABLE", pluginSecretTable.tableName);
163
+ if (opts.pluginSecretEncryptionKey) {
164
+ trustedFn.addEnvironment("PLUGIN_SECRET_ENCRYPTION_KEY", opts.pluginSecretEncryptionKey);
165
+ }
166
+ if (opts.pluginSecretHandler) {
167
+ const secretHandlerFn = backend.pluginSecretHandler.resources.lambda;
168
+ pluginSecretTable.grantReadWriteData(secretHandlerFn);
169
+ pluginSecretIndicatorTable.grantReadWriteData(secretHandlerFn);
170
+ secretHandlerFn.addEnvironment("AMPLESS_PLUGIN_SECRET_TABLE", pluginSecretTable.tableName);
171
+ secretHandlerFn.addEnvironment(
172
+ "AMPLESS_PLUGIN_SECRET_INDICATOR_TABLE",
173
+ pluginSecretIndicatorTable.tableName
174
+ );
175
+ if (opts.pluginSecretEncryptionKey) {
176
+ secretHandlerFn.addEnvironment("PLUGIN_SECRET_ENCRYPTION_KEY", opts.pluginSecretEncryptionKey);
177
+ }
178
+ }
157
179
  const untrustedFn = backend.processorUntrusted.resources.lambda;
158
180
  untrustedFn.addEventSource(new SqsEventSource(untrustedQueue, { batchSize: 5 }));
159
- const graphqlApi = backend.data.resources.cfnResources.cfnGraphqlApi;
160
181
  const apiKeyRenewerFn = backend.apiKeyRenewer.resources.lambda;
161
182
  apiKeyRenewerFn.addEnvironment("APPSYNC_API_ID", graphqlApi.attrApiId);
162
183
  apiKeyRenewerFn.addToRolePolicy(
@@ -183,7 +204,7 @@ function defineAmplessBackend(opts) {
183
204
  mcpHandlerFn.addEnvironment("AMPLESS_MCP_TOKEN_TABLE", mcpTokenTable.tableName);
184
205
  mcpHandlerFn.addEnvironment(
185
206
  "AMPLESS_APPSYNC_URL",
186
- backend.data.resources.cfnResources.cfnGraphqlApi.attrGraphQlUrl
207
+ graphqlApi.attrGraphQlUrl
187
208
  );
188
209
  mcpHandlerFn.addToRolePolicy(
189
210
  new PolicyStatement({
@@ -396,6 +417,90 @@ function amplessSchemaModels(a, opts = {}) {
396
417
  }).identifier(["hash"]).authorization((allow) => [
397
418
  allow.groups(["ampless-admin"])
398
419
  ]),
420
+ // Isolated secret storage for trusted plugins (Phase 6a).
421
+ //
422
+ // This is a COMPLETELY SEPARATE model from KvStore. KvStore grants
423
+ // admin/editor full read access through AppSync, which means any
424
+ // value stored there can be read by anyone with admin/editor
425
+ // credentials. PluginSecret is intentionally inaccessible to
426
+ // admin/editor Cognito users via AppSync — the only way to write
427
+ // a secret is through the `setPluginSecret` / `clearPluginSecret`
428
+ // AppSync mutations which are backed by the plugin-secret-handler
429
+ // Lambda. The Lambda receives the plaintext, reads the encryption
430
+ // key from `process.env.PLUGIN_SECRET_ENCRYPTION_KEY` (injected by
431
+ // CDK at deploy time from `amplify/secrets/encryption-key.ts` — see
432
+ // Phase 6a v2.2 in docs/architecture/08-plugin-architecture.md),
433
+ // and performs the DDB PutItem using its own IAM role. Ciphertext
434
+ // never flows back to the browser.
435
+ //
436
+ // Authorization design:
437
+ // - admin / editor: NO direct AppSync access. All writes go
438
+ // through the plugin-secret-handler Lambda mutation.
439
+ // - IAM (Lambda): full read+write. Both the plugin-secret-handler
440
+ // (writes ciphertext) and the trusted-processor (reads +
441
+ // decrypts) use IAM-signed DDB SDK calls.
442
+ //
443
+ // Storage key convention:
444
+ // siteId = 'default' (single-site architecture)
445
+ // sk = `plugins.${instanceId ?? name}.${fieldKey}`
446
+ //
447
+ // DynamoDB auto-encrypts at rest (AWS-managed KMS key). Secrets
448
+ // never flow to the S3 site-settings mirror because the trusted
449
+ // processor only queries KvStore (pk='siteconfig') for that path —
450
+ // PluginSecret is a structurally separate table.
451
+ PluginSecret: a.model({
452
+ // Single-site architecture: siteId is always 'default'.
453
+ siteId: a.string().required(),
454
+ // Composite sort key: `plugins.<instanceId>.<fieldKey>`
455
+ sk: a.string().required(),
456
+ // The secret value stored as AES-256-GCM ciphertext (base64).
457
+ // Format: base64( IV[12] || ciphertext || authTag[16] ).
458
+ // Encrypted by the plugin-secret-handler Lambda using the key
459
+ // in `process.env.PLUGIN_SECRET_ENCRYPTION_KEY` (set by CDK at
460
+ // deploy time from the `amplify/secrets/encryption-key.ts`
461
+ // constant). Even if an AWS account operator reads this column
462
+ // via the DDB Console they only see ciphertext — the key lives
463
+ // outside DynamoDB. The honest threat model is documented in
464
+ // docs/architecture/08-plugin-architecture.md: defeated for
465
+ // DDB-only browsing, NOT defeated for anyone with source repo
466
+ // / deploy artifact access, NOT defeated for a malicious
467
+ // trusted plugin co-located in the same Lambda.
468
+ value: a.string().required()
469
+ }).identifier(["siteId", "sk"]).authorization((allow) => [
470
+ // IAM only — no Cognito group has any AppSync access.
471
+ // plugin-secret-handler Lambda writes (PutItem / DeleteItem).
472
+ // trusted-processor Lambda reads (GetItem, read-only grant
473
+ // in backend.ts via grantReadData).
474
+ allow.authenticated("iam").to(["read", "create", "update", "delete"])
475
+ ]),
476
+ // Existence-only indicator for PluginSecret rows.
477
+ //
478
+ // Admin/editor Cognito users cannot read from PluginSecret at all
479
+ // (IAM-only authorization above). But the admin UI needs to know
480
+ // whether a secret has been saved so it can show the "stored"
481
+ // indicator (••••••••) vs an empty input. PluginSecretIndicator
482
+ // solves this without ever exposing ciphertext or plaintext to the
483
+ // browser — it stores only the timestamp of the last write.
484
+ //
485
+ // The plugin-secret-handler Lambda writes to both tables atomically
486
+ // (in practice: two sequential DDB puts; true DDB transactions would
487
+ // require TransactWriteItems which adds latency; an extra indicator
488
+ // row is at most stale-indicator-but-no-secret, which degrades
489
+ // gracefully as a false "stored" indicator in the UI).
490
+ PluginSecretIndicator: a.model({
491
+ siteId: a.string().required(),
492
+ // Same sort-key format as PluginSecret:
493
+ // `plugins.${instanceId ?? name}.${fieldKey}`
494
+ sk: a.string().required(),
495
+ // ISO 8601 datetime string — set by plugin-secret-handler on
496
+ // every write. Admin UI may show this as "last updated" hint.
497
+ lastSetAt: a.datetime().required()
498
+ }).identifier(["siteId", "sk"]).authorization((allow) => [
499
+ // Admin/editor can read and write (write needed for clear).
500
+ allow.groups(["ampless-admin", "ampless-editor"]),
501
+ // plugin-secret-handler Lambda also writes via IAM.
502
+ allow.authenticated("iam").to(["read", "create", "update", "delete"])
503
+ ]),
399
504
  // Custom return type for public post reads. Decoupling from `Post` lets
400
505
  // AppSync skip the model-level (admin-only) auth check on fields.
401
506
  //
@@ -507,6 +612,33 @@ function amplessSchemaModels(a, opts = {}) {
507
612
  allow.publicApiKey(),
508
613
  allow.groups(["ampless-admin", "ampless-editor"])
509
614
  ]),
615
+ // Plugin secret mutation ops, only wired when the caller supplies a
616
+ // Lambda function ref. Conditionally spread because
617
+ // `a.handler.function(undefined)` is not a valid call.
618
+ //
619
+ // setPluginSecret: admin browser sends plaintext → Lambda encrypts
620
+ // (AES-256-GCM, env-var key) → DDB PutItem on
621
+ // PluginSecret + PutItem on PluginSecretIndicator.
622
+ // clearPluginSecret: Lambda deletes from both tables.
623
+ //
624
+ // Both ops require Cognito group admin-or-editor — the Lambda still
625
+ // re-checks via the Cognito token in the AppSync event context so
626
+ // a raw IAM call bypassing AppSync cannot skip the group gate.
627
+ ...opts.pluginSecretHandlerFunction ? {
628
+ setPluginSecret: a.mutation().arguments({
629
+ fieldKey: a.string().required(),
630
+ instanceId: a.string().required(),
631
+ value: a.string().required()
632
+ }).returns(a.string()).handler(a.handler.function(opts.pluginSecretHandlerFunction)).authorization((allow) => [
633
+ allow.groups(["ampless-admin", "ampless-editor"])
634
+ ]),
635
+ clearPluginSecret: a.mutation().arguments({
636
+ fieldKey: a.string().required(),
637
+ instanceId: a.string().required()
638
+ }).returns(a.string()).handler(a.handler.function(opts.pluginSecretHandlerFunction)).authorization((allow) => [
639
+ allow.groups(["ampless-admin", "ampless-editor"])
640
+ ])
641
+ } : {},
510
642
  // User management ops, only wired when the caller supplies a
511
643
  // Lambda function ref. Conditionally spread because
512
644
  // `a.handler.function(undefined)` is not a valid call — projects
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ampless/backend",
3
- "version": "1.0.0-alpha.45",
3
+ "version": "1.0.0-alpha.47",
4
4
  "description": "Amplify Gen 2 backend factories for ampless: auth, data, storage, event processors, API key renewer",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -36,6 +36,10 @@
36
36
  "./functions/mcp-handler": {
37
37
  "import": "./dist/functions/mcp-handler.js",
38
38
  "types": "./dist/functions/mcp-handler.d.ts"
39
+ },
40
+ "./functions/plugin-secret-handler": {
41
+ "import": "./dist/functions/plugin-secret-handler.js",
42
+ "types": "./dist/functions/plugin-secret-handler.d.ts"
39
43
  }
40
44
  },
41
45
  "files": [
@@ -65,8 +69,8 @@
65
69
  "@smithy/protocol-http": "^5.4.4",
66
70
  "@smithy/signature-v4": "^5.4.4",
67
71
  "fflate": "^0.8.3",
68
- "ampless": "1.0.0-alpha.28",
69
- "@ampless/mcp-server": "1.0.0-alpha.34"
72
+ "@ampless/mcp-server": "1.0.0-alpha.36",
73
+ "ampless": "1.0.0-alpha.30"
70
74
  },
71
75
  "peerDependencies": {
72
76
  "@aws-amplify/backend": "^1",