@alchemy.run/fly 2.0.0-beta.85 → 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 (271) hide show
  1. package/lib/App.d.ts +26 -4
  2. package/lib/App.d.ts.map +1 -1
  3. package/lib/App.js +25 -2
  4. package/lib/App.js.map +1 -1
  5. package/lib/AuthProvider.d.ts +1 -1
  6. package/lib/AuthProvider.d.ts.map +1 -1
  7. package/lib/AuthProvider.js.map +1 -1
  8. package/lib/BindService.d.ts +7 -7
  9. package/lib/BindService.d.ts.map +1 -1
  10. package/lib/BindService.js +20 -27
  11. package/lib/BindService.js.map +1 -1
  12. package/lib/Bootstrap.d.ts.map +1 -1
  13. package/lib/Bootstrap.js +2 -1
  14. package/lib/Bootstrap.js.map +1 -1
  15. package/lib/Bucket.d.ts +36 -2
  16. package/lib/Bucket.d.ts.map +1 -1
  17. package/lib/Bucket.js +181 -28
  18. package/lib/Bucket.js.map +1 -1
  19. package/lib/BucketBinding.d.ts.map +1 -1
  20. package/lib/BucketBinding.js.map +1 -1
  21. package/lib/Catalog.d.ts.map +1 -1
  22. package/lib/Catalog.js.map +1 -1
  23. package/lib/Certificate.d.ts +25 -1
  24. package/lib/Certificate.d.ts.map +1 -1
  25. package/lib/Certificate.js +18 -0
  26. package/lib/Certificate.js.map +1 -1
  27. package/lib/Checkpoint.d.ts.map +1 -1
  28. package/lib/Checkpoint.js.map +1 -1
  29. package/lib/CheckpointHttp.d.ts.map +1 -1
  30. package/lib/CheckpointHttp.js.map +1 -1
  31. package/lib/ConnectPostgres.d.ts.map +1 -1
  32. package/lib/ConnectPostgres.js.map +1 -1
  33. package/lib/ConnectPostgresHttp.d.ts.map +1 -1
  34. package/lib/ConnectPostgresHttp.js.map +1 -1
  35. package/lib/Contributions.d.ts +1 -1
  36. package/lib/Contributions.d.ts.map +1 -1
  37. package/lib/Contributions.js.map +1 -1
  38. package/lib/Credentials.d.ts +4 -3
  39. package/lib/Credentials.d.ts.map +1 -1
  40. package/lib/Credentials.js +9 -5
  41. package/lib/Credentials.js.map +1 -1
  42. package/lib/Decrypt.d.ts.map +1 -1
  43. package/lib/Decrypt.js.map +1 -1
  44. package/lib/DecryptHttp.d.ts.map +1 -1
  45. package/lib/DecryptHttp.js.map +1 -1
  46. package/lib/DeleteObject.d.ts.map +1 -1
  47. package/lib/DeleteObject.js.map +1 -1
  48. package/lib/DeleteObjectHttp.d.ts.map +1 -1
  49. package/lib/DeleteObjectHttp.js.map +1 -1
  50. package/lib/Deployment.d.ts.map +1 -1
  51. package/lib/Deployment.js.map +1 -1
  52. package/lib/DeploymentImages.d.ts.map +1 -1
  53. package/lib/DeploymentImages.js.map +1 -1
  54. package/lib/DeploymentState.d.ts.map +1 -1
  55. package/lib/DeploymentState.js.map +1 -1
  56. package/lib/Encrypt.d.ts.map +1 -1
  57. package/lib/Encrypt.js.map +1 -1
  58. package/lib/EncryptHttp.d.ts.map +1 -1
  59. package/lib/EncryptHttp.js.map +1 -1
  60. package/lib/Environment.d.ts.map +1 -1
  61. package/lib/Environment.js.map +1 -1
  62. package/lib/Errors.d.ts.map +1 -1
  63. package/lib/Errors.js.map +1 -1
  64. package/lib/Exec.d.ts.map +1 -1
  65. package/lib/Exec.js.map +1 -1
  66. package/lib/ExecHttp.d.ts.map +1 -1
  67. package/lib/ExecHttp.js.map +1 -1
  68. package/lib/GetObject.d.ts.map +1 -1
  69. package/lib/GetObject.js.map +1 -1
  70. package/lib/GetObjectHttp.d.ts.map +1 -1
  71. package/lib/GetObjectHttp.js.map +1 -1
  72. package/lib/GetSecret.d.ts.map +1 -1
  73. package/lib/GetSecret.js.map +1 -1
  74. package/lib/GetSecretHttp.d.ts.map +1 -1
  75. package/lib/GetSecretHttp.js.map +1 -1
  76. package/lib/HeadObject.d.ts.map +1 -1
  77. package/lib/HeadObject.js.map +1 -1
  78. package/lib/HeadObjectHttp.d.ts.map +1 -1
  79. package/lib/HeadObjectHttp.js.map +1 -1
  80. package/lib/IpAssignment.d.ts +24 -1
  81. package/lib/IpAssignment.d.ts.map +1 -1
  82. package/lib/IpAssignment.js +17 -0
  83. package/lib/IpAssignment.js.map +1 -1
  84. package/lib/ListObjectsV2.d.ts.map +1 -1
  85. package/lib/ListObjectsV2.js.map +1 -1
  86. package/lib/ListObjectsV2Http.d.ts.map +1 -1
  87. package/lib/ListObjectsV2Http.js.map +1 -1
  88. package/lib/ListSecrets.d.ts.map +1 -1
  89. package/lib/ListSecrets.js.map +1 -1
  90. package/lib/ListSecretsHttp.d.ts.map +1 -1
  91. package/lib/ListSecretsHttp.js.map +1 -1
  92. package/lib/Machine.d.ts +26 -1
  93. package/lib/Machine.d.ts.map +1 -1
  94. package/lib/Machine.js +16 -1
  95. package/lib/Machine.js.map +1 -1
  96. package/lib/MachineContainers.d.ts.map +1 -1
  97. package/lib/MachineContainers.js.map +1 -1
  98. package/lib/Metadata.d.ts.map +1 -1
  99. package/lib/Metadata.js.map +1 -1
  100. package/lib/MountVolume.d.ts +7 -2
  101. package/lib/MountVolume.d.ts.map +1 -1
  102. package/lib/MountVolume.js.map +1 -1
  103. package/lib/Postgres.d.ts +33 -1
  104. package/lib/Postgres.d.ts.map +1 -1
  105. package/lib/Postgres.js +20 -0
  106. package/lib/Postgres.js.map +1 -1
  107. package/lib/PostgresMigrations.d.ts.map +1 -1
  108. package/lib/PostgresMigrations.js.map +1 -1
  109. package/lib/Providers.d.ts +1 -1
  110. package/lib/Providers.d.ts.map +1 -1
  111. package/lib/Providers.js.map +1 -1
  112. package/lib/PutObject.d.ts.map +1 -1
  113. package/lib/PutObject.js.map +1 -1
  114. package/lib/PutObjectHttp.d.ts.map +1 -1
  115. package/lib/PutObjectHttp.js.map +1 -1
  116. package/lib/ReadRedis.d.ts.map +1 -1
  117. package/lib/ReadRedis.js.map +1 -1
  118. package/lib/ReadRedisHttp.d.ts.map +1 -1
  119. package/lib/ReadRedisHttp.js.map +1 -1
  120. package/lib/ReadWriteRedis.d.ts.map +1 -1
  121. package/lib/ReadWriteRedis.js.map +1 -1
  122. package/lib/ReadWriteRedisHttp.d.ts.map +1 -1
  123. package/lib/ReadWriteRedisHttp.js.map +1 -1
  124. package/lib/Redis.d.ts +21 -1
  125. package/lib/Redis.d.ts.map +1 -1
  126. package/lib/Redis.js +28 -0
  127. package/lib/Redis.js.map +1 -1
  128. package/lib/RedisBinding.d.ts.map +1 -1
  129. package/lib/RedisBinding.js.map +1 -1
  130. package/lib/RedisHttp.d.ts.map +1 -1
  131. package/lib/RedisHttp.js.map +1 -1
  132. package/lib/Region.d.ts.map +1 -1
  133. package/lib/Region.js.map +1 -1
  134. package/lib/Secret.d.ts +22 -1
  135. package/lib/Secret.d.ts.map +1 -1
  136. package/lib/Secret.js +15 -0
  137. package/lib/Secret.js.map +1 -1
  138. package/lib/SecretHttp.d.ts.map +1 -1
  139. package/lib/SecretHttp.js.map +1 -1
  140. package/lib/SecretKey.d.ts +22 -1
  141. package/lib/SecretKey.d.ts.map +1 -1
  142. package/lib/SecretKey.js +15 -0
  143. package/lib/SecretKey.js.map +1 -1
  144. package/lib/SecretKeyHttp.d.ts.map +1 -1
  145. package/lib/SecretKeyHttp.js.map +1 -1
  146. package/lib/Service.d.ts +96 -21
  147. package/lib/Service.d.ts.map +1 -1
  148. package/lib/Service.js +251 -37
  149. package/lib/Service.js.map +1 -1
  150. package/lib/Sign.d.ts.map +1 -1
  151. package/lib/Sign.js.map +1 -1
  152. package/lib/SignHttp.d.ts.map +1 -1
  153. package/lib/SignHttp.js.map +1 -1
  154. package/lib/Sprite.d.ts +22 -3
  155. package/lib/Sprite.d.ts.map +1 -1
  156. package/lib/Sprite.js +15 -0
  157. package/lib/Sprite.js.map +1 -1
  158. package/lib/SpriteHttp.d.ts.map +1 -1
  159. package/lib/SpriteHttp.js.map +1 -1
  160. package/lib/Verify.d.ts.map +1 -1
  161. package/lib/Verify.js.map +1 -1
  162. package/lib/VerifyHttp.d.ts.map +1 -1
  163. package/lib/VerifyHttp.js.map +1 -1
  164. package/lib/Volume.d.ts +3 -3
  165. package/lib/Volume.d.ts.map +1 -1
  166. package/lib/Volume.js +17 -1
  167. package/lib/Volume.js.map +1 -1
  168. package/lib/VolumeSnapshot.d.ts +32 -1
  169. package/lib/VolumeSnapshot.d.ts.map +1 -1
  170. package/lib/VolumeSnapshot.js +23 -0
  171. package/lib/VolumeSnapshot.js.map +1 -1
  172. package/lib/Website/AssetDeployment.d.ts.map +1 -1
  173. package/lib/Website/AssetDeployment.js.map +1 -1
  174. package/lib/Website/Astro.d.ts.map +1 -1
  175. package/lib/Website/Astro.js.map +1 -1
  176. package/lib/Website/Foldkit.d.ts.map +1 -1
  177. package/lib/Website/Foldkit.js.map +1 -1
  178. package/lib/Website/FrameworkSite.d.ts.map +1 -1
  179. package/lib/Website/FrameworkSite.js.map +1 -1
  180. package/lib/Website/Nextjs.d.ts.map +1 -1
  181. package/lib/Website/Nextjs.js.map +1 -1
  182. package/lib/Website/Nuxt.d.ts.map +1 -1
  183. package/lib/Website/Nuxt.js.map +1 -1
  184. package/lib/Website/Octane.d.ts.map +1 -1
  185. package/lib/Website/Octane.js.map +1 -1
  186. package/lib/Website/ReactRouter.d.ts.map +1 -1
  187. package/lib/Website/ReactRouter.js.map +1 -1
  188. package/lib/Website/SolidStart.d.ts.map +1 -1
  189. package/lib/Website/SolidStart.js.map +1 -1
  190. package/lib/Website/StaticSite.d.ts.map +1 -1
  191. package/lib/Website/StaticSite.js.map +1 -1
  192. package/lib/Website/SvelteKit.d.ts.map +1 -1
  193. package/lib/Website/SvelteKit.js.map +1 -1
  194. package/lib/Website/TanStackStart.d.ts.map +1 -1
  195. package/lib/Website/TanStackStart.js.map +1 -1
  196. package/lib/Website/Vinext.d.ts.map +1 -1
  197. package/lib/Website/Vinext.js.map +1 -1
  198. package/lib/Website/Vite.d.ts.map +1 -1
  199. package/lib/Website/Vite.js.map +1 -1
  200. package/lib/Website/Vocs.d.ts.map +1 -1
  201. package/lib/Website/Vocs.js.map +1 -1
  202. package/lib/Website/Waku.d.ts.map +1 -1
  203. package/lib/Website/Waku.js.map +1 -1
  204. package/lib/Website/index.d.ts.map +1 -1
  205. package/lib/Website/index.js.map +1 -1
  206. package/lib/WriteCertificates.d.ts.map +1 -1
  207. package/lib/WriteCertificates.js.map +1 -1
  208. package/lib/WriteCertificatesHttp.d.ts.map +1 -1
  209. package/lib/WriteCertificatesHttp.js.map +1 -1
  210. package/lib/WriteRedis.d.ts.map +1 -1
  211. package/lib/WriteRedis.js.map +1 -1
  212. package/lib/WriteRedisHttp.d.ts.map +1 -1
  213. package/lib/WriteRedisHttp.js.map +1 -1
  214. package/lib/WriteSecret.d.ts.map +1 -1
  215. package/lib/WriteSecret.js.map +1 -1
  216. package/lib/WriteSecretHttp.d.ts.map +1 -1
  217. package/lib/WriteSecretHttp.js.map +1 -1
  218. package/lib/bluegreen.d.ts +5 -4
  219. package/lib/bluegreen.d.ts.map +1 -1
  220. package/lib/bluegreen.js +1 -1
  221. package/lib/bluegreen.js.map +1 -1
  222. package/lib/hosted.d.ts +16 -0
  223. package/lib/hosted.d.ts.map +1 -1
  224. package/lib/hosted.js +101 -11
  225. package/lib/hosted.js.map +1 -1
  226. package/lib/index.d.ts.map +1 -1
  227. package/lib/index.js.map +1 -1
  228. package/lib/internal/Oidc.d.ts +15 -0
  229. package/lib/internal/Oidc.d.ts.map +1 -0
  230. package/lib/internal/Oidc.js +73 -0
  231. package/lib/internal/Oidc.js.map +1 -0
  232. package/lib/internal/RemoteBuilder.d.ts.map +1 -1
  233. package/lib/internal/RemoteBuilder.js.map +1 -1
  234. package/lib/leases.d.ts +6 -2
  235. package/lib/leases.d.ts.map +1 -1
  236. package/lib/leases.js +25 -5
  237. package/lib/leases.js.map +1 -1
  238. package/lib/ports.d.ts.map +1 -1
  239. package/lib/ports.js.map +1 -1
  240. package/lib/replicas.d.ts +38 -26
  241. package/lib/replicas.d.ts.map +1 -1
  242. package/lib/replicas.js +57 -2
  243. package/lib/replicas.js.map +1 -1
  244. package/lib/rpc.d.ts +14 -12
  245. package/lib/rpc.d.ts.map +1 -1
  246. package/lib/rpc.js +38 -40
  247. package/lib/rpc.js.map +1 -1
  248. package/package.json +15 -15
  249. package/src/App.ts +36 -3
  250. package/src/BindService.ts +27 -34
  251. package/src/Bootstrap.ts +2 -0
  252. package/src/Bucket.ts +264 -33
  253. package/src/Certificate.ts +32 -1
  254. package/src/Credentials.ts +9 -5
  255. package/src/IpAssignment.ts +31 -1
  256. package/src/Machine.ts +34 -1
  257. package/src/MountVolume.ts +5 -2
  258. package/src/Postgres.ts +43 -1
  259. package/src/Redis.ts +43 -1
  260. package/src/Secret.ts +29 -1
  261. package/src/SecretKey.ts +29 -1
  262. package/src/Service.ts +389 -70
  263. package/src/Sprite.ts +34 -8
  264. package/src/Volume.ts +25 -1
  265. package/src/VolumeSnapshot.ts +39 -1
  266. package/src/bluegreen.ts +4 -1
  267. package/src/hosted.ts +118 -13
  268. package/src/internal/Oidc.ts +99 -0
  269. package/src/leases.ts +35 -7
  270. package/src/replicas.ts +63 -2
  271. package/src/rpc.ts +37 -40
