@intentic/sandbox-contract 1.234.0 → 1.236.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.
Files changed (70) hide show
  1. package/dist/chores/chores.d.ts.map +1 -1
  2. package/dist/chores/chores.js +2 -1
  3. package/dist/chores/chores.js.map +1 -1
  4. package/dist/chores/index.d.ts +1 -0
  5. package/dist/chores/index.d.ts.map +1 -1
  6. package/dist/chores/index.js +1 -0
  7. package/dist/chores/index.js.map +1 -1
  8. package/dist/chores/probes.d.ts.map +1 -1
  9. package/dist/chores/probes.js +4 -3
  10. package/dist/chores/probes.js.map +1 -1
  11. package/dist/chores/workspace-scope.d.ts +4 -0
  12. package/dist/chores/workspace-scope.d.ts.map +1 -0
  13. package/dist/chores/workspace-scope.js +4 -0
  14. package/dist/chores/workspace-scope.js.map +1 -0
  15. package/dist/contracts/extensions.contract.d.ts +1 -2
  16. package/dist/contracts/extensions.contract.d.ts.map +1 -1
  17. package/dist/contracts/extensions.contract.js +5 -5
  18. package/dist/contracts/extensions.contract.js.map +1 -1
  19. package/dist/contracts/system.contract.d.ts +12 -0
  20. package/dist/contracts/system.contract.d.ts.map +1 -1
  21. package/dist/contracts/system.contract.js +10 -1
  22. package/dist/contracts/system.contract.js.map +1 -1
  23. package/dist/contracts/workspace.contract.d.ts +1 -0
  24. package/dist/contracts/workspace.contract.d.ts.map +1 -1
  25. package/dist/contracts/workspace.contract.js +2 -2
  26. package/dist/contracts/workspace.contract.js.map +1 -1
  27. package/dist/index.d.ts +14 -2
  28. package/dist/index.d.ts.map +1 -1
  29. package/dist/ingress-contract.d.ts +23 -0
  30. package/dist/ingress-contract.d.ts.map +1 -0
  31. package/dist/ingress-contract.js +43 -0
  32. package/dist/ingress-contract.js.map +1 -0
  33. package/dist/ingress-protocol.d.ts +16 -0
  34. package/dist/ingress-protocol.d.ts.map +1 -0
  35. package/dist/ingress-protocol.js +263 -0
  36. package/dist/ingress-protocol.js.map +1 -0
  37. package/dist/schemas/agents.d.ts.map +1 -1
  38. package/dist/schemas/agents.js +5 -5
  39. package/dist/schemas/agents.js.map +1 -1
  40. package/dist/schemas/computers.d.ts +41 -0
  41. package/dist/schemas/computers.d.ts.map +1 -1
  42. package/dist/schemas/computers.js +16 -0
  43. package/dist/schemas/computers.js.map +1 -1
  44. package/dist/schemas/extension-updates.d.ts +2 -3
  45. package/dist/schemas/extension-updates.d.ts.map +1 -1
  46. package/dist/schemas/extension-updates.js +4 -3
  47. package/dist/schemas/extension-updates.js.map +1 -1
  48. package/dist/schemas/workspace-tree.d.ts +1 -0
  49. package/dist/schemas/workspace-tree.d.ts.map +1 -1
  50. package/dist/schemas/workspace-tree.js +8 -1
  51. package/dist/schemas/workspace-tree.js.map +1 -1
  52. package/package.json +26 -4
  53. package/src/chores/chores.ts +2 -1
  54. package/src/chores/index.ts +1 -0
  55. package/src/chores/probes.test.ts +12 -0
  56. package/src/chores/probes.ts +5 -3
  57. package/src/chores/workspace-scope.ts +12 -0
  58. package/src/contracts/extensions.contract.ts +7 -6
  59. package/src/contracts/system.contract.ts +22 -1
  60. package/src/contracts/workspace.contract.ts +3 -2
  61. package/src/events.ts +2 -2
  62. package/src/ingress-contract.test.ts +65 -0
  63. package/src/ingress-contract.ts +155 -0
  64. package/src/ingress-protocol.test.ts +509 -0
  65. package/src/ingress-protocol.ts +574 -0
  66. package/src/schemas/agents.ts +20 -21
  67. package/src/schemas/computers.ts +60 -0
  68. package/src/schemas/extension-updates.ts +9 -7
  69. package/src/schemas/workspace-tree.ts +13 -4
  70. package/src/tunnel-ids.ts +4 -4
