@ashlr/stack-core 0.1.1

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 (88) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +57 -0
  3. package/package.json +57 -0
  4. package/src/__tests__/_harness.ts +146 -0
  5. package/src/__tests__/api-key-providers.test.ts +237 -0
  6. package/src/__tests__/catalog-index.test.ts +103 -0
  7. package/src/__tests__/circuit-breaker.test.ts +112 -0
  8. package/src/__tests__/clone-url-validation.test.ts +47 -0
  9. package/src/__tests__/cloudflare-provider.test.ts +74 -0
  10. package/src/__tests__/completion-ci.test.ts +62 -0
  11. package/src/__tests__/config-split.test.ts +138 -0
  12. package/src/__tests__/config.test.ts +66 -0
  13. package/src/__tests__/deps.test.ts +58 -0
  14. package/src/__tests__/detect-source.test.ts +147 -0
  15. package/src/__tests__/detect.test.ts +67 -0
  16. package/src/__tests__/github-provider.test.ts +65 -0
  17. package/src/__tests__/helpers.test.ts +38 -0
  18. package/src/__tests__/http.test.ts +235 -0
  19. package/src/__tests__/import-integration.test.ts +87 -0
  20. package/src/__tests__/inference.test.ts +326 -0
  21. package/src/__tests__/neon-provider.test.ts +79 -0
  22. package/src/__tests__/openai-provider.test.ts +54 -0
  23. package/src/__tests__/phantom-harness.test.ts +44 -0
  24. package/src/__tests__/phantom-wire.test.ts +85 -0
  25. package/src/__tests__/pipeline-integration.test.ts +95 -0
  26. package/src/__tests__/pipeline-recovery.test.ts +106 -0
  27. package/src/__tests__/pipeline.test.ts +92 -0
  28. package/src/__tests__/recipe.test.ts +164 -0
  29. package/src/__tests__/recommend-apply-e2e.test.ts +222 -0
  30. package/src/__tests__/registry.test.ts +78 -0
  31. package/src/__tests__/secret-redaction.test.ts +170 -0
  32. package/src/__tests__/security.test.ts +80 -0
  33. package/src/__tests__/supabase-provider.test.ts +108 -0
  34. package/src/__tests__/templates.test.ts +26 -0
  35. package/src/__tests__/turso-provider.test.ts +71 -0
  36. package/src/ai/catalog-index.ts +256 -0
  37. package/src/ai/circuit-breaker.ts +116 -0
  38. package/src/ai/cost-tracker.ts +103 -0
  39. package/src/ai/inference.ts +470 -0
  40. package/src/ai/phantom-wire.ts +184 -0
  41. package/src/ai/recipe.ts +178 -0
  42. package/src/catalog.ts +430 -0
  43. package/src/config.ts +293 -0
  44. package/src/detect-source.ts +344 -0
  45. package/src/detect.ts +207 -0
  46. package/src/env.ts +35 -0
  47. package/src/errors.ts +29 -0
  48. package/src/http.ts +212 -0
  49. package/src/index.ts +61 -0
  50. package/src/mcp-writer.ts +50 -0
  51. package/src/oauth.ts +184 -0
  52. package/src/phantom.ts +156 -0
  53. package/src/pipeline.ts +131 -0
  54. package/src/providers/_api-key.ts +122 -0
  55. package/src/providers/_base.ts +96 -0
  56. package/src/providers/_helpers.ts +81 -0
  57. package/src/providers/anthropic.ts +27 -0
  58. package/src/providers/aws.ts +198 -0
  59. package/src/providers/braintrust.ts +33 -0
  60. package/src/providers/clerk.ts +23 -0
  61. package/src/providers/cloudflare.ts +146 -0
  62. package/src/providers/convex.ts +28 -0
  63. package/src/providers/deepseek.ts +24 -0
  64. package/src/providers/firebase.ts +35 -0
  65. package/src/providers/fly.ts +24 -0
  66. package/src/providers/github.ts +180 -0
  67. package/src/providers/index.ts +59 -0
  68. package/src/providers/linear.ts +46 -0
  69. package/src/providers/mailgun.ts +26 -0
  70. package/src/providers/modal.ts +27 -0
  71. package/src/providers/neon.ts +175 -0
  72. package/src/providers/openai.ts +24 -0
  73. package/src/providers/posthog.ts +30 -0
  74. package/src/providers/postmark.ts +27 -0
  75. package/src/providers/railway.ts +35 -0
  76. package/src/providers/render.ts +31 -0
  77. package/src/providers/replicate.ts +30 -0
  78. package/src/providers/resend.ts +24 -0
  79. package/src/providers/sendgrid.ts +24 -0
  80. package/src/providers/sentry.ts +183 -0
  81. package/src/providers/stripe.ts +33 -0
  82. package/src/providers/supabase.ts +309 -0
  83. package/src/providers/turso.ts +197 -0
  84. package/src/providers/upstash.ts +32 -0
  85. package/src/providers/vercel.ts +134 -0
  86. package/src/providers/xai.ts +24 -0
  87. package/src/registry.ts +114 -0
  88. package/src/templates.ts +39 -0
