@molecule/api-code-sandbox-flyio 1.0.1

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/dist/api.d.ts ADDED
@@ -0,0 +1,140 @@
1
+ /**
2
+ * Minimal HTTP client for the Fly Machines API.
3
+ *
4
+ * Speaks the documented REST surface at `https://api.machines.dev/v1` with a
5
+ * `Authorization: Bearer <token>` header
6
+ * (https://fly.io/docs/machines/api/working-with-machines-api/).
7
+ *
8
+ * The one non-obvious responsibility here is RATE LIMITING. Fly documents the
9
+ * Machines API at "1 request, per second, per action (i.e. Create Machine, Start
10
+ * Machine etc.) — with a short-term burst limit up to 3 req/s, per action", and
11
+ * 5 req/s (burst 10) for Get Machine. A sandbox boot issues a burst of calls
12
+ * (ensure app → create volume → create machine → wait → several execs), so a
13
+ * client with no 429 handling WILL trip that limit under concurrent boots and
14
+ * fail the whole boot. Retries are therefore part of the transport, not the
15
+ * caller's problem.
16
+ *
17
+ * @module
18
+ */
19
+ /**
20
+ * An error from the Fly Machines API. Carries the HTTP status and the raw
21
+ * response body so callers can branch on `404` (absent) or `409` (conflict)
22
+ * without string-matching a message.
23
+ */
24
+ export declare class FlyApiError extends Error {
25
+ /** HTTP status code, or 0 for a transport-level failure (timeout, DNS, reset). */
26
+ readonly status: number;
27
+ /** Raw response body text, truncated to a sane length for logging. */
28
+ readonly body: string;
29
+ /** HTTP method of the failed request. */
30
+ readonly method: string;
31
+ /** API path of the failed request (no base URL). */
32
+ readonly path: string;
33
+ /**
34
+ * Creates a Fly API error.
35
+ * @param method - HTTP method of the failed request.
36
+ * @param path - API path of the failed request.
37
+ * @param status - HTTP status code, or 0 for a transport failure.
38
+ * @param body - Raw response body text.
39
+ * @param options - Standard error options (used to carry a `cause`).
40
+ */
41
+ constructor(method: string, path: string, status: number, body: string, options?: {
42
+ cause?: unknown;
43
+ });
44
+ }
45
+ /** Options for a single Fly API request. */
46
+ export interface FlyRequestOptions {
47
+ /** HTTP method. Defaults to `GET`. */
48
+ method?: string;
49
+ /** JSON request body. Omitted entirely when undefined. */
50
+ body?: unknown;
51
+ /** Per-request timeout in ms. Defaults to the client's configured timeout. */
52
+ timeoutMs?: number;
53
+ /** Max attempts for a retryable failure. Defaults to 4. Pass 1 to disable retries. */
54
+ attempts?: number;
55
+ /**
56
+ * Treat these HTTP statuses as a successful `null` result instead of throwing.
57
+ * Used for idempotent deletes and existence checks (`[404]`).
58
+ */
59
+ nullOn?: number[];
60
+ }
61
+ /**
62
+ * Decides whether a failed attempt is worth repeating.
63
+ *
64
+ * `429` (the documented per-action rate limit) and `5xx` are transient — the
65
+ * request was well-formed and the same call can succeed moments later. Every
66
+ * other `4xx` is a real answer (no such app, name taken, bad token) and
67
+ * retrying it only wastes the rate-limit budget that the retry exists to
68
+ * protect. Status `0` means the transport failed before any answer arrived.
69
+ * @param status - HTTP status, or 0 for a transport-level failure.
70
+ * @returns `true` when the request should be retried.
71
+ */
72
+ export declare function isRetryableStatus(status: number): boolean;
73
+ /**
74
+ * Resolves how long to wait before the next attempt.
75
+ *
76
+ * Honors a `Retry-After` header when the server sent one (Fly returns it on
77
+ * 429), clamped to {@link MAX_RETRY_DELAY_MS}; otherwise uses exponential
78
+ * backoff from a 500 ms base. Fly's documented budget is one request per second
79
+ * per action, so the first backoff step deliberately exceeds a second.
80
+ * @param attempt - The 1-based attempt number that just failed.
81
+ * @param retryAfter - Raw `Retry-After` header value, if present.
82
+ * @returns The delay in milliseconds before the next attempt.
83
+ */
84
+ export declare function retryDelayMs(attempt: number, retryAfter?: string | null): number;
85
+ /**
86
+ * Normalizes a configured API base into a URL prefix ending in `/v1`.
87
+ *
88
+ * `FLY_API_HOSTNAME` is documented as a bare host (`https://api.machines.dev`,
89
+ * or `http://_api.internal:4280` from inside a Fly private network), so a value
90
+ * with no path gets `/v1` appended. A value that already carries a path is used
91
+ * verbatim, which is how an operator points at a proxy or a pinned version.
92
+ * @param value - Configured base URL, or undefined.
93
+ * @returns The base URL with no trailing slash.
94
+ */
95
+ export declare function normalizeApiUrl(value: string | undefined): string;
96
+ /** Constructor options for {@link FlyApiClient}. */
97
+ export interface FlyApiClientOptions {
98
+ /** Bearer token. Resolved lazily so env/secrets can land after construction. */
99
+ token: () => string | undefined;
100
+ /** Base URL including `/v1`. */
101
+ baseUrl: string;
102
+ /** Default per-request timeout (ms). */
103
+ timeoutMs?: number;
104
+ /** Injectable fetch, for tests. Defaults to the global `fetch`. */
105
+ fetchImpl?: typeof fetch;
106
+ /** Injectable sleep, for tests. Defaults to `setTimeout`. */
107
+ sleep?: (ms: number) => Promise<void>;
108
+ }
109
+ /**
110
+ * Thin, retrying JSON client for the Fly Machines API.
111
+ *
112
+ * Deliberately not a generated SDK: this provider touches ~12 endpoints, and a
113
+ * generated client would drag in the whole Machines + Postgres + tokens surface
114
+ * for no benefit.
115
+ */
116
+ export declare class FlyApiClient {
117
+ private readonly token;
118
+ private readonly baseUrl;
119
+ private readonly timeoutMs;
120
+ private readonly fetchImpl;
121
+ private readonly sleep;
122
+ /**
123
+ * Creates a Fly Machines API client.
124
+ * @param options - Token resolver, base URL, and injectable fetch/sleep.
125
+ */
126
+ constructor(options: FlyApiClientOptions);
127
+ /**
128
+ * Issues a request against the Machines API and parses the JSON response.
129
+ *
130
+ * @template T - Expected shape of the parsed response body.
131
+ * @param path - API path relative to the base URL, e.g. `/apps/foo/machines`.
132
+ * @param options - Method, body, timeout, attempts, and statuses to null on.
133
+ * @returns The parsed body, or `null` for an empty body or a status listed in
134
+ * `options.nullOn`.
135
+ * @throws {FlyApiError} On a non-2xx status that is not listed in `nullOn`,
136
+ * or when every retryable attempt is exhausted.
137
+ */
138
+ request<T>(path: string, options?: FlyRequestOptions): Promise<T | null>;
139
+ }
140
+ //# sourceMappingURL=api.d.ts.map
@@ -0,0 +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;CAClB;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;AAED;;;;;;GAMG;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;CA4EnF"}
package/dist/api.js ADDED
@@ -0,0 +1,226 @@
1
+ /**
2
+ * Minimal HTTP client for the Fly Machines API.
3
+ *
4
+ * Speaks the documented REST surface at `https://api.machines.dev/v1` with a
5
+ * `Authorization: Bearer <token>` header
6
+ * (https://fly.io/docs/machines/api/working-with-machines-api/).
7
+ *
8
+ * The one non-obvious responsibility here is RATE LIMITING. Fly documents the
9
+ * Machines API at "1 request, per second, per action (i.e. Create Machine, Start
10
+ * Machine etc.) — with a short-term burst limit up to 3 req/s, per action", and
11
+ * 5 req/s (burst 10) for Get Machine. A sandbox boot issues a burst of calls
12
+ * (ensure app → create volume → create machine → wait → several execs), so a
13
+ * client with no 429 handling WILL trip that limit under concurrent boots and
14
+ * fail the whole boot. Retries are therefore part of the transport, not the
15
+ * caller's problem.
16
+ *
17
+ * @module
18
+ */
19
+ import { getLogger } from '@molecule/api-bond';
20
+ import { t } from '@molecule/api-i18n';
21
+ const logger = getLogger();
22
+ /** Default per-request timeout (ms). */
23
+ const DEFAULT_REQUEST_TIMEOUT_MS = 30_000;
24
+ /** Default number of attempts for a retryable failure. */
25
+ const DEFAULT_ATTEMPTS = 4;
26
+ /** Upper bound on a `Retry-After`-derived delay, so a hostile header cannot stall a boot. */
27
+ const MAX_RETRY_DELAY_MS = 10_000;
28
+ /**
29
+ * An error from the Fly Machines API. Carries the HTTP status and the raw
30
+ * response body so callers can branch on `404` (absent) or `409` (conflict)
31
+ * without string-matching a message.
32
+ */
33
+ export class FlyApiError extends Error {
34
+ /** HTTP status code, or 0 for a transport-level failure (timeout, DNS, reset). */
35
+ status;
36
+ /** Raw response body text, truncated to a sane length for logging. */
37
+ body;
38
+ /** HTTP method of the failed request. */
39
+ method;
40
+ /** API path of the failed request (no base URL). */
41
+ path;
42
+ /**
43
+ * Creates a Fly API error.
44
+ * @param method - HTTP method of the failed request.
45
+ * @param path - API path of the failed request.
46
+ * @param status - HTTP status code, or 0 for a transport failure.
47
+ * @param body - Raw response body text.
48
+ * @param options - Standard error options (used to carry a `cause`).
49
+ */
50
+ constructor(method, path, status, body, options) {
51
+ super(t('codeSandbox.flyio.error.apiError', { method, path, status: String(status), error: body }, { defaultValue: `Fly API ${method} ${path}: ${status} ${body}` }), options);
52
+ this.name = 'FlyApiError';
53
+ this.status = status;
54
+ this.body = body;
55
+ this.method = method;
56
+ this.path = path;
57
+ }
58
+ }
59
+ /**
60
+ * Decides whether a failed attempt is worth repeating.
61
+ *
62
+ * `429` (the documented per-action rate limit) and `5xx` are transient — the
63
+ * request was well-formed and the same call can succeed moments later. Every
64
+ * other `4xx` is a real answer (no such app, name taken, bad token) and
65
+ * retrying it only wastes the rate-limit budget that the retry exists to
66
+ * protect. Status `0` means the transport failed before any answer arrived.
67
+ * @param status - HTTP status, or 0 for a transport-level failure.
68
+ * @returns `true` when the request should be retried.
69
+ */
70
+ export function isRetryableStatus(status) {
71
+ return status === 0 || status === 429 || status >= 500;
72
+ }
73
+ /**
74
+ * Resolves how long to wait before the next attempt.
75
+ *
76
+ * Honors a `Retry-After` header when the server sent one (Fly returns it on
77
+ * 429), clamped to {@link MAX_RETRY_DELAY_MS}; otherwise uses exponential
78
+ * backoff from a 500 ms base. Fly's documented budget is one request per second
79
+ * per action, so the first backoff step deliberately exceeds a second.
80
+ * @param attempt - The 1-based attempt number that just failed.
81
+ * @param retryAfter - Raw `Retry-After` header value, if present.
82
+ * @returns The delay in milliseconds before the next attempt.
83
+ */
84
+ export function retryDelayMs(attempt, retryAfter) {
85
+ if (retryAfter) {
86
+ const seconds = Number(retryAfter);
87
+ if (Number.isFinite(seconds) && seconds >= 0) {
88
+ return Math.min(seconds * 1000, MAX_RETRY_DELAY_MS);
89
+ }
90
+ }
91
+ return Math.min(1000 * 2 ** (attempt - 1), MAX_RETRY_DELAY_MS);
92
+ }
93
+ /**
94
+ * Normalizes a configured API base into a URL prefix ending in `/v1`.
95
+ *
96
+ * `FLY_API_HOSTNAME` is documented as a bare host (`https://api.machines.dev`,
97
+ * or `http://_api.internal:4280` from inside a Fly private network), so a value
98
+ * with no path gets `/v1` appended. A value that already carries a path is used
99
+ * verbatim, which is how an operator points at a proxy or a pinned version.
100
+ * @param value - Configured base URL, or undefined.
101
+ * @returns The base URL with no trailing slash.
102
+ */
103
+ export function normalizeApiUrl(value) {
104
+ const raw = value?.trim();
105
+ if (!raw)
106
+ return 'https://api.machines.dev/v1';
107
+ const withoutSlash = raw.replace(/\/+$/, '');
108
+ try {
109
+ const url = new URL(withoutSlash);
110
+ if (url.pathname === '' || url.pathname === '/')
111
+ return `${withoutSlash}/v1`;
112
+ return withoutSlash;
113
+ }
114
+ catch (error) {
115
+ // Not an absolute URL — the operator gave something we cannot safely extend.
116
+ // Surface it instead of silently guessing a base for every subsequent call.
117
+ throw new Error(t('codeSandbox.flyio.error.badApiUrl', { value: raw }, { defaultValue: `Invalid Fly API URL "${raw}" — expected an absolute URL.` }), { cause: error });
118
+ }
119
+ }
120
+ /**
121
+ * Thin, retrying JSON client for the Fly Machines API.
122
+ *
123
+ * Deliberately not a generated SDK: this provider touches ~12 endpoints, and a
124
+ * generated client would drag in the whole Machines + Postgres + tokens surface
125
+ * for no benefit.
126
+ */
127
+ export class FlyApiClient {
128
+ token;
129
+ baseUrl;
130
+ timeoutMs;
131
+ fetchImpl;
132
+ sleep;
133
+ /**
134
+ * Creates a Fly Machines API client.
135
+ * @param options - Token resolver, base URL, and injectable fetch/sleep.
136
+ */
137
+ constructor(options) {
138
+ this.token = options.token;
139
+ this.baseUrl = options.baseUrl;
140
+ this.timeoutMs = options.timeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
141
+ this.fetchImpl = options.fetchImpl ?? globalThis.fetch;
142
+ this.sleep =
143
+ options.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
144
+ }
145
+ /**
146
+ * Issues a request against the Machines API and parses the JSON response.
147
+ *
148
+ * @template T - Expected shape of the parsed response body.
149
+ * @param path - API path relative to the base URL, e.g. `/apps/foo/machines`.
150
+ * @param options - Method, body, timeout, attempts, and statuses to null on.
151
+ * @returns The parsed body, or `null` for an empty body or a status listed in
152
+ * `options.nullOn`.
153
+ * @throws {FlyApiError} On a non-2xx status that is not listed in `nullOn`,
154
+ * or when every retryable attempt is exhausted.
155
+ */
156
+ async request(path, options = {}) {
157
+ const method = options.method ?? 'GET';
158
+ const attempts = options.attempts ?? DEFAULT_ATTEMPTS;
159
+ const token = this.token();
160
+ if (!token) {
161
+ throw new Error(t('codeSandbox.flyio.error.noToken', undefined, {
162
+ defaultValue: 'No Fly API token — set FLY_API_TOKEN (or FLY_ACCESS_TOKEN), or pass config.apiToken.',
163
+ }));
164
+ }
165
+ let lastError;
166
+ for (let attempt = 1; attempt <= attempts; attempt++) {
167
+ const controller = new AbortController();
168
+ const timer = setTimeout(() => controller.abort(), options.timeoutMs ?? this.timeoutMs);
169
+ // Initialized to 0 ("no answer received"), which is the value that
170
+ // stands when the request throws before a response arrives.
171
+ let status = 0;
172
+ let retryAfter = null;
173
+ try {
174
+ const response = await this.fetchImpl(`${this.baseUrl}${path}`, {
175
+ method,
176
+ headers: {
177
+ Authorization: `Bearer ${token}`,
178
+ ...(options.body === undefined ? {} : { 'Content-Type': 'application/json' }),
179
+ },
180
+ ...(options.body === undefined ? {} : { body: JSON.stringify(options.body) }),
181
+ signal: controller.signal,
182
+ });
183
+ status = response.status;
184
+ retryAfter = response.headers?.get?.('retry-after') ?? null;
185
+ const text = await response.text();
186
+ if (status >= 200 && status < 300) {
187
+ if (!text.trim())
188
+ return null;
189
+ try {
190
+ return JSON.parse(text);
191
+ }
192
+ catch (error) {
193
+ // A 2xx with a non-JSON body is a real protocol surprise (an
194
+ // intercepting proxy, an HTML error page behind a 200). Refusing it
195
+ // loudly beats handing the caller a silently-empty result.
196
+ throw new FlyApiError(method, path, status, text.slice(0, 500), { cause: error });
197
+ }
198
+ }
199
+ if (options.nullOn?.includes(status))
200
+ return null;
201
+ lastError = new FlyApiError(method, path, status, text.slice(0, 500));
202
+ }
203
+ catch (error) {
204
+ if (error instanceof FlyApiError)
205
+ throw error;
206
+ // Transport-level failure (abort/timeout, DNS, connection reset) — no
207
+ // status was ever received, so it is modelled as status 0 and retried.
208
+ lastError = new FlyApiError(method, path, 0, error instanceof Error ? error.message : String(error), { cause: error });
209
+ }
210
+ finally {
211
+ clearTimeout(timer);
212
+ }
213
+ if (!isRetryableStatus(status) || attempt === attempts)
214
+ break;
215
+ const delay = retryDelayMs(attempt, retryAfter);
216
+ logger.warn(`Fly API transient failure on ${method} ${path} — retrying`, {
217
+ attempt,
218
+ attempts,
219
+ status,
220
+ delay,
221
+ });
222
+ await this.sleep(delay);
223
+ }
224
+ throw lastError ?? new FlyApiError(method, path, 0, 'unknown failure');
225
+ }
226
+ }
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=browser-guard.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"browser-guard.d.ts","sourceRoot":"","sources":["../src/browser-guard.ts"],"names":[],"mappings":"AAuBA,OAAO,EAAE,CAAA"}
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Browser guard — `@molecule/api-code-sandbox-flyio` is SERVER-ONLY.
3
+ *
4
+ * Generated by scripts/gen-browser-guards.mjs (workspace root) — edit THAT, not this.
5
+ * Evaluating a server package in a browser bundle is always an import-graph mistake
6
+ * (node APIs, secrets); without this guard it surfaces as a cryptic downstream crash
7
+ * ("Buffer is not defined") far from the culprit. Throwing here names the package and
8
+ * the fix at the exact moment the client bundle evaluates it. jsdom tests and SSR are
9
+ * unaffected: the throw requires browser globals AND the absence of a node runtime.
10
+ */
11
+ const g = globalThis;
12
+ if (g.window !== undefined && g.document !== undefined && !g.process?.versions?.node) {
13
+ throw new Error('@molecule/api-code-sandbox-flyio is SERVER-ONLY: it was bundled into browser/client code. Import it only ' +
14
+ 'from server code (a server route/function or your API), or dynamic-import it inside ' +
15
+ 'the server handler — never from components or shared client modules, and never ' +
16
+ 'polyfill Buffer/process to silence this.');
17
+ }
18
+ export {};
@@ -0,0 +1,180 @@
1
+ /**
2
+ * Fly network policies — the only egress restriction Fly.io exposes — and the
3
+ * pure logic behind the observation that proves one is actually in force.
4
+ *
5
+ * **What Fly actually offers.** 6PN custom private networks isolate apps from
6
+ * EACH OTHER ("Apps on separate 6PNs can never communicate unless explicitly
7
+ * configured to do so",
8
+ * https://fly.io/docs/networking/custom-private-networks/); they say nothing
9
+ * about reaching the public internet, which is a different question and the one
10
+ * `verifyEgress()` answers. The mechanism Fly documents for that is **network
11
+ * policies** (https://fly.io/docs/machines/guides-examples/network-policies/,
12
+ * announced at https://community.fly.io/t/new-feature-network-policies/19173):
13
+ * an app-scoped object with a `selector` and `allow` rules, where "Once you
14
+ * create a rule for a given direction, the default for that direction becomes
15
+ * drop."
16
+ *
17
+ * Three properties of that mechanism drive everything below, and every one of
18
+ * them is a limitation you cannot design around:
19
+ *
20
+ * 1. **Rules match protocol + port ONLY.** There is no host, IP or CIDR
21
+ * matching, and (as of the announcement) no port ranges. A network policy
22
+ * therefore CANNOT express a host allowlist. `allow tcp/443` permits a
23
+ * sandbox to reach every host on the internet that listens on 443.
24
+ * 2. **They do not apply to Fly Proxy traffic** — stated in the docs and, in
25
+ * the announcement, in capitals. Inbound preview traffic through the proxy
26
+ * is unaffected by an ingress rule.
27
+ * 3. **They take effect at Machine boot.** Fly's own troubleshooting note:
28
+ * "After creating or updating a policy, restart or redeploy the Machines for
29
+ * changes to take effect." A policy applied to an app with an already-running
30
+ * Machine does not retroactively cover it.
31
+ *
32
+ * The endpoints are documented in the guide and the announcement but are NOT in
33
+ * Fly's OpenAPI specification (https://docs.machines.dev/openapi.json — 68
34
+ * paths, none of them `network_policies`), so the request shape below is taken
35
+ * from those two documents verbatim and the LIST response shape, which neither
36
+ * document specifies, is parsed defensively by {@link extractPolicyId}.
37
+ *
38
+ * VERIFIED AGAINST A REAL FLY ORG, 2026-08-05 — the endpoints DO exist despite
39
+ * their absence from the spec:
40
+ *
41
+ * GET /apps/{app}/network_policies -> 200 []
42
+ * GET /apps/{app}/network_policies/ -> 404 (trailing slash is NOT accepted)
43
+ * POST /apps/{app}/network_policies -> 400 {"error":"name is required"}
44
+ *
45
+ * The POST error is the useful one: it proves the route is real and validating,
46
+ * and that `name` is mandatory — which is why {@link DEFAULT_EGRESS_POLICY_NAME}
47
+ * is always sent.
48
+ *
49
+ * @module
50
+ */
51
+ import type { EgressVerdict } from '@molecule/api-code-sandbox';
52
+ import type { FlyNetworkPolicyPort } from './types.js';
53
+ /** Default name of the egress policy this provider owns on each sandbox app. */
54
+ export declare const DEFAULT_EGRESS_POLICY_NAME = "molecule-sandbox-egress";
55
+ /**
56
+ * Probe exit status meaning at least one raw connection to a public IP
57
+ * SUCCEEDED. Deliberately not 1, so an interpreter error (which exits 1) can
58
+ * never be mistaken for a completed observation.
59
+ */
60
+ export declare const EGRESS_PROBE_EXIT_REACHED = 9;
61
+ /** Probe exit status meaning every raw connection attempt was refused or timed out. */
62
+ export declare const EGRESS_PROBE_EXIT_BLOCKED = 0;
63
+ /**
64
+ * Default probe targets: one IPv4 and one IPv6 literal, both anycast resolvers
65
+ * that answer on 443 from anywhere. IPv6 is included because Fly Machines have
66
+ * native public IPv6 egress and "often egress over IPv6 when the destination
67
+ * has an AAAA record" (https://fly.io/docs/networking/egress-ips/) — probing
68
+ * only IPv4 could miss an open v6 path entirely.
69
+ */
70
+ export declare const DEFAULT_EGRESS_PROBE_TARGETS = "1.1.1.1:443,[2606:4700:4700::1111]:443";
71
+ /** One literal host:port the probe attempts a raw TCP connection to. */
72
+ export interface EgressProbeTarget {
73
+ /** A literal IPv4 or IPv6 address. Never a hostname — see {@link parseEgressProbeTargets}. */
74
+ host: string;
75
+ /** TCP port. */
76
+ port: number;
77
+ }
78
+ /** Context {@link verdictForProbeExit} renders its operator-facing messages from. */
79
+ export interface EgressProbeContext {
80
+ /** Fly app the probe Machine ran in. */
81
+ app: string;
82
+ /** Targets the probe attempted. */
83
+ targets: EgressProbeTarget[];
84
+ /**
85
+ * 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.
88
+ */
89
+ policyPorts?: FlyNetworkPolicyPort[];
90
+ }
91
+ /**
92
+ * Renders a target the way it is written in configuration, so an operator can
93
+ * paste it straight back.
94
+ * @param target - The parsed target.
95
+ * @returns `host:port`, with IPv6 hosts bracketed.
96
+ */
97
+ export declare function formatEgressProbeTarget(target: EgressProbeTarget): string;
98
+ /**
99
+ * Parses probe targets from configuration.
100
+ *
101
+ * Only LITERAL addresses are accepted, never hostnames — Fly's own
102
+ * troubleshooting guidance for testing a policy is to "use direct IP addresses
103
+ * (not hostnames) to test blocked traffic to avoid DNS masking", and a probe
104
+ * that failed at name resolution would report a blocked connection it never
105
+ * actually attempted.
106
+ * @param raw - Comma-separated `ip:port` string, or an array of them. IPv6
107
+ * literals must be bracketed (`[2606:4700:4700::1111]:443`).
108
+ * @returns The valid targets, in order. Invalid entries are dropped rather than
109
+ * throwing: the caller turns an empty result into an `inconclusive` verdict,
110
+ * which is the honest outcome for "nothing to probe".
111
+ */
112
+ export declare function parseEgressProbeTargets(raw: string | string[] | undefined): EgressProbeTarget[];
113
+ /**
114
+ * Parses an allowed-port list for the egress network policy.
115
+ *
116
+ * An EMPTY list is deliberately not expressible. Fly documents the deny default
117
+ * as a consequence of at least one `allow` rule existing for a direction; what
118
+ * an allow rule with zero ports does is not documented anywhere, so a provider
119
+ * that sent one would be guessing at a security control. An operator who wants
120
+ * near-total denial allows a single port nothing in the sandbox uses.
121
+ * @param raw - Comma-separated `protocol:port` pairs, e.g. `tcp:3128,udp:53`.
122
+ * A bare port is treated as TCP.
123
+ * @returns The parsed ports, or `undefined` when the input is absent or empty
124
+ * (meaning "apply no policy"), which is NOT the same as "deny everything".
125
+ * @throws {Error} When an entry is present but unparseable — silently dropping a
126
+ * port from a security control would produce a policy the operator never wrote.
127
+ */
128
+ export declare function parseEgressAllowedPorts(raw: string | undefined): FlyNetworkPolicyPort[] | undefined;
129
+ /**
130
+ * Builds the shell command that observes egress from inside a Machine.
131
+ *
132
+ * Raw `net.connect` on purpose: an HTTP client would honour proxy environment
133
+ * variables and report the PROXY's policy instead of the network's. Every target
134
+ * is attempted in parallel and the process exits
135
+ * {@link EGRESS_PROBE_EXIT_REACHED} if any connected,
136
+ * {@link EGRESS_PROBE_EXIT_BLOCKED} if all were refused or timed out.
137
+ * @param targets - Literal targets to attempt.
138
+ * @param timeoutMs - Per-connection timeout.
139
+ * @returns A `sh`-safe command string.
140
+ */
141
+ export declare function buildEgressProbeCommand(targets: EgressProbeTarget[], timeoutMs: number): string;
142
+ /**
143
+ * Maps a probe's exit status onto an {@link EgressVerdict}.
144
+ *
145
+ * The mapping is the whole security contract of this file, so it is total and
146
+ * has exactly one path to each state:
147
+ *
148
+ * | Exit | Verdict | Meaning |
149
+ * |---|---|---|
150
+ * | {@link EGRESS_PROBE_EXIT_REACHED} | `open` | A raw socket reached a public IP. |
151
+ * | {@link EGRESS_PROBE_EXIT_BLOCKED} | `filtered` | Every attempt was refused or timed out. |
152
+ * | anything else | `inconclusive` | The probe did not complete its attempts. |
153
+ *
154
+ * Every other outcome — a missing interpreter (127), a killed process, Fly
155
+ * returning no status at all (-1), an API failure before the probe ever ran — is
156
+ * `inconclusive`. Collapsing any of those into `filtered` is precisely the "I
157
+ * could not look" ⇒ "I looked and it is safe" conflation the capability exists
158
+ * to prevent, and the caller refuses to boot production on anything but
159
+ * `filtered`.
160
+ * @param exitCode - The probe process's exit status.
161
+ * @param context - App, targets and the policy this provider applied.
162
+ * @returns The verdict.
163
+ */
164
+ export declare function verdictForProbeExit(exitCode: number, context: EgressProbeContext): EgressVerdict;
165
+ /**
166
+ * Finds an existing policy's id in a LIST response, so the next write updates it
167
+ * instead of stacking duplicates.
168
+ *
169
+ * Neither the guide nor the announcement specifies what
170
+ * `GET /v1/apps/{app}/network_policies/` returns, and the endpoint is absent
171
+ * from Fly's OpenAPI specification — so this accepts a bare array or an object
172
+ * wrapping one under any of the plausible keys, and returns `undefined` for
173
+ * anything it does not recognize. `undefined` is safe: the caller then POSTs
174
+ * without an `id`, which still applies the policy.
175
+ * @param response - The raw parsed LIST response.
176
+ * @param name - The policy name to match.
177
+ * @returns The matching policy's id, or `undefined`.
178
+ */
179
+ export declare function extractPolicyId(response: unknown, name: string): string | undefined;
180
+ //# sourceMappingURL=egress.d.ts.map
@@ -0,0 +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"}