@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,36 @@
1
+ import { makeApiKeyProvider } from "./_api-key.ts";
2
+
3
+ // GCP uses a service-account JSON blob as the primary credential. We store
4
+ // the raw JSON in Phantom. The verify step does a structural parse only —
5
+ // exchanging the JSON for an OAuth2 access token and then calling the Cloud
6
+ // Resource Manager API requires network round-trips to token.googleapis.com
7
+ // and is deferred to v0.2. The structural check is sufficient to catch copy-
8
+ // paste errors (wrong file, truncated JSON, wrong project).
9
+ export default makeApiKeyProvider({
10
+ name: "gcp",
11
+ displayName: "GCP",
12
+ category: "cloud",
13
+ docs: "https://cloud.google.com/docs/authentication/getting-started",
14
+ secretName: "GCP_SERVICE_ACCOUNT_JSON",
15
+ howTo:
16
+ "Download a service-account JSON key from GCP → IAM & Admin → Service Accounts and paste the full JSON content.",
17
+ dashboard: "https://console.cloud.google.com",
18
+ async verify(key) {
19
+ try {
20
+ const parsed = JSON.parse(key) as Record<string, unknown>;
21
+ if (
22
+ parsed.type === "service_account" &&
23
+ typeof parsed.project_id === "string" &&
24
+ typeof parsed.client_email === "string"
25
+ ) {
26
+ return {
27
+ project_id: parsed.project_id as string,
28
+ client_email: parsed.client_email as string,
29
+ };
30
+ }
31
+ return undefined;
32
+ } catch {
33
+ return undefined;
34
+ }
35
+ },
36
+ });
@@ -1,9 +1,7 @@
1
1
  import type { ServiceEntry } from "../config.ts";
2
+ import { resolveOAuthClientId } from "../env.ts";
2
3
  import { StackError } from "../errors.ts";
3
- import { addSecret } from "../phantom.ts";
4
4
  import { fetchWithRetry } from "../http.ts";
