@ashlr/stack-core 0.1.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (111) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +26 -15
  3. package/package.json +1 -1
  4. package/src/__tests__/_harness.ts +67 -7
  5. package/src/__tests__/agent-onboard.test.ts +279 -0
  6. package/src/__tests__/api-key-providers.test.ts +6 -2
  7. package/src/__tests__/base-urls.test.ts +208 -0
  8. package/src/__tests__/catalog-index.test.ts +14 -20
  9. package/src/__tests__/catalog.test.ts +31 -0
  10. package/src/__tests__/cloudflare-provider.test.ts +32 -10
  11. package/src/__tests__/completion-ci.test.ts +239 -23
  12. package/src/__tests__/config-own-services.test.ts +183 -0
  13. package/src/__tests__/config-split.test.ts +272 -2
  14. package/src/__tests__/config.test.ts +94 -4
  15. package/src/__tests__/credential-staging.test.ts +660 -0
  16. package/src/__tests__/deps.test.ts +2 -1
  17. package/src/__tests__/detect-source.test.ts +183 -4
  18. package/src/__tests__/fixtures/stack-manifest.v1.json +10 -0
  19. package/src/__tests__/github-provider.test.ts +3 -2
  20. package/src/__tests__/helpers.test.ts +24 -4
  21. package/src/__tests__/http.test.ts +2 -2
  22. package/src/__tests__/import-integration.test.ts +18 -4
  23. package/src/__tests__/inference.test.ts +57 -13
  24. package/src/__tests__/interactive-prompt.test.ts +106 -0
  25. package/src/__tests__/mcp-mirror.test.ts +116 -0
  26. package/src/__tests__/neon-provider.test.ts +136 -3
  27. package/src/__tests__/new-providers.test.ts +470 -0
  28. package/src/__tests__/no-orphan-modules.test.ts +114 -0
  29. package/src/__tests__/oauth-port.test.ts +84 -0
  30. package/src/__tests__/openai-provider.test.ts +1 -1
  31. package/src/__tests__/phantom-contract.test.ts +177 -0
  32. package/src/__tests__/phantom-harness.test.ts +226 -8
  33. package/src/__tests__/phantom-process.test.ts +135 -0
  34. package/src/__tests__/phantom-project-preflight.test.ts +118 -0
  35. package/src/__tests__/phantom-wire.test.ts +403 -22
  36. package/src/__tests__/pipeline-integration.test.ts +33 -1
  37. package/src/__tests__/pipeline-recovery.test.ts +29 -28
  38. package/src/__tests__/pipeline-rollback.test.ts +509 -0
  39. package/src/__tests__/pipeline.test.ts +3 -1
  40. package/src/__tests__/pm-detect.test.ts +113 -0
  41. package/src/__tests__/provider-deprovision.test.ts +269 -0
  42. package/src/__tests__/provider-parity.test.ts +46 -0
  43. package/src/__tests__/publish-script.test.ts +704 -0
  44. package/src/__tests__/recipe.test.ts +27 -2
  45. package/src/__tests__/recommend-apply-e2e.test.ts +29 -27
  46. package/src/__tests__/registry.test.ts +11 -2
  47. package/src/__tests__/release-artifacts.test.ts +55 -0
  48. package/src/__tests__/scan-missing-dir.test.ts +45 -0
  49. package/src/__tests__/secret-redaction.test.ts +10 -10
  50. package/src/__tests__/security.test.ts +9 -10
  51. package/src/__tests__/stack-manifest.test.ts +38 -0
  52. package/src/__tests__/supabase-provider.test.ts +6 -4
  53. package/src/__tests__/swap.test.ts +114 -0
  54. package/src/__tests__/telemetry.test.ts +354 -0
  55. package/src/__tests__/template-litellm-gateway.test.ts +70 -0
  56. package/src/__tests__/template-temporal-worker-phantom.test.ts +45 -0
  57. package/src/__tests__/templates.test.ts +1 -1
  58. package/src/__tests__/turso-provider.test.ts +2 -5
  59. package/src/agent-onboard.ts +346 -0
  60. package/src/ai/catalog-index.ts +68 -10
  61. package/src/ai/circuit-breaker.ts +3 -6
  62. package/src/ai/cost-tracker.ts +6 -15
  63. package/src/ai/inference.ts +19 -11
  64. package/src/ai/phantom-wire.ts +73 -58
  65. package/src/ai/recipe.ts +69 -0
  66. package/src/base-urls.ts +71 -0
  67. package/src/catalog.ts +281 -2
  68. package/src/config.ts +31 -18
  69. package/src/detect-source.ts +50 -9
  70. package/src/detect.ts +13 -5
  71. package/src/errors.ts +6 -0
  72. package/src/http.ts +6 -5
  73. package/src/index.ts +16 -0
  74. package/src/mcp-writer.ts +97 -2
  75. package/src/oauth.ts +85 -53
  76. package/src/phantom-process.ts +72 -0
  77. package/src/phantom.ts +319 -30
  78. package/src/pipeline.ts +177 -68
  79. package/src/pm-detect.ts +56 -0
  80. package/src/providers/_api-key.ts +18 -17
  81. package/src/providers/_base.ts +28 -1
  82. package/src/providers/_credential-stage.ts +237 -0
  83. package/src/providers/_helpers.ts +55 -9
  84. package/src/providers/anthropic.ts +2 -1
  85. package/src/providers/auth0.ts +27 -0
  86. package/src/providers/aws.ts +32 -33
  87. package/src/providers/cloudflare.ts +18 -37
  88. package/src/providers/convex.ts +1 -2
  89. package/src/providers/datadog.ts +29 -0
  90. package/src/providers/digitalocean.ts +30 -0
  91. package/src/providers/gcp.ts +36 -0
  92. package/src/providers/github.ts +37 -35
  93. package/src/providers/grafana.ts +29 -0
  94. package/src/providers/hetzner.ts +29 -0
  95. package/src/providers/index.ts +13 -1
  96. package/src/providers/launchdarkly.ts +33 -0
  97. package/src/providers/mixpanel.ts +37 -0
  98. package/src/providers/neon.ts +50 -26
  99. package/src/providers/openai.ts +2 -1
  100. package/src/providers/plausible.ts +26 -0
  101. package/src/providers/sentry.ts +17 -17
  102. package/src/providers/stripe.ts +1 -2
  103. package/src/providers/supabase.ts +66 -37
  104. package/src/providers/turso.ts +49 -38
  105. package/src/providers/vercel.ts +50 -20
  106. package/src/providers/workos.ts +27 -0
  107. package/src/registry.ts +7 -11
  108. package/src/stack-manifest.ts +39 -0
  109. package/src/swap.ts +108 -0
  110. package/src/telemetry.ts +267 -0
  111. package/src/templates.ts +6 -2
