@ashlr/stack-core 0.1.1 → 0.2.2

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 (101) 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 +217 -0
  6. package/src/__tests__/api-key-providers.test.ts +6 -2
  7. package/src/__tests__/base-urls.test.ts +162 -0
  8. package/src/__tests__/catalog-index.test.ts +14 -20
  9. package/src/__tests__/cloudflare-provider.test.ts +9 -10
  10. package/src/__tests__/completion-ci.test.ts +239 -23
  11. package/src/__tests__/config-own-services.test.ts +183 -0
  12. package/src/__tests__/config-split.test.ts +206 -2
  13. package/src/__tests__/config.test.ts +2 -1
  14. package/src/__tests__/credential-staging.test.ts +659 -0
  15. package/src/__tests__/deps.test.ts +2 -1
  16. package/src/__tests__/detect-source.test.ts +1 -1
  17. package/src/__tests__/github-provider.test.ts +3 -2
  18. package/src/__tests__/helpers.test.ts +24 -4
  19. package/src/__tests__/http.test.ts +2 -2
  20. package/src/__tests__/inference.test.ts +19 -13
  21. package/src/__tests__/interactive-prompt.test.ts +106 -0
  22. package/src/__tests__/mcp-mirror.test.ts +116 -0
  23. package/src/__tests__/neon-provider.test.ts +136 -3
  24. package/src/__tests__/new-providers.test.ts +470 -0
  25. package/src/__tests__/oauth-port.test.ts +84 -0
  26. package/src/__tests__/openai-provider.test.ts +1 -1
  27. package/src/__tests__/phantom-contract.test.ts +177 -0
  28. package/src/__tests__/phantom-harness.test.ts +125 -8
  29. package/src/__tests__/phantom-project-preflight.test.ts +118 -0
  30. package/src/__tests__/phantom-wire.test.ts +31 -20
  31. package/src/__tests__/pipeline-integration.test.ts +1 -1
  32. package/src/__tests__/pipeline-recovery.test.ts +29 -28
  33. package/src/__tests__/pipeline-rollback.test.ts +509 -0
  34. package/src/__tests__/pipeline.test.ts +3 -1
  35. package/src/__tests__/pm-detect.test.ts +113 -0
  36. package/src/__tests__/provider-deprovision.test.ts +269 -0
  37. package/src/__tests__/provider-parity.test.ts +46 -0
  38. package/src/__tests__/publish-script.test.ts +659 -0
  39. package/src/__tests__/recipe.test.ts +4 -2
  40. package/src/__tests__/recommend-apply-e2e.test.ts +14 -27
  41. package/src/__tests__/registry.test.ts +11 -2
  42. package/src/__tests__/release-artifacts.test.ts +55 -0
  43. package/src/__tests__/secret-redaction.test.ts +10 -10
  44. package/src/__tests__/security.test.ts +9 -10
  45. package/src/__tests__/supabase-provider.test.ts +6 -4
  46. package/src/__tests__/swap.test.ts +114 -0
  47. package/src/__tests__/telemetry.test.ts +354 -0
  48. package/src/__tests__/template-litellm-gateway.test.ts +41 -0
  49. package/src/__tests__/template-temporal-worker-phantom.test.ts +31 -0
  50. package/src/__tests__/templates.test.ts +1 -1
  51. package/src/__tests__/turso-provider.test.ts +2 -5
  52. package/src/agent-onboard.ts +346 -0
  53. package/src/ai/catalog-index.ts +68 -10
  54. package/src/ai/circuit-breaker.ts +3 -6
  55. package/src/ai/cost-tracker.ts +6 -15
  56. package/src/ai/inference.ts +19 -11
  57. package/src/ai/phantom-wire.ts +29 -52
  58. package/src/ai/recipe.ts +69 -0
  59. package/src/base-urls.ts +71 -0
  60. package/src/catalog.ts +281 -2
  61. package/src/config.ts +31 -18
  62. package/src/detect-source.ts +36 -8
  63. package/src/detect.ts +13 -5
  64. package/src/http.ts +6 -5
  65. package/src/index.ts +15 -0
  66. package/src/mcp-writer.ts +97 -2
  67. package/src/oauth.ts +85 -53
  68. package/src/phantom.ts +193 -15
  69. package/src/pipeline.ts +177 -68
  70. package/src/pm-detect.ts +56 -0
  71. package/src/providers/_api-key.ts +18 -17
  72. package/src/providers/_base.ts +28 -1
  73. package/src/providers/_credential-stage.ts +237 -0
  74. package/src/providers/_helpers.ts +55 -9
  75. package/src/providers/anthropic.ts +2 -1
  76. package/src/providers/auth0.ts +27 -0
  77. package/src/providers/aws.ts +32 -33
  78. package/src/providers/cloudflare.ts +18 -37
  79. package/src/providers/convex.ts +1 -2
  80. package/src/providers/datadog.ts +29 -0
  81. package/src/providers/digitalocean.ts +30 -0
  82. package/src/providers/gcp.ts +36 -0
  83. package/src/providers/github.ts +37 -35
  84. package/src/providers/grafana.ts +29 -0
  85. package/src/providers/hetzner.ts +29 -0
  86. package/src/providers/index.ts +13 -1
  87. package/src/providers/launchdarkly.ts +33 -0
  88. package/src/providers/mixpanel.ts +37 -0
  89. package/src/providers/neon.ts +50 -26
  90. package/src/providers/openai.ts +2 -1
  91. package/src/providers/plausible.ts +26 -0
  92. package/src/providers/sentry.ts +17 -17
  93. package/src/providers/stripe.ts +1 -2
  94. package/src/providers/supabase.ts +66 -37
  95. package/src/providers/turso.ts +49 -38
  96. package/src/providers/vercel.ts +50 -20
  97. package/src/providers/workos.ts +27 -0
  98. package/src/registry.ts +7 -11
  99. package/src/swap.ts +108 -0
  100. package/src/telemetry.ts +267 -0
  101. package/src/templates.ts +6 -2