5
- import { readLine, tryRevealSecret } from "./_helpers.ts";
6
- import { resolveOAuthClientId } from "../env.ts";
7
5
  import type {
8
6
  AuthHandle,
9
7
  HealthStatus,
@@ -12,6 +10,8 @@ import type {
12
10
  ProviderContext,
13
11
  Resource,
14
12
  } from "./_base.ts";
13
+ import { acceptVerifiedExisting, storeProviderSecret } from "./_credential-stage.ts";
14
+ import { promptSecret, tryRevealSecret } from "./_helpers.ts";
15
15
 
16
16
  /**
17
17
  * GitHub — OAuth device flow (no local redirect needed, works in SSH/remote
@@ -32,33 +32,39 @@ const github: Provider = {
32
32
  docs: "https://docs.github.com/en/rest",
33
33
 
34
34
  async login(ctx: ProviderContext): Promise<AuthHandle> {
35
- const cached = await tryRevealSecret(TOKEN_SECRET);
35
+ const cached = await tryRevealSecret(TOKEN_SECRET, ctx);
36
36
  if (cached) {
37
37
  const identity = await fetchUser(cached);
38
- if (identity) return { token: cached, identity };
38
+ if (identity) {
39
+ acceptVerifiedExisting(ctx, TOKEN_SECRET, cached);
40
+ return { token: cached, identity };
41
+ }
39
42
  ctx.log({ level: "warn", msg: "Cached GitHub token invalid; re-authenticating." });
40
43
  }
41
44
 
42
45
  const clientId = resolveOAuthClientId("github", process.env.GITHUB_STACK_CLIENT_ID);
43
46
  if (clientId) {
44
- const token = await runDeviceFlow(clientId);
47
+ const token = await runDeviceFlow(ctx, clientId);
45
48
  const identity = await fetchUser(token);
46
49
  if (!identity)
47
- throw new StackError("GITHUB_AUTH_INVALID", "GitHub accepted the code but rejected the token.");
48
- await addSecret(TOKEN_SECRET, token);
50
+ throw new StackError(
51
+ "GITHUB_AUTH_INVALID",
52
+ "GitHub accepted the code but rejected the token.",
53
+ );
54
+ await storeProviderSecret(ctx, TOKEN_SECRET, token);
49
55
  return { token, identity };
50
56
  }
51
57
 
52
58
  // PAT fallback.
53
59
  if (!ctx.interactive)
54
60
  throw new StackError("GITHUB_AUTH_REQUIRED", "Set GITHUB_STACK_CLIENT_ID or paste a PAT.");
55
- process.stderr.write(
56
- "\n Create a GitHub PAT at https://github.com/settings/personal-access-tokens/new\n Paste it here: ",
57
- );
58
- const token = (await readLine()).trim();
61
+ const token = await promptSecret(ctx, {
62
+ message: "Paste your GitHub PAT",
63
+ howTo: "Create a GitHub PAT at https://github.com/settings/personal-access-tokens/new",
64
+ });
59
65
  const identity = await fetchUser(token);
60
66
  if (!identity) throw new StackError("GITHUB_AUTH_INVALID", "GitHub rejected that token.");
61
- await addSecret(TOKEN_SECRET, token);
67
+ await storeProviderSecret(ctx, TOKEN_SECRET, token);
62
68
  return { token, identity };
63
69
  },
64
70
 
@@ -72,7 +78,7 @@ const github: Provider = {
72
78
  return { id: login, displayName: `@${login}` };
73
79
  },
74
80
 
75
- async materialize(_ctx, _resource, auth): Promise<Materialized> {
81
+ async materialize(ctx, _resource, auth): Promise<Materialized> {
76
82
  return {
77
83
  secrets: { [TOKEN_SECRET]: auth.token },
78
84
  mcp: {
@@ -85,8 +91,8 @@ const github: Provider = {
85
91
  };
86
92
  },
87
93
 
88
- async healthcheck(_ctx, _entry: ServiceEntry): Promise<HealthStatus> {
89
- const token = await tryRevealSecret(TOKEN_SECRET);
94
+ async healthcheck(ctx, _entry: ServiceEntry): Promise<HealthStatus> {
95
+ const token = await tryRevealSecret(TOKEN_SECRET, ctx);
90
96
  if (!token) return { kind: "error", detail: `${TOKEN_SECRET} missing from vault` };
91
97
  const start = Date.now();
92
98
  const user = await fetchUser(token);
@@ -123,7 +129,7 @@ async function fetchUser(token: string): Promise<Record<string, string> | undefi
123
129
  }
124
130
  }
125
131
 
126
- async function runDeviceFlow(clientId: string): Promise<string> {
132
+ async function runDeviceFlow(ctx: ProviderContext, clientId: string): Promise<string> {
127
133
  // 1. Request device code.
128
134
  const startRes = await fetch("https://github.com/login/device/code", {
129
135
  method: "POST",
@@ -132,23 +138,21 @@ async function runDeviceFlow(clientId: string): Promise<string> {
132
138
  });
133
139
  if (!startRes.ok)
134
140
  throw new StackError("GITHUB_DEVICE_START_FAILED", `device/code ${startRes.status}`);
135
- const {
136
- device_code,
137
- user_code,
138
- verification_uri,
139
- interval,
140
- expires_in,
141
- } = (await startRes.json()) as {
142
- device_code: string;
143
- user_code: string;
144
- verification_uri: string;
145
- interval: number;
146
- expires_in: number;
147
- };
141
+ const { device_code, user_code, verification_uri, interval, expires_in } =
142
+ (await startRes.json()) as {
143
+ device_code: string;
144
+ user_code: string;
145
+ verification_uri: string;
146
+ interval: number;
147
+ expires_in: number;
148
+ };
148
149
 
149
- process.stderr.write(
150
- `\n Visit ${verification_uri} and enter this code: ${user_code}\n (waiting for approval…)\n`,
151
- );
150
+ // Route through ctx.log so the CLI pauses its spinner — a bare stderr write
151
+ // here is repainted over by the spinner and the user never sees the code.
152
+ ctx.log({
153
+ level: "info",
154
+ msg: `Visit ${verification_uri} and enter this code: ${user_code} (waiting for approval…)`,
155
+ });
152
156
 
153
157
  // 2. Poll for the token.
154
158
  const deadline = Date.now() + expires_in * 1000;
@@ -176,5 +180,3 @@ async function runDeviceFlow(clientId: string): Promise<string> {
176
180
  }
177
181
  throw new StackError("GITHUB_DEVICE_TIMEOUT", "Device flow timed out.");
178
182
  }
179
-
180
-
@@ -0,0 +1,29 @@
1
+ import { makeApiKeyProvider } from "./_api-key.ts";
2
+ import { verifyFetch } from "./_helpers.ts";
3
+
4
+ // Grafana is self-hosted or Grafana Cloud — we store both the token and the
5
+ // base URL. The secretName for `makeApiKeyProvider` is the token slot; the URL
6
+ // is prompted separately in the howTo message and stored via a custom
7
+ // materialize approach. For now, the key that is "verified" is the API token,
8
+ // and the GRAFANA_URL is a second secret the user must add manually or via
9
+ // `stack add grafana` with a follow-up prompt. This matches the pattern used
10
+ // by Sentry (which also stores multiple secrets).
11
+ export default makeApiKeyProvider({
12
+ name: "grafana",
13
+ displayName: "Grafana",
14
+ category: "observability",
15
+ docs: "https://grafana.com/docs/grafana/latest/developers/http_api/",
16
+ secretName: "GRAFANA_API_KEY",
17
+ howTo:
18
+ "Create a service-account token in Grafana → Administration → Service accounts. You will also need your Grafana base URL (e.g. https://myorg.grafana.net).",
19
+ dashboard: "https://grafana.com",
20
+ async verify(key) {
21
+ // Without the URL we can't verify against the actual instance. We do a
22
+ // structural check: Grafana service-account tokens start with "glsa_".
23
+ // Grafana Cloud API tokens also start with "glc_". Accept both shapes.
24
+ if (key.startsWith("glsa_") || key.startsWith("glc_") || key.length >= 32) {
25
+ return { token_type: key.startsWith("glsa_") ? "service_account" : "api_key" };
26
+ }
27
+ return undefined;
28
+ },
29
+ });
@@ -0,0 +1,29 @@
1
+ import { makeApiKeyProvider } from "./_api-key.ts";
2
+ import { verifyFetch } from "./_helpers.ts";
3
+
4
+ export default makeApiKeyProvider({
5
+ name: "hetzner",
6
+ displayName: "Hetzner",
7
+ category: "cloud",
8
+ docs: "https://docs.hetzner.cloud/",
9
+ secretName: "HETZNER_API_TOKEN",
10
+ howTo: "Create an API token in Hetzner Cloud Console → Project → Security → API Tokens.",
11
+ dashboard: "https://console.hetzner.cloud",
12
+ async verify(key) {
13
+ try {
14
+ // GET /v1/locations is a lightweight public-ish endpoint that returns
15
+ // available datacenters — valid for any token, no side effects.
16
+ const res = await verifyFetch("https://api.hetzner.cloud/v1/locations", {
17
+ headers: {
18
+ Authorization: `Bearer ${key}`,
19
+ Accept: "application/json",
20
+ },
21
+ });
22
+ if (!res.ok) return undefined;
23
+ const body = (await res.json()) as { locations?: Array<{ name: string }> };
24
+ return { locations: String(body.locations?.length ?? 0) };
25
+ } catch {
26
+ return undefined;
27
+ }
28
+ },
29
+ });
@@ -1,5 +1,5 @@
1
- import type { Provider } from "./_base.ts";
2
1
  import { ProviderNotFoundError } from "../errors.ts";
2
+ import type { Provider } from "./_base.ts";
3
3
 
4
4
  /**
5
5
  * Registry of curated providers. Entries are added in Waves 2–3 as each
@@ -44,6 +44,18 @@ const registry: Record<string, () => Promise<Provider>> = {
44
44
  postmark: async () => (await import("./postmark.ts")).default,
45
45
  clerk: async () => (await import("./clerk.ts")).default,
46
46
  aws: async () => (await import("./aws.ts")).default,
47
+
48
+ // Wave 4 — key-only adapters (coming-soon catalog entries shipped as adapters).
49
+ auth0: async () => (await import("./auth0.ts")).default,
50
+ datadog: async () => (await import("./datadog.ts")).default,
51
+ digitalocean: async () => (await import("./digitalocean.ts")).default,
52
+ gcp: async () => (await import("./gcp.ts")).default,
53
+ grafana: async () => (await import("./grafana.ts")).default,
54
+ hetzner: async () => (await import("./hetzner.ts")).default,
55
+ launchdarkly: async () => (await import("./launchdarkly.ts")).default,
56
+ mixpanel: async () => (await import("./mixpanel.ts")).default,
57
+ plausible: async () => (await import("./plausible.ts")).default,
58
+ workos: async () => (await import("./workos.ts")).default,
47
59
  };
48
60
 
49
61
  export const providers = registry;
@@ -0,0 +1,33 @@
1
+ import { makeApiKeyProvider } from "./_api-key.ts";
2
+ import { verifyFetch } from "./_helpers.ts";
3
+
4
+ export default makeApiKeyProvider({
5
+ name: "launchdarkly",
6
+ displayName: "LaunchDarkly",
7
+ category: "featureflags",
8
+ docs: "https://apidocs.launchdarkly.com/",
9
+ secretName: "LAUNCHDARKLY_API_TOKEN",
10
+ howTo:
11
+ "Create an API access token at LaunchDarkly → Account settings → Authorization. You will also need your SDK key from LaunchDarkly → Environments.",
12
+ dashboard: "https://app.launchdarkly.com",
13
+ async verify(key) {
14
+ try {
15
+ // GET /api/v2/caller-identity is a lightweight endpoint that returns
16
+ // metadata about the token without requiring any specific permission scope.
17
+ const res = await verifyFetch("https://app.launchdarkly.com/api/v2/caller-identity", {
18
+ headers: {
19
+ Authorization: key,
20
+ Accept: "application/json",
21
+ },
22
+ });
23
+ if (!res.ok) return undefined;
24
+ const body = (await res.json()) as { accountId?: string; tokenType?: string };
25
+ return {
26
+ accountId: body.accountId ?? "",
27
+ tokenType: body.tokenType ?? "api",
28
+ };
29
+ } catch {
30
+ return undefined;
31
+ }
32
+ },
33
+ });
@@ -0,0 +1,37 @@
1
+ import { makeApiKeyProvider } from "./_api-key.ts";
2
+ import { verifyFetch } from "./_helpers.ts";
3
+
4
+ export default makeApiKeyProvider({
5
+ name: "mixpanel",
6
+ displayName: "Mixpanel",
7
+ category: "analytics",
8
+ docs: "https://developer.mixpanel.com/reference/overview",
9
+ secretName: "MIXPANEL_PROJECT_TOKEN",
10
+ howTo:
11
+ "Find your project token in Mixpanel → Settings → Project Settings. The API secret is on the same page.",
12
+ dashboard: "https://mixpanel.com",
13
+ async verify(key) {
14
+ // Mixpanel's Ingestion API accepts the project token directly. We send a
15
+ // no-op import request to confirm the token is valid without writing data.
16
+ // POST /import with an empty events array returns 200 on a valid token.
17
+ try {
18
+ const res = await verifyFetch("https://api.mixpanel.com/import?strict=1", {
19
+ method: "POST",
20
+ headers: {
21
+ "Content-Type": "application/json",
22
+ Accept: "application/json",
23
+ Authorization: `Basic ${Buffer.from(`${key}:`).toString("base64")}`,
24
+ },
25
+ body: JSON.stringify([]),
26
+ });
27
+ // 200 = valid token (empty import is fine), 401/403 = invalid
28
+ if (res.status === 200 || res.status === 400) {
29
+ // 400 means "no events" which still confirms the token is valid
30
+ return { project_token: `${key.slice(0, 6)}…` };
31
+ }
32
+ return undefined;
33
+ } catch {
34
+ return undefined;
35
+ }
36
+ },
37
+ });
@@ -1,8 +1,6 @@
1
1
  import type { ServiceEntry } from "../config.ts";
2
2
  import { StackError } from "../errors.ts";
3
- import { addSecret } from "../phantom.ts";
4
3
  import { fetchWithRetry } from "../http.ts";
5
- import { readLine, tryRevealSecret } from "./_helpers.ts";
6
4
  import type {
7
5
  AuthHandle,
8
6
  HealthStatus,
@@ -12,6 +10,8 @@ import type {
12
10
  ProvisionOpts,
13
11
  Resource,
14
12
  } from "./_base.ts";
13
+ import { acceptVerifiedExisting, storeProviderSecret } from "./_credential-stage.ts";
14
+ import { promptSecret, tryRevealSecret } from "./_helpers.ts";
15
15
 
16
16
  /**
17
17
  * Neon — serverless Postgres. v1 uses a Neon API key (users create one at
@@ -33,20 +33,24 @@ const neon: Provider = {
33
33
  docs: "https://neon.tech/docs/reference/api-reference",
34
34
 
35
35
  async login(ctx: ProviderContext): Promise<AuthHandle> {
36
- const cached = await tryRevealSecret(TOKEN_SECRET);
36
+ const cached = await tryRevealSecret(TOKEN_SECRET, ctx);
37
37
  if (cached) {
38
38
  const identity = await fetchIdentity(cached);
39
- if (identity) return { token: cached, identity };
39
+ if (identity) {
40
+ acceptVerifiedExisting(ctx, TOKEN_SECRET, cached);
41
+ return { token: cached, identity };
42
+ }
40
43
  ctx.log({ level: "warn", msg: "Cached Neon key invalid; re-entering." });
41
44
  }
42
- if (!ctx.interactive) throw new StackError("NEON_AUTH_REQUIRED", "No valid Neon API key in vault.");
43
- process.stderr.write(
44
- "\n Create a Neon API key at https://console.neon.tech/app/settings/api-keys\n Paste it here: ",
45
- );
46
- const token = (await readLine()).trim();
45
+ if (!ctx.interactive)
46
+ throw new StackError("NEON_AUTH_REQUIRED", "No valid Neon API key in vault.");
47
+ const token = await promptSecret(ctx, {
48
+ message: "Paste your Neon API key",
49
+ howTo: "Create a Neon API key at https://console.neon.tech/app/settings/api-keys",
50
+ });
47
51
  const identity = await fetchIdentity(token);
48
52
  if (!identity) throw new StackError("NEON_AUTH_INVALID", "Neon rejected that API key.");
49
- await addSecret(TOKEN_SECRET, token);
53
+ await storeProviderSecret(ctx, TOKEN_SECRET, token);
50
54
  return { token, identity };
51
55
  },
52
56
 
@@ -54,7 +58,10 @@ const neon: Provider = {
54
58
  if (opts.existingResourceId) {
55
59
  const project = await fetchProject(auth.token, opts.existingResourceId);
56
60
  if (!project)
57
- throw new StackError("NEON_PROJECT_NOT_FOUND", `No Neon project ${opts.existingResourceId}.`);
61
+ throw new StackError(
62
+ "NEON_PROJECT_NOT_FOUND",
63
+ `No Neon project ${opts.existingResourceId}.`,
64
+ );
58
65
  return toResource(project);
59
66
  }
60
67
  const name = `stack-${Date.now().toString(36)}`;
@@ -69,19 +76,16 @@ const neon: Provider = {
69
76
  "NEON_CREATE_FAILED",
70
77
  `Neon project creation failed (${res.status}): ${await res.text()}`,
71
78
  );
72
- const body = (await res.json()) as { project: NeonProject; connection_uris?: Array<{ connection_uri: string }> };
73
- // Stash the fresh connection URI keyed by project id so it isn't confused
74
- // with another project's DATABASE_URL. Also write the canonical slot (the
75
- // current project's materialize call will overwrite it).
76
- if (body.connection_uris?.[0]?.connection_uri) {
77
- const uri = body.connection_uris[0].connection_uri;
78
- await addSecret(`NEON_DATABASE_URL_${body.project.id}`, uri);
79
- await addSecret("DATABASE_URL", uri);
80
- }
79
+ const body = (await res.json()) as {
80
+ project: NeonProject;
81
+ connection_uris?: Array<{ connection_uri: string }>;
82
+ };
83
+ // Return the created resource before any vault write. materialize() obtains
84
+ // its fresh URI from the API; the pipeline owns persistence and rollback.
81
85
  return toResource(body.project);
82
86
  },
83
87
 
84
- async materialize(_ctx, resource, auth): Promise<Materialized> {
88
+ async materialize(ctx, resource, auth): Promise<Materialized> {
85
89
  // Always fetch the connection URI from the API — the vault's DATABASE_URL
86
90
  // is a generic key that may belong to another project or another provider.
87
91
  // Don't trust it as a cache here.
@@ -100,8 +104,8 @@ const neon: Provider = {
100
104
  };
101
105
  },
102
106
 
103
- async healthcheck(_ctx, entry: ServiceEntry): Promise<HealthStatus> {
104
- const token = await tryRevealSecret(TOKEN_SECRET);
107
+ async healthcheck(ctx, entry: ServiceEntry): Promise<HealthStatus> {
108
+ const token = await tryRevealSecret(TOKEN_SECRET, ctx);
105
109
  if (!token) return { kind: "error", detail: `${TOKEN_SECRET} missing from vault` };
106
110
  if (!entry.resource_id) return { kind: "warn", detail: "no resource_id" };
107
111
  const start = Date.now();
@@ -115,6 +119,28 @@ const neon: Provider = {
115
119
  ? `https://console.neon.tech/app/projects/${entry.resource_id}`
116
120
  : "https://console.neon.tech";
117
121
  },
122
+
123
+ async deprovision(_ctx: ProviderContext, auth: AuthHandle, resourceId: string): Promise<void> {
124
+ let res: Response;
125
+ try {
126
+ res = await fetch(`${API}/projects/${resourceId}`, {
127
+ method: "DELETE",
128
+ headers: authHeaders(auth.token),
129
+ });
130
+ } catch {
131
+ // Transport details can contain credentials; absence is unconfirmed.
132
+ throw new StackError(
133
+ "NEON_DELETE_UNCONFIRMED",
134
+ "Neon resource deletion could not be confirmed.",
135
+ );
136
+ }
137
+ if (res.status === 404 || res.ok) return;
138
+ // Keep the bounded status, never the remote response body or secret values.
139
+ throw new StackError(
140
+ "NEON_DELETE_FAILED",
141
+ `Neon resource deletion failed (HTTP ${res.status}).`,
142
+ );
143
+ },
118
144
  };
119
145
 
120
146
  export default neon;
@@ -125,7 +151,7 @@ interface NeonProject {
125
151
  region_id: string;
126
152
  }
127
153
 
128
- function authHeaders(token: string): HeadersInit {
154
+ function authHeaders(token: string): NonNullable<RequestInit["headers"]> {
129
155
  return {
130
156
  Authorization: `Bearer ${token}`,
131
157
  "content-type": "application/json",
@@ -171,5 +197,3 @@ async function fetchConnectionUri(token: string, projectId: string): Promise<str
171
197
  function toResource(project: NeonProject): Resource {
172
198
  return { id: project.id, displayName: project.name, region: project.region_id };
173
199
  }
174
-
175
-
@@ -1,3 +1,4 @@
1
+ import { openaiBaseUrl } from "../base-urls.ts";
1
2
  import { makeApiKeyProvider } from "./_api-key.ts";
2
3
  import { verifyFetch } from "./_helpers.ts";
3
4
 
@@ -11,7 +12,7 @@ export default makeApiKeyProvider({
11
12
  dashboard: "https://platform.openai.com",
12
13
  async verify(key) {
13
14
  try {
14
- const res = await verifyFetch("https://api.openai.com/v1/models", {
15
+ const res = await verifyFetch(`${openaiBaseUrl()}/models`, {
15
16
  headers: { Authorization: `Bearer ${key}` },
16
17
  });
17
18
  if (!res.ok) return undefined;
@@ -0,0 +1,26 @@
1
+ import { makeApiKeyProvider } from "./_api-key.ts";
2
+ import { verifyFetch } from "./_helpers.ts";
3
+
4
+ export default makeApiKeyProvider({
5
+ name: "plausible",
6
+ displayName: "Plausible",
7
+ category: "analytics",
8
+ docs: "https://plausible.io/docs/stats-api",
9
+ secretName: "PLAUSIBLE_API_KEY",
10
+ howTo:
11
+ "Generate an API key at https://plausible.io/settings and note your site ID (the domain you track).",
12
+ dashboard: "https://plausible.io",
13
+ async verify(key) {
14
+ try {
15
+ // /api/v1/sites lists all sites — read-only, confirms key validity.
16
+ const res = await verifyFetch("https://plausible.io/api/v1/sites", {
17
+ headers: { Authorization: `Bearer ${key}` },
18
+ });
19
+ if (!res.ok) return undefined;
20
+ const body = (await res.json()) as { sites?: Array<{ domain: string }> };
21
+ return { sites: String(body.sites?.length ?? 0) };
22
+ } catch {
23
+ return undefined;
24
+ }
25
+ },
26
+ });
@@ -1,8 +1,6 @@
1
1
  import type { ServiceEntry } from "../config.ts";
2
2
  import { StackError } from "../errors.ts";
3
- import { addSecret } from "../phantom.ts";
4
3
  import { fetchWithRetry } from "../http.ts";
5
- import { readLine, tryRevealSecret } from "./_helpers.ts";
6
4
  import type {
7
5
  AuthHandle,
8
6
  HealthStatus,
@@ -12,6 +10,8 @@ import type {
12
10
  ProvisionOpts,
13
11
  Resource,
14
12
  } from "./_base.ts";
13
+ import { acceptVerifiedExisting, storeProviderSecret } from "./_credential-stage.ts";
14
+ import { promptSecret, tryRevealSecret } from "./_helpers.ts";
15
15
 
16
16
  /**
17
17
  * Sentry — error tracking. v1 uses an Auth Token (users create one at
@@ -33,20 +33,24 @@ const sentry: Provider = {
33
33
  docs: "https://docs.sentry.io/api/",
34
34
 
35
35
  async login(ctx: ProviderContext): Promise<AuthHandle> {
36
- const cached = await tryRevealSecret(TOKEN_SECRET);
36
+ const cached = await tryRevealSecret(TOKEN_SECRET, ctx);
37
37
  if (cached) {
38
38
  const identity = await fetchIdentity(cached);
39
- if (identity) return { token: cached, identity };
39
+ if (identity) {
40
+ acceptVerifiedExisting(ctx, TOKEN_SECRET, cached);
41
+ return { token: cached, identity };
42
+ }
40
43
  ctx.log({ level: "warn", msg: "Cached Sentry token invalid." });
41
44
  }
42
45
  if (!ctx.interactive) throw new StackError("SENTRY_AUTH_REQUIRED", "No valid Sentry token.");
43
- process.stderr.write(
44
- "\n Create a Sentry Auth Token at https://sentry.io/settings/account/api/auth-tokens/\n Required scopes: project:write, org:read\n Paste it here: ",
45
- );
46
- const token = (await readLine()).trim();
46
+ const token = await promptSecret(ctx, {
47
+ message: "Paste your Sentry Auth Token",
48
+ howTo:
49
+ "Create a Sentry Auth Token at https://sentry.io/settings/account/api/auth-tokens/ (scopes: project:write, org:read)",
50
+ });
47
51
  const identity = await fetchIdentity(token);
48
52
  if (!identity) throw new StackError("SENTRY_AUTH_INVALID", "Sentry rejected that token.");
49
- await addSecret(TOKEN_SECRET, token);
53
+ await storeProviderSecret(ctx, TOKEN_SECRET, token);
50
54
  return { token, identity };
51
55
  },
52
56
 
@@ -79,7 +83,7 @@ const sentry: Provider = {
79
83
  return { id: `${orgSlug}/${project.slug}`, displayName: project.name };
80
84
  },
81
85
 
82
- async materialize(_ctx, resource, auth): Promise<Materialized> {
86
+ async materialize(ctx, resource, auth): Promise<Materialized> {
83
87
  const [orgSlug, projectSlug] = resource.id.split("/");
84
88
  const dsn = await fetchDsn(auth.token, orgSlug, projectSlug);
85
89
  const secrets: Record<string, string> = { SENTRY_AUTH_TOKEN: auth.token };
@@ -102,8 +106,8 @@ const sentry: Provider = {
102
106
  };
103
107
  },
104
108
 
105
- async healthcheck(_ctx, entry: ServiceEntry): Promise<HealthStatus> {
106
- const token = await tryRevealSecret(TOKEN_SECRET);
109
+ async healthcheck(ctx, entry: ServiceEntry): Promise<HealthStatus> {
110
+ const token = await tryRevealSecret(TOKEN_SECRET, ctx);
107
111
  if (!token) return { kind: "error", detail: `${TOKEN_SECRET} missing from vault` };
108
112
  if (!entry.resource_id) return { kind: "warn", detail: "no resource_id" };
109
113
  const [orgSlug, projectSlug] = entry.resource_id.split("/");
@@ -114,9 +118,7 @@ const sentry: Provider = {
114
118
  headers: { Authorization: `Bearer ${token}` },
115
119
  });
116
120
  const latencyMs = Date.now() - start;
117
- return res.ok
118
- ? { kind: "ok", latencyMs }
119
- : { kind: "error", detail: `HTTP ${res.status}` };
121
+ return res.ok ? { kind: "ok", latencyMs } : { kind: "error", detail: `HTTP ${res.status}` };
120
122
  },
121
123
 
122
124
  dashboardUrl(entry: ServiceEntry): string {
@@ -179,5 +181,3 @@ async function fetchDsn(
179
181
  const keys = (await res.json()) as Array<{ dsn?: { public?: string } }>;
180
182
  return keys[0]?.dsn?.public;
181
183
  }
182
-
183
-
@@ -23,8 +23,7 @@ export default makeApiKeyProvider({
23
23
  if (!res.ok) return undefined;
24
24
  const body = (await res.json()) as Record<string, unknown>;
25
25
  const out: Record<string, string> = {};
26
- for (const [k, v] of Object.entries(body))
27
- if (typeof v === "string") out[k] = v;
26
+ for (const [k, v] of Object.entries(body)) if (typeof v === "string") out[k] = v;
28
27
  return out;
29
28
  } catch {
30
29
  return undefined;