1import * as Effect from "effect/Effect";
2import * as Option from "effect/Option";
3import * as Redacted from "effect/Redacted";
4import type { Interaction } from "../../Interaction.ts";
5import { AuthError } from "../../Auth/AuthProvider.ts";
6import { CredentialsStore } from "../../Auth/Credentials.ts";
7import {
8 inspectProvider,
9 type ProviderConnection,
10} from "../../Auth/Inspect.ts";
11import { withProfileCredentialsLock } from "../../Auth/Lock.ts";
12import { ProfileStore } from "../../Auth/Profile.ts";
13import { AlchemistInvalidInput, AlchemistNotFound } from "../Errors.ts";
14import { Progress } from "../Progress.ts";
15import {
16 collectAuthProviders,
17 DEFAULT_ENTRYPOINT,
18 type Target,
19} from "../Session.ts";
20
21/** Which project/profile pair a provider-scoped route resolves against. */
22export interface ProviderContext extends Target {
23 readonly profile: string;
24}
25
26export interface ProfileSummary {
27 readonly name: string;
28 readonly active: boolean;
29 readonly providers: ReadonlyArray<{
30 readonly name: string;
31 readonly method: string;
32 }>;
33}
34
35export type { ProviderConnection } from "../../Auth/Inspect.ts";
36
37export interface ProfileSnapshot {
38 readonly name: string;
39 readonly active: boolean;
40 readonly providers: ReadonlyArray<ProviderConnection>;
41}
42
43export interface ConfigureField {
44 readonly name: string;
45 readonly label: string;
46 readonly secret: boolean;
47 readonly required: boolean;
48 readonly description?: string;
49 readonly placeholder?: string;
50}
51
52export interface ConfigureMethod {
53 readonly method: string;
54 readonly label: string;
55 readonly fields: ReadonlyArray<ConfigureField>;
56}
57
58export interface AuthProviderDescriptor {
59 readonly name: string;
60 readonly connected: boolean;
61 readonly configureMethods: ReadonlyArray<ConfigureMethod>;
62 readonly supportsRefresh: boolean;
63 readonly supportsLogout: boolean;
64}
65
66export interface ConfigureInput extends ProviderContext {
67 readonly provider: string;
68 readonly action: "add" | "reconfigure";
69 readonly method?: string;
70 readonly values?: Readonly<Record<string, Redacted.Redacted<string>>>;
71}
72
73/**
74 * Every auth provider reachable from the given project: the built-ins plus
75 * whatever the user's stack module registers.
76 */
77const registry = (input: ProviderContext) =>
78 collectAuthProviders({
79 main: input.entrypoint ?? DEFAULT_ENTRYPOINT,
80 envFile: Option.fromNullishOr(input.envFile),
81 profile: input.profile,
82 });
83
84/** The effective profile and how it was selected. */
85export const current = Effect.fn("Alchemist.profile.current")(function* () {
86 return yield* (yield* ProfileStore).current;
87});
88
89/** Every profile with its connected providers, active profile first. */
90export const list = Effect.fn("Alchemist.profile.list")(function* () {
91 const profiles = yield* ProfileStore;
92 const [manifest, selected] = yield* Effect.all([
93 profiles.readManifest,
94 profiles.current,
95 ]);
96 return Object.entries(manifest.profiles)
97 .sort(([a], [b]) =>
98 a === "default" ? -1 : b === "default" ? 1 : a.localeCompare(b),
99 )
100 .map(([name, profile]): ProfileSummary => ({
101 name,
102 active: name === selected.name,
103 providers: Object.entries(profile.providers)
104 .sort(([a], [b]) => a.localeCompare(b))
105 .map(([name, config]) => ({
106 name,
107 method: config.method ?? "unknown",
108 })),
109 }));
110});
111
112/** One profile with the live status of each connected provider. */
113export const get = Effect.fn("Alchemist.profile.get")(function* (input: {
114 readonly name: string;
115 readonly includeProviderStatus?: boolean;
116 readonly entrypoint?: string;
117 readonly envFile?: string;
118}) {
119 const includeProviderStatus = input.includeProviderStatus ?? true;
120 const profiles = yield* ProfileStore;
121 const [profile, selected] = yield* Effect.all([
122 profiles.getProfile(input.name),
123 profiles.current,
124 ]);
125 if (profile === undefined) {
126 return yield* Effect.fail(
127 new AlchemistNotFound({ kind: "profile", id: input.name }),
128 );
129 }
130 // Skipping the status probe is what makes `profile list` fast: it
131 // reaches no provider APIs, so every connection reads as connected.
132 const registered = includeProviderStatus
133 ? yield* registry({
134 profile: input.name,
135 entrypoint: input.entrypoint,
136 envFile: input.envFile,
137 })
138 : {};
139 const entries = Object.entries(profile.providers).sort(([a], [b]) =>
140 a.localeCompare(b),
141 );
142 return {
143 name: input.name,
144 active: selected.name === input.name,
145 providers: yield* Effect.forEach(
146 entries,
147 ([provider, config]): Effect.Effect<
148 ProviderConnection,
149 never,
150 Interaction
151 > =>
152 includeProviderStatus
153 ? inspectProvider(
154 input.name,
155 provider,
156 config,
157 registered,
158 (updated) =>
159 profiles.setProviderConfig(input.name, provider, updated).pipe(
160 Effect.mapError(
161 (cause) =>
162 new AuthError({
163 message: `Could not persist repaired ${provider} credentials for profile '${input.name}'.`,
164 cause,
165 }),
166 ),
167 ),
168 )
169 : Effect.succeed({
170 name: provider,
171 method: config.method ?? "unknown",
172 status: "connected",
173 details: [],
174 }),
175 ),
176 } satisfies ProfileSnapshot;
177});
178
179export const create = Effect.fn("Alchemist.profile.create")(function* (input: {
180 readonly name: string;
181}) {
182 yield* (yield* ProfileStore).createProfile(input.name);
183 return yield* get({ name: input.name });
184});
185
186export const rename = Effect.fn("Alchemist.profile.rename")(function* (input: {
187 readonly name: string;
188 readonly newName: string;
189}) {
190 yield* (yield* ProfileStore).renameProfile(input.name, input.newName);
191 return yield* get({ name: input.newName });
192});
193
194/** Delete a profile and every credential stored for it. */
195export const deleteProfile = Effect.fn("Alchemist.profile.delete")(
196 function* (input: { readonly name: string }) {
197 const profiles = yield* ProfileStore;
198 const credentials = yield* CredentialsStore;
199 return yield* withProfileCredentialsLock(
200 input.name,
201 Effect.gen(function* () {
202 const deleted = yield* profiles.deleteProfile(input.name);
203 if (!deleted) {
204 return yield* Effect.fail(
205 new AlchemistNotFound({ kind: "profile", id: input.name }),
206 );
207 }
208 yield* credentials.deleteProfile(input.name);
209 return { name: input.name, credentialsDeleted: true } as const;
210 }),
211 );
212 },
213);
214
215/** Every registered auth provider and how it can be configured. */
216export const providers = Effect.fn("Alchemist.profile.providers")(
217 function* (input: {
218 readonly profile?: string;
219 readonly entrypoint?: string;
220 readonly envFile?: string;
221 }) {
222 const profiles = yield* ProfileStore;
223 const profile = input.profile ?? (yield* profiles.current).name;
224 const stored = yield* profiles.ensureProfile(profile);
225 const registered = yield* registry({ ...input, profile });
226 return Object.values(registered)
227 .sort((a, b) => a.name.localeCompare(b.name))
228 .map((provider): AuthProviderDescriptor => ({
229 name: provider.name,
230 connected: provider.name in stored.providers,
231 configureMethods: (provider.configureMethods ?? []).map((method) => ({
232 method: method.method,
233 label: method.method,
234 fields: method.fields.map((field) => ({
235 name: field.name,
236 label: field.label,
237 secret: field.secret ?? false,
238 required: !(field.optional ?? false),
239 description: field.description,
240 placeholder: field.placeholder,
241 })),
242 })),
243 supportsRefresh: true,
244 supportsLogout: true,
245 }));
246 },
247);
248
249/** The configure methods (and fields) one provider accepts. */
250export const configureForm = Effect.fn("Alchemist.profile.configureForm")(
251 function* (input: {
252 readonly profile: string;
253 readonly provider: string;
254 readonly method?: string;
255 }) {
256 const registered = yield* providers({ profile: input.profile });
257 const provider = registered.find(({ name }) => name === input.provider);
258 if (provider === undefined) {
259 return yield* Effect.fail(
260 new AlchemistNotFound({ kind: "provider", id: input.provider }),
261 );
262 }
263 return input.method === undefined
264 ? provider.configureMethods
265 : provider.configureMethods.filter(
266 ({ method }) => method === input.method,
267 );
268 },
269);
270
271/**
272 * Connect or reconfigure a provider in a profile. Reported through
273 * {@link Progress} as `ProviderConfigureStarted`; interactive providers drive
274 * their own prompts.
275 */
276export const configure = Effect.fn("Alchemist.profile.configure")(function* (
277 input: ConfigureInput,
278) {
279 const report = yield* Progress;
280 yield* report({
281 _tag: "provider.configure.started",
282 provider: input.provider,
283 });
284 const profiles = yield* ProfileStore;
285 const stored = yield* profiles.ensureProfile(input.profile);
286 const provider = (yield* registry(input))[input.provider];
287 if (provider === undefined) {
288 return yield* Effect.fail(
289 new AlchemistInvalidInput({
290 field: "provider",
291 message: `Auth provider '${input.provider}' is not registered.`,
292 }),
293 );
294 }
295 const connected = input.provider in stored.providers;
296 if (
297 (input.action === "add" && connected) ||
298 (input.action === "reconfigure" && !connected)
299 ) {
300 return yield* Effect.fail(
301 new AlchemistInvalidInput({
302 field: "provider",
303 message: `Provider '${input.provider}' is ${connected ? "already" : "not"} connected.`,
304 }),
305 );
306 }
307 const config =
308 input.method !== undefined &&
309 input.values !== undefined &&
310 provider.configureWith !== undefined
311 ? yield* provider.configureWith(input.profile, {
312 method: input.method,
313 values: Object.fromEntries(
314 Object.entries(input.values).map(([key, value]) => [
315 key,
316 Redacted.value(value),
317 ]),
318 ),
319 })
320 : yield* provider.configure(
321 input.profile,
322 connected
323 ? yield* provider
324 .decodeConfig(input.profile, stored.providers[input.provider]!)
325 .pipe(Effect.orElseSucceed(() => undefined))
326 : undefined,
327 );
328 yield* profiles.setProviderConfig(input.profile, input.provider, config);
329 yield* report({
330 _tag: "provider.configure.completed",
331 provider: input.provider,
332 });
333 return yield* get({
334 name: input.profile,
335 entrypoint: input.entrypoint,
336 envFile: input.envFile,
337 });
338});
339
340/** Log a provider out and disconnect it from the profile. */
341export const removeProvider = Effect.fn("Alchemist.profile.removeProvider")(
342 function* (
343 input: ProviderContext & {
344 readonly provider: string;
345 readonly logout?: boolean;
346 },
347 ) {
348 const profiles = yield* ProfileStore;
349 const stored = yield* profiles.ensureProfile(input.profile);
350 const config = stored.providers[input.provider];
351 if (config === undefined) {
352 return yield* Effect.fail(
353 new AlchemistNotFound({ kind: "provider", id: input.provider }),
354 );
355 }
356 const provider = (yield* registry(input))[input.provider];
357 let logout: "completed" | "skipped-invalid-config" | "unavailable" =
358 "unavailable";
359 if (provider !== undefined) {
360 const decoded = yield* provider
361 .decodeConfig(input.profile, config)
362 .pipe(Effect.option);
363 if (Option.isSome(decoded)) {
364 if (input.logout ?? true)
365 yield* provider.logout(input.profile, decoded.value);
366 logout = "completed";
367 } else logout = "skipped-invalid-config";
368 }
369 yield* profiles.deleteProviderConfig(input.profile, input.provider);
370 return { profile: input.profile, provider: input.provider, logout };
371 },
372);
373
374/**
375 * Re-run login for connected providers without reconfiguring them. Each
376 * provider is reported through {@link Progress} as `ProviderRefreshStarted`.
377 */
378export const refresh = Effect.fn("Alchemist.profile.refresh")(function* (
379 input: ProviderContext & {
380 readonly providers?: ReadonlyArray<string>;
381 },
382) {
383 const report = yield* Progress;
384 const profiles = yield* ProfileStore;
385 const stored = yield* profiles.ensureProfile(input.profile);
386 const registered = yield* registry(input);
387 const requested =
388 input.providers === undefined || input.providers.length === 0
389 ? Object.keys(stored.providers).sort()
390 : input.providers;
391 for (const name of requested) {
392 const config = stored.providers[name];
393 const provider = registered[name];
394 if (config === undefined || provider === undefined) {
395 return yield* Effect.fail(
396 new AlchemistInvalidInput({
397 field: "providers",
398 message: `Provider '${name}' is not connected or registered.`,
399 }),
400 );
401 }
402 yield* report({ _tag: "provider.refresh.started", provider: name });
403 const refreshed = yield* provider.login(
404 input.profile,
405 yield* provider.decodeConfig(input.profile, config),
406 (updated) =>
407 profiles.setProviderConfig(input.profile, name, updated).pipe(
408 Effect.mapError(
409 (cause) =>
410 new AuthError({
411 message: `Could not persist refreshed ${name} credentials for profile '${input.profile}'.`,
412 cause,
413 }),
414 ),
415 ),
416 );
417 if (refreshed !== undefined) {
418 yield* profiles.setProviderConfig(input.profile, name, refreshed);
419 }
420 yield* report({ _tag: "provider.refresh.completed", provider: name });
421 }
422 return yield* get({
423 name: input.profile,
424 entrypoint: input.entrypoint,
425 envFile: input.envFile,
426 });
427});