1/**
2 * The credential-demand seam for credential-free `alchemy dev`.
3 *
4 * A dev run whose plan is entirely local must never touch (or prompt for)
5 * cloud credentials. When the plan DOES need the cloud — a resource opted
6 * out of local emulation via `Alchemy.remote()`, a local Worker binding
7 * that proxies to a remote resource (`dev: { remote: true }`), or the
8 * deletion of a row that was last reconciled live — credentials are
9 * validated up front, BEFORE apply begins: the gate resolves each demanded
10 * provider's credentials through the same precedence the run will use. The
11 * seam never starts a login flow: missing credentials fail with the typed
12 * {@link CredentialsRequired} error naming the demanding resources and
13 * pointing at `alchemy profile edit` — the profile command is the only
14 * place logins happen.
15 *
16 * How deep the up-front resolution goes is PROVIDER-DEPENDENT — it runs
17 * the provider's `read`, no more:
18 *
19 * - Cloudflare's `read` eagerly checks OAuth expiry, silently refreshes,
20 * and persists under the profile lock — so a dead refresh token fails
21 * here as a typed `NeedsReauth`, and child processes (which only ever
22 * read what a parent persisted) start with a warm token.
23 * - AWS's `read` returns a credentials RECIPE whose inner effect stays
24 * unevaluated (SSO tokens are loaded lazily by consumers), so an
25 * expired SSO token passes the gate and still surfaces mid-run. Local-mode
26 * AWS providers carry a floci-scoped environment of their own (see
27 * `AWS/Local/FlociServices.ts`) and ensure the emulator there.
28 *
29 * The demand scan keys on provider MODE, not plan action — noop live rows
30 * still demand (their runtime proxies need live credentials to serve), so
31 * a watch restart re-runs this resolution even for an unchanged plan.
32 *
33 * Non-dev runs (`alchemy deploy` / `destroy`) never enter this seam:
34 * state-store init and live providers drive the pre-existing lazy
35 * credential-resolution flow unchanged.
36 */
37import * as Config from "effect/Config";
38import * as Data from "effect/Data";
39import * as Effect from "effect/Effect";
40import * as Option from "effect/Option";
41import * as Result from "effect/Result";
42import * as Schema from "effect/Schema";
43import type { BindingNode, Plan } from "../Plan.ts";
44import { profileCommandHint } from "../Util/interactive.ts";
45import {
46 AuthError,
47 AuthProviders,
48 NeedsReauth,
49 presentEnvironment,
50} from "./AuthProvider.ts";
51import {
52 ALCHEMY_PROFILE,
53 DEFAULT_PROFILE_NAME,
54 ProfileStore,
55 SuppressMissingProviderConfig,
56} from "./Profile.ts";
57import { CredentialsUnavailable, resolveProviderConfig } from "./Resolve.ts";
58import { UserFacingError } from "../UserFacingError.ts";
59
60/** Why a plan row demands live (cloud) credentials during a dev run. */
61export type CredentialDemandReason =
62 /** The resource's resolved provider mode is `"live"` (`Alchemy.remote()`). */
63 | "remote"
64 /** Binding data carries a truthy `devRemote` entry — the local runtime proxies this binding to the real cloud. */
65 | "remote-binding"
66 /** The plan deletes a row stamped `providerMode: "live"` — the live provider must run to delete it. */
67 | "live-delete";
68
69export interface DemandingResource {
70 /** FQN of the demanding resource within the stack. */
71 readonly fqn: string;
72 readonly reason: CredentialDemandReason;
73}
74
75/**
76 * All the resources of one cloud provider that demand live credentials.
77 * `provider` is the auth-provider name, derived from the resource Type's
78 * leading namespace segment (`"AWS.S3.Bucket"` → `"AWS"`, matching the
79 * name the cloud's auth provider registers under).
80 */
81export interface CredentialDemand {
82 readonly provider: string;
83 readonly resources: readonly DemandingResource[];
84}
85
86/**
87 * A dev-mode plan needs cloud credentials and none are configured for the
88 * active profile. The message names the demanding resources and the exact
89 * `alchemy profile edit` invocation that fixes it.
90 */
91export class CredentialsRequired extends Data.TaggedError(
92 "CredentialsRequired",
93)<{
94 message: string;
95 /** Auth-provider name whose credentials are missing (e.g. `"AWS"`). */
96 provider: string;
97 /** FQNs of the resources demanding the credentials. */
98 resources: string[];
99 /** Short summary of why the credentials are needed. */
100 reason: string;
101}> {
102 readonly [UserFacingError] = true;
103}
104
105/**
106 * Live providers resolve credentials lazily, on their first cloud call, and
107 * a failed resolution leaves the credential service as a
108 * {@link CredentialsUnavailable} defect. Applied around every provider
109 * lifecycle call, this fails the operation with {@link CredentialsRequired}
110 * naming the resource instead of treating it as a provider crash.
111 */
112export const failCredentialsRequired =
113 (fqn: string) =>
114 <A, E, R>(self: Effect.Effect<A, E, R>) =>
115 Effect.catchDefect(self, (defect) =>
116 defect instanceof CredentialsUnavailable
117 ? Effect.fail(
118 new CredentialsRequired({
119 provider: defect.provider,
120 resources: [fqn],
121 reason: "runs against the real cloud",
122 message: defect.message,
123 }),
124 )
125 : Effect.die(defect),
126 );
127
128const describeReason = (reason: CredentialDemandReason): string => {
129 switch (reason) {
130 case "remote":
131 return "runs against the real cloud via Alchemy.remote()";
132 case "remote-binding":
133 return "has a binding that proxies to a remote resource (dev: { remote: true })";
134 case "live-delete":
135 return "deletes an instance that was deployed to the real cloud";
136 }
137};
138
139/** `"AWS.S3.Bucket"` → `"AWS"` (the auth-provider name). */
140const cloudOf = (type: string): string => type.split(".")[0] ?? type;
141
142const resourceLines = (demand: CredentialDemand): string =>
143 demand.resources
144 .map((r) => ` - ${r.fqn} (${describeReason(r.reason)})`)
145 .join("\n");
146
147/**
148 * Build the typed {@link CredentialsRequired} failure for a demand.
149 * Exported so tests can pin the message format.
150 */
151export const credentialsRequired = (
152 demand: CredentialDemand,
153 profileName: string,
154): Effect.Effect<never, CredentialsRequired> =>
155 Effect.gen(function* () {
156 const command = yield* profileCommandHint(
157 `alchemy profile edit --profile ${profileName} --add ${demand.provider}`,
158 );
159 return yield* new CredentialsRequired({
160 provider: demand.provider,
161 resources: demand.resources.map((r) => r.fqn),
162 reason: [...new Set(demand.resources.map((r) => r.reason))].join(", "),
163 message:
164 `${demand.provider} credentials are required, but none are configured ` +
165 `for profile '${profileName}'.\n` +
166 `These resources require ${demand.provider} credentials:\n` +
167 `${resourceLines(demand)}\n` +
168 `Run \`${command}\` to configure ` +
169 "credentials, or set CI=1 to use environment-variable credentials.",
170 });
171 });
172
173/**
174 * The slice of binding data the demand scan cares about: local runtimes
175 * mark cloud-proxied bindings with a truthy `devRemote` entry.
176 */
177const RemoteBindingData = Schema.Struct({
178 devRemote: Schema.optional(Schema.Record(Schema.String, Schema.Boolean)),
179});
180
181const decodeRemoteBindingData = Schema.decodeUnknownResult(RemoteBindingData);
182
183const bindingDemandsRemote = (
184 bindings: readonly BindingNode[] | undefined,
185): boolean =>
186 (bindings ?? []).some((binding) => {
187 // A binding being REMOVED needs no runtime proxy — the restarted
188 // local instance simply no longer carries it.
189 if (binding.action === "delete") return false;
190 const data = decodeRemoteBindingData(binding.data);
191 return (
192 Result.isSuccess(data) &&
193 Object.values(data.success.devRemote ?? {}).some(
194 (remote) => remote === true,
195 )
196 );
197 });
198
199/**
200 * Scan a plan for rows that need live (cloud) credentials during a dev
201 * run, grouped by cloud provider:
202 *
203 * 1. resources whose resolved provider mode is `"live"` (`Alchemy.remote()`)
204 * 2. resources whose binding data carries a truthy `devRemote` entry
205 * (the local runtime proxies that binding to the real cloud)
206 * 3. planned deletions of rows stamped `providerMode: "live"`
207 *
208 * Pure and side-effect-free — callers gate on the run being a dev run
209 * (in a live run every dual-provider row resolves `"live"` and the
210 * pre-existing lazy credential flow applies instead).
211 *
212 * Mode-agnostic rows (`mode === undefined`, single-implementation
213 * providers that run live even in dev) are deliberately NOT collected:
214 * they resolve credentials lazily exactly as they do today.
215 */
216export const collectCredentialDemands = (plan: Plan): CredentialDemand[] => {
217 const byProvider = new Map<string, Map<string, CredentialDemandReason>>();
218 const add = (
219 type: string,
220 fqn: string,
221 reason: CredentialDemandReason,
222 ): void => {
223 const provider = cloudOf(type);
224 const resources =
225 byProvider.get(provider) ?? new Map<string, CredentialDemandReason>();
226 if (!resources.has(fqn)) resources.set(fqn, reason);
227 byProvider.set(provider, resources);
228 };
229 for (const [fqn, node] of Object.entries(plan.resources)) {
230 if (node.mode === "live") {
231 add(node.resource.Type, fqn, "remote");
232 } else if (bindingDemandsRemote(node.bindings)) {
233 add(node.resource.Type, fqn, "remote-binding");
234 }
235 }
236 for (const [fqn, node] of Object.entries(plan.deletions)) {
237 if (node !== undefined && node.mode === "live") {
238 add(node.resource.Type, fqn, "live-delete");
239 }
240 }
241 return [...byProvider.entries()].map(([provider, resources]) => ({
242 provider,
243 resources: [...resources.entries()].map(([fqn, reason]) => ({
244 fqn,
245 reason,
246 })),
247 }));
248};
249
250/**
251 * Re-fail a warm-up resolution error with the demand attached, so the user
252 * sees WHY a dev run wanted cloud credentials. Tags are preserved —
253 * consumers match `NeedsReauth`/`AuthError` by tag, never by message.
254 */
255const attachDemandContext =
256 (demand: CredentialDemand) =>
257 <A, R>(
258 self: Effect.Effect<A, AuthError | NeedsReauth, R>,
259 ): Effect.Effect<A, AuthError | NeedsReauth, R> =>
260 self.pipe(
261 Effect.mapError((error) => {
262 const message =
263 `${error.message}\n` +
264 `These resources require ${demand.provider} credentials:\n` +
265 resourceLines(demand);
266 return error._tag === "NeedsReauth"
267 ? new NeedsReauth({
268 provider: error.provider,
269 profile: error.profile,
270 message,
271 cause: error.cause,
272 })
273 : new AuthError({ message, cause: error.cause });
274 }),
275 );
276
277/**
278 * Validate credentials for every demand. Never starts a login flow —
279 * logging in belongs to `alchemy profile edit` exclusively:
280 *
281 * - configured for the active profile → resolve the credentials through
282 * the exact precedence the run will use ({@link resolveProviderConfig}:
283 * CI environment, explicit env vars, then the profile). Resolution
284 * runs the provider's `read` — how much that validates and warms is
285 * provider-dependent (see the module header): Cloudflare eagerly
286 * refreshes and persists so a dead refresh token fails HERE as a
287 * typed {@link NeedsReauth} naming the demanding resources, while
288 * AWS's lazy credential recipe defers SSO-token failures to mid-run.
289 * - missing → typed {@link CredentialsRequired} failure with the exact
290 * `alchemy profile edit` invocation to run
291 * - CI → validates the provider's environment credentials directly,
292 * matching every other CI path without creating profile state
293 *
294 * Demands whose cloud has no registered auth provider are skipped (bare
295 * engine runs with test providers demand nothing). All context is
296 * resolved optionally, so the effect is safe to run in any environment —
297 * and a plan with zero demand touches no credentials at all.
298 */
299export const demandCredentials = Effect.fn("Alchemy.demandCredentials")(
300 function* (demands: readonly CredentialDemand[]) {
301 if (demands.length === 0) return;
302 const registry = Option.getOrUndefined(
303 yield* Effect.serviceOption(AuthProviders),
304 );
305 const profile = Option.getOrUndefined(
306 yield* Effect.serviceOption(ProfileStore),
307 );
308 if (registry === undefined || profile === undefined) return;
309 const ci = yield* Config.Boolean("CI").pipe(Config.withDefault(false));
310 // CI credentials come exclusively from the environment. Do not require
311 // or inspect a local profile first: CI runners intentionally have no
312 // profile manifest, and doing so would also risk consulting a developer's
313 // stored profiles when this path is exercised locally under Doppler.
314 const configuredProfile = yield* Config.option(ALCHEMY_PROFILE);
315 const profileName = Option.getOrElse(
316 configuredProfile,
317 () => DEFAULT_PROFILE_NAME,
318 );
319 yield* Effect.forEach(
320 demands,
321 (demand) =>
322 Effect.gen(function* () {
323 const auth = registry[demand.provider];
324 if (auth == null) return;
325 if (ci) {
326 if (auth.readEnvironment === undefined) {
327 return yield* credentialsRequired(demand, profileName);
328 }
329 return yield* auth.readEnvironment.pipe(
330 attachDemandContext(demand),
331 );
332 }
333 // Read-only precheck of the two non-CI configuration sources the
334 // resolution precedence below consults: environment credentials
335 // (which win regardless of the selected profile), or a profile
336 // entry. "Nothing configured" fails with the actionable
337 // {@link CredentialsRequired} here, WITHOUT entering
338 // `resolveProviderConfig`: its profile path goes through
339 // `ensureProfile`, which fails a nonexistent profile with a
340 // generic ProfileError that would bury which resources demanded
341 // credentials and what command fixes it.
342 const envUsable =
343 auth.readEnvironment !== undefined &&
344 (yield* presentEnvironment(auth.environment)) !== undefined;
345 const existing = yield* profile.getProfile(profileName);
346 if (existing?.providers[auth.name] == null && !envUsable) {
347 return yield* credentialsRequired(demand, profileName);
348 }
349 // Something IS configured — resolve through the same precedence
350 // the run's lazy credential flow uses, so the gate can never
351 // pass something resolution would later reject (or vice versa).
352 // Suppressed missing-config mode turns a check/resolve race into
353 // the typed tag below instead of a generic AuthError; it also
354 // mutes the env-credentials warning here — the run's own
355 // resolution still emits it.
356 const resolved = yield* resolveProviderConfig(demand.provider).pipe(
357 Effect.provideService(AuthProviders, registry),
358 Effect.provideService(ProfileStore, profile),
359 Effect.provideService(SuppressMissingProviderConfig, true),
360 Effect.catchTag("MissingProviderConfig", () =>
361 credentialsRequired(demand, profileName),
362 ),
363 Effect.catchTag(
364 "ProfileError",
365 (error) =>
366 new AuthError({ message: error.message, cause: error }),
367 ),
368 );
369 yield* resolved.resolve.pipe(attachDemandContext(demand));
370 }),
371 { concurrency: 4, discard: true },
372 );
373 },
374);
375
376/**
377 * Plan-time seam for `Alchemy.remote()` rows. The planner probes every new
378 * resource with its provider's `read` (engine-level adoption) — for a
379 * live-mode row in a dev run that is a real cloud call, made BEFORE
380 * {@link demandPlanCredentials} could run in apply. Demanding here first
381 * turns "nothing configured" into the typed {@link CredentialsRequired}
382 * (naming the rows and the exact `alchemy profile edit` command) instead
383 * of whatever raw error the first credential-less read produces. Bindings
384 * and live deletions are still gated in apply — nothing reads for them
385 * during plan.
386 */
387export const demandRemoteCredentials = (
388 resources: ReadonlyArray<{ readonly Type: string; readonly FQN: string }>,
389) => {
390 const byProvider = new Map<string, DemandingResource[]>();
391 for (const resource of resources) {
392 const provider = cloudOf(resource.Type);
393 byProvider.set(provider, [
394 ...(byProvider.get(provider) ?? []),
395 { fqn: resource.FQN, reason: "remote" },
396 ]);
397 }
398 return demandCredentials(
399 [...byProvider.entries()].map(([provider, resources]) => ({
400 provider,
401 resources,
402 })),
403 );
404};
405
406/**
407 * The one-call seam wired into the dev path: scan the plan for live
408 * demand and, if any, run {@link demandCredentials} BEFORE apply begins.
409 */
410export const demandPlanCredentials = (plan: Plan) =>
411 demandCredentials(collectCredentialDemands(plan));