| 1 | import { fromApiKey, listSecretRaw } from "@distilled.cloud/infisical"; |
| 2 | import * as Retry from "@distilled.cloud/infisical/Retry"; |
| 3 | import * as ConfigProvider from "effect/ConfigProvider"; |
| 4 | import * as Data from "effect/Data"; |
| 5 | import * as Effect from "effect/Effect"; |
| 6 | import * as Redacted from "effect/Redacted"; |
| 7 | import * as Schema from "effect/Schema"; |
| 8 | import { refreshHint } from "../Auth/AuthProvider.ts"; |
| 9 | import { SuppressMissingProviderConfig } from "../Auth/Profile.ts"; |
| 10 | import { resolveProviderConfig } from "../Auth/Resolve.ts"; |
| 11 | import { |
| 12 | InfisicalAuth, |
| 13 | type InfisicalAuthConfig, |
| 14 | type InfisicalResolvedCredentials, |
| 15 | } from "./AuthProvider.ts"; |
| 16 | import { UserFacingError } from "../UserFacingError.ts"; |
| 17 | import { logLoadedKeys } from "../Secrets/Log.ts"; |
| 18 | import { |
| 19 | CredentialsConfig, |
| 20 | resolveSecretsOption, |
| 21 | type SecretsLayer, |
| 22 | type SecretsOption, |
| 23 | } from "../Secrets/Provider.ts"; |
| 24 | |
| 25 | export interface InfisicalOptions { |
| 26 | /** |
| 27 | * Project slug, or the project's UUID. Infisical only resolves slugs for |
| 28 | * machine identities, so a user token must pass the UUID. |
| 29 | */ |
| 30 | project: string; |
| 31 | /** Environment slug, e.g. `dev` or `prod`. */ |
| 32 | environment: string; |
| 33 | /** |
| 34 | * Folder to read from. |
| 35 | * @default "/" |
| 36 | */ |
| 37 | path?: string; |
| 38 | /** |
| 39 | * Also read every sub-folder of `path`. |
| 40 | * @default false |
| 41 | */ |
| 42 | recursive?: boolean; |
| 43 | /** |
| 44 | * Include secrets imported into the folder from elsewhere. Directly |
| 45 | * defined secrets win over imported ones. |
| 46 | * @default true |
| 47 | */ |
| 48 | includeImports?: boolean; |
| 49 | } |
| 50 | |
| 51 | interface InfisicalCredentials extends InfisicalResolvedCredentials { |
| 52 | /** Set when the credentials came from a stored Alchemy profile. */ |
| 53 | readonly profileName?: string; |
| 54 | } |
| 55 | |
| 56 | /** Human description of which secrets were asked for, for error messages. */ |
| 57 | const describeSelection = (options: InfisicalOptions) => { |
| 58 | const folder = options.path === undefined ? "" : ` path '${options.path}'`; |
| 59 | return `project '${options.project}' environment '${options.environment}'${folder}`; |
| 60 | }; |
| 61 | |
| 62 | /** |
| 63 | * Infisical could not serve the requested secrets. The message says |
| 64 | * "Infisical" up front and names the selection, so a stack trace never has |
| 65 | * to be read to know which secrets source broke. |
| 66 | */ |
| 67 | export class InfisicalSecretsError extends Data.TaggedError( |
| 68 | "InfisicalSecretsError", |
| 69 | )<{ |
| 70 | readonly selection: InfisicalOptions; |
| 71 | readonly credentials: InfisicalCredentials; |
| 72 | /** What the Infisical SDK failed with. */ |
| 73 | readonly cause: { readonly _tag: string; readonly message: string }; |
| 74 | }> { |
| 75 | readonly [UserFacingError] = true; |
| 76 | |
| 77 | override get message() { |
| 78 | const selection = describeSelection(this.selection); |
| 79 | switch (this.cause._tag) { |
| 80 | case "Unauthorized": |
| 81 | return this.credentials.profileName === undefined |
| 82 | ? "Infisical rejected the token. Check INFISICAL_TOKEN; to set up a machine identity locally run `alchemy profile edit --add Infisical`." |
| 83 | : `Infisical credentials were rejected. ${refreshHint("Infisical", this.credentials.profileName)}`; |
| 84 | case "NotFound": |
| 85 | case "Forbidden": |
| 86 | return `Infisical could not read ${selection}: ${this.cause.message}. Check Infisical.Secrets({ project, environment }) and that the identity has read access to it.`; |
| 87 | default: |
| 88 | return `Infisical could not download secrets for ${selection}: ${this.cause.message}`; |
| 89 | } |
| 90 | } |
| 91 | } |
| 92 | |
| 93 | /** |
| 94 | * Resolve credentials through the Infisical auth provider: `INFISICAL_TOKEN` |
| 95 | * when present, otherwise the selected profile (which mints a token from |
| 96 | * the stored machine identity). |
| 97 | */ |
| 98 | const resolveCredentials = Effect.gen(function* () { |
| 99 | const resolved = yield* resolveProviderConfig< |
| 100 | InfisicalAuthConfig, |
| 101 | InfisicalResolvedCredentials |
| 102 | >("Infisical").pipe(Effect.provide(InfisicalAuth)); |
| 103 | const { token, apiBaseUrl } = yield* resolved.resolve; |
| 104 | const credentials: InfisicalCredentials = { |
| 105 | token, |
| 106 | apiBaseUrl, |
| 107 | profileName: resolved.profileName, |
| 108 | }; |
| 109 | return credentials; |
| 110 | }).pipe(Effect.provide(CredentialsConfig)); |
| 111 | |
| 112 | const isUUID = Schema.is(Schema.String.check(Schema.isUUID())); |
| 113 | |
| 114 | const text = (value: string | Redacted.Redacted<string>) => |
| 115 | Redacted.isRedacted(value) ? Redacted.value(value) : value; |
| 116 | |
| 117 | /** Download the selected secrets (plus imports) as a flat env map. */ |
| 118 | const downloadSecrets = Effect.fn("downloadInfisicalSecrets")(function* ( |
| 119 | options: InfisicalOptions, |
| 120 | credentials: InfisicalCredentials, |
| 121 | ) { |
| 122 | const includeImports = options.includeImports ?? true; |
| 123 | const byId = isUUID(options.project); |
| 124 | const response = yield* listSecretRaw({ |
| 125 | workspaceId: byId ? options.project : undefined, |
| 126 | workspaceSlug: byId ? undefined : options.project, |
| 127 | environment: options.environment, |
| 128 | secretPath: options.path, |
| 129 | recursive: options.recursive, |
| 130 | include_imports: includeImports, |
| 131 | viewSecretValue: true, |
| 132 | expandSecretReferences: true, |
| 133 | }).pipe( |
| 134 | Retry.none, |
| 135 | Effect.provide( |
| 136 | fromApiKey({ |
| 137 | apiKey: Redacted.value(credentials.token), |
| 138 | apiBaseUrl: credentials.apiBaseUrl, |
| 139 | }), |
| 140 | ), |
| 141 | Effect.timeout("30 seconds"), |
| 142 | Effect.mapError( |
| 143 | (cause) => |
| 144 | new InfisicalSecretsError({ selection: options, credentials, cause }), |
| 145 | ), |
| 146 | ); |
| 147 | |
| 148 | // Imports first so a directly defined secret overwrites an imported one. |
| 149 | const env: Record<string, string> = {}; |
| 150 | for (const imported of response.imports ?? []) { |
| 151 | for (const secret of imported.secrets) { |
| 152 | env[text(secret.secretKey)] = secret.secretValue; |
| 153 | } |
| 154 | } |
| 155 | for (const secret of response.secrets) { |
| 156 | env[text(secret.secretKey)] = secret.secretValue; |
| 157 | } |
| 158 | return env; |
| 159 | }); |
| 160 | |
| 161 | /** A `secrets` entry that loads an Infisical environment. */ |
| 162 | export class InfisicalSecretsProvider extends Data.TaggedClass( |
| 163 | "alchemy/SecretProvider::Infisical", |
| 164 | )<{ readonly layer: SecretsLayer }> {} |
| 165 | |
| 166 | /** |
| 167 | * Load Infisical secrets into Effect Config without touching `process.env`. |
| 168 | * |
| 169 | * Authenticate locally with `alchemy profile edit --add Infisical` and |
| 170 | * paste a machine identity's universal-auth client id and secret; Alchemy |
| 171 | * mints a fresh access token on every run. The client secret must remain valid. In |
| 172 | * CI, set `INFISICAL_TOKEN`, or `INFISICAL_IDENTITY_ID` to log in with the |
| 173 | * platform's OIDC token. |
| 174 | * |
| 175 | * ```ts |
| 176 | * secrets: [ |
| 177 | * Infisical.Secrets(({ stage }) => ({ |
| 178 | * project: "my-app", |
| 179 | * environment: stage === "prod" ? "prod" : "dev", |
| 180 | * })), |
| 181 | * ] |
| 182 | * ``` |
| 183 | */ |
| 184 | export const Secrets = (options: SecretsOption<InfisicalOptions>) => |
| 185 | new InfisicalSecretsProvider({ |
| 186 | layer: ConfigProvider.layerAdd( |
| 187 | Effect.gen(function* () { |
| 188 | // Auth-provider discovery builds stack layers just to find out which |
| 189 | // providers are used. It must work offline and with broken |
| 190 | // credentials, so the user can configure the very identity this |
| 191 | // layer needs. |
| 192 | if (yield* SuppressMissingProviderConfig) |
| 193 | return ConfigProvider.fromEnv({ env: {} }); |
| 194 | |
| 195 | const resolved = yield* resolveSecretsOption(options); |
| 196 | const credentials = yield* resolveCredentials; |
| 197 | const env = yield* downloadSecrets(resolved, credentials); |
| 198 | yield* logLoadedKeys( |
| 199 | `Infisical (${describeSelection(resolved)})`, |
| 200 | Object.keys(env), |
| 201 | ); |
| 202 | return ConfigProvider.fromEnv({ env, preserveEmptyStrings: true }); |
| 203 | }), |
| 204 | { asPrimary: true }, |
| 205 | ), |
| 206 | }); |