1import type { AppSecret } from "@distilled.cloud/fly-io/machines";
2import * as machines from "@distilled.cloud/fly-io/machines";
3import { createHash } from "node:crypto";
4import * as Data from "effect/Data";
5import * as Effect from "effect/Effect";
6import * as Redacted from "effect/Redacted";
7import * as Schedule from "effect/Schedule";
8import { Unowned } from "../AdoptPolicy.ts";
9import { isResolved } from "../Diff.ts";
10import * as Provider from "../Provider.ts";
11import { Resource } from "../Resource.ts";
12import { App, listOwnedApps } from "./App.ts";
13import { createFlyVolumeName, matchesAlchemyPhysicalName } from "./Metadata.ts";
14import type { Providers } from "./Providers.ts";
15
16/**
17 * A resource-valued prop: the resource itself, or an Effect that produces
18 * it (so `yield* App(...)` and `App(...)` both type-check).
19 */
20type Ref<T> = T | Effect.Effect<T, never, Providers>;
21
22export interface SecretProps {
23 /**
24 * Parent Fly App. Changing it replaces the secret.
25 */
26 app: Ref<App>;
27 /**
28 * Secret name. Used as the Machine env-var name when set by the user
29 * (case-sensitive, stored as-is). If omitted, a unique name is generated
30 * from the stack, stage and logical ID (the ownership stamp). Changing
31 * it replaces the secret.
32 */
33 name?: string;
34 /**
35 * Secret value. Wrap with `Redacted.make(...)` so it is never logged.
36 * Updated in place via `updateSecrets`. Never persisted in attributes.
37 */
38 value: Redacted.Redacted<string> | string;
39}
40
41export type Secret = Resource<
42 "Fly.Secret",
43 SecretProps,
44 {
45 /** Parent Fly App name. */
46 appName: string;
47 /** Secret name (unique per App). */
48 name: string;
49 /** Fly digest of the current value. Not the plaintext. */
50 digest: string | undefined;
51 /** RFC3339 creation timestamp. */
52 createdAt: string | undefined;
53 /** RFC3339 last-update timestamp. */
54 updatedAt: string | undefined;
55 },
56 never,
57 Providers
58>;
59
60const resolveSecretProps = (
61 props: SecretProps | Effect.Effect<SecretProps, never, Providers>,
62): Effect.Effect<SecretProps, never, Providers> =>
63 Effect.gen(function* () {
64 const resolved = Effect.isEffect(props) ? yield* props : props;
65 if (globalThis.__ALCHEMY_RUNTIME__) return resolved;
66 const app = Effect.isEffect(resolved.app)
67 ? yield* resolved.app as Effect.Effect<App, never, Providers>
68 : resolved.app;
69 return { ...resolved, app };
70 });
71
72const SecretResource = Resource<Secret>("Fly.Secret");
73
74/**
75 * A Fly.Secret is an App vault entry. Fly injects it as an environment
76 * variable on every Machine. Use it when the value is shared and
77 * managed in one place by Fly.
78 *
79 * For a secret only this {@link Service} reads from `.env` at deploy
80 * time, yield `Config.Redacted` instead. Do not pass `env: { ... }` on
81 * a Service.
82 *
83 * @see https://fly.io/docs/apps/secrets/
84 *
85 * ### Config.Redacted on a Service
86 * Most secrets in a Service come from your `.env`. Yield
87 * `Config.Redacted` in init. Alchemy binds the value onto the Machine.
88 *
89 * **Example:** Bind from .env
90 * ```typescript
91 * import * as Config from "effect/Config";
92 * import * as Redacted from "effect/Redacted";
93 *
94 * export default class Api extends Fly.Service<Api>()(
95 * "Api",
96 * { app: Site, main: import.meta.url, port: 3000 },
97 * Effect.gen(function* () {
98 * const apiKey = yield* Config.Redacted("API_KEY");
99 *
100 * return {
101 * fetch: Effect.gen(function* () {
102 * const token = Redacted.value(apiKey);
103 * return HttpServerResponse.text("ok");
104 * }),
105 * };
106 * }),
107 * ) {}
108 * ```
109 *
110 * ### Create a Secret
111 * Wrap the value with `Redacted.make` so it is never logged. The
112 * plaintext is never stored in attributes. Omit `name` and Alchemy
113 * generates an ownership-stamped name.
114 *
115 * **Example:** Generated name
116 * ```typescript
117 * const dbUrl = yield* Fly.Secret("DatabaseUrl", {
118 * app: Site,
119 * value: Redacted.make("postgres://…"),
120 * });
121 * ```
122 *
123 * :::caution[Changing `app` replaces the Secret]
124 * The value is created on the new App. The old name is deleted.
125 * :::
126 *
127 * ### Env-var name
128 * `name` is the env-var Machines see. It is stored as-is
129 * (case-sensitive).
130 *
131 * **Example:** Explicit name
132 * ```typescript
133 * export const ApiToken = Fly.Secret("ApiToken", {
134 * app: Site,
135 * name: "API_TOKEN",
136 * value: Redacted.make("sk_live_…"),
137 * });
138 * ```
139 *
140 * :::caution[Changing `name` replaces the Secret]
141 * Fly cannot rename a secret. Alchemy creates the new name, then
142 * deletes the old one.
143 * :::
144 *
145 * ### Rotate the value
146 * Updating `value` is in place via `updateSecrets`.
147 *
148 * **Example:** New value
149 * ```typescript
150 * export const ApiToken = Fly.Secret("ApiToken", {
151 * app: Site,
152 * name: "API_TOKEN",
153 * value: Redacted.make("sk_live_rotated"),
154 * });
155 * ```
156 *
157 * ### Get a secret at runtime
158 * {@link GetSecret} is bound to one Secret. Provide
159 * {@link GetSecretHttp}. Fly only returns plaintext from a Machine in
160 * the same App. From a deploy-time Action you get metadata (name,
161 * digest, timestamps).
162 *
163 * **Example:** GetSecret
164 * ```typescript
165 * const get = yield* Fly.GetSecret(ApiToken);
166 * const got = yield* get();
167 * ```
168 *
169 * ### List secrets
170 * {@link ListSecrets} is bound to an {@link App}. From an Action, the
171 * org token can list any App in the org. From a Machine, deploy tokens
172 * are per-App. Mixing Apps on one Machine shares one `FLY_API_TOKEN`
173 * and is not supported.
174 *
175 * **Example:** ListSecrets
176 * ```typescript
177 * const list = yield* Fly.ListSecrets(Site);
178 * const { secrets } = yield* list();
179 * ```
180 *
181 * ### Write secrets
182 * {@link WriteSecret} creates, updates, and deletes by name. Provide
183 * {@link WriteSecretHttp} on the Action or Service Effect.
184 *
185 * **Example:** Rotate from an Action
186 * ```typescript
187 * const Seed = Alchemy.Action(
188 * "Seed",
189 * Effect.gen(function* () {
190 * const secrets = yield* Fly.WriteSecret(ApiToken);
191 *
192 * return Effect.fn(function* () {
193 * yield* secrets.update("API_TOKEN", Redacted.make("sk_live_rotated"));
194 * });
195 * }).pipe(Effect.provide(Fly.WriteSecretHttp)),
196 * );
197 * ```
198 *
199 * @resource
200 * @product Secret
201 */
202export const Secret: typeof SecretResource = Object.assign(
203 (
204 id: string,
205 props: SecretProps | Effect.Effect<SecretProps, never, Providers>,
206 ) => SecretResource(id, resolveSecretProps(props)),
207 SecretResource,
208);
209
210export class SecretNotCreated extends Data.TaggedError("Fly.SecretNotCreated")<{
211 appName: string;
212 name: string;
213}> {}
214
215export class SecretAppRequired extends Data.TaggedError(
216 "Fly.SecretAppRequired",
217)<{
218 message: string;
219}> {}
220
221const appNameOf = (value: unknown): string | undefined => {
222 if (value === null || typeof value !== "object") return undefined;
223 const rec = value as { appName?: unknown };
224 return typeof rec.appName === "string" && rec.appName.length > 0
225 ? rec.appName
226 : undefined;
227};
228
229const unwrapSecret = (value: Redacted.Redacted<string> | string): string =>
230 Redacted.isRedacted(value) ? Redacted.value(value) : value;
231
232const resolveName = (id: string, name: string | undefined, existing?: string) =>
233 Effect.gen(function* () {
234 // User env-var names are stored as-is (case-sensitive). Generated
235 // names use the Volume underscore shape — Fly rejects hyphens.
236 if (name !== undefined) return name;
237 if (existing !== undefined) return existing;
238 return yield* createFlyVolumeName(id);
239 });
240
241const toAttrs = (
242 appName: string,
243 secret: AppSecret,
244 fallbackName: string,
245): Secret["Attributes"] => ({
246 appName,
247 name: secret.name ?? fallbackName,
248 digest: secret.digest,
249 createdAt: secret.created_at,
250 updatedAt: secret.updated_at,
251});
252
253const getByName = (appName: string, secretName: string) =>
254 machines
255 .getSecret({
256 app_name: appName,
257 secret_name: secretName,
258 show_secrets: false,
259 })
260 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
261
262const listSecrets = (appName: string) =>
263 machines
264 .listSecrets({
265 app_name: appName,
266 show_secrets: false,
267 })
268 .pipe(
269 Effect.map((res) => res.secrets ?? []),
270 Effect.catchTag(["NotFound", "Forbidden"], () => Effect.succeed([])),
271 );
272
273const flyDigestCandidates = (plain: string) =>
274 Effect.sync(() => {
275 const utf8 = Buffer.from(plain, "utf8");
276 return [
277 createHash("md5").update(utf8).digest("hex"),
278 createHash("sha256").update(utf8).digest("hex"),
279 ];
280 });
281
282export const SecretProvider = () =>
283 Provider.succeed(Secret, {
284 stables: ["appName", "name", "createdAt"],
285 nuke: { dependsOn: ["Fly.App"] },
286
287 diff: Effect.fn(function* ({ news, output }) {
288 if (news === undefined || !isResolved(news)) return undefined;
289 if (output === undefined) return undefined;
290 const desiredName = news.name !== undefined ? news.name : output.name;
291 const nameChanged = desiredName !== output.name;
292 const nextApp = appNameOf(news.app);
293 const appChanged = nextApp !== undefined && nextApp !== output.appName;
294 if (nameChanged || appChanged) {
295 return {
296 action: "replace" as const,
297 // Same (app, name) cannot exist twice — delete the old secret first.
298 deleteFirst: nameChanged && !appChanged,
299 };
300 }
301 return undefined;
302 }),
303
304 read: Effect.fn(function* ({ id, olds, output }) {
305 const appName =
306 output?.appName ??
307 (olds !== undefined ? appNameOf(olds.app) : undefined);
308 if (appName === undefined) return undefined;
309 const name = yield* resolveName(id, olds?.name, output?.name);
310 const found = yield* getByName(appName, name);
311 if (found === undefined) return undefined;
312 const attrs = toAttrs(appName, found, name);
313 if (output !== undefined) return attrs;
314 return matchesAlchemyPhysicalName(name) ? attrs : Unowned(attrs);
315 }),
316
317 list: Effect.fn(function* () {
318 const apps = yield* listOwnedApps();
319 const rows = yield* Effect.forEach(
320 apps,
321 (app) =>
322 listSecrets(app.appName).pipe(
323 Effect.map((secrets) =>
324 secrets.flatMap((secret) => {
325 const name = secret.name;
326 if (!matchesAlchemyPhysicalName(name)) return [];
327 return [toAttrs(app.appName, secret, name ?? "")];
328 }),
329 ),
330 ),
331 { concurrency: 8 },
332 );
333 return rows.flat();
334 }),
335
336 reconcile: Effect.fn(function* ({ id, news, olds, output }) {
337 const props = news ?? ({} as SecretProps);
338 const appName = appNameOf(props.app) ?? output?.appName;
339 if (appName === undefined) {
340 return yield* new SecretAppRequired({
341 message: "Secret requires a resolved Fly.App",
342 });
343 }
344 const name = yield* resolveName(id, props.name, output?.name);
345 const desiredPlain = unwrapSecret(props.value);
346
347 // Observe by cached identity, then the desired (app, name).
348 let current =
349 output !== undefined
350 ? yield* getByName(output.appName, output.name)
351 : undefined;
352 if (
353 current === undefined &&
354 (output === undefined ||
355 output.appName !== appName ||
356 output.name !== name)
357 ) {
358 current = yield* getByName(appName, name);
359 }
360
361 let createdThisRun = false;
362 if (current === undefined) {
363 yield* machines
364 .createSecret({
365 app_name: appName,
366 secret_name: name,
367 value: desiredPlain,
368 })
369 .pipe(Effect.catchTag("Conflict", () => Effect.void));
370 current = yield* getByName(appName, name);
371 createdThisRun = true;
372 }
373
374 if (current === undefined) {
375 return yield* new SecretNotCreated({ appName, name });
376 }
377
378 // Sync — skip if we just created. Prefer observed digest vs hash of
379 // desired; otherwise re-put when olds is absent (adoption) or the
380 // previous value differs. Never log the plaintext.
381 if (!createdThisRun) {
382 const candidates = yield* flyDigestCandidates(desiredPlain);
383 const digestMatches =
384 current.digest !== undefined && candidates.includes(current.digest);
385 const previousPlain =
386 olds?.value !== undefined ? unwrapSecret(olds.value) : undefined;
387 const valueChanged =
388 previousPlain === undefined || previousPlain !== desiredPlain;
389 if (!digestMatches && valueChanged) {
390 yield* machines
391 .updateSecrets({
392 app_name: appName,
393 values: { [name]: desiredPlain },
394 })
395 .pipe(Effect.catchTag("Conflict", () => Effect.void));
396 current = (yield* getByName(appName, name)) ?? current;
397 }
398 }
399
400 return toAttrs(appName, current, name);
401 }),
402
403 delete: Effect.fn(function* ({ output }) {
404 if (output.appName.length === 0 || output.name.length === 0) return;
405 yield* machines
406 .deleteSecret({
407 app_name: output.appName,
408 secret_name: output.name,
409 })
410 .pipe(Effect.catchTag("NotFound", () => Effect.void));
411 yield* getByName(output.appName, output.name).pipe(
412 Effect.map((secret) => secret === undefined),
413 Effect.repeat({
414 schedule: Schedule.spaced("1 second"),
415 until: (gone) => gone,
416 times: 8,
417 }),
418 );
419 }),
420 });