1import { fromApiKey, listSecretRaw } from "@distilled.cloud/infisical";
2import * as Retry from "@distilled.cloud/infisical/Retry";
3import * as ConfigProvider from "effect/ConfigProvider";
4import * as Data from "effect/Data";
5import * as Effect from "effect/Effect";
6import * as Redacted from "effect/Redacted";
7import * as Schema from "effect/Schema";
8import { refreshHint } from "../Auth/AuthProvider.ts";
9import { SuppressMissingProviderConfig } from "../Auth/Profile.ts";
10import { resolveProviderConfig } from "../Auth/Resolve.ts";
11import {
12 InfisicalAuth,
13 type InfisicalAuthConfig,
14 type InfisicalResolvedCredentials,
15} from "./AuthProvider.ts";
16import { UserFacingError } from "../UserFacingError.ts";
17import { logLoadedKeys } from "../Secrets/Log.ts";
18import {
19 CredentialsConfig,
20 resolveSecretsOption,
21 type SecretsLayer,
22 type SecretsOption,
23} from "../Secrets/Provider.ts";
24
25export 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
51interface 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. */
57const 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 */
67export 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 */
98const 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
112const isUUID = Schema.is(Schema.String.check(Schema.isUUID()));
113
114const 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. */
118const 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. */
162export 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 */
184export 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 });