@@ -0,0 +1,180 @@
1
+ import type { ServiceEntry } from "../config.ts";
2
+ import { StackError } from "../errors.ts";
3
+ import { addSecret } from "../phantom.ts";
4
+ import { fetchWithRetry } from "../http.ts";
5
+ import { readLine, tryRevealSecret } from "./_helpers.ts";
6
+ import { resolveOAuthClientId } from "../env.ts";
7
+ import type {
8
+ AuthHandle,
9
+ HealthStatus,
10
+ Materialized,
11
+ Provider,
12
+ ProviderContext,
13
+ Resource,
14
+ } from "./_base.ts";
15
+
16
+ /**
17
+ * GitHub — OAuth device flow (no local redirect needed, works in SSH/remote
18
+ * sessions). Requires a GitHub OAuth App; we fall back to PAT paste when the
19
+ * client id isn't configured.
20
+ *
21
+ * Device flow: https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/authorizing-oauth-apps#device-flow
22
+ */
23
+
24
+ const TOKEN_SECRET = "GITHUB_TOKEN";
25
+ const DEFAULT_SCOPE = "repo,read:org,read:user";
26
+
27
+ const github: Provider = {
28
+ name: "github",
29
+ displayName: "GitHub",
30
+ category: "code",
31
+ authKind: "oauth_device",
32
+ docs: "https://docs.github.com/en/rest",
33
+
34
+ async login(ctx: ProviderContext): Promise<AuthHandle> {
35
+ const cached = await tryRevealSecret(TOKEN_SECRET);
36
+ if (cached) {
37
+ const identity = await fetchUser(cached);
38
+ if (identity) return { token: cached, identity };
39
+ ctx.log({ level: "warn", msg: "Cached GitHub token invalid; re-authenticating." });
40
+ }
41
+
42
+ const clientId = resolveOAuthClientId("github", process.env.GITHUB_STACK_CLIENT_ID);
43
+ if (clientId) {
44
+ const token = await runDeviceFlow(clientId);
45
+ const identity = await fetchUser(token);
46
+ if (!identity)
47
+ throw new StackError("GITHUB_AUTH_INVALID", "GitHub accepted the code but rejected the token.");
48
+ await addSecret(TOKEN_SECRET, token);
49
+ return { token, identity };
50
+ }
51
+
52
+ // PAT fallback.
53
+ if (!ctx.interactive)
54
+ 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();
59
+ const identity = await fetchUser(token);
60
+ if (!identity) throw new StackError("GITHUB_AUTH_INVALID", "GitHub rejected that token.");
61
+ await addSecret(TOKEN_SECRET, token);
62
+ return { token, identity };
63
+ },
64
+
65
+ async provision(_ctx, auth, opts): Promise<Resource> {
66
+ const login = auth.identity?.login ?? "user";
67
+ if (opts.existingResourceId) {
68
+ return { id: opts.existingResourceId, displayName: opts.existingResourceId };
69
+ }
70
+ // v1 doesn't auto-create repos — users usually have a repo already. We
71
+ // attach to the account and surface the login as the resource id.
72
+ return { id: login, displayName: `@${login}` };
73
+ },
74
+
75
+ async materialize(_ctx, _resource, auth): Promise<Materialized> {
76
+ return {
77
+ secrets: { [TOKEN_SECRET]: auth.token },
78
+ mcp: {
79
+ name: "github",
80
+ type: "stdio",
81
+ command: "npx",
82
+ args: ["-y", "@modelcontextprotocol/server-github"],
83
+ env: { GITHUB_PERSONAL_ACCESS_TOKEN: `$(phantom reveal ${TOKEN_SECRET})` },
84
+ },
85
+ };
86
+ },
87
+
88
+ async healthcheck(_ctx, _entry: ServiceEntry): Promise<HealthStatus> {
89
+ const token = await tryRevealSecret(TOKEN_SECRET);
90
+ if (!token) return { kind: "error", detail: `${TOKEN_SECRET} missing from vault` };
91
+ const start = Date.now();
92
+ const user = await fetchUser(token);
93
+ const latencyMs = Date.now() - start;
94
+ return user ? { kind: "ok", latencyMs } : { kind: "error", detail: "token invalid" };
95
+ },
96
+
97
+ dashboardUrl(): string {
98
+ return "https://github.com";
99
+ },
100
+ };
101
+
102
+ export default github;
103
+
104
+ async function fetchUser(token: string): Promise<Record<string, string> | undefined> {
105
+ try {
106
+ // Idempotent GET — retry transient 429s (GitHub's primary-rate-limit
107
+ // responses usually carry Retry-After) + 5xx from github.com.
108
+ const res = await fetchWithRetry("https://api.github.com/user", {
109
+ headers: {
110
+ Authorization: `Bearer ${token}`,
111
+ Accept: "application/vnd.github+json",
112
+ "User-Agent": "ashlr-stack",
113
+ },
114
+ });
115
+ if (!res.ok) return undefined;
116
+ const body = (await res.json()) as Record<string, unknown>;
117
+ const out: Record<string, string> = {};
118
+ for (const [k, v] of Object.entries(body))
119
+ if (typeof v === "string" || typeof v === "number") out[k] = String(v);
120
+ return out;
121
+ } catch {
122
+ return undefined;
123
+ }
124
+ }
125
+
126
+ async function runDeviceFlow(clientId: string): Promise<string> {
127
+ // 1. Request device code.
128
+ const startRes = await fetch("https://github.com/login/device/code", {
129
+ method: "POST",
130
+ headers: { Accept: "application/json", "content-type": "application/json" },
131
+ body: JSON.stringify({ client_id: clientId, scope: DEFAULT_SCOPE }),
132
+ });
133
+ if (!startRes.ok)
134
+ 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
+ };
148
+
149
+ process.stderr.write(
150
+ `\n Visit ${verification_uri} and enter this code: ${user_code}\n (waiting for approval…)\n`,
151
+ );
152
+
153
+ // 2. Poll for the token.
154
+ const deadline = Date.now() + expires_in * 1000;
155
+ let pollInterval = (interval ?? 5) * 1000;
156
+ while (Date.now() < deadline) {
157
+ await new Promise((r) => setTimeout(r, pollInterval));
158
+ const res = await fetch("https://github.com/login/oauth/access_token", {
159
+ method: "POST",
160
+ headers: { Accept: "application/json", "content-type": "application/json" },
161
+ body: JSON.stringify({
162
+ client_id: clientId,
163
+ device_code,
164
+ grant_type: "urn:ietf:params:oauth:grant-type:device_code",
165
+ }),
166
+ });
167
+ const body = (await res.json()) as {
168
+ access_token?: string;
169
+ error?: string;
170
+ interval?: number;
171
+ };
172
+ if (body.access_token) return body.access_token;
173
+ if (body.error === "slow_down") pollInterval += 5000;
174
+ else if (body.error === "authorization_pending") continue;
175
+ else throw new StackError("GITHUB_DEVICE_FAILED", body.error ?? "unknown error");
176
+ }
177
+ throw new StackError("GITHUB_DEVICE_TIMEOUT", "Device flow timed out.");
178
+ }
179
+
180
+
@@ -0,0 +1,59 @@
1
+ import type { Provider } from "./_base.ts";
2
+ import { ProviderNotFoundError } from "../errors.ts";
3
+
4
+ /**
5
+ * Registry of curated providers. Entries are added in Waves 2–3 as each
6
+ * provider is implemented. Keeping this list explicit (no dynamic discovery)
7
+ * keeps the catalog curated and the bundle small.
8
+ */
9
+
10
+ const registry: Record<string, () => Promise<Provider>> = {
11
+ // Wave 2 pilot — full upstream lifecycle via Management API.
12
+ supabase: async () => (await import("./supabase.ts")).default,
13
+
14
+ // Wave 3 — databases & deploy.
15
+ neon: async () => (await import("./neon.ts")).default,
16
+ turso: async () => (await import("./turso.ts")).default,
17
+ convex: async () => (await import("./convex.ts")).default,
18
+ vercel: async () => (await import("./vercel.ts")).default,
19
+ railway: async () => (await import("./railway.ts")).default,
20
+ fly: async () => (await import("./fly.ts")).default,
21
+ cloudflare: async () => (await import("./cloudflare.ts")).default,
22
+ render: async () => (await import("./render.ts")).default,
23
+ firebase: async () => (await import("./firebase.ts")).default,
24
+ upstash: async () => (await import("./upstash.ts")).default,
25
+
26
+ // Wave 3 — AI API keys (no upstream provisioning; paste + verify).
27
+ openai: async () => (await import("./openai.ts")).default,
28
+ anthropic: async () => (await import("./anthropic.ts")).default,
29
+ xai: async () => (await import("./xai.ts")).default,
30
+ deepseek: async () => (await import("./deepseek.ts")).default,
31
+ replicate: async () => (await import("./replicate.ts")).default,
32
+ braintrust: async () => (await import("./braintrust.ts")).default,
33
+ modal: async () => (await import("./modal.ts")).default,
34
+
35
+ // Wave 3 — analytics / errors / code / ops.
36
+ posthog: async () => (await import("./posthog.ts")).default,
37
+ sentry: async () => (await import("./sentry.ts")).default,
38
+ github: async () => (await import("./github.ts")).default,
39
+ linear: async () => (await import("./linear.ts")).default,
40
+ stripe: async () => (await import("./stripe.ts")).default,
41
+ resend: async () => (await import("./resend.ts")).default,
42
+ sendgrid: async () => (await import("./sendgrid.ts")).default,
43
+ mailgun: async () => (await import("./mailgun.ts")).default,
44
+ postmark: async () => (await import("./postmark.ts")).default,
45
+ clerk: async () => (await import("./clerk.ts")).default,
46
+ aws: async () => (await import("./aws.ts")).default,
47
+ };
48
+
49
+ export const providers = registry;
50
+
51
+ export function listProviderNames(): string[] {
52
+ return Object.keys(registry).sort();
53
+ }
54
+
55
+ export async function getProvider(name: string): Promise<Provider> {
56
+ const loader = registry[name];
57
+ if (!loader) throw new ProviderNotFoundError(name);
58
+ return loader();
59
+ }
@@ -0,0 +1,46 @@
1
+ import { makeApiKeyProvider } from "./_api-key.ts";
2
+ import { verifyFetch } from "./_helpers.ts";
3
+
4
+ /**
5
+ * Linear — v1 uses a personal API key (users create one at
6
+ * https://linear.app/settings/api). OAuth is deferred to when we register the
7
+ * Ashlr Stack app with Linear (see provider-auth-matrix.md).
8
+ */
9
+ export default makeApiKeyProvider({
10
+ name: "linear",
11
+ displayName: "Linear",
12
+ category: "tickets",
13
+ docs: "https://developers.linear.app/docs",
14
+ secretName: "LINEAR_API_KEY",
15
+ howTo: "Create a personal API key at https://linear.app/settings/api",
16
+ dashboard: "https://linear.app",
17
+ mcp: {
18
+ name: "linear",
19
+ type: "stdio",
20
+ command: "npx",
21
+ args: ["-y", "mcp-linear"],
22
+ env: { LINEAR_API_KEY: "$(phantom reveal LINEAR_API_KEY)" },
23
+ },
24
+ async verify(key) {
25
+ try {
26
+ // GraphQL POST but a read-only `{ viewer }` query — opt-in idempotent.
27
+ const res = await verifyFetch("https://api.linear.app/graphql", {
28
+ method: "POST",
29
+ headers: { "content-type": "application/json", Authorization: key },
30
+ body: JSON.stringify({ query: "{ viewer { id name email } }" }),
31
+ });
32
+ if (!res.ok) return undefined;
33
+ const body = (await res.json()) as {
34
+ data?: { viewer?: { id: string; name: string; email: string } };
35
+ };
36
+ if (!body.data?.viewer?.id) return undefined;
37
+ return {
38
+ id: body.data.viewer.id,
39
+ name: body.data.viewer.name,
40
+ email: body.data.viewer.email,
41
+ };
42
+ } catch {
43
+ return undefined;
44
+ }
45
+ },
46
+ });
@@ -0,0 +1,26 @@
1
+ import { makeApiKeyProvider } from "./_api-key.ts";
2
+ import { verifyFetch } from "./_helpers.ts";
3
+
4
+ // Mailgun's API uses HTTP Basic auth with the literal username `api` and the
5
+ // API key as password, not a bearer token — hence the inlined base64 header.
6
+ export default makeApiKeyProvider({
7
+ name: "mailgun",
8
+ displayName: "Mailgun",
9
+ category: "email",
10
+ docs: "https://documentation.mailgun.com/docs/mailgun/api-reference/",
11
+ secretName: "MAILGUN_API_KEY",
12
+ howTo: "Paste an API key from https://app.mailgun.com/settings/api_security.",
13
+ dashboard: "https://app.mailgun.com",
14
+ async verify(key) {
15
+ try {
16
+ const res = await verifyFetch("https://api.mailgun.net/v3/domains", {
17
+ headers: { Authorization: `Basic ${btoa(`api:${key}`)}` },
18
+ });
19
+ if (!res.ok) return undefined;
20
+ const body = (await res.json()) as { items?: Array<{ name: string }> };
21
+ return { domains: String(body.items?.length ?? 0) };
22
+ } catch {
23
+ return undefined;
24
+ }
25
+ },
26
+ });
@@ -0,0 +1,27 @@
1
+ import { makeApiKeyProvider } from "./_api-key.ts";
2
+
3
+ /**
4
+ * Modal — serverless GPU / sandbox platform for AI workloads. v1 accepts the
5
+ * token + token-secret pair (Modal uses two credentials: a token ID and a
6
+ * token secret, generated via `modal token new`). Users paste them separated
7
+ * by a colon: `<token-id>:<token-secret>`.
8
+ */
9
+ export default makeApiKeyProvider({
10
+ name: "modal",
11
+ displayName: "Modal",
12
+ category: "deploy",
13
+ docs: "https://modal.com/docs",
14
+ secretName: "MODAL_TOKEN",
15
+ howTo:
16
+ "Run `modal token new` locally, then paste as `<token-id>:<token-secret>` (or create at https://modal.com/settings/tokens)",
17
+ dashboard: "https://modal.com",
18
+ async verify(key) {
19
+ if (!key.includes(":")) return undefined;
20
+ const [id, secret] = key.split(":");
21
+ if (!id || !secret) return undefined;
22
+ // Modal doesn't expose a public lightweight verify endpoint; we shape-check
23
+ // the token. First real API call (modal deploy) will surface any issue.
24
+ if (!id.startsWith("ak-") && !id.startsWith("as-")) return undefined;
25
+ return { token_id: id };
26
+ },
27
+ });
@@ -0,0 +1,175 @@
1
+ import type { ServiceEntry } from "../config.ts";
2
+ import { StackError } from "../errors.ts";
3
+ import { addSecret } from "../phantom.ts";
4
+ import { fetchWithRetry } from "../http.ts";
5
+ import { readLine, tryRevealSecret } from "./_helpers.ts";
6
+ import type {
7
+ AuthHandle,
8
+ HealthStatus,
9
+ Materialized,
10
+ Provider,
11
+ ProviderContext,
12
+ ProvisionOpts,
13
+ Resource,
14
+ } from "./_base.ts";
15
+
16
+ /**
17
+ * Neon — serverless Postgres. v1 uses a Neon API key (users create one at
18
+ * https://console.neon.tech/app/settings/api-keys). Provision creates a new
19
+ * project (and thus a default branch + database + role), materialize returns
20
+ * the connection string.
21
+ *
22
+ * API reference: https://api-docs.neon.tech/reference/getting-started-with-neon-api
23
+ */
24
+
25
+ const API = "https://console.neon.tech/api/v2";
26
+ const TOKEN_SECRET = "NEON_API_KEY";
27
+
28
+ const neon: Provider = {
29
+ name: "neon",
30
+ displayName: "Neon",
31
+ category: "database",
32
+ authKind: "pat",
33
+ docs: "https://neon.tech/docs/reference/api-reference",
34
+
35
+ async login(ctx: ProviderContext): Promise<AuthHandle> {
36
+ const cached = await tryRevealSecret(TOKEN_SECRET);
37
+ if (cached) {
38
+ const identity = await fetchIdentity(cached);
39
+ if (identity) return { token: cached, identity };
40
+ ctx.log({ level: "warn", msg: "Cached Neon key invalid; re-entering." });
41
+ }
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();
47
+ const identity = await fetchIdentity(token);
48
+ if (!identity) throw new StackError("NEON_AUTH_INVALID", "Neon rejected that API key.");
49
+ await addSecret(TOKEN_SECRET, token);
50
+ return { token, identity };
51
+ },
52
+
53
+ async provision(_ctx, auth, opts: ProvisionOpts): Promise<Resource> {
54
+ if (opts.existingResourceId) {
55
+ const project = await fetchProject(auth.token, opts.existingResourceId);
56
+ if (!project)
57
+ throw new StackError("NEON_PROJECT_NOT_FOUND", `No Neon project ${opts.existingResourceId}.`);
58
+ return toResource(project);
59
+ }
60
+ const name = `stack-${Date.now().toString(36)}`;
61
+ const region = (opts.hints?.region as string | undefined) ?? "aws-us-east-2";
62
+ const res = await fetch(`${API}/projects`, {
63
+ method: "POST",
64
+ headers: authHeaders(auth.token),
65
+ body: JSON.stringify({ project: { name, region_id: region } }),
66
+ });
67
+ if (!res.ok)
68
+ throw new StackError(
69
+ "NEON_CREATE_FAILED",
70
+ `Neon project creation failed (${res.status}): ${await res.text()}`,
71
+ );
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
+ }
81
+ return toResource(body.project);
82
+ },
83
+
84
+ async materialize(_ctx, resource, auth): Promise<Materialized> {
85
+ // Always fetch the connection URI from the API — the vault's DATABASE_URL
86
+ // is a generic key that may belong to another project or another provider.
87
+ // Don't trust it as a cache here.
88
+ const connectionUri = await fetchConnectionUri(auth.token, resource.id);
89
+ if (!connectionUri)
90
+ throw new StackError(
91
+ "NEON_CONNECTION_UNAVAILABLE",
92
+ `Couldn't obtain a connection string for Neon project ${resource.id}.`,
93
+ );
94
+ return {
95
+ secrets: {
96
+ NEON_API_KEY: auth.token,
97
+ DATABASE_URL: connectionUri,
98
+ },
99
+ urls: { dashboard: `https://console.neon.tech/app/projects/${resource.id}` },
100
+ };
101
+ },
102
+
103
+ async healthcheck(_ctx, entry: ServiceEntry): Promise<HealthStatus> {
104
+ const token = await tryRevealSecret(TOKEN_SECRET);
105
+ if (!token) return { kind: "error", detail: `${TOKEN_SECRET} missing from vault` };
106
+ if (!entry.resource_id) return { kind: "warn", detail: "no resource_id" };
107
+ const start = Date.now();
108
+ const project = await fetchProject(token, entry.resource_id);
109
+ const latencyMs = Date.now() - start;
110
+ return project ? { kind: "ok", latencyMs } : { kind: "error", detail: "project not found" };
111
+ },
112
+
113
+ dashboardUrl(entry: ServiceEntry): string {
114
+ return entry.resource_id
115
+ ? `https://console.neon.tech/app/projects/${entry.resource_id}`
116
+ : "https://console.neon.tech";
117
+ },
118
+ };
119
+
120
+ export default neon;
121
+
122
+ interface NeonProject {
123
+ id: string;
124
+ name: string;
125
+ region_id: string;
126
+ }
127
+
128
+ function authHeaders(token: string): HeadersInit {
129
+ return {
130
+ Authorization: `Bearer ${token}`,
131
+ "content-type": "application/json",
132
+ Accept: "application/json",
133
+ };
134
+ }
135
+
136
+ async function fetchIdentity(token: string): Promise<Record<string, string> | undefined> {
137
+ try {
138
+ // Idempotent GET — retry transient failures so a flaky Neon console
139
+ // gateway doesn't force the user to re-paste their API key.
140
+ const res = await fetchWithRetry(`${API}/users/me`, { headers: authHeaders(token) });
141
+ if (!res.ok) return undefined;
142
+ const body = (await res.json()) as Record<string, unknown>;
143
+ const out: Record<string, string> = {};
144
+ for (const [k, v] of Object.entries(body)) if (typeof v === "string") out[k] = v;
145
+ return out;
146
+ } catch {
147
+ return undefined;
148
+ }
149
+ }
150
+
151
+ async function fetchProject(token: string, id: string): Promise<NeonProject | undefined> {
152
+ // Idempotent GET — healthcheck + doctor both call this on every loop.
153
+ const res = await fetchWithRetry(`${API}/projects/${id}`, { headers: authHeaders(token) });
154
+ if (res.status === 404) return undefined;
155
+ if (!res.ok) return undefined;
156
+ const body = (await res.json()) as { project: NeonProject };
157
+ return body.project;
158
+ }
159
+
160
+ async function fetchConnectionUri(token: string, projectId: string): Promise<string | undefined> {
161
+ // Idempotent GET — retry transient failures so healthcheck + materialize
162
+ // don't flap on a single flaky Neon gateway response.
163
+ const res = await fetchWithRetry(`${API}/projects/${projectId}/connection_uri`, {
164
+ headers: authHeaders(token),
165
+ });
166
+ if (!res.ok) return undefined;
167
+ const body = (await res.json()) as { uri?: string };
168
+ return body.uri;
169
+ }
170
+
171
+ function toResource(project: NeonProject): Resource {
172
+ return { id: project.id, displayName: project.name, region: project.region_id };
173
+ }
174
+
175
+
@@ -0,0 +1,24 @@
1
+ import { makeApiKeyProvider } from "./_api-key.ts";
2
+ import { verifyFetch } from "./_helpers.ts";
3
+
4
+ export default makeApiKeyProvider({
5
+ name: "openai",
6
+ displayName: "OpenAI",
7
+ category: "ai",
8
+ docs: "https://platform.openai.com/docs/api-reference",
9
+ secretName: "OPENAI_API_KEY",
10
+ howTo: "Create a key at https://platform.openai.com/api-keys",
11
+ dashboard: "https://platform.openai.com",
12
+ async verify(key) {
13
+ try {
14
+ const res = await verifyFetch("https://api.openai.com/v1/models", {
15
+ headers: { Authorization: `Bearer ${key}` },
16
+ });
17
+ if (!res.ok) return undefined;
18
+ const body = (await res.json()) as { data?: Array<{ id: string }> };
19
+ return { models: String(body.data?.length ?? 0) };
20
+ } catch {
21
+ return undefined;
22
+ }
23
+ },
24
+ });
@@ -0,0 +1,30 @@
1
+ import { makeApiKeyProvider } from "./_api-key.ts";
2
+ import { verifyFetch } from "./_helpers.ts";
3
+
4
+ export default makeApiKeyProvider({
5
+ name: "posthog",
6
+ displayName: "PostHog",
7
+ category: "analytics",
8
+ docs: "https://posthog.com/docs/api",
9
+ secretName: "POSTHOG_PERSONAL_API_KEY",
10
+ howTo: "Create a personal API key at https://app.posthog.com/me/settings (scope: all)",
11
+ dashboard: "https://app.posthog.com",
12
+ mcp: {
13
+ name: "posthog",
14
+ type: "sse",
15
+ url: "https://mcp.posthog.com/sse",
16
+ headers: { Authorization: "Bearer $(phantom reveal POSTHOG_PERSONAL_API_KEY)" },
17
+ },
18
+ async verify(key) {
19
+ try {
20
+ const res = await verifyFetch("https://app.posthog.com/api/projects", {
21
+ headers: { Authorization: `Bearer ${key}` },
22
+ });
23
+ if (!res.ok) return undefined;
24
+ const body = (await res.json()) as { results?: Array<{ id: number; name: string }> };
25
+ return { projects: String(body.results?.length ?? 0) };
26
+ } catch {
27
+ return undefined;
28
+ }
29
+ },
30
+ });
@@ -0,0 +1,27 @@
1
+ import { makeApiKeyProvider } from "./_api-key.ts";
2
+ import { verifyFetch } from "./_helpers.ts";
3
+
4
+ export default makeApiKeyProvider({
5
+ name: "postmark",
6
+ displayName: "Postmark",
7
+ category: "email",
8
+ docs: "https://postmarkapp.com/developer/api/overview",
9
+ secretName: "POSTMARK_ACCOUNT_TOKEN",
10
+ howTo: "Paste an account token from https://account.postmarkapp.com/api_tokens.",
11
+ dashboard: "https://account.postmarkapp.com",
12
+ async verify(key) {
13
+ try {
14
+ const res = await verifyFetch("https://api.postmarkapp.com/servers", {
15
+ headers: {
16
+ "X-Postmark-Account-Token": key,
17
+ Accept: "application/json",
18
+ },
19
+ });
20
+ if (!res.ok) return undefined;
21
+ const body = (await res.json()) as { Servers?: Array<{ ID: number }> };
22
+ return { servers: String(body.Servers?.length ?? 0) };
23
+ } catch {
24
+ return undefined;
25
+ }
26
+ },
27
+ });
@@ -0,0 +1,35 @@
1
+ import { makeApiKeyProvider } from "./_api-key.ts";
2
+ import { verifyFetch } from "./_helpers.ts";
3
+
4
+ export default makeApiKeyProvider({
5
+ name: "railway",
6
+ displayName: "Railway",
7
+ category: "deploy",
8
+ docs: "https://docs.railway.app/reference/public-api",
9
+ secretName: "RAILWAY_TOKEN",
10
+ howTo: "Create a team or personal token at https://railway.app/account/tokens",
11
+ dashboard: "https://railway.app/dashboard",
12
+ async verify(key) {
13
+ try {
14
+ // GraphQL POST, but it's a read-only `{ me }` query — safe to retry,
15
+ // so we explicitly opt the helper into idempotent semantics.
16
+ const res = await verifyFetch("https://backboard.railway.app/graphql/v2", {
17
+ method: "POST",
18
+ headers: {
19
+ "content-type": "application/json",
20
+ Authorization: `Bearer ${key}`,
21
+ },
22
+ body: JSON.stringify({ query: "{ me { id email } }" }),
23
+ });
24
+ if (!res.ok) return undefined;
25
+ const body = (await res.json()) as { data?: { me?: { id?: string; email?: string } } };
26
+ if (!body.data?.me?.id) return undefined;
27
+ return {
28
+ id: body.data.me.id,
29
+ ...(body.data.me.email ? { email: body.data.me.email } : {}),
30
+ };
31
+ } catch {
32
+ return undefined;
33
+ }
34
+ },
35
+ });
@@ -0,0 +1,31 @@
1
+ import { makeApiKeyProvider } from "./_api-key.ts";
2
+ import { verifyFetch } from "./_helpers.ts";
3
+
4
+ /**
5
+ * Render — managed deploys for web services, static sites, private services.
6
+ * v1 accepts a user API key (create at https://dashboard.render.com/u/settings).
7
+ * Verification hits /v1/owners.
8
+ */
9
+ export default makeApiKeyProvider({
10
+ name: "render",
11
+ displayName: "Render",
12
+ category: "deploy",
13
+ docs: "https://api-docs.render.com",
14
+ secretName: "RENDER_API_KEY",
15
+ howTo: "Create an API key at https://dashboard.render.com/u/settings",
16
+ dashboard: "https://dashboard.render.com",
17
+ async verify(key) {
18
+ try {
19
+ const res = await verifyFetch("https://api.render.com/v1/owners", {
20
+ headers: { Authorization: `Bearer ${key}`, Accept: "application/json" },
21
+ });
22
+ if (!res.ok) return undefined;
23
+ const body = (await res.json()) as Array<{ owner?: { id?: string; name?: string } }>;
24
+ const first = body[0]?.owner;
25
+ if (!first?.id) return undefined;
26
+ return { id: first.id, name: first.name ?? "" };
27
+ } catch {
28
+ return undefined;
29
+ }
30
+ },
31
+ });