@@ -0,0 +1,118 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
+ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { emptyConfig, readConfig, writeConfig } from "../config.ts";
6
+ import { addSecret } from "../phantom.ts";
7
+ import { addService } from "../pipeline.ts";
8
+ import type { Provider } from "../providers/_base.ts";
9
+ import { providers } from "../providers/index.ts";
10
+ import { type Harness, setupFakePhantom } from "./_harness.ts";
11
+
12
+ describe("project metadata before provider contact", () => {
13
+ let h: Harness;
14
+ let cwd: string;
15
+ const contacts = { login: 0, provision: 0, materialize: 0, deprovision: 0 };
16
+
17
+ beforeEach(async () => {
18
+ h = setupFakePhantom({}, { initialized: false });
19
+ cwd = mkdtempSync(join(tmpdir(), "stack-preflight-"));
20
+ await writeConfig(emptyConfig(), cwd);
21
+ contacts.login = contacts.provision = contacts.materialize = contacts.deprovision = 0;
22
+ const provider: Provider = {
23
+ name: "preflightfixture",
24
+ displayName: "Inert fixture",
25
+ category: "ai",
26
+ authKind: "api_key",
27
+ async login() {
28
+ contacts.login++;
29
+ return { token: "synthetic" };
30
+ },
31
+ async provision() {
32
+ contacts.provision++;
33
+ return { id: "synthetic", displayName: "fixture" };
34
+ },
35
+ async materialize() {
36
+ contacts.materialize++;
37
+ return { secrets: { NEW_KEY: "synthetic" } };
38
+ },
39
+ async deprovision() {
40
+ contacts.deprovision++;
41
+ },
42
+ };
43
+ providers.preflightfixture = async () => provider;
44
+ });
45
+
46
+ afterEach(() => {
47
+ Reflect.deleteProperty(providers, "preflightfixture");
48
+ h.cleanup();
49
+ rmSync(cwd, { recursive: true, force: true });
50
+ });
51
+
52
+ test("uninitialized config refuses login and provisioning without initializing a vault", async () => {
53
+ const before = readFileSync(join(cwd, ".stack.toml"), "utf8");
54
+ await expect(addService({ providerName: "preflightfixture", cwd })).rejects.toMatchObject({
55
+ code: "PHANTOM_PROJECT_NOT_INITIALIZED",
56
+ });
57
+ expect(contacts).toEqual({ login: 0, provision: 0, materialize: 0, deprovision: 0 });
58
+ expect(h.callsTo("status")).toEqual([{ cmd: "status", args: ["--json"] }]);
59
+ expect(h.callsTo("init")).toEqual([]);
60
+ expect(h.callsTo("add")).toEqual([]);
61
+ expect(h.callsTo("list")).toEqual([]);
62
+ expect(readFileSync(join(cwd, ".stack.toml"), "utf8")).toBe(before);
63
+ });
64
+
65
+ test.each([
66
+ "not-json",
67
+ '{"schema_version":1,"inspection":"metadata-only"}',
68
+ '{"schema_version":2,"inspection":"metadata-only","initialized":true}',
69
+ '{"schema_version":1,"inspection":"metadata-only","initialized":"true"}',
70
+ '{"schema_version":1,"inspection":"vault-health","initialized":true}',
71
+ ])("unknown metadata refuses all provider contact (%s)", async (status) => {
72
+ writeFileSync(
73
+ join(h.dir, "phantom"),
74
+ `#!/bin/sh\ncase "$1" in --version) echo fixture;; *) printf '%s' '${status}';; esac\n`,
75
+ );
76
+ await expect(addService({ providerName: "preflightfixture", cwd })).rejects.toMatchObject({
77
+ code: "PHANTOM_METADATA_UNAVAILABLE",
78
+ });
79
+ expect(contacts).toEqual({ login: 0, provision: 0, materialize: 0, deprovision: 0 });
80
+ expect((await readConfig(cwd)).services.preflightfixture).toBeUndefined();
81
+ });
82
+
83
+ test("signal-terminated storage never commits pipeline configuration", async () => {
84
+ writeFileSync(
85
+ join(h.dir, "phantom"),
86
+ `#!/bin/sh
87
+ case "$1" in
88
+ --version) echo fixture;;
89
+ status) echo '{"schema_version":1,"inspection":"metadata-only","initialized":true}';;
90
+ *) kill -TERM $$;;
91
+ esac
92
+ `,
93
+ );
94
+ await expect(addService({ providerName: "preflightfixture", cwd })).rejects.toMatchObject({
95
+ code: "ADD_SERVICE_ROLLED_BACK",
96
+ });
97
+ expect(contacts).toEqual({ login: 1, provision: 1, materialize: 1, deprovision: 1 });
98
+ expect((await readConfig(cwd)).services.preflightfixture).toBeUndefined();
99
+ });
100
+
101
+ test("cached collision output followed by signal cannot commit pipeline configuration", async () => {
102
+ await addSecret("NEW_KEY", "synthetic", cwd);
103
+ writeFileSync(
104
+ join(h.dir, "phantom"),
105
+ `#!/bin/sh
106
+ case "$1" in
107
+ status) echo '{"schema_version":1,"inspection":"metadata-only","initialized":true}';;
108
+ *) echo 'already protected' >&2; kill -TERM $$;;
109
+ esac
110
+ `,
111
+ );
112
+ await expect(addService({ providerName: "preflightfixture", cwd })).rejects.toMatchObject({
113
+ code: "ADD_SERVICE_ROLLED_BACK",
114
+ });
115
+ expect(contacts).toEqual({ login: 1, provision: 1, materialize: 1, deprovision: 1 });
116
+ expect((await readConfig(cwd)).services.preflightfixture).toBeUndefined();
117
+ });
118
+ });
@@ -1,9 +1,14 @@
1
- import { afterEach, beforeEach, describe, expect, it } from "bun:test";
2
- import { existsSync, readFileSync } from "node:fs";
1
+ import { afterEach, beforeEach, describe, expect, it, setDefaultTimeout } from "bun:test";
2
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
3
3
  import { join } from "node:path";
