1import { downloadSecret, fromApiKey } from "@distilled.cloud/doppler";
2import * as Retry from "@distilled.cloud/doppler/Retry";
3import * as ConfigProvider from "effect/ConfigProvider";
4import * as Data from "effect/Data";
5import * as Effect from "effect/Effect";
6import type * as Redacted from "effect/Redacted";
7import { AuthError, refreshHint } from "../Auth/AuthProvider.ts";
8import { SuppressMissingProviderConfig } from "../Auth/Profile.ts";
9import { resolveProviderConfig } from "../Auth/Resolve.ts";
10import {
11 DopplerAuth,
12 type DopplerAuthConfig,
13 type DopplerResolvedCredentials,
14} from "./AuthProvider.ts";
15import { UserFacingError } from "../UserFacingError.ts";
16import { logLoadedKeys } from "../Secrets/Log.ts";
17import {
18 CredentialsConfig,
19 resolveSecretsOption,
20 type SecretsLayer,
21 type SecretsOption,
22} from "../Secrets/Provider.ts";
23
24export interface DopplerOptions {
25 /** Project slug. Required with browser login or personal tokens. */
26 project?: string;
27 /** Config slug, e.g. `dev` or `prd`. Required with browser login or personal tokens. */
28 config?: string;
29}
30
31interface DopplerCredentials {
32 readonly token: Redacted.Redacted<string>;
33 /** Set when the token came from a stored Alchemy profile. */
34 readonly profileName?: string;
35 /** How the stored profile authenticated, when the token came from one. */
36 readonly method?: DopplerAuthConfig["method"];
37}
38
39/** Human description of which secrets were asked for, for error messages. */
40const describeSelection = (options: DopplerOptions) => {
41 if (options.project && options.config) {
42 return `project '${options.project}' config '${options.config}'`;
43 }
44 if (options.project) {
45 return `project '${options.project}'`;
46 }
47 return "the token's own project and config";
48};
49
50/**
51 * Doppler could not serve the requested secrets. The message says "Doppler"
52 * up front and names the selection, so a stack trace never has to be read
53 * to know which secrets source broke.
54 */
55export class DopplerSecretsError extends Data.TaggedError(
56 "DopplerSecretsError",
57)<{
58 readonly selection: DopplerOptions;
59 readonly credentials: DopplerCredentials;
60 /** What the Doppler SDK failed with. */
61 readonly cause: { readonly _tag: string; readonly message: string };
62}> {
63 readonly [UserFacingError] = true;
64
65 override get message() {
66 const selection = describeSelection(this.selection);
67 switch (this.cause._tag) {
68 case "Unauthorized":
69 return this.credentials.profileName === undefined
70 ? "Doppler rejected the token. Check DOPPLER_TOKEN; to log in locally run `alchemy profile edit --add Doppler`."
71 : `Doppler credentials were rejected. ${refreshHint("Doppler", this.credentials.profileName)}`;
72 case "NotFound":
73 return `Doppler could not find ${selection}: ${this.cause.message}. Check Doppler.Secrets({ project, config }) and that the token has access to it.`;
74 default:
75 return `Doppler could not download secrets for ${selection}: ${this.cause.message}`;
76 }
77 }
78}
79
80/**
81 * Resolve the token through the Doppler auth provider: `DOPPLER_TOKEN` when
82 * present, otherwise the selected profile.
83 */
84const resolveCredentials = Effect.gen(function* () {
85 const resolved = yield* resolveProviderConfig<
86 DopplerAuthConfig,
87 DopplerResolvedCredentials
88 >("Doppler").pipe(Effect.provide(DopplerAuth));
89 const { token } = yield* resolved.resolve;
90 const credentials: DopplerCredentials = {
91 token,
92 profileName: resolved.profileName,
93 method: resolved.config?.method,
94 };
95 return credentials;
96}).pipe(Effect.provide(CredentialsConfig));
97
98/** Download every secret of the selected project/config as a flat env map. */
99const downloadSecrets = Effect.fn("downloadDopplerSecrets")(function* (
100 options: DopplerOptions,
101 credentials: DopplerCredentials,
102) {
103 const secrets = yield* downloadSecret({
104 project: options.project,
105 config: options.config,
106 format: "json",
107 }).pipe(
108 Retry.none,
109 Effect.provide(fromApiKey({ apiKey: credentials.token })),
110 Effect.timeout("30 seconds"),
111 Effect.mapError(
112 (cause) =>
113 new DopplerSecretsError({ selection: options, credentials, cause }),
114 ),
115 );
116
117 const env: Record<string, string> = {};
118 for (const [key, value] of Object.entries(secrets)) {
119 if (value !== undefined) env[key] = value;
120 }
121 return env;
122});
123
124/** A `secrets` entry that loads a Doppler config. */
125export class DopplerSecretsProvider extends Data.TaggedClass(
126 "alchemy/SecretProvider::Doppler",
127)<{ readonly layer: SecretsLayer }> {}
128
129/**
130 * Load Doppler secrets into Effect Config without touching `process.env`.
131 * Config-scoped service tokens may omit `project` and `config`.
132 *
133 * Authenticate locally with `alchemy profile edit --add Doppler` and choose
134 * Login or API token. In CI, set `DOPPLER_TOKEN`, or `DOPPLER_IDENTITY_ID`
135 * to log in with the platform's OIDC token. Loading secrets never starts a
136 * login flow.
137 *
138 * ```ts
139 * secrets: [
140 * Doppler.Secrets(({ stage }) => ({
141 * project: "my-app",
142 * config: stage === "prod" ? "prd" : "dev",
143 * })),
144 * ]
145 * ```
146 */
147export const Secrets = (options: SecretsOption<DopplerOptions> = {}) =>
148 new DopplerSecretsProvider({
149 layer: ConfigProvider.layerAdd(
150 Effect.gen(function* () {
151 // Auth-provider discovery builds stack layers just to find out which
152 // providers are used. It must work offline and with expired tokens, so
153 // the user can configure or refresh the very token this layer needs.
154 if (yield* SuppressMissingProviderConfig)
155 return ConfigProvider.fromEnv({ env: {} });
156
157 const resolved = yield* resolveSecretsOption(options);
158 const credentials = yield* resolveCredentials;
159
160 // Browser-login tokens are personal tokens: they can see every project,
161 // so Doppler needs to be told which one to read.
162 const missingSelector = !resolved.project || !resolved.config;
163 if (credentials.method === "login" && missingSelector) {
164 return yield* new AuthError({
165 message:
166 "Doppler browser login requires both project and config in Doppler.Secrets({ project, config }).",
167 });
168 }
169
170 const env = yield* downloadSecrets(resolved, credentials);
171 yield* logLoadedKeys(
172 `Doppler (${describeSelection(resolved)})`,
173 Object.keys(env),
174 );
175 return ConfigProvider.fromEnv({ env, preserveEmptyStrings: true });
176 }),
177 { asPrimary: true },
178 ),
179 });