@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.
@@ -0,0 +1,173 @@
1
+ /**
2
+ * Flycast private routes — the one mechanism Fly documents for reaching an app
3
+ * ACROSS custom 6PN private networks, and the pure logic behind wiring it per
4
+ * project.
5
+ *
6
+ * **The problem this solves.** This provider puts every project's sandbox app on
7
+ * its own custom 6PN, which is what makes tenants unable to reach each other:
8
+ * "Apps on separate 6PNs can never communicate unless explicitly configured to
9
+ * do so" (https://fly.io/docs/networking/custom-private-networks/). The same
10
+ * sentence severs two paths the product REQUIRES — a sandbox reaching its own
11
+ * `mol_<uuid>` database on the tenant Postgres cluster, and a sandbox reaching
12
+ * the control plane's egress proxy. Neither is optional: a scaffolded app
13
+ * connects to `DATABASE_URL` at startup, and the proxy is where the per-project
14
+ * host allowlist is enforced.
15
+ *
16
+ * **The mechanism.** Fly names exactly three ways to cross a 6PN boundary
17
+ * (https://fly.io/docs/networking/custom-private-networks/): a public service
18
+ * IP, `fly-replay`, and a Flycast private address allocated INTO the calling
19
+ * network. Only the third one grants a single directed edge without exposing
20
+ * anything publicly, so it is what this module implements:
21
+ *
22
+ * > "You can use Flycast to expose an app on one private network to another
23
+ * > private network in your organization; the app won't be accessible via
24
+ * > Flycast from its own network. Use the `--network` option to specify the
25
+ * > network from which requests will originate:
26
+ * > `fly ips allocate-v6 --private --network custom-network-name`"
27
+ * > — https://fly.io/docs/networking/flycast/
28
+ *
29
+ * The REST equivalent is `POST /v1/apps/{app}/ip_assignments`, whose
30
+ * `assignIPRequest` schema carries `network`, `org_slug`, `region`,
31
+ * `service_name` and `type` — read directly from
32
+ * https://docs.machines.dev/openapi.json rather than inferred. `type` is a bare
33
+ * `string` there with no enum and no description, so `private_v6` — the value
34
+ * flyctl passes for `allocate-v6 --private`
35
+ * (https://github.com/superfly/flyctl `internal/command/ips/allocate.go`) — is
36
+ * this provider's default and is configurable via
37
+ * {@link FlyioConfig.privateIpAssignmentType}. Treat the literal as UNVERIFIED
38
+ * against an official Fly *doc page*; the flyctl source is a secondary source.
39
+ *
40
+ * **Three consequences that shape the code, none of which can be designed
41
+ * around:**
42
+ *
43
+ * 1. **The allocation lives on the TARGET app, keyed by the CALLER's network.**
44
+ * So the tenant Postgres app accumulates one private address per project
45
+ * network, and destroying a project must release its own. Fly documents no
46
+ * limit on how many private addresses one app may hold, in either direction —
47
+ * see the module `@remarks` in `index.ts`.
48
+ * 2. **`GET /apps/{app}/ip_assignments` does not report which network an address
49
+ * belongs to** (`IPAssignment` is `{created_at, ip, region, service_name,
50
+ * shared}`). A listing therefore cannot tell you whether THIS project's route
51
+ * already exists, so the addresses are recorded in the sandbox Machine's own
52
+ * metadata and read back from there.
53
+ * 3. **The target app needs a services block.** "Services configured in your
54
+ * app's `fly.toml` with an `[http_service]` or `[services]` section"
55
+ * (https://fly.io/docs/networking/flycast/). That is an operator action on
56
+ * the target app, not something this provider can perform.
57
+ *
58
+ * @module
59
+ */
60
+ import type { FlyNetworkPolicyPort, FlyPrivateService } from './types.js';
61
+ /**
62
+ * DNS suffix Fly serves a Flycast address at: "If an app has a Flycast address
63
+ * allocated to it, there will be an AAAA record at `my-app-name.flycast`"
64
+ * (https://fly.io/docs/networking/flycast/).
65
+ */
66
+ export declare const FLYCAST_SUFFIX = ".flycast";
67
+ /**
68
+ * Machine-metadata key suffix (after the configured prefix) recording the
69
+ * private addresses allocated for a project, so `destroy()` can release exactly
70
+ * the ones it created. See consequence 2 in the module description: a listing on
71
+ * the target app cannot tell you which network an address serves.
72
+ */
73
+ export declare const PRIVATE_ROUTES_METADATA_SUFFIX = "privateRoutes";
74
+ /**
75
+ * `type` sent to `POST /apps/{app}/ip_assignments` for a Flycast address. This
76
+ * is the value flyctl passes for `fly ips allocate-v6 --private`; the field has
77
+ * no enum in Fly's OpenAPI specification, so it is configurable rather than
78
+ * hardcoded.
79
+ */
80
+ export declare const DEFAULT_PRIVATE_IP_TYPE = "private_v6";
81
+ /**
82
+ * Renders the DNS name a sandbox dials to reach an app over its Flycast address.
83
+ * @param app - The target Fly app name.
84
+ * @returns `<app>.flycast`.
85
+ */
86
+ export declare function flycastHost(app: string): string;
87
+ /**
88
+ * Parses the declared cross-network services.
89
+ *
90
+ * Each entry is `<app>:<port>` — the Fly app to allocate a Flycast address on,
91
+ * and the TCP port a sandbox dials it at. The port is not cosmetic: it is what
92
+ * {@link mergeEgressPorts} adds to the Fly network policy, and what
93
+ * {@link assertPrivateRoutesForEnv} checks the injected connection URLs against.
94
+ * @param raw - Comma-separated `app:port` pairs, e.g.
95
+ * `molecule-pg-tenant:5432,molecule-api:3129`.
96
+ * @returns The declared services, deduplicated, in order — or `undefined` when
97
+ * the input is absent or empty, meaning "allocate nothing", which is correct
98
+ * for a control plane that shares one 6PN with its sandboxes.
99
+ * @throws {Error} When an entry is present but unparseable. Dropping one
100
+ * silently would leave a sandbox with a `DATABASE_URL` that nothing routes.
101
+ */
102
+ export declare function parsePrivateServices(raw: string | undefined): FlyPrivateService[] | undefined;
103
+ /**
104
+ * Adds every declared private-service port to the egress network policy.
105
+ *
106
+ * A Fly network policy is deny-by-default once any rule exists for a direction,
107
+ * so a sandbox told to dial `molecule-pg-tenant.flycast:5432` under a policy
108
+ * allowing only `tcp:3128` would fail to connect with no diagnostic beyond a
109
+ * timeout. Deriving the port from the declaration — rather than asking the
110
+ * operator to keep two lists in step — is what makes that class of outage
111
+ * impossible: FLY-OPERATOR-SETUP.md § 9 can keep saying "never widen this list",
112
+ * because the operator never has to.
113
+ *
114
+ * It is deliberately a UNION and never a replacement, so an operator's list is
115
+ * still exactly what they wrote plus the ports their own declarations require,
116
+ * and the applied policy is logged.
117
+ *
118
+ * Whether the addition is even load-bearing is UNVERIFIED: Fly states "Network
119
+ * policies only apply to traffic directly to and from Machines. They do not
120
+ * affect traffic routed through the Fly Proxy"
121
+ * (https://fly.io/docs/machines/guides-examples/network-policies/), and Flycast
122
+ * traffic IS routed through Fly Proxy — but Fly says that about ingress and
123
+ * never addresses a Machine's egress toward a Flycast address. An inert entry
124
+ * costs a port on the allow list; a missing one costs every database in the
125
+ * fleet, so the port is added.
126
+ * @param ports - The operator's configured ports, or `undefined` when no policy
127
+ * is being applied at all.
128
+ * @param services - The declared private services, or `undefined`.
129
+ * @returns The union, deduplicated — or `undefined` when `ports` is `undefined`.
130
+ * "No policy" is never turned INTO a policy here: applying one that Fly then
131
+ * uses to drop everything else is not something to infer from an unrelated
132
+ * setting.
133
+ */
134
+ export declare function mergeEgressPorts(ports: FlyNetworkPolicyPort[] | undefined, services: FlyPrivateService[] | undefined): FlyNetworkPolicyPort[] | undefined;
135
+ /**
136
+ * Encodes allocated addresses for storage in Machine metadata.
137
+ * @param routes - Target app name → allocated private address.
138
+ * @returns `app=address` pairs joined by commas. An IPv6 literal contains
139
+ * neither `,` nor `=`, so the encoding is unambiguous.
140
+ */
141
+ export declare function encodePrivateRoutes(routes: Record<string, string>): string;
142
+ /**
143
+ * Decodes the addresses recorded by {@link encodePrivateRoutes}.
144
+ * @param raw - The metadata value, or `undefined` when the Machine carries none.
145
+ * @returns Target app name → allocated private address. Unparseable entries are
146
+ * skipped: this value only drives cleanup and self-healing, and a malformed
147
+ * entry must not stop either.
148
+ */
149
+ export declare function decodePrivateRoutes(raw: string | undefined): Record<string, string>;
150
+ /**
151
+ * Refuses to boot a sandbox that is told to dial a Flycast host this provider
152
+ * did not allocate a route for.
153
+ *
154
+ * This is the check that turns the product-breaking failure into a startup
155
+ * error. The control plane bakes `DATABASE_URL` (and the proxy environment) into
156
+ * every sandbox; on Fly those must name `<app>.flycast`, and that name resolves
157
+ * ONLY because this provider allocated a private address into the project's
158
+ * network for that app. Get the two out of step — a `SANDBOX_DB_HOST` pointing
159
+ * at an app nobody declared, a port that does not match the one the policy
160
+ * allows — and the sandbox boots healthy, the scaffolded app cannot connect, and
161
+ * the only symptom is a connection timeout inside someone else's project.
162
+ *
163
+ * It is also the guard on the control-plane cluster: reaching
164
+ * `molecule-pg-control.flycast` from a sandbox would require an operator to have
165
+ * declared it as a private service, which is an explicit act with its own name
166
+ * in the configuration — never something this provider arranges on its own.
167
+ * @param env - The environment the caller is baking into the sandbox.
168
+ * @param services - The declared private services, or `undefined`.
169
+ * @throws {Error} When an environment value names a `.flycast` host with no
170
+ * matching declaration, naming the variable and the fix.
171
+ */
172
+ export declare function assertPrivateRoutesForEnv(env: Record<string, string> | undefined, services: FlyPrivateService[] | undefined): void;
173
+ //# sourceMappingURL=flycast.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"flycast.d.ts","sourceRoot":"","sources":["../src/flycast.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA0DG;AAIH,OAAO,KAAK,EAAE,oBAAoB,EAAE,iBAAiB,EAAE,MAAM,YAAY,CAAA;AAEzE;;;;GAIG;AACH,eAAO,MAAM,cAAc,aAAa,CAAA;AAExC;;;;;GAKG;AACH,eAAO,MAAM,8BAA8B,kBAAkB,CAAA;AAE7D;;;;;GAKG;AACH,eAAO,MAAM,uBAAuB,eAAe,CAAA;AAKnD;;;;GAIG;AACH,wBAAgB,WAAW,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAE/C;AAED;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,oBAAoB,CAAC,GAAG,EAAE,MAAM,GAAG,SAAS,GAAG,iBAAiB,EAAE,GAAG,SAAS,CAqC7F;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8BG;AACH,wBAAgB,gBAAgB,CAC9B,KAAK,EAAE,oBAAoB,EAAE,GAAG,SAAS,EACzC,QAAQ,EAAE,iBAAiB,EAAE,GAAG,SAAS,GACxC,oBAAoB,EAAE,GAAG,SAAS,CAUpC;AAED;;;;;GAKG;AACH,wBAAgB,mBAAmB,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GAAG,MAAM,CAI1E;AAED;;;;;;GAMG;AACH,wBAAgB,mBAAmB,CAAC,GAAG,EAAE,MAAM,GAAG,SAAS,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAUnF;AA2BD;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,wBAAgB,yBAAyB,CACvC,GAAG,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GAAG,SAAS,EACvC,QAAQ,EAAE,iBAAiB,EAAE,GAAG,SAAS,GACxC,IAAI,CAwCN"}
@@ -0,0 +1,287 @@
1
+ /**
2
+ * Flycast private routes — the one mechanism Fly documents for reaching an app
3
+ * ACROSS custom 6PN private networks, and the pure logic behind wiring it per
4
+ * project.
5
+ *
6
+ * **The problem this solves.** This provider puts every project's sandbox app on
7
+ * its own custom 6PN, which is what makes tenants unable to reach each other:
8
+ * "Apps on separate 6PNs can never communicate unless explicitly configured to
9
+ * do so" (https://fly.io/docs/networking/custom-private-networks/). The same
10
+ * sentence severs two paths the product REQUIRES — a sandbox reaching its own
11
+ * `mol_<uuid>` database on the tenant Postgres cluster, and a sandbox reaching
12
+ * the control plane's egress proxy. Neither is optional: a scaffolded app
13
+ * connects to `DATABASE_URL` at startup, and the proxy is where the per-project
14
+ * host allowlist is enforced.
15
+ *
16
+ * **The mechanism.** Fly names exactly three ways to cross a 6PN boundary
17
+ * (https://fly.io/docs/networking/custom-private-networks/): a public service
18
+ * IP, `fly-replay`, and a Flycast private address allocated INTO the calling
19
+ * network. Only the third one grants a single directed edge without exposing
20
+ * anything publicly, so it is what this module implements:
21
+ *
22
+ * > "You can use Flycast to expose an app on one private network to another
23
+ * > private network in your organization; the app won't be accessible via
24
+ * > Flycast from its own network. Use the `--network` option to specify the
25
+ * > network from which requests will originate:
26
+ * > `fly ips allocate-v6 --private --network custom-network-name`"
27
+ * > — https://fly.io/docs/networking/flycast/
28
+ *
29
+ * The REST equivalent is `POST /v1/apps/{app}/ip_assignments`, whose
30
+ * `assignIPRequest` schema carries `network`, `org_slug`, `region`,
31
+ * `service_name` and `type` — read directly from
32
+ * https://docs.machines.dev/openapi.json rather than inferred. `type` is a bare
33
+ * `string` there with no enum and no description, so `private_v6` — the value
34
+ * flyctl passes for `allocate-v6 --private`
35
+ * (https://github.com/superfly/flyctl `internal/command/ips/allocate.go`) — is
36
+ * this provider's default and is configurable via
37
+ * {@link FlyioConfig.privateIpAssignmentType}. Treat the literal as UNVERIFIED
38
+ * against an official Fly *doc page*; the flyctl source is a secondary source.
39
+ *
40
+ * **Three consequences that shape the code, none of which can be designed
41
+ * around:**
42
+ *
43
+ * 1. **The allocation lives on the TARGET app, keyed by the CALLER's network.**
44
+ * So the tenant Postgres app accumulates one private address per project
45
+ * network, and destroying a project must release its own. Fly documents no
46
+ * limit on how many private addresses one app may hold, in either direction —
47
+ * see the module `@remarks` in `index.ts`.
48
+ * 2. **`GET /apps/{app}/ip_assignments` does not report which network an address
49
+ * belongs to** (`IPAssignment` is `{created_at, ip, region, service_name,
50
+ * shared}`). A listing therefore cannot tell you whether THIS project's route
51
+ * already exists, so the addresses are recorded in the sandbox Machine's own
52
+ * metadata and read back from there.
53
+ * 3. **The target app needs a services block.** "Services configured in your
54
+ * app's `fly.toml` with an `[http_service]` or `[services]` section"
55
+ * (https://fly.io/docs/networking/flycast/). That is an operator action on
56
+ * the target app, not something this provider can perform.
57
+ *
58
+ * @module
59
+ */
60
+ import { t } from '@molecule/api-i18n';
61
+ /**
62
+ * DNS suffix Fly serves a Flycast address at: "If an app has a Flycast address
63
+ * allocated to it, there will be an AAAA record at `my-app-name.flycast`"
64
+ * (https://fly.io/docs/networking/flycast/).
65
+ */
66
+ export const FLYCAST_SUFFIX = '.flycast';
67
+ /**
68
+ * Machine-metadata key suffix (after the configured prefix) recording the
69
+ * private addresses allocated for a project, so `destroy()` can release exactly
70
+ * the ones it created. See consequence 2 in the module description: a listing on
71
+ * the target app cannot tell you which network an address serves.
72
+ */
73
+ export const PRIVATE_ROUTES_METADATA_SUFFIX = 'privateRoutes';
74
+ /**
75
+ * `type` sent to `POST /apps/{app}/ip_assignments` for a Flycast address. This
76
+ * is the value flyctl passes for `fly ips allocate-v6 --private`; the field has
77
+ * no enum in Fly's OpenAPI specification, so it is configurable rather than
78
+ * hardcoded.
79
+ */
80
+ export const DEFAULT_PRIVATE_IP_TYPE = 'private_v6';
81
+ /** Fly app names are DNS labels: lowercase alphanumerics and hyphens. */
82
+ const APP_NAME = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/;
83
+ /**
84
+ * Renders the DNS name a sandbox dials to reach an app over its Flycast address.
85
+ * @param app - The target Fly app name.
86
+ * @returns `<app>.flycast`.
87
+ */
88
+ export function flycastHost(app) {
89
+ return `${app}${FLYCAST_SUFFIX}`;
90
+ }
91
+ /**
92
+ * Parses the declared cross-network services.
93
+ *
94
+ * Each entry is `<app>:<port>` — the Fly app to allocate a Flycast address on,
95
+ * and the TCP port a sandbox dials it at. The port is not cosmetic: it is what
96
+ * {@link mergeEgressPorts} adds to the Fly network policy, and what
97
+ * {@link assertPrivateRoutesForEnv} checks the injected connection URLs against.
98
+ * @param raw - Comma-separated `app:port` pairs, e.g.
99
+ * `molecule-pg-tenant:5432,molecule-api:3129`.
100
+ * @returns The declared services, deduplicated, in order — or `undefined` when
101
+ * the input is absent or empty, meaning "allocate nothing", which is correct
102
+ * for a control plane that shares one 6PN with its sandboxes.
103
+ * @throws {Error} When an entry is present but unparseable. Dropping one
104
+ * silently would leave a sandbox with a `DATABASE_URL` that nothing routes.
105
+ */
106
+ export function parsePrivateServices(raw) {
107
+ const entries = (raw ?? '')
108
+ .split(',')
109
+ .map((entry) => entry.trim())
110
+ .filter(Boolean);
111
+ if (entries.length === 0)
112
+ return undefined;
113
+ const services = [];
114
+ for (const entry of entries) {
115
+ const index = entry.lastIndexOf(':');
116
+ const app = index > 0 ? entry.slice(0, index) : '';
117
+ const port = Number(entry.slice(index + 1));
118
+ if (!APP_NAME.test(app) ||
119
+ app.length > 63 ||
120
+ !Number.isInteger(port) ||
121
+ port < 1 ||
122
+ port > 65535) {
123
+ throw new Error(t('codeSandbox.flyio.error.badPrivateService', { entry }, {
124
+ defaultValue: `Invalid Fly private service "${entry}" — expected "<fly-app>:<port>", e.g. ` +
125
+ '"molecule-pg-tenant:5432". The app is the Fly app a Flycast address is allocated ' +
126
+ 'on; the port is the one a sandbox dials it at.',
127
+ }));
128
+ }
129
+ if (!services.some((service) => service.app === app && service.port === port)) {
130
+ services.push({ app, port });
131
+ }
132
+ }
133
+ return services;
134
+ }
135
+ /**
136
+ * Adds every declared private-service port to the egress network policy.
137
+ *
138
+ * A Fly network policy is deny-by-default once any rule exists for a direction,
139
+ * so a sandbox told to dial `molecule-pg-tenant.flycast:5432` under a policy
140
+ * allowing only `tcp:3128` would fail to connect with no diagnostic beyond a
141
+ * timeout. Deriving the port from the declaration — rather than asking the
142
+ * operator to keep two lists in step — is what makes that class of outage
143
+ * impossible: FLY-OPERATOR-SETUP.md § 9 can keep saying "never widen this list",
144
+ * because the operator never has to.
145
+ *
146
+ * It is deliberately a UNION and never a replacement, so an operator's list is
147
+ * still exactly what they wrote plus the ports their own declarations require,
148
+ * and the applied policy is logged.
149
+ *
150
+ * Whether the addition is even load-bearing is UNVERIFIED: Fly states "Network
151
+ * policies only apply to traffic directly to and from Machines. They do not
152
+ * affect traffic routed through the Fly Proxy"
153
+ * (https://fly.io/docs/machines/guides-examples/network-policies/), and Flycast
154
+ * traffic IS routed through Fly Proxy — but Fly says that about ingress and
155
+ * never addresses a Machine's egress toward a Flycast address. An inert entry
156
+ * costs a port on the allow list; a missing one costs every database in the
157
+ * fleet, so the port is added.
158
+ * @param ports - The operator's configured ports, or `undefined` when no policy
159
+ * is being applied at all.
160
+ * @param services - The declared private services, or `undefined`.
161
+ * @returns The union, deduplicated — or `undefined` when `ports` is `undefined`.
162
+ * "No policy" is never turned INTO a policy here: applying one that Fly then
163
+ * uses to drop everything else is not something to infer from an unrelated
164
+ * setting.
165
+ */
166
+ export function mergeEgressPorts(ports, services) {
167
+ if (!ports)
168
+ return undefined;
169
+ if (!services?.length)
170
+ return ports;
171
+ const merged = [...ports];
172
+ for (const service of services) {
173
+ if (!merged.some((port) => port.protocol === 'tcp' && port.port === service.port)) {
174
+ merged.push({ protocol: 'tcp', port: service.port });
175
+ }
176
+ }
177
+ return merged;
178
+ }
179
+ /**
180
+ * Encodes allocated addresses for storage in Machine metadata.
181
+ * @param routes - Target app name → allocated private address.
182
+ * @returns `app=address` pairs joined by commas. An IPv6 literal contains
183
+ * neither `,` nor `=`, so the encoding is unambiguous.
184
+ */
185
+ export function encodePrivateRoutes(routes) {
186
+ return Object.entries(routes)
187
+ .map(([app, ip]) => `${app}=${ip}`)
188
+ .join(',');
189
+ }
190
+ /**
191
+ * Decodes the addresses recorded by {@link encodePrivateRoutes}.
192
+ * @param raw - The metadata value, or `undefined` when the Machine carries none.
193
+ * @returns Target app name → allocated private address. Unparseable entries are
194
+ * skipped: this value only drives cleanup and self-healing, and a malformed
195
+ * entry must not stop either.
196
+ */
197
+ export function decodePrivateRoutes(raw) {
198
+ const routes = {};
199
+ for (const entry of (raw ?? '').split(',')) {
200
+ const index = entry.indexOf('=');
201
+ if (index <= 0)
202
+ continue;
203
+ const app = entry.slice(0, index).trim();
204
+ const ip = entry.slice(index + 1).trim();
205
+ if (app && ip)
206
+ routes[app] = ip;
207
+ }
208
+ return routes;
209
+ }
210
+ /**
211
+ * Extracts the `<app>` and port from a URL that names a Flycast host.
212
+ * @param value - A candidate environment-variable value.
213
+ * @returns The target app and the port the URL dials it at (`undefined` when the
214
+ * URL carries no explicit port), or `null` when the value is not a URL naming
215
+ * a `.flycast` host.
216
+ */
217
+ function flycastTarget(value) {
218
+ let url;
219
+ try {
220
+ url = new URL(value);
221
+ }
222
+ catch (_error) {
223
+ // Intentionally ignored: "not a URL" is the normal case for an environment
224
+ // value, and a non-URL cannot be the connection string this check is
225
+ // responsible for. There is nothing to report.
226
+ return null;
227
+ }
228
+ const host = url.hostname.toLowerCase();
229
+ if (!host.endsWith(FLYCAST_SUFFIX))
230
+ return null;
231
+ const app = host.slice(0, -FLYCAST_SUFFIX.length);
232
+ if (!app)
233
+ return null;
234
+ const port = url.port ? Number(url.port) : undefined;
235
+ return Number.isInteger(port) ? { app, port } : { app };
236
+ }
237
+ /**
238
+ * Refuses to boot a sandbox that is told to dial a Flycast host this provider
239
+ * did not allocate a route for.
240
+ *
241
+ * This is the check that turns the product-breaking failure into a startup
242
+ * error. The control plane bakes `DATABASE_URL` (and the proxy environment) into
243
+ * every sandbox; on Fly those must name `<app>.flycast`, and that name resolves
244
+ * ONLY because this provider allocated a private address into the project's
245
+ * network for that app. Get the two out of step — a `SANDBOX_DB_HOST` pointing
246
+ * at an app nobody declared, a port that does not match the one the policy
247
+ * allows — and the sandbox boots healthy, the scaffolded app cannot connect, and
248
+ * the only symptom is a connection timeout inside someone else's project.
249
+ *
250
+ * It is also the guard on the control-plane cluster: reaching
251
+ * `molecule-pg-control.flycast` from a sandbox would require an operator to have
252
+ * declared it as a private service, which is an explicit act with its own name
253
+ * in the configuration — never something this provider arranges on its own.
254
+ * @param env - The environment the caller is baking into the sandbox.
255
+ * @param services - The declared private services, or `undefined`.
256
+ * @throws {Error} When an environment value names a `.flycast` host with no
257
+ * matching declaration, naming the variable and the fix.
258
+ */
259
+ export function assertPrivateRoutesForEnv(env, services) {
260
+ if (!env)
261
+ return;
262
+ const declared = services ?? [];
263
+ for (const [key, value] of Object.entries(env)) {
264
+ if (typeof value !== 'string' || !value.toLowerCase().includes(FLYCAST_SUFFIX))
265
+ continue;
266
+ const target = flycastTarget(value);
267
+ if (!target)
268
+ continue;
269
+ const forApp = declared.filter((service) => service.app === target.app);
270
+ if (forApp.length === 0) {
271
+ throw new Error(t('codeSandbox.flyio.error.undeclaredPrivateRoute', { key, app: target.app }, {
272
+ defaultValue: `${key} points at "${flycastHost(target.app)}", but "${target.app}" is not a ` +
273
+ 'declared Fly private service, so no Flycast address is allocated into this ' +
274
+ "project's 6PN and the name will not resolve inside the sandbox. Add " +
275
+ `"${target.app}:<port>" to FLY_SANDBOX_PRIVATE_SERVICES (or config.privateServices).`,
276
+ }));
277
+ }
278
+ if (target.port !== undefined && !forApp.some((service) => service.port === target.port)) {
279
+ throw new Error(t('codeSandbox.flyio.error.privateRoutePortMismatch', { key, app: target.app, port: String(target.port) }, {
280
+ defaultValue: `${key} dials "${flycastHost(target.app)}" on port ${target.port}, but the declared ` +
281
+ `Fly private service for "${target.app}" is on port ` +
282
+ `${forApp.map((service) => service.port).join(', ')}. The sandbox egress policy ` +
283
+ 'allows the declared port, so the connection would be dropped. Make the two match.',
284
+ }));
285
+ }
286
+ }
287
+ }
package/dist/ids.d.ts ADDED
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Sandbox identifiers, Fly app naming, and Machine-state mapping.
3
+ *
4
+ * A Fly Machine id is only unique WITHIN its app, and every Machines API call is
5
+ * `/v1/apps/{app_name}/machines/{machine_id}` — so a bare Machine id is not
6
+ * enough to address a Machine. The core interface hands `get(id)` and
7
+ * `destroy(id)` a single opaque string, so this provider's sandbox id is the
8
+ * composite `"<app>:<machineId>"`. Fly app names cannot contain `:` and Machine
9
+ * ids are hex, so the split is unambiguous. Callers must keep treating the id as
10
+ * opaque; nothing outside this module should parse it.
11
+ *
12
+ * @module
13
+ */
14
+ import type { FlyMachineState } from './types.js';
15
+ /**
16
+ * Builds the opaque sandbox id addressing one Machine inside one app.
17
+ * @param app - Fly app name.
18
+ * @param machineId - Fly Machine id.
19
+ * @returns The composite sandbox id.
20
+ */
21
+ export declare function toSandboxId(app: string, machineId: string): string;
22
+ /**
23
+ * Splits a composite sandbox id back into its app and Machine id.
24
+ * @param id - A sandbox id previously produced by {@link toSandboxId}.
25
+ * @returns The app name and Machine id.
26
+ * @throws {Error} When the id is not in `<app>:<machineId>` form — a bare Machine id
27
+ * cannot be addressed, and guessing an app would delete or inspect the wrong
28
+ * tenant's Machine.
29
+ */
30
+ export declare function parseSandboxId(id: string): {
31
+ app: string;
32
+ machineId: string;
33
+ };
34
+ /**
35
+ * Derives a Fly app name for a project.
36
+ *
37
+ * Fly app names are globally unique DNS labels: lowercase alphanumerics and
38
+ * hyphens, no leading/trailing hyphen, at most 63 characters (they become
39
+ * `<app>.fly.dev`). Project ids are uuids, so `<prefix>-<uuid>` fits with room
40
+ * to spare; longer ids are truncated rather than rejected, and the trailing
41
+ * hyphen that truncation can leave is stripped.
42
+ * @param prefix - Configured app-name prefix.
43
+ * @param projectId - The project id from `SandboxConfig`.
44
+ * @returns A syntactically valid Fly app name.
45
+ * @throws {Error} When `projectId` has no characters usable in a DNS label.
46
+ */
47
+ export declare function appNameForProject(prefix: string, projectId: string): string;
48
+ /**
49
+ * Maps a Fly Machine state onto the core `Sandbox['status']` union.
50
+ *
51
+ * The core has four statuses and Fly documents seventeen states
52
+ * (https://fly.io/docs/machines/machine-states/), so the mapping is lossy by
53
+ * construction:
54
+ *
55
+ * - `started` → `running`.
56
+ * - `suspended`/`suspending` → `sleeping`. This is the mapping this bond exists
57
+ * for: `sleep()` suspends and `wake()` resumes from the memory snapshot.
58
+ * - In-flight transitions (`creating`, `starting`, `restarting`, `updating`,
59
+ * `replacing`, `migrated`) → `creating`, i.e. "not usable yet, will be".
60
+ * - Everything else — including `failed` and `launch_failed` — → `stopped`.
61
+ * The core union has no error status, so a failed Machine is reported as
62
+ * stopped; `failed` is distinguishable only via {@link isFailedState}.
63
+ *
64
+ * An unrecognized future state falls through to `stopped`, which is the safe
65
+ * default: a caller retries a start rather than assuming a usable sandbox.
66
+ * @param state - Raw Fly Machine state string.
67
+ * @returns The core sandbox status.
68
+ */
69
+ export declare function mapMachineState(state: FlyMachineState): 'creating' | 'running' | 'sleeping' | 'stopped';
70
+ /**
71
+ * Reports whether a Fly Machine state means the Machine errored, rather than
72
+ * having been stopped on purpose. {@link mapMachineState} flattens both to
73
+ * `stopped` because the core union has no failure status, so this is the only
74
+ * way to tell them apart.
75
+ * @param state - Raw Fly Machine state string.
76
+ * @returns `true` for `failed` and `launch_failed`.
77
+ */
78
+ export declare function isFailedState(state: FlyMachineState): boolean;
79
+ //# sourceMappingURL=ids.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ids.d.ts","sourceRoot":"","sources":["../src/ids.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAIH,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,YAAY,CAAA;AAKjD;;;;;GAKG;AACH,wBAAgB,WAAW,CAAC,GAAG,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,MAAM,CAElE;AAED;;;;;;;GAOG;AACH,wBAAgB,cAAc,CAAC,EAAE,EAAE,MAAM,GAAG;IAAE,GAAG,EAAE,MAAM,CAAC;IAAC,SAAS,EAAE,MAAM,CAAA;CAAE,CAgB7E;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,iBAAiB,CAAC,MAAM,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,MAAM,CAqB3E;AAED;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,wBAAgB,eAAe,CAC7B,KAAK,EAAE,eAAe,GACrB,UAAU,GAAG,SAAS,GAAG,UAAU,GAAG,SAAS,CAiBjD;AAED;;;;;;;GAOG;AACH,wBAAgB,aAAa,CAAC,KAAK,EAAE,eAAe,GAAG,OAAO,CAE7D"}
package/dist/ids.js ADDED
@@ -0,0 +1,121 @@
1
+ /**
2
+ * Sandbox identifiers, Fly app naming, and Machine-state mapping.
3
+ *
4
+ * A Fly Machine id is only unique WITHIN its app, and every Machines API call is
5
+ * `/v1/apps/{app_name}/machines/{machine_id}` — so a bare Machine id is not
6
+ * enough to address a Machine. The core interface hands `get(id)` and
7
+ * `destroy(id)` a single opaque string, so this provider's sandbox id is the
8
+ * composite `"<app>:<machineId>"`. Fly app names cannot contain `:` and Machine
9
+ * ids are hex, so the split is unambiguous. Callers must keep treating the id as
10
+ * opaque; nothing outside this module should parse it.
11
+ *
12
+ * @module
13
+ */
14
+ import { t } from '@molecule/api-i18n';
15
+ /** Separator between the app name and the Machine id in a composite sandbox id. */
16
+ const ID_SEPARATOR = ':';
17
+ /**
18
+ * Builds the opaque sandbox id addressing one Machine inside one app.
19
+ * @param app - Fly app name.
20
+ * @param machineId - Fly Machine id.
21
+ * @returns The composite sandbox id.
22
+ */
23
+ export function toSandboxId(app, machineId) {
24
+ return `${app}${ID_SEPARATOR}${machineId}`;
25
+ }
26
+ /**
27
+ * Splits a composite sandbox id back into its app and Machine id.
28
+ * @param id - A sandbox id previously produced by {@link toSandboxId}.
29
+ * @returns The app name and Machine id.
30
+ * @throws {Error} When the id is not in `<app>:<machineId>` form — a bare Machine id
31
+ * cannot be addressed, and guessing an app would delete or inspect the wrong
32
+ * tenant's Machine.
33
+ */
34
+ export function parseSandboxId(id) {
35
+ const index = id.indexOf(ID_SEPARATOR);
36
+ if (index <= 0 || index === id.length - 1) {
37
+ throw new Error(t('codeSandbox.flyio.error.badSandboxId', { id }, {
38
+ defaultValue: `Invalid Fly sandbox id "${id}" — expected "<app>:<machineId>". ` +
39
+ 'Fly Machine ids are only unique within an app, so a bare Machine id cannot be addressed.',
40
+ }));
41
+ }
42
+ return { app: id.slice(0, index), machineId: id.slice(index + 1) };
43
+ }
44
+ /**
45
+ * Derives a Fly app name for a project.
46
+ *
47
+ * Fly app names are globally unique DNS labels: lowercase alphanumerics and
48
+ * hyphens, no leading/trailing hyphen, at most 63 characters (they become
49
+ * `<app>.fly.dev`). Project ids are uuids, so `<prefix>-<uuid>` fits with room
50
+ * to spare; longer ids are truncated rather than rejected, and the trailing
51
+ * hyphen that truncation can leave is stripped.
52
+ * @param prefix - Configured app-name prefix.
53
+ * @param projectId - The project id from `SandboxConfig`.
54
+ * @returns A syntactically valid Fly app name.
55
+ * @throws {Error} When `projectId` has no characters usable in a DNS label.
56
+ */
57
+ export function appNameForProject(prefix, projectId) {
58
+ const sanitize = (value) => value
59
+ .toLowerCase()
60
+ .replace(/[^a-z0-9-]+/g, '-')
61
+ .replace(/-+/g, '-')
62
+ .replace(/^-|-$/g, '');
63
+ const safePrefix = sanitize(prefix) || 'mol-sandbox';
64
+ const safeProject = sanitize(projectId);
65
+ if (!safeProject) {
66
+ throw new Error(t('codeSandbox.flyio.error.badProjectId', { projectId }, {
67
+ defaultValue: `Cannot derive a Fly app name from projectId "${projectId}" — no DNS-safe characters.`,
68
+ }));
69
+ }
70
+ return `${safePrefix}-${safeProject}`.slice(0, 63).replace(/-+$/, '');
71
+ }
72
+ /**
73
+ * Maps a Fly Machine state onto the core `Sandbox['status']` union.
74
+ *
75
+ * The core has four statuses and Fly documents seventeen states
76
+ * (https://fly.io/docs/machines/machine-states/), so the mapping is lossy by
77
+ * construction:
78
+ *
79
+ * - `started` → `running`.
80
+ * - `suspended`/`suspending` → `sleeping`. This is the mapping this bond exists
81
+ * for: `sleep()` suspends and `wake()` resumes from the memory snapshot.
82
+ * - In-flight transitions (`creating`, `starting`, `restarting`, `updating`,
83
+ * `replacing`, `migrated`) → `creating`, i.e. "not usable yet, will be".
84
+ * - Everything else — including `failed` and `launch_failed` — → `stopped`.
85
+ * The core union has no error status, so a failed Machine is reported as
86
+ * stopped; `failed` is distinguishable only via {@link isFailedState}.
87
+ *
88
+ * An unrecognized future state falls through to `stopped`, which is the safe
89
+ * default: a caller retries a start rather than assuming a usable sandbox.
90
+ * @param state - Raw Fly Machine state string.
91
+ * @returns The core sandbox status.
92
+ */
93
+ export function mapMachineState(state) {
94
+ switch (state) {
95
+ case 'started':
96
+ return 'running';
97
+ case 'suspended':
98
+ case 'suspending':
99
+ return 'sleeping';
100
+ case 'creating':
101
+ case 'starting':
102
+ case 'restarting':
103
+ case 'updating':
104
+ case 'replacing':
105
+ case 'migrated':
106
+ return 'creating';
107
+ default:
108
+ return 'stopped';
109
+ }
110
+ }
111
+ /**
112
+ * Reports whether a Fly Machine state means the Machine errored, rather than
113
+ * having been stopped on purpose. {@link mapMachineState} flattens both to
114
+ * `stopped` because the core union has no failure status, so this is the only
115
+ * way to tell them apart.
116
+ * @param state - Raw Fly Machine state string.
117
+ * @returns `true` for `failed` and `launch_failed`.
118
+ */
119
+ export function isFailedState(state) {
120
+ return state === 'failed' || state === 'launch_failed';
121
+ }