@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,81 @@
|
|
|
1
|
+
import { revealSecret } from "../phantom.ts";
|
|
2
|
+
import { fetchWithRetry, type RetryOptions } from "../http.ts";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Shared helpers for every hand-written provider. Before this existed, each
|
|
6
|
+
* provider shipped a byte-for-byte copy of `readLine` and `tryRevealSecret` —
|
|
7
|
+
* one source of truth keeps behaviour consistent.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export async function tryRevealSecret(key: string): Promise<string | undefined> {
|
|
11
|
+
try {
|
|
12
|
+
const value = await revealSecret(key);
|
|
13
|
+
return value.length > 0 ? value : undefined;
|
|
14
|
+
} catch {
|
|
15
|
+
return undefined;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Read a single line from stdin. Used by every provider's PAT-paste fallback
|
|
21
|
+
* when no OAuth client is configured. Resumes stdin, reads until newline, then
|
|
22
|
+
* pauses stdin so the parent process doesn't hang waiting for EOF.
|
|
23
|
+
*/
|
|
24
|
+
export async function readLine(): Promise<string> {
|
|
25
|
+
return new Promise((resolve) => {
|
|
26
|
+
let buf = "";
|
|
27
|
+
const onData = (chunk: Buffer) => {
|
|
28
|
+
buf += chunk.toString();
|
|
29
|
+
if (buf.includes("\n")) {
|
|
30
|
+
process.stdin.off("data", onData);
|
|
31
|
+
process.stdin.pause();
|
|
32
|
+
resolve(buf.split("\n")[0]);
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
process.stdin.resume();
|
|
36
|
+
process.stdin.on("data", onData);
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Build a standard Authorization + content-type + Accept header set for
|
|
42
|
+
* Bearer-token providers. Providers with custom auth header shapes (Anthropic,
|
|
43
|
+
* Upstash, Linear, Supabase PKCE) inline their own.
|
|
44
|
+
*/
|
|
45
|
+
export function bearerJsonHeaders(token: string): HeadersInit {
|
|
46
|
+
return {
|
|
47
|
+
Authorization: `Bearer ${token}`,
|
|
48
|
+
"content-type": "application/json",
|
|
49
|
+
Accept: "application/json",
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Idempotent GET wrapper for the `verify` closures used by api-key providers
|
|
55
|
+
* (and for any other read-only provider call). Thin shim around
|
|
56
|
+
* `fetchWithRetry` that forces `idempotent: true` — callers are always
|
|
57
|
+
* verifying a credential via a read-only endpoint, so retrying transient
|
|
58
|
+
* 429/5xx is always safe. Keeps each provider's `verify` free of retry glue.
|
|
59
|
+
*/
|
|
60
|
+
export function verifyFetch(
|
|
61
|
+
input: RequestInfo | URL,
|
|
62
|
+
init?: RequestInit,
|
|
63
|
+
opts?: RetryOptions,
|
|
64
|
+
): Promise<Response> {
|
|
65
|
+
return fetchWithRetry(input, init, { ...opts, idempotent: true });
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Redact all but the last `keepLast` characters of a secret for safe display.
|
|
70
|
+
* Always show at least a few asterisks so log lines that include the redacted
|
|
71
|
+
* value still read clearly. Short strings get fully hidden — the suffix alone
|
|
72
|
+
* could be enough to bruteforce a 6-char token.
|
|
73
|
+
*
|
|
74
|
+
* Usage: `ctx.log({ msg: \`token ${scrub(t)} rejected\` })` prints
|
|
75
|
+
* `token ****abcd rejected`, never the raw secret.
|
|
76
|
+
*/
|
|
77
|
+
export function scrub(value: string, keepLast: number = 4): string {
|
|
78
|
+
if (!value) return "";
|
|
79
|
+
if (value.length <= keepLast) return "****";
|
|
80
|
+
return `****${value.slice(-keepLast)}`;
|
|
81
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { makeApiKeyProvider } from "./_api-key.ts";
|
|
2
|
+
import { verifyFetch } from "./_helpers.ts";
|
|
3
|
+
|
|
4
|
+
export default makeApiKeyProvider({
|
|
5
|
+
name: "anthropic",
|
|
6
|
+
displayName: "Anthropic",
|
|
7
|
+
category: "ai",
|
|
8
|
+
docs: "https://docs.anthropic.com/en/api",
|
|
9
|
+
secretName: "ANTHROPIC_API_KEY",
|
|
10
|
+
howTo: "Create a key at https://console.anthropic.com/settings/keys",
|
|
11
|
+
dashboard: "https://console.anthropic.com",
|
|
12
|
+
async verify(key) {
|
|
13
|
+
try {
|
|
14
|
+
const res = await verifyFetch("https://api.anthropic.com/v1/models", {
|
|
15
|
+
headers: {
|
|
16
|
+
"x-api-key": key,
|
|
17
|
+
"anthropic-version": "2023-06-01",
|
|
18
|
+
},
|
|
19
|
+
});
|
|
20
|
+
if (!res.ok) return undefined;
|
|
21
|
+
const body = (await res.json()) as { data?: Array<{ id: string }> };
|
|
22
|
+
return { models: String(body.data?.length ?? 0) };
|
|
23
|
+
} catch {
|
|
24
|
+
return undefined;
|
|
25
|
+
}
|
|
26
|
+
},
|
|
27
|
+
});
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import type { ServiceEntry } from "../config.ts";
|
|
2
|
+
import { createHash, createHmac } from "node:crypto";
|
|
3
|
+
import { StackError } from "../errors.ts";
|
|
4
|
+
import { addSecret } from "../phantom.ts";
|
|
5
|
+
import { readLine, tryRevealSecret } from "./_helpers.ts";
|
|
6
|
+
import type {
|
|
7
|
+
AuthHandle,
|
|
8
|
+
HealthStatus,
|
|
9
|
+
Materialized,
|
|
10
|
+
Provider,
|
|
11
|
+
ProviderContext,
|
|
12
|
+
Resource,
|
|
13
|
+
} from "./_base.ts";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* AWS — v1 accepts an IAM access key pair (access key id + secret access key).
|
|
17
|
+
* Verifies via STS GetCallerIdentity using a hand-rolled SigV4 signer so we
|
|
18
|
+
* don't pull in the AWS SDK (it's a big dep for one call). Stores keys in the
|
|
19
|
+
* Phantom vault and optionally writes ~/.aws/credentials for use by the AWS
|
|
20
|
+
* CLI / SDKs outside Stack.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
const SECRET_ID_KEY = "AWS_ACCESS_KEY_ID";
|
|
24
|
+
const SECRET_SECRET_KEY = "AWS_SECRET_ACCESS_KEY";
|
|
25
|
+
const SECRET_REGION = "AWS_REGION";
|
|
26
|
+
|
|
27
|
+
const aws: Provider = {
|
|
28
|
+
name: "aws",
|
|
29
|
+
displayName: "AWS",
|
|
30
|
+
category: "cloud",
|
|
31
|
+
authKind: "api_key",
|
|
32
|
+
docs: "https://docs.aws.amazon.com/iam/",
|
|
33
|
+
|
|
34
|
+
async login(ctx: ProviderContext): Promise<AuthHandle> {
|
|
35
|
+
const cachedId = await tryRevealSecret(SECRET_ID_KEY);
|
|
36
|
+
const cachedSecret = await tryRevealSecret(SECRET_SECRET_KEY);
|
|
37
|
+
if (cachedId && cachedSecret) {
|
|
38
|
+
const identity = await callStsIdentity(cachedId, cachedSecret, "us-east-1");
|
|
39
|
+
if (identity) return { token: `${cachedId}:${cachedSecret}`, identity };
|
|
40
|
+
ctx.log({ level: "warn", msg: "Cached AWS credentials invalid." });
|
|
41
|
+
}
|
|
42
|
+
if (!ctx.interactive)
|
|
43
|
+
throw new StackError("AWS_AUTH_REQUIRED", "No valid AWS credentials in vault.");
|
|
44
|
+
process.stderr.write(
|
|
45
|
+
"\n Create an IAM access key at https://console.aws.amazon.com/iam/home#/security_credentials\n AWS_ACCESS_KEY_ID: ",
|
|
46
|
+
);
|
|
47
|
+
const accessKeyId = (await readLine()).trim();
|
|
48
|
+
process.stderr.write(" AWS_SECRET_ACCESS_KEY: ");
|
|
49
|
+
const secretAccessKey = (await readLine()).trim();
|
|
50
|
+
const identity = await callStsIdentity(accessKeyId, secretAccessKey, "us-east-1");
|
|
51
|
+
if (!identity) throw new StackError("AWS_AUTH_INVALID", "AWS rejected those credentials.");
|
|
52
|
+
await addSecret(SECRET_ID_KEY, accessKeyId);
|
|
53
|
+
await addSecret(SECRET_SECRET_KEY, secretAccessKey);
|
|
54
|
+
return { token: `${accessKeyId}:${secretAccessKey}`, identity };
|
|
55
|
+
},
|
|
56
|
+
|
|
57
|
+
async provision(_ctx, auth, opts): Promise<Resource> {
|
|
58
|
+
// v1: attach to the account the keys belong to.
|
|
59
|
+
const region = (opts.hints?.region as string | undefined) ?? "us-east-1";
|
|
60
|
+
return {
|
|
61
|
+
id: auth.identity?.Account ?? "unknown",
|
|
62
|
+
displayName: auth.identity?.Arn ?? "AWS account",
|
|
63
|
+
region,
|
|
64
|
+
};
|
|
65
|
+
},
|
|
66
|
+
|
|
67
|
+
async materialize(_ctx, resource, auth): Promise<Materialized> {
|
|
68
|
+
const sep = auth.token.indexOf(":");
|
|
69
|
+
if (sep <= 0 || sep === auth.token.length - 1) {
|
|
70
|
+
throw new StackError(
|
|
71
|
+
"AWS_AUTH_MALFORMED",
|
|
72
|
+
"AWS auth handle is malformed (expected accessKeyId:secretAccessKey).",
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
const accessKeyId = auth.token.slice(0, sep);
|
|
76
|
+
const secretAccessKey = auth.token.slice(sep + 1);
|
|
77
|
+
if (!accessKeyId || !secretAccessKey) {
|
|
78
|
+
throw new StackError(
|
|
79
|
+
"AWS_AUTH_MALFORMED",
|
|
80
|
+
"AWS auth handle missing access key id or secret access key.",
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
await addSecret(SECRET_REGION, resource.region ?? "us-east-1");
|
|
84
|
+
return {
|
|
85
|
+
secrets: {
|
|
86
|
+
AWS_ACCESS_KEY_ID: accessKeyId,
|
|
87
|
+
AWS_SECRET_ACCESS_KEY: secretAccessKey,
|
|
88
|
+
AWS_REGION: resource.region ?? "us-east-1",
|
|
89
|
+
},
|
|
90
|
+
};
|
|
91
|
+
},
|
|
92
|
+
|
|
93
|
+
async healthcheck(_ctx, _entry: ServiceEntry): Promise<HealthStatus> {
|
|
94
|
+
const id = await tryRevealSecret(SECRET_ID_KEY);
|
|
95
|
+
const secret = await tryRevealSecret(SECRET_SECRET_KEY);
|
|
96
|
+
const region = (await tryRevealSecret(SECRET_REGION)) ?? "us-east-1";
|
|
97
|
+
if (!id || !secret) return { kind: "error", detail: "AWS credentials missing from vault" };
|
|
98
|
+
const start = Date.now();
|
|
99
|
+
const identity = await callStsIdentity(id, secret, region);
|
|
100
|
+
const latencyMs = Date.now() - start;
|
|
101
|
+
return identity ? { kind: "ok", latencyMs } : { kind: "error", detail: "credentials invalid" };
|
|
102
|
+
},
|
|
103
|
+
|
|
104
|
+
dashboardUrl(): string {
|
|
105
|
+
return "https://console.aws.amazon.com";
|
|
106
|
+
},
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
export default aws;
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Minimal SigV4 signer for a single call: STS GetCallerIdentity. Scoped tight
|
|
113
|
+
* on purpose — any more and we should pull in @aws-sdk/client-sts.
|
|
114
|
+
*/
|
|
115
|
+
async function callStsIdentity(
|
|
116
|
+
accessKeyId: string,
|
|
117
|
+
secretAccessKey: string,
|
|
118
|
+
region: string,
|
|
119
|
+
): Promise<Record<string, string> | undefined> {
|
|
120
|
+
// us-east-1 uses the unregional endpoint; everything else uses sts.<region>.amazonaws.com.
|
|
121
|
+
const host = region === "us-east-1" ? "sts.amazonaws.com" : `sts.${region}.amazonaws.com`;
|
|
122
|
+
const service = "sts";
|
|
123
|
+
const method = "POST";
|
|
124
|
+
const now = new Date();
|
|
125
|
+
const amzDate = toAmzDate(now);
|
|
126
|
+
const datestamp = amzDate.slice(0, 8);
|
|
127
|
+
const payload = "Action=GetCallerIdentity&Version=2011-06-15";
|
|
128
|
+
const payloadHash = createHash("sha256").update(payload).digest("hex");
|
|
129
|
+
|
|
130
|
+
const canonicalHeaders =
|
|
131
|
+
`content-type:application/x-www-form-urlencoded\n` +
|
|
132
|
+
`host:${host}\n` +
|
|
133
|
+
`x-amz-date:${amzDate}\n`;
|
|
134
|
+
const signedHeaders = "content-type;host;x-amz-date";
|
|
135
|
+
const canonicalRequest = [
|
|
136
|
+
method,
|
|
137
|
+
"/",
|
|
138
|
+
"",
|
|
139
|
+
canonicalHeaders,
|
|
140
|
+
signedHeaders,
|
|
141
|
+
payloadHash,
|
|
142
|
+
].join("\n");
|
|
143
|
+
const scope = `${datestamp}/${region}/${service}/aws4_request`;
|
|
144
|
+
const stringToSign = [
|
|
145
|
+
"AWS4-HMAC-SHA256",
|
|
146
|
+
amzDate,
|
|
147
|
+
scope,
|
|
148
|
+
createHash("sha256").update(canonicalRequest).digest("hex"),
|
|
149
|
+
].join("\n");
|
|
150
|
+
|
|
151
|
+
const kDate = hmac(`AWS4${secretAccessKey}`, datestamp);
|
|
152
|
+
const kRegion = hmac(kDate, region);
|
|
153
|
+
const kService = hmac(kRegion, service);
|
|
154
|
+
const kSigning = hmac(kService, "aws4_request");
|
|
155
|
+
const signature = createHmac("sha256", kSigning).update(stringToSign).digest("hex");
|
|
156
|
+
|
|
157
|
+
const authorization =
|
|
158
|
+
`AWS4-HMAC-SHA256 Credential=${accessKeyId}/${scope}, ` +
|
|
159
|
+
`SignedHeaders=${signedHeaders}, Signature=${signature}`;
|
|
160
|
+
|
|
161
|
+
try {
|
|
162
|
+
const res = await fetch(`https://${host}/`, {
|
|
163
|
+
method,
|
|
164
|
+
headers: {
|
|
165
|
+
"content-type": "application/x-www-form-urlencoded",
|
|
166
|
+
"x-amz-date": amzDate,
|
|
167
|
+
Authorization: authorization,
|
|
168
|
+
},
|
|
169
|
+
body: payload,
|
|
170
|
+
});
|
|
171
|
+
if (!res.ok) return undefined;
|
|
172
|
+
const xml = await res.text();
|
|
173
|
+
const arn = matchXml(xml, "Arn");
|
|
174
|
+
const userId = matchXml(xml, "UserId");
|
|
175
|
+
const account = matchXml(xml, "Account");
|
|
176
|
+
if (!arn) return undefined;
|
|
177
|
+
return {
|
|
178
|
+
Arn: arn,
|
|
179
|
+
...(userId ? { UserId: userId } : {}),
|
|
180
|
+
...(account ? { Account: account } : {}),
|
|
181
|
+
};
|
|
182
|
+
} catch {
|
|
183
|
+
return undefined;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function hmac(key: string | Buffer, value: string): Buffer {
|
|
188
|
+
return createHmac("sha256", key).update(value).digest();
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function toAmzDate(d: Date): string {
|
|
192
|
+
return d.toISOString().replace(/[-:]/g, "").replace(/\.\d{3}/, "");
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function matchXml(xml: string, tag: string): string | undefined {
|
|
196
|
+
const m = xml.match(new RegExp(`<${tag}>([^<]+)<\\/${tag}>`));
|
|
197
|
+
return m?.[1];
|
|
198
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { makeApiKeyProvider } from "./_api-key.ts";
|
|
2
|
+
import { verifyFetch } from "./_helpers.ts";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Braintrust — LLM eval + observability. v1 accepts a Braintrust API key
|
|
6
|
+
* (create at https://www.braintrust.dev/app/settings/api-keys) and verifies
|
|
7
|
+
* via /v1/organization (hits `https://api.braintrust.dev`).
|
|
8
|
+
*/
|
|
9
|
+
export default makeApiKeyProvider({
|
|
10
|
+
name: "braintrust",
|
|
11
|
+
displayName: "Braintrust",
|
|
12
|
+
category: "ai",
|
|
13
|
+
docs: "https://www.braintrust.dev/docs",
|
|
14
|
+
secretName: "BRAINTRUST_API_KEY",
|
|
15
|
+
howTo: "Create a key at https://www.braintrust.dev/app/settings/api-keys",
|
|
16
|
+
dashboard: "https://www.braintrust.dev/app",
|
|
17
|
+
async verify(key) {
|
|
18
|
+
try {
|
|
19
|
+
const res = await verifyFetch("https://api.braintrust.dev/v1/organization", {
|
|
20
|
+
headers: { Authorization: `Bearer ${key}` },
|
|
21
|
+
});
|
|
22
|
+
if (!res.ok) return undefined;
|
|
23
|
+
const body = (await res.json()) as {
|
|
24
|
+
objects?: Array<{ id?: string; name?: string }>;
|
|
25
|
+
};
|
|
26
|
+
const first = body.objects?.[0];
|
|
27
|
+
if (!first?.id) return undefined;
|
|
28
|
+
return { id: first.id, name: first.name ?? "" };
|
|
29
|
+
} catch {
|
|
30
|
+
return undefined;
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
});
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { makeApiKeyProvider } from "./_api-key.ts";
|
|
2
|
+
import { verifyFetch } from "./_helpers.ts";
|
|
3
|
+
|
|
4
|
+
export default makeApiKeyProvider({
|
|
5
|
+
name: "clerk",
|
|
6
|
+
displayName: "Clerk",
|
|
7
|
+
category: "auth",
|
|
8
|
+
docs: "https://clerk.com/docs",
|
|
9
|
+
secretName: "CLERK_SECRET_KEY",
|
|
10
|
+
howTo: "Grab your secret key from https://dashboard.clerk.com → API Keys",
|
|
11
|
+
dashboard: "https://dashboard.clerk.com",
|
|
12
|
+
async verify(key) {
|
|
13
|
+
try {
|
|
14
|
+
const res = await verifyFetch("https://api.clerk.com/v1/jwks", {
|
|
15
|
+
headers: { Authorization: `Bearer ${key}` },
|
|
16
|
+
});
|
|
17
|
+
if (!res.ok) return undefined;
|
|
18
|
+
return { verified: "true" };
|
|
19
|
+
} catch {
|
|
20
|
+
return undefined;
|
|
21
|
+
}
|
|
22
|
+
},
|
|
23
|
+
});
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import type { ServiceEntry } from "../config.ts";
|
|
2
|
+
import { StackError } from "../errors.ts";
|
|
3
|
+
import { addSecret, revealSecret } from "../phantom.ts";
|
|
4
|
+
import { fetchWithRetry } from "../http.ts";
|
|
5
|
+
import type {
|
|
6
|
+
AuthHandle,
|
|
7
|
+
HealthStatus,
|
|
8
|
+
Materialized,
|
|
9
|
+
Provider,
|
|
10
|
+
ProviderContext,
|
|
11
|
+
ProvisionOpts,
|
|
12
|
+
Resource,
|
|
13
|
+
} from "./_base.ts";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Cloudflare — Workers / Pages / R2 / D1 / KV all hang off one Account API
|
|
17
|
+
* token. v1 uses a user-created token (users make one at
|
|
18
|
+
* https://dash.cloudflare.com/profile/api-tokens with minimal scopes). Auto-
|
|
19
|
+
* provisioning a new Worker is intentionally deferred — most real workflows
|
|
20
|
+
* start from a template or wrangler scaffold.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
const API = "https://api.cloudflare.com/client/v4";
|
|
24
|
+
const TOKEN_SECRET = "CLOUDFLARE_API_TOKEN";
|
|
25
|
+
|
|
26
|
+
const cloudflare: Provider = {
|
|
27
|
+
name: "cloudflare",
|
|
28
|
+
displayName: "Cloudflare",
|
|
29
|
+
category: "deploy",
|
|
30
|
+
authKind: "pat",
|
|
31
|
+
docs: "https://developers.cloudflare.com/api",
|
|
32
|
+
|
|
33
|
+
async login(ctx: ProviderContext): Promise<AuthHandle> {
|
|
34
|
+
const cached = await tryRevealSecret(TOKEN_SECRET);
|
|
35
|
+
if (cached) {
|
|
36
|
+
const identity = await verifyToken(cached);
|
|
37
|
+
if (identity) return { token: cached, identity };
|
|
38
|
+
ctx.log({ level: "warn", msg: "Cached Cloudflare token invalid." });
|
|
39
|
+
}
|
|
40
|
+
if (!ctx.interactive)
|
|
41
|
+
throw new StackError("CLOUDFLARE_AUTH_REQUIRED", "No valid Cloudflare token in vault.");
|
|
42
|
+
process.stderr.write(
|
|
43
|
+
"\n Create an API token at https://dash.cloudflare.com/profile/api-tokens\n Scopes: Account · Workers Scripts / Pages / R2 / D1 as needed\n Paste it here: ",
|
|
44
|
+
);
|
|
45
|
+
const token = (await readLine()).trim();
|
|
46
|
+
const identity = await verifyToken(token);
|
|
47
|
+
if (!identity) throw new StackError("CLOUDFLARE_AUTH_INVALID", "Cloudflare rejected that token.");
|
|
48
|
+
await addSecret(TOKEN_SECRET, token);
|
|
49
|
+
return { token, identity };
|
|
50
|
+
},
|
|
51
|
+
|
|
52
|
+
async provision(ctx, auth, opts: ProvisionOpts): Promise<Resource> {
|
|
53
|
+
const accounts = await fetchAccounts(auth.token);
|
|
54
|
+
if (accounts.length === 0)
|
|
55
|
+
throw new StackError("CLOUDFLARE_NO_ACCOUNT", "Token has no account scope.");
|
|
56
|
+
const accountId = (opts.hints?.accountId as string | undefined) ?? accounts[0].id;
|
|
57
|
+
if (accounts.length > 1) {
|
|
58
|
+
ctx.log({
|
|
59
|
+
level: "warn",
|
|
60
|
+
msg: `Multiple Cloudflare accounts; defaulting to "${accounts[0].name}". Pass --use <account-id> to pick.`,
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
return {
|
|
64
|
+
id: opts.existingResourceId ?? accountId,
|
|
65
|
+
displayName: accounts.find((a) => a.id === accountId)?.name ?? accountId,
|
|
66
|
+
meta: { account_id: accountId },
|
|
67
|
+
};
|
|
68
|
+
},
|
|
69
|
+
|
|
70
|
+
async materialize(_ctx, resource, auth): Promise<Materialized> {
|
|
71
|
+
return {
|
|
72
|
+
secrets: {
|
|
73
|
+
CLOUDFLARE_API_TOKEN: auth.token,
|
|
74
|
+
CLOUDFLARE_ACCOUNT_ID: resource.id,
|
|
75
|
+
},
|
|
76
|
+
urls: { dashboard: `https://dash.cloudflare.com/${resource.id}` },
|
|
77
|
+
};
|
|
78
|
+
},
|
|
79
|
+
|
|
80
|
+
async healthcheck(_ctx, _entry: ServiceEntry): Promise<HealthStatus> {
|
|
81
|
+
const token = await tryRevealSecret(TOKEN_SECRET);
|
|
82
|
+
if (!token) return { kind: "error", detail: `${TOKEN_SECRET} missing from vault` };
|
|
83
|
+
const start = Date.now();
|
|
84
|
+
const identity = await verifyToken(token);
|
|
85
|
+
const latencyMs = Date.now() - start;
|
|
86
|
+
return identity ? { kind: "ok", latencyMs } : { kind: "error", detail: "token invalid" };
|
|
87
|
+
},
|
|
88
|
+
|
|
89
|
+
dashboardUrl(entry: ServiceEntry): string {
|
|
90
|
+
return entry.resource_id
|
|
91
|
+
? `https://dash.cloudflare.com/${entry.resource_id}`
|
|
92
|
+
: "https://dash.cloudflare.com";
|
|
93
|
+
},
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
export default cloudflare;
|
|
97
|
+
|
|
98
|
+
async function verifyToken(token: string): Promise<Record<string, string> | undefined> {
|
|
99
|
+
try {
|
|
100
|
+
// Idempotent GET — healthcheck hits this on every loop.
|
|
101
|
+
const res = await fetchWithRetry(`${API}/user/tokens/verify`, {
|
|
102
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
103
|
+
});
|
|
104
|
+
if (!res.ok) return undefined;
|
|
105
|
+
const body = (await res.json()) as { result?: { id?: string; status?: string } };
|
|
106
|
+
if (body.result?.status !== "active") return undefined;
|
|
107
|
+
return { token_id: body.result.id ?? "" };
|
|
108
|
+
} catch {
|
|
109
|
+
return undefined;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async function fetchAccounts(token: string): Promise<Array<{ id: string; name: string }>> {
|
|
114
|
+
// Idempotent GET — retry transient failures on `stack add` startup.
|
|
115
|
+
const res = await fetchWithRetry(`${API}/accounts`, {
|
|
116
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
117
|
+
});
|
|
118
|
+
if (!res.ok) return [];
|
|
119
|
+
const body = (await res.json()) as { result?: Array<{ id: string; name: string }> };
|
|
120
|
+
return body.result ?? [];
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async function tryRevealSecret(key: string): Promise<string | undefined> {
|
|
124
|
+
try {
|
|
125
|
+
const v = await revealSecret(key);
|
|
126
|
+
return v.length > 0 ? v : undefined;
|
|
127
|
+
} catch {
|
|
128
|
+
return undefined;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async function readLine(): Promise<string> {
|
|
133
|
+
return new Promise((resolve) => {
|
|
134
|
+
let buf = "";
|
|
135
|
+
const onData = (chunk: Buffer) => {
|
|
136
|
+
buf += chunk.toString();
|
|
137
|
+
if (buf.includes("\n")) {
|
|
138
|
+
process.stdin.off("data", onData);
|
|
139
|
+
process.stdin.pause();
|
|
140
|
+
resolve(buf.split("\n")[0]);
|
|
141
|
+
}
|
|
142
|
+
};
|
|
143
|
+
process.stdin.resume();
|
|
144
|
+
process.stdin.on("data", onData);
|
|
145
|
+
});
|
|
146
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { makeApiKeyProvider } from "./_api-key.ts";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Convex — reactive backend with subscriptions + scheduled functions. v1 uses
|
|
5
|
+
* a deploy key (users create one at https://dashboard.convex.dev → Project
|
|
6
|
+
* Settings → Deploy Keys). Creating Convex deployments programmatically
|
|
7
|
+
* requires their deploy CLI with a browser flow — out of scope for v1.
|
|
8
|
+
*/
|
|
9
|
+
export default makeApiKeyProvider({
|
|
10
|
+
name: "convex",
|
|
11
|
+
displayName: "Convex",
|
|
12
|
+
category: "database",
|
|
13
|
+
docs: "https://docs.convex.dev",
|
|
14
|
+
secretName: "CONVEX_DEPLOY_KEY",
|
|
15
|
+
howTo:
|
|
16
|
+
"Create a deploy key at https://dashboard.convex.dev (Project → Settings → Deploy Keys).",
|
|
17
|
+
dashboard: "https://dashboard.convex.dev",
|
|
18
|
+
async verify(key) {
|
|
19
|
+
// Convex deploy keys have the shape prod:<team>:<project>|<token>. No
|
|
20
|
+
// public validate-only endpoint, so we do a shape check and defer real
|
|
21
|
+
// validation to first CLI use (convex deploy / npx convex dev).
|
|
22
|
+
if (!key.includes(":") || !key.includes("|")) return undefined;
|
|
23
|
+
const [prefix] = key.split("|");
|
|
24
|
+
const parts = prefix.split(":");
|
|
25
|
+
if (parts.length < 3) return undefined;
|
|
26
|
+
return { environment: parts[0], team: parts[1], project: parts[2] };
|
|
27
|
+
},
|
|
28
|
+
});
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { makeApiKeyProvider } from "./_api-key.ts";
|
|
2
|
+
import { verifyFetch } from "./_helpers.ts";
|
|
3
|
+
|
|
4
|
+
export default makeApiKeyProvider({
|
|
5
|
+
name: "deepseek",
|
|
6
|
+
displayName: "DeepSeek",
|
|
7
|
+
category: "ai",
|
|
8
|
+
docs: "https://api-docs.deepseek.com",
|
|
9
|
+
secretName: "DEEPSEEK_API_KEY",
|
|
10
|
+
howTo: "Create a key at https://platform.deepseek.com/api_keys",
|
|
11
|
+
dashboard: "https://platform.deepseek.com",
|
|
12
|
+
async verify(key) {
|
|
13
|
+
try {
|
|
14
|
+
const res = await verifyFetch("https://api.deepseek.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,35 @@
|
|
|
1
|
+
import { makeApiKeyProvider } from "./_api-key.ts";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Firebase — v1 accepts a service-account JSON (users download from
|
|
5
|
+
* https://console.firebase.google.com → Project Settings → Service Accounts
|
|
6
|
+
* → Generate new private key). We store the entire JSON blob as a single
|
|
7
|
+
* secret and shape-check it has the fields Firebase SDKs need.
|
|
8
|
+
*/
|
|
9
|
+
export default makeApiKeyProvider({
|
|
10
|
+
name: "firebase",
|
|
11
|
+
displayName: "Firebase",
|
|
12
|
+
category: "database",
|
|
13
|
+
docs: "https://firebase.google.com/docs/admin/setup",
|
|
14
|
+
secretName: "FIREBASE_SERVICE_ACCOUNT_JSON",
|
|
15
|
+
howTo:
|
|
16
|
+
"Download a Service Account JSON at https://console.firebase.google.com → Settings → Service Accounts",
|
|
17
|
+
dashboard: "https://console.firebase.google.com",
|
|
18
|
+
async verify(blob) {
|
|
19
|
+
try {
|
|
20
|
+
const parsed = JSON.parse(blob) as {
|
|
21
|
+
type?: string;
|
|
22
|
+
project_id?: string;
|
|
23
|
+
client_email?: string;
|
|
24
|
+
};
|
|
25
|
+
if (parsed.type !== "service_account") return undefined;
|
|
26
|
+
if (!parsed.project_id || !parsed.client_email) return undefined;
|
|
27
|
+
return {
|
|
28
|
+
project_id: parsed.project_id,
|
|
29
|
+
client_email: parsed.client_email,
|
|
30
|
+
};
|
|
31
|
+
} catch {
|
|
32
|
+
return undefined;
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
});
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { makeApiKeyProvider } from "./_api-key.ts";
|
|
2
|
+
import { verifyFetch } from "./_helpers.ts";
|
|
3
|
+
|
|
4
|
+
export default makeApiKeyProvider({
|
|
5
|
+
name: "fly",
|
|
6
|
+
displayName: "Fly.io",
|
|
7
|
+
category: "deploy",
|
|
8
|
+
docs: "https://fly.io/docs/machines/api",
|
|
9
|
+
secretName: "FLY_API_TOKEN",
|
|
10
|
+
howTo: "Run `fly auth token` or create one at https://fly.io/user/personal_access_tokens",
|
|
11
|
+
dashboard: "https://fly.io/dashboard",
|
|
12
|
+
async verify(key) {
|
|
13
|
+
try {
|
|
14
|
+
const res = await verifyFetch("https://api.machines.dev/v1/apps", {
|
|
15
|
+
headers: { Authorization: `Bearer ${key}` },
|
|
16
|
+
});
|
|
17
|
+
if (!res.ok) return undefined;
|
|
18
|
+
const body = (await res.json()) as { apps?: Array<{ name: string }> };
|
|
19
|
+
return { apps: String(body.apps?.length ?? 0) };
|
|
20
|
+
} catch {
|
|
21
|
+
return undefined;
|
|
22
|
+
}
|
|
23
|
+
},
|
|
24
|
+
});
|