@@ -0,0 +1,659 @@
1
+ import { afterEach, beforeEach, describe, expect, spyOn, 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 * as oauth from "../oauth.ts";
7
+ import { addSecret } from "../phantom.ts";
8
+ import { addService } from "../pipeline.ts";
9
+ import { makeApiKeyProvider } from "../providers/_api-key.ts";
10
+ import type { Provider, ProviderContext } from "../providers/_base.ts";
11
+ import {
12
+ acceptVerifiedExisting,
13
+ beginCredentialStage,
14
+ carryResourceCredential,
15
+ closeCredentialStage,
16
+ credentialPhase,
17
+ ordinaryCredentialPlan,
18
+ readProviderSecret,
19
+ storeProviderSecret,
20
+ } from "../providers/_credential-stage.ts";
21
+ import aws from "../providers/aws.ts";
22
+ import { providers } from "../providers/index.ts";
23
+ import supabase from "../providers/supabase.ts";
24
+ import { type Harness, readVault, setupFakePhantom } from "./_harness.ts";
25
+
26
+ describe("operation-owned credential persistence", () => {
27
+ let h: Harness;
28
+ let cwd: string;
29
+ let realFetch: typeof fetch;
30
+ beforeEach(async () => {
31
+ realFetch = globalThis.fetch;
32
+ globalThis.fetch = Object.assign(
33
+ async () => {
34
+ throw new Error("unexpected network in inert fixture");
35
+ },
36
+ {
37
+ preconnect() {
38
+ throw new Error("inert preconnect refused");
39
+ },
40
+ },
41
+ );
42
+ h = setupFakePhantom();
43
+ cwd = mkdtempSync(join(tmpdir(), "stack-staging-"));
44
+ await writeConfig(emptyConfig("inert"), cwd);
45
+ });
46
+ afterEach(() => {
47
+ globalThis.fetch = realFetch;
48
+ Reflect.deleteProperty(providers, "stagefixture");
49
+ h.cleanup();
50
+ rmSync(cwd, { recursive: true, force: true });
51
+ });
52
+ function factory(): Provider {
53
+ return makeApiKeyProvider({
54
+ name: "stagefixture",
55
+ displayName: "Stage Fixture",
56
+ category: "ai",
57
+ docs: "https://example.invalid",
58
+ secretName: "FIXTURE_KEY",
59
+ howTo: "synthetic",
60
+ async verify(key) {
61
+ return key === "synthetic-key" ? { id: "inert" } : undefined;
62
+ },
63
+ });
64
+ }
65
+ function run(provider = factory()) {
66
+ providers.stagefixture = async () => provider;
67
+ return addService({
68
+ providerName: "stagefixture",
69
+ cwd,
70
+ interactive: true,
71
+ prompt: async () => "synthetic-key",
72
+ });
73
+ }
74
+ test("factory login/materialize/pipeline produce exactly one create per unique key", async () => {
75
+ const result = await run();
76
+ expect(result.secretCount).toBe(1);
77
+ expect(h.callsTo("add")).toEqual([{ cmd: "add", args: ["FIXTURE_KEY", "--stdin"] }]);
78
+ expect(await readVault(h.dir)).toEqual({ FIXTURE_KEY: "synthetic-key" });
79
+ });
80
+ test("failed provision saves no new verified login credential", async () => {
81
+ const p = factory();
82
+ p.provision = async () => {
83
+ throw new Error("inert provision failed");
84
+ };
85
+ await expect(run(p)).rejects.toThrow("inert provision failed");
86
+ expect(h.callsTo("add")).toEqual([]);
87
+ expect(await readVault(h.dir)).toEqual({});
88
+ expect((await readConfig(cwd)).services.stagefixture).toBeUndefined();
89
+ });
90
+ test("conflicting login/materialized values refuse before any ordinary create", async () => {
91
+ const p = factory();
92
+ p.materialize = async () => ({ secrets: { OTHER_KEY: "other", FIXTURE_KEY: "conflict" } });
93
+ await expect(run(p)).rejects.toThrow();
94
+ expect(h.callsTo("add")).toEqual([]);
95
+ expect(await readVault(h.dir)).toEqual({});
96
+ });
97
+ test("all ordinary values are checked for CR/LF before the first create", async () => {
98
+ const p = factory();
99
+ p.materialize = async () => ({ secrets: { FIRST_KEY: "first", BAD_KEY: "one\ntwo" } });
100
+ await expect(run(p)).rejects.toThrow();
101
+ expect(h.callsTo("add")).toEqual([]);
102
+ expect(await readVault(h.dir)).toEqual({});
103
+ });
104
+ test("a completed identical historical collision always refuses", async () => {
105
+ await addSecret("HISTORY_KEY", "same", cwd);
106
+ writeFileSync(h.vaultPath, JSON.stringify({ HISTORY_KEY: "foreign" }));
107
+ await expect(addSecret("HISTORY_KEY", "same", cwd)).rejects.toMatchObject({
108
+ code: "PHANTOM_SECRET_EXISTS",
109
+ });
110
+ expect(await readVault(h.dir)).toEqual({ HISTORY_KEY: "foreign" });
111
+ });
112
+ test("direct verified login remains immediate and refuses a later collision", async () => {
113
+ const ctx = { cwd, interactive: true, log: () => {}, prompt: async () => "synthetic-key" };
114
+ await factory().login(ctx);
115
+ expect(await readVault(h.dir)).toEqual({ FIXTURE_KEY: "synthetic-key" });
116
+ await expect(factory().login(ctx)).rejects.toMatchObject({ code: "PHANTOM_SECRET_EXISTS" });
117
+ expect(h.callsTo("add")).toHaveLength(2);
118
+ });
119
+ function syntheticFetch(
120
+ handler: (url: string, init?: RequestInit) => Response | Promise<Response>,
121
+ ) {
122
+ globalThis.fetch = Object.assign(
123
+ async (url: string | URL | Request, init?: RequestInit) => handler(String(url), init),
124
+ {
125
+ preconnect() {
126
+ throw new Error("inert preconnect refused");
127
+ },
128
+ },
129
+ );
130
+ }
131
+ function ctx(): ProviderContext {
132
+ return { cwd, interactive: true, log: () => {}, prompt: async () => "synthetic-key" };
133
+ }
134
+ function assertNoConfig() {
135
+ expect(readFileSync(join(cwd, ".stack.local.toml"), "utf8")).not.toContain("stagefixture");
136
+ }
137
+
138
+ test("AWS stages both verified keys and region, creating each once", async () => {
139
+ let verified = 0;
140
+ syntheticFetch((url) => {
141
+ expect(url).toBe("https://sts.amazonaws.com/");
142
+ verified++;
143
+ return new Response(
144
+ "<GetCallerIdentityResponse><Arn>inert-arn</Arn><Account>inert-account</Account></GetCallerIdentityResponse>",
145
+ );
146
+ });
147
+ const result = await addService({
148
+ providerName: "aws",
149
+ cwd,
150
+ interactive: true,
151
+ prompt: async ({ message }) =>
152
+ message === "AWS_ACCESS_KEY_ID" ? "synthetic-id" : "synthetic-secret",
153
+ });
154
+ expect(verified).toBe(1);
155
+ expect(result.entry.secrets).toEqual([
156
+ "AWS_ACCESS_KEY_ID",
157
+ "AWS_SECRET_ACCESS_KEY",
158
+ "AWS_REGION",
159
+ ]);
160
+ expect(h.callsTo("add").map((c) => c.args)).toEqual([
161
+ ["AWS_ACCESS_KEY_ID", "--stdin"],
162
+ ["AWS_SECRET_ACCESS_KEY", "--stdin"],
163
+ ["AWS_REGION", "--stdin"],
164
+ ]);
165
+ });
166
+ test("freshly verified cached factory credential is never created or rolled back", async () => {
167
+ h.cleanup();
168
+ h = setupFakePhantom({ FIXTURE_KEY: "synthetic-key" }, { legacyHeadlessReveal: true });
169
+ let verifies = 0;
170
+ const p = makeApiKeyProvider({
171
+ name: "stagefixture",
172
+ displayName: "Fixture",
173
+ category: "ai",
174
+ docs: "inert",
175
+ secretName: "FIXTURE_KEY",
176
+ howTo: "inert",
177
+ async verify() {
178
+ verifies++;
179
+ return { id: "inert" };
180
+ },
181
+ });
182
+ p.materialize = async (context, resource, auth) => {
183
+ await storeProviderSecret(context, "FIXTURE_KEY", auth.token);
184
+ return {
185
+ secrets: { FIXTURE_KEY: auth.token, NEW_SLOT: "new" },
186
+ mcp: { name: "inert", type: "stdio", command: "echo" },
187
+ };
188
+ };
189
+ writeFileSync(join(cwd, ".mcp.json"), "malformed");
190
+ await expect(run(p)).rejects.toThrow();
191
+ expect(verifies).toBe(1);
192
+ expect(h.callsTo("add").map((c) => c.args[0])).toEqual(["NEW_SLOT"]);
193
+ expect(h.callsTo("remove").map((c) => c.args[0])).toEqual(["NEW_SLOT"]);
194
+ expect((await readVault(h.dir)).FIXTURE_KEY).toBe("synthetic-key");
195
+ });
196
+ test("unreadable existing factory credential still attempts strict create and refuses", async () => {
197
+ writeFileSync(h.vaultPath, JSON.stringify({ FIXTURE_KEY: "synthetic-key" }));
198
+ const p = factory();
199
+ p.deprovision = async () => {};
200
+ const error = await run(p).catch((e: Error) => e);
201
+ expect(error).toBeInstanceOf(Error);
202
+ expect((error as Error).message).toContain("already exists");
203
+ expect(h.callsTo("add")).toHaveLength(1);
204
+ expect(h.callsTo("remove")).toEqual([]);
205
+ assertNoConfig();
206
+ });
207
+ test("invalid cached input cannot authorize omission even when newly pasted value matches", async () => {
208
+ h.cleanup();
209
+ h = setupFakePhantom({ FIXTURE_KEY: "synthetic-key" }, { legacyHeadlessReveal: true });
210
+ let verifies = 0;
211
+ const p = makeApiKeyProvider({
212
+ name: "stagefixture",
213
+ displayName: "Fixture",
214
+ category: "ai",
215
+ docs: "inert",
216
+ secretName: "FIXTURE_KEY",
217
+ howTo: "inert",
218
+ async verify() {
219
+ verifies++;
220
+ return verifies === 1 ? undefined : { id: "inert" };
221
+ },
222
+ });
223
+ p.deprovision = async () => {};
224
+ await expect(run(p)).rejects.toThrow("already exists");
225
+ expect(verifies).toBe(2);
226
+ expect(h.callsTo("add")).toHaveLength(1);
227
+ });
228
+ test("cached AWS marks both keys only after fresh STS verification", async () => {
229
+ h.cleanup();
230
+ h = setupFakePhantom(
231
+ { AWS_ACCESS_KEY_ID: "synthetic-id", AWS_SECRET_ACCESS_KEY: "synthetic-secret" },
232
+ { legacyHeadlessReveal: true },
233
+ );
234
+ let verifies = 0;
235
+ syntheticFetch(() => {
236
+ verifies++;
237
+ return new Response("<Arn>inert</Arn><Account>inert</Account>");
238
+ });
239
+ await addService({ providerName: "aws", cwd, interactive: false });
240
+ expect(verifies).toBe(1);
241
+ expect(h.callsTo("add").map((c) => c.args[0])).toEqual(["AWS_REGION"]);
242
+ });
243
+ test("reads/writes stay at captured absolute cwd despite ctx and opts mutation", async () => {
244
+ // Add cwd metadata to the inert producer log, never secret input values.
245
+ const bin = join(h.dir, "phantom");
246
+ writeFileSync(
247
+ bin,
248
+ readFileSync(bin, "utf8")
249
+ .replace("import json,sys; open", "import json,sys,os; open")
250
+ .replace('{"cmd": sys.argv[2]', '{"cwd": os.getcwd(), "cmd": sys.argv[2]'),
251
+ );
252
+ const opts = { providerName: "stagefixture", cwd, interactive: true };
253
+ const p = factory();
254
+ const originalLogin = p.login;
255
+ // Preserve the exact source-created context; copying it would intentionally
256
+ // be standalone, so mutate only after the real login has staged its input.
257
+ p.login = async (context) => {
258
+ context.prompt = async () => "synthetic-key";
259
+ context.cwd = tmpdir();
260
+ const auth = await originalLogin(context);
261
+ opts.cwd = tmpdir();
262
+ return auth;
263
+ };
264
+ providers.stagefixture = async () => p;
265
+ await addService(opts);
266
+ const rows = readFileSync(h.logPath, "utf8")
267
+ .trim()
268
+ .split("\n")
269
+ .map((line) => JSON.parse(line));
270
+ for (const row of rows.filter((r) => ["status", "reveal", "add"].includes(r.cmd)))
271
+ expect(row.cwd).toBe(cwd);
272
+ expect((await readConfig(cwd)).services.stagefixture).toBeDefined();
273
+ });
274
+ for (const fail of ["login", "provision", "materialize", "success"] as const) {
275
+ test(`retained context is closed after ${fail}; no standalone persistence reopens`, async () => {
276
+ let retained: ProviderContext | undefined;
277
+ const p = factory();
278
+ const originalLogin = p.login;
279
+ p.login = async (context) => {
280
+ retained = context;
281
+ const auth = await originalLogin(context);
282
+ if (fail === "login") throw new Error("inert login failure");
283
+ return auth;
284
+ };
285
+ if (fail === "provision")
286
+ p.provision = async () => {
287
+ throw new Error("inert provision failure");
288
+ };
289
+ if (fail === "materialize")
290
+ p.materialize = async () => {
291
+ throw new Error("inert materialize failure");
292
+ };
293
+ await run(p).catch(() => {});
294
+ expect(retained).toBeDefined();
295
+ const before = h.callsTo("add").length;
296
+ await expect(storeProviderSecret(retained!, "LATE_KEY", "late")).rejects.toMatchObject({
297
+ code: "CREDENTIAL_SCOPE_CLOSED",
298
+ });
299
+ expect(() => beginCredentialStage(retained!)).toThrow();
300
+ expect(h.callsTo("add")).toHaveLength(before);
301
+ expect((await readVault(h.dir)).LATE_KEY).toBeUndefined();
302
+ });
303
+ }
304
+ test("two contexts isolate inputs, existing candidates and captured projects", async () => {
305
+ h.cleanup();
306
+ h = setupFakePhantom({ FIXTURE_KEY: "synthetic-key" }, { legacyHeadlessReveal: true });
307
+ const a = ctx();
308
+ const b = { ...ctx(), cwd: tmpdir() };
309
+ beginCredentialStage(a);
310
+ beginCredentialStage(b);
311
+ try {
312
+ const value = await readProviderSecret(a, "FIXTURE_KEY");
313
+ expect(() => acceptVerifiedExisting(b, "FIXTURE_KEY", value)).toThrow();
314
+ acceptVerifiedExisting(a, "FIXTURE_KEY", value);
315
+ await storeProviderSecret(b, "FIXTURE_KEY", "other");
316
+ expect(ordinaryCredentialPlan(a, { FIXTURE_KEY: value })).toEqual([]);
317
+ expect(ordinaryCredentialPlan(b, { FIXTURE_KEY: "other" })).toEqual([
318
+ ["FIXTURE_KEY", "other"],
319
+ ]);
320
+ expect(h.callsTo("add")).toEqual([]);
321
+ } finally {
322
+ closeCredentialStage(a);
323
+ closeCredentialStage(b);
324
+ }
325
+ });
326
+ test("read alone and late-phase mark never authorize current credential omission", async () => {
327
+ h.cleanup();
328
+ h = setupFakePhantom({ FIXTURE_KEY: "synthetic-key" }, { legacyHeadlessReveal: true });
329
+ const context = ctx();
330
+ beginCredentialStage(context);
331
+ try {
332
+ const value = await readProviderSecret(context, "FIXTURE_KEY");
333
+ expect(ordinaryCredentialPlan(context, { FIXTURE_KEY: value })).toEqual([
334
+ ["FIXTURE_KEY", value],
335
+ ]);
336
+ credentialPhase(context, "materialize");
337
+ expect(() => acceptVerifiedExisting(context, "FIXTURE_KEY", value)).toThrow();
338
+ expect(ordinaryCredentialPlan(context, { FIXTURE_KEY: value })).toEqual([
339
+ ["FIXTURE_KEY", value],
340
+ ]);
341
+ } finally {
342
+ closeCredentialStage(context);
343
+ }
344
+ });
345
+ test("materialized value different from freshly accepted cached input conflicts before writes", async () => {
346
+ h.cleanup();
347
+ h = setupFakePhantom({ FIXTURE_KEY: "synthetic-key" }, { legacyHeadlessReveal: true });
348
+ const p = factory();
349
+ p.materialize = async () => ({ secrets: { FIRST_KEY: "first", FIXTURE_KEY: "different" } });
350
+ p.deprovision = async () => {};
351
+ await expect(run(p)).rejects.toThrow("inputs disagree");
352
+ expect(h.callsTo("add")).toEqual([]);
353
+ expect(h.callsTo("remove")).toEqual([]);
354
+ });
355
+ test("auth-only credentials follow unique materialized keys, entry/count remain materialized only", async () => {
356
+ const p = factory();
357
+ p.materialize = async () => ({ secrets: { RESOURCE_KEY: "resource" } });
358
+ const result = await run(p);
359
+ expect(h.callsTo("add").map((c) => c.args[0])).toEqual(["RESOURCE_KEY", "FIXTURE_KEY"]);
360
+ expect(result.secretCount).toBe(1);
361
+ expect(result.entry.secrets).toEqual(["RESOURCE_KEY"]);
362
+ });
363
+ test("MCP/config failure tracks actual new auth-only creates and keeps truthful headless note", async () => {
364
+ const p = factory();
365
+ p.materialize = async () => ({
366
+ secrets: { RESOURCE_KEY: "resource" },
367
+ mcp: { name: "inert", command: "echo" },
368
+ });
369
+ writeFileSync(join(cwd, ".mcp.json"), "malformed");
370
+ const error = await run(p).catch((e: Error) => e);
371
+ expect((error as Error).message).toContain('step "MCP write"');
372
+ expect((error as Error).message).toContain("Phantom kept RESOURCE_KEY, FIXTURE_KEY");
373
+ expect(h.callsTo("remove").map((c) => c.args[0])).toEqual(["RESOURCE_KEY", "FIXTURE_KEY"]);
374
+ assertNoConfig();
375
+ });
376
+ test("host failure after success cannot reuse the ended context to replay storage", async () => {
377
+ let retained: ProviderContext | undefined;
378
+ const p = factory();
379
+ const login = p.login;
380
+ p.login = async (context) => {
381
+ retained = context;
382
+ return login(context);
383
+ };
384
+ await run(p);
385
+ const count = h.callsTo("add").length;
386
+ await Promise.reject(new Error("synthetic host failure after success")).catch(async () => {
387
+ await expect(
388
+ storeProviderSecret(retained!, "FIXTURE_KEY", "synthetic-key"),
389
+ ).rejects.toMatchObject({ code: "CREDENTIAL_SCOPE_CLOSED" });
390
+ });
391
+ expect(h.callsTo("add")).toHaveLength(count);
392
+ expect((await readConfig(cwd)).services.stagefixture).toBeDefined();
393
+ });
394
+ function supabaseFixture(
395
+ options: { materializeFails?: boolean; teardownFails?: boolean; attach?: boolean } = {},
396
+ ) {
397
+ const events: string[] = [];
398
+ let password = "";
399
+ syntheticFetch((url, init) => {
400
+ const method = init?.method ?? "GET";
401
+ if (url.endsWith("/profile")) {
402
+ events.push("verify");
403
+ return Response.json({ id: "inert-user" });
404
+ }
405
+ if (url.endsWith("/organizations")) {
406
+ events.push("organizations");
407
+ return Response.json([{ id: "inert-org", name: "inert" }]);
408
+ }
409
+ if (url.endsWith("/projects") && method === "POST") {
410
+ events.push("POST");
411
+ password = JSON.parse(String(init?.body)).db_pass;
412
+ expect(h.callsTo("add")).toEqual([]);
413
+ return Response.json({
414
+ id: "inert-project",
415
+ name: "inert",
416
+ region: "us-east-1",
417
+ organization_id: "inert-org",
418
+ });
419
+ }
420
+ if (url.endsWith("/api-keys")) {
421
+ events.push("materialize");
422
+ const vault = JSON.parse(readFileSync(h.vaultPath, "utf8"));
423
+ if (!options.attach)
424
+ expect(
425
+ vault.SUPABASE_DB_PASSWORD_inert_project ?? vault["SUPABASE_DB_PASSWORD_inert-project"],
426
+ ).toBe(password);
427
+ expect(vault.STACK_SUPABASE_ACCESS_TOKEN).toBeUndefined();
428
+ if (options.materializeFails) return new Response("inert", { status: 400 });
429
+ return Response.json([
430
+ { name: "anon", api_key: "inert-anon" },
431
+ { name: "service_role", api_key: "inert-role" },
432
+ ]);
433
+ }
434
+ if (url.endsWith("/projects/inert-project") && method === "DELETE") {
435
+ events.push("DELETE");
436
+ if (options.teardownFails) throw new Error("inert transport failure");
437
+ return new Response("", { status: 200 });
438
+ }
439
+ if (url.endsWith("/projects/inert-project") && options.attach)
440
+ return Response.json({
441
+ id: "inert-project",
442
+ name: "inert",
443
+ region: "us-east-1",
444
+ organization_id: "inert-org",
445
+ });
446
+ throw new Error("unexpected synthetic Supabase request");
447
+ });
448
+ return { events, password: () => password };
449
+ }
450
+ async function runSupabase(existingResourceId?: string) {
451
+ return addService({
452
+ providerName: "supabase",
453
+ cwd,
454
+ interactive: true,
455
+ existingResourceId,
456
+ prompt: async () => "synthetic-supabase-token",
457
+ });
458
+ }
459
+ test("Supabase generated password is private and created once before materialize", async () => {
460
+ const f = supabaseFixture();
461
+ const result = await runSupabase();
462
+ expect(f.events).toEqual(["verify", "organizations", "POST", "materialize"]);
463
+ expect(h.callsTo("add").map((c) => c.args[0])).toEqual([
464
+ "SUPABASE_DB_PASSWORD_inert-project",
465
+ "SUPABASE_URL",
466
+ "SUPABASE_ANON_KEY",
467
+ "SUPABASE_SERVICE_ROLE_KEY",
468
+ "STACK_SUPABASE_ACCESS_TOKEN",
469
+ ]);
470
+ expect(result.secretCount).toBe(3);
471
+ expect(result.entry.secrets).toEqual([
472
+ "SUPABASE_URL",
473
+ "SUPABASE_ANON_KEY",
474
+ "SUPABASE_SERVICE_ROLE_KEY",
475
+ ]);
476
+ expect(JSON.stringify(result)).not.toContain(f.password());
477
+ expect(readFileSync(join(cwd, ".stack.local.toml"), "utf8")).not.toContain(f.password());
478
+ expect(readFileSync(join(cwd, ".mcp.json"), "utf8")).not.toContain(f.password());
479
+ });
480
+ test("Supabase materialize failure owns password rollback while new auth remains unsaved", async () => {
481
+ const f = supabaseFixture({ materializeFails: true });
482
+ const error = await runSupabase().catch((e: Error) => e);
483
+ expect(f.events).toEqual(["verify", "organizations", "POST", "materialize", "DELETE"]);
484
+ expect(h.callsTo("add").map((c) => c.args[0])).toEqual(["SUPABASE_DB_PASSWORD_inert-project"]);
485
+ expect(h.callsTo("remove").map((c) => c.args[0])).toEqual([
486
+ "SUPABASE_DB_PASSWORD_inert-project",
487
+ ]);
488
+ expect((error as Error).message).toContain(
489
+ "1 resource credential(s); manual vault reconciliation is required",
490
+ );
491
+ expect((error as Error).message).not.toContain(f.password());
492
+ expect((await readVault(h.dir)).STACK_SUPABASE_ACCESS_TOKEN).toBeUndefined();
493
+ });
494
+ test("critical vault refusal plus unknown teardown reports a hold without durability promise", async () => {
495
+ writeFileSync(
496
+ h.vaultPath,
497
+ JSON.stringify({ "SUPABASE_DB_PASSWORD_inert-project": "existing-password" }),
498
+ );
499
+ const f = supabaseFixture({ teardownFails: true });
500
+ const error = await runSupabase().catch((e: Error) => e);
501
+ expect(f.events).toEqual(["verify", "organizations", "POST", "DELETE"]);
502
+ expect(error).toMatchObject({ code: "ADD_SERVICE_PARTIAL_FAILURE" });
503
+ expect((error as Error).message).toContain("may still exist");
504
+ expect((error as Error).message).toContain(
505
+ "Resource credential storage was refused or could not be confirmed",
506
+ );
507
+ expect((error as Error).message).not.toContain(f.password());
508
+ expect(h.callsTo("remove")).toEqual([]);
509
+ expect((await readVault(h.dir))["SUPABASE_DB_PASSWORD_inert-project"]).toBe(
510
+ "existing-password",
511
+ );
512
+ });
513
+ test("attached Supabase materialize failure never gains DELETE or generated password authority", async () => {
514
+ const f = supabaseFixture({ attach: true, materializeFails: true });
515
+ const error = await runSupabase("inert-project").catch((e: Error) => e);
516
+ expect(error).toMatchObject({ code: "ADD_SERVICE_PARTIAL_FAILURE" });
517
+ expect((error as Error).message).toContain("preserved");
518
+ expect(f.events).toEqual(["verify", "materialize"]);
519
+ expect(h.callsTo("add")).toEqual([]);
520
+ expect(h.callsTo("remove")).toEqual([]);
521
+ });
522
+ test("later ordinary conflict follows critical write and invokes owned cleanup", async () => {
523
+ const p = factory();
524
+ let deletes = 0;
525
+ p.provision = async (context) => {
526
+ const resource = { id: "inert", displayName: "inert" };
527
+ await carryResourceCredential(context, resource, "CRITICAL_KEY", "critical");
528
+ return resource;
529
+ };
530
+ p.materialize = async () => ({ secrets: { FIRST_KEY: "first", FIXTURE_KEY: "conflict" } });
531
+ p.deprovision = async () => {
532
+ deletes++;
533
+ };
534
+ await expect(run(p)).rejects.toThrow("inputs disagree");
535
+ expect(h.callsTo("add").map((c) => c.args[0])).toEqual(["CRITICAL_KEY"]);
536
+ expect(h.callsTo("remove").map((c) => c.args[0])).toEqual(["CRITICAL_KEY"]);
537
+ expect(deletes).toBe(1);
538
+ });
539
+ test("private resource carry matches exact context and resource identity", async () => {
540
+ const context = ctx();
541
+ beginCredentialStage(context);
542
+ const resource = { id: "same", displayName: "inert" };
543
+ try {
544
+ credentialPhase(context, "provision");
545
+ await carryResourceCredential(context, resource, "CRITICAL_KEY", "critical");
546
+ const { commitResourceCredentials } = await import("../providers/_credential-stage.ts");
547
+ credentialPhase(context, "critical");
548
+ await commitResourceCredentials(context, { ...resource }, () => {
549
+ throw new Error("must not create");
550
+ });
551
+ expect(h.callsTo("add")).toEqual([]);
552
+ await commitResourceCredentials(context, resource, () => {});
553
+ await commitResourceCredentials(context, resource, () => {
554
+ throw new Error("must not retry");
555
+ });
556
+ expect(h.callsTo("add")).toHaveLength(1);
557
+ } finally {
558
+ closeCredentialStage(context);
559
+ }
560
+ });
561
+ for (const valid of [true, false]) {
562
+ test(`Supabase OAuth verifies before direct persistence (valid=${valid})`, async () => {
563
+ const oldClient = process.env.SUPABASE_STACK_CLIENT_ID;
564
+ process.env.SUPABASE_STACK_CLIENT_ID = "inert-client";
565
+ const mock = spyOn(oauth, "runPkceFlow").mockResolvedValue({
566
+ access_token: "synthetic-oauth-token",
567
+ token_type: "bearer",
568
+ expires_in: 60,
569
+ });
570
+ let verifies = 0;
571
+ syntheticFetch((url) => {
572
+ expect(url).toBe("https://api.supabase.com/v1/profile");
573
+ verifies++;
574
+ expect(h.callsTo("add")).toEqual([]);
575
+ return valid
576
+ ? Response.json({ id: "inert-user" })
577
+ : new Response("inert invalid", { status: 401 });
578
+ });
579
+ try {
580
+ if (valid) {
581
+ const auth = await supabase.login(ctx());
582
+ expect(auth.identity?.id).toBe("inert-user");
583
+ expect(auth.expiresAt).toBeGreaterThan(Date.now());
584
+ expect(h.callsTo("add")).toHaveLength(1);
585
+ } else {
586
+ await expect(supabase.login(ctx())).rejects.toMatchObject({
587
+ code: "SUPABASE_AUTH_INVALID",
588
+ message: "Supabase rejected that access token.",
589
+ });
590
+ expect(h.callsTo("add")).toEqual([]);
591
+ }
592
+ expect(mock).toHaveBeenCalledTimes(1);
593
+ expect(verifies).toBe(1);
594
+ } finally {
595
+ mock.mockRestore();
596
+ if (oldClient === undefined)
597
+ Reflect.deleteProperty(process.env, "SUPABASE_STACK_CLIENT_ID");
598
+ else process.env.SUPABASE_STACK_CLIENT_ID = oldClient;
599
+ }
600
+ });
601
+ }
602
+ test("new staged auth commit never reflects producer output or input values", async () => {
603
+ const bin = join(h.dir, "phantom");
604
+ const original = readFileSync(bin, "utf8");
605
+ writeFileSync(
606
+ bin,
607
+ original.replace(
608
+ " add)\n",
609
+ ' add)\n echo "synthetic-key response-secret-sentinel" >&2; exit 9\n',
610
+ ),
611
+ );
612
+ const p = factory();
613
+ p.deprovision = async () => {};
614
+ const error = await run(p).catch((e: Error) => e);
615
+ expect(error).toMatchObject({ code: "ADD_SERVICE_ROLLED_BACK" });
616
+ expect((error as Error).message).toContain("Credential storage could not be confirmed");
617
+ expect((error as Error).message).not.toContain("synthetic-key");
618
+ expect((error as Error).message).not.toContain("response-secret-sentinel");
619
+ expect(h.callsTo("remove")).toEqual([]);
620
+ assertNoConfig();
621
+ });
622
+ test("direct login producer failure cannot claim signed in or echo producer output", async () => {
623
+ const bin = join(h.dir, "phantom");
624
+ const original = readFileSync(bin, "utf8");
625
+ writeFileSync(
626
+ bin,
627
+ original.replace(
628
+ " add)\n",
629
+ ' add)\n echo "synthetic-key response-secret-sentinel" >&2; exit 9\n',
630
+ ),
631
+ );
632
+ const error = await factory()
633
+ .login(ctx())
634
+ .catch((e: Error) => e);
635
+ expect(error).toMatchObject({
636
+ code: "PHANTOM_EXEC_FAILED",
637
+ message: "Credential storage could not be confirmed.",
638
+ });
639
+ expect(await readVault(h.dir)).toEqual({});
640
+ });
641
+ test("config failure after MCP has exact phase and only actual completed creates are tracked", async () => {
642
+ const p = factory();
643
+ p.deprovision = async () => {};
644
+ p.materialize = async () => {
645
+ // Keep original readConfig preflight successful; make only the later
646
+ // config write fail by turning its instance path into a directory.
647
+ const { unlinkSync, mkdirSync } = await import("node:fs");
648
+ unlinkSync(join(cwd, ".stack.local.toml"));
649
+ mkdirSync(join(cwd, ".stack.local.toml"));
650
+ return { secrets: { RESOURCE_KEY: "resource" }, mcp: { name: "inert", command: "echo" } };
651
+ };
652
+ const error = await run(p).catch((e: Error) => e);
653
+ expect((error as Error).message).toContain('step "config write"');
654
+ expect(h.callsTo("add").map((c) => c.args[0])).toEqual(["RESOURCE_KEY", "FIXTURE_KEY"]);
655
+ expect(h.callsTo("remove").map((c) => c.args[0])).toEqual(["RESOURCE_KEY", "FIXTURE_KEY"]);
656
+ const mcp = JSON.parse(readFileSync(join(cwd, ".mcp.json"), "utf8"));
657
+ expect(mcp.mcpServers.inert).toBeUndefined();
658
+ });
659
+ });
@@ -37,7 +37,8 @@ describe("config carrying 21+ provider entries still round-trips", () => {
37
37
  });
38
38
 
39
39
  afterEach(() => {
40
- if (previousRegistryDir === undefined) delete process.env.STACK_REGISTRY_DIR;
40
+ if (previousRegistryDir === undefined)
41
+ Reflect.deleteProperty(process.env, "STACK_REGISTRY_DIR");
41
42
  else process.env.STACK_REGISTRY_DIR = previousRegistryDir;
42
43
  });
43
44
 
@@ -94,7 +94,7 @@ describe("source scanner — config files", () => {
94
94
 
95
95
  test("wrangler.toml picks up Cloudflare", async () => {
96
96
  const cwd = makeProject({
97
- "wrangler.toml": "name = \"my-worker\"\nmain = \"src/index.ts\"",
97
+ "wrangler.toml": 'name = "my-worker"\nmain = "src/index.ts"',
98
98
  });
99
99
  const hits = await scanSource(cwd);
100
100
  expect(hits.some((h) => h.provider === "cloudflare")).toBe(true);