@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.
- package/LICENSE +21 -0
- package/README.md +57 -0
- package/package.json +57 -0
- package/src/__tests__/_harness.ts +146 -0
- package/src/__tests__/api-key-providers.test.ts +237 -0
- package/src/__tests__/catalog-index.test.ts +103 -0
- package/src/__tests__/circuit-breaker.test.ts +112 -0
- package/src/__tests__/clone-url-validation.test.ts +47 -0
- package/src/__tests__/cloudflare-provider.test.ts +74 -0
- package/src/__tests__/completion-ci.test.ts +62 -0
- package/src/__tests__/config-split.test.ts +138 -0
- package/src/__tests__/config.test.ts +66 -0
- package/src/__tests__/deps.test.ts +58 -0
- package/src/__tests__/detect-source.test.ts +147 -0
- package/src/__tests__/detect.test.ts +67 -0
- package/src/__tests__/github-provider.test.ts +65 -0
- package/src/__tests__/helpers.test.ts +38 -0
- package/src/__tests__/http.test.ts +235 -0
- package/src/__tests__/import-integration.test.ts +87 -0
- package/src/__tests__/inference.test.ts +326 -0
- package/src/__tests__/neon-provider.test.ts +79 -0
- package/src/__tests__/openai-provider.test.ts +54 -0
- package/src/__tests__/phantom-harness.test.ts +44 -0
- package/src/__tests__/phantom-wire.test.ts +85 -0
- package/src/__tests__/pipeline-integration.test.ts +95 -0
- package/src/__tests__/pipeline-recovery.test.ts +106 -0
- package/src/__tests__/pipeline.test.ts +92 -0
- package/src/__tests__/recipe.test.ts +164 -0
- package/src/__tests__/recommend-apply-e2e.test.ts +222 -0
- package/src/__tests__/registry.test.ts +78 -0
- package/src/__tests__/secret-redaction.test.ts +170 -0
- package/src/__tests__/security.test.ts +80 -0
- package/src/__tests__/supabase-provider.test.ts +108 -0
- package/src/__tests__/templates.test.ts +26 -0
- package/src/__tests__/turso-provider.test.ts +71 -0
- package/src/ai/catalog-index.ts +256 -0
- package/src/ai/circuit-breaker.ts +116 -0
- package/src/ai/cost-tracker.ts +103 -0
- package/src/ai/inference.ts +470 -0
- package/src/ai/phantom-wire.ts +184 -0
- package/src/ai/recipe.ts +178 -0
- package/src/catalog.ts +430 -0
- package/src/config.ts +293 -0
- package/src/detect-source.ts +344 -0
- package/src/detect.ts +207 -0
- package/src/env.ts +35 -0
- package/src/errors.ts +29 -0
- package/src/http.ts +212 -0
- package/src/index.ts +61 -0
- package/src/mcp-writer.ts +50 -0
- package/src/oauth.ts +184 -0
- package/src/phantom.ts +156 -0
- package/src/pipeline.ts +131 -0
- package/src/providers/_api-key.ts +122 -0
- package/src/providers/_base.ts +96 -0
- package/src/providers/_helpers.ts +81 -0
- package/src/providers/anthropic.ts +27 -0
- package/src/providers/aws.ts +198 -0
- package/src/providers/braintrust.ts +33 -0
- package/src/providers/clerk.ts +23 -0
- package/src/providers/cloudflare.ts +146 -0
- package/src/providers/convex.ts +28 -0
- package/src/providers/deepseek.ts +24 -0
- package/src/providers/firebase.ts +35 -0
- package/src/providers/fly.ts +24 -0
- package/src/providers/github.ts +180 -0
- package/src/providers/index.ts +59 -0
- package/src/providers/linear.ts +46 -0
- package/src/providers/mailgun.ts +26 -0
- package/src/providers/modal.ts +27 -0
- package/src/providers/neon.ts +175 -0
- package/src/providers/openai.ts +24 -0
- package/src/providers/posthog.ts +30 -0
- package/src/providers/postmark.ts +27 -0
- package/src/providers/railway.ts +35 -0
- package/src/providers/render.ts +31 -0
- package/src/providers/replicate.ts +30 -0
- package/src/providers/resend.ts +24 -0
- package/src/providers/sendgrid.ts +24 -0
- package/src/providers/sentry.ts +183 -0
- package/src/providers/stripe.ts +33 -0
- package/src/providers/supabase.ts +309 -0
- package/src/providers/turso.ts +197 -0
- package/src/providers/upstash.ts +32 -0
- package/src/providers/vercel.ts +134 -0
- package/src/providers/xai.ts +24 -0
- package/src/registry.ts +114 -0
- package/src/templates.ts +39 -0
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { makeApiKeyProvider } from "./_api-key.ts";
|
|
2
|
+
import { verifyFetch } from "./_helpers.ts";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Replicate — run open-source ML models via API. v1 accepts a Replicate API
|
|
6
|
+
* token (users create at https://replicate.com/account/api-tokens) and
|
|
7
|
+
* verifies via a GET /account call.
|
|
8
|
+
*/
|
|
9
|
+
export default makeApiKeyProvider({
|
|
10
|
+
name: "replicate",
|
|
11
|
+
displayName: "Replicate",
|
|
12
|
+
category: "ai",
|
|
13
|
+
docs: "https://replicate.com/docs/reference/http",
|
|
14
|
+
secretName: "REPLICATE_API_TOKEN",
|
|
15
|
+
howTo: "Create a token at https://replicate.com/account/api-tokens",
|
|
16
|
+
dashboard: "https://replicate.com",
|
|
17
|
+
async verify(key) {
|
|
18
|
+
try {
|
|
19
|
+
const res = await verifyFetch("https://api.replicate.com/v1/account", {
|
|
20
|
+
headers: { Authorization: `Bearer ${key}` },
|
|
21
|
+
});
|
|
22
|
+
if (!res.ok) return undefined;
|
|
23
|
+
const body = (await res.json()) as { username?: string; type?: string };
|
|
24
|
+
if (!body.username) return undefined;
|
|
25
|
+
return { username: body.username, type: body.type ?? "" };
|
|
26
|
+
} catch {
|
|
27
|
+
return undefined;
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
});
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { makeApiKeyProvider } from "./_api-key.ts";
|
|
2
|
+
import { verifyFetch } from "./_helpers.ts";
|
|
3
|
+
|
|
4
|
+
export default makeApiKeyProvider({
|
|
5
|
+
name: "resend",
|
|
6
|
+
displayName: "Resend",
|
|
7
|
+
category: "email",
|
|
8
|
+
docs: "https://resend.com/docs",
|
|
9
|
+
secretName: "RESEND_API_KEY",
|
|
10
|
+
howTo: "Create a key at https://resend.com/api-keys",
|
|
11
|
+
dashboard: "https://resend.com",
|
|
12
|
+
async verify(key) {
|
|
13
|
+
try {
|
|
14
|
+
const res = await verifyFetch("https://api.resend.com/domains", {
|
|
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 { domains: String(body.data?.length ?? 0) };
|
|
20
|
+
} catch {
|
|
21
|
+
return undefined;
|
|
22
|
+
}
|
|
23
|
+
},
|
|
24
|
+
});
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { makeApiKeyProvider } from "./_api-key.ts";
|
|
2
|
+
import { verifyFetch } from "./_helpers.ts";
|
|
3
|
+
|
|
4
|
+
export default makeApiKeyProvider({
|
|
5
|
+
name: "sendgrid",
|
|
6
|
+
displayName: "SendGrid",
|
|
7
|
+
category: "email",
|
|
8
|
+
docs: "https://docs.sendgrid.com/api-reference",
|
|
9
|
+
secretName: "SENDGRID_API_KEY",
|
|
10
|
+
howTo: "Paste an API key from https://app.sendgrid.com/settings/api_keys.",
|
|
11
|
+
dashboard: "https://app.sendgrid.com",
|
|
12
|
+
async verify(key) {
|
|
13
|
+
try {
|
|
14
|
+
const res = await verifyFetch("https://api.sendgrid.com/v3/scopes", {
|
|
15
|
+
headers: { Authorization: `Bearer ${key}` },
|
|
16
|
+
});
|
|
17
|
+
if (!res.ok) return undefined;
|
|
18
|
+
const body = (await res.json()) as { scopes?: string[] };
|
|
19
|
+
return { scopes: String(body.scopes?.length ?? 0) };
|
|
20
|
+
} catch {
|
|
21
|
+
return undefined;
|
|
22
|
+
}
|
|
23
|
+
},
|
|
24
|
+
});
|
|
@@ -0,0 +1,183 @@
|
|
|
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
|
+
* Sentry — error tracking. v1 uses an Auth Token (users create one at
|
|
18
|
+
* https://sentry.io/settings/account/api/auth-tokens/ with scope `project:write`
|
|
19
|
+
* `org:read`). Provision attaches to an existing project by slug; we don't
|
|
20
|
+
* auto-create in v1 because Sentry projects require a platform choice.
|
|
21
|
+
*
|
|
22
|
+
* API reference: https://docs.sentry.io/api/
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
const API = "https://sentry.io/api/0";
|
|
26
|
+
const TOKEN_SECRET = "SENTRY_AUTH_TOKEN";
|
|
27
|
+
|
|
28
|
+
const sentry: Provider = {
|
|
29
|
+
name: "sentry",
|
|
30
|
+
displayName: "Sentry",
|
|
31
|
+
category: "errors",
|
|
32
|
+
authKind: "pat",
|
|
33
|
+
docs: "https://docs.sentry.io/api/",
|
|
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 Sentry token invalid." });
|
|
41
|
+
}
|
|
42
|
+
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();
|
|
47
|
+
const identity = await fetchIdentity(token);
|
|
48
|
+
if (!identity) throw new StackError("SENTRY_AUTH_INVALID", "Sentry rejected that token.");
|
|
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 [orgSlug, projectSlug] = opts.existingResourceId.split("/");
|
|
56
|
+
if (!orgSlug || !projectSlug)
|
|
57
|
+
throw new StackError(
|
|
58
|
+
"SENTRY_BAD_RESOURCE_ID",
|
|
59
|
+
"Sentry resource id must be `org-slug/project-slug`.",
|
|
60
|
+
);
|
|
61
|
+
return { id: opts.existingResourceId, displayName: projectSlug };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const orgs = await fetchOrgs(auth.token);
|
|
65
|
+
if (orgs.length === 0)
|
|
66
|
+
throw new StackError("SENTRY_NO_ORGS", "No Sentry organizations accessible by this token.");
|
|
67
|
+
const orgSlug = orgs[0].slug;
|
|
68
|
+
const projects = await fetchProjects(auth.token, orgSlug);
|
|
69
|
+
if (projects.length === 0)
|
|
70
|
+
throw new StackError(
|
|
71
|
+
"SENTRY_NO_PROJECTS",
|
|
72
|
+
`Org ${orgSlug} has no Sentry projects yet. Create one in the dashboard, then retry with --use ${orgSlug}/<slug>.`,
|
|
73
|
+
);
|
|
74
|
+
const project = projects[0];
|
|
75
|
+
ctx.log({
|
|
76
|
+
level: "info",
|
|
77
|
+
msg: `Attaching to Sentry project ${orgSlug}/${project.slug}. Use --use to pick a different one.`,
|
|
78
|
+
});
|
|
79
|
+
return { id: `${orgSlug}/${project.slug}`, displayName: project.name };
|
|
80
|
+
},
|
|
81
|
+
|
|
82
|
+
async materialize(_ctx, resource, auth): Promise<Materialized> {
|
|
83
|
+
const [orgSlug, projectSlug] = resource.id.split("/");
|
|
84
|
+
const dsn = await fetchDsn(auth.token, orgSlug, projectSlug);
|
|
85
|
+
const secrets: Record<string, string> = { SENTRY_AUTH_TOKEN: auth.token };
|
|
86
|
+
if (dsn) secrets.SENTRY_DSN = dsn;
|
|
87
|
+
secrets.SENTRY_ORG = orgSlug;
|
|
88
|
+
secrets.SENTRY_PROJECT = projectSlug;
|
|
89
|
+
return {
|
|
90
|
+
secrets,
|
|
91
|
+
mcp: {
|
|
92
|
+
name: "sentry",
|
|
93
|
+
type: "stdio",
|
|
94
|
+
command: "npx",
|
|
95
|
+
args: ["-y", "@sentry/mcp-server"],
|
|
96
|
+
env: {
|
|
97
|
+
SENTRY_AUTH_TOKEN: `$(phantom reveal ${TOKEN_SECRET})`,
|
|
98
|
+
SENTRY_HOST: "https://sentry.io",
|
|
99
|
+
},
|
|
100
|
+
},
|
|
101
|
+
urls: { dashboard: `https://sentry.io/organizations/${orgSlug}/projects/${projectSlug}/` },
|
|
102
|
+
};
|
|
103
|
+
},
|
|
104
|
+
|
|
105
|
+
async healthcheck(_ctx, entry: ServiceEntry): Promise<HealthStatus> {
|
|
106
|
+
const token = await tryRevealSecret(TOKEN_SECRET);
|
|
107
|
+
if (!token) return { kind: "error", detail: `${TOKEN_SECRET} missing from vault` };
|
|
108
|
+
if (!entry.resource_id) return { kind: "warn", detail: "no resource_id" };
|
|
109
|
+
const [orgSlug, projectSlug] = entry.resource_id.split("/");
|
|
110
|
+
const start = Date.now();
|
|
111
|
+
// Idempotent GET — retry transient 429/5xx so a Sentry hiccup doesn't
|
|
112
|
+
// falsely mark the healthcheck red.
|
|
113
|
+
const res = await fetchWithRetry(`${API}/projects/${orgSlug}/${projectSlug}/`, {
|
|
114
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
115
|
+
});
|
|
116
|
+
const latencyMs = Date.now() - start;
|
|
117
|
+
return res.ok
|
|
118
|
+
? { kind: "ok", latencyMs }
|
|
119
|
+
: { kind: "error", detail: `HTTP ${res.status}` };
|
|
120
|
+
},
|
|
121
|
+
|
|
122
|
+
dashboardUrl(entry: ServiceEntry): string {
|
|
123
|
+
if (!entry.resource_id) return "https://sentry.io";
|
|
124
|
+
const [orgSlug, projectSlug] = entry.resource_id.split("/");
|
|
125
|
+
return `https://sentry.io/organizations/${orgSlug}/projects/${projectSlug}/`;
|
|
126
|
+
},
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
export default sentry;
|
|
130
|
+
|
|
131
|
+
async function fetchIdentity(token: string): Promise<Record<string, string> | undefined> {
|
|
132
|
+
try {
|
|
133
|
+
// Idempotent GET — retry so a flaky Sentry gateway doesn't force re-paste.
|
|
134
|
+
const res = await fetchWithRetry(`${API}/users/me/`, {
|
|
135
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
136
|
+
});
|
|
137
|
+
if (!res.ok) return undefined;
|
|
138
|
+
const body = (await res.json()) as Record<string, unknown>;
|
|
139
|
+
const out: Record<string, string> = {};
|
|
140
|
+
for (const [k, v] of Object.entries(body)) if (typeof v === "string") out[k] = v;
|
|
141
|
+
return out;
|
|
142
|
+
} catch {
|
|
143
|
+
// Network errors surface in healthchecks; don't let them throw uncaught.
|
|
144
|
+
return undefined;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async function fetchOrgs(token: string): Promise<Array<{ slug: string; name: string }>> {
|
|
149
|
+
// Idempotent GET — retry transient 429/5xx.
|
|
150
|
+
const res = await fetchWithRetry(`${API}/organizations/`, {
|
|
151
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
152
|
+
});
|
|
153
|
+
if (!res.ok) return [];
|
|
154
|
+
return (await res.json()) as Array<{ slug: string; name: string }>;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async function fetchProjects(
|
|
158
|
+
token: string,
|
|
159
|
+
orgSlug: string,
|
|
160
|
+
): Promise<Array<{ slug: string; name: string }>> {
|
|
161
|
+
// Idempotent GET.
|
|
162
|
+
const res = await fetchWithRetry(`${API}/organizations/${orgSlug}/projects/`, {
|
|
163
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
164
|
+
});
|
|
165
|
+
if (!res.ok) return [];
|
|
166
|
+
return (await res.json()) as Array<{ slug: string; name: string }>;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async function fetchDsn(
|
|
170
|
+
token: string,
|
|
171
|
+
orgSlug: string,
|
|
172
|
+
projectSlug: string,
|
|
173
|
+
): Promise<string | undefined> {
|
|
174
|
+
// Idempotent GET — called by materialize to fetch the DSN.
|
|
175
|
+
const res = await fetchWithRetry(`${API}/projects/${orgSlug}/${projectSlug}/keys/`, {
|
|
176
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
177
|
+
});
|
|
178
|
+
if (!res.ok) return undefined;
|
|
179
|
+
const keys = (await res.json()) as Array<{ dsn?: { public?: string } }>;
|
|
180
|
+
return keys[0]?.dsn?.public;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { makeApiKeyProvider } from "./_api-key.ts";
|
|
2
|
+
import { verifyFetch } from "./_helpers.ts";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Stripe — v1 uses a restricted secret key (users create at
|
|
6
|
+
* https://dashboard.stripe.com/apikeys). Full Stripe Connect / account
|
|
7
|
+
* linking lands when we register the Ashlr Stack OAuth app with Stripe.
|
|
8
|
+
*/
|
|
9
|
+
export default makeApiKeyProvider({
|
|
10
|
+
name: "stripe",
|
|
11
|
+
displayName: "Stripe",
|
|
12
|
+
category: "payments",
|
|
13
|
+
docs: "https://docs.stripe.com/api",
|
|
14
|
+
secretName: "STRIPE_SECRET_KEY",
|
|
15
|
+
howTo:
|
|
16
|
+
"Create a restricted key at https://dashboard.stripe.com/apikeys (use test mode for development)",
|
|
17
|
+
dashboard: "https://dashboard.stripe.com",
|
|
18
|
+
async verify(key) {
|
|
19
|
+
try {
|
|
20
|
+
const res = await verifyFetch("https://api.stripe.com/v1/account", {
|
|
21
|
+
headers: { Authorization: `Bearer ${key}` },
|
|
22
|
+
});
|
|
23
|
+
if (!res.ok) return undefined;
|
|
24
|
+
const body = (await res.json()) as Record<string, unknown>;
|
|
25
|
+
const out: Record<string, string> = {};
|
|
26
|
+
for (const [k, v] of Object.entries(body))
|
|
27
|
+
if (typeof v === "string") out[k] = v;
|
|
28
|
+
return out;
|
|
29
|
+
} catch {
|
|
30
|
+
return undefined;
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
});
|
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type AuthHandle,
|
|
3
|
+
type HealthStatus,
|
|
4
|
+
type Materialized,
|
|
5
|
+
type Provider,
|
|
6
|
+
type ProviderContext,
|
|
7
|
+
type ProvisionOpts,
|
|
8
|
+
type Resource,
|
|
9
|
+
} from "./_base.ts";
|
|
10
|
+
import type { ServiceEntry } from "../config.ts";
|
|
11
|
+
import { StackError } from "../errors.ts";
|
|
12
|
+
import { addSecret, exec as phantomExec } from "../phantom.ts";
|
|
13
|
+
import { readLine, tryRevealSecret } from "./_helpers.ts";
|
|
14
|
+
import { fetchWithRetry } from "../http.ts";
|
|
15
|
+
import { runPkceFlow } from "../oauth.ts";
|
|
16
|
+
import { resolveOAuthClientId } from "../env.ts";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Supabase — the Wave 2 pilot provider. Proves the full loop:
|
|
20
|
+
* login (OAuth PKCE or PAT) → provision (create project via Management API)
|
|
21
|
+
* → materialize (fetch URL + anon key + service role key) → MCP wiring.
|
|
22
|
+
*
|
|
23
|
+
* Management API docs: https://supabase.com/docs/reference/api/introduction
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
const API = "https://api.supabase.com";
|
|
27
|
+
const STACK_PAT_SECRET = "STACK_SUPABASE_ACCESS_TOKEN";
|
|
28
|
+
|
|
29
|
+
const supabase: Provider = {
|
|
30
|
+
name: "supabase",
|
|
31
|
+
displayName: "Supabase",
|
|
32
|
+
category: "database",
|
|
33
|
+
authKind: "oauth_pkce",
|
|
34
|
+
docs: "https://supabase.com/docs/reference/api/introduction",
|
|
35
|
+
|
|
36
|
+
async login(ctx: ProviderContext): Promise<AuthHandle> {
|
|
37
|
+
const clientId = resolveOAuthClientId("supabase", process.env.SUPABASE_STACK_CLIENT_ID);
|
|
38
|
+
|
|
39
|
+
// Path A — cached PAT from a previous login
|
|
40
|
+
const cached = await tryRevealSecret(STACK_PAT_SECRET);
|
|
41
|
+
if (cached) {
|
|
42
|
+
const identity = await fetchIdentity(cached);
|
|
43
|
+
if (identity) {
|
|
44
|
+
ctx.log({ level: "info", msg: `Reusing cached Supabase token for ${identity.email ?? identity.id}` });
|
|
45
|
+
return { token: cached, identity };
|
|
46
|
+
}
|
|
47
|
+
ctx.log({ level: "warn", msg: "Cached Supabase token invalid; re-authenticating." });
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Path B — PKCE OAuth (requires registered Ashlr Stack OAuth client)
|
|
51
|
+
if (clientId) {
|
|
52
|
+
const tokenResponse = await runPkceFlow({
|
|
53
|
+
providerName: "Supabase",
|
|
54
|
+
clientId,
|
|
55
|
+
authUrl: `${API}/v1/oauth/authorize`,
|
|
56
|
+
tokenUrl: `${API}/v1/oauth/token`,
|
|
57
|
+
});
|
|
58
|
+
const token = tokenResponse.access_token;
|
|
59
|
+
await addSecret(STACK_PAT_SECRET, token);
|
|
60
|
+
const identity = await fetchIdentity(token);
|
|
61
|
+
return {
|
|
62
|
+
token,
|
|
63
|
+
identity,
|
|
64
|
+
expiresAt: tokenResponse.expires_in
|
|
65
|
+
? Date.now() + tokenResponse.expires_in * 1000
|
|
66
|
+
: undefined,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Path C — PAT paste fallback (pre-OAuth-app registration)
|
|
71
|
+
if (!ctx.interactive) {
|
|
72
|
+
throw new StackError(
|
|
73
|
+
"SUPABASE_AUTH_REQUIRED",
|
|
74
|
+
"Set SUPABASE_STACK_CLIENT_ID for OAuth, or provide an access token interactively.",
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Avoid a hard dependency on @clack/prompts in the core package — read from stdin.
|
|
79
|
+
process.stderr.write(
|
|
80
|
+
"\n Create a Personal Access Token at https://supabase.com/dashboard/account/tokens\n Paste it here: ",
|
|
81
|
+
);
|
|
82
|
+
const token = (await readLine()).trim();
|
|
83
|
+
if (!token) throw new StackError("SUPABASE_AUTH_REQUIRED", "No token provided.");
|
|
84
|
+
const identity = await fetchIdentity(token);
|
|
85
|
+
if (!identity)
|
|
86
|
+
throw new StackError("SUPABASE_AUTH_INVALID", "Supabase rejected that access token.");
|
|
87
|
+
await addSecret(STACK_PAT_SECRET, token);
|
|
88
|
+
return { token, identity };
|
|
89
|
+
},
|
|
90
|
+
|
|
91
|
+
async provision(
|
|
92
|
+
ctx: ProviderContext,
|
|
93
|
+
auth: AuthHandle,
|
|
94
|
+
opts: ProvisionOpts,
|
|
95
|
+
): Promise<Resource> {
|
|
96
|
+
if (opts.existingResourceId) {
|
|
97
|
+
const project = await fetchProject(auth.token, opts.existingResourceId);
|
|
98
|
+
if (!project)
|
|
99
|
+
throw new StackError(
|
|
100
|
+
"SUPABASE_PROJECT_NOT_FOUND",
|
|
101
|
+
`Could not find project ${opts.existingResourceId}. Double-check the ref.`,
|
|
102
|
+
);
|
|
103
|
+
return toResource(project);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const orgs = await fetchOrganizations(auth.token);
|
|
107
|
+
if (orgs.length === 0)
|
|
108
|
+
throw new StackError(
|
|
109
|
+
"SUPABASE_NO_ORGS",
|
|
110
|
+
"This Supabase account has no organizations. Create one in the dashboard first.",
|
|
111
|
+
);
|
|
112
|
+
const orgId = orgs[0].id;
|
|
113
|
+
if (orgs.length > 1) {
|
|
114
|
+
ctx.log({
|
|
115
|
+
level: "warn",
|
|
116
|
+
msg: `Multiple Supabase orgs; defaulting to "${orgs[0].name}". Use --use <project_ref> to pick a specific project.`,
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const name = `stack-${Date.now().toString(36)}`;
|
|
121
|
+
const region = (opts.hints?.region as string | undefined) ?? "us-east-1";
|
|
122
|
+
const dbPass = generateStrongPassword();
|
|
123
|
+
|
|
124
|
+
const res = await fetch(`${API}/v1/projects`, {
|
|
125
|
+
method: "POST",
|
|
126
|
+
headers: authHeaders(auth.token),
|
|
127
|
+
body: JSON.stringify({
|
|
128
|
+
name,
|
|
129
|
+
organization_id: orgId,
|
|
130
|
+
db_pass: dbPass,
|
|
131
|
+
region,
|
|
132
|
+
plan: "free",
|
|
133
|
+
}),
|
|
134
|
+
});
|
|
135
|
+
if (!res.ok) {
|
|
136
|
+
throw new StackError(
|
|
137
|
+
"SUPABASE_CREATE_FAILED",
|
|
138
|
+
`Supabase project creation failed (${res.status}): ${await res.text()}`,
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
const project = (await res.json()) as SupabaseProject;
|
|
142
|
+
// Stash the DB password as a secret too — it's otherwise unrecoverable.
|
|
143
|
+
await addSecret(`SUPABASE_DB_PASSWORD_${project.id}`, dbPass);
|
|
144
|
+
return toResource(project);
|
|
145
|
+
},
|
|
146
|
+
|
|
147
|
+
async materialize(
|
|
148
|
+
_ctx: ProviderContext,
|
|
149
|
+
resource: Resource,
|
|
150
|
+
auth: AuthHandle,
|
|
151
|
+
): Promise<Materialized> {
|
|
152
|
+
const keys = await fetchApiKeys(auth.token, resource.id);
|
|
153
|
+
const anon = keys.find((k) => k.name === "anon")?.api_key;
|
|
154
|
+
const service = keys.find((k) => k.name === "service_role")?.api_key;
|
|
155
|
+
if (!anon || !service) {
|
|
156
|
+
throw new StackError(
|
|
157
|
+
"SUPABASE_KEYS_MISSING",
|
|
158
|
+
`Supabase did not return both anon and service_role keys for ${resource.id}.`,
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
const url = `https://${resource.id}.supabase.co`;
|
|
162
|
+
return {
|
|
163
|
+
secrets: {
|
|
164
|
+
SUPABASE_URL: url,
|
|
165
|
+
SUPABASE_ANON_KEY: anon,
|
|
166
|
+
SUPABASE_SERVICE_ROLE_KEY: service,
|
|
167
|
+
},
|
|
168
|
+
mcp: {
|
|
169
|
+
name: "supabase",
|
|
170
|
+
type: "stdio",
|
|
171
|
+
command: "npx",
|
|
172
|
+
args: ["-y", "@supabase/mcp-server-supabase@latest", "--project-ref", resource.id],
|
|
173
|
+
env: {
|
|
174
|
+
SUPABASE_ACCESS_TOKEN: `$(phantom reveal ${STACK_PAT_SECRET})`,
|
|
175
|
+
},
|
|
176
|
+
},
|
|
177
|
+
urls: { project: url, dashboard: dashboardUrl(resource.id) },
|
|
178
|
+
};
|
|
179
|
+
},
|
|
180
|
+
|
|
181
|
+
async healthcheck(_ctx: ProviderContext, entry: ServiceEntry): Promise<HealthStatus> {
|
|
182
|
+
if (!entry.resource_id) return { kind: "error", detail: "missing resource_id" };
|
|
183
|
+
const anon = await tryRevealSecret("SUPABASE_ANON_KEY");
|
|
184
|
+
if (!anon) return { kind: "error", detail: "SUPABASE_ANON_KEY missing from vault" };
|
|
185
|
+
const start = Date.now();
|
|
186
|
+
try {
|
|
187
|
+
// GET against the REST root. Safe to retry on transient 5xx / 429.
|
|
188
|
+
const res = await fetchWithRetry(
|
|
189
|
+
`https://${entry.resource_id}.supabase.co/rest/v1/`,
|
|
190
|
+
{ headers: { apikey: anon, Authorization: `Bearer ${anon}` } },
|
|
191
|
+
);
|
|
192
|
+
const latencyMs = Date.now() - start;
|
|
193
|
+
if (res.ok) return { kind: "ok", latencyMs };
|
|
194
|
+
return { kind: "warn", detail: `HTTP ${res.status}` };
|
|
195
|
+
} catch (err) {
|
|
196
|
+
return { kind: "error", detail: (err as Error).message };
|
|
197
|
+
}
|
|
198
|
+
},
|
|
199
|
+
|
|
200
|
+
dashboardUrl(entry: ServiceEntry): string {
|
|
201
|
+
return entry.resource_id ? dashboardUrl(entry.resource_id) : "https://supabase.com/dashboard";
|
|
202
|
+
},
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
export default supabase;
|
|
206
|
+
|
|
207
|
+
// ---------------------------------------------------------------------------
|
|
208
|
+
// Supabase API helpers
|
|
209
|
+
// ---------------------------------------------------------------------------
|
|
210
|
+
|
|
211
|
+
interface SupabaseProject {
|
|
212
|
+
id: string;
|
|
213
|
+
name: string;
|
|
214
|
+
region: string;
|
|
215
|
+
organization_id: string;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
interface SupabaseOrg {
|
|
219
|
+
id: string;
|
|
220
|
+
name: string;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
interface SupabaseApiKey {
|
|
224
|
+
name: "anon" | "service_role" | string;
|
|
225
|
+
api_key: string;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
type SupabaseIdentity = Record<string, string>;
|
|
229
|
+
|
|
230
|
+
function authHeaders(token: string): HeadersInit {
|
|
231
|
+
return {
|
|
232
|
+
Authorization: `Bearer ${token}`,
|
|
233
|
+
"content-type": "application/json",
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
async function fetchIdentity(token: string): Promise<SupabaseIdentity | undefined> {
|
|
238
|
+
try {
|
|
239
|
+
// Idempotent GET — retry transient 429/5xx so a flapping Supabase gateway
|
|
240
|
+
// doesn't force the user to re-paste their PAT.
|
|
241
|
+
const res = await fetchWithRetry(`${API}/v1/profile`, { headers: authHeaders(token) });
|
|
242
|
+
if (!res.ok) return undefined;
|
|
243
|
+
const body = (await res.json()) as Record<string, unknown>;
|
|
244
|
+
const out: SupabaseIdentity = {};
|
|
245
|
+
for (const [k, v] of Object.entries(body)) {
|
|
246
|
+
if (typeof v === "string") out[k] = v;
|
|
247
|
+
}
|
|
248
|
+
return out;
|
|
249
|
+
} catch {
|
|
250
|
+
return undefined;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
async function fetchOrganizations(token: string): Promise<SupabaseOrg[]> {
|
|
255
|
+
// Idempotent GET — retry transient 429/5xx.
|
|
256
|
+
const res = await fetchWithRetry(`${API}/v1/organizations`, { headers: authHeaders(token) });
|
|
257
|
+
if (!res.ok)
|
|
258
|
+
throw new StackError(
|
|
259
|
+
"SUPABASE_ORGS_FAILED",
|
|
260
|
+
`Couldn't list Supabase orgs (${res.status}): ${await res.text()}`,
|
|
261
|
+
);
|
|
262
|
+
return (await res.json()) as SupabaseOrg[];
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
async function fetchProject(token: string, ref: string): Promise<SupabaseProject | undefined> {
|
|
266
|
+
// Idempotent GET — retry transient failures before surfacing 404 vs. outage.
|
|
267
|
+
const res = await fetchWithRetry(`${API}/v1/projects/${ref}`, { headers: authHeaders(token) });
|
|
268
|
+
if (res.status === 404) return undefined;
|
|
269
|
+
if (!res.ok)
|
|
270
|
+
throw new StackError(
|
|
271
|
+
"SUPABASE_PROJECT_FAILED",
|
|
272
|
+
`Couldn't fetch Supabase project ${ref} (${res.status}).`,
|
|
273
|
+
);
|
|
274
|
+
return (await res.json()) as SupabaseProject;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
async function fetchApiKeys(token: string, ref: string): Promise<SupabaseApiKey[]> {
|
|
278
|
+
// Idempotent GET — materialize calls this immediately after provision.
|
|
279
|
+
const res = await fetchWithRetry(`${API}/v1/projects/${ref}/api-keys`, {
|
|
280
|
+
headers: authHeaders(token),
|
|
281
|
+
});
|
|
282
|
+
if (!res.ok)
|
|
283
|
+
throw new StackError(
|
|
284
|
+
"SUPABASE_KEYS_FAILED",
|
|
285
|
+
`Couldn't fetch Supabase API keys (${res.status}): ${await res.text()}`,
|
|
286
|
+
);
|
|
287
|
+
return (await res.json()) as SupabaseApiKey[];
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function toResource(project: SupabaseProject): Resource {
|
|
291
|
+
return {
|
|
292
|
+
id: project.id,
|
|
293
|
+
displayName: project.name,
|
|
294
|
+
region: project.region,
|
|
295
|
+
meta: { organization_id: project.organization_id },
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function dashboardUrl(ref: string): string {
|
|
300
|
+
return `https://supabase.com/dashboard/project/${ref}`;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function generateStrongPassword(): string {
|
|
304
|
+
const bytes = crypto.getRandomValues(new Uint8Array(24));
|
|
305
|
+
return Buffer.from(bytes).toString("base64").replace(/[/+=]/g, "").slice(0, 32);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// Silence unused-import linters — phantomExec may be used in future extensions.
|
|
309
|
+
void phantomExec;
|