1import {
2 Credentials,
3 CredentialsFromEnv,
4 credentials,
5 MachineIdentity,
6} from "@distilled.cloud/fly-io";
7import * as NodeHttpClient from "@effect/platform-node/NodeHttpClient";
8import type * as Context from "effect/Context";
9import * as Effect from "effect/Effect";
10import * as Layer from "effect/Layer";
11import * as Redacted from "effect/Redacted";
12import * as FetchHttpClient from "effect/http/FetchHttpClient";
13import * as HttpClient from "effect/http/HttpClient";
14import { bindFlyApiToken } from "./Credentials.ts";
15import type { RuntimeContext } from "../RuntimeContext.ts";
16import type { App } from "./App.ts";
17import type { Secret } from "./Secret.ts";
18import type { SecretKey } from "./SecretKey.ts";
19
20/**
21 * Shared scaffolding for the HTTP-backed Fly Secret bindings.
22 *
23 * Fly has no native Worker-style binding. This layer captures the ambient
24 * org token during stack-eval (so Actions work in-process) and `yield*`s
25 * it plus `appName` / secret `name` so RuntimeContext.set runs. Platform
26 * copies those Outputs into host env; runtime `yield*` gets them back.
27 * {@link CredentialsFromEnv} still reads `FLY_API_TOKEN` after that copy.
28 *
29 * NOT exported from `index.ts`.
30 */
31export type AppNamed = Secret | SecretKey;
32
33export const makeHttpSecretBinding = <
34 Target extends AppNamed,
35 Client,
36>(options: {
37 makeClient: (
38 auth: SecretAuth,
39 appName: Effect.Effect<string>,
40 secretName: Effect.Effect<string>,
41 ) => Client;
42 /**
43 * PetSem encrypt/sign/decrypt/verify only work from a Machine over
44 * `/.fly/api` (implicit machine identity). Org tokens return Forbidden.
45 */
46 kms?: boolean;
47}) =>
48 Effect.gen(function* () {
49 const context = yield* Effect.context<
50 Credentials | HttpClient.HttpClient
51 >();
52 const auth =
53 options.kms === true ? makeKmsAuth(context) : makeSecretAuth(context);
54
55 return Effect.fn(function* (resource: Target) {
56 yield* bindFlyApiToken().pipe(Effect.provideContext(context));
57 const appName = yield* resource.appName;
58 const secretName = yield* resource.name;
59 return options.makeClient(auth, appName, secretName);
60 });
61 });
62
63/**
64 * Same scaffolding as {@link makeHttpSecretBinding}, but the target is
65 * an {@link App} (no secret name). Used by ListSecrets.
66 */
67export const makeHttpAppBinding = <Client>(options: {
68 makeClient: (auth: SecretAuth, appName: Effect.Effect<string>) => Client;
69}) =>
70 Effect.gen(function* () {
71 const context = yield* Effect.context<
72 Credentials | HttpClient.HttpClient
73 >();
74
75 return Effect.fn(function* (app: App) {
76 yield* bindFlyApiToken().pipe(Effect.provideContext(context));
77 const appName = yield* app.appName;
78 return options.makeClient(makeSecretAuth(context), appName);
79 });
80 });
81
82/**
83 * Injectable auth for the Secret HTTP client builders. Supplies an
84 * `authorize` that provides `Credentials` + `HttpClient` to a raw SDK op.
85 */
86export interface SecretAuth {
87 authorize: <A, E>(
88 eff: Effect.Effect<A, E, Credentials | HttpClient.HttpClient>,
89 ) => Effect.Effect<A, E, RuntimeContext>;
90}
91
92/** Build auth that uses ambient stack creds, or env creds inside a host. */
93export const makeSecretAuth = (
94 ambient: Context.Context<Credentials | HttpClient.HttpClient>,
95): SecretAuth => ({
96 authorize: <A, E>(
97 eff: Effect.Effect<A, E, Credentials | HttpClient.HttpClient>,
98 ): Effect.Effect<A, E, RuntimeContext> => {
99 if (globalThis.__ALCHEMY_RUNTIME__) {
100 return eff.pipe(
101 Effect.provide(
102 Layer.mergeAll(CredentialsFromEnv, FetchHttpClient.layer),
103 ),
104 Effect.timeout("8 seconds"),
105 ) as Effect.Effect<A, E, RuntimeContext>;
106 }
107 return eff.pipe(Effect.provideContext(ambient)) as Effect.Effect<
108 A,
109 E,
110 RuntimeContext
111 >;
112 },
113});
114
115const FLY_MACHINE_API_SOCKET = "/.fly/api";
116
117/** Distilled machines client over `/.fly/api`. URL host is unused. */
118const flyMachineApiHttp: Layer.Layer<HttpClient.HttpClient> =
119 NodeHttpClient.layerNodeHttpNoAgent.pipe(
120 Layer.provide(
121 NodeHttpClient.layerAgentOptions({
122 // @ts-expect-error Node Agent accepts unix socketPath; Https.AgentOptions omits it
123 socketPath: FLY_MACHINE_API_SOCKET,
124 keepAlive: false,
125 }),
126 ),
127 );
128
129/**
130 * PetSem encrypt/sign/decrypt/verify from a Machine. Org API tokens are
131 * Forbidden; the machine identity is the unix socket at `/.fly/api`.
132 * {@link MachineIdentity} is the protocol signal (no Authorization,
133 * Connection: close). GetSecret in the same process keeps Bearer.
134 */
135export const makeKmsAuth = (
136 ambient: Context.Context<Credentials | HttpClient.HttpClient>,
137): SecretAuth => ({
138 authorize: <A, E>(
139 eff: Effect.Effect<A, E, Credentials | HttpClient.HttpClient>,
140 ): Effect.Effect<A, E, RuntimeContext> => {
141 if (globalThis.__ALCHEMY_RUNTIME__) {
142 return Effect.scoped(
143 eff.pipe(
144 Effect.provide(
145 Layer.mergeAll(
146 Layer.succeed(MachineIdentity, true),
147 flyMachineApiHttp,
148 credentials({
149 apiKey: "unused",
150 apiBaseUrl: "http://localhost/v1",
151 }),
152 ),
153 ),
154 ),
155 ).pipe(Effect.timeout("8 seconds")) as Effect.Effect<
156 A,
157 E,
158 RuntimeContext
159 >;
160 }
161 return eff.pipe(Effect.provideContext(ambient)) as Effect.Effect<
162 A,
163 E,
164 RuntimeContext
165 >;
166 },
167});
168
169export const unwrapSecretValue = (
170 value: Redacted.Redacted<string> | string,
171): string => (Redacted.isRedacted(value) ? Redacted.value(value) : value);