4
4
  import { wirePhantomForRecipe } from "../ai/phantom-wire.ts";
5
- import type { Recipe } from "../ai/recipe.ts";
6
- import { setupFakePhantom, type Harness } from "./_harness.ts";
5
+ import { type Recipe, readRecipe, writeRecipe } from "../ai/recipe.ts";
6
+ import { listSecrets } from "../phantom.ts";
7
+ import { type Harness, setupFakePhantom } from "./_harness.ts";
8
+
9
+ // Every test spawns the fake `phantom` shim (bash + several python3 calls),
10
+ // which takes multiple seconds on loaded machines — well past the 5s default.
11
+ setDefaultTimeout(30_000);
7
12
 
8
13
  describe("ai/phantom-wire", () => {
9
14
  let harness: Harness;
@@ -22,17 +27,16 @@ describe("ai/phantom-wire", () => {
22
27
  providers: [{ name: "stripe" }],
23
28
  };
24
29
 
25
- it("calls `phantom add STRIPE_SECRET_KEY` and writes a webhook stub", async () => {
30
+ it("never writes to the vault, reports the missing secret, and writes a webhook stub", async () => {
26
31
  const result = await wirePhantomForRecipe(STRIPE_RECIPE, { cwd: harness.dir });
27
32
 
28
- expect(result.envelopes).toContain("stripe:STRIPE_SECRET_KEY");
29
-
30
- // Fake phantom treats `--rotate` as an unknown arg and succeeds anyway
31
- // (our fake echoes success for unknown cases via the `*` branch exit 0),
32
- // but we always log the call — so the `add` log should mention the key.
33
- const adds = harness.callsTo("add");
34
- const keys = adds.flatMap((c) => c.args);
35
- expect(keys).toContain("STRIPE_SECRET_KEY");
33
+ // Phantom Secrets refuses values in argv and denies overwriting names, so
34
+ // apply must not try to plant placeholders. It only reads names.
35
+ expect(harness.callsTo("add")).toEqual([]);
36
+ expect(harness.callsTo("list")).toEqual([{ cmd: "list", args: ["--json"] }]);
37
+ expect(JSON.parse(readFileSync(harness.vaultPath, "utf-8"))).toEqual({});
38
+ expect(result.present).toEqual([]);
39
+ expect(result.missing).toContain("stripe:STRIPE_SECRET_KEY");
36
40
 
37
41
  const stubPath = join(harness.dir, ".stack/webhooks/stripe.ts");
38
42
  expect(existsSync(stubPath)).toBe(true);
@@ -42,19 +46,242 @@ describe("ai/phantom-wire", () => {
42
46
  expect(result.webhooks).toHaveLength(1);
43
47
  });
44
48
 
45
- it("noWire: true skips envelopes and webhooks entirely", async () => {
49
+ it("wires an altered local recipe without authenticating its contents", async () => {
50
+ const path = await writeRecipe(STRIPE_RECIPE, harness.dir);
51
+ const original = readFileSync(path, "utf-8");
52
+ expect(original).toContain("stripe");
53
+ writeFileSync(path, original.replace('stripe"', 'github"'));
54
+
55
+ const recipe = await readRecipe(STRIPE_RECIPE.id, harness.dir);
56
+ expect(recipe.providers).toEqual([{ name: "github" }]);
57
+ const result = await wirePhantomForRecipe(recipe, { cwd: harness.dir });
58
+ expect(result.missing).toContain("github:GITHUB_TOKEN");
59
+ expect(result.webhooks).toEqual([join(harness.dir, ".stack/webhooks/github.ts")]);
60
+ });
61
+
62
+ it("generates a commented request handler and response for every webhook provider", async () => {
63
+ const providers = ["stripe", "clerk", "supabase", "github"];
64
+ const recipe: Recipe = {
65
+ ...STRIPE_RECIPE,
66
+ providers: providers.map((name) => ({ name })),
67
+ };
68
+ const result = await wirePhantomForRecipe(recipe, { cwd: harness.dir });
69
+ expect(result.webhooks).toEqual(
70
+ providers.map((provider) => join(harness.dir, ".stack/webhooks", `${provider}.ts`)),
71
+ );
72
+
73
+ for (const [index, provider] of providers.entries()) {
74
+ const stub = readFileSync(result.webhooks[index], "utf-8");
75
+ const handlerName = `handle${provider[0].toUpperCase()}${provider.slice(1)}Webhook`;
76
+ const example = stub.match(/^\/\/ export async function [\s\S]*?^\/\/ }$/m)?.[0];
77
+ expect(example).toBeDefined();
78
+ expect(example).toContain(
79
+ `// export async function ${handlerName}(req: Request): Promise<Response> {`,
80
+ );
81
+ expect(example).toContain('// return new Response("ok", { status: 200 });');
82
+ }
83
+ });
84
+
85
+ it.each([
86
+ ["stripe", "handleStripeWebhook"],
87
+ ["clerk", "handleClerkWebhook"],
88
+ ["supabase", "handleSupabaseWebhook"],
89
+ ["github", "handleGithubWebhook"],
90
+ ])("returns the unsigned %s response without reading the body", async (provider, name) => {
91
+ const recipe: Recipe = { ...STRIPE_RECIPE, providers: [{ name: provider }] };
92
+ const result = await wirePhantomForRecipe(recipe, { cwd: harness.dir });
93
+ const stub = readFileSync(result.webhooks[0], "utf-8");
94
+ const example = stub.match(/^\/\/ export async function [\s\S]*?^\/\/ }$/m)?.[0];
95
+ if (!example) throw new Error(`Missing ${provider} webhook example`);
96
+ const examplePath = join(harness.dir, `${provider}-unsigned-example.ts`);
97
+ writeFileSync(examplePath, example.replace(/^\/\/ ?/gm, ""));
98
+ const handler: unknown = (await import(examplePath))[name];
99
+ if (typeof handler !== "function") throw new Error(`Missing ${name}`);
100
+
101
+ const request = new Request("https://example.com", {
102
+ method: "POST",
103
+ body: "untrusted webhook payload",
104
+ });
105
+ const response: Response = await handler(request);
106
+ expect(response.status).toBe(400);
107
+ expect(await response.text()).toBe("Missing signature");
108
+ expect(request.bodyUsed).toBe(false);
109
+ });
110
+
111
+ it.each([
112
+ ["stripe", "stripe-signature", "handleStripeWebhook"],
113
+ ["clerk", "svix-signature", "handleClerkWebhook"],
114
+ ["supabase", "x-supabase-signature", "handleSupabaseWebhook"],
115
+ ["github", "x-hub-signature-256", "handleGithubWebhook"],
116
+ ])("rejects missing signatures and interrupted %s requests", async (provider, header, name) => {
117
+ const recipe: Recipe = { ...STRIPE_RECIPE, providers: [{ name: provider }] };
118
+ const result = await wirePhantomForRecipe(recipe, { cwd: harness.dir });
119
+ expect(result.webhooks).toHaveLength(1);
120
+
121
+ const stub = readFileSync(result.webhooks[0], "utf-8");
122
+ expect(stub.split("\n").every((line) => line === "" || line.startsWith("//"))).toBe(true);
123
+ expect(stub).toContain("This entire example is commented out and does not verify signatures.");
124
+ expect(stub).toContain(
125
+ "If uncommented as-is, it accepts any non-empty signature header and returns\n" +
126
+ "// 200 without authenticating the request. Implement verification first.",
127
+ );
128
+ const example = stub.match(/^\/\/ export async function [\s\S]*?^\/\/ }$/m)?.[0];
129
+ if (!example) throw new Error(`Missing ${provider} webhook example`);
130
+ expect(example).toContain(
131
+ "// // TODO(security): signature verification is NOT implemented here. The\n" +
132
+ "// // header check above proves only that a signature header arrived; its\n" +
133
+ "// // value is never compared to anything.",
134
+ );
135
+ expect(example).toContain(
136
+ `// // is unauthenticated, attacker-controlled input — anyone who can POST\n// // to this endpoint can forge a ${provider} webhook.`,
137
+ );
138
+ expect(example).toContain(
139
+ "// // constant time; reject missing or invalid signatures, and only then\n" +
140
+ "// // parse, trust, or act on the body.",
141
+ );
142
+ const examplePath = join(harness.dir, `${provider}-example.ts`);
143
+ writeFileSync(examplePath, example.replace(/^\/\/ ?/gm, ""));
144
+ const handler: unknown = (await import(examplePath))[name];
145
+ expect(typeof handler).toBe("function");
146
+ if (typeof handler !== "function") throw new Error(`Missing ${name}`);
147
+
148
+ const missingHeaders: HeadersInit[] = [
149
+ {},
150
+ { "x-unrelated-signature": "present" },
151
+ { [header]: "" },
152
+ ];
153
+ for (const headers of missingHeaders) {
154
+ const response: Response = await handler(new Request("https://example.com", { headers }));
155
+ expect(response.status).toBe(400);
156
+ expect(await response.text()).toBe("Missing signature");
157
+ }
158
+
159
+ // A non-empty header reaches the placeholder; it is not signature verification.
160
+ const response: Response = await handler(
161
+ new Request("https://example.com", {
162
+ method: "POST",
163
+ headers: { [header]: "placeholder" },
164
+ body: "unverified webhook payload",
165
+ }),
166
+ );
167
+ expect(response.status).toBe(200);
168
+
169
+ const networkError = new Error("request body connection lost");
170
+ const interruptedBody = new ReadableStream<Uint8Array>({
171
+ pull(controller) {
172
+ controller.error(networkError);
173
+ },
174
+ });
175
+ const interruptedRequest = new Request("https://example.com", {
176
+ method: "POST",
177
+ headers: { [header]: "placeholder" },
178
+ body: interruptedBody,
179
+ });
180
+ await expect(handler(interruptedRequest)).rejects.toBe(networkError);
181
+ });
182
+
183
+ it.each([
184
+ ["stripe", "stripe-signature", "handleStripeWebhook"],
185
+ ["clerk", "svix-signature", "handleClerkWebhook"],
186
+ ["supabase", "x-supabase-signature", "handleSupabaseWebhook"],
187
+ ["github", "x-hub-signature-256", "handleGithubWebhook"],
188
+ ])(
189
+ "returns the successful %s webhook response after reading the body",
190
+ async (provider, header, name) => {
191
+ const recipe: Recipe = { ...STRIPE_RECIPE, providers: [{ name: provider }] };
192
+ const result = await wirePhantomForRecipe(recipe, { cwd: harness.dir });
193
+ const stub = readFileSync(result.webhooks[0], "utf-8");
194
+ const example = stub.match(/^\/\/ export async function [\s\S]*?^\/\/ }$/m)?.[0];
195
+ if (!example) throw new Error(`Missing ${provider} webhook example`);
196
+ const examplePath = join(harness.dir, `${provider}-success-example.ts`);
197
+ writeFileSync(examplePath, example.replace(/^\/\/ ?/gm, ""));
198
+ const handler: unknown = (await import(examplePath))[name];
199
+ if (typeof handler !== "function") throw new Error(`Missing ${name}`);
200
+
201
+ const request = new Request("https://example.com", {
202
+ method: "POST",
203
+ headers: { [header]: "placeholder" },
204
+ body: "unverified webhook payload",
205
+ });
206
+ const response: Response = await handler(request);
207
+ expect(request.bodyUsed).toBe(true);
208
+ expect(response.status).toBe(200);
209
+ expect(await response.text()).toBe("ok");
210
+ },
211
+ );
212
+
213
+ it.each(["stripe", "clerk", "supabase", "github"])(
214
+ "places the signature verification TODO before trusting the %s webhook body",
215
+ async (provider) => {
216
+ const recipe: Recipe = { ...STRIPE_RECIPE, providers: [{ name: provider }] };
217
+ const result = await wirePhantomForRecipe(recipe, { cwd: harness.dir });
218
+ expect(result.webhooks).toHaveLength(1);
219
+ const stub = readFileSync(result.webhooks[0], "utf-8");
220
+ const example = stub.match(/^\/\/ export async function [\s\S]*?^\/\/ }$/m)?.[0];
221
+ if (!example) throw new Error(`Missing ${provider} webhook example`);
222
+
223
+ // Reading the raw body supplies an input to verification; no executable
224
+ // handling of it should appear before the verification reminder.
225
+ expect(example).toContain(
226
+ "// const body = await req.text();\n" +
227
+ "// // TODO(security): signature verification is NOT implemented here.",
228
+ );
229
+ const todo = example.indexOf("TODO(security): signature verification is NOT implemented");
230
+ const success = example.indexOf('return new Response("ok", { status: 200 })');
231
+ expect(todo).toBeGreaterThanOrEqual(0);
232
+ expect(success).toBeGreaterThan(todo);
233
+ },
234
+ );
235
+
236
+ it.each([
237
+ ["stripe", "stripe.webhooks.constructEvent"],
238
+ ["clerk", "new Webhook(secret).verify(body, headers)"],
239
+ ["supabase", "`x-supabase-signature` per Supabase's documented webhook scheme"],
240
+ ["github", "`sha256=` + HMAC-SHA256"],
241
+ ])(
242
+ "points the %s stub at the provider's verification scheme without claiming it ran",
243
+ async (provider, hint) => {
244
+ const recipe: Recipe = { ...STRIPE_RECIPE, providers: [{ name: provider }] };
245
+ const result = await wirePhantomForRecipe(recipe, { cwd: harness.dir });
246
+ const example = readFileSync(result.webhooks[0], "utf-8").match(
247
+ /^\/\/ export async function [\s\S]*?^\/\/ }$/m,
248
+ )?.[0];
249
+ if (!example) throw new Error(`Missing ${provider} webhook example`);
250
+
251
+ // The TODO names where verification belongs and how the provider does it.
252
+ expect(example).toContain(hint);
253
+
254
+ // No comment may describe the request as already verified, and the
255
+ // uncommented handler must contain no verification code — the TODO's
256
+ // "NOT implemented" claim has to be true, not aspirational.
257
+ expect(example).not.toMatch(
258
+ /signature verified|verified signature|request is authenticated/i,
259
+ );
260
+ const runnable = example
261
+ .replace(/^\/\/ ?/gm, "")
262
+ .split("\n")
263
+ .filter((line) => !line.trimStart().startsWith("//"))
264
+ .join("\n");
265
+ expect(runnable).not.toMatch(/createHmac|timingSafeEqual|\.verify\(/);
266
+ },
267
+ );
268
+
269
+ it("noWire: true skips the vault check and webhooks entirely", async () => {
46
270
  const result = await wirePhantomForRecipe(STRIPE_RECIPE, {
47
271
  cwd: harness.dir,
48
272
  noWire: true,
49
273
  });
50
- expect(result).toEqual({ envelopes: [], webhooks: [], skipped: [] });
274
+ expect(result).toEqual({ present: [], missing: [], webhooks: [], skipped: [] });
51
275
 
52
276
  const stubPath = join(harness.dir, ".stack/webhooks/stripe.ts");
53
277
  expect(existsSync(stubPath)).toBe(false);
54
278
  expect(harness.callsTo("add")).toEqual([]);
279
+ expect(harness.callsTo("list")).toEqual([]);
55
280
  });
56
281
 
57
- it("creates envelopes for every secret on a multi-secret provider", async () => {
282
+ it("splits a multi-secret provider into present and missing", async () => {
283
+ harness.cleanup();
284
+ harness = setupFakePhantom({ SUPABASE_URL: "https://x.supabase.co", SUPABASE_ANON_KEY: "a" });
58
285
  const recipe: Recipe = {
59
286
  id: "supa",
60
287
  query: "database",
@@ -62,15 +289,169 @@ describe("ai/phantom-wire", () => {
62
289
  providers: [{ name: "supabase" }],
63
290
  };
64
291
  const result = await wirePhantomForRecipe(recipe, { cwd: harness.dir });
65
- expect(result.envelopes).toEqual([
66
- "supabase:SUPABASE_URL",
67
- "supabase:SUPABASE_ANON_KEY",
68
- "supabase:SUPABASE_SERVICE_ROLE_KEY",
69
- ]);
292
+ expect(result.present).toEqual(["supabase:SUPABASE_URL", "supabase:SUPABASE_ANON_KEY"]);
293
+ expect(result.missing).toEqual(["supabase:SUPABASE_SERVICE_ROLE_KEY"]);
294
+ expect(result.skipped).toEqual([]);
295
+ expect(harness.callsTo("add")).toEqual([]);
70
296
  // Supabase is webhook-capable in our moat list.
71
297
  expect(result.webhooks).toHaveLength(1);
72
298
  });
73
299
 
300
+ it("skips providers when the vault can't be listed, but still writes stubs", async () => {
301
+ writeFileSync(harness.vaultPath, "not json");
302
+ const result = await wirePhantomForRecipe(STRIPE_RECIPE, { cwd: harness.dir });
303
+ expect(result.skipped).toEqual(["stripe"]);
304
+ expect(result.present).toEqual([]);
305
+ expect(result.missing).toEqual([]);
306
+ expect(result.webhooks).toHaveLength(1);
307
+ expect(harness.callsTo("add")).toEqual([]);
308
+ });
309
+
310
+ it("surfaces a failed inventory command as unavailable and skips providers", async () => {
311
+ const phantomPath = join(harness.dir, "phantom");
312
+ const shim = readFileSync(phantomPath, "utf-8");
313
+ // Even plausible inventory on stdout is not evidence when the command fails.
314
+ expect(shim).toContain(" list)\n");
315
+ writeFileSync(
316
+ phantomPath,
317
+ shim.replace(
318
+ " list)\n",
319
+ ` list)\n echo '[{"name":"STRIPE_SECRET_KEY"}]'\n echo 'inventory unavailable' >&2\n exit 1\n`,
320
+ ),
321
+ );
322
+
323
+ await expect(listSecrets(harness.dir)).rejects.toMatchObject({
324
+ code: "PHANTOM_METADATA_UNAVAILABLE",
325
+ message: "Phantom secret inventory is unavailable; stored names are unknown.",
326
+ });
327
+ const recipe: Recipe = { ...STRIPE_RECIPE, providers: [{ name: "stripe" }, { name: "neon" }] };
328
+ const stubPath = join(harness.dir, ".stack/webhooks/stripe.ts");
329
+ expect(await wirePhantomForRecipe(recipe, { cwd: harness.dir })).toEqual({
330
+ present: [],
331
+ missing: [],
332
+ webhooks: [stubPath],
333
+ skipped: ["stripe", "neon"],
334
+ });
335
+ expect(existsSync(stubPath)).toBe(true);
336
+ expect(harness.callsTo("list")).toEqual([
337
+ { cmd: "list", args: ["--json"] },
338
+ { cmd: "list", args: ["--json"] },
339
+ ]);
340
+ expect(harness.callsTo("add")).toEqual([]);
341
+ expect(JSON.parse(readFileSync(harness.vaultPath, "utf-8"))).toEqual({});
342
+ });
343
+
344
+ it("treats surrounding whitespace in vault inventory JSON the same as compact JSON", async () => {
345
+ const inventory = '[{"name":"STRIPE_SECRET_KEY"}]';
346
+ const setListOutput = (output: string) => {
347
+ writeFileSync(
348
+ join(harness.dir, "phantom"),
349
+ `#!/usr/bin/env bash\ncase "$1" in\n --version) echo "phantom fake 0.0.1" ;;\n list) printf '%s' '${output}' ;;\n *) exit 1 ;;\nesac\n`,
350
+ );
351
+ };
352
+
353
+ setListOutput(inventory);
354
+ const compact = await wirePhantomForRecipe(STRIPE_RECIPE, { cwd: harness.dir });
355
+ expect(compact).toEqual({
356
+ present: ["stripe:STRIPE_SECRET_KEY"],
357
+ missing: [],
358
+ skipped: [],
359
+ webhooks: [join(harness.dir, ".stack/webhooks/stripe.ts")],
360
+ });
361
+
362
+ setListOutput(` \t\r\n${inventory}\n\t `);
363
+ expect(await wirePhantomForRecipe(STRIPE_RECIPE, { cwd: harness.dir })).toEqual(compact);
364
+ });
365
+
366
+ it("rejects malformed vault inventory JSON and keeps the provider's secrets unknown", async () => {
367
+ // A successful command may still return a truncated inventory containing a plausible name.
368
+ writeFileSync(
369
+ join(harness.dir, "phantom"),
370
+ `#!/usr/bin/env bash\ncase "$1" in\n --version) echo "phantom fake 0.0.1" ;;\n list) printf '%s' '[{"name":"STRIPE_SECRET_KEY"}' ;;\n *) exit 1 ;;\nesac\n`,
371
+ );
372
+
373
+ await expect(listSecrets(harness.dir)).rejects.toMatchObject({
374
+ code: "PHANTOM_OUTPUT_UNRECOGNIZED",
375
+ message:
376
+ "phantom list --json did not return JSON; upgrade Phantom Secrets to 0.7.9 or newer.",
377
+ });
378
+ const result = await wirePhantomForRecipe(STRIPE_RECIPE, { cwd: harness.dir });
379
+ expect(result).toEqual({
380
+ present: [],
381
+ missing: [],
382
+ skipped: ["stripe"],
383
+ webhooks: [join(harness.dir, ".stack/webhooks/stripe.ts")],
384
+ });
385
+ expect(existsSync(result.webhooks[0])).toBe(true);
386
+ expect(JSON.parse(readFileSync(harness.vaultPath, "utf-8"))).toEqual({});
387
+ });
388
+
389
+ it("rejects a JSON number from the vault inventory and skips the provider", async () => {
390
+ // Return valid JSON from a successful list command, but not the required array.
391
+ writeFileSync(
392
+ join(harness.dir, "phantom"),
393
+ '#!/usr/bin/env bash\ncase "$1" in\n --version) echo "phantom fake 0.0.1" ;;\n list) echo 42 ;;\n *) exit 1 ;;\nesac\n',
394
+ );
395
+
396
+ await expect(listSecrets(harness.dir)).rejects.toMatchObject({
397
+ code: "PHANTOM_OUTPUT_UNRECOGNIZED",
398
+ message: "phantom list --json did not return an array.",
399
+ });
400
+ const result = await wirePhantomForRecipe(STRIPE_RECIPE, { cwd: harness.dir });
401
+ expect(result).toEqual({
402
+ present: [],
403
+ missing: [],
404
+ skipped: ["stripe"],
405
+ webhooks: [join(harness.dir, ".stack/webhooks/stripe.ts")],
406
+ });
407
+ expect(existsSync(result.webhooks[0])).toBe(true);
408
+ });
409
+
410
+ it.each(["true", "false"])(
411
+ "rejects JSON boolean %s from the vault inventory and skips the provider",
412
+ async (output) => {
413
+ writeFileSync(
414
+ join(harness.dir, "phantom"),
415
+ `#!/usr/bin/env bash\ncase "$1" in\n --version) echo "phantom fake 0.0.1" ;;\n list) echo ${output} ;;\n *) exit 1 ;;\nesac\n`,
416
+ );
417
+
418
+ await expect(listSecrets(harness.dir)).rejects.toMatchObject({
419
+ code: "PHANTOM_OUTPUT_UNRECOGNIZED",
420
+ message: "phantom list --json did not return an array.",
421
+ });
422
+ const result = await wirePhantomForRecipe(STRIPE_RECIPE, { cwd: harness.dir });
423
+ expect(result).toEqual({
424
+ present: [],
425
+ missing: [],
426
+ skipped: ["stripe"],
427
+ webhooks: [join(harness.dir, ".stack/webhooks/stripe.ts")],
428
+ });
429
+ expect(existsSync(result.webhooks[0])).toBe(true);
430
+ expect(harness.callsTo("add")).toEqual([]);
431
+ },
432
+ );
433
+
434
+ it("does not report a JSON string vault response as a verified secret", async () => {
435
+ // A successful command returning the expected name as a scalar is not an inventory.
436
+ writeFileSync(
437
+ join(harness.dir, "phantom"),
438
+ `#!/usr/bin/env bash\ncase "$1" in\n --version) echo "phantom fake 0.0.1" ;;\n list) printf '%s' '"STRIPE_SECRET_KEY"' ;;\n *) exit 1 ;;\nesac\n`,
439
+ );
440
+
441
+ await expect(listSecrets(harness.dir)).rejects.toMatchObject({
442
+ code: "PHANTOM_OUTPUT_UNRECOGNIZED",
443
+ message: "phantom list --json did not return an array.",
444
+ });
445
+ const result = await wirePhantomForRecipe(STRIPE_RECIPE, { cwd: harness.dir });
446
+ expect(result).toEqual({
447
+ present: [],
448
+ missing: [],
449
+ skipped: ["stripe"],
450
+ webhooks: [join(harness.dir, ".stack/webhooks/stripe.ts")],
451
+ });
452
+ expect(existsSync(result.webhooks[0])).toBe(true);
453
+ });
454
+
74
455
  it("skips providers with no webhook stub without crashing", async () => {
75
456
  const recipe: Recipe = {
76
457
  id: "neon",
@@ -80,6 +461,6 @@ describe("ai/phantom-wire", () => {
80
461
  };
81
462
  const result = await wirePhantomForRecipe(recipe, { cwd: harness.dir });
82
463
  expect(result.webhooks).toEqual([]);
83
- expect(result.envelopes.length).toBeGreaterThan(0);
464
+ expect(result.missing.length).toBeGreaterThan(0);
84
465
  });
85
466
  });
@@ -2,6 +2,8 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
2
  import { mkdtempSync } from "node:fs";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
+ import { wirePhantomForRecipe } from "../ai/phantom-wire.ts";
6
+ import { readRecipe, writeRecipe } from "../ai/recipe.ts";
5
7
  import { emptyConfig, writeConfig } from "../config.ts";
6
8
  import { addService } from "../pipeline.ts";
7
9
  import type { Provider } from "../providers/_base.ts";
@@ -90,6 +92,36 @@ describe("addService full loop", () => {
90
92
  const mcp = JSON.parse(mcpRaw) as { mcpServers: Record<string, unknown> };
91
93
  expect(mcp.mcpServers.stubsvc).toBeDefined();
92
94
 
93
- delete providers.stubsvc;
95
+ Reflect.deleteProperty(providers, "stubsvc");
94
96
  });
95
97
  });
98
+
99
+ test("apply wiring preserves an existing value and leaves missing values absent", async () => {
100
+ const initialVault = {
101
+ NEON_API_KEY: "existing-test-value",
102
+ UNRELATED_KEY: "unrelated-test-value",
103
+ };
104
+ const h = setupFakePhantom(initialVault);
105
+ try {
106
+ const recipe = {
107
+ id: "preserve-existing-value",
108
+ query: "database",
109
+ createdAt: "2026-04-18T00:00:00.000Z",
110
+ providers: [{ name: "neon" }],
111
+ };
112
+ await writeRecipe(recipe, h.dir);
113
+ const loaded = await readRecipe(recipe.id, h.dir);
114
+
115
+ // This is the vault-wiring phase used by stack apply after provisioning.
116
+ const result = await wirePhantomForRecipe(loaded, { cwd: h.dir });
117
+ expect(result.present).toEqual(["neon:NEON_API_KEY"]);
118
+ expect(result.missing).toEqual(["neon:DATABASE_URL"]);
119
+ expect(result.skipped).toEqual([]);
120
+ expect(h.callsTo("list")).toEqual([{ cmd: "list", args: ["--json"] }]);
121
+ expect(h.callsTo("add")).toEqual([]);
122
+ expect(h.callsTo("remove")).toEqual([]);
123
+ expect(await readVault(h.dir)).toEqual(initialVault);
124
+ } finally {
125
+ h.cleanup();
126
+ }
127
+ });