@@ -1,6 +1,6 @@
1
1
  import * as Binding from "alchemy/Binding";
2
2
  import type { Resource } from "alchemy/Resource";
3
- import { makeFetchRpcStub, RPC_PATH_PREFIX, type Rpc } from "alchemy/Rpc";
3
+ import { makeFetchRpcStub, type Rpc } from "alchemy/Rpc";
4
4
  import { CurrentRuntimeContext, type RuntimeContext, sanitizeKey } from "alchemy/RuntimeContext";
5
5
  import { isYieldableEffectLike } from "alchemy/Util/effect";
6
6
  import * as Data from "effect/Data";
@@ -9,9 +9,7 @@ import * as HttpClient from "effect/http/HttpClient";
9
9
  import type * as HttpClientError from "effect/http/HttpClientError";
10
10
  import * as HttpClientRequest from "effect/http/HttpClientRequest";
11
11
  import type * as HttpClientResponse from "effect/http/HttpClientResponse";
12
- import * as Redacted from "effect/Redacted";
13
12
  import type { BoundTarget, ServiceBinding } from "./MountVolume.ts";
14
- import { RPC_TOKEN_HEADER } from "./rpc.ts";
15
13
  import type { Service } from "./Service.ts";
16
14
 
17
15
  /** The deployed caller has no address for the bound Service. */
