@molecule/api-code-sandbox-flyio 1.0.18 → 1.2.0

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.
package/README.md CHANGED
@@ -3,7 +3,7 @@ AUTO-GENERATED — DO NOT EDIT THIS FILE.
3
3
  Generated by `mlcl sync-docs` from the package's src/index.ts JSDoc + mlcl/registry.json.
4
4
  Edits here are overwritten on the next commit (molecule's pre-commit hook regenerates).
5
5
  To change this document, edit the module-level JSDoc in src/index.ts.
6
- Generated: 2026-08-08T23:10:20.743Z
6
+ Generated: 2026-08-16T14:04:35.624Z
7
7
  -->
8
8
 
9
9
  # @molecule/api-code-sandbox-flyio
@@ -75,7 +75,7 @@ const warm = await provider.create({
75
75
  ## Installation
76
76
 
77
77
  ```bash
78
- npm install @molecule/api-code-sandbox-flyio @aws-sdk/client-s3 @aws-sdk/s3-request-presigner @molecule/api-bond @molecule/api-code-sandbox @molecule/api-i18n
78
+ npm install @molecule/api-code-sandbox-flyio @aws-sdk/client-s3 @aws-sdk/s3-request-presigner @molecule/api-bond @molecule/api-code-sandbox @molecule/api-i18n @molecule/api-proxy-agent
79
79
  ```
80
80
 
81
81
  ## API
@@ -94,10 +94,24 @@ interface EgressProbeContext {
94
94
  targets: EgressProbeTarget[]
95
95
  /**
96
96
  * Ports this provider's own egress policy allows, or `undefined` when it
97
- * applies no policy at all. Used ONLY for the message and the remediation —
98
- * never to decide the verdict, which comes from the observation alone.
97
+ * applies no policy at all. Used for the message, the remediation, and as the
98
+ * INTENT half of the drift check against {@link appliedPolicyPorts} — never to
99
+ * decide `open` vs `filtered` from a probe that reached a target, which comes
100
+ * from the observation alone.
99
101
  */
100
102
  policyPorts?: FlyNetworkPolicyPort[]
103
+ /**
104
+ * Ports the policy Fly ACTUALLY holds for this app allows, read back from the
105
+ * API after the probe ran, or `undefined` when the readback failed or found no
106
+ * policy of this provider's name.
107
+ *
108
+ * This is the observation half of the only drift this module can catch: a
109
+ * policy widened outside the provider (by hand, by another tool, by an older
110
+ * build) allows ports nothing here configured, and the raw-connect probe
111
+ * cannot see it because it only attempts the targets it was given. See
112
+ * {@link unexpectedPolicyPorts}.
113
+ */
114
+ appliedPolicyPorts?: FlyNetworkPolicyPort[]
101
115
  }
102
116
  ```
103
117
 
@@ -133,6 +147,30 @@ interface FlyApiClientOptions {
133
147
  }
134
148
  ```
135
149
 
150
+ #### `FlyApp`
151
+
152
+ An app as returned by `GET /v1/apps?org_slug=…` (the fields this provider reads).
153
+
154
+ ```typescript
155
+ interface FlyApp {
156
+ id?: string
157
+ name: string
158
+ machine_count?: number
159
+ }
160
+ ```
161
+
162
+ #### `FlyAppList`
163
+
164
+ Response body of `GET /v1/apps?org_slug=…`.
165
+
166
+ ```typescript
167
+ interface FlyAppList {
168
+ /** Total apps in the org, which the returned page may not cover. */
169
+ total_apps?: number
170
+ apps?: FlyApp[]
171
+ }
172
+ ```
173
+
136
174
  #### `FlyExecResponse`
137
175
 
138
176
  Response body of `POST /v1/apps/{app}/machines/{id}/exec` (`flydv1.ExecResponse`).
@@ -244,10 +282,13 @@ interface FlyioConfig {
244
282
  */
245
283
  publicService?: boolean
246
284
  /**
247
- * Fly Proxy idle behaviour for the preview service. `suspend` (the default)
248
- * is the scale-to-zero mapping this bond exists for: the proxy suspends an
249
- * idle Machine and resumes it from its memory snapshot on the next request.
250
- * `stop` is a full stop (cold boot on wake); `off` never idles the Machine.
285
+ * Fly Proxy idle behaviour for the preview service. Defaults to `'off'` —
286
+ * never idle the Machine — because the autostop timer counts the PROXY
287
+ * service's idle time, not the workload's, so a sandbox with nobody looking at
288
+ * its preview is suspended out from under a build that is still running.
289
+ * `suspend` is the scale-to-zero mapping (resume from a memory snapshot on the
290
+ * next request) and `stop` is a full stop with a cold boot on wake; opt into
291
+ * either deliberately.
251
292
  */
252
293
  autostop?: 'off' | 'stop' | 'suspend'
253
294
  /**
@@ -264,9 +305,12 @@ interface FlyioConfig {
264
305
  /** Timeout for a single Machines API request, in ms. Defaults to 30000. */
265
306
  requestTimeoutMs?: number
266
307
  /**
267
- * How long `start()`/`wake()` block waiting for the Machine to actually reach
268
- * `started`, in seconds. Clamped to Fly's own ceiling — `GET .../wait` "will
269
- * block for up to 60 seconds". Defaults to 60.
308
+ * TOTAL budget, in seconds, that `create()`/`start()`/`wake()` block waiting
309
+ * for the Machine to actually reach `started`. Fly's `GET .../wait` blocks
310
+ * for at most 60 seconds per call, so a larger budget is spent as consecutive
311
+ * wait rounds. Defaults to 180 — a Machine whose image is not yet cached on
312
+ * its host (every first boot after an image push) pulls it before starting,
313
+ * and that alone can exceed a single 60 s round.
270
314
  *
271
315
  * Without this wait, `start()` resolves the moment Fly ACCEPTS the request,
272
316
  * and the caller's very next `exec` hits a Machine that is not running yet.
@@ -449,6 +493,14 @@ interface FlyMachine {
449
493
  region?: string
450
494
  private_ip?: string
451
495
  config?: FlyMachineConfig
496
+ /** RFC 3339 creation time, as Fly reports it. */
497
+ created_at?: string
498
+ /**
499
+ * Lifecycle events, newest first. The ONLY place Fly records when a Machine
500
+ * was first started — there is no `started_at` field — which is what
501
+ * distinguishes a stopped Machine from one that was created and never ran.
502
+ */
503
+ events?: FlyMachineEvent[]
452
504
  }
453
505
  ```
454
506
 
@@ -470,6 +522,20 @@ interface FlyMachineConfig {
470
522
  }
471
523
  ```
472
524
 
525
+ #### `FlyMachineEvent`
526
+
527
+ One entry of {@link FlyMachine.events}.
528
+
529
+ ```typescript
530
+ interface FlyMachineEvent {
531
+ /** `start`, `launch`, `exit`, … */
532
+ type?: string
533
+ status?: string
534
+ /** Epoch milliseconds. */
535
+ timestamp?: number
536
+ }
537
+ ```
538
+
473
539
  #### `FlyMachineService`
474
540
 
475
541
  A Fly Proxy service attached to a Machine (`fly.MachineService`).
@@ -528,6 +594,13 @@ One rule in a Fly network policy. `allow` is the only documented action:
528
594
  "Once you create a rule for a given direction, the default for that direction
529
595
  becomes drop."
530
596
 
597
+ There is no destination field, and that is measured rather than assumed
598
+ (2026-08-16, live API): `ipv6_cidrs`, `ipv4_cidrs`, `cidrs`, `destinations`,
599
+ `apps`, `app`, `to` and `dst` were each POSTed on a rule and each came back
600
+ absent — the stored rule is `{action, direction, ports}` and nothing more. An
601
+ `allow tcp/5432` rule therefore permits the Machine to reach port 5432 on
602
+ every host on the internet.
603
+
531
604
  ```typescript
532
605
  interface FlyNetworkPolicyRule {
533
606
  /** Only `allow` is supported by Fly. */
@@ -541,8 +614,16 @@ interface FlyNetworkPolicyRule {
541
614
 
542
615
  #### `FlyNetworkPolicySelector`
543
616
 
544
- Which Machines in the app a policy applies to. Documented criteria combine
545
- with AND, so this provider uses `{ all: true }` alone.
617
+ Which Machines in the app a policy applies to — the SOURCE side only. There is
618
+ no destination selector: an `apps` array is the one candidate the API answers
619
+ about, and it answers `400 {"error":"apps array not currently supported in
620
+ selectors"}` (measured 2026-08-16); every other candidate field is silently
621
+ dropped. Documented criteria combine with AND, so this provider uses
622
+ `{ all: true }` alone.
623
+
624
+ The API's LIST response returns this object under the key `netpolSelector`
625
+ while accepting it as `selector` on write — which is why nothing here reads it
626
+ back by name.
546
627
 
547
628
  ```typescript
548
629
  interface FlyNetworkPolicySelector {
@@ -855,11 +936,9 @@ type RawExec = (command: string[], timeoutSeconds: number) => Promise<FlyExecRes
855
936
 
856
937
  #### `FlyApiClient`
857
938
 
858
- Thin, retrying JSON client for the Fly Machines API.
859
-
860
- Deliberately not a generated SDK: this provider touches ~12 endpoints, and a
861
- generated client would drag in the whole Machines + Postgres + tokens surface
862
- for no benefit.
939
+ Minimal Fly Machines API client: bearer auth, JSON bodies, per-request
940
+ timeout, bounded retries with backoff, and account-wide request pacing via
941
+ {@link paceFlyRequest}.
863
942
 
864
943
  #### `FlyApiError`
865
944
 
@@ -1115,6 +1194,28 @@ function decodePrivateRoutes(raw: string | undefined): Record<string, string>
1115
1194
 
1116
1195
  **Returns:** Target app name → allocated private address. Unparseable entries are skipped: this value only drives cleanup and self-healing, and a malformed entry must not stop either.
1117
1196
 
1197
+ #### `describePublicReach(policyPorts)`
1198
+
1199
+ States, in the verdict itself, what an allowed port actually permits.
1200
+
1201
+ A Fly network policy has no destination (see the module description — measured
1202
+ against the live API, not inferred), so every allowed port is open to every
1203
+ host on the internet, not only to the private service it was derived for. That
1204
+ is the single most consequential thing about this mechanism and it was invisible
1205
+ everywhere it mattered: the probe attempts one port the policy denies, reports
1206
+ `filtered`, and an operator reasonably reads that as "nothing gets out".
1207
+
1208
+ Naming it here means the residual is restated on every re-probe (the consumer
1209
+ re-verifies every 15 minutes) instead of living only in a document.
1210
+
1211
+ ```typescript
1212
+ function describePublicReach(policyPorts: FlyNetworkPolicyPort[] | undefined): string
1213
+ ```
1214
+
1215
+ - `policyPorts` — Allowed ports, or `undefined` when no policy is applied.
1216
+
1217
+ **Returns:** A sentence naming the public reach, or `''` when there is nothing to say (no policy, or nothing but UDP/53 — DNS is a documented residual of its own and not a TCP channel).
1218
+
1118
1219
  #### `encodePrivateRoutes(routes)`
1119
1220
 
1120
1221
  Encodes allocated addresses for storage in Machine metadata.
@@ -1173,6 +1274,32 @@ function extractPolicyId(response: unknown, name: string): string | undefined
1173
1274
 
1174
1275
  **Returns:** The matching policy's id, or `undefined`.
1175
1276
 
1277
+ #### `extractPolicyPorts(response, name)`
1278
+
1279
+ Reads back the egress ports the policy Fly ACTUALLY holds allows.
1280
+
1281
+ The write is not the observation: this provider POSTs a policy, but what
1282
+ governs a Machine is whatever Fly has when that Machine boots — which may
1283
+ differ because a person, another tool or an older build changed it. Reading it
1284
+ back is what lets {@link unexpectedPolicyPorts} catch a policy wider than the
1285
+ one this provider configured, so `verifyEgress()` can fail on drift its raw
1286
+ connects were never going to attempt.
1287
+
1288
+ Parsed as defensively as {@link extractPolicyId}, and for the same reason:
1289
+ neither Fly document specifies the LIST response shape. The live API answers
1290
+ with a bare array whose selector key is `netpolSelector` rather than the
1291
+ `selector` it accepts (verified 2026-08-16), which is exactly why nothing here
1292
+ depends on any key but `name` and `rules`.
1293
+
1294
+ ```typescript
1295
+ function extractPolicyPorts(response: unknown, name: string): FlyNetworkPolicyPort[] | undefined
1296
+ ```
1297
+
1298
+ - `response` — The raw parsed LIST response.
1299
+ - `name` — The policy name to match.
1300
+
1301
+ **Returns:** The allowed egress ports, or `undefined` when no policy of that name is present or the shape is unrecognized. `undefined` means "nothing to compare", never "nothing is allowed".
1302
+
1176
1303
  #### `flycastHost(app)`
1177
1304
 
1178
1305
  Renders the DNS name a sandbox dials to reach an app over its Flycast address.
@@ -1338,14 +1465,25 @@ It is deliberately a UNION and never a replacement, so an operator's list is
1338
1465
  still exactly what they wrote plus the ports their own declarations require,
1339
1466
  and the applied policy is logged.
1340
1467
 
1341
- Whether the addition is even load-bearing is UNVERIFIED: Fly states "Network
1342
- policies only apply to traffic directly to and from Machines. They do not
1343
- affect traffic routed through the Fly Proxy"
1468
+ The addition is load-bearing — VERIFIED 2026-08-16, not inferred. Fly states
1469
+ "Network policies only apply to traffic directly to and from Machines. They do
1470
+ not affect traffic routed through the Fly Proxy"
1344
1471
  (https://fly.io/docs/machines/guides-examples/network-policies/), and Flycast
1345
- traffic IS routed through Fly Proxy — but Fly says that about ingress and
1346
- never addresses a Machine's egress toward a Flycast address. An inert entry
1347
- costs a port on the allow list; a missing one costs every database in the
1348
- fleet, so the port is added.
1472
+ traffic IS routed through Fly Proxy, so it was an open question whether these
1473
+ ports did anything. Measured on a throwaway app with a Flycast address into
1474
+ its own 6PN: under a policy allowing only `udp:53`, `molecule-pg-tenant.flycast`
1475
+ and `molecule-api.flycast` both RESOLVED and every TCP connect to them was
1476
+ dropped; re-applying the policy with `tcp:443` opened 443 and nothing else.
1477
+ Fly's sentence is about INGRESS — a Machine's egress TOWARD a Flycast address
1478
+ is filtered like any other. Drop a declared service's port and every database
1479
+ connection in the fleet goes with it.
1480
+
1481
+ The cost of each derived port, stated plainly because a Fly policy has no
1482
+ destination field of any kind (also measured — see the `egress.ts` module
1483
+ description): the port is opened to EVERY host on the internet, not only to
1484
+ the private service it was derived for. That residual cannot be closed at this
1485
+ layer; it is named in `verifyEgress()`'s verdict and in
1486
+ `docs/sandbox-egress-enforcement.md`.
1349
1487
 
1350
1488
  ```typescript
1351
1489
  function mergeEgressPorts(
@@ -1520,6 +1658,14 @@ function requireStore(ctx: TemplateContext): ObjectStore
1520
1658
 
1521
1659
  **Returns:** The configured store.
1522
1660
 
1661
+ #### `resetFlyPacerForTests()`
1662
+
1663
+ Test-only: reset the shared pacer so timing tests start from a clean slate.
1664
+
1665
+ ```typescript
1666
+ function resetFlyPacerForTests(): void
1667
+ ```
1668
+
1523
1669
  #### `resolveTemplateStorage(config)`
1524
1670
 
1525
1671
  Resolves template-storage settings from config and the environment.
@@ -1614,6 +1760,30 @@ function toSandboxId(app: string, machineId: string): string
1614
1760
 
1615
1761
  **Returns:** The composite sandbox id.
1616
1762
 
1763
+ #### `unexpectedPolicyPorts(applied, intended)`
1764
+
1765
+ Finds ports the policy Fly holds allows that this provider never configured.
1766
+
1767
+ The raw-connect probe can only speak for the targets it was handed, so a
1768
+ policy widened outside this provider — edited by hand to unblock something, or
1769
+ left behind by an older build — is invisible to it: the probe's own port stays
1770
+ denied and the verdict stays `filtered` while a port nothing here asked for is
1771
+ open to the whole internet. Comparing what Fly holds against what this
1772
+ provider intended is the one check that can catch that, and it is why the
1773
+ verdict reads the policy back rather than trusting the write.
1774
+
1775
+ ```typescript
1776
+ function unexpectedPolicyPorts(
1777
+ applied: FlyNetworkPolicyPort[] | undefined,
1778
+ intended: FlyNetworkPolicyPort[] | undefined,
1779
+ ): FlyNetworkPolicyPort[]
1780
+ ```
1781
+
1782
+ - `applied` — Ports in the policy Fly actually holds, or `undefined` when the readback found nothing (in which case there is nothing to compare).
1783
+ - `intended` — Ports this provider configured, or `undefined` when it applies no policy.
1784
+
1785
+ **Returns:** The applied ports with no counterpart in `intended`, in order.
1786
+
1617
1787
  #### `verdictForProbeExit(exitCode, context)`
1618
1788
 
1619
1789
  Maps a probe's exit status onto an {@link EgressVerdict}.
@@ -1903,7 +2073,7 @@ export function setupCodeSandboxFlyio(): void {
1903
2073
  Peer dependencies:
1904
2074
 
1905
2075
  - `@molecule/api-bond` ^1.0.1
1906
- - `@molecule/api-code-sandbox` ^1.0.1
2076
+ - `@molecule/api-code-sandbox` ^1.2.0
1907
2077
  - `@molecule/api-i18n` ^1.0.1
1908
2078
 
1909
2079
  ### Environment Variables
@@ -1942,6 +2112,7 @@ Peer dependencies:
1942
2112
  - `@molecule/api-bond`
1943
2113
  - `@molecule/api-code-sandbox`
1944
2114
  - `@molecule/api-i18n`
2115
+ - `@molecule/api-proxy-agent`
1945
2116
 
1946
2117
  **Sources.** Every endpoint, payload field and documented behaviour used here
1947
2118
  was checked against Fly's own material, not recalled:
@@ -2169,12 +2340,14 @@ live, and any failure to LIST or read the store throws rather than reporting
2169
2340
  exist for providers whose templates are host-local; object storage is already
2170
2341
  the shared store, so there is nothing to publish or fetch.
2171
2342
 
2172
- **`exportFiles`/`importFiles` are NOT implemented.** The core says a provider
2173
- with no bulk transfer should leave them unimplemented rather than emulate one,
2174
- and Fly's only channel is the `exec` endpoint's JSON response — a file-by-file
2175
- or base64 emulation would look supported and take hours. The template path
2176
- moves bulk data without them precisely because it never routes the bytes
2177
- through the control plane.
2343
+ **`importFiles` IS implemented; `exportFiles` is not.** Import takes the same
2344
+ route the template restore does — a presigned object-store URL the Machine
2345
+ pulls itself, falling back to chunked base64 over `exec` when no store is
2346
+ configured — so an app tree can be delivered into a fresh Machine. There is no
2347
+ inverse: Fly's only read channel is the `exec` endpoint's JSON response, and a
2348
+ base64 emulation of a whole-tree export would look supported and route every
2349
+ byte through the control plane. Pair a Fly destination with a source that CAN
2350
+ export (the core's rooting contract makes them interchangeable).
2178
2351
 
2179
2352
  **Sandbox ids are composite: `"<app>:<machineId>"`.** A Fly Machine id is only
2180
2353
  unique within its app and every endpoint is `/v1/apps/{app}/machines/{id}`, so
package/dist/api.d.ts CHANGED
@@ -115,6 +115,11 @@ export interface FlyApiClientOptions {
115
115
  }
116
116
  /** Test-only: reset the shared pacer so timing tests start from a clean slate. */
117
117
  export declare function resetFlyPacerForTests(): void;
118
+ /**
119
+ * Minimal Fly Machines API client: bearer auth, JSON bodies, per-request
120
+ * timeout, bounded retries with backoff, and account-wide request pacing via
121
+ * {@link paceFlyRequest}.
122
+ */
118
123
  export declare class FlyApiClient {
119
124
  private readonly token;
120
125
  private readonly baseUrl;
package/dist/api.d.ts.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"api.d.ts","sourceRoot":"","sources":["../src/api.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AAgBH;;;;GAIG;AACH,qBAAa,WAAY,SAAQ,KAAK;IACpC,kFAAkF;IAClF,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;IACvB,sEAAsE;IACtE,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,yCAAyC;IACzC,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;IACvB,oDAAoD;IACpD,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IAErB;;;;;;;OAOG;gBAED,MAAM,EAAE,MAAM,EACd,IAAI,EAAE,MAAM,EACZ,MAAM,EAAE,MAAM,EACd,IAAI,EAAE,MAAM,EACZ,OAAO,CAAC,EAAE;QAAE,KAAK,CAAC,EAAE,OAAO,CAAA;KAAE;CAgBhC;AAED,4CAA4C;AAC5C,MAAM,WAAW,iBAAiB;IAChC,sCAAsC;IACtC,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,0DAA0D;IAC1D,IAAI,CAAC,EAAE,OAAO,CAAA;IACd,8EAA8E;IAC9E,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,sFAAsF;IACtF,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB;;;OAGG;IACH,MAAM,CAAC,EAAE,MAAM,EAAE,CAAA;IACjB;;;;;OAKG;IACH,aAAa,CAAC,EAAE,MAAM,EAAE,CAAA;CACzB;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,iBAAiB,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAEzD;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,YAAY,CAAC,OAAO,EAAE,MAAM,EAAE,UAAU,CAAC,EAAE,MAAM,GAAG,IAAI,GAAG,MAAM,CAQhF;AAED;;;;;;;;;GASG;AACH,wBAAgB,eAAe,CAAC,KAAK,EAAE,MAAM,GAAG,SAAS,GAAG,MAAM,CAoBjE;AAED,oDAAoD;AACpD,MAAM,WAAW,mBAAmB;IAClC,gFAAgF;IAChF,KAAK,EAAE,MAAM,MAAM,GAAG,SAAS,CAAA;IAC/B,gCAAgC;IAChC,OAAO,EAAE,MAAM,CAAA;IACf,wCAAwC;IACxC,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,mEAAmE;IACnE,SAAS,CAAC,EAAE,OAAO,KAAK,CAAA;IACxB,6DAA6D;IAC7D,KAAK,CAAC,EAAE,CAAC,EAAE,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAA;CACtC;AAyCD,kFAAkF;AAClF,wBAAgB,qBAAqB,IAAI,IAAI,CAG5C;AAED,qBAAa,YAAY;IACvB,OAAO,CAAC,QAAQ,CAAC,KAAK,CAA0B;IAChD,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAQ;IAChC,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAQ;IAClC,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAc;IACxC,OAAO,CAAC,QAAQ,CAAC,KAAK,CAA+B;IAErD;;;OAGG;gBACS,OAAO,EAAE,mBAAmB;IASxC;;;;;;;;;;OAUG;IACG,OAAO,CAAC,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,OAAO,GAAE,iBAAsB,GAAG,OAAO,CAAC,CAAC,GAAG,IAAI,CAAC;CAiFnF"}
1
+ {"version":3,"file":"api.d.ts","sourceRoot":"","sources":["../src/api.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AAgBH;;;;GAIG;AACH,qBAAa,WAAY,SAAQ,KAAK;IACpC,kFAAkF;IAClF,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;IACvB,sEAAsE;IACtE,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,yCAAyC;IACzC,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;IACvB,oDAAoD;IACpD,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IAErB;;;;;;;OAOG;gBAED,MAAM,EAAE,MAAM,EACd,IAAI,EAAE,MAAM,EACZ,MAAM,EAAE,MAAM,EACd,IAAI,EAAE,MAAM,EACZ,OAAO,CAAC,EAAE;QAAE,KAAK,CAAC,EAAE,OAAO,CAAA;KAAE;CAgBhC;AAED,4CAA4C;AAC5C,MAAM,WAAW,iBAAiB;IAChC,sCAAsC;IACtC,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,0DAA0D;IAC1D,IAAI,CAAC,EAAE,OAAO,CAAA;IACd,8EAA8E;IAC9E,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,sFAAsF;IACtF,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB;;;OAGG;IACH,MAAM,CAAC,EAAE,MAAM,EAAE,CAAA;IACjB;;;;;OAKG;IACH,aAAa,CAAC,EAAE,MAAM,EAAE,CAAA;CACzB;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,iBAAiB,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAEzD;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,YAAY,CAAC,OAAO,EAAE,MAAM,EAAE,UAAU,CAAC,EAAE,MAAM,GAAG,IAAI,GAAG,MAAM,CAQhF;AAED;;;;;;;;;GASG;AACH,wBAAgB,eAAe,CAAC,KAAK,EAAE,MAAM,GAAG,SAAS,GAAG,MAAM,CAoBjE;AAED,oDAAoD;AACpD,MAAM,WAAW,mBAAmB;IAClC,gFAAgF;IAChF,KAAK,EAAE,MAAM,MAAM,GAAG,SAAS,CAAA;IAC/B,gCAAgC;IAChC,OAAO,EAAE,MAAM,CAAA;IACf,wCAAwC;IACxC,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,mEAAmE;IACnE,SAAS,CAAC,EAAE,OAAO,KAAK,CAAA;IACxB,6DAA6D;IAC7D,KAAK,CAAC,EAAE,CAAC,EAAE,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAA;CACtC;AAgDD,kFAAkF;AAClF,wBAAgB,qBAAqB,IAAI,IAAI,CAG5C;AAED;;;;GAIG;AACH,qBAAa,YAAY;IACvB,OAAO,CAAC,QAAQ,CAAC,KAAK,CAA0B;IAChD,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAQ;IAChC,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAQ;IAClC,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAc;IACxC,OAAO,CAAC,QAAQ,CAAC,KAAK,CAA+B;IAErD;;;OAGG;gBACS,OAAO,EAAE,mBAAmB;IASxC;;;;;;;;;;OAUG;IACG,OAAO,CAAC,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,OAAO,GAAE,iBAAsB,GAAG,OAAO,CAAC,CAAC,GAAG,IAAI,CAAC;CAiFnF"}
package/dist/api.js CHANGED
@@ -145,6 +145,12 @@ function minRequestGapMs() {
145
145
  */
146
146
  let flyPacerTail = Promise.resolve();
147
147
  let flyLastRequestAt = 0;
148
+ /**
149
+ * Queues the caller behind the shared pacer chain and resolves once its slot
150
+ * arrives (at least {@link MIN_REQUEST_GAP_MS} after the previous request).
151
+ * @param sleep - Injected sleep, so tests can collapse the waits.
152
+ * @returns Resolves when the caller may fire its request.
153
+ */
148
154
  function paceFlyRequest(sleep) {
149
155
  const mine = flyPacerTail.then(async () => {
150
156
  const wait = Math.max(0, flyLastRequestAt + minRequestGapMs() - Date.now());
@@ -160,6 +166,11 @@ export function resetFlyPacerForTests() {
160
166
  flyPacerTail = Promise.resolve();
161
167
  flyLastRequestAt = 0;
162
168
  }
169
+ /**
170
+ * Minimal Fly Machines API client: bearer auth, JSON bodies, per-request
171
+ * timeout, bounded retries with backoff, and account-wide request pacing via
172
+ * {@link paceFlyRequest}.
173
+ */
163
174
  export class FlyApiClient {
164
175
  token;
165
176
  baseUrl;
package/dist/egress.d.ts CHANGED
@@ -46,6 +46,30 @@
46
46
  * and that `name` is mandatory — which is why {@link DEFAULT_EGRESS_POLICY_NAME}
47
47
  * is always sent.
48
48
  *
49
+ * **THE PORT LIST IS THE WHOLE POLICY — there is no destination, and this was
50
+ * measured rather than inferred (2026-08-16, live API, throwaway app).** Every
51
+ * plausible destination field was POSTed and read back: on a RULE,
52
+ * `ipv6_cidrs` / `ipv4_cidrs` / `cidrs` / `destinations` / `apps` / `app` / `to`
53
+ * / `dst` are all accepted by the decoder and DROPPED — the stored rule comes
54
+ * back as `{action, direction, ports}` and nothing else. On the SELECTOR (which
55
+ * chooses which Machines a policy covers, i.e. the SOURCE, never a destination)
56
+ * only `all`, `machines` and `metadata` persist; `apps` is the one field the API
57
+ * answers about, with `400 {"error":"apps array not currently supported in
58
+ * selectors"}`. So an `allow tcp/5432` rule permits that Machine to reach port
59
+ * 5432 on EVERY host on the internet, and no arrangement of this object can say
60
+ * otherwise. {@link describePublicReach} states that in the verdict rather than
61
+ * leaving an operator to deduce it.
62
+ *
63
+ * **The derived private-service ports ARE load-bearing** (the open question this
64
+ * module and FLY-OPERATOR-SETUP §9 both carried since 2026-08-05). Measured the
65
+ * same day: with a policy allowing only `udp:53`, a Machine on a custom 6PN
66
+ * could resolve `molecule-pg-tenant.flycast` and `molecule-api.flycast` but
67
+ * every TCP connect to them was dropped, and re-applying `tcp:443` re-opened
68
+ * only 443. Fly's "policies do not affect traffic routed through the Fly Proxy"
69
+ * is about INGRESS; a Machine's egress TOWARD a Flycast address is filtered like
70
+ * any other. Dropping a declared service's port would therefore take out every
71
+ * database connection in the fleet.
72
+ *
49
73
  * @module
50
74
  */
51
75
  import type { EgressVerdict } from '@molecule/api-code-sandbox';
@@ -83,10 +107,24 @@ export interface EgressProbeContext {
83
107
  targets: EgressProbeTarget[];
84
108
  /**
85
109
  * Ports this provider's own egress policy allows, or `undefined` when it
86
- * applies no policy at all. Used ONLY for the message and the remediation —
87
- * never to decide the verdict, which comes from the observation alone.
110
+ * applies no policy at all. Used for the message, the remediation, and as the
111
+ * INTENT half of the drift check against {@link appliedPolicyPorts} — never to
112
+ * decide `open` vs `filtered` from a probe that reached a target, which comes
113
+ * from the observation alone.
88
114
  */
89
115
  policyPorts?: FlyNetworkPolicyPort[];
116
+ /**
117
+ * Ports the policy Fly ACTUALLY holds for this app allows, read back from the
118
+ * API after the probe ran, or `undefined` when the readback failed or found no
119
+ * policy of this provider's name.
120
+ *
121
+ * This is the observation half of the only drift this module can catch: a
122
+ * policy widened outside the provider (by hand, by another tool, by an older
123
+ * build) allows ports nothing here configured, and the raw-connect probe
124
+ * cannot see it because it only attempts the targets it was given. See
125
+ * {@link unexpectedPolicyPorts}.
126
+ */
127
+ appliedPolicyPorts?: FlyNetworkPolicyPort[];
90
128
  }
91
129
  /**
92
130
  * Renders a target the way it is written in configuration, so an operator can
@@ -139,6 +177,41 @@ export declare function parseEgressAllowedPorts(raw: string | undefined): FlyNet
139
177
  * @returns A `sh`-safe command string.
140
178
  */
141
179
  export declare function buildEgressProbeCommand(targets: EgressProbeTarget[], timeoutMs: number): string;
180
+ /**
181
+ * States, in the verdict itself, what an allowed port actually permits.
182
+ *
183
+ * A Fly network policy has no destination (see the module description — measured
184
+ * against the live API, not inferred), so every allowed port is open to every
185
+ * host on the internet, not only to the private service it was derived for. That
186
+ * is the single most consequential thing about this mechanism and it was invisible
187
+ * everywhere it mattered: the probe attempts one port the policy denies, reports
188
+ * `filtered`, and an operator reasonably reads that as "nothing gets out".
189
+ *
190
+ * Naming it here means the residual is restated on every re-probe (the consumer
191
+ * re-verifies every 15 minutes) instead of living only in a document.
192
+ * @param policyPorts - Allowed ports, or `undefined` when no policy is applied.
193
+ * @returns A sentence naming the public reach, or `''` when there is nothing to
194
+ * say (no policy, or nothing but UDP/53 — DNS is a documented residual of its
195
+ * own and not a TCP channel).
196
+ */
197
+ export declare function describePublicReach(policyPorts: FlyNetworkPolicyPort[] | undefined): string;
198
+ /**
199
+ * Finds ports the policy Fly holds allows that this provider never configured.
200
+ *
201
+ * The raw-connect probe can only speak for the targets it was handed, so a
202
+ * policy widened outside this provider — edited by hand to unblock something, or
203
+ * left behind by an older build — is invisible to it: the probe's own port stays
204
+ * denied and the verdict stays `filtered` while a port nothing here asked for is
205
+ * open to the whole internet. Comparing what Fly holds against what this
206
+ * provider intended is the one check that can catch that, and it is why the
207
+ * verdict reads the policy back rather than trusting the write.
208
+ * @param applied - Ports in the policy Fly actually holds, or `undefined` when
209
+ * the readback found nothing (in which case there is nothing to compare).
210
+ * @param intended - Ports this provider configured, or `undefined` when it
211
+ * applies no policy.
212
+ * @returns The applied ports with no counterpart in `intended`, in order.
213
+ */
214
+ export declare function unexpectedPolicyPorts(applied: FlyNetworkPolicyPort[] | undefined, intended: FlyNetworkPolicyPort[] | undefined): FlyNetworkPolicyPort[];
142
215
  /**
143
216
  * Maps a probe's exit status onto an {@link EgressVerdict}.
144
217
  *
@@ -177,4 +250,26 @@ export declare function verdictForProbeExit(exitCode: number, context: EgressPro
177
250
  * @returns The matching policy's id, or `undefined`.
178
251
  */
179
252
  export declare function extractPolicyId(response: unknown, name: string): string | undefined;
253
+ /**
254
+ * Reads back the egress ports the policy Fly ACTUALLY holds allows.
255
+ *
256
+ * The write is not the observation: this provider POSTs a policy, but what
257
+ * governs a Machine is whatever Fly has when that Machine boots — which may
258
+ * differ because a person, another tool or an older build changed it. Reading it
259
+ * back is what lets {@link unexpectedPolicyPorts} catch a policy wider than the
260
+ * one this provider configured, so `verifyEgress()` can fail on drift its raw
261
+ * connects were never going to attempt.
262
+ *
263
+ * Parsed as defensively as {@link extractPolicyId}, and for the same reason:
264
+ * neither Fly document specifies the LIST response shape. The live API answers
265
+ * with a bare array whose selector key is `netpolSelector` rather than the
266
+ * `selector` it accepts (verified 2026-08-16), which is exactly why nothing here
267
+ * depends on any key but `name` and `rules`.
268
+ * @param response - The raw parsed LIST response.
269
+ * @param name - The policy name to match.
270
+ * @returns The allowed egress ports, or `undefined` when no policy of that name
271
+ * is present or the shape is unrecognized. `undefined` means "nothing to
272
+ * compare", never "nothing is allowed".
273
+ */
274
+ export declare function extractPolicyPorts(response: unknown, name: string): FlyNetworkPolicyPort[] | undefined;
180
275
  //# sourceMappingURL=egress.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"egress.d.ts","sourceRoot":"","sources":["../src/egress.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAiDG;AAEH,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,4BAA4B,CAAA;AAE/D,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,YAAY,CAAA;AAGtD,gFAAgF;AAChF,eAAO,MAAM,0BAA0B,4BAA4B,CAAA;AAEnE;;;;GAIG;AACH,eAAO,MAAM,yBAAyB,IAAI,CAAA;AAE1C,uFAAuF;AACvF,eAAO,MAAM,yBAAyB,IAAI,CAAA;AAE1C;;;;;;GAMG;AACH,eAAO,MAAM,4BAA4B,2CAA2C,CAAA;AAEpF,wEAAwE;AACxE,MAAM,WAAW,iBAAiB;IAChC,8FAA8F;IAC9F,IAAI,EAAE,MAAM,CAAA;IACZ,gBAAgB;IAChB,IAAI,EAAE,MAAM,CAAA;CACb;AAED,qFAAqF;AACrF,MAAM,WAAW,kBAAkB;IACjC,wCAAwC;IACxC,GAAG,EAAE,MAAM,CAAA;IACX,mCAAmC;IACnC,OAAO,EAAE,iBAAiB,EAAE,CAAA;IAC5B;;;;OAIG;IACH,WAAW,CAAC,EAAE,oBAAoB,EAAE,CAAA;CACrC;AAQD;;;;;GAKG;AACH,wBAAgB,uBAAuB,CAAC,MAAM,EAAE,iBAAiB,GAAG,MAAM,CAGzE;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAgB,uBAAuB,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,EAAE,GAAG,SAAS,GAAG,iBAAiB,EAAE,CAc/F;AAED;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,uBAAuB,CACrC,GAAG,EAAE,MAAM,GAAG,SAAS,GACtB,oBAAoB,EAAE,GAAG,SAAS,CAyBpC;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,uBAAuB,CAAC,OAAO,EAAE,iBAAiB,EAAE,EAAE,SAAS,EAAE,MAAM,GAAG,MAAM,CAY/F;AAaD;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,wBAAgB,mBAAmB,CAAC,QAAQ,EAAE,MAAM,EAAE,OAAO,EAAE,kBAAkB,GAAG,aAAa,CA0ChG;AAWD;;;;;;;;;;;;;GAaG;AACH,wBAAgB,eAAe,CAAC,QAAQ,EAAE,OAAO,EAAE,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAanF"}
1
+ {"version":3,"file":"egress.d.ts","sourceRoot":"","sources":["../src/egress.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAyEG;AAEH,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,4BAA4B,CAAA;AAE/D,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,YAAY,CAAA;AAGtD,gFAAgF;AAChF,eAAO,MAAM,0BAA0B,4BAA4B,CAAA;AAcnE;;;;GAIG;AACH,eAAO,MAAM,yBAAyB,IAAI,CAAA;AAE1C,uFAAuF;AACvF,eAAO,MAAM,yBAAyB,IAAI,CAAA;AAE1C;;;;;;GAMG;AACH,eAAO,MAAM,4BAA4B,2CAA2C,CAAA;AAEpF,wEAAwE;AACxE,MAAM,WAAW,iBAAiB;IAChC,8FAA8F;IAC9F,IAAI,EAAE,MAAM,CAAA;IACZ,gBAAgB;IAChB,IAAI,EAAE,MAAM,CAAA;CACb;AAED,qFAAqF;AACrF,MAAM,WAAW,kBAAkB;IACjC,wCAAwC;IACxC,GAAG,EAAE,MAAM,CAAA;IACX,mCAAmC;IACnC,OAAO,EAAE,iBAAiB,EAAE,CAAA;IAC5B;;;;;;OAMG;IACH,WAAW,CAAC,EAAE,oBAAoB,EAAE,CAAA;IACpC;;;;;;;;;;OAUG;IACH,kBAAkB,CAAC,EAAE,oBAAoB,EAAE,CAAA;CAC5C;AAQD;;;;;GAKG;AACH,wBAAgB,uBAAuB,CAAC,MAAM,EAAE,iBAAiB,GAAG,MAAM,CAGzE;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAgB,uBAAuB,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,EAAE,GAAG,SAAS,GAAG,iBAAiB,EAAE,CAc/F;AAED;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,uBAAuB,CACrC,GAAG,EAAE,MAAM,GAAG,SAAS,GACtB,oBAAoB,EAAE,GAAG,SAAS,CAyBpC;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,uBAAuB,CAAC,OAAO,EAAE,iBAAiB,EAAE,EAAE,SAAS,EAAE,MAAM,GAAG,MAAM,CAY/F;AAqBD;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAgB,mBAAmB,CAAC,WAAW,EAAE,oBAAoB,EAAE,GAAG,SAAS,GAAG,MAAM,CAW3F;AAED;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,qBAAqB,CACnC,OAAO,EAAE,oBAAoB,EAAE,GAAG,SAAS,EAC3C,QAAQ,EAAE,oBAAoB,EAAE,GAAG,SAAS,GAC3C,oBAAoB,EAAE,CAMxB;AAED;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,wBAAgB,mBAAmB,CAAC,QAAQ,EAAE,MAAM,EAAE,OAAO,EAAE,kBAAkB,GAAG,aAAa,CAgEhG;AAWD;;;;;;;;;;;;;GAaG;AACH,wBAAgB,eAAe,CAAC,QAAQ,EAAE,OAAO,EAAE,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAGnF;AAED;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,wBAAgB,kBAAkB,CAChC,QAAQ,EAAE,OAAO,EACjB,IAAI,EAAE,MAAM,GACX,oBAAoB,EAAE,GAAG,SAAS,CAiBpC"}