| 1 | import * as Effect from "effect/Effect"; |
| 2 | import * as Option from "effect/Option"; |
| 3 | import * as Redacted from "effect/Redacted"; |
| 4 | import type { Interaction } from "../../Interaction.ts"; |
| 5 | import { AuthError } from "../../Auth/AuthProvider.ts"; |
| 6 | import { CredentialsStore } from "../../Auth/Credentials.ts"; |
| 7 | import { |
| 8 | inspectProvider, |
| 9 | type ProviderConnection, |
| 10 | } from "../../Auth/Inspect.ts"; |
| 11 | import { withProfileCredentialsLock } from "../../Auth/Lock.ts"; |
| 12 | import { ProfileStore } from "../../Auth/Profile.ts"; |
| 13 | import { AlchemistInvalidInput, AlchemistNotFound } from "../Errors.ts"; |
| 14 | import { Progress } from "../Progress.ts"; |
| 15 | import { |
| 16 | collectAuthProviders, |
| 17 | DEFAULT_ENTRYPOINT, |
| 18 | type Target, |
| 19 | } from "../Session.ts"; |
| 20 | |
| 21 | /** Which project/profile pair a provider-scoped route resolves against. */ |
| 22 | export interface ProviderContext extends Target { |
| 23 | readonly profile: string; |
| 24 | } |
| 25 | |
| 26 | export 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 | |
| 35 | export type { ProviderConnection } from "../../Auth/Inspect.ts"; |
| 36 | |
| 37 | export interface ProfileSnapshot { |
| 38 | readonly name: string; |
| 39 | readonly active: boolean; |
| 40 | readonly providers: ReadonlyArray<ProviderConnection>; |
| 41 | } |
| 42 | |
| 43 | export 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 | |
| 52 | export interface ConfigureMethod { |
| 53 | readonly method: string; |
| 54 | readonly label: string; |
| 55 | readonly fields: ReadonlyArray<ConfigureField>; |
| 56 | } |
| 57 | |
| 58 | export 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 | |
| 66 | export 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 | */ |
| 77 | const 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. */ |
| 85 | export 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. */ |
| 90 | export 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. */ |
| 113 | export 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 | |
| 179 | export 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 | |
| 186 | export 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. */ |
| 195 | export 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. */ |
| 216 | export 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. */ |
| 250 | export 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 | */ |
| 276 | export 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. */ |
| 341 | export 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 | */ |
| 378 | export 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 | }); |