@@ -75,12 +73,13 @@ const logicalIdOf = (target: unknown): string => {
75
73
  export const boundTargetEnvKeys = (id: string) => ({
76
74
  appName: sanitizeKey(`FLY_BIND_${id}_APP`),
77
75
  url: sanitizeKey(`FLY_BIND_${id}_URL`),
78
- token: sanitizeKey(`FLY_BIND_${id}_TOKEN`),
79
76
  });
80
77
 
81
78
  /**
82
79
  * Record the binding on the caller and transport the target's private
83
- * address and caller token as Outputs. Returns runtime accessors for them.
80
+ * address as Outputs. For {@link bindService}, also record the caller's
81
+ * App on the target, which serves its methods only to the Apps recorded
82
+ * there. Returns runtime accessors for the address.
84
83
  */
85
84
  const bindTarget = Effect.fn(function* <Shape, Req>(
86
85
  target: Target<Shape> | Effect.Effect<Target<Shape>, never, Req>,
@@ -105,11 +104,22 @@ const bindTarget = Effect.fn(function* <Shape, Req>(
105
104
  appName: service.appName,
106
105
  network: service.network,
107
106
  privateUrl: service.privateUrl,
108
- rpcToken: service.rpcToken,
109
107
  port,
110
108
  endpoints: service.endpoints,
111
109
  } as unknown as BoundTarget,
112
110
  });
111
+ if (port === undefined) {
112
+ // Fly signs each request's calling App (`Fly-Src`), so the target
113
+ // authorizes callers by App: no key to hand out.
114
+ yield* (
115
+ service as unknown as {
116
+ bind: (
117
+ label: TemplateStringsArray,
118
+ ...args: unknown[]
119
+ ) => (data: ServiceBinding) => Effect.Effect<void>;
120
+ }
121
+ ).bind`Caller(${host})`({ caller: { appName: host.appName as unknown as string } });
122
+ }
113
123
  }
114
124
  }
