@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.
- package/LICENSE +1 -1
- package/README.md +26 -15
- package/package.json +1 -1
- package/src/__tests__/_harness.ts +67 -7
- package/src/__tests__/agent-onboard.test.ts +279 -0
- package/src/__tests__/api-key-providers.test.ts +6 -2
- package/src/__tests__/base-urls.test.ts +208 -0
- package/src/__tests__/catalog-index.test.ts +14 -20
- package/src/__tests__/catalog.test.ts +31 -0
- package/src/__tests__/cloudflare-provider.test.ts +32 -10
- package/src/__tests__/completion-ci.test.ts +239 -23
- package/src/__tests__/config-own-services.test.ts +183 -0
- package/src/__tests__/config-split.test.ts +272 -2
- package/src/__tests__/config.test.ts +94 -4
- package/src/__tests__/credential-staging.test.ts +660 -0
- package/src/__tests__/deps.test.ts +2 -1
- package/src/__tests__/detect-source.test.ts +183 -4
- package/src/__tests__/fixtures/stack-manifest.v1.json +10 -0
- package/src/__tests__/github-provider.test.ts +3 -2
- package/src/__tests__/helpers.test.ts +24 -4
- package/src/__tests__/http.test.ts +2 -2
- package/src/__tests__/import-integration.test.ts +18 -4
- package/src/__tests__/inference.test.ts +57 -13
- package/src/__tests__/interactive-prompt.test.ts +106 -0
- package/src/__tests__/mcp-mirror.test.ts +116 -0
- package/src/__tests__/neon-provider.test.ts +136 -3
- package/src/__tests__/new-providers.test.ts +470 -0
- package/src/__tests__/no-orphan-modules.test.ts +114 -0
- package/src/__tests__/oauth-port.test.ts +84 -0
- package/src/__tests__/openai-provider.test.ts +1 -1
- package/src/__tests__/phantom-contract.test.ts +177 -0
- package/src/__tests__/phantom-harness.test.ts +226 -8
- package/src/__tests__/phantom-process.test.ts +135 -0
- package/src/__tests__/phantom-project-preflight.test.ts +118 -0
- package/src/__tests__/phantom-wire.test.ts +403 -22
- package/src/__tests__/pipeline-integration.test.ts +33 -1
- package/src/__tests__/pipeline-recovery.test.ts +29 -28
- package/src/__tests__/pipeline-rollback.test.ts +509 -0
- package/src/__tests__/pipeline.test.ts +3 -1
- package/src/__tests__/pm-detect.test.ts +113 -0
- package/src/__tests__/provider-deprovision.test.ts +269 -0
- package/src/__tests__/provider-parity.test.ts +46 -0
- package/src/__tests__/publish-script.test.ts +704 -0
- package/src/__tests__/recipe.test.ts +27 -2
- package/src/__tests__/recommend-apply-e2e.test.ts +29 -27
- package/src/__tests__/registry.test.ts +11 -2
- package/src/__tests__/release-artifacts.test.ts +55 -0
- package/src/__tests__/scan-missing-dir.test.ts +45 -0
- package/src/__tests__/secret-redaction.test.ts +10 -10
- package/src/__tests__/security.test.ts +9 -10
- package/src/__tests__/stack-manifest.test.ts +38 -0
- package/src/__tests__/supabase-provider.test.ts +6 -4
- package/src/__tests__/swap.test.ts +114 -0
- package/src/__tests__/telemetry.test.ts +354 -0
- package/src/__tests__/template-litellm-gateway.test.ts +70 -0
- package/src/__tests__/template-temporal-worker-phantom.test.ts +45 -0
- package/src/__tests__/templates.test.ts +1 -1
- package/src/__tests__/turso-provider.test.ts +2 -5
- package/src/agent-onboard.ts +346 -0
- package/src/ai/catalog-index.ts +68 -10
- package/src/ai/circuit-breaker.ts +3 -6
- package/src/ai/cost-tracker.ts +6 -15
- package/src/ai/inference.ts +19 -11
- package/src/ai/phantom-wire.ts +73 -58
- package/src/ai/recipe.ts +69 -0
- package/src/base-urls.ts +71 -0
- package/src/catalog.ts +281 -2
- package/src/config.ts +31 -18
- package/src/detect-source.ts +50 -9
- package/src/detect.ts +13 -5
- package/src/errors.ts +6 -0
- package/src/http.ts +6 -5
- package/src/index.ts +16 -0
- package/src/mcp-writer.ts +97 -2
- package/src/oauth.ts +85 -53
- package/src/phantom-process.ts +72 -0
- package/src/phantom.ts +319 -30
- package/src/pipeline.ts +177 -68
- package/src/pm-detect.ts +56 -0
- package/src/providers/_api-key.ts +18 -17
- package/src/providers/_base.ts +28 -1
- package/src/providers/_credential-stage.ts +237 -0
- package/src/providers/_helpers.ts +55 -9
- package/src/providers/anthropic.ts +2 -1
- package/src/providers/auth0.ts +27 -0
- package/src/providers/aws.ts +32 -33
- package/src/providers/cloudflare.ts +18 -37
- package/src/providers/convex.ts +1 -2
- package/src/providers/datadog.ts +29 -0
- package/src/providers/digitalocean.ts +30 -0
- package/src/providers/gcp.ts +36 -0
- package/src/providers/github.ts +37 -35
- package/src/providers/grafana.ts +29 -0
- package/src/providers/hetzner.ts +29 -0
- package/src/providers/index.ts +13 -1
- package/src/providers/launchdarkly.ts +33 -0
- package/src/providers/mixpanel.ts +37 -0
- package/src/providers/neon.ts +50 -26
- package/src/providers/openai.ts +2 -1
- package/src/providers/plausible.ts +26 -0
- package/src/providers/sentry.ts +17 -17
- package/src/providers/stripe.ts +1 -2
- package/src/providers/supabase.ts +66 -37
- package/src/providers/turso.ts +49 -38
- package/src/providers/vercel.ts +50 -20
- package/src/providers/workos.ts +27 -0
- package/src/registry.ts +7 -11
- package/src/stack-manifest.ts +39 -0
- package/src/swap.ts +108 -0
- package/src/telemetry.ts +267 -0
- package/src/templates.ts +6 -2
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import { resolve } from "node:path";
|
|
2
|
+
import { StackError } from "../errors.ts";
|
|
3
|
+
import { addSecret, revealSecret } from "../phantom.ts";
|
|
4
|
+
import type { ProviderContext, Resource } from "./_base.ts";
|
|
5
|
+
|
|
6
|
+
type Phase = "login" | "provision" | "critical" | "materialize" | "commit";
|
|
7
|
+
interface Scope {
|
|
8
|
+
cwd: string;
|
|
9
|
+
phase: Phase;
|
|
10
|
+
inputs: Map<string, string>;
|
|
11
|
+
candidates: Map<string, string>;
|
|
12
|
+
existing: Map<string, string>;
|
|
13
|
+
critical: Map<Resource, Map<string, string>>;
|
|
14
|
+
createdCritical: Map<string, string>;
|
|
15
|
+
}
|
|
16
|
+
const scopes = new WeakMap<ProviderContext, Scope>();
|
|
17
|
+
// Keep only a no-values tombstone after disposal: a retained operation context
|
|
18
|
+
// must never fall through to standalone persistence after its operation ends.
|
|
19
|
+
const managed = new WeakSet<ProviderContext>();
|
|
20
|
+
|
|
21
|
+
function active(ctx: ProviderContext): Scope | undefined {
|
|
22
|
+
const scope = scopes.get(ctx);
|
|
23
|
+
if (!scope && managed.has(ctx)) {
|
|
24
|
+
throw new StackError("CREDENTIAL_SCOPE_CLOSED", "Credential operation has already ended.");
|
|
25
|
+
}
|
|
26
|
+
return scope;
|
|
27
|
+
}
|
|
28
|
+
function required(ctx: ProviderContext): Scope {
|
|
29
|
+
const scope = active(ctx);
|
|
30
|
+
if (!scope) throw new StackError("CREDENTIAL_SCOPE_REQUIRED", "Credential operation is missing.");
|
|
31
|
+
return scope;
|
|
32
|
+
}
|
|
33
|
+
function conflict(): never {
|
|
34
|
+
throw new StackError(
|
|
35
|
+
"CREDENTIAL_INPUT_CONFLICT",
|
|
36
|
+
"Credential inputs disagree for one secret slot.",
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
function validate(value: string): void {
|
|
40
|
+
if (/[\r\n]/.test(value)) {
|
|
41
|
+
throw new StackError(
|
|
42
|
+
"PHANTOM_SECRET_VALUE_UNSUPPORTED",
|
|
43
|
+
"Phantom Secrets accepts one line per secret; CR/LF values cannot be stored unchanged.",
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
function merge(inputs: Map<string, string>, key: string, value: string): void {
|
|
48
|
+
validate(value);
|
|
49
|
+
if (inputs.has(key) && inputs.get(key) !== value) conflict();
|
|
50
|
+
inputs.set(key, value);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Internal source coordination only; no public receipt or vault attestation. */
|
|
54
|
+
export function beginCredentialStage(ctx: ProviderContext): void {
|
|
55
|
+
if (managed.has(ctx)) {
|
|
56
|
+
throw new StackError("CREDENTIAL_SCOPE_CLOSED", "Credential context cannot be reused.");
|
|
57
|
+
}
|
|
58
|
+
managed.add(ctx);
|
|
59
|
+
scopes.set(ctx, {
|
|
60
|
+
cwd: resolve(ctx.cwd),
|
|
61
|
+
phase: "login",
|
|
62
|
+
inputs: new Map(),
|
|
63
|
+
candidates: new Map(),
|
|
64
|
+
existing: new Map(),
|
|
65
|
+
critical: new Map(),
|
|
66
|
+
createdCritical: new Map(),
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
export function credentialPhase(ctx: ProviderContext, phase: Phase): void {
|
|
70
|
+
const scope = required(ctx);
|
|
71
|
+
scope.candidates.clear();
|
|
72
|
+
scope.phase = phase;
|
|
73
|
+
}
|
|
74
|
+
export function closeCredentialStage(ctx: ProviderContext): void {
|
|
75
|
+
const scope = scopes.get(ctx);
|
|
76
|
+
if (!scope) return;
|
|
77
|
+
scope.inputs.clear();
|
|
78
|
+
scope.candidates.clear();
|
|
79
|
+
scope.existing.clear();
|
|
80
|
+
for (const values of scope.critical.values()) values.clear();
|
|
81
|
+
scope.critical.clear();
|
|
82
|
+
scope.createdCritical.clear();
|
|
83
|
+
scopes.delete(ctx);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export async function readProviderSecret(ctx: ProviderContext, key: string): Promise<string> {
|
|
87
|
+
const scope = active(ctx);
|
|
88
|
+
const value = await revealSecret(key, scope?.cwd ?? ctx.cwd);
|
|
89
|
+
if (scope) {
|
|
90
|
+
if (active(ctx) !== scope) {
|
|
91
|
+
throw new StackError("CREDENTIAL_SCOPE_CLOSED", "Credential operation has already ended.");
|
|
92
|
+
}
|
|
93
|
+
if (scope.phase === "login" && value) scope.candidates.set(key, value);
|
|
94
|
+
}
|
|
95
|
+
return value;
|
|
96
|
+
}
|
|
97
|
+
/** Called only by the original cached branch after its fresh verifier succeeds.
|
|
98
|
+
* Exact input equality authorizes omitting this operation's duplicate create;
|
|
99
|
+
* it does not attest a concurrently unchanged vault or authenticate arbitrary JS.
|
|
100
|
+
*/
|
|
101
|
+
export function acceptVerifiedExisting(ctx: ProviderContext, key: string, value: string): void {
|
|
102
|
+
const scope = active(ctx);
|
|
103
|
+
if (!scope) return;
|
|
104
|
+
if (scope.phase !== "login" || scope.candidates.get(key) !== value) {
|
|
105
|
+
throw new StackError(
|
|
106
|
+
"CREDENTIAL_EXISTING_UNCONFIRMED",
|
|
107
|
+
"Existing credential was not verified in this login.",
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
if (scope.inputs.has(key) && scope.inputs.get(key) !== value) conflict();
|
|
111
|
+
scope.existing.set(key, value);
|
|
112
|
+
scope.candidates.delete(key);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async function createCredential(key: string, value: string, cwd: string): Promise<void> {
|
|
116
|
+
try {
|
|
117
|
+
await addSecret(key, value, cwd);
|
|
118
|
+
} catch (err) {
|
|
119
|
+
// Producer output can contain input values. Preserve a recognized refusal
|
|
120
|
+
// code, never its raw message/cause or an upstream-derived secret name.
|
|
121
|
+
const code = err instanceof StackError ? err.code : "PHANTOM_EXEC_FAILED";
|
|
122
|
+
throw new StackError(
|
|
123
|
+
code,
|
|
124
|
+
code === "PHANTOM_SECRET_EXISTS"
|
|
125
|
+
? "Credential storage was refused; the secret name already exists."
|
|
126
|
+
: "Credential storage could not be confirmed.",
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
export async function commitOrdinaryCredential(
|
|
131
|
+
ctx: ProviderContext,
|
|
132
|
+
key: string,
|
|
133
|
+
value: string,
|
|
134
|
+
): Promise<void> {
|
|
135
|
+
const scope = required(ctx);
|
|
136
|
+
if (scope.phase !== "commit") {
|
|
137
|
+
throw new StackError("CREDENTIAL_PHASE_CLOSED", "Credential commit is not active.");
|
|
138
|
+
}
|
|
139
|
+
await createCredential(key, value, scope.cwd);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export async function storeProviderSecret(
|
|
143
|
+
ctx: ProviderContext,
|
|
144
|
+
key: string,
|
|
145
|
+
value: string,
|
|
146
|
+
): Promise<void> {
|
|
147
|
+
const scope = active(ctx);
|
|
148
|
+
if (!scope) return createCredential(key, value, ctx.cwd);
|
|
149
|
+
if (scope.phase !== "login" && scope.phase !== "provision" && scope.phase !== "materialize") {
|
|
150
|
+
throw new StackError("CREDENTIAL_PHASE_CLOSED", "Credential input collection has ended.");
|
|
151
|
+
}
|
|
152
|
+
if (scope.existing.has(key) && scope.existing.get(key) !== value) conflict();
|
|
153
|
+
merge(scope.inputs, key, value);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** The generated password is not recoverable from the API. Carry it privately
|
|
157
|
+
* on the exact returned resource until the pipeline owns failure cleanup.
|
|
158
|
+
* Standalone provision retains its immediate strict persistence contract.
|
|
159
|
+
*/
|
|
160
|
+
export async function carryResourceCredential(
|
|
161
|
+
ctx: ProviderContext,
|
|
162
|
+
resource: Resource,
|
|
163
|
+
key: string,
|
|
164
|
+
value: string,
|
|
165
|
+
): Promise<void> {
|
|
166
|
+
const scope = active(ctx);
|
|
167
|
+
if (!scope) return createCredential(key, value, ctx.cwd);
|
|
168
|
+
if (scope.phase !== "provision") {
|
|
169
|
+
throw new StackError("CREDENTIAL_PHASE_CLOSED", "Resource credential collection has ended.");
|
|
170
|
+
}
|
|
171
|
+
let values = scope.critical.get(resource);
|
|
172
|
+
if (!values) {
|
|
173
|
+
values = new Map();
|
|
174
|
+
scope.critical.set(resource, values);
|
|
175
|
+
}
|
|
176
|
+
merge(values, key, value);
|
|
177
|
+
}
|
|
178
|
+
export async function commitResourceCredentials(
|
|
179
|
+
ctx: ProviderContext,
|
|
180
|
+
resource: Resource,
|
|
181
|
+
created: (key: string) => void,
|
|
182
|
+
): Promise<void> {
|
|
183
|
+
const scope = required(ctx);
|
|
184
|
+
if (scope.phase !== "critical") {
|
|
185
|
+
throw new StackError("CREDENTIAL_PHASE_CLOSED", "Resource credential commit is not active.");
|
|
186
|
+
}
|
|
187
|
+
const values = scope.critical.get(resource);
|
|
188
|
+
if (!values) return;
|
|
189
|
+
// Take once before the first await. Refusal or unknown completion is never
|
|
190
|
+
// converted into a successful write or retried from this private carry.
|
|
191
|
+
scope.critical.delete(resource);
|
|
192
|
+
try {
|
|
193
|
+
for (const [key, value] of values) {
|
|
194
|
+
if (scope.inputs.has(key) && scope.inputs.get(key) !== value) conflict();
|
|
195
|
+
if (scope.existing.has(key) || scope.createdCritical.has(key)) conflict();
|
|
196
|
+
try {
|
|
197
|
+
await addSecret(key, value, scope.cwd);
|
|
198
|
+
} catch {
|
|
199
|
+
// The generated name includes an upstream identifier. Do not reflect
|
|
200
|
+
// that name, producer output or the password into a failure diagnostic.
|
|
201
|
+
throw new StackError(
|
|
202
|
+
"RESOURCE_CREDENTIAL_UNCONFIRMED",
|
|
203
|
+
"Resource credential storage was refused or could not be confirmed.",
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
scope.createdCritical.set(key, value);
|
|
207
|
+
created(key);
|
|
208
|
+
}
|
|
209
|
+
} finally {
|
|
210
|
+
values.clear();
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** Validate the entire ordinary plan before its first create. Critical resource
|
|
215
|
+
* writes necessarily precede materialize; a later conflict uses owned rollback.
|
|
216
|
+
* Materialized slots stay first; auth-only slots follow, before MCP/config.
|
|
217
|
+
*/
|
|
218
|
+
export function ordinaryCredentialPlan(
|
|
219
|
+
ctx: ProviderContext,
|
|
220
|
+
materialized: Record<string, string>,
|
|
221
|
+
): Array<[string, string]> {
|
|
222
|
+
const scope = required(ctx);
|
|
223
|
+
const plan = new Map<string, string>();
|
|
224
|
+
for (const [key, value] of Object.entries(materialized)) merge(plan, key, value);
|
|
225
|
+
for (const [key, value] of scope.inputs) merge(plan, key, value);
|
|
226
|
+
for (const [key, value] of plan) {
|
|
227
|
+
if (scope.existing.has(key)) {
|
|
228
|
+
if (scope.existing.get(key) !== value) conflict();
|
|
229
|
+
plan.delete(key);
|
|
230
|
+
}
|
|
231
|
+
if (scope.createdCritical.has(key)) {
|
|
232
|
+
if (scope.createdCritical.get(key) !== value) conflict();
|
|
233
|
+
plan.delete(key);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
return [...plan];
|
|
237
|
+
}
|
|
@@ -1,5 +1,8 @@
|
|
|
1
|
+
import { StackError } from "../errors.ts";
|
|
2
|
+
import { type RetryOptions, fetchWithRetry } from "../http.ts";
|
|
1
3
|
import { revealSecret } from "../phantom.ts";
|
|
2
|
-
import {
|
|
4
|
+
import type { ProviderContext } from "./_base.ts";
|
|
5
|
+
import { readProviderSecret } from "./_credential-stage.ts";
|
|
3
6
|
|
|
4
7
|
/**
|
|
5
8
|
* Shared helpers for every hand-written provider. Before this existed, each
|
|
@@ -7,18 +10,61 @@ import { fetchWithRetry, type RetryOptions } from "../http.ts";
|
|
|
7
10
|
* one source of truth keeps behaviour consistent.
|
|
8
11
|
*/
|
|
9
12
|
|
|
10
|
-
|
|
13
|
+
/**
|
|
14
|
+
* Names whose stored value Phantom refused to reveal to this process (it only
|
|
15
|
+
* reveals into an attached terminal). Lets `stack doctor` tell "stored but
|
|
16
|
+
* unreadable here" apart from "missing", since `tryRevealSecret` returns
|
|
17
|
+
* `undefined` for both. Names only, never values.
|
|
18
|
+
*/
|
|
19
|
+
const revealRefusals = new Set<string>();
|
|
20
|
+
|
|
21
|
+
/** Return and clear the names Phantom refused to reveal since the last call. */
|
|
22
|
+
export function takeRevealRefusals(): string[] {
|
|
23
|
+
const names = [...revealRefusals];
|
|
24
|
+
revealRefusals.clear();
|
|
25
|
+
return names;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function tryRevealSecret(
|
|
29
|
+
key: string,
|
|
30
|
+
ctx?: ProviderContext,
|
|
31
|
+
): Promise<string | undefined> {
|
|
11
32
|
try {
|
|
12
|
-
const value = await revealSecret(key);
|
|
33
|
+
const value = ctx ? await readProviderSecret(ctx, key) : await revealSecret(key);
|
|
13
34
|
return value.length > 0 ? value : undefined;
|
|
14
|
-
} catch {
|
|
35
|
+
} catch (err) {
|
|
36
|
+
if (err instanceof StackError && err.code === "PHANTOM_TRUSTED_TERMINAL_REQUIRED") {
|
|
37
|
+
revealRefusals.add(key);
|
|
38
|
+
}
|
|
15
39
|
return undefined;
|
|
16
40
|
}
|
|
17
41
|
}
|
|
18
42
|
|
|
19
43
|
/**
|
|
20
|
-
*
|
|
21
|
-
*
|
|
44
|
+
* Prompt the user for a credential. When the host supplies `ctx.prompt` (the
|
|
45
|
+
* CLI always does), the request goes through it — the CLI pauses its spinner,
|
|
46
|
+
* runs a @clack prompt, then resumes. This is the ONLY safe way to read a
|
|
47
|
+
* credential while a spinner is running: writing the prompt to stderr directly
|
|
48
|
+
* gets clobbered by the spinner's repaint and `stack add` looks like it hung.
|
|
49
|
+
*
|
|
50
|
+
* Falls back to a bare stderr-prompt + stdin read for non-CLI hosts and tests,
|
|
51
|
+
* where no spinner is competing for the terminal.
|
|
52
|
+
*/
|
|
53
|
+
export async function promptSecret(
|
|
54
|
+
ctx: ProviderContext,
|
|
55
|
+
req: { message: string; howTo?: string },
|
|
56
|
+
): Promise<string> {
|
|
57
|
+
if (ctx.prompt) {
|
|
58
|
+
return (await ctx.prompt({ message: req.message, howTo: req.howTo, secret: true })).trim();
|
|
59
|
+
}
|
|
60
|
+
if (req.howTo) process.stderr.write(`\n ${req.howTo}\n`);
|
|
61
|
+
process.stderr.write(` ${req.message}: `);
|
|
62
|
+
return (await readLine()).trim();
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Read a single line from stdin. Used by `promptSecret`'s non-CLI fallback when
|
|
67
|
+
* no host prompt is configured. Resumes stdin, reads until newline, then
|
|
22
68
|
* pauses stdin so the parent process doesn't hang waiting for EOF.
|
|
23
69
|
*/
|
|
24
70
|
export async function readLine(): Promise<string> {
|
|
@@ -42,7 +88,7 @@ export async function readLine(): Promise<string> {
|
|
|
42
88
|
* Bearer-token providers. Providers with custom auth header shapes (Anthropic,
|
|
43
89
|
* Upstash, Linear, Supabase PKCE) inline their own.
|
|
44
90
|
*/
|
|
45
|
-
export function bearerJsonHeaders(token: string):
|
|
91
|
+
export function bearerJsonHeaders(token: string): NonNullable<RequestInit["headers"]> {
|
|
46
92
|
return {
|
|
47
93
|
Authorization: `Bearer ${token}`,
|
|
48
94
|
"content-type": "application/json",
|
|
@@ -58,7 +104,7 @@ export function bearerJsonHeaders(token: string): HeadersInit {
|
|
|
58
104
|
* 429/5xx is always safe. Keeps each provider's `verify` free of retry glue.
|
|
59
105
|
*/
|
|
60
106
|
export function verifyFetch(
|
|
61
|
-
input:
|
|
107
|
+
input: string | URL | Request,
|
|
62
108
|
init?: RequestInit,
|
|
63
109
|
opts?: RetryOptions,
|
|
64
110
|
): Promise<Response> {
|
|
@@ -74,7 +120,7 @@ export function verifyFetch(
|
|
|
74
120
|
* Usage: `ctx.log({ msg: \`token ${scrub(t)} rejected\` })` prints
|
|
75
121
|
* `token ****abcd rejected`, never the raw secret.
|
|
76
122
|
*/
|
|
77
|
-
export function scrub(value: string, keepLast
|
|
123
|
+
export function scrub(value: string, keepLast = 4): string {
|
|
78
124
|
if (!value) return "";
|
|
79
125
|
if (value.length <= keepLast) return "****";
|
|
80
126
|
return `****${value.slice(-keepLast)}`;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { anthropicBaseUrl } 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://console.anthropic.com",
|
|
12
13
|
async verify(key) {
|
|
13
14
|
try {
|
|
14
|
-
const res = await verifyFetch(
|
|
15
|
+
const res = await verifyFetch(`${anthropicBaseUrl()}/v1/models`, {
|
|
15
16
|
headers: {
|
|
16
17
|
"x-api-key": key,
|
|
17
18
|
"anthropic-version": "2023-06-01",
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { makeApiKeyProvider } from "./_api-key.ts";
|
|
2
|
+
import { verifyFetch } from "./_helpers.ts";
|
|
3
|
+
|
|
4
|
+
export default makeApiKeyProvider({
|
|
5
|
+
name: "auth0",
|
|
6
|
+
displayName: "Auth0",
|
|
7
|
+
category: "auth",
|
|
8
|
+
docs: "https://auth0.com/docs/api/management/v2",
|
|
9
|
+
secretName: "AUTH0_DOMAIN",
|
|
10
|
+
howTo:
|
|
11
|
+
"Enter your Auth0 tenant domain, such as your-tenant.us.auth0.com. This adapter checks its discovery response and stores only that domain.",
|
|
12
|
+
dashboard: "https://manage.auth0.com",
|
|
13
|
+
async verify(key) {
|
|
14
|
+
// `key` here is the domain (e.g. "myapp.us.auth0.com").
|
|
15
|
+
// We hit the OIDC discovery endpoint — no credentials required — to confirm
|
|
16
|
+
// the tenant is reachable. Full M2M credential verification requires an
|
|
17
|
+
// access-token exchange which is deferred to the multi-secret login path.
|
|
18
|
+
try {
|
|
19
|
+
const res = await verifyFetch(`https://${key}/.well-known/openid-configuration`);
|
|
20
|
+
if (!res.ok) return undefined;
|
|
21
|
+
const body = (await res.json()) as { issuer?: string };
|
|
22
|
+
return body.issuer ? { issuer: body.issuer } : undefined;
|
|
23
|
+
} catch {
|
|
24
|
+
return undefined;
|
|
25
|
+
}
|
|
26
|
+
},
|
|
27
|
+
});
|
package/src/providers/aws.ts
CHANGED
|
@@ -1,8 +1,6 @@
|
|
|
1
|
-
import type { ServiceEntry } from "../config.ts";
|
|
2
1
|
import { createHash, createHmac } from "node:crypto";
|
|
2
|
+
import type { ServiceEntry } from "../config.ts";
|
|
3
3
|
import { StackError } from "../errors.ts";
|
|
4
|
-
import { addSecret } from "../phantom.ts";
|
|
5
|
-
import { readLine, tryRevealSecret } from "./_helpers.ts";
|
|
6
4
|
import type {
|
|
7
5
|
AuthHandle,
|
|
8
6
|
HealthStatus,
|
|
@@ -11,6 +9,8 @@ import type {
|
|
|
11
9
|
ProviderContext,
|
|
12
10
|
Resource,
|
|
13
11
|
} from "./_base.ts";
|
|
12
|
+
import { acceptVerifiedExisting, storeProviderSecret } from "./_credential-stage.ts";
|
|
13
|
+
import { promptSecret, tryRevealSecret } from "./_helpers.ts";
|
|
14
14
|
|
|
15
15
|
/**
|
|
16
16
|
* AWS — v1 accepts an IAM access key pair (access key id + secret access key).
|
|
@@ -32,25 +32,29 @@ const aws: Provider = {
|
|
|
32
32
|
docs: "https://docs.aws.amazon.com/iam/",
|
|
33
33
|
|
|
34
34
|
async login(ctx: ProviderContext): Promise<AuthHandle> {
|
|
35
|
-
const cachedId = await tryRevealSecret(SECRET_ID_KEY);
|
|
36
|
-
const cachedSecret = await tryRevealSecret(SECRET_SECRET_KEY);
|
|
35
|
+
const cachedId = await tryRevealSecret(SECRET_ID_KEY, ctx);
|
|
36
|
+
const cachedSecret = await tryRevealSecret(SECRET_SECRET_KEY, ctx);
|
|
37
37
|
if (cachedId && cachedSecret) {
|
|
38
38
|
const identity = await callStsIdentity(cachedId, cachedSecret, "us-east-1");
|
|
39
|
-
if (identity)
|
|
39
|
+
if (identity) {
|
|
40
|
+
acceptVerifiedExisting(ctx, SECRET_ID_KEY, cachedId);
|
|
41
|
+
acceptVerifiedExisting(ctx, SECRET_SECRET_KEY, cachedSecret);
|
|
42
|
+
return { token: `${cachedId}:${cachedSecret}`, identity };
|
|
43
|
+
}
|
|
40
44
|
ctx.log({ level: "warn", msg: "Cached AWS credentials invalid." });
|
|
41
45
|
}
|
|
42
46
|
if (!ctx.interactive)
|
|
43
47
|
throw new StackError("AWS_AUTH_REQUIRED", "No valid AWS credentials in vault.");
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
const secretAccessKey =
|
|
48
|
+
const accessKeyId = await promptSecret(ctx, {
|
|
49
|
+
message: "AWS_ACCESS_KEY_ID",
|
|
50
|
+
howTo:
|
|
51
|
+
"Create an IAM access key at https://console.aws.amazon.com/iam/home#/security_credentials",
|
|
52
|
+
});
|
|
53
|
+
const secretAccessKey = await promptSecret(ctx, { message: "AWS_SECRET_ACCESS_KEY" });
|
|
50
54
|
const identity = await callStsIdentity(accessKeyId, secretAccessKey, "us-east-1");
|
|
51
55
|
if (!identity) throw new StackError("AWS_AUTH_INVALID", "AWS rejected those credentials.");
|
|
52
|
-
await
|
|
53
|
-
await
|
|
56
|
+
await storeProviderSecret(ctx, SECRET_ID_KEY, accessKeyId);
|
|
57
|
+
await storeProviderSecret(ctx, SECRET_SECRET_KEY, secretAccessKey);
|
|
54
58
|
return { token: `${accessKeyId}:${secretAccessKey}`, identity };
|
|
55
59
|
},
|
|
56
60
|
|
|
@@ -64,7 +68,7 @@ const aws: Provider = {
|
|
|
64
68
|
};
|
|
65
69
|
},
|
|
66
70
|
|
|
67
|
-
async materialize(
|
|
71
|
+
async materialize(ctx, resource, auth): Promise<Materialized> {
|
|
68
72
|
const sep = auth.token.indexOf(":");
|
|
69
73
|
if (sep <= 0 || sep === auth.token.length - 1) {
|
|
70
74
|
throw new StackError(
|
|
@@ -80,7 +84,7 @@ const aws: Provider = {
|
|
|
80
84
|
"AWS auth handle missing access key id or secret access key.",
|
|
81
85
|
);
|
|
82
86
|
}
|
|
83
|
-
await
|
|
87
|
+
await storeProviderSecret(ctx, SECRET_REGION, resource.region ?? "us-east-1");
|
|
84
88
|
return {
|
|
85
89
|
secrets: {
|
|
86
90
|
AWS_ACCESS_KEY_ID: accessKeyId,
|
|
@@ -90,10 +94,10 @@ const aws: Provider = {
|
|
|
90
94
|
};
|
|
91
95
|
},
|
|
92
96
|
|
|
93
|
-
async healthcheck(
|
|
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
|
+
async healthcheck(ctx, _entry: ServiceEntry): Promise<HealthStatus> {
|
|
98
|
+
const id = await tryRevealSecret(SECRET_ID_KEY, ctx);
|
|
99
|
+
const secret = await tryRevealSecret(SECRET_SECRET_KEY, ctx);
|
|
100
|
+
const region = (await tryRevealSecret(SECRET_REGION, ctx)) ?? "us-east-1";
|
|
97
101
|
if (!id || !secret) return { kind: "error", detail: "AWS credentials missing from vault" };
|
|
98
102
|
const start = Date.now();
|
|
99
103
|
const identity = await callStsIdentity(id, secret, region);
|
|
@@ -127,19 +131,11 @@ async function callStsIdentity(
|
|
|
127
131
|
const payload = "Action=GetCallerIdentity&Version=2011-06-15";
|
|
128
132
|
const payloadHash = createHash("sha256").update(payload).digest("hex");
|
|
129
133
|
|
|
130
|
-
const canonicalHeaders =
|
|
131
|
-
`content-type:application/x-www-form-urlencoded\n` +
|
|
132
|
-
`host:${host}\n` +
|
|
133
|
-
`x-amz-date:${amzDate}\n`;
|
|
134
|
+
const canonicalHeaders = `content-type:application/x-www-form-urlencoded\nhost:${host}\nx-amz-date:${amzDate}\n`;
|
|
134
135
|
const signedHeaders = "content-type;host;x-amz-date";
|
|
135
|
-
const canonicalRequest = [
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
"",
|
|
139
|
-
canonicalHeaders,
|
|
140
|
-
signedHeaders,
|
|
141
|
-
payloadHash,
|
|
142
|
-
].join("\n");
|
|
136
|
+
const canonicalRequest = [method, "/", "", canonicalHeaders, signedHeaders, payloadHash].join(
|
|
137
|
+
"\n",
|
|
138
|
+
);
|
|
143
139
|
const scope = `${datestamp}/${region}/${service}/aws4_request`;
|
|
144
140
|
const stringToSign = [
|
|
145
141
|
"AWS4-HMAC-SHA256",
|
|
@@ -189,7 +185,10 @@ function hmac(key: string | Buffer, value: string): Buffer {
|
|
|
189
185
|
}
|
|
190
186
|
|
|
191
187
|
function toAmzDate(d: Date): string {
|
|
192
|
-
return d
|
|
188
|
+
return d
|
|
189
|
+
.toISOString()
|
|
190
|
+
.replace(/[-:]/g, "")
|
|
191
|
+
.replace(/\.\d{3}/, "");
|
|
193
192
|
}
|
|
194
193
|
|
|
195
194
|
function matchXml(xml: string, tag: string): string | undefined {
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import type { ServiceEntry } from "../config.ts";
|
|
2
2
|
import { StackError } from "../errors.ts";
|
|
3
|
-
import { addSecret, revealSecret } from "../phantom.ts";
|
|
4
3
|
import { fetchWithRetry } from "../http.ts";
|
|
5
4
|
import type {
|
|
6
5
|
AuthHandle,
|
|
@@ -11,6 +10,8 @@ import type {
|
|
|
11
10
|
ProvisionOpts,
|
|
12
11
|
Resource,
|
|
13
12
|
} from "./_base.ts";
|
|
13
|
+
import { acceptVerifiedExisting, storeProviderSecret } from "./_credential-stage.ts";
|
|
14
|
+
import { promptSecret, tryRevealSecret } from "./_helpers.ts";
|
|
14
15
|
|
|
15
16
|
/**
|
|
16
17
|
* Cloudflare — Workers / Pages / R2 / D1 / KV all hang off one Account API
|
|
@@ -31,21 +32,26 @@ const cloudflare: Provider = {
|
|
|
31
32
|
docs: "https://developers.cloudflare.com/api",
|
|
32
33
|
|
|
33
34
|
async login(ctx: ProviderContext): Promise<AuthHandle> {
|
|
34
|
-
const cached = await tryRevealSecret(TOKEN_SECRET);
|
|
35
|
+
const cached = await tryRevealSecret(TOKEN_SECRET, ctx);
|
|
35
36
|
if (cached) {
|
|
36
37
|
const identity = await verifyToken(cached);
|
|
37
|
-
if (identity)
|
|
38
|
+
if (identity) {
|
|
39
|
+
acceptVerifiedExisting(ctx, TOKEN_SECRET, cached);
|
|
40
|
+
return { token: cached, identity };
|
|
41
|
+
}
|
|
38
42
|
ctx.log({ level: "warn", msg: "Cached Cloudflare token invalid." });
|
|
39
43
|
}
|
|
40
44
|
if (!ctx.interactive)
|
|
41
45
|
throw new StackError("CLOUDFLARE_AUTH_REQUIRED", "No valid Cloudflare token in vault.");
|
|
42
|
-
|
|
43
|
-
"
|
|
44
|
-
|
|
45
|
-
|
|
46
|
+
const token = await promptSecret(ctx, {
|
|
47
|
+
message: "Paste your Cloudflare API token",
|
|
48
|
+
howTo:
|
|
49
|
+
"Create an API token at https://dash.cloudflare.com/profile/api-tokens (scopes: Account · Workers Scripts / Pages / R2 / D1 as needed)",
|
|
50
|
+
});
|
|
46
51
|
const identity = await verifyToken(token);
|
|
47
|
-
if (!identity)
|
|
48
|
-
|
|
52
|
+
if (!identity)
|
|
53
|
+
throw new StackError("CLOUDFLARE_AUTH_INVALID", "Cloudflare rejected that token.");
|
|
54
|
+
await storeProviderSecret(ctx, TOKEN_SECRET, token);
|
|
49
55
|
return { token, identity };
|
|
50
56
|
},
|
|
51
57
|
|
|
@@ -67,7 +73,7 @@ const cloudflare: Provider = {
|
|
|
67
73
|
};
|
|
68
74
|
},
|
|
69
75
|
|
|
70
|
-
async materialize(
|
|
76
|
+
async materialize(ctx, resource, auth): Promise<Materialized> {
|
|
71
77
|
return {
|
|
72
78
|
secrets: {
|
|
73
79
|
CLOUDFLARE_API_TOKEN: auth.token,
|
|
@@ -77,8 +83,8 @@ const cloudflare: Provider = {
|
|
|
77
83
|
};
|
|
78
84
|
},
|
|
79
85
|
|
|
80
|
-
async healthcheck(
|
|
81
|
-
const token = await tryRevealSecret(TOKEN_SECRET);
|
|
86
|
+
async healthcheck(ctx, _entry: ServiceEntry): Promise<HealthStatus> {
|
|
87
|
+
const token = await tryRevealSecret(TOKEN_SECRET, ctx);
|
|
82
88
|
if (!token) return { kind: "error", detail: `${TOKEN_SECRET} missing from vault` };
|
|
83
89
|
const start = Date.now();
|
|
84
90
|
const identity = await verifyToken(token);
|
|
@@ -119,28 +125,3 @@ async function fetchAccounts(token: string): Promise<Array<{ id: string; name: s
|
|
|
119
125
|
const body = (await res.json()) as { result?: Array<{ id: string; name: string }> };
|
|
120
126
|
return body.result ?? [];
|
|
121
127
|
}
|
|
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
|
-
}
|
package/src/providers/convex.ts
CHANGED
|
@@ -12,8 +12,7 @@ export default makeApiKeyProvider({
|
|
|
12
12
|
category: "database",
|
|
13
13
|
docs: "https://docs.convex.dev",
|
|
14
14
|
secretName: "CONVEX_DEPLOY_KEY",
|
|
15
|
-
howTo:
|
|
16
|
-
"Create a deploy key at https://dashboard.convex.dev (Project → Settings → Deploy Keys).",
|
|
15
|
+
howTo: "Create a deploy key at https://dashboard.convex.dev (Project → Settings → Deploy Keys).",
|
|
17
16
|
dashboard: "https://dashboard.convex.dev",
|
|
18
17
|
async verify(key) {
|
|
19
18
|
// Convex deploy keys have the shape prod:<team>:<project>|<token>. No
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { makeApiKeyProvider } from "./_api-key.ts";
|
|
2
|
+
import { verifyFetch } from "./_helpers.ts";
|
|
3
|
+
|
|
4
|
+
export default makeApiKeyProvider({
|
|
5
|
+
name: "datadog",
|
|
6
|
+
displayName: "Datadog",
|
|
7
|
+
category: "observability",
|
|
8
|
+
docs: "https://docs.datadoghq.com/api/latest/",
|
|
9
|
+
secretName: "DD_API_KEY",
|
|
10
|
+
howTo:
|
|
11
|
+
"Create an API key at https://app.datadoghq.com/organization-settings/api-keys. This adapter validates and stores that key only.",
|
|
12
|
+
dashboard: "https://app.datadoghq.com",
|
|
13
|
+
async verify(key) {
|
|
14
|
+
try {
|
|
15
|
+
// GET /api/v1/validate — requires only the API key in the header.
|
|
16
|
+
const res = await verifyFetch("https://api.datadoghq.com/api/v1/validate", {
|
|
17
|
+
headers: {
|
|
18
|
+
"DD-API-KEY": key,
|
|
19
|
+
Accept: "application/json",
|
|
20
|
+
},
|
|
21
|
+
});
|
|
22
|
+
if (!res.ok) return undefined;
|
|
23
|
+
const body = (await res.json()) as { valid?: boolean };
|
|
24
|
+
return body.valid ? { valid: "true" } : undefined;
|
|
25
|
+
} catch {
|
|
26
|
+
return undefined;
|
|
27
|
+
}
|
|
28
|
+
},
|
|
29
|
+
});
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { makeApiKeyProvider } from "./_api-key.ts";
|
|
2
|
+
import { verifyFetch } from "./_helpers.ts";
|
|
3
|
+
|
|
4
|
+
export default makeApiKeyProvider({
|
|
5
|
+
name: "digitalocean",
|
|
6
|
+
displayName: "DigitalOcean",
|
|
7
|
+
category: "cloud",
|
|
8
|
+
docs: "https://docs.digitalocean.com/reference/api/api-reference/",
|
|
9
|
+
secretName: "DIGITALOCEAN_TOKEN",
|
|
10
|
+
howTo: "Create a personal access token at https://cloud.digitalocean.com/account/api/tokens.",
|
|
11
|
+
dashboard: "https://cloud.digitalocean.com",
|
|
12
|
+
async verify(key) {
|
|
13
|
+
try {
|
|
14
|
+
const res = await verifyFetch("https://api.digitalocean.com/v2/account", {
|
|
15
|
+
headers: {
|
|
16
|
+
Authorization: `Bearer ${key}`,
|
|
17
|
+
Accept: "application/json",
|
|
18
|
+
},
|
|
19
|
+
});
|
|
20
|
+
if (!res.ok) return undefined;
|
|
21
|
+
const body = (await res.json()) as { account?: { email?: string; uuid?: string } };
|
|
22
|
+
return {
|
|
23
|
+
email: body.account?.email ?? "",
|
|
24
|
+
uuid: body.account?.uuid ?? "",
|
|
25
|
+
};
|
|
26
|
+
} catch {
|
|
27
|
+
return undefined;
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
});
|