1import * as Fly from "@/Fly";
2import * as Effect from "effect/Effect";
3import * as Redacted from "effect/Redacted";
4import { HttpServerRequest } from "effect/http/HttpServerRequest";
5import * as HttpServerResponse from "effect/http/HttpServerResponse";
6import { API_PORT, BoxKey, Marker, SignKey, Site } from "./bindings-shared.ts";
7import Box from "./bindings-sprite.ts";
8
9const bytesToB64 = (bytes: Uint8Array) => Buffer.from(bytes).toString("base64");
10const b64ToBytes = (value: string) =>
11 Uint8Array.from(Buffer.from(value, "base64"));
12
13/**
14 * HTTP Service that exercises Secret, SecretKey, and Sprite bindings.
15 * Cryptographic operations run inside the deployed Machine.
16 */
17export default class BindingsApi extends Fly.Service<BindingsApi>()(
18 "BindingsApi",
19 {
20 app: Site,
21 main: import.meta.url,
22 region: "iad",
23 port: API_PORT,
24 guest: { cpuKind: "shared", cpus: 1, memoryMb: 256 },
25 },
26 Effect.gen(function* () {
27 const site = yield* Site;
28 const marker = yield* Marker;
29 const appName = yield* site.appName;
30 const secretName = yield* marker.name;
31 const get = yield* Fly.GetSecret(marker);
32 const list = yield* Fly.ListSecrets(Site);
33 const write = yield* Fly.WriteSecret(Marker);
34 const encrypt = yield* Fly.Encrypt(BoxKey);
35 const decrypt = yield* Fly.Decrypt(BoxKey);
36 const sign = yield* Fly.Sign(SignKey);
37 const verify = yield* Fly.Verify(SignKey);
38 const exec = yield* Fly.Exec(Box);
39
40 return {
41 fetch: Effect.gen(function* () {
42 const request = yield* HttpServerRequest;
43 const url = new URL(request.url, "http://service");
44 const path = url.pathname;
45
46 const fail = (error: unknown) => {
47 const tagged =
48 typeof error === "object" && error !== null && "_tag" in error
49 ? (error as { _tag: string; message?: unknown })
50 : undefined;
51 const message =
52 tagged !== undefined
53 ? `${tagged._tag}${tagged.message === undefined ? "" : `: ${String(tagged.message)}`}`
54 : error instanceof Error
55 ? `${error.name}: ${error.message}`
56 : String(error);
57 return HttpServerResponse.json({ error: message }, { status: 500 });
58 };
59
60 if (path === "/health") {
61 const resolvedApp = yield* appName;
62 const resolvedSecret = yield* secretName;
63 const token = process.env.FLY_API_TOKEN ?? "";
64 return yield* HttpServerResponse.json({
65 ok: true,
66 appName: resolvedApp,
67 secretName: resolvedSecret,
68 hasFlySecretMarkerEnv: process.env.FLY_SECRET_Marker !== undefined,
69 hasToken: token.length > 0,
70 tokenKind: token.startsWith("{")
71 ? "marker"
72 : token.startsWith("FlyV1")
73 ? "flyv1"
74 : token.length > 0
75 ? "other"
76 : "missing",
77 });
78 }
79
80 if (path === "/sprite" && request.method === "GET") {
81 return yield* exec({ cmd: ["echo", "sprite-runtime-binding"] }).pipe(
82 Effect.flatMap((result) =>
83 HttpServerResponse.json({
84 stdout: result.stdout,
85 exitCode: result.exit_code,
86 }),
87 ),
88 Effect.catch(fail),
89 );
90 }
91
92 if (path === "/secret" && request.method === "GET") {
93 const got = yield* get().pipe(
94 Effect.catch((error) =>
95 Effect.succeed({
96 name: undefined as string | undefined,
97 value: undefined as string | undefined,
98 error:
99 error instanceof Error
100 ? `${error.name}: ${error.message}`
101 : String(error),
102 }),
103 ),
104 );
105 if ("error" in got) {
106 return yield* HttpServerResponse.json(got, { status: 500 });
107 }
108 return yield* HttpServerResponse.json({
109 ok: true,
110 name: got.name,
111 hasValue: typeof got.value === "string" && got.value.length > 0,
112 });
113 }
114
115 if (path === "/secrets" && request.method === "GET") {
116 return yield* list().pipe(
117 Effect.flatMap((listed) =>
118 HttpServerResponse.json({
119 names: (listed.secrets ?? []).flatMap((row) =>
120 row.name === undefined ? [] : [row.name],
121 ),
122 }),
123 ),
124 Effect.catch((error) => fail(error)),
125 );
126 }
127
128 if (path === "/secret" && request.method === "POST") {
129 const body = (yield* request.json) as {
130 name?: string;
131 value?: string;
132 };
133 const name = body.name ?? "BINDING_CREATED";
134 return yield* write
135 .create(name, Redacted.make(body.value ?? "created"))
136 .pipe(
137 Effect.flatMap(() => HttpServerResponse.json({ ok: true, name })),
138 Effect.catch((error) => fail(error)),
139 );
140 }
141
142 if (path === "/encrypt" && request.method === "POST") {
143 const body = (yield* request.json) as { text?: string };
144 return yield* encrypt({
145 plaintext: new TextEncoder().encode(body.text ?? ""),
146 }).pipe(
147 Effect.flatMap((enc) =>
148 HttpServerResponse.json({
149 ciphertext: bytesToB64(enc.ciphertext),
150 }),
151 ),
152 Effect.catch((error) => fail(error)),
153 );
154 }
155
156 if (path === "/decrypt" && request.method === "POST") {
157 const body = (yield* request.json) as { ciphertext?: string };
158 return yield* decrypt({
159 ciphertext: b64ToBytes(body.ciphertext ?? ""),
160 }).pipe(
161 Effect.flatMap((dec) =>
162 HttpServerResponse.json({
163 text: new TextDecoder().decode(Redacted.value(dec.plaintext)),
164 }),
165 ),
166 Effect.catch((error) => fail(error)),
167 );
168 }
169
170 if (path === "/sign" && request.method === "POST") {
171 const body = (yield* request.json) as { text?: string };
172 return yield* sign({
173 plaintext: new TextEncoder().encode(body.text ?? ""),
174 }).pipe(
175 Effect.flatMap((signed) =>
176 HttpServerResponse.json({
177 signature: bytesToB64(signed.signature),
178 }),
179 ),
180 Effect.catch((error) => fail(error)),
181 );
182 }
183
184 if (path === "/verify" && request.method === "POST") {
185 const body = (yield* request.json) as {
186 text?: string;
187 signature?: string;
188 };
189 return yield* verify({
190 plaintext: new TextEncoder().encode(body.text ?? ""),
191 signature: b64ToBytes(body.signature ?? ""),
192 }).pipe(
193 Effect.flatMap((checked) =>
194 HttpServerResponse.json({ valid: checked.valid }),
195 ),
196 Effect.catch((error) => fail(error)),
197 );
198 }
199
200 return yield* HttpServerResponse.json({ ok: false }, { status: 404 });
201 }),
202 };
203 }).pipe(
204 Effect.provide([
205 Fly.GetSecretHttp,
206 Fly.ListSecretsHttp,
207 Fly.WriteSecretHttp,
208 Fly.EncryptHttp,
209 Fly.DecryptHttp,
210 Fly.SignHttp,
211 Fly.VerifyHttp,
212 Fly.ExecHttp,
213 ]),
214 ),
215) {}