@alchemy.run/infisical 0.0.0 → 2.0.0-beta.85

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,202 @@
1
+ import { fromApiKey, getProjectBySlug, listSecretsV4 } from "@distilled.cloud/infisical";
2
+ import * as Retry from "@distilled.cloud/infisical/Retry";
3
+ import { refreshHint } from "alchemy/Auth/AuthProvider";
4
+ import { SuppressMissingProviderConfig } from "alchemy/Auth/Profile";
5
+ import { resolveProviderConfig } from "alchemy/Auth/Resolve";
6
+ import { logLoadedKeys } from "alchemy/Secrets/Log";
7
+ import {
8
+ CredentialsConfig,
9
+ resolveSecretsOption,
10
+ type SecretsLayer,
11
+ type SecretsOption,
12
+ } from "alchemy/Secrets/Provider";
13
+ import { UserFacingError } from "alchemy/UserFacingError";
14
+ import * as ConfigProvider from "effect/ConfigProvider";
15
+ import * as Data from "effect/Data";
16
+ import * as Effect from "effect/Effect";
17
+ import * as Redacted from "effect/Redacted";
18
+ import * as Schema from "effect/Schema";
19
+ import {
20
+ InfisicalAuth,
21
+ type InfisicalAuthConfig,
22
+ type InfisicalResolvedCredentials,
23
+ } from "./AuthProvider.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("InfisicalSecretsError")<{
68
+ readonly selection: InfisicalOptions;
69
+ readonly credentials: InfisicalCredentials;
70
+ /** What the Infisical SDK failed with. */
71
+ readonly cause: { readonly _tag: string; readonly message: string };
72
+ }> {
73
+ readonly [UserFacingError] = true;
74
+
75
+ override get message() {
76
+ const selection = describeSelection(this.selection);
77
+ switch (this.cause._tag) {
78
+ case "Unauthorized":
79
+ return this.credentials.profileName === undefined
80
+ ? "Infisical rejected the token. Check INFISICAL_TOKEN; to set up a machine identity locally run `alchemy profile edit --add Infisical`."
81
+ : `Infisical credentials were rejected. ${refreshHint("Infisical", this.credentials.profileName)}`;
82
+ case "NotFound":
83
+ case "Forbidden":
84
+ return `Infisical could not read ${selection}: ${this.cause.message}. Check Infisical.Secrets({ project, environment }) and that the identity has read access to it.`;
85
+ default:
86
+ return `Infisical could not download secrets for ${selection}: ${this.cause.message}`;
87
+ }
88
+ }
89
+ }
90
+
91
+ /**
92
+ * Resolve credentials through the Infisical auth provider: `INFISICAL_TOKEN`
93
+ * when present, otherwise the selected profile (which mints a token from
94
+ * the stored machine identity).
95
+ */
96
+ const resolveCredentials = Effect.gen(function* () {
97
+ const resolved = yield* resolveProviderConfig<InfisicalAuthConfig, InfisicalResolvedCredentials>(
98
+ "Infisical",
99
+ ).pipe(Effect.provide(InfisicalAuth));
100
+ const { token, apiBaseUrl } = yield* resolved.resolve;
101
+ const credentials: InfisicalCredentials = {
102
+ token,
103
+ apiBaseUrl,
104
+ profileName: resolved.profileName,
105
+ };
106
+ return credentials;
107
+ }).pipe(Effect.provide(CredentialsConfig));
108
+
109
+ const isUUID = Schema.is(Schema.String.check(Schema.isUUID()));
110
+
111
+ const text = (value: string | Redacted.Redacted<string>) =>
112
+ Redacted.isRedacted(value) ? Redacted.value(value) : value;
113
+
114
+ /** Download the selected secrets (plus imports) as a flat env map. */
115
+ const downloadSecrets = Effect.fn("downloadInfisicalSecrets")(function* (
116
+ options: InfisicalOptions,
117
+ credentials: InfisicalCredentials,
118
+ ) {
119
+ const includeImports = options.includeImports ?? true;
120
+ const response = yield* Effect.gen(function* () {
121
+ // The v4 secrets API only accepts project ids, so resolve slugs first.
122
+ const projectId = isUUID(options.project)
123
+ ? options.project
124
+ : (yield* getProjectBySlug({ slug: options.project })).id;
125
+ return yield* listSecretsV4({
126
+ projectId,
127
+ environment: options.environment,
128
+ secretPath: options.path,
129
+ recursive: options.recursive,
130
+ includeImports,
131
+ viewSecretValue: true,
132
+ expandSecretReferences: true,
133
+ });
134
+ }).pipe(
135
+ Retry.none,
136
+ Effect.provide(
137
+ fromApiKey({
138
+ apiKey: credentials.token,
139
+ apiBaseUrl: credentials.apiBaseUrl,
140
+ }),
141
+ ),
142
+ Effect.timeout("30 seconds"),
143
+ Effect.mapError(
144
+ (cause) => 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) return ConfigProvider.fromEnv({ env: {} });
193
+
194
+ const resolved = yield* resolveSecretsOption(options);
195
+ const credentials = yield* resolveCredentials;
196
+ const env = yield* downloadSecrets(resolved, credentials);
197
+ yield* logLoadedKeys(`Infisical (${describeSelection(resolved)})`, Object.keys(env));
198
+ return ConfigProvider.fromEnv({ env, preserveEmptyStrings: true });
199
+ }),
200
+ { asPrimary: true },
201
+ ),
202
+ });
package/src/index.ts ADDED
@@ -0,0 +1,19 @@
1
+ export {
2
+ InfisicalAuth,
3
+ InfisicalAuthConfigSchema,
4
+ INFISICAL_API_URL_ENV,
5
+ INFISICAL_IDENTITY_ID_ENV,
6
+ INFISICAL_OIDC_AUDIENCE_ENV,
7
+ INFISICAL_OIDC_TOKEN_ENV,
8
+ INFISICAL_TOKEN_ENV,
9
+ mintAccessToken,
10
+ mintAccessTokenFromOidc,
11
+ type InfisicalAuthConfig,
12
+ type InfisicalResolvedCredentials,
13
+ } from "./AuthProvider.ts";
14
+ export {
15
+ Secrets,
16
+ InfisicalSecretsError,
17
+ InfisicalSecretsProvider,
18
+ type InfisicalOptions,
19
+ } from "./SecretsProvider.ts";