@alchemy.run/boat 0.0.0 → 2.0.0-beta.86

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.
Files changed (149) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +66 -4
  3. package/lib/AiProxy.d.ts +89 -0
  4. package/lib/AiProxy.d.ts.map +1 -0
  5. package/lib/AiProxy.js +125 -0
  6. package/lib/AiProxy.js.map +1 -0
  7. package/lib/ApiKey.d.ts +105 -0
  8. package/lib/ApiKey.d.ts.map +1 -0
  9. package/lib/ApiKey.js +134 -0
  10. package/lib/ApiKey.js.map +1 -0
  11. package/lib/AuthProvider.d.ts +71 -0
  12. package/lib/AuthProvider.d.ts.map +1 -0
  13. package/lib/AuthProvider.js +322 -0
  14. package/lib/AuthProvider.js.map +1 -0
  15. package/lib/BoatBindings.d.ts +52 -0
  16. package/lib/BoatBindings.d.ts.map +1 -0
  17. package/lib/BoatBindings.js +104 -0
  18. package/lib/BoatBindings.js.map +1 -0
  19. package/lib/Bootstrap.d.ts +8 -0
  20. package/lib/Bootstrap.d.ts.map +1 -0
  21. package/lib/Bootstrap.js +28 -0
  22. package/lib/Bootstrap.js.map +1 -0
  23. package/lib/ConnectSandbox.d.ts +56 -0
  24. package/lib/ConnectSandbox.d.ts.map +1 -0
  25. package/lib/ConnectSandbox.js +3 -0
  26. package/lib/ConnectSandbox.js.map +1 -0
  27. package/lib/ConnectSandboxHttp.d.ts +21 -0
  28. package/lib/ConnectSandboxHttp.d.ts.map +1 -0
  29. package/lib/ConnectSandboxHttp.js +46 -0
  30. package/lib/ConnectSandboxHttp.js.map +1 -0
  31. package/lib/Contributions.d.ts +7 -0
  32. package/lib/Contributions.d.ts.map +1 -0
  33. package/lib/Contributions.js +6 -0
  34. package/lib/Contributions.js.map +1 -0
  35. package/lib/CreateSandbox.d.ts +134 -0
  36. package/lib/CreateSandbox.d.ts.map +1 -0
  37. package/lib/CreateSandbox.js +3 -0
  38. package/lib/CreateSandbox.js.map +1 -0
  39. package/lib/CreateSandboxHttp.d.ts +21 -0
  40. package/lib/CreateSandboxHttp.d.ts.map +1 -0
  41. package/lib/CreateSandboxHttp.js +93 -0
  42. package/lib/CreateSandboxHttp.js.map +1 -0
  43. package/lib/CredentialProfile.d.ts +157 -0
  44. package/lib/CredentialProfile.d.ts.map +1 -0
  45. package/lib/CredentialProfile.js +245 -0
  46. package/lib/CredentialProfile.js.map +1 -0
  47. package/lib/Credentials.d.ts +15 -0
  48. package/lib/Credentials.d.ts.map +1 -0
  49. package/lib/Credentials.js +23 -0
  50. package/lib/Credentials.js.map +1 -0
  51. package/lib/Environment.d.ts +219 -0
  52. package/lib/Environment.d.ts.map +1 -0
  53. package/lib/Environment.js +273 -0
  54. package/lib/Environment.js.map +1 -0
  55. package/lib/Errors.d.ts +50 -0
  56. package/lib/Errors.d.ts.map +1 -0
  57. package/lib/Errors.js +17 -0
  58. package/lib/Errors.js.map +1 -0
  59. package/lib/Providers.d.ts +33 -0
  60. package/lib/Providers.d.ts.map +1 -0
  61. package/lib/Providers.js +47 -0
  62. package/lib/Providers.js.map +1 -0
  63. package/lib/Sandbox.d.ts +205 -0
  64. package/lib/Sandbox.d.ts.map +1 -0
  65. package/lib/Sandbox.js +368 -0
  66. package/lib/Sandbox.js.map +1 -0
  67. package/lib/SandboxClient.d.ts +159 -0
  68. package/lib/SandboxClient.d.ts.map +1 -0
  69. package/lib/SandboxClient.js +1 -0
  70. package/lib/SandboxClient.js.map +1 -0
  71. package/lib/SandboxEvents.d.ts +111 -0
  72. package/lib/SandboxEvents.d.ts.map +1 -0
  73. package/lib/SandboxEvents.js +60 -0
  74. package/lib/SandboxEvents.js.map +1 -0
  75. package/lib/SandboxEventsLive.d.ts +13 -0
  76. package/lib/SandboxEventsLive.d.ts.map +1 -0
  77. package/lib/SandboxEventsLive.js +63 -0
  78. package/lib/SandboxEventsLive.js.map +1 -0
  79. package/lib/Snapshot.d.ts +123 -0
  80. package/lib/Snapshot.d.ts.map +1 -0
  81. package/lib/Snapshot.js +186 -0
  82. package/lib/Snapshot.js.map +1 -0
  83. package/lib/Webhook.d.ts +56 -0
  84. package/lib/Webhook.d.ts.map +1 -0
  85. package/lib/Webhook.js +98 -0
  86. package/lib/Webhook.js.map +1 -0
  87. package/lib/index.d.ts +19 -0
  88. package/lib/index.d.ts.map +1 -0
  89. package/lib/index.js +19 -0
  90. package/lib/index.js.map +1 -0
  91. package/lib/internal/Declared.d.ts +34 -0
  92. package/lib/internal/Declared.d.ts.map +1 -0
  93. package/lib/internal/Declared.js +38 -0
  94. package/lib/internal/Declared.js.map +1 -0
  95. package/lib/internal/Org.d.ts +22 -0
  96. package/lib/internal/Org.d.ts.map +1 -0
  97. package/lib/internal/Org.js +23 -0
  98. package/lib/internal/Org.js.map +1 -0
  99. package/lib/internal/Ownership.d.ts +29 -0
  100. package/lib/internal/Ownership.d.ts.map +1 -0
  101. package/lib/internal/Ownership.js +50 -0
  102. package/lib/internal/Ownership.js.map +1 -0
  103. package/lib/internal/Program.d.ts +69 -0
  104. package/lib/internal/Program.d.ts.map +1 -0
  105. package/lib/internal/Program.js +178 -0
  106. package/lib/internal/Program.js.map +1 -0
  107. package/lib/internal/Ref.d.ts +14 -0
  108. package/lib/internal/Ref.d.ts.map +1 -0
  109. package/lib/internal/Ref.js +7 -0
  110. package/lib/internal/Ref.js.map +1 -0
  111. package/lib/internal/SandboxHandle.d.ts +17 -0
  112. package/lib/internal/SandboxHandle.d.ts.map +1 -0
  113. package/lib/internal/SandboxHandle.js +249 -0
  114. package/lib/internal/SandboxHandle.js.map +1 -0
  115. package/lib/internal/WebhookDelivery.d.ts +16 -0
  116. package/lib/internal/WebhookDelivery.d.ts.map +1 -0
  117. package/lib/internal/WebhookDelivery.js +109 -0
  118. package/lib/internal/WebhookDelivery.js.map +1 -0
  119. package/package.json +67 -3
  120. package/src/AiProxy.ts +171 -0
  121. package/src/ApiKey.ts +250 -0
  122. package/src/AuthProvider.ts +472 -0
  123. package/src/BoatBindings.ts +136 -0
  124. package/src/Bootstrap.ts +55 -0
  125. package/src/ConnectSandbox.ts +60 -0
  126. package/src/ConnectSandboxHttp.ts +59 -0
  127. package/src/Contributions.ts +9 -0
  128. package/src/CreateSandbox.ts +143 -0
  129. package/src/CreateSandboxHttp.ts +115 -0
  130. package/src/CredentialProfile.ts +350 -0
  131. package/src/Credentials.ts +33 -0
  132. package/src/Environment.ts +416 -0
  133. package/src/Errors.ts +34 -0
  134. package/src/Providers.ts +71 -0
  135. package/src/Sandbox.ts +647 -0
  136. package/src/SandboxClient.ts +199 -0
  137. package/src/SandboxEvents.ts +125 -0
  138. package/src/SandboxEventsLive.ts +88 -0
  139. package/src/Snapshot.ts +284 -0
  140. package/src/Webhook.ts +163 -0
  141. package/src/globals.d.ts +2 -0
  142. package/src/index.ts +18 -0
  143. package/src/internal/Declared.ts +87 -0
  144. package/src/internal/Org.ts +31 -0
  145. package/src/internal/Ownership.ts +75 -0
  146. package/src/internal/Program.ts +250 -0
  147. package/src/internal/Ref.ts +15 -0
  148. package/src/internal/SandboxHandle.ts +425 -0
  149. package/src/internal/WebhookDelivery.ts +179 -0
