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

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 (154) 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 +173 -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 +238 -0
  114. package/lib/internal/SandboxHandle.js.map +1 -0
  115. package/lib/internal/SavedSnapshot.d.ts +10 -0
  116. package/lib/internal/SavedSnapshot.d.ts.map +1 -0
  117. package/lib/internal/SavedSnapshot.js +22 -0
  118. package/lib/internal/SavedSnapshot.js.map +1 -0
  119. package/lib/internal/WebhookDelivery.d.ts +16 -0
  120. package/lib/internal/WebhookDelivery.d.ts.map +1 -0
  121. package/lib/internal/WebhookDelivery.js +109 -0
  122. package/lib/internal/WebhookDelivery.js.map +1 -0
  123. package/package.json +67 -3
  124. package/src/AiProxy.ts +171 -0
  125. package/src/ApiKey.ts +250 -0
  126. package/src/AuthProvider.ts +472 -0
  127. package/src/BoatBindings.ts +136 -0
  128. package/src/Bootstrap.ts +55 -0
  129. package/src/ConnectSandbox.ts +60 -0
  130. package/src/ConnectSandboxHttp.ts +59 -0
  131. package/src/Contributions.ts +9 -0
  132. package/src/CreateSandbox.ts +143 -0
  133. package/src/CreateSandboxHttp.ts +115 -0
  134. package/src/CredentialProfile.ts +350 -0
  135. package/src/Credentials.ts +33 -0
  136. package/src/Environment.ts +416 -0
  137. package/src/Errors.ts +34 -0
  138. package/src/Providers.ts +71 -0
  139. package/src/Sandbox.ts +647 -0
  140. package/src/SandboxClient.ts +200 -0
  141. package/src/SandboxEvents.ts +125 -0
  142. package/src/SandboxEventsLive.ts +88 -0
  143. package/src/Snapshot.ts +264 -0
  144. package/src/Webhook.ts +163 -0
  145. package/src/globals.d.ts +2 -0
  146. package/src/index.ts +18 -0
  147. package/src/internal/Declared.ts +87 -0
  148. package/src/internal/Org.ts +31 -0
  149. package/src/internal/Ownership.ts +75 -0
  150. package/src/internal/Program.ts +250 -0
  151. package/src/internal/Ref.ts +15 -0
  152. package/src/internal/SandboxHandle.ts +407 -0
  153. package/src/internal/SavedSnapshot.ts +34 -0
  154. package/src/internal/WebhookDelivery.ts +179 -0