@@ -0,0 +1,65 @@
1
+ import { generateKeyPairSync } from "node:crypto";
2
+ import { describe, expect, it } from "vitest";
3
+ import { hostOwnerId, mintReachabilityGrant, verifyReachabilityGrant } from "./ingress-contract.js";
4
+
5
+ const pemPair = (): { privateKey: string; publicKey: string } => {
6
+ const { privateKey, publicKey } = generateKeyPairSync("ed25519");
7
+ return {
8
+ privateKey: privateKey.export({ type: "pkcs8", format: "pem" }).toString(),
9
+ publicKey: publicKey.export({ type: "spki", format: "pem" }).toString(),
10
+ };
11
+ };
12
+
13
+ const SANDBOX_ID = "abc123def456";
14
+
15
+ describe("reachability grant", () => {
16
+ it("round-trips through mint and verify", () => {
17
+ const keys = pemPair();
18
+ const token = mintReachabilityGrant(keys.privateKey, SANDBOX_ID, 1_700_000_000_123);
19
+ expect(verifyReachabilityGrant(keys.publicKey, token)).toEqual({ sandboxId: SANDBOX_ID, issuedAt: 1_700_000_000 });
20
+ });
21
+
22
+ it("refuses a grant signed by another key", () => {
23
+ const token = mintReachabilityGrant(pemPair().privateKey, SANDBOX_ID, Date.now());
24
+ expect(verifyReachabilityGrant(pemPair().publicKey, token)).toBeUndefined();
25
+ });
26
+
27
+ it("refuses a tampered payload", () => {
28
+ const keys = pemPair();
29
+ const token = mintReachabilityGrant(keys.privateKey, SANDBOX_ID, Date.now());
30
+ const [prefix, , signature] = token.split(".");
31
+ const forged = Buffer.from(JSON.stringify({ sub: "000000000000", iat: 1 }), "utf8").toString("base64url");
32
+ expect(verifyReachabilityGrant(keys.publicKey, `${prefix}.${forged}.${signature}`)).toBeUndefined();
33
+ });
34
+
35
+ it("answers undefined for garbage rather than throwing", () => {
36
+ const keys = pemPair();
37
+ for (const junk of ["", "ig1", "ig1..", "nonsense.a.b", "ig2.a.b", "ig1.%%%.%%%"]) {
38
+ expect(verifyReachabilityGrant(keys.publicKey, junk)).toBeUndefined();
39
+ }
40
+ });
41
+
42
+ it("refuses to mint for something that is not a sandbox id", () => {
43
+ expect(() => mintReachabilityGrant(pemPair().privateKey, "not-an-id", Date.now())).toThrow(/12-hex/);
44
+ });
45
+ });
46
+
47
+ describe("hostOwnerId", () => {
48
+ it("owns the daemon's own name and every labelled name", () => {
49
+ expect(hostOwnerId(`sandbox-${SANDBOX_ID}.sbx.example.dev`)).toBe(SANDBOX_ID);
50
+ expect(hostOwnerId(`preview-operator-${SANDBOX_ID}.sbx.example.dev`)).toBe(SANDBOX_ID);
51
+ expect(hostOwnerId(`port-0f0f0f0f0f0f-${SANDBOX_ID}.sbx.example.dev`)).toBe(SANDBOX_ID);
52
+ expect(hostOwnerId(`public-1a2b3c4d5e6f-${SANDBOX_ID}.sbx.example.dev`)).toBe(SANDBOX_ID);
53
+ });
54
+
55
+ it("ignores a port suffix", () => {
56
+ expect(hostOwnerId(`sandbox-${SANDBOX_ID}.sbx.example.dev:443`)).toBe(SANDBOX_ID);
57
+ });
58
+
59
+ it("owns nothing that does not end in a 12-hex tail", () => {
60
+ // The ingress's own door, the zone apex, the loopback name's bare-id label, and a near-miss tail.
61
+ for (const host of ["ingress.sbx.example.dev", "sbx.example.dev", `${SANDBOX_ID}.local.sbx.example.dev`, "sandbox-abc123def45.sbx.example.dev", ""]) {
62
+ expect(hostOwnerId(host)).toBeUndefined();
63
+ }
64
+ });
65
+ });
@@ -0,0 +1,155 @@
1
+ import { createPrivateKey, createPublicKey, sign as edSign, verify as edVerify } from "node:crypto";
2
+
3
+ /* THE INGRESS CONTRACT: how a sandbox is reached now that the reachability fabric is the platform's OWN edge
4
+ * (the `@intentic/ingress` app on Fly) instead of a zrok hub. Node-only (crypto), like ./tunnel-ids beside it;
5
+ * shared by the platform (mints grants), the ingress (verifies grants, routes hosts), and the daemon (dials
6
+ * the tunnel). The three MUST agree on every string in this file, which is why it exists.
7
+ *
8
+ * WHAT REPLACED WHAT. Under zrok, reachability was STATE: an account minted per sandbox over the hub's admin
9
+ * API, names claimed one by one, shares bound to them, and a reaper collecting what the soft-deletes leaked.
10
+ * Every piece of that state existed to answer one question — "which sandbox may serve this hostname?" — that
11
+ * the hostnames ALREADY answer by construction: every public name a sandbox serves ends in its own 12-hex id
12
+ * (`sandbox-<id>`, `preview-<panel>-<id>`, `port-<slot>-<id>`, `public-<slot>-<id>`; hostnames.ts is the
13
+ * single source). So ownership is a PARSE, not a registry, and the only thing that has to be minted is proof
14
+ * of identity: a grant, signed by the platform, saying "the bearer is sandbox <id>". Provisioning reachability
15
+ * becomes a pure function — no hub round trip, no row to cache a token on, no orphan to reconcile, and
16
+ * revoking it is deleting the sandbox row (the ingress asks the platform on register, below).
17
+ *
18
+ * THE FLOW. The daemon dials ONE outbound WebSocket to the ingress (INGRESS_TUNNEL_PATH) presenting its grant
19
+ * in INGRESS_GRANT_HEADER. The ingress verifies the signature offline, registers the tunnel under grant.sub,
20
+ * and from then on routes every edge request whose Host's leftmost label ends in `-<that id>` (or is exactly
21
+ * `sandbox-<id>`) down that tunnel. A second tunnel for the same id DISPLACES the first — the new container is
22
+ * by definition the live one, which is what buries the zrok-era stale-share reclaim dance (a recreated box
23
+ * used to fight the hub over names its dead predecessor still held; here the fight cannot exist).
24
+ *
25
+ * THE DATA PLANE over the tunnel is an HTTP/2 cleartext session runs over the WebSocket's binary stream:
26
+ * the ingress side opens an http2 CLIENT session over the duplex (node's http2.connect with createConnection),
27
+ * the daemon side feeds the duplex to an http2 SERVER session, and each edge request becomes one h2 stream
28
+ * with the original :authority preserved, which the daemon's client forwards to its own loopback listener
29
+ * (the Hono app already dispatches previews by Host). WebSocket upgrades ride CONNECT-method streams carrying
30
+ * the raw upgraded bytes. All of it is node core — the mux, flow control and per-stream backpressure are
31
+ * h2's own, not ours to reimplement. The implementation lives in ./ingress-protocol.ts (both halves, one
32
+ * owner); this file pins only what every party must agree on.
33
+ *
34
+ * WHY PER-REQUEST ROUTING IS NOT OPTIONAL: the edge terminates TLS under ONE wildcard certificate, and h2
35
+ * browsers coalesce connections across every hostname a certificate covers — one TCP connection can carry
36
+ * `sandbox-a…` and `preview-x-b…` interleaved. Routing a CONNECTION by its first Host would send one
37
+ * sandbox's requests to another. The unit of routing is the request (h2 stream), never the connection. */
38
+
39
+ // ── The reachability grant ──────────────────────────────────────────────────────────────────────────────
40
+
41
+ /* Version prefix, so a future shape can coexist during a key rotation. Not a negotiation: an ingress that
42
+ * does not know a prefix refuses the tunnel, and the box retries until its operator updates something. */
43
+ const GRANT_PREFIX = "ig1";
44
+
45
+ const base64url = (bytes: Buffer): string => bytes.toString("base64url");
46
+
47
+ // The signed claim. `sub` is the sandbox's 12-hex id (sandboxIdFromToken in ./tunnel-ids); `iat` is seconds.
48
+ // Deliberately no expiry: the grant lives in a container's env for the container's whole life, and the
49
+ // revocation that matters (the sandbox being deleted) is answered by the platform on register, not by time.
50
+ export interface ReachabilityGrant {
51
+ readonly sandboxId: string;
52
+ readonly issuedAt: number;
53
+ }
54
+
55
+ const SANDBOX_ID = /^[0-9a-f]{12}$/;
56
+
57
+ /* Mint a grant: Ed25519 over the canonical payload bytes. Ed25519 because node signs/verifies it with key
58
+ * objects alone (no hash negotiation, no padding modes), signatures are 64 bytes, and the platform already
59
+ * depends on nothing for it — the private key is config (PEM, PKCS8), the public key rides the ingress env. */
60
+ export const mintReachabilityGrant = (privateKeyPem: string, sandboxId: string, issuedAtMs: number): string => {
61
+ if (!SANDBOX_ID.test(sandboxId)) {
62
+ throw new Error(`a reachability grant names a 12-hex sandbox id, got "${sandboxId}"`);
63
+ }
64
+ const payload = Buffer.from(JSON.stringify({ sub: sandboxId, iat: Math.floor(issuedAtMs / 1000) }), "utf8");
65
+ const signature = edSign(null, payload, createPrivateKey(privateKeyPem));
66
+ return `${GRANT_PREFIX}.${base64url(payload)}.${base64url(signature)}`;
67
+ };
68
+
69
+ /* Verify a grant against the platform's public key. Every malformed shape answers undefined rather than
70
+ * throwing: this runs on the ingress's unauthenticated door, where a garbage token is weather, not a fault. */
71
+ export const verifyReachabilityGrant = (publicKeyPem: string, token: string): ReachabilityGrant | undefined => {
72
+ const parts = token.split(".");
73
+ if (parts.length !== 3 || parts[0] !== GRANT_PREFIX) {
74
+ return undefined;
75
+ }
76
+ try {
77
+ const payload = Buffer.from(parts[1] as string, "base64url");
78
+ const signature = Buffer.from(parts[2] as string, "base64url");
79
+ if (!edVerify(null, payload, createPublicKey(publicKeyPem), signature)) {
80
+ return undefined;
81
+ }
82
+ const parsed = JSON.parse(payload.toString("utf8")) as { sub?: unknown; iat?: unknown };
83
+ if (typeof parsed.sub !== "string" || !SANDBOX_ID.test(parsed.sub) || typeof parsed.iat !== "number") {
84
+ return undefined;
85
+ }
86
+ return { sandboxId: parsed.sub, issuedAt: parsed.iat };
87
+ } catch {
88
+ return undefined;
89
+ }
90
+ };
91
+
92
+ // ── Host → owner routing ────────────────────────────────────────────────────────────────────────────────
93
+
94
+ /* Which sandbox may serve this Host. The leftmost DNS label either IS `sandbox-<id>` or ends in `-<id>`
95
+ * (preview/port/public labels, hostnames.ts) — a fixed-length tail, so label keys containing `-` stay
96
+ * unambiguous. Anything else (the ingress's own name, the zone apex, a stray subdomain) answers undefined,
97
+ * which the ingress turns into its 404. The loopback name (`<id>.local.<zone>`) never reaches the ingress —
98
+ * it resolves to 127.0.0.1 — and its bare-id label deliberately does not match here. */
99
+ export const hostOwnerId = (host: string): string | undefined => {
100
+ const label = host.split(":")[0]?.split(".")[0] ?? "";
101
+ const match = /-([0-9a-f]{12})$/.exec(label);
102
+ return match === null ? undefined : match[1];
103
+ };
104
+
105
+ // ── Wire constants ──────────────────────────────────────────────────────────────────────────────────────
106
+
107
+ /* The tunnel door on the ingress. Versioned in the path so a v2 session shape is a new door, not a flag day:
108
+ * the ingress serves both for as long as old containers exist. Checked BEFORE host routing — the ingress's own
109
+ * hostname carries no sandbox id on purpose. */
110
+ export const INGRESS_TUNNEL_PATH = "/tunnel/v1";
111
+
112
+ // The grant rides a header on the tunnel upgrade (a Node client can set one; this is never a browser).
113
+ export const INGRESS_GRANT_HEADER = "x-intentic-grant";
114
+
115
+ /* The env vocabulary, every lane (connect one-liner, compose file, hosted machine env) hands the same pair
116
+ * down and the entrypoint/daemon read exactly these names. SANDBOX_PUBLIC_URL is unchanged from the zrok era
117
+ * and stays beside them. */
118
+ export const ENV_INGRESS_URL = "INGRESS_URL";
119
+ export const ENV_SANDBOX_GRANT = "SANDBOX_GRANT";
120
+
121
+ // ── The daemon-side surface (pinned for the boot wiring) ────────────────────────────────────────────────
122
+
123
+ /* The daemon-side tunnel behavior this contract requires. Dial, register, forward to the loopback listener,
124
+ * reconnect forever with backoff —
125
+ * the tunnel is the sandbox's reachability, so like the zrok agent's restart loop it never gives up, it only
126
+ * ever waits longer. close() is for shutdown and tests. */
127
+ export interface IngressTunnelOptions {
128
+ // e.g. https://ingress.<zone>. The daemon derives the wss:// door itself (INGRESS_TUNNEL_PATH).
129
+ readonly url: string;
130
+ readonly grant: string;
131
+ // The daemon's own loopback listener; every h2 stream lands there as a plain HTTP/1.1 request or upgrade.
132
+ readonly targetPort: number;
133
+ readonly log: (message: string, error?: unknown) => void;
134
+ }
135
+
136
+ export interface IngressTunnelHandle {
137
+ readonly close: () => Promise<void>;
138
+ // For /health and the boot log: whether the tunnel currently holds a registered session.
139
+ readonly connected: () => boolean;
140
+ }
141
+
142
+ export type StartIngressTunnel = (options: IngressTunnelOptions) => IngressTunnelHandle;
143
+
144
+ /* ── What the INGRESS side must also honor (spec, enforced by ingress-protocol tests) ──────────────────────
145
+ *
146
+ * • Register: verify the grant offline; then, when PLATFORM_URL is configured, ask the platform whether the
147
+ * sandbox still exists (GET /api/reachability/<id>, 200/404, answer cached; fail-OPEN on a platform that
148
+ * does not answer — reachability must not depend on the platform being up, that is the whole point of the
149
+ * platform being off the hot path). A 404 refuses the tunnel: that is revocation.
150
+ * • Displacement: a new tunnel for an id closes the old session (code 4001) and takes the registration.
151
+ * • Liveness: WebSocket ping every 15s; a peer silent for 45s is dead and unregistered.
152
+ * • Routing: request host → hostOwnerId → registered tunnel; no tunnel answers 502 with a body naming the
153
+ * sandbox label (the browser's availability flow reads any 5xx as "sandbox unreachable" and drives wake).
154
+ * • The tunnel door itself (INGRESS_TUNNEL_PATH) and anything not carrying a sandbox-id host answer on the
155
+ * ingress directly; they are never routed. */