@@ -0,0 +1,425 @@
1
+ /**
2
+ * Builds sandbox handles over Boat's REST API: commands, files, ports and
3
+ * lifecycle all go through `/api/v1` with the binding's key.
4
+ */
5
+ import * as boat from "@distilled.cloud/boat-dev/boat";
6
+ import type { RuntimeContext } from "alchemy/RuntimeContext";
7
+ import * as Sandbox from "alchemy/Sandbox";
8
+ import * as Duration from "effect/Duration";
9
+ import * as Effect from "effect/Effect";
10
+ import * as FetchHttpClient from "effect/http/FetchHttpClient";
11
+ import * as HttpClient from "effect/http/HttpClient";
12
+ import * as HttpClientRequest from "effect/http/HttpClientRequest";
13
+ import * as Option from "effect/Option";
14
+ import * as Redacted from "effect/Redacted";
15
+ import * as Schedule from "effect/Schedule";
16
+ import * as Stream from "effect/Stream";
17
+ import type { BoatAuth } from "../BoatBindings.ts";
18
+ import { BoatUnexpectedResponse, ExecTimedOut, SnapshotFailed } from "../Errors.ts";
19
+ import type {
20
+ BoatSandbox,
21
+ Command,
22
+ ConnectedSandbox,
23
+ EntryInfo,
24
+ ExecOptions,
25
+ ExecResult,
26
+ Port,
27
+ Process,
28
+ ProcessOutput,
29
+ SandboxOperations,
30
+ StartOptions,
31
+ } from "../SandboxClient.ts";
32
+
33
+ const encoder = new TextEncoder();
34
+
35
+ const toBase64 = (data: string | Uint8Array) => {
36
+ const bytes = typeof data === "string" ? encoder.encode(data) : data;
37
+ let binary = "";
38
+ for (const byte of bytes) binary += String.fromCharCode(byte);
39
+ return btoa(binary);
40
+ };
41
+
42
+ const fromBase64 = (data: string) => Uint8Array.from(atob(data), (char) => char.charCodeAt(0));
43
+
44
+ /** POSIX single quotes: the value verbatim. */
45
+ const quote = (value: string) => `'${value.replaceAll("'", `'"'"'`)}'`;
46
+
47
+ /** Boat's `ttlSeconds` for an auto-stop: `null` never stops. */
48
+ export const ttlOf = (autoStop: Duration.Input | false) =>
49
+ autoStop === false
50
+ ? null
51
+ : Math.max(1, Math.round(Duration.toMillis(Duration.fromInputUnsafe(autoStop)) / 1000));
52
+
53
+ /** Boat runs commands for at most 10 minutes. */
54
+ const MAX_TIMEOUT_SECONDS = 600;
55
+
56
+ const timeoutSeconds = (timeout: Duration.Input) =>
57
+ Math.min(
58
+ MAX_TIMEOUT_SECONDS,
59
+ Math.max(1, Math.ceil(Duration.toMillis(Duration.fromInputUnsafe(timeout)) / 1000)),
60
+ );
61
+
62
+ /**
63
+ * One shell line for Boat's `commands` endpoint, which takes only a line
64
+ * and a working directory: `env` becomes exports, an argument vector is
65
+ * quoted, and `stdin` is piped in from base64.
66
+ */
67
+ const scriptOf = (
68
+ command: Command,
69
+ options?: {
70
+ readonly env?: Readonly<Record<string, string>>;
71
+ readonly stdin?: string | Uint8Array;
72
+ },
73
+ ) => {
74
+ const line = typeof command === "string" ? command : command.map(quote).join(" ");
75
+ const exports = Object.entries(options?.env ?? {})
76
+ .map(([key, value]) => `export ${key}=${quote(value)}; `)
77
+ .join("");
78
+ const body = exports === "" ? line : `${exports}${line}`;
79
+ return options?.stdin === undefined
80
+ ? body
81
+ : `printf %s ${quote(toBase64(options.stdin))} | base64 -d | { ${body}\n}`;
82
+ };
83
+
84
+ const entryOf = (entry: {
85
+ readonly path: string;
86
+ readonly isDir: boolean;
87
+ readonly isSymlink: boolean;
88
+ readonly size: number;
89
+ readonly mode: string;
90
+ readonly modifiedAt: string;
91
+ }): EntryInfo => ({
92
+ path: entry.path,
93
+ type: entry.isSymlink ? "symlink" : entry.isDir ? "directory" : "file",
94
+ size: entry.size,
95
+ mode: entry.mode,
96
+ modifiedAt: entry.modifiedAt,
97
+ });
98
+
99
+ const awaitSnapshot = (name: string) =>
100
+ boat.getSavedSnapshot({ name }).pipe(
101
+ Effect.map((got) => got.snapshot),
102
+ Effect.repeat({
103
+ schedule: Schedule.spaced("2 seconds"),
104
+ until: (snapshot) => snapshot.status !== "saving",
105
+ times: 45,
106
+ }),
107
+ Effect.flatMap((snapshot) =>
108
+ snapshot.status === "ready"
109
+ ? Effect.succeed(snapshot)
110
+ : new SnapshotFailed({
111
+ snapshot: name,
112
+ message:
113
+ snapshot.status === "failed"
114
+ ? `Boat could not save snapshot ${name}: ${snapshot.error ?? "unknown error"}`
115
+ : `Boat snapshot ${name} was still saving after 90 seconds`,
116
+ }),
117
+ ),
118
+ );
119
+
120
+ /**
121
+ * The operations every handle shares. `sandboxIdOf` resolves the id (read
122
+ * at runtime for a declared sandbox).
123
+ */
124
+ const makeOperations = (
125
+ auth: BoatAuth,
126
+ sandboxIdOf: Effect.Effect<string, never, RuntimeContext>,
127
+ ): SandboxOperations => {
128
+ const onSandbox = <A, E>(
129
+ call: (sandboxId: string) => Effect.Effect<A, E, boat.BoatOpContext>,
130
+ ): Effect.Effect<A, E, RuntimeContext> =>
131
+ Effect.flatMap(sandboxIdOf, (sandboxId) => auth.authorize(call(sandboxId)));
132
+
133
+ const status = (processId: number, offsets: { stdout: number; stderr: number }) =>
134
+ onSandbox((sandboxId) =>
135
+ boat.getCommandStatus({
136
+ sandboxId,
137
+ processId,
138
+ stdoutOffset: offsets.stdout,
139
+ stderrOffset: offsets.stderr,
140
+ }),
141
+ );
142
+
143
+ const makeProcess = (processId: number, from: { stdout: number; stderr: number }): Process => {
144
+ /** Polls the process's logs from `from`, emitting what's new, until it exits. */
145
+ const output = Stream.paginate(
146
+ { offsets: from, idle: false },
147
+ Effect.fnUntraced(function* (state) {
148
+ if (state.idle) yield* Effect.sleep("500 millis");
149
+ const current = yield* status(processId, state.offsets);
150
+ const chunks: ProcessOutput[] = [];
151
+ if (current.stdout.length > 0) {
152
+ chunks.push({ fd: "stdout", data: encoder.encode(current.stdout) });
153
+ }
154
+ if (current.stderr.length > 0) {
155
+ chunks.push({ fd: "stderr", data: encoder.encode(current.stderr) });
156
+ }
157
+ const offsets = {
158
+ stdout: current.stdoutNextOffset ?? state.offsets.stdout,
159
+ stderr: current.stderrNextOffset ?? state.offsets.stderr,
160
+ };
161
+ const finished = !current.running && current.more !== true;
162
+ return [
163
+ chunks,
164
+ finished
165
+ ? Option.none()
166
+ : Option.some({ offsets, idle: chunks.length === 0 && current.more !== true }),
167
+ ] as const;
168
+ }),
169
+ );
170
+ const wait = status(processId, { stdout: 0, stderr: 0 }).pipe(
171
+ Effect.repeat({
172
+ schedule: Schedule.spaced("1 second"),
173
+ until: (current) => !current.running,
174
+ }),
175
+ Effect.map((current): ExecResult => ({
176
+ exitCode: current.exitCode ?? -1,
177
+ stdout: current.stdout,
178
+ stderr: current.stderr,
179
+ commandId: undefined,
180
+ })),
181
+ );
182
+ return {
183
+ id: String(processId),
184
+ processId,
185
+ output,
186
+ kill: () =>
187
+ onSandbox((sandboxId) => boat.stopCommand({ sandboxId, processId })).pipe(Effect.asVoid),
188
+ wait,
189
+ };
190
+ };
191
+
192
+ const files: SandboxOperations["files"] = {
193
+ read: (path) =>
194
+ onSandbox((sandboxId) => boat.readSandboxFile({ sandboxId, path, encoding: "base64" })).pipe(
195
+ Effect.map((file) => fromBase64(file.content)),
196
+ ),
197
+ readText: (path) =>
198
+ onSandbox((sandboxId) => boat.readSandboxFile({ sandboxId, path, encoding: "utf8" })).pipe(
199
+ Effect.map((file) => file.content),
200
+ ),
201
+ write: (path, data) =>
202
+ onSandbox((sandboxId) =>
203
+ typeof data === "string"
204
+ ? boat.writeSandboxFile({ sandboxId, path, content: data, encoding: "utf8" })
205
+ : boat.writeSandboxFile({ sandboxId, path, content: toBase64(data), encoding: "base64" }),
206
+ ).pipe(Effect.asVoid),
207
+ list: (path) =>
208
+ onSandbox((sandboxId) => boat.listFiles({ sandboxId, path })).pipe(
209
+ Effect.map((listed) => listed.files.map(entryOf)),
210
+ ),
211
+ stat: (path) =>
212
+ onSandbox((sandboxId) => boat.getFileInfo({ sandboxId, path })).pipe(Effect.map(entryOf)),
213
+ remove: (path) =>
214
+ onSandbox((sandboxId) => boat.deleteFile({ sandboxId, path, recursive: true })).pipe(
215
+ Effect.asVoid,
216
+ ),
217
+ makeDir: (path) =>
218
+ onSandbox((sandboxId) => boat.createFolder({ sandboxId, path })).pipe(Effect.asVoid),
219
+ exists: (path) =>
220
+ onSandbox((sandboxId) => boat.getFileInfo({ sandboxId, path })).pipe(
221
+ Effect.as(true),
222
+ Effect.catchTag("FileNotFound", () => Effect.succeed(false)),
223
+ ),
224
+ move: (source, destination, options) =>
225
+ onSandbox((sandboxId) =>
226
+ boat.moveFile({
227
+ sandboxId,
228
+ source,
229
+ destination,
230
+ ...(options?.overwrite === undefined ? {} : { overwrite: options.overwrite }),
231
+ }),
232
+ ).pipe(Effect.asVoid),
233
+ };
234
+
235
+ return {
236
+ exec: (command: Command, options?: ExecOptions) =>
237
+ onSandbox((sandboxId) =>
238
+ boat.executeSandboxCommand({
239
+ sandboxId,
240
+ command: scriptOf(command, options),
241
+ ...(options?.cwd === undefined ? {} : { cwd: options.cwd }),
242
+ ...(options?.timeout === undefined
243
+ ? {}
244
+ : { timeoutSeconds: timeoutSeconds(options.timeout) }),
245
+ }),
246
+ ).pipe(
247
+ Effect.flatMap(
248
+ (response): Effect.Effect<ExecResult, BoatUnexpectedResponse | ExecTimedOut> => {
249
+ if (!("exitCode" in response)) {
250
+ return Effect.fail(
251
+ new BoatUnexpectedResponse({
252
+ message: "Boat started the command in the background",
253
+ }),
254
+ );
255
+ }
256
+ if (response.timedOut === true) {
257
+ return Effect.fail(
258
+ new ExecTimedOut({
259
+ message: "The command ran past its timeout and Boat stopped it",
260
+ stdout: response.stdout,
261
+ stderr: response.stderr,
262
+ }),
263
+ );
264
+ }
265
+ return Effect.succeed<ExecResult>({
266
+ exitCode: response.exitCode ?? -1,
267
+ stdout: response.stdout,
268
+ stderr: response.stderr,
269
+ commandId: response.commandId,
270
+ });
271
+ },
272
+ ),
273
+ ),
274
+ start: (command: Command, options?: StartOptions) =>
275
+ onSandbox((sandboxId) =>
276
+ boat.executeSandboxCommand({
277
+ sandboxId,
278
+ command: scriptOf(command, options),
279
+ detached: true,
280
+ ...(options?.cwd === undefined ? {} : { cwd: options.cwd }),
281
+ }),
282
+ ).pipe(
283
+ Effect.flatMap((response) =>
284
+ "processId" in response && typeof response.processId === "number"
285
+ ? Effect.succeed(makeProcess(response.processId, { stdout: 0, stderr: 0 }))
286
+ : Effect.fail(
287
+ new BoatUnexpectedResponse({ message: "Boat returned no process for the command" }),
288
+ ),
289
+ ),
290
+ ),
291
+ processes: {
292
+ attach: (processId) =>
293
+ status(processId, { stdout: 0, stderr: 0 }).pipe(
294
+ Effect.map((current) =>
295
+ makeProcess(processId, {
296
+ stdout: current.stdoutNextOffset ?? 0,
297
+ stderr: current.stderrNextOffset ?? 0,
298
+ }),
299
+ ),
300
+ ),
301
+ },
302
+ files,
303
+ getTcpPort: (port, options) =>
304
+ onSandbox((sandboxId) =>
305
+ boat.hostPort({
306
+ sandboxId,
307
+ port,
308
+ ...(options?.public === undefined ? {} : { public: options.public }),
309
+ ...(options?.localhost === undefined ? {} : { localhost: options.localhost }),
310
+ }),
311
+ ).pipe(
312
+ Effect.flatMap((hosted) =>
313
+ hosted.url === undefined
314
+ ? Effect.fail(
315
+ new BoatUnexpectedResponse({ message: `Boat hosted port ${port} without a URL` }),
316
+ )
317
+ : Effect.sync(() => {
318
+ const parsed = new URL(hosted.url!);
319
+ const raw = parsed.searchParams.get("_token");
320
+ const token = raw === null ? undefined : Redacted.make(raw);
321
+ const base = parsed.origin;
322
+ // A protected port reads its token from the `_port_auth`
323
+ // cookie. The `_token` query answers with a redirect that
324
+ // sets it, which would turn a POST into a GET, so the cookie
325
+ // is sent directly. Boat routes on the host, so only the
326
+ // request's path and query are kept.
327
+ const send = (request: HttpClientRequest.HttpClientRequest) =>
328
+ Effect.gen(function* () {
329
+ const client = yield* HttpClient.HttpClient;
330
+ const target = new URL(request.url, base);
331
+ const cookie = request.headers.cookie;
332
+ return yield* client.execute(
333
+ request.pipe(
334
+ HttpClientRequest.setUrl(`${base}${target.pathname}${target.search}`),
335
+ HttpClientRequest.removeHeader("host"),
336
+ token === undefined
337
+ ? (req) => req
338
+ : HttpClientRequest.setHeader(
339
+ "cookie",
340
+ [cookie, `_port_auth=${Redacted.value(token)}`]
341
+ .filter((part) => part !== undefined && part !== "")
342
+ .join("; "),
343
+ ),
344
+ ),
345
+ );
346
+ }).pipe(Effect.provide(FetchHttpClient.layer));
347
+ return { url: base, token, fetch: Sandbox.portFetch(send) } satisfies Port;
348
+ }),
349
+ ),
350
+ ),
351
+ describe: () =>
352
+ onSandbox((sandboxId) => boat.getSandbox({ sandboxId })).pipe(
353
+ Effect.flatMap((got) =>
354
+ got.sandbox === undefined
355
+ ? Effect.fail(new BoatUnexpectedResponse({ message: "Boat returned no sandbox" }))
356
+ : Effect.succeed(got.sandbox),
357
+ ),
358
+ ),
359
+ stop: () =>
360
+ onSandbox((sandboxId) => boat.stopSandbox({ sandboxId })).pipe(
361
+ Effect.asVoid,
362
+ Effect.catchTag("SandboxAlreadyStopped", () => Effect.void),
363
+ ),
364
+ resume: (options) =>
365
+ onSandbox((sandboxId) =>
366
+ boat.resumeSandbox({
367
+ sandboxId,
368
+ ...(options?.autoStop === undefined ? {} : { ttlSeconds: ttlOf(options.autoStop) }),
369
+ }),
370
+ ).pipe(Effect.asVoid),
371
+ setAutoStop: (autoStop) =>
372
+ onSandbox((sandboxId) => boat.updateSandbox({ sandboxId, ttlSeconds: ttlOf(autoStop) })).pipe(
373
+ Effect.asVoid,
374
+ ),
375
+ snapshot: (name) =>
376
+ onSandbox((sandboxId) =>
377
+ boat.saveSavedSnapshot({ sandboxId, name }).pipe(Effect.andThen(awaitSnapshot(name))),
378
+ ),
379
+ };
380
+ };
381
+
382
+ /** A handle to a sandbox created or connected at runtime, from what Boat returned. */
383
+ export const makeSandboxHandle = (
384
+ auth: BoatAuth,
385
+ info: boat.Sandbox,
386
+ ownerLabels: Effect.Effect<Readonly<Record<string, string>>, never, RuntimeContext>,
387
+ ): BoatSandbox => ({
388
+ ...makeOperations(auth, Effect.succeed(info.id)),
389
+ sandboxId: info.id,
390
+ info,
391
+ // Boat never copies labels onto a fork, so it gets the owner's again.
392
+ fork: (forkOptions = {}) => {
393
+ const { autoStop, ...options } = forkOptions;
394
+ return Effect.flatMap(ownerLabels, (owner) =>
395
+ auth.authorize(
396
+ boat.forkSandbox({
397
+ ...options,
398
+ ...(autoStop === undefined ? {} : { ttlSeconds: ttlOf(autoStop) }),
399
+ sandboxId: info.id,
400
+ labels: { ...options.labels, ...owner },
401
+ }),
402
+ ),
403
+ ).pipe(
404
+ Effect.flatMap((forked) =>
405
+ forked.sandbox === undefined || forked.sandbox === null
406
+ ? Effect.fail(new BoatUnexpectedResponse({ message: "Boat returned no fork" }))
407
+ : Effect.succeed(makeSandboxHandle(auth, forked.sandbox, ownerLabels)),
408
+ ),
409
+ );
410
+ },
411
+ delete: () =>
412
+ auth.authorize(boat.deleteSandbox({ sandboxId: info.id, xAsciiConfirmDelete: info.id })).pipe(
413
+ Effect.asVoid,
414
+ Effect.catchTag("NotFound", () => Effect.void),
415
+ ),
416
+ });
417
+
418
+ /** A handle to a sandbox declared in the stack. */
419
+ export const makeConnectedSandbox = (
420
+ auth: BoatAuth,
421
+ sandboxIdOf: Effect.Effect<string, never, RuntimeContext>,
422
+ ): ConnectedSandbox => ({
423
+ ...makeOperations(auth, sandboxIdOf),
424
+ sandboxId: sandboxIdOf,
425
+ });
@@ -0,0 +1,179 @@
1
+ /**
2
+ * INTERNAL — handles one delivery of Boat's webhook on a host: checks the
3
+ * signature, the timestamp and the payload, then scopes and dispatches it.
4
+ */
5
+ import * as boat from "@distilled.cloud/boat-dev/boat";
6
+ import { isRedactedMarker, type RuntimeContext } from "alchemy/RuntimeContext";
7
+ import * as Clock from "effect/Clock";
8
+ import * as Effect from "effect/Effect";
9
+ import * as Redacted from "effect/Redacted";
10
+ import type { BoatAuth } from "../BoatBindings.ts";
11
+ import type { SandboxEvent } from "../SandboxEvents.ts";
12
+ import { ALCHEMY_LABEL_PREFIX } from "./Ownership.ts";
13
+
14
+ /** How old a delivery's timestamp may be before it is refused. */
15
+ export const MAX_AGE_SECONDS = 300;
16
+
17
+ /** The largest delivery accepted. */
18
+ export const MAX_BODY_BYTES = 1024 * 1024;
19
+
20
+ export interface Scope {
21
+ readonly auth: BoatAuth;
22
+ readonly owner: Effect.Effect<Readonly<Record<string, string>>>;
23
+ }
24
+
25
+ export const handleDelivery = <Req>(
26
+ request: Request,
27
+ secret: Effect.Effect<string | undefined>,
28
+ types: ReadonlySet<string>,
29
+ scope: Scope | undefined,
30
+ process: (event: SandboxEvent) => Effect.Effect<void, never, Req>,
31
+ ): Effect.Effect<Response, never, Req | RuntimeContext> =>
32
+ Effect.gen(function* () {
33
+ if (request.method !== "POST") return new Response("method not allowed", { status: 405 });
34
+ // Boat's events are a few hundred bytes; refuse anything large before reading it.
35
+ if (Number(request.headers.get("content-length") ?? "0") > MAX_BODY_BYTES) {
36
+ return new Response("payload too large", { status: 413 });
37
+ }
38
+ const body = yield* Effect.tryPromise(() => request.arrayBuffer()).pipe(Effect.option);
39
+ if (body._tag === "None") return new Response("unreadable body", { status: 400 });
40
+ if (body.value.byteLength > MAX_BODY_BYTES) {
41
+ return new Response("payload too large", { status: 413 });
42
+ }
43
+
44
+ // The signature covers the delivery id, the timestamp and the raw body
45
+ // bytes; a stale timestamp is refused, so a captured delivery can't be
46
+ // replayed later.
47
+ const delivery = request.headers.get("x-ascii-delivery") ?? "";
48
+ const timestamp = request.headers.get("x-ascii-timestamp") ?? "";
49
+ const signature = (request.headers.get("x-ascii-signature") ?? "").replace(/^v1=/, "");
50
+ const now = yield* Clock.currentTimeMillis;
51
+ const fresh =
52
+ /^\d{1,12}$/.test(timestamp) &&
53
+ Math.abs(Number(now) / 1000 - Number(timestamp)) <= MAX_AGE_SECONDS;
54
+ const valid =
55
+ delivery !== "" &&
56
+ fresh &&
57
+ (yield* verifySignature(
58
+ (yield* secret) ?? "",
59
+ `${delivery}.${timestamp}.`,
60
+ body.value,
61
+ signature,
62
+ ));
63
+ if (!valid) return new Response("invalid signature", { status: 401 });
64
+
65
+ const parsed = yield* Effect.try(
66
+ () => JSON.parse(new TextDecoder().decode(body.value)) as Omit<SandboxEvent, "attempt">,
67
+ ).pipe(Effect.orElseSucceed(() => undefined));
68
+ if (
69
+ parsed === undefined ||
70
+ parsed.id !== delivery ||
71
+ typeof parsed.type !== "string" ||
72
+ typeof parsed.data?.sandbox?.id !== "string"
73
+ ) {
74
+ return new Response("invalid payload", { status: 400 });
75
+ }
76
+ // An event this host didn't subscribe to: acknowledged, not handled.
77
+ if (!types.has(parsed.type)) return new Response(null, { status: 202 });
78
+ let event: SandboxEvent = {
79
+ ...parsed,
80
+ attempt: Number(request.headers.get("x-ascii-attempt") ?? "1") || 1,
81
+ };
82
+
83
+ if (scope !== undefined) {
84
+ const lookup = yield* labelsOf(scope.auth, parsed.data.sandbox.id);
85
+ // Boat couldn't be asked: answer 503 so it delivers again, rather than
86
+ // drop an event that may be ours.
87
+ if (lookup._tag === "Unavailable") return new Response("try again", { status: 503 });
88
+ const labels = lookup._tag === "Found" ? lookup.labels : undefined;
89
+ const owner = yield* scope.owner;
90
+ const ours =
91
+ labels !== undefined &&
92
+ ["stack", "stage"].every(
93
+ (key) =>
94
+ owner[`${ALCHEMY_LABEL_PREFIX}${key}`] !== undefined &&
95
+ labels[`${ALCHEMY_LABEL_PREFIX}${key}`] === owner[`${ALCHEMY_LABEL_PREFIX}${key}`],
96
+ );
97
+ // Another stack's sandbox, or one already deleted: acknowledged, not handled.
98
+ if (!ours) return new Response(null, { status: 202 });
99
+ event = { ...event, data: { ...event.data, sandbox: { ...event.data.sandbox, labels } } };
100
+ }
101
+
102
+ yield* process(event);
103
+ return new Response(null, { status: 202 });
104
+ });
105
+
106
+ /** The bound secret as a string: a `Redacted`, its serialized form, or a plain string. */
107
+ export const asSecret = (raw: unknown): string | undefined => {
108
+ if (raw === undefined || raw === null || raw === "") return undefined;
109
+ if (Redacted.isRedacted(raw)) {
110
+ const value = Redacted.value(raw);
111
+ return typeof value === "string" && value.length > 0 ? value : undefined;
112
+ }
113
+ if (isRedactedMarker(raw) && typeof raw.value === "string") {
114
+ return raw.value.length > 0 ? raw.value : undefined;
115
+ }
116
+ return typeof raw === "string" ? raw : undefined;
117
+ };
118
+
119
+ type LabelLookup =
120
+ | { readonly _tag: "Found"; readonly labels: Record<string, string> }
121
+ | { readonly _tag: "Gone" }
122
+ | { readonly _tag: "Unavailable" };
123
+
124
+ /** A sandbox's labels; `Gone` when Boat no longer has it, `Unavailable` when Boat couldn't be asked. */
125
+ const labelsOf = (
126
+ auth: BoatAuth,
127
+ sandboxId: string,
128
+ ): Effect.Effect<LabelLookup, never, RuntimeContext> =>
129
+ auth.authorize(boat.getSandbox({ sandboxId })).pipe(
130
+ Effect.map((got): LabelLookup => ({
131
+ _tag: "Found",
132
+ labels: Object.fromEntries(
133
+ Object.entries(got.sandbox?.labels ?? {}).filter(
134
+ (entry): entry is [string, string] => typeof entry[1] === "string",
135
+ ),
136
+ ),
137
+ })),
138
+ Effect.catchTag("NotFound", () => Effect.succeed<LabelLookup>({ _tag: "Gone" })),
139
+ Effect.orElseSucceed((): LabelLookup => ({ _tag: "Unavailable" })),
140
+ );
141
+
142
+ const fromHex = (hex: string) =>
143
+ Uint8Array.from(hex.match(/../g) ?? [], (byte) => Number.parseInt(byte, 16));
144
+
145
+ /**
146
+ * Boat's signature: hex HMAC-SHA256 of `<delivery>.<timestamp>.` followed
147
+ * by the raw body bytes. Web Crypto checks it (in constant time), so it runs
148
+ * on Workers without `nodejs_compat`.
149
+ */
150
+ const verifySignature = (
151
+ secret: string,
152
+ prefix: string,
153
+ body: ArrayBuffer,
154
+ signature: string,
155
+ ): Effect.Effect<boolean> =>
156
+ Effect.gen(function* () {
157
+ if (secret === "" || !/^[a-f0-9]{64}$/i.test(signature)) return false;
158
+ const head = new TextEncoder().encode(prefix);
159
+ const signed = new Uint8Array(head.byteLength + body.byteLength);
160
+ signed.set(head, 0);
161
+ signed.set(new Uint8Array(body), head.byteLength);
162
+ const valid = yield* Effect.tryPromise(() =>
163
+ crypto.subtle.importKey(
164
+ "raw",
165
+ new TextEncoder().encode(secret),
166
+ { name: "HMAC", hash: "SHA-256" },
167
+ false,
168
+ ["verify"],
169
+ ),
170
+ ).pipe(
171
+ Effect.flatMap((key) =>
172
+ Effect.tryPromise(() =>
173
+ crypto.subtle.verify("HMAC", key, fromHex(signature.toLowerCase()), signed),
174
+ ),
175
+ ),
176
+ Effect.orElseSucceed(() => false),
177
+ );
178
+ return valid;
179
+ });