@@ -0,0 +1,472 @@
1
+ import * as boat from "@distilled.cloud/boat-dev";
2
+ import { DEFAULT_API_BASE_URL, fromApiKey } from "@distilled.cloud/boat-dev/Credentials";
3
+ import * as Login from "@distilled.cloud/boat-dev/Login";
4
+ import {
5
+ AuthError,
6
+ AuthProviderLayer,
7
+ NeedsReauth,
8
+ refreshHint,
9
+ type ConfigureField,
10
+ type ConfigureMethod,
11
+ type ProviderDetails,
12
+ } from "alchemy/Auth/AuthProvider";
13
+ import { displayRedacted } from "alchemy/Auth/Credentials";
14
+ import { getEnv, getEnvRedacted, mapPromptCancellation } from "alchemy/Auth/Env";
15
+ import {
16
+ collectFieldValues,
17
+ storedSecret,
18
+ storedValueText,
19
+ validateFieldValues,
20
+ type StoredValues,
21
+ } from "alchemy/Auth/StoredAuthProvider";
22
+ import * as Interaction from "alchemy/Interaction";
23
+ import * as Clock from "effect/Clock";
24
+ import * as Duration from "effect/Duration";
25
+ import * as Effect from "effect/Effect";
26
+ import * as FetchHttpClient from "effect/http/FetchHttpClient";
27
+ import * as Layer from "effect/Layer";
28
+ import * as Match from "effect/Match";
29
+ import type { ChildProcessSpawner } from "effect/process/ChildProcessSpawner";
30
+ import * as Redacted from "effect/Redacted";
31
+ import * as Schema from "effect/Schema";
32
+
33
+ /**
34
+ * Canonical name registered in the AuthProviders registry. Use this key to
35
+ * look up the Boat AuthProvider from inside provider Layers.
36
+ */
37
+ export const BOAT_AUTH_PROVIDER_NAME = "Boat";
38
+
39
+ /**
40
+ * A Boat profile entry. Both methods store a Boat API key (`boat_…`):
41
+ *
42
+ * - `browser`: Boat's agent sign-in (https://boat.dev/auth.md). You sign in
43
+ * to boat.dev with GitHub, Google or an email code, type the code Alchemy
44
+ * shows, and Boat issues a key. The identity assertion that comes with it
45
+ * mints the next key when this one expires.
46
+ * - `stored`: you paste a key created with `boat api-keys create` or on the
47
+ * dashboard.
48
+ *
49
+ * Either may pin `org`, the organization Alchemy works in, the way the
50
+ * dashboard's organization select or `boat --org` do: every call bills it
51
+ * (`X-Boat-Org`), and `Boat.Environment` manages its shared environments.
52
+ * Sign-in picks the account's organization (asking only when it belongs
53
+ * to several) and stores its id (`team_…`), which survives renames, with
54
+ * `orgName` for display.
55
+ * Without it, sandboxes bill the account's active wallet (`boat org
56
+ * switch`) and environments are personal.
57
+ *
58
+ * A revoked key fails with Boat's `unauthorized` error; run
59
+ * `alchemy profile edit --reconfigure Boat` to sign in again.
60
+ */
61
+ export const BoatAuthConfigSchema = Schema.Union([
62
+ Schema.Struct({
63
+ method: Schema.Literal("browser"),
64
+ email: Schema.String,
65
+ apiKey: Schema.String,
66
+ identityAssertion: Schema.optional(Schema.String),
67
+ /** Epoch millis the key expires at, when Boat says. */
68
+ expires: Schema.optional(Schema.Number),
69
+ account: Schema.optional(Schema.String),
70
+ org: Schema.optional(Schema.String),
71
+ orgName: Schema.optional(Schema.String),
72
+ }),
73
+ Schema.Struct({
74
+ method: Schema.Literal("stored"),
75
+ apiKey: Schema.String,
76
+ org: Schema.optional(Schema.String),
77
+ orgName: Schema.optional(Schema.String),
78
+ }),
79
+ ]);
80
+ export type BoatAuthConfig = typeof BoatAuthConfigSchema.Type;
81
+ type BrowserConfig = Extract<BoatAuthConfig, { method: "browser" }>;
82
+
83
+ /** Resolved Boat credentials: an API key sent as a bearer token. */
84
+ export interface BoatResolvedCredentials {
85
+ type: "apiKey";
86
+ apiKey: Redacted.Redacted<string>;
87
+ apiBaseUrl: string;
88
+ /** The organization (wallet) calls bill, sent as `X-Boat-Org`. */
89
+ org?: string;
90
+ source: { type: BoatAuthConfig["method"] | "env"; details?: string };
91
+ }
92
+
93
+ const apiKeyField: ConfigureField = {
94
+ name: "apiKey",
95
+ label: "Boat API key",
96
+ description: "Create one with `boat api-keys create` or at https://boat.dev/dashboard.",
97
+ secret: true,
98
+ validate: (value) => (value.trim().startsWith("boat_") ? undefined : "Expected a boat_… key"),
99
+ };
100
+
101
+ const orgField: ConfigureField = {
102
+ name: "org",
103
+ label: "Boat organization",
104
+ description:
105
+ "The organization to work in: its id (team_…), its name, or `personal`. Leave it out to follow your active wallet.",
106
+ optional: true,
107
+ };
108
+
109
+ /** `--set` fields; interactively the org is picked from a list instead. */
110
+ const keyFields: ReadonlyArray<ConfigureField> = [apiKeyField, orgField];
111
+
112
+ /** A set org, trimmed; blank means the active wallet. */
113
+ const orgOf = (value: string | undefined) => {
114
+ const org = value?.trim();
115
+ return org ? { org } : {};
116
+ };
117
+
118
+ const storedConfig = (values: StoredValues): BoatAuthConfig => ({
119
+ method: "stored",
120
+ apiKey: Redacted.value(storedSecret(values.apiKey) ?? Redacted.make("")).trim(),
121
+ ...orgOf(storedValueText(values.org)),
122
+ });
123
+
124
+ /** Sentinel choice: pin nothing and follow the account's active wallet. */
125
+ const ACTIVE_WALLET = "";
126
+
127
+ /**
128
+ * The organization to work in. An account in no organization works
129
+ * personally and one in exactly one works in it, without a question;
130
+ * only an account in several is asked to pick.
131
+ */
132
+ const chooseOrg = Effect.fn(function* (apiKey: Redacted.Redacted<string>, current?: string) {
133
+ const { orgs } = yield* boat.listOrganizations({}).pipe(
134
+ Effect.provide(Layer.mergeAll(fromApiKey({ apiKey }), FetchHttpClient.layer)),
135
+ Effect.mapError((cause) =>
136
+ AuthError.make({ message: "Boat: could not list your organizations.", cause }),
137
+ ),
138
+ );
139
+ const teams = orgs.filter((org) => org.type === "org");
140
+ if (teams.length === 0) return {};
141
+ if (teams.length === 1) {
142
+ const [team] = teams;
143
+ yield* Interaction.info(`Boat: working in ${team!.name}.`);
144
+ return { org: team!.id, orgName: team!.name };
145
+ }
146
+ const active = orgs.find((org) => org.active);
147
+ const org = yield* Interaction.select({
148
+ message: "Boat organization to work in",
149
+ options: [
150
+ ...teams.map((team) => ({
151
+ value: team.id,
152
+ label: team.name,
153
+ description: `${team.role ?? "member"}: sandboxes bill it, environments are its shared ones`,
154
+ })),
155
+ {
156
+ value: "personal",
157
+ label: "Personal",
158
+ description: "your own wallet and your personal environments",
159
+ },
160
+ {
161
+ value: ACTIVE_WALLET,
162
+ label: "Follow the active wallet",
163
+ description: `whatever \`boat org switch\` picks (now ${active?.name ?? "Personal"}); environments stay personal`,
164
+ },
165
+ ],
166
+ initialValue: current ?? (active?.type === "org" ? active.id : teams[0]!.id),
167
+ }).pipe(mapPromptCancellation);
168
+ if (org === ACTIVE_WALLET) return {};
169
+ const orgName = org === "personal" ? "Personal" : teams.find((team) => team.id === org)?.name;
170
+ return { org, ...(orgName ? { orgName } : {}) };
171
+ });
172
+
173
+ const loginFailed = (cause: { readonly message: string }) =>
174
+ AuthError.make({ message: `Boat: browser sign-in failed: ${cause.message}`, cause });
175
+
176
+ /** The account a key acts for, as `boat whoami` shows it. */
177
+ const accountOf = (apiKey: Redacted.Redacted<string>) =>
178
+ boat.getCurrentUser({}).pipe(
179
+ Effect.provide(Layer.mergeAll(fromApiKey({ apiKey }), FetchHttpClient.layer)),
180
+ Effect.map(({ user }) => user.email ?? user.login ?? user.displayName ?? user.id),
181
+ Effect.mapError((cause) =>
182
+ AuthError.make({ message: "Boat: the new API key was refused.", cause }),
183
+ ),
184
+ );
185
+
186
+ /** Fresh codes the sign-in hands out before it gives up. */
187
+ const CODE_ROUNDS = 6;
188
+
189
+ /** Polls give up after this many attempts: about fifteen minutes at 5s. */
190
+ const POLL_ATTEMPTS = 180;
191
+
192
+ const expiryOf = (now: number, expiresIn: number | undefined) =>
193
+ expiresIn === undefined ? {} : { expires: now + expiresIn * 1000 };
194
+
195
+ /**
196
+ * Boat's agent sign-in: register for the account with the user's email,
197
+ * open the claim page, where the user signs in with any method and types the
198
+ * code, then poll until Boat issues a key.
199
+ */
200
+ const browserLogin = (initialEmail?: string, initialOrg?: string) =>
201
+ Effect.gen(function* () {
202
+ const email = (yield* Interaction.text({
203
+ message: "Email of your Boat account",
204
+ description: "You sign in on boat.dev next, with GitHub, Google or an email code.",
205
+ ...(initialEmail ? { initialValue: initialEmail } : {}),
206
+ validate: (value) => (value.includes("@") ? undefined : "Expected an email address"),
207
+ }).pipe(mapPromptCancellation)).trim();
208
+ const http = FetchHttpClient.layer;
209
+ const registered = yield* Login.registerAgent({ email }).pipe(
210
+ Effect.provide(http),
211
+ Effect.mapError(loginFailed),
212
+ );
213
+ const poll = (
214
+ claim: Login.Claim,
215
+ interval: number,
216
+ attempt: number,
217
+ ): Effect.Effect<Login.IssuedKey, AuthError | Login.BoatLoginError> =>
218
+ Effect.sleep(Duration.seconds(interval)).pipe(
219
+ Effect.andThen(
220
+ Login.pollClaim({ claimToken: claim.claimToken, interval }).pipe(Effect.provide(http)),
221
+ ),
222
+ Effect.flatMap((result) =>
223
+ result._tag === "Issued"
224
+ ? Effect.succeed(result.key)
225
+ : attempt >= POLL_ATTEMPTS
226
+ ? Effect.fail(
227
+ AuthError.make({ message: "Boat: the sign-in wasn't completed in time." }),
228
+ )
229
+ : poll(claim, result.interval, attempt + 1),
230
+ ),
231
+ );
232
+ // A code lapses after about ten minutes (`expired_token`); the claim
233
+ // then hands out a fresh one, shown in a new sign-in prompt.
234
+ const signIn = (
235
+ claim: Login.Claim,
236
+ round: number,
237
+ ): Effect.Effect<Login.IssuedKey, AuthError, Interaction.Interaction | ChildProcessSpawner> =>
238
+ Interaction.browserLogin({
239
+ provider: "Boat",
240
+ url: claim.verificationUri,
241
+ code: claim.userCode,
242
+ waitFor: poll(claim, claim.interval, 1),
243
+ }).pipe(
244
+ Effect.catchIf(
245
+ (e) => e._tag === "BoatLoginError" && e.code === "expired_token" && round < CODE_ROUNDS,
246
+ () =>
247
+ Login.refreshClaim({ claimToken: claim.claimToken, email }).pipe(
248
+ Effect.provide(http),
249
+ Effect.mapError(loginFailed),
250
+ Effect.tap(() => Interaction.info("Boat: the code expired; here is a new one.")),
251
+ Effect.flatMap((next) => signIn(next, round + 1)),
252
+ ),
253
+ ),
254
+ Effect.mapError((e) => (e._tag === "AuthError" ? e : loginFailed(e))),
255
+ );
256
+ const key = yield* signIn(registered, 1);
257
+ const account = yield* accountOf(key.apiKey);
258
+ yield* Interaction.success(`Boat: signed in as ${account ?? email}.`);
259
+ const org = yield* chooseOrg(key.apiKey, initialOrg);
260
+ const now = yield* Clock.currentTimeMillis;
261
+ return {
262
+ method: "browser" as const,
263
+ email,
264
+ apiKey: Redacted.value(key.apiKey),
265
+ ...(key.identityAssertion
266
+ ? { identityAssertion: Redacted.value(key.identityAssertion) }
267
+ : {}),
268
+ ...expiryOf(now, key.expiresIn),
269
+ ...(account ? { account } : {}),
270
+ ...org,
271
+ } satisfies BrowserConfig;
272
+ });
273
+
274
+ /** Mints a new key from the assertion when the stored one is within a minute of expiring. */
275
+ const freshKey = Effect.fn(function* (profileName: string, config: BrowserConfig) {
276
+ const now = yield* Clock.currentTimeMillis;
277
+ if (config.expires === undefined || config.expires > now + 60_000) return config;
278
+ if (config.identityAssertion === undefined) {
279
+ return yield* NeedsReauth.make({
280
+ provider: BOAT_AUTH_PROVIDER_NAME,
281
+ profile: profileName,
282
+ message: `Boat API key expired. ${refreshHint(BOAT_AUTH_PROVIDER_NAME, profileName)}`,
283
+ });
284
+ }
285
+ const key = yield* Login.exchangeAssertion({
286
+ identityAssertion: Redacted.make(config.identityAssertion),
287
+ }).pipe(
288
+ Effect.provide(FetchHttpClient.layer),
289
+ Effect.mapError((cause) =>
290
+ NeedsReauth.make({
291
+ provider: BOAT_AUTH_PROVIDER_NAME,
292
+ profile: profileName,
293
+ message: `Boat sign-in expired. ${refreshHint(BOAT_AUTH_PROVIDER_NAME, profileName)}`,
294
+ cause,
295
+ }),
296
+ ),
297
+ );
298
+ return {
299
+ ...config,
300
+ apiKey: Redacted.value(key.apiKey),
301
+ ...(key.identityAssertion ? { identityAssertion: Redacted.value(key.identityAssertion) } : {}),
302
+ expires: key.expiresIn === undefined ? undefined : now + key.expiresIn * 1000,
303
+ } satisfies BrowserConfig;
304
+ });
305
+
306
+ const readEnvironment = Effect.gen(function* () {
307
+ const apiKey = yield* getEnvRedacted("BOAT_API_KEY");
308
+ if (apiKey === undefined || Redacted.value(apiKey).trim().length === 0) {
309
+ return yield* AuthError.make({
310
+ message: "Boat CI credentials not found. Set BOAT_API_KEY.",
311
+ });
312
+ }
313
+ return {
314
+ type: "apiKey" as const,
315
+ apiKey: Redacted.make(Redacted.value(apiKey).trim()),
316
+ apiBaseUrl: (yield* getEnv("BOAT_API_BASE_URL")) ?? DEFAULT_API_BASE_URL,
317
+ ...orgOf(yield* getEnv("BOAT_ORG")),
318
+ source: { type: "env" as const, details: "BOAT_API_KEY" },
319
+ } satisfies BoatResolvedCredentials;
320
+ });
321
+
322
+ const resolve = (config: BoatAuthConfig): BoatResolvedCredentials => ({
323
+ type: "apiKey",
324
+ apiKey: Redacted.make(config.apiKey),
325
+ apiBaseUrl: DEFAULT_API_BASE_URL,
326
+ ...orgOf(config.org),
327
+ source: {
328
+ type: config.method,
329
+ details: config.method === "browser" ? (config.account ?? config.email) : undefined,
330
+ },
331
+ });
332
+
333
+ /**
334
+ * Layer that registers the Boat {@link AuthProvider} into the
335
+ * `AuthProviders` registry when built. Included in `Boat.providers()` so
336
+ * `alchemy profile edit` can configure it.
337
+ *
338
+ * Supported methods:
339
+ * - `browser`: Boat's browser sign-in with GitHub or Google (interactive only).
340
+ * - `stored`: a pasted API key (`--method stored --set apiKey=-`).
341
+ */
342
+ export const BoatAuth = AuthProviderLayer<BoatAuthConfig, BoatResolvedCredentials>()(
343
+ BOAT_AUTH_PROVIDER_NAME,
344
+ Effect.gen(function* () {
345
+ const pasteKey = collectFieldValues([apiKeyField]).pipe(
346
+ Effect.map(storedConfig),
347
+ Effect.flatMap((config) =>
348
+ chooseOrg(Redacted.make(config.apiKey)).pipe(Effect.map((org) => ({ ...config, ...org }))),
349
+ ),
350
+ Effect.tap(() => Interaction.success("Boat: credentials saved.")),
351
+ );
352
+
353
+ const configure = (_profileName: string) =>
354
+ Interaction.select({
355
+ message: "Boat authentication method",
356
+ options: [
357
+ {
358
+ value: "browser" as const,
359
+ label: "Browser sign-in",
360
+ description: "recommended — sign in on boat.dev with GitHub, Google or email",
361
+ },
362
+ {
363
+ value: "stored" as const,
364
+ label: "API key",
365
+ description: "paste a boat_… key",
366
+ },
367
+ ],
368
+ }).pipe(
369
+ mapPromptCancellation,
370
+ Effect.flatMap((method) =>
371
+ Match.value(method).pipe(
372
+ Match.when("browser", () => browserLogin()),
373
+ Match.when("stored", () => pasteKey),
374
+ Match.exhaustive,
375
+ ),
376
+ ),
377
+ Effect.mapError((e) =>
378
+ e._tag === "AuthError"
379
+ ? e
380
+ : AuthError.make({ message: "Boat: failed to configure credentials", cause: e }),
381
+ ),
382
+ );
383
+
384
+ /** The browser sign-in needs a person, so only `stored` takes flags. */
385
+ const configureMethods: ReadonlyArray<ConfigureMethod> = [
386
+ { method: "stored", fields: keyFields },
387
+ ];
388
+
389
+ const configureWith = (
390
+ _profileName: string,
391
+ input: { readonly method: string; readonly values: Record<string, string> },
392
+ ) =>
393
+ input.method === "stored"
394
+ ? validateFieldValues(BOAT_AUTH_PROVIDER_NAME, keyFields, input.values).pipe(
395
+ Effect.map(storedConfig),
396
+ Effect.tap(() => Interaction.success("Boat: credentials saved.")),
397
+ )
398
+ : Effect.fail(
399
+ AuthError.make({
400
+ message: `Boat: unknown method '${input.method}'. Valid methods: stored. (Browser sign-in is interactive-only.)`,
401
+ }),
402
+ );
403
+
404
+ // A key doesn't refresh, so logging in again repeats the browser
405
+ // sign-in; a pasted key stays as it is.
406
+ const login = (_profileName: string, config: BoatAuthConfig) =>
407
+ config.method === "browser"
408
+ ? browserLogin(config.email, config.org).pipe(
409
+ Effect.mapError((e) =>
410
+ e._tag === "AuthError"
411
+ ? e
412
+ : AuthError.make({ message: "Boat: login failed", cause: e }),
413
+ ),
414
+ )
415
+ : Effect.succeed(config);
416
+
417
+ const details = (_profileName: string, config: BoatAuthConfig) =>
418
+ Effect.succeed<ProviderDetails>({
419
+ lines: [
420
+ ...(config.method === "browser"
421
+ ? [{ key: "account", value: config.account ?? config.email }]
422
+ : []),
423
+ { key: "apiKey", value: displayRedacted(Redacted.make(config.apiKey)) },
424
+ ...(config.org ? [{ key: "org", value: config.orgName ?? config.org }] : []),
425
+ { key: "source", value: config.method },
426
+ ],
427
+ });
428
+
429
+ return {
430
+ configSchema: BoatAuthConfigSchema,
431
+ configure,
432
+ configureWith,
433
+ configureMethods,
434
+ login,
435
+ logout: () => Effect.void,
436
+ details,
437
+ read: (
438
+ profileName: string,
439
+ config: BoatAuthConfig,
440
+ updateConfig?: (config: BoatAuthConfig) => Effect.Effect<void, AuthError>,
441
+ ): Effect.Effect<BoatResolvedCredentials, AuthError | NeedsReauth> =>
442
+ config.method === "stored"
443
+ ? Effect.succeed(resolve(config))
444
+ : freshKey(profileName, config).pipe(
445
+ Effect.tap((fresh) =>
446
+ fresh === config ? Effect.void : (updateConfig?.(fresh) ?? Effect.void),
447
+ ),
448
+ Effect.map(resolve),
449
+ ),
450
+ readEnvironment,
451
+ environment: [
452
+ {
453
+ name: "BOAT_API_KEY",
454
+ required: true,
455
+ secret: true,
456
+ description: "Boat API key (`boat_…`).",
457
+ },
458
+ {
459
+ name: "BOAT_API_BASE_URL",
460
+ required: false,
461
+ description: `Boat API URL override. Defaults to ${DEFAULT_API_BASE_URL}.`,
462
+ },
463
+ {
464
+ name: "BOAT_ORG",
465
+ required: false,
466
+ description:
467
+ "Boat organization (wallet) to bill: an org id (`team_…`), an org name, or `personal`. Defaults to the account's active wallet.",
468
+ },
469
+ ],
470
+ };
471
+ }),
472
+ );
@@ -0,0 +1,136 @@
1
+ /**
2
+ * How Boat bindings reach Boat from any host.
3
+ *
4
+ * Boat has scoped API keys and no identity federation, so every binding
5
+ * mints its own key at deploy, a `Boat.ApiKey` in the host's namespace,
6
+ * with only the actions it needs and, for a declared sandbox, only that
7
+ * sandbox. The key is deleted with the host. The deployer's credentials
8
+ * never reach a host. The binding captures the key as a named runtime
9
+ * Output; inside the deployed host it is read back from the host's env,
10
+ * still `Redacted`. Every runtime call provides `Credentials` from it, so
11
+ * nothing reads `process.env`. The deployer's org and API URL are bound
12
+ * alongside it (`BOAT_ORG`, `BOAT_API_BASE_URL`), so the host's calls bill
13
+ * the same wallet the deploy did.
14
+ *
15
+ * Bindings that create sandboxes at run time label them with the host's
16
+ * `alchemy.stack` / `alchemy.stage` / `alchemy.id`, computed at deploy and
17
+ * bound into the host (`bindOwnerLabels`). Destroying the stack does not
18
+ * delete them.
19
+ */
20
+ import { Credentials, DEFAULT_API_BASE_URL } from "@distilled.cloud/boat-dev/Credentials";
21
+ import { orDieCredentialsUnavailable } from "alchemy/Auth/Resolve";
22
+ import * as Binding from "alchemy/Binding";
23
+ import type { PropsInput } from "alchemy/Input";
24
+ import * as Namespace from "alchemy/Namespace";
25
+ import * as Output from "alchemy/Output";
26
+ import type { RuntimeContext } from "alchemy/RuntimeContext";
27
+ import { Stack } from "alchemy/Stack";
28
+ import { Stage } from "alchemy/Stage";
29
+ import * as Effect from "effect/Effect";
30
+ import * as FetchHttpClient from "effect/http/FetchHttpClient";
31
+ import type * as HttpClient from "effect/http/HttpClient";
32
+ import * as Layer from "effect/Layer";
33
+ import * as Option from "effect/Option";
34
+ import * as Redacted from "effect/Redacted";
35
+ import { ApiKey, type ApiKeyProps } from "./ApiKey.ts";
36
+ import { ALCHEMY_LABEL_PREFIX } from "./internal/Ownership.ts";
37
+
38
+ /** A plain value as an Output with no requirements (`asOutput` leaves them `any`). */
39
+ const output = <A>(value: A) => Output.asOutput(value) as Output.Output<A, never>;
40
+
41
+ /** The `alchemy.*` labels on sandboxes a host creates at run time. */
42
+ export type OwnerLabels = Readonly<Record<string, string>>;
43
+
44
+ export interface BoatAuth {
45
+ /** Runs a distilled call with the binding's key and an HTTP client. */
46
+ readonly authorize: <A, E>(
47
+ effect: Effect.Effect<A, E, Credentials | HttpClient.HttpClient>,
48
+ ) => Effect.Effect<A, E, RuntimeContext>;
49
+ }
50
+
51
+ /**
52
+ * Mints the binding's key (`<keyId>` in the host's namespace, with
53
+ * `scope`) and binds it onto the host as `<envName>`, and returns a runtime
54
+ * accessor for it. Without a host (a script) nothing is minted, and calls
55
+ * use the ambient credentials.
56
+ */
57
+ export const bindBoat = Effect.fn("Boat.bind")(function* (
58
+ keyId: string,
59
+ envName: string,
60
+ scope: PropsInput<ApiKeyProps>,
61
+ ) {
62
+ // Yielding the resource class erases its `Providers` requirement; the
63
+ // stack satisfies it at plan time.
64
+ const makeKey = yield* ApiKey;
65
+ let apiKey: Output.Output<Redacted.Redacted<string>, never> = output(Redacted.make(""));
66
+ let deployer = { apiBaseUrl: DEFAULT_API_BASE_URL, org: "" };
67
+ if (!globalThis.__ALCHEMY_RUNTIME__) {
68
+ const host = yield* Binding.Host;
69
+ if (host !== undefined) {
70
+ const key = yield* Namespace.push(host.LogicalId, makeKey(keyId, scope));
71
+ apiKey = key.key as Output.Output<Redacted.Redacted<string>, never>;
72
+ const credentials = yield* Effect.serviceOption(Credentials);
73
+ if (Option.isSome(credentials)) {
74
+ const { apiBaseUrl, org } = yield* credentials.value.pipe(
75
+ orDieCredentialsUnavailable("Boat"),
76
+ );
77
+ deployer = { apiBaseUrl, org: org ?? "" };
78
+ }
79
+ }
80
+ }
81
+ const key = yield* Output.named(apiKey, envName).asEffect();
82
+ const apiBaseUrl = yield* Output.named(
83
+ output(deployer.apiBaseUrl),
84
+ "BOAT_API_BASE_URL",
85
+ ).asEffect();
86
+ const org = yield* Output.named(output(deployer.org), "BOAT_ORG").asEffect();
87
+ const services = Layer.mergeAll(
88
+ Layer.succeed(
89
+ Credentials,
90
+ Effect.all([key, apiBaseUrl, org]).pipe(
91
+ Effect.map(([apiKey, apiBaseUrl, org]) => ({
92
+ apiKey,
93
+ apiBaseUrl: apiBaseUrl || DEFAULT_API_BASE_URL,
94
+ ...(org ? { org } : {}),
95
+ })),
96
+ ),
97
+ ),
98
+ FetchHttpClient.layer,
99
+ );
100
+ return {
101
+ authorize: (effect) => effect.pipe(Effect.provide(services)),
102
+ } satisfies BoatAuth;
103
+ });
104
+
105
+ const parseOwnerLabels = (json: string): OwnerLabels => {
106
+ try {
107
+ return JSON.parse(json || "{}");
108
+ } catch {
109
+ return {};
110
+ }
111
+ };
112
+
113
+ /**
114
+ * Binds the host's `alchemy.stack` / `alchemy.stage` / `alchemy.id` into
115
+ * it, and returns them at run time for `create` to label what it makes.
116
+ * Without a host (a script) there are none.
117
+ */
118
+ export const bindOwnerLabels = Effect.gen(function* () {
119
+ let labels = "{}";
120
+ if (!globalThis.__ALCHEMY_RUNTIME__) {
121
+ const host = yield* Binding.Host;
122
+ // The values `createInternalTags(host.LogicalId)` gives, read without
123
+ // adding `Stack` / `Stage` to the binding's requirements.
124
+ const stack = yield* Effect.serviceOption(Stack);
125
+ const stage = yield* Effect.serviceOption(Stage);
126
+ if (host !== undefined && Option.isSome(stack) && Option.isSome(stage)) {
127
+ labels = JSON.stringify({
128
+ [`${ALCHEMY_LABEL_PREFIX}stack`]: stack.value.name,
129
+ [`${ALCHEMY_LABEL_PREFIX}stage`]: stage.value,
130
+ [`${ALCHEMY_LABEL_PREFIX}id`]: host.LogicalId,
131
+ } satisfies OwnerLabels);
132
+ }
133
+ }
134
+ const bound = yield* Output.named(output(labels), "BOAT_OWNER_LABELS").asEffect();
135
+ return Effect.map(bound, parseOwnerLabels);
136
+ });
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Process bootstrap for a `Boat.Sandbox` with `main` (a Node process in
3
+ * the sandbox serving the bundled program). The generated entry imports this module
4
+ * and the user's `main`, nothing else — see `alchemy/Runtime/Bootstrap`
5
+ * for why.
6
+ */
7
+ import { NodeServices } from "@effect/platform-node";
8
+ import { NodeHttpServer } from "alchemy/Http";
9
+ import { reifyBoundConfigProvider } from "alchemy/Runtime";
10
+ import {
11
+ entrypointLayer,
12
+ resolveProgram,
13
+ runProcess,
14
+ stackFromEnv,
15
+ } from "alchemy/Runtime/Bootstrap";
16
+ import * as ConfigProvider from "effect/ConfigProvider";
17
+ import * as SecureRandom from "effect/crypto/SecureRandom";
18
+ import * as Effect from "effect/Effect";
19
+ import * as FetchHttpClient from "effect/http/FetchHttpClient";
20
+ import * as Layer from "effect/Layer";
21
+ import * as Logger from "effect/Logger";
22
+
23
+ /**
24
+ * Resolve the bundled program (the runners registered via `host.run` /
25
+ * serve) and run it with a Node HTTP server bound to `PORT` on every
26
+ * interface, so the sandbox's port URL reaches the `{ fetch }`
27
+ * handler and `host.run` loops stay alive.
28
+ */
29
+ export const bootstrap = (entrypoint: unknown): Promise<void> => {
30
+ const platform = Layer.mergeAll(
31
+ NodeServices.layer,
32
+ FetchHttpClient.layer,
33
+ SecureRandom.layerWebCrypto,
34
+ Logger.layer([Logger.consolePretty()]),
35
+ );
36
+
37
+ const program = resolveProgram("program").pipe(
38
+ Effect.provide(
39
+ entrypointLayer(entrypoint).pipe(
40
+ Layer.provideMerge(stackFromEnv),
41
+ Layer.provideMerge(NodeHttpServer({ hostname: "0.0.0.0" })),
42
+ Layer.provideMerge(platform),
43
+ Layer.provideMerge(
44
+ Layer.succeed(
45
+ ConfigProvider.ConfigProvider,
46
+ reifyBoundConfigProvider(ConfigProvider.fromEnv(), process.env),
47
+ ),
48
+ ),
49
+ ),
50
+ ),
51
+ Effect.scoped,
52
+ );
53
+
54
+ return runProcess("Boat program", program);
55
+ };