create-ampless 1.0.0-alpha.96 → 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 +189 -27
- package/dist/templates/_shared/amplify/backend.ts +8 -0
- package/dist/templates/_shared/amplify/data/resource.ts +2 -0
- package/dist/templates/_shared/amplify/functions/plugin-secret-handler/handler.ts +11 -0
- package/dist/templates/_shared/amplify/functions/plugin-secret-handler/resource.ts +12 -0
- package/dist/templates/_shared/amplify/secrets/.gitkeep +0 -0
- package/dist/templates/_shared/amplify/secrets/encryption-key.ts +9 -0
- package/dist/templates/_shared/docs/plugin-author-guide.ja.md +4 -2
- package/dist/templates/_shared/docs/plugin-author-guide.md +58 -9
- package/dist/templates/_shared/package.json +14 -14
- package/package.json +1 -1
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
|
|
5
|
-
import { existsSync as
|
|
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/
|
|
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
|
-
|
|
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
|
-
|
|
2659
|
-
|
|
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
|
-
|
|
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
|
-
`${
|
|
2837
|
+
`${pc7.green("\u2714")} Project deployed`,
|
|
2680
2838
|
``,
|
|
2681
|
-
` GitHub: ${
|
|
2682
|
-
` Amplify app: ${
|
|
2683
|
-
` Amplify URL: ${
|
|
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: ${
|
|
2844
|
+
lines.push(` Custom domain: ${pc7.cyan(result.domainUrl)}`);
|
|
2687
2845
|
}
|
|
2688
2846
|
if (result.domainVerification && result.domainVerification.length > 0) {
|
|
2689
|
-
lines.push("", ` ${
|
|
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 ${
|
|
2855
|
+
` Watch it at ${pc7.cyan(`https://console.aws.amazon.com/amplify/home#/${result.amplifyAppId}`)}`
|
|
2698
2856
|
);
|
|
2699
|
-
|
|
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
|
-
|
|
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 (
|
|
2735
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2921
|
+
log7.error(err instanceof Error ? err.message : String(err));
|
|
2760
2922
|
process.exit(1);
|
|
2761
2923
|
}
|
|
2762
2924
|
return;
|
|
2763
2925
|
}
|
|
2764
|
-
|
|
2765
|
-
`${
|
|
2926
|
+
outro5(
|
|
2927
|
+
`${pc7.green("\u2714")} Project created at ${pc7.bold(opts.projectName)}
|
|
2766
2928
|
|
|
2767
2929
|
Next steps:
|
|
2768
|
-
${
|
|
2769
|
-
${
|
|
2770
|
-
${
|
|
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
|
+
})
|
|
File without changes
|
|
@@ -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
|
-
-
|
|
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
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
-
|
|
930
|
-
|
|
925
|
+
- Stored in the `PluginSecret` DynamoDB table (separate from KvStore).
|
|
926
|
+
Admin/editor Cognito users have **no direct AppSync access** to this
|
|
927
|
+
table — all 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.
|
|
998
|
-
|
|
999
|
-
|
|
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.
|
|
29
|
-
"@ampless/plugin-cookie-consent": "^0.1.0-alpha.
|
|
30
|
-
"@ampless/plugin-gtm": "^0.2.0-alpha.
|
|
31
|
-
"@ampless/plugin-og-image": "^0.2.0-alpha.
|
|
32
|
-
"@ampless/plugin-plausible": "^0.2.0-alpha.
|
|
33
|
-
"@ampless/plugin-reading-time": "^0.1.0-alpha.
|
|
34
|
-
"@ampless/plugin-rss": "^0.2.0-alpha.
|
|
35
|
-
"@ampless/plugin-schema-jsonld": "^0.1.1-alpha.
|
|
36
|
-
"@ampless/plugin-seo": "^0.2.0-alpha.
|
|
37
|
-
"@ampless/plugin-webhook": "^0.2.0-alpha.
|
|
38
|
-
"@ampless/admin": "^1.0.0-alpha.
|
|
39
|
-
"@ampless/backend": "^1.0.0-alpha.
|
|
40
|
-
"@ampless/runtime": "^1.0.0-alpha.
|
|
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.
|
|
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",
|