create-ampless 1.0.0-alpha.95 → 1.0.0-alpha.98

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/dist/index.js CHANGED
@@ -1,8 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/index.ts
4
- import { spinner as spinner2, outro as outro4, log as log6 } from "@clack/prompts";
5
- import { existsSync as existsSync8 } from "fs";
4
+ import { spinner as spinner2, outro as outro5, log as log7 } from "@clack/prompts";
5
+ import { existsSync as existsSync9 } from "fs";
6
6
  import { basename as basename4, resolve as resolve5 } from "path";
7
7
 
8
8
  // src/prompts.ts
@@ -315,7 +315,9 @@ var BOOLEAN_FLAGS = /* @__PURE__ */ new Set([
315
315
  "-h",
316
316
  // Phase 5 `plugin <name>` mode flags
317
317
  "--local",
318
- "--standalone"
318
+ "--standalone",
319
+ // setup-encryption-key flags
320
+ "--gitignore"
319
321
  ]);
320
322
  function parseDeployArgs(argv) {
321
323
  const out = {
@@ -324,6 +326,8 @@ function parseDeployArgs(argv) {
324
326
  upgrade: false,
325
327
  copyTheme: false,
326
328
  createPlugin: false,
329
+ setupEncryptionKey: false,
330
+ gitignore: false,
327
331
  dryRun: false,
328
332
  noInstall: false,
329
333
  githubPrivate: false,
@@ -383,6 +387,9 @@ function parseDeployArgs(argv) {
383
387
  }
384
388
  out.pluginMode = "standalone";
385
389
  break;
390
+ case "--gitignore":
391
+ out.gitignore = true;
392
+ break;
386
393
  }
387
394
  continue;
388
395
  }
@@ -474,6 +481,10 @@ function parseDeployArgs(argv) {
474
481
  out.upgrade = true;
475
482
  continue;
476
483
  }
484
+ if (raw === "setup-encryption-key" && out.projectName === void 0 && !out.setupEncryptionKey && !out.upgrade && !out.copyTheme && !out.createPlugin) {
485
+ out.setupEncryptionKey = true;
486
+ continue;
487
+ }
477
488
  if (raw === "copy-theme" && out.projectName === void 0 && !out.copyTheme) {
478
489
  out.copyTheme = true;
479
490
  continue;
@@ -510,6 +521,7 @@ Usage:
510
521
  npx create-ampless@latest upgrade [options] # in an existing project dir
511
522
  npx create-ampless@latest copy-theme <src> <dst> # in an existing project dir
512
523
  npx create-ampless@latest plugin <name> [options] # scaffold a plugin (Phase 5)
524
+ npx create-ampless@latest setup-encryption-key [--gitignore] # generate encryption key file
513
525
 
514
526
  Options:
515
527
  --site-name <name> Site display name (default: "My Blog")
@@ -586,6 +598,34 @@ plugin <name>
586
598
  Examples:
587
599
  npx create-ampless@latest plugin site-verification
588
600
  npx create-ampless@latest plugin @ishinao/ampless-plugin-foo --standalone
601
+
602
+ setup-encryption-key
603
+ Generate the AES-256-GCM encryption key for plugin secret storage
604
+ and write it to amplify/secrets/encryption-key.ts (adjacent to
605
+ amplify/backend.ts). No AWS credentials required \u2014 this is a
606
+ local file operation only.
607
+
608
+ After generating, import the constant in amplify/backend.ts and
609
+ pass it to defineAmplessBackend({ pluginSecretEncryptionKey: ... }).
610
+ Then redeploy (or restart the sandbox) to inject the key into the
611
+ Lambda env vars.
612
+
613
+ --gitignore Add amplify/secrets/encryption-key.ts to
614
+ .gitignore. Default: file is committed
615
+ (safe for private repos). Use this flag only
616
+ for public repos; distribute the key separately.
617
+
618
+ Threat model:
619
+ \u2713 DDB Console operator sees ciphertext only
620
+ \u26A0 Source repo / deploy artifact access defeats encryption
621
+ \u2717 Malicious trusted plugin in the same Lambda can also read
622
+ the key (per-plugin Lambda isolation = roadmap)
623
+
624
+ Rotation: re-run with confirm overwrite. Existing ciphertext
625
+ becomes unreadable; re-save each secret via /admin/plugins.
626
+
627
+ Example: npx create-ampless@latest setup-encryption-key
628
+ Example: npx create-ampless@latest setup-encryption-key --gitignore
589
629
  `;
590
630
 
591
631
  // src/deploy-prompts.ts
@@ -1909,7 +1949,7 @@ var PROTECTED_PATTERNS = [
1909
1949
  // plugin" doc lives in `packages/ampless/docs/plugin-author-guide.md`.
1910
1950
  /^plugins(\/|$)/
1911
1951
  ];
1912
- var SEED_IF_MISSING_PATTERN = /\.custom\.ts$|^plugins\/README(\.ja)?\.md$/;
1952
+ var SEED_IF_MISSING_PATTERN = /\.custom\.ts$|^plugins\/README(\.ja)?\.md$|^amplify\/secrets\/encryption-key\.ts$/;
1913
1953
  var TEXT_EXTENSIONS2 = /* @__PURE__ */ new Set([
1914
1954
  ".json",
1915
1955
  ".md",
@@ -2620,8 +2660,126 @@ async function runCreatePlugin(args) {
2620
2660
  }
2621
2661
  }
2622
2662
 
2623
- // src/index.ts
2663
+ // src/setup-encryption-key.ts
2664
+ import { writeFile as writeFile6, mkdir as mkdir4, readFile as readFile6 } from "fs/promises";
2665
+ import { existsSync as existsSync8 } from "fs";
2666
+ import { join as join6 } from "path";
2667
+ import { randomBytes } from "crypto";
2668
+ import { log as log6, confirm as confirm3, outro as outro4, cancel as cancel4 } from "@clack/prompts";
2624
2669
  import pc6 from "picocolors";
2670
+ var SECRETS_DIR = "amplify/secrets";
2671
+ var KEY_FILE = "encryption-key.ts";
2672
+ var KEY_FILE_PATH = `${SECRETS_DIR}/${KEY_FILE}`;
2673
+ function buildFileContent(keyB64) {
2674
+ return `// Generated by \`npx create-ampless setup-encryption-key\`.
2675
+ // AES-256-GCM key (32 bytes, base64) for plugin secret storage.
2676
+ // Rotate via the same CLI command. Existing ciphertext becomes unreadable on rotation.
2677
+
2678
+ export const PLUGIN_SECRET_ENCRYPTION_KEY = '${keyB64}'
2679
+ `;
2680
+ }
2681
+ function extractKeyValue(source) {
2682
+ const match = source.match(
2683
+ /\bPLUGIN_SECRET_ENCRYPTION_KEY\b\s*(?::\s*[^=]+)?=\s*(['"])(.*?)\1/
2684
+ );
2685
+ return match?.[2] ?? null;
2686
+ }
2687
+ function isEmptyPlaceholder(source) {
2688
+ return extractKeyValue(source) === "";
2689
+ }
2690
+ async function appendGitignoreEntry(cwd, entry) {
2691
+ const gitignorePath = join6(cwd, ".gitignore");
2692
+ if (existsSync8(gitignorePath)) {
2693
+ const content = await readFile6(gitignorePath, "utf-8");
2694
+ if (content.includes(entry)) return;
2695
+ await writeFile6(gitignorePath, content + (content.endsWith("\n") ? "" : "\n") + entry + "\n");
2696
+ } else {
2697
+ await writeFile6(gitignorePath, entry + "\n");
2698
+ }
2699
+ }
2700
+ async function runSetupEncryptionKey(args) {
2701
+ const cwd = process.cwd();
2702
+ if (!existsSync8(join6(cwd, "amplify"))) {
2703
+ log6.error(
2704
+ `No \`amplify/\` directory found in ${cwd}.
2705
+ Run this command from the root of an ampless project (where amplify/backend.ts lives).`
2706
+ );
2707
+ process.exit(1);
2708
+ }
2709
+ const keyFilePath = join6(cwd, KEY_FILE_PATH);
2710
+ if (existsSync8(keyFilePath)) {
2711
+ const existingContent = await readFile6(keyFilePath, "utf-8");
2712
+ if (!isEmptyPlaceholder(existingContent)) {
2713
+ log6.warn(
2714
+ pc6.yellow(
2715
+ `${KEY_FILE_PATH} already exists.
2716
+ Overwriting it will make all existing plugin secrets unreadable
2717
+ until each secret is re-saved through the admin UI.`
2718
+ )
2719
+ );
2720
+ const confirmed = await confirm3({
2721
+ message: "Overwrite the existing encryption key?",
2722
+ initialValue: false
2723
+ });
2724
+ if (confirmed === false || confirmed === void 0) {
2725
+ cancel4("Cancelled \u2014 existing key preserved.");
2726
+ return;
2727
+ }
2728
+ }
2729
+ }
2730
+ const keyBytes = randomBytes(32);
2731
+ const keyB64 = keyBytes.toString("base64");
2732
+ const secretsDir = join6(cwd, SECRETS_DIR);
2733
+ if (!existsSync8(secretsDir)) {
2734
+ await mkdir4(secretsDir, { recursive: true });
2735
+ }
2736
+ try {
2737
+ await writeFile6(keyFilePath, buildFileContent(keyB64), "utf-8");
2738
+ } catch (err) {
2739
+ log6.error(
2740
+ `Failed to write ${KEY_FILE_PATH}: ${err instanceof Error ? err.message : String(err)}`
2741
+ );
2742
+ process.exit(1);
2743
+ }
2744
+ if (args.gitignore) {
2745
+ try {
2746
+ await appendGitignoreEntry(cwd, KEY_FILE_PATH);
2747
+ log6.info(`Added ${KEY_FILE_PATH} to .gitignore`);
2748
+ } catch (err) {
2749
+ log6.warn(
2750
+ `Could not update .gitignore: ${err instanceof Error ? err.message : String(err)}
2751
+ Add \`${KEY_FILE_PATH}\` to your .gitignore manually.`
2752
+ );
2753
+ }
2754
+ }
2755
+ outro4(
2756
+ `${pc6.green("\u2714")} Generated ${pc6.cyan(KEY_FILE_PATH)}
2757
+
2758
+ ${pc6.bold("Next steps:")}
2759
+ 1. In \`amplify/backend.ts\`, add:
2760
+ import { PLUGIN_SECRET_ENCRYPTION_KEY } from './secrets/encryption-key.js'
2761
+ defineAmplessBackend({ ..., pluginSecretEncryptionKey: PLUGIN_SECRET_ENCRYPTION_KEY })
2762
+ 2. Redeploy (or restart the sandbox) to inject the key into Lambda env vars.
2763
+ 3. Save plugin secrets via the admin UI \u2014 they are now encrypted at rest.
2764
+
2765
+ ${pc6.bold("Threat model summary:")}
2766
+ ${pc6.green("\u2713")} AWS Console operator browsing DDB sees ciphertext only
2767
+ ${pc6.yellow("\u26A0")} Anyone with source repo / deploy artifact access can decrypt
2768
+ ${pc6.red("\u2717")} Malicious trusted plugin in the same Lambda can also decrypt
2769
+ (per-plugin Lambda isolation = roadmap)
2770
+
2771
+ ` + (args.gitignore ? ` ${pc6.dim(`${KEY_FILE_PATH} added to .gitignore.`)}
2772
+
2773
+ ` : ` ${pc6.yellow("Tip:")} If committing to a PUBLIC repo, run with --gitignore instead
2774
+ and distribute the key separately.
2775
+
2776
+ `) + ` ${pc6.yellow("Rotation:")} Re-run this command and confirm overwrite.
2777
+ Existing ciphertext will be unreadable; re-save via /admin/plugins.`
2778
+ );
2779
+ }
2780
+
2781
+ // src/index.ts
2782
+ import pc7 from "picocolors";
2625
2783
  var DEFAULT_THEMES = VALID_THEMES;
2626
2784
  function buildNonInteractiveOpts(args) {
2627
2785
  const projectName = args.projectName ?? (() => {
@@ -2646,7 +2804,7 @@ function warnIgnoredScaffoldFlags(args) {
2646
2804
  if (args.plugins) ignored.push("--plugins");
2647
2805
  if (args.projectName) ignored.push("<project-name> positional");
2648
2806
  if (ignored.length > 0) {
2649
- log6.warn(`--mount mode ignores: ${ignored.join(", ")}`);
2807
+ log7.warn(`--mount mode ignores: ${ignored.join(", ")}`);
2650
2808
  }
2651
2809
  }
2652
2810
  async function runMount(args) {
@@ -2655,8 +2813,8 @@ async function runMount(args) {
2655
2813
  warnIgnoredScaffoldFlags(args);
2656
2814
  const problem = validateMountableProject(destDir);
2657
2815
  if (problem) {
2658
- log6.error(problem);
2659
- log6.info(
2816
+ log7.error(problem);
2817
+ log7.info(
2660
2818
  "Run `npx create-ampless@latest <name>` first to scaffold, or `cd` into a scaffolded project before passing --mount."
2661
2819
  );
2662
2820
  process.exit(1);
@@ -2670,23 +2828,23 @@ async function runMount(args) {
2670
2828
  if (err instanceof PreflightError) {
2671
2829
  process.exit(1);
2672
2830
  }
2673
- log6.error(err instanceof Error ? err.message : String(err));
2831
+ log7.error(err instanceof Error ? err.message : String(err));
2674
2832
  process.exit(1);
2675
2833
  }
2676
2834
  }
2677
2835
  function printDeployResult(result) {
2678
2836
  const lines = [
2679
- `${pc6.green("\u2714")} Project deployed`,
2837
+ `${pc7.green("\u2714")} Project deployed`,
2680
2838
  ``,
2681
- ` GitHub: ${pc6.cyan(result.githubRepoUrl)}`,
2682
- ` Amplify app: ${pc6.cyan(result.amplifyAppId)}`,
2683
- ` Amplify URL: ${pc6.cyan(result.amplifyAppUrl)}`
2839
+ ` GitHub: ${pc7.cyan(result.githubRepoUrl)}`,
2840
+ ` Amplify app: ${pc7.cyan(result.amplifyAppId)}`,
2841
+ ` Amplify URL: ${pc7.cyan(result.amplifyAppUrl)}`
2684
2842
  ];
2685
2843
  if (result.domainUrl) {
2686
- lines.push(` Custom domain: ${pc6.cyan(result.domainUrl)}`);
2844
+ lines.push(` Custom domain: ${pc7.cyan(result.domainUrl)}`);
2687
2845
  }
2688
2846
  if (result.domainVerification && result.domainVerification.length > 0) {
2689
- lines.push("", ` ${pc6.bold("Add these DNS records to verify the domain:")}`);
2847
+ lines.push("", ` ${pc7.bold("Add these DNS records to verify the domain:")}`);
2690
2848
  for (const v of result.domainVerification) {
2691
2849
  lines.push(` ${v.cname} CNAME ${v.target}`);
2692
2850
  }
@@ -2694,9 +2852,9 @@ function printDeployResult(result) {
2694
2852
  lines.push(
2695
2853
  "",
2696
2854
  ` First build is now running in Amplify Hosting.`,
2697
- ` Watch it at ${pc6.cyan(`https://console.aws.amazon.com/amplify/home#/${result.amplifyAppId}`)}`
2855
+ ` Watch it at ${pc7.cyan(`https://console.aws.amazon.com/amplify/home#/${result.amplifyAppId}`)}`
2698
2856
  );
2699
- outro4(lines.join("\n"));
2857
+ outro5(lines.join("\n"));
2700
2858
  }
2701
2859
  async function main() {
2702
2860
  const args = parseDeployArgs(process.argv.slice(2));
@@ -2705,7 +2863,7 @@ async function main() {
2705
2863
  return;
2706
2864
  }
2707
2865
  for (const flag of args.unknown) {
2708
- log6.warn(`Unknown argument ignored: ${flag}`);
2866
+ log7.warn(`Unknown argument ignored: ${flag}`);
2709
2867
  }
2710
2868
  if (args.upgrade) {
2711
2869
  await runUpgrade(args);
@@ -2719,6 +2877,10 @@ async function main() {
2719
2877
  await runCreatePlugin(args);
2720
2878
  return;
2721
2879
  }
2880
+ if (args.setupEncryptionKey) {
2881
+ await runSetupEncryptionKey(args);
2882
+ return;
2883
+ }
2722
2884
  if (args.mount) {
2723
2885
  await runMount(args);
2724
2886
  return;
@@ -2731,8 +2893,8 @@ async function main() {
2731
2893
  }
2732
2894
  if (!opts) return;
2733
2895
  const destDir = resolve5(process.cwd(), opts.projectName);
2734
- if (existsSync8(destDir)) {
2735
- log6.error(`Directory already exists: ${destDir}`);
2896
+ if (existsSync9(destDir)) {
2897
+ log7.error(`Directory already exists: ${destDir}`);
2736
2898
  process.exit(1);
2737
2899
  }
2738
2900
  const sharedDir = sharedTemplateDir();
@@ -2743,7 +2905,7 @@ async function main() {
2743
2905
  s.stop("Done!");
2744
2906
  } catch (err) {
2745
2907
  s.stop("Failed.");
2746
- log6.error(String(err));
2908
+ log7.error(String(err));
2747
2909
  process.exit(1);
2748
2910
  }
2749
2911
  if (args.deploy) {
@@ -2756,18 +2918,18 @@ async function main() {
2756
2918
  if (err instanceof PreflightError) {
2757
2919
  process.exit(1);
2758
2920
  }
2759
- log6.error(err instanceof Error ? err.message : String(err));
2921
+ log7.error(err instanceof Error ? err.message : String(err));
2760
2922
  process.exit(1);
2761
2923
  }
2762
2924
  return;
2763
2925
  }
2764
- outro4(
2765
- `${pc6.green("\u2714")} Project created at ${pc6.bold(opts.projectName)}
2926
+ outro5(
2927
+ `${pc7.green("\u2714")} Project created at ${pc7.bold(opts.projectName)}
2766
2928
 
2767
2929
  Next steps:
2768
- ${pc6.cyan("cd")} ${opts.projectName}
2769
- ${pc6.cyan("npm install")}
2770
- ${pc6.cyan("npm run sandbox")} ${pc6.dim("# deploy sandbox + start Next.js")}`
2930
+ ${pc7.cyan("cd")} ${opts.projectName}
2931
+ ${pc7.cyan("npm install")}
2932
+ ${pc7.cyan("npm run sandbox")} ${pc7.dim("# deploy sandbox + start Next.js")}`
2771
2933
  );
2772
2934
  }
2773
2935
  main().catch((err) => {
@@ -10,7 +10,13 @@ import { processorUntrusted } from './events/processor-untrusted/resource.js'
10
10
  import { apiKeyRenewer } from './functions/api-key-renewer/resource.js'
11
11
  import { mcpHandler } from './functions/mcp-handler/resource.js'
12
12
  import { userAdmin } from './functions/user-admin/resource.js'
13
+ import { pluginSecretHandler } from './functions/plugin-secret-handler/resource.js'
13
14
  import { customizeBackend } from './backend.custom.js'
15
+ // Plugin secret encryption key (Phase 6a v2.2).
16
+ // Generate with: npx create-ampless setup-encryption-key
17
+ // The key lives in amplify/secrets/encryption-key.ts (gitignore it for
18
+ // public repos; safe to commit for private repos).
19
+ import { PLUGIN_SECRET_ENCRYPTION_KEY } from './secrets/encryption-key.js'
14
20
 
15
21
  // `defineAmplessBackend` provisions auth, data, storage, the event
16
22
  // system (DynamoDB Streams → SQS-trusted / SQS-untrusted → trust_level
@@ -30,6 +36,8 @@ const backend = defineAmplessBackend({
30
36
  apiKeyRenewer,
31
37
  mcpHandler,
32
38
  userAdmin,
39
+ pluginSecretHandler,
40
+ pluginSecretEncryptionKey: PLUGIN_SECRET_ENCRYPTION_KEY,
33
41
  })
34
42
 
35
43
  // Run user-defined customizations after baseline wiring. `backend.custom.ts`
@@ -8,6 +8,7 @@ import {
8
8
  } from '@ampless/backend'
9
9
  import { userAdmin } from '../functions/user-admin/resource.js'
10
10
  import { mcpHandler } from '../functions/mcp-handler/resource.js'
11
+ import { pluginSecretHandler } from '../functions/plugin-secret-handler/resource.js'
11
12
  import { customSchemaModels } from './resource.custom.js'
12
13
 
13
14
  // AppSync's `a.handler.custom({ entry })` paths are resolved by CDK
@@ -38,6 +39,7 @@ const schema = a
38
39
  ...amplessSchemaModels(a, {
39
40
  resolverPaths,
40
41
  userAdminFunction: userAdmin,
42
+ pluginSecretHandlerFunction: pluginSecretHandler,
41
43
  }),
42
44
  ...customSchemaModels(a),
43
45
  })
@@ -0,0 +1,11 @@
1
+ // Re-exported from @ampless/backend so the package can ship Lambda
2
+ // handler updates via `npm update`. Amplify's esbuild follows this
3
+ // import and bundles the real handler into the Lambda artifact.
4
+ //
5
+ // The handler validates Cognito group membership (admin/editor),
6
+ // enforces a hard 10,000-char server cap on the value (field-level
7
+ // `pattern` / `maxLength` come from the admin client and are NOT
8
+ // re-enforced here), encrypts with the AES-256-GCM key from
9
+ // `process.env.PLUGIN_SECRET_ENCRYPTION_KEY`, and dual-writes to
10
+ // PluginSecret + PluginSecretIndicator.
11
+ export { handler } from '@ampless/backend/functions/plugin-secret-handler'
@@ -0,0 +1,12 @@
1
+ import { defineFunction } from '@aws-amplify/backend'
2
+
3
+ // `resourceGroupName: 'data'` co-locates this Lambda with the AppSync
4
+ // stack. Required because `setPluginSecret` / `clearPluginSecret` are
5
+ // wired as custom GraphQL resolvers (data → function), matching the
6
+ // same circular-dependency avoidance used for user-admin.
7
+ export const pluginSecretHandler = defineFunction({
8
+ name: 'plugin-secret-handler',
9
+ entry: './handler.ts',
10
+ runtime: 22,
11
+ resourceGroupName: 'data',
12
+ })
@@ -0,0 +1,9 @@
1
+ // Generated by `npx create-ampless setup-encryption-key`.
2
+ // AES-256-GCM key (32 bytes, base64) for plugin secret storage.
3
+ // Rotate via the same CLI command. Existing ciphertext becomes unreadable on rotation.
4
+ //
5
+ // IMPORTANT: This is a placeholder. Replace the value below with a real key
6
+ // by running: npx create-ampless setup-encryption-key
7
+ // (from your project root, after scaffolding).
8
+
9
+ export const PLUGIN_SECRET_ENCRYPTION_KEY = ''
@@ -708,10 +708,12 @@ Secret settings を使うと、trusted プラグインが認証情報 (Webhook
708
708
 
709
709
  詳しいリファレンスは [`packages/ampless/docs/plugin-author-guide.md`](../../packages/ampless/docs/plugin-author-guide.md) §9a を参照してください。概要:
710
710
 
711
- - `settings.secret` は `trust_level: 'trusted'` + `'secretSettings'` capability 必須。
711
+ - `settings.secret` は `trust_level: 'trusted'` + `'secretSettings'` capability 必須。鍵の初回セットアップ: `npx create-ampless setup-encryption-key` を実行して `amplify/secrets/encryption-key.ts` を生成し、`defineAmplessBackend({ pluginSecretEncryptionKey })` に渡してデプロイ(AWS 認証情報不要)。
712
712
  - フィールド型は `PluginSecretField` = `Omit<PluginTextField, 'default'> | Omit<PluginTextareaField, 'default'>`。`default` は型レベルで禁止。fallback は closure-private 変数で保持。
713
- - hook 内で `await ctx.secret<string>('signingSecret')` で読む。per-invocation キャッシュ済み。
713
+ - admin ブラウザは AppSync mutation (`setPluginSecret`) 経由で書き込む → `plugin-secret-handler` Lambda が env var から鍵取得・AES-256-GCM 暗号化・DDB PutItem。平文は DDB に保存されずブラウザにも返らない。`ctx.secret<T>(key)` は trusted Lambda が復号した平文を返す(`process.env.PLUGIN_SECRET_ENCRYPTION_KEY` を使用; DDB に鍵は保存しない)。per-invocation でキャッシュ。
714
714
  - admin UI が「保存済み」表示 (`••••••••`) + Replace + Clear を提供。値は取得・表示されない。
715
+ - **Dual-write 整合性**: set/clear は 2 テーブルに連続して書く。2 回目失敗時 — set パス部分失敗は「secret 機能する・indicator 不在(UI: 未保存誤表示)」; clear パス部分失敗は「secret 削除済・indicator stale(UI: 保存済み誤表示、secret は発火しない安全側)」。
716
+ - **field manifest 検証の範囲**: admin クライアントは UX フィードバック用に `pattern` / `maxLength` / `required` を検証するが、Lambda は **汎用 10,000 文字キャップと安全文字サニタイザのみ**を強制。admin/editor が AppSync mutation を直接呼ぶと field 単位の制約は迂回可能 — admin/editor は信頼されたオペレータと扱う設計のため、manifest チェックはセキュリティ境界ではなく UX ガイダンス。
715
717
 
716
718
  ---
717
719
 
@@ -922,16 +922,39 @@ etc. — but completely wrong for a webhook signing secret.
922
922
 
923
923
  `settings.secret` has a structurally different storage model:
924
924
 
925
- - Stored in the `PluginSecret` DynamoDB model (separate from KvStore).
926
- - Admin/editor groups can **write and delete** but have **no read
927
- authorization**the AppSync schema does not generate
928
- `getPluginSecret` or `listPluginSecrets` queries for those groups.
929
- - Only the trusted-processor Lambda IAM role can read, via DDB
930
- `GetItem` directly.
925
+ - Stored in the `PluginSecret` DynamoDB table (separate from KvStore).
926
+ Admin/editor Cognito users have **no direct AppSync access** to this
927
+ tableall writes go through the `setPluginSecret` mutation backed
928
+ by the `plugin-secret-handler` Lambda.
929
+ - Values are **AES-256-GCM encrypted** before reaching DynamoDB.
930
+ The admin browser sends the plaintext to the Lambda via AppSync
931
+ (TLS); the Lambda validates, reads the 32-byte encryption key from
932
+ `process.env.PLUGIN_SECRET_ENCRYPTION_KEY` (injected by CDK from
933
+ `amplify/secrets/encryption-key.ts`), and writes only the ciphertext.
934
+ The plaintext never rests in DynamoDB and never flows back to the browser.
935
+ - **Threat model (Phase 6a v2.2)**:
936
+
937
+ | Threat | Status |
938
+ |---|---|
939
+ | AWS Console operator browsing PluginSecret table | ✓ defeated — ciphertext only, no key in DDB |
940
+ | Source repo / deploy artifact access | ⚠ NOT defeated — key is in `amplify/secrets/encryption-key.ts`. Private repo + restricted artifact access expected. |
941
+ | Malicious trusted plugin in same Lambda | ✗ NOT defeated — `process.env.PLUGIN_SECRET_ENCRYPTION_KEY` reachable from plugin code. True isolation = per-plugin Lambda (privileged tier, roadmap). |
942
+ | S3 mirror leak | ✓ defeated — PluginSecret table never mirrored. |
943
+
944
+ - The trusted-processor Lambda decrypts with `node:crypto` on read.
945
+ `ctx.secret<T>(key)` returns the plaintext string, never ciphertext.
931
946
  - Never queried by the site-settings mirror path.
932
947
  - Never passed to any public-render surface
933
948
  (`publicHead`, `publicBodyEnd`, `publicBodyForPost`,
934
949
  `publicHtmlForPost`) — those surfaces only see `ctx.setting()`.
950
+ - **Field-manifest validation scope**: the admin client validates
951
+ `pattern` / `maxLength` / `required` for UX feedback, but the
952
+ Lambda only enforces a generic 10,000-character hard cap +
953
+ safe-character sanitizer. An admin/editor calling the AppSync
954
+ mutation directly can bypass field-level constraints — by design,
955
+ since admin/editor are trusted operators authorised to set
956
+ secrets. The manifest checks are UX guidance, not a security
957
+ boundary.
935
958
 
936
959
  ### Requirements
937
960
 
@@ -943,6 +966,29 @@ etc. — but completely wrong for a webhook signing secret.
943
966
  2. `'secretSettings'` in `capabilities` — required so admin UI and
944
967
  future allow-lists can gate the capability. Omitting it produces
945
968
  a console warning (same pattern as `'schema'` vs `publicBodyForPost`).
969
+ 3. **One-time key setup** — run from your project root:
970
+ ```sh
971
+ npx create-ampless setup-encryption-key
972
+ ```
973
+ Generates a cryptographically random 32-byte key and writes it to
974
+ `amplify/secrets/encryption-key.ts`. No AWS credentials required.
975
+ Then import it in `amplify/backend.ts` and pass it to
976
+ `defineAmplessBackend({ pluginSecretEncryptionKey })`. Redeploy (or
977
+ restart the sandbox) to inject the key into Lambda env vars.
978
+ For public repos, add `--gitignore` to exclude from version control.
979
+
980
+ ### Dual-write integrity
981
+
982
+ The `setPluginSecret` and `clearPluginSecret` operations each write to
983
+ **two DynamoDB tables** in sequence. If the second write fails, the
984
+ tables are left in a predictable state:
985
+
986
+ | Failure | `PluginSecret` | `PluginSecretIndicator` | `ctx.secret()` | `hasPluginSecret()` |
987
+ |---|---|---|---|---|
988
+ | **set**: indicator PutItem fails | ciphertext present | absent | returns plaintext ✓ | `false` (UI: "not saved") |
989
+ | **clear**: indicator DeleteItem fails | absent | stale | `undefined` ✓ | `true` (UI: "saved") |
990
+
991
+ The clear-path failure is the "safe side": the secret stops firing.
946
992
 
947
993
  ### Declaring secret fields
948
994
 
@@ -994,9 +1040,12 @@ fallback variable instead; never put credentials in the manifest.
994
1040
  ### Reading secrets: `ctx.secret<T>(key)`
995
1041
 
996
1042
  `ctx.secret<T>(key)` is only available in trusted hook handlers.
997
- Returns `undefined` when no value has been saved by admin. Results
998
- are per-invocation cached; cache keys are namespaced by
999
- `${instanceId ?? name}:${fieldKey}`.
1043
+ Returns `undefined` when no value has been saved by admin. Returns
1044
+ the **decrypted plaintext** never the ciphertext blob.
1045
+ Results are per-invocation cached (plaintext, not ciphertext).
1046
+ The encryption key is decoded from the Lambda env var at cold-start
1047
+ (no extra DDB fetch).
1048
+ Cache keys are namespaced by `${instanceId ?? name}:${fieldKey}`.
1000
1049
 
1001
1050
  ### Admin UI
1002
1051
 
@@ -25,21 +25,21 @@
25
25
  "@tiptap/pm": "^3.23.6",
26
26
  "@tiptap/react": "^3.23.6",
27
27
  "@tiptap/starter-kit": "^3.23.6",
28
- "@ampless/plugin-analytics-ga4": "^0.2.0-alpha.13",
29
- "@ampless/plugin-cookie-consent": "^0.1.0-alpha.4",
30
- "@ampless/plugin-gtm": "^0.2.0-alpha.12",
31
- "@ampless/plugin-og-image": "^0.2.0-alpha.29",
32
- "@ampless/plugin-plausible": "^0.2.0-alpha.12",
33
- "@ampless/plugin-reading-time": "^0.1.0-alpha.2",
34
- "@ampless/plugin-rss": "^0.2.0-alpha.29",
35
- "@ampless/plugin-schema-jsonld": "^0.1.1-alpha.8",
36
- "@ampless/plugin-seo": "^0.2.0-alpha.29",
37
- "@ampless/plugin-webhook": "^0.2.0-alpha.29",
38
- "@ampless/admin": "^1.0.0-alpha.58",
39
- "@ampless/backend": "^1.0.0-alpha.46",
40
- "@ampless/runtime": "^1.0.0-alpha.37",
28
+ "@ampless/plugin-analytics-ga4": "^0.2.0-alpha.14",
29
+ "@ampless/plugin-cookie-consent": "^0.1.0-alpha.5",
30
+ "@ampless/plugin-gtm": "^0.2.0-alpha.13",
31
+ "@ampless/plugin-og-image": "^0.2.0-alpha.30",
32
+ "@ampless/plugin-plausible": "^0.2.0-alpha.13",
33
+ "@ampless/plugin-reading-time": "^0.1.0-alpha.3",
34
+ "@ampless/plugin-rss": "^0.2.0-alpha.30",
35
+ "@ampless/plugin-schema-jsonld": "^0.1.1-alpha.9",
36
+ "@ampless/plugin-seo": "^0.2.0-alpha.30",
37
+ "@ampless/plugin-webhook": "^0.2.0-alpha.31",
38
+ "@ampless/admin": "^1.0.0-alpha.59",
39
+ "@ampless/backend": "^1.0.0-alpha.47",
40
+ "@ampless/runtime": "^1.0.0-alpha.38",
41
41
  "@digital-go-jp/tailwind-theme-plugin": "^0.3.4",
42
- "ampless": "^1.0.0-alpha.29",
42
+ "ampless": "^1.0.0-alpha.30",
43
43
  "aws-amplify": "^6.17.0",
44
44
  "class-variance-authority": "^0.7.1",
45
45
  "clsx": "^2.1.1",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-ampless",
3
- "version": "1.0.0-alpha.95",
3
+ "version": "1.0.0-alpha.98",
4
4
  "description": "Create a new ampless project",
5
5
  "license": "MIT",
6
6
  "type": "module",