115
125
  // Captured at bind time: the host's runtime context reads the values
@@ -126,15 +136,6 @@ const bindTarget = Effect.fn(function* <Shape, Req>(
126
136
  return {
127
137
  appName: required(keys.appName),
128
138
  url: required(keys.url),
129
- token: read(keys.token).pipe(
130
- Effect.map((value) =>
131
- Redacted.isRedacted(value)
132
- ? String(Redacted.value(value))
133
- : typeof value === "string"
134
- ? value
135
- : "",
136
- ),
137
- ),
138
139
  };
139
140
  });
140
141
 
@@ -150,12 +151,13 @@ const withBase = (base: string, url: string) => {
150
151
  * `fetch` and `run`) and forwards `fetch` requests, over Fly's private
151
152
  * network.
152
153
  *
153
- * Binding makes the caller deploy after the Service and hands it the
154
- * Service's private address and caller token. Only callers that bind a
155
- * Service receive its token, so other Services on the same network cannot
156
- * call its methods. Every call must also carry Fly's signed `Fly-Src`
157
- * header from the same organization, so the public internet cannot call
158
- * them. Calls are plain HTTP inside Fly's WireGuard-encrypted network.
154
+ * Binding makes the caller deploy after the Service, hands it the
155
+ * Service's private address, and records the caller's App on the Service.
156
+ * Fly's proxy signs every call with the calling App (`Fly-Src`), and the
157
+ * Service answers methods only for the Apps recorded on it, so neither
158
+ * other Services on the network nor the public internet can call them,
159
+ * and no key is involved. Calls are plain HTTP inside Fly's
160
+ * WireGuard-encrypted network.
159
161
  *
160
162
  * ### Call a private Service
161
163
  * **Example:** Methods on the target
@@ -250,24 +252,15 @@ export const bindService = <Shape, Req = never>(
250
252
  Effect.gen(function* () {
251
253
  const http = yield* HttpClient.HttpClient;
252
254
  const bound = yield* bindTarget(target, undefined);
253
- const send = (request: HttpClientRequest.HttpClientRequest, isRpc: boolean) =>
255
+ const send = (request: HttpClientRequest.HttpClientRequest) =>
254
256
  Effect.gen(function* () {
255
257
  const base = yield* bound.url;
256
- let next = HttpClientRequest.setUrl(request, withBase(base, request.url));
257
- if (isRpc) {
258
- next = HttpClientRequest.setHeader(next, RPC_TOKEN_HEADER, yield* bound.token);
259
- }
260
- return yield* http.execute(next);
258
+ return yield* http.execute(HttpClientRequest.setUrl(request, withBase(base, request.url)));
261
259
  });
262
260
  return makeFetchRpcStub<ServiceClient<Shape>>({
263
261
  fetch: (request) =>
264
- send(request, request.url.includes(RPC_PATH_PREFIX)) as Effect.Effect<
265
- HttpClientResponse.HttpClientResponse,
266
- unknown
267
- >,
268
- base: {
269
- fetch: (request: HttpClientRequest.HttpClientRequest) => send(request, false),
270
- },
262
+ send(request) as Effect.Effect<HttpClientResponse.HttpClientResponse, unknown>,
263
+ base: { fetch: send },
271
264
  });
272
265
  });
273
266
 
package/src/Bootstrap.ts CHANGED
@@ -14,6 +14,7 @@ import {
14
14
  } from "alchemy/Runtime/Bootstrap";
15
15
  import { ManagedHttpShutdown } from "alchemy/Runtime/ManagedHttpShutdown";
16
16
  import * as ConfigProvider from "effect/ConfigProvider";
17
+ import * as SecureRandom from "effect/crypto/SecureRandom";
17
18
  import * as Effect from "effect/Effect";
18
19
  import * as FetchHttpClient from "effect/http/FetchHttpClient";
19
20
  import * as Layer from "effect/Layer";
@@ -31,6 +32,7 @@ export const bootstrap = (entrypoint: unknown): Promise<void> => {
31
32
  const platform = Layer.mergeAll(
32
33
  NodeServices.layer,
33
34
  FetchHttpClient.layer,
35
+ SecureRandom.layerWebCrypto,
34
36
  Logger.layer([Logger.consolePretty()]),
35
37
  );
36
38
 
package/src/Bucket.ts CHANGED
@@ -1,3 +1,7 @@
1
+ import { fromCredentials, type Credentials } from "@distilled.cloud/aws/Credentials";
2
+ import * as AwsEndpoint from "@distilled.cloud/aws/Endpoint";
3
+ import type { RegionName } from "@distilled.cloud/aws/Region";
4
+ import * as S3 from "@distilled.cloud/aws/s3";
1
5
  import type { AddOnsResponseEdgesItemNode } from "@distilled.cloud/fly-io/addons";
2
6
  import * as addons from "@distilled.cloud/fly-io/addons";
3
7
  import * as machines from "@distilled.cloud/fly-io/machines";
@@ -9,6 +13,9 @@ import { createInternalTags } from "alchemy/Tags";
9
13
  import * as Data from "effect/Data";
10
14
  import * as Duration from "effect/Duration";
11
15
  import * as Effect from "effect/Effect";
16
+ import * as FetchHttpClient from "effect/http/FetchHttpClient";
17
+ import type * as HttpClient from "effect/http/HttpClient";
18
+ import * as Layer from "effect/Layer";
12
19
  import * as Redacted from "effect/Redacted";
13
20
  import * as Result from "effect/Result";
14
21
  import * as Schedule from "effect/Schedule";
@@ -133,9 +140,19 @@ export type Bucket = Resource<
133
140
  bucketName: Redacted.Redacted<string> | undefined;
134
141
  },
135
142
  never,
136
- Providers
143
+ Providers,
144
+ BucketLookup
137
145
  >;
138
146
 
147
+ /**
148
+ * Props for {@link Bucket.lookup}: find a Tigris bucket this stack
149
+ * doesn't manage by its name.
150
+ */
151
+ export interface BucketLookup {
152
+ /** Tigris bucket / add-on name. */
153
+ name: string;
154
+ }
155
+
139
156
  /**
140
157
  * A Fly.Bucket is Tigris S3-compatible object storage, billed through
141
158
  * Fly. It is an org-scoped GraphQL add-on (`AddOnType` `tigris`).
@@ -245,6 +262,18 @@ export type Bucket = Resource<
245
262
  * });
246
263
  * ```
247
264
  *
265
+ * ### Looking up an existing bucket
266
+ * `Bucket.lookup` references a bucket Alchemy doesn't manage (created with
267
+ * `fly storage create` or by another stack). It is read during plan/deploy,
268
+ * never created, updated or deleted. Tigris returns a bucket's access keys
269
+ * only when it is created, so a looked-up bucket's credential attributes
270
+ * are `undefined` unless Fly still reports them.
271
+ *
272
+ * **Example:** Reference an existing bucket
273
+ * ```typescript
274
+ * const assets = Fly.Bucket.lookup({ name: "my-assets" });
275
+ * ```
276
+ *
248
277
  * @resource
249
278
  * @product Bucket
250
279
  */
@@ -267,10 +296,29 @@ export class BucketProvisionFailed extends Data.TaggedError("Fly.BucketProvision
267
296
  class BucketPending extends Data.TaggedError("Fly.BucketPending")<{
268
297
  resourceName: string;
269
298
  status: string;
270
- }> {}
299
+ }> {
300
+ override get message() {
301
+ return `Tigris bucket ${this.resourceName} is still ${this.status}`;
302
+ }
303
+ }
304
+
305
+ /**
306
+ * Fly lists an add-on whose Tigris bucket is gone, and Fly's delete
307
+ * fails on the missing bucket.
308
+ */
309
+ export class BucketRecordStale extends Data.TaggedError("Fly.BucketRecordStale")<{
310
+ resourceName: string;
311
+ reason: string;
312
+ }> {
313
+ override get message() {
314
+ return `Fly keeps a stale add-on record for Tigris bucket ${this.resourceName}: ${this.reason}`;
315
+ }
316
+ }
271
317
 
272
318
  const TIGRIS = "tigris" as const;
273
319
 
320
+ const TIGRIS_ENDPOINT = "https://fly.storage.tigris.dev";
321
+
274
322
  const backoff = Schedule.min([
275
323
  Schedule.exponential(Duration.millis(500), 1.5),
276
324
  Schedule.spaced(Duration.seconds(5)),
@@ -493,32 +541,213 @@ const waitUntilReady = (name: string, addOnId: string) =>
493
541
  Effect.catchTag("Fly.BucketPending", () => findById(addOnId)),
494
542
  );
495
543
 
496
- const waitUntilGone = (addOnId: string, name: string) =>
497
- Effect.gen(function* () {
498
- // Best-effort: Tigris often answers "The specified bucket does not
499
- // exist" (untyped UnknownFlyIoError) for a delete that already
500
- // succeeded. Never fail the wait on that — only on the add-on still
501
- // showing up in list.
502
- yield* Effect.result(
503
- addons.deleteAddOn({
504
- input: addOnId.length > 0 ? { addOnId } : { name, provider: TIGRIS },
544
+ interface TigrisKeys {
545
+ accessKeyId: Redacted.Redacted<string>;
546
+ secretAccessKey: Redacted.Redacted<string>;
547
+ endpoint: string;
548
+ }
549
+
550
+ /** The bucket's own S3 keys. Fly returns them only from createAddOn. */
551
+ const tigrisKeysOf = (
552
+ attrs: Pick<Bucket["Attributes"], "accessKeyId" | "secretAccessKey" | "endpoint">,
553
+ ): TigrisKeys | undefined =>
554
+ attrs.accessKeyId !== undefined && attrs.secretAccessKey !== undefined
555
+ ? {
556
+ accessKeyId: attrs.accessKeyId,
557
+ secretAccessKey: attrs.secretAccessKey,
558
+ endpoint: asString(attrs.endpoint) ?? TIGRIS_ENDPOINT,
559
+ }
560
+ : undefined;
561
+
562
+ const withTigrisKeys = <A, E>(
563
+ keys: TigrisKeys,
564
+ operation: Effect.Effect<A, E, Credentials | HttpClient.HttpClient>,
565
+ ) =>
566
+ operation.pipe(
567
+ Effect.provide(
568
+ Layer.mergeAll(
569
+ fromCredentials(
570
+ { accessKeyId: keys.accessKeyId, secretAccessKey: keys.secretAccessKey },
571
+ "auto" as RegionName,
572
+ ),
573
+ AwsEndpoint.of(keys.endpoint),
574
+ FetchHttpClient.layer,
575
+ ),
576
+ ),
577
+ );
578
+
579
+ /**
580
+ * Signed HeadBucket. Tigris answers 404 for a name it has no bucket
581
+ * under and 403 for a bucket the keys don't own. After a delete it can
582
+ * keep answering 200 to the deleted bucket's own keys for minutes.
583
+ */
584
+ const tigrisHasBucket = (keys: TigrisKeys, bucketName: string) =>
585
+ withTigrisKeys(keys, S3.headBucket({ Bucket: bucketName })).pipe(
586
+ Effect.as(true),
587
+ Effect.catchTag(["NotFound", "NoSuchBucket"], () => Effect.succeed(false)),
588
+ );
589
+
590
+ const requestDelete = (addOnId: string, name: string) =>
591
+ addons
592
+ .deleteAddOn({
593
+ input: addOnId.length > 0 ? { addOnId } : { name, provider: TIGRIS },
594
+ })
595
+ .pipe(
596
+ Effect.as("deleted" as const),
597
+ Effect.catchTag("TigrisBucketNotFound", () => Effect.succeed("missing" as const)),
598
+ );
599
+
600
+ interface DeleteTarget {
601
+ addOnId: string;
602
+ /** Fly add-on name. */
603
+ name: string;
604
+ /** Tigris bucket name (the add-on name unless Fly said otherwise). */
605
+ bucketName: string;
606
+ keys: TigrisKeys | undefined;
607
+ }
608
+
609
+ const deleteTargetOf = (attrs: Bucket["Attributes"]): DeleteTarget => ({
610
+ addOnId: attrs.addOnId,
611
+ name: attrs.name,
612
+ bucketName: asString(attrs.bucketName) ?? attrs.name,
613
+ keys: tigrisKeysOf(attrs),
614
+ });
615
+
616
+ /**
617
+ * Delete through Fly (which also empties the bucket), then confirm.
618
+ * With the bucket's keys, Tigris decides (its missing-bucket answer to
619
+ * the delete, else HeadBucket): Fly's list lags it and can keep a stale
620
+ * record. Without keys, the add-on must leave Fly's list.
621
+ * Fails with BucketPending once the confirm runs out; `missing` reports
622
+ * whether Fly's last delete said Tigris has no such bucket while the
623
+ * record stayed listed (only without keys).
624
+ */
625
+ const deleteVerified = (target: DeleteTarget) =>
626
+ Effect.suspend(() => {
627
+ const state = { missing: false };
628
+ return Effect.gen(function* () {
629
+ // Other delete failures (a bucket still attached to an App, a Fly
630
+ // 5xx) are transient; the confirm below decides.
631
+ const requested = yield* Effect.result(requestDelete(target.addOnId, target.name));
632
+ state.missing = Result.isSuccess(requested) && requested.success === "missing";
633
+ if (target.keys !== undefined) {
634
+ // Fly forwards Tigris's own "bucket does not exist" for the delete:
635
+ // the bucket is gone. HeadBucket can keep answering 200 to the
636
+ // deleted bucket's keys for minutes after that.
637
+ if (state.missing) return;
638
+ if (!(yield* tigrisHasBucket(target.keys, target.bucketName))) return;
639
+ return yield* new BucketPending({ resourceName: target.name, status: "deleting" });
640
+ }
641
+ const still =
642
+ (target.addOnId.length > 0 ? yield* findById(target.addOnId) : undefined) ??
643
+ (target.name.length > 0 ? yield* findByName(target.name) : undefined);
644
+ if (still !== undefined) {
645
+ return yield* new BucketPending({
646
+ resourceName: target.name,
647
+ status: still.status ?? "deleting",
648
+ });
649
+ }
650
+ }).pipe(
651
+ Effect.retry({
652
+ while: (e) => e._tag === "Fly.BucketPending",
653
+ schedule: Schedule.spaced("2 seconds"),
654
+ times: 20,
505
655
  }),
656
+ Effect.as({ missing: false }),
657
+ Effect.catchTag("Fly.BucketPending", (pending) =>
658
+ state.missing && target.keys === undefined
659
+ ? Effect.succeed({ missing: true })
660
+ : Effect.fail(pending),
661
+ ),
506
662
  );
507
- const still =
508
- (yield* findById(addOnId)) ?? (name.length > 0 ? yield* findByName(name) : undefined);
509
- if (still !== undefined) {
510
- return yield* new BucketPending({
511
- resourceName: name,
512
- status: still.status ?? "deleting",
513
- });
514
- }
515
- }).pipe(
516
- Effect.retry({
517
- while: (e) => e._tag === "Fly.BucketPending",
518
- schedule: Schedule.spaced("2 seconds"),
519
- times: 20,
663
+ });
664
+
665
+ const randomBase32 = (length: number) =>
666
+ Effect.sync(() => {
667
+ const alphabet = "abcdefghijklmnopqrstuvwxyz234567";
668
+ return Array.from(crypto.getRandomValues(new Uint8Array(length)), (b) => alphabet[b % 32]).join(
669
+ "",
670
+ );
671
+ });
672
+
673
+ /**
674
+ * A throwaway bucket, only for its keys. Its name matches the physical
675
+ * name shape, so a leaked one is listed and nuked like any other.
676
+ */
677
+ const helperBucket = Effect.fn(function* (orgSlug: string) {
678
+ const org = yield* resolveOrganization(orgSlug);
679
+ if (org === undefined) {
680
+ return yield* new BucketOrgMissing({ orgSlug });
681
+ }
682
+ yield* ensureTos(orgSlug, org.id);
683
+ const name = `alchemy-fix-${yield* randomBase32(16)}`;
684
+ const created = yield* addons.createAddOn({
685
+ input: {
686
+ type: TIGRIS,
687
+ name,
688
+ organizationId: org.id,
689
+ options: desiredOptions({}),
690
+ },
691
+ });
692
+ return toAttrs(created.addOn, undefined, { name, orgSlug });
693
+ });
694
+
695
+ /**
696
+ * Fly keeps an add-on record whose Tigris bucket is gone, and its
697
+ * delete fails on the missing bucket. Recreate the name in Tigris with a
698
+ * throwaway bucket's keys so Fly's delete has a bucket to remove.
699
+ * CreateBucket fails on a name Tigris still holds, so this never touches
700
+ * a live bucket. A name deleted in the last several minutes stays
701
+ * blocked (`BucketInaccessible`); the delete fails and a later one
702
+ * repairs it.
703
+ */
704
+ const repairStaleRecord = Effect.fn(function* (target: DeleteTarget, orgSlug: string) {
705
+ yield* Effect.logWarning(
706
+ `Fly still lists Tigris bucket ${target.name} after Tigris reported it gone; repairing the stale record`,
707
+ );
708
+ yield* Effect.scoped(
709
+ Effect.gen(function* () {
710
+ const helper = yield* Effect.acquireRelease(helperBucket(orgSlug), (attrs) =>
711
+ deleteVerified(deleteTargetOf(attrs)).pipe(Effect.ignore),
712
+ );
713
+ const keys = tigrisKeysOf(helper);
714
+ if (keys === undefined) {
715
+ return yield* new BucketRecordStale({
716
+ resourceName: target.name,
717
+ reason: "Fly returned no keys for the temporary repair bucket",
718
+ });
719
+ }
720
+ const recreated = yield* Effect.result(
721
+ withTigrisKeys(keys, S3.createBucket({ Bucket: target.bucketName })),
722
+ );
723
+ if (Result.isFailure(recreated)) {
724
+ return yield* new BucketRecordStale({
725
+ resourceName: target.name,
726
+ reason: `Tigris would not recreate the name (${recreated.failure._tag}); a recently deleted name stays blocked for several minutes, so retry the delete later`,
727
+ });
728
+ }
729
+ const result = yield* deleteVerified({ ...target, keys: undefined });
730
+ if (result.missing) {
731
+ return yield* new BucketRecordStale({
732
+ resourceName: target.name,
733
+ reason: "Fly's delete still reported a missing bucket after the name was recreated",
734
+ });
735
+ }
520
736
  }),
521
737
  );
738
+ });
739
+
740
+ /**
741
+ * Delete and confirm. A stale Fly record is repaired only when there are
742
+ * no keys to ask Tigris with (nuke, adopted buckets): with keys, Tigris's
743
+ * 404 already proves the bucket is gone.
744
+ */
745
+ const waitUntilGone = Effect.fn(function* (attrs: Bucket["Attributes"]) {
746
+ const target = deleteTargetOf(attrs);
747
+ const result = yield* deleteVerified(target);
748
+ if (!result.missing) return;
749
+ yield* repairStaleRecord(target, attrs.orgSlug ?? (yield* resolveOrgSlug()));
750
+ });
522
751
 
523
752
  const resolveOrganization = (orgSlug: string) =>
524
753
  addons
@@ -582,6 +811,15 @@ export const BucketProvider = () =>
582
811
  return undefined;
583
812
  }),
584
813
 
814
+ lookup: Effect.fn(function* ({ name }) {
815
+ const found = yield* findByName(name);
816
+ if (found === undefined) return undefined;
817
+ const detail = yield* addons
818
+ .addOn({ id: found.id, name: found.name ?? name, provider: TIGRIS })
819
+ .pipe(Effect.catchTag("FlyIoParseError", () => Effect.undefined));
820
+ return toAttrs(detail ?? found, undefined, { name });
821
+ }),
822
+
585
823
  read: Effect.fn(function* ({ id, olds, output }) {
586
824
  const name = yield* resolveBucketName(id, olds?.name, output?.name);
587
825
  const found =
@@ -716,15 +954,8 @@ export const BucketProvider = () =>
716
954
  }),
717
955
 
718
956
  delete: Effect.fn(function* ({ output }) {
719
- const addOnId = output.addOnId;
720
- const name = output.name;
721
- if (addOnId.length === 0 && name.length === 0) return;
722
- // Always wait for the add-on to leave the list. A failed
723
- // deleteAddOn plus a single list miss is not gone — Tigris
724
- // list lags, and a bucket still attached to an App is a
725
- // transient error. waitUntilGone retries delete and only
726
- // succeeds once list (id and name) is empty.
727
- yield* waitUntilGone(addOnId, name);
957
+ if (output.addOnId.length === 0 && output.name.length === 0) return;
958
+ yield* waitUntilGone(output);
728
959
  }),
729
960
  });
730
961
 
@@ -97,9 +97,21 @@ export type Certificate = Resource<
97
97
  source: CertificateSource;
98
98
  },
99
99
  never,
100
- Providers
100
+ Providers,
101
+ CertificateLookup
101
102
  >;
102
103
 
104
+ /**
105
+ * Props for {@link Certificate.lookup}: find a certificate this stack
106
+ * doesn't manage by its App and hostname.
107
+ */
108
+ export interface CertificateLookup {
109
+ /** Fly App name the certificate is attached to. */
110
+ appName: string;
111
+ /** Hostname the certificate covers. */
112
+ hostname: string;
113
+ }
114
+
103
115
  /**
104
116
  * A Fly.Certificate covers a hostname on an {@link App}. Default
105
117
  * `kind` is `"acme"` (Let's Encrypt). `"custom"` uploads a PEM.
@@ -176,6 +188,20 @@ export type Certificate = Resource<
176
188
  * });
177
189
  * ```
178
190
  *
191
+ * ### Looking up an existing certificate
192
+ * `Certificate.lookup` references a certificate Alchemy doesn't manage
193
+ * (added with `fly certs add` or by another stack). It is read during
194
+ * plan/deploy, never created, updated or deleted.
195
+ *
196
+ * **Example:** Read the DNS records Fly expects
197
+ * ```typescript
198
+ * const cert = Fly.Certificate.lookup({
199
+ * appName: "my-site",
200
+ * hostname: "www.example.com",
201
+ * });
202
+ * const target = cert.dnsRequirements;
203
+ * ```
204
+ *
179
205
  * @resource
180
206
  * @product Certificate
181
207
  */
@@ -423,6 +449,11 @@ export const CertificateProvider = () =>
423
449
  return undefined;
424
450
  }),
425
451
 
452
+ lookup: Effect.fn(function* ({ appName, hostname }) {
453
+ const found = yield* getByHostname(appName, hostname);
454
+ return found === undefined ? undefined : toAttrs(appName, found, hostname);
455
+ }),
456
+
426
457
  read: Effect.fn(function* ({ olds, output }) {
427
458
  const appName = output?.appName ?? appNameOf(olds?.app);
428
459
  const hostname = output?.hostname ?? olds?.hostname;
@@ -24,11 +24,14 @@ export {
24
24
  */
25
25
  export const bindFlyApiToken = (): Effect.Effect<void, never, Credentials> =>
26
26
  Effect.gen(function* () {
27
+ // Bound as Redacted so a Service that owns its App stores the token
28
+ // as an App secret, not plain Machine env. The runtime reads it back
29
+ // with `CredentialsFromEnv`.
27
30
  const token = globalThis.__ALCHEMY_RUNTIME__
28
- ? ""
31
+ ? Redacted.make("")
29
32
  : yield* Credentials.pipe(
30
33
  Effect.flatMap((resolve) => resolve),
31
- Effect.map((cfg) => Redacted.value(cfg.apiKey)),
34
+ Effect.map((cfg) => cfg.apiKey),
32
35
  // Binding initialization cannot fail; the engine reports this
33
36
  // defect as `CredentialsRequired`.
34
37
  orDieCredentialsUnavailable("Fly"),
@@ -44,9 +47,10 @@ export const bindFlyApiToken = (): Effect.Effect<void, never, Credentials> =>
44
47
  * `Credentials`, and the binding must use them — a laptop deploy has no
45
48
  * `FLY_API_TOKEN` in its env once the token lives in the Alchemy profile.
46
49
  * Inside a deployed Machine there is no profile; `bindFlyApiToken`
47
- * has already `RuntimeContext.set` the org token, which Platform copies
48
- * into Machine env as `FLY_API_TOKEN`. So: reuse the ambient
49
- * `Credentials` when present, otherwise read the env.
50
+ * has already `RuntimeContext.set` the org token, which reaches the
51
+ * Machine as `FLY_API_TOKEN` (an App secret, or Machine env in a shared
52
+ * App). So: reuse the ambient `Credentials` when present, otherwise read
53
+ * it with {@link CredentialsFromEnv}.
50
54
  */
51
55
  export const CredentialsFromAmbientOrEnv: Layer.Layer<Credentials> = Layer.effect(
52
56
  Credentials,
@@ -85,9 +85,21 @@ export type IpAssignment = Resource<
85
85
  createdAt: string | undefined;
86
86
  },
87
87
  never,
88
- Providers
88
+ Providers,
89
+ IpAssignmentLookup
89
90
  >;
90
91
 
92
+ /**
93
+ * Props for {@link IpAssignment.lookup}: find an address this stack
94
+ * doesn't manage by its App and IP.
95
+ */
96
+ export interface IpAssignmentLookup {
97
+ /** Fly App name the address is assigned to. */
98
+ appName: string;
99
+ /** The allocated address. */
100
+ ip: string;
101
+ }
102
+
91
103
  const resolveIpAssignmentProps = (
92
104
  props: IpAssignmentProps | Effect.Effect<IpAssignmentProps, never, Providers>,
93
105
  ): Effect.Effect<IpAssignmentProps, never, Providers> =>
@@ -247,6 +259,19 @@ const IpAssignmentResource = Resource<IpAssignment>("Fly.IpAssignment");
247
259
  * );
248
260
  * ```
249
261
  *
262
+ * ### Looking up an existing address
263
+ * `IpAssignment.lookup` references an address Alchemy doesn't manage
264
+ * (allocated with `fly ips allocate-v4` or by another stack). It is read
265
+ * during plan/deploy, never allocated or released.
266
+ *
267
+ * **Example:** Reference an existing address
268
+ * ```typescript
269
+ * const ip = Fly.IpAssignment.lookup({
270
+ * appName: "my-site",
271
+ * ip: "2a09:8280:1::1",
272
+ * });
273
+ * ```
274
+ *
250
275
  * @resource
251
276
  * @product App
252
277
  */
@@ -439,6 +464,11 @@ export const IpAssignmentProvider = () =>
439
464
  return undefined;
440
465
  }),
441
466
 
467
+ lookup: Effect.fn(function* ({ appName, ip }) {
468
+ const found = yield* findByIp(appName, ip);
469
+ return found?.ip === undefined ? undefined : toAttrs(appName, found);
470
+ }),
471
+
442
472
  read: Effect.fn(function* ({ olds, output }) {
443
473
  const appName = output?.appName ?? appNameOf(olds?.app);
444
474
  if (appName === undefined) return undefined;