@questpie/sandbox 3.1.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/dist/index.mjs ADDED
@@ -0,0 +1,69 @@
1
+ import { a as validateEgressHosts, i as parseHostEntry, n as httpSandboxAdapter, o as validateHostEgress, r as classifyIpLiteral, t as HttpSandboxAdapter } from "./adapter-http-w7SjAfcC.mjs";
2
+
3
+ //#region src/types.ts
4
+ /** Line prefix marking a framed protocol message on guest stdout. */
5
+ const FRAME_MARKER = "__QP_SANDBOX_MSG__";
6
+ /**
7
+ * Header carrying the per-run scoped token on the supervisor → broker request.
8
+ * Mirrors `questpie`'s `BINDINGS_TOKEN_HEADER`. Supervisor-only; never set by or
9
+ * exposed to guest code.
10
+ */
11
+ const BINDINGS_TOKEN_HEADER = "x-questpie-sandbox-token";
12
+
13
+ //#endregion
14
+ //#region src/guest-bindings.ts
15
+ /**
16
+ * Build the guest bindings proxy over an injected {@link HostCall}.
17
+ *
18
+ * `collections` is a Proxy so any collection NAME resolves to a `{ find,
19
+ * findOne }` shim — capability enforcement for the specific collection happens
20
+ * HOST-SIDE in the broker (the guest can't pre-know which names are granted, and
21
+ * the proxy must not leak that). An out-of-scope collection call simply rejects
22
+ * when the host denies it.
23
+ */
24
+ function buildGuestBindings(hostCall) {
25
+ return {
26
+ files: {
27
+ read: (args) => hostCall("files.read", args),
28
+ write: (args) => hostCall("files.write", args),
29
+ list: (args) => hostCall("files.list", args ?? {})
30
+ },
31
+ collections: new Proxy({}, { get(_t, name) {
32
+ if (typeof name !== "string") return void 0;
33
+ return {
34
+ find: (args) => hostCall(`collections.${name}.find`, args ?? {}),
35
+ findOne: (args) => hostCall(`collections.${name}.findOne`, args ?? {}),
36
+ create: (args) => hostCall(`collections.${name}.create`, args ?? {}),
37
+ update: (args) => hostCall(`collections.${name}.update`, args ?? {}),
38
+ delete: (args) => hostCall(`collections.${name}.delete`, args ?? {})
39
+ };
40
+ } }),
41
+ store: new Proxy({}, { get(_t, name) {
42
+ if (typeof name !== "string") return void 0;
43
+ const withStoreWhere = (args) => {
44
+ const where = args.where && typeof args.where === "object" ? {
45
+ ...args.where,
46
+ store: name
47
+ } : { store: name };
48
+ return {
49
+ ...args,
50
+ where
51
+ };
52
+ };
53
+ const ds = (op, args) => hostCall(`collections.document_store.${op}`, args);
54
+ return {
55
+ find: (args = {}) => ds("find", withStoreWhere(args)),
56
+ findOne: (args = {}) => ds("findOne", withStoreWhere(args)),
57
+ create: (args) => ds("create", {
58
+ ...args ?? {},
59
+ store: name
60
+ }),
61
+ update: (args) => ds("update", withStoreWhere(args ?? {})),
62
+ delete: (args = {}) => ds("delete", withStoreWhere(args))
63
+ };
64
+ } })
65
+ };
66
+ }
67
+
68
+ //#endregion
69
+ export { BINDINGS_TOKEN_HEADER, FRAME_MARKER, HttpSandboxAdapter, buildGuestBindings, classifyIpLiteral, httpSandboxAdapter, parseHostEntry, validateEgressHosts, validateHostEgress };
package/package.json ADDED
@@ -0,0 +1,71 @@
1
+ {
2
+ "name": "@questpie/sandbox",
3
+ "version": "3.1.0",
4
+ "private": false,
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "https://github.com/questpie/questpie.git",
8
+ "directory": "packages/sandbox"
9
+ },
10
+ "files": [
11
+ "dist",
12
+ "src/sandbox-server.ts",
13
+ "src/guest-entry.ts",
14
+ "src/net-validation.ts",
15
+ "src/server-internals.ts",
16
+ "src/egress-firewall.ts",
17
+ "src/types.ts",
18
+ "src/guest-bindings.ts"
19
+ ],
20
+ "type": "module",
21
+ "sideEffects": false,
22
+ "main": "./dist/index.mjs",
23
+ "module": "./dist/index.mjs",
24
+ "types": "./dist/index.d.mts",
25
+ "exports": {
26
+ ".": {
27
+ "types": "./src/exports/index.ts",
28
+ "default": "./src/exports/index.ts"
29
+ },
30
+ "./adapter": {
31
+ "types": "./src/exports/adapter.ts",
32
+ "default": "./src/exports/adapter.ts"
33
+ },
34
+ "./package.json": {
35
+ "types": "./package.json",
36
+ "default": "./package.json"
37
+ }
38
+ },
39
+ "publishConfig": {
40
+ "access": "public",
41
+ "exports": {
42
+ ".": {
43
+ "types": "./dist/index.d.mts",
44
+ "default": "./dist/index.mjs"
45
+ },
46
+ "./adapter": {
47
+ "types": "./dist/adapter.d.mts",
48
+ "default": "./dist/adapter.mjs"
49
+ },
50
+ "./package.json": {
51
+ "types": "./package.json",
52
+ "default": "./package.json"
53
+ }
54
+ }
55
+ },
56
+ "scripts": {
57
+ "build": "tsdown",
58
+ "check-types": "tsc --noEmit",
59
+ "test": "bun test"
60
+ },
61
+ "dependencies": {
62
+ "questpie": "workspace:*"
63
+ },
64
+ "devDependencies": {
65
+ "bun-types": "latest",
66
+ "tsdown": "^0.18.3"
67
+ },
68
+ "peerDependencies": {
69
+ "questpie": "^3.0.0"
70
+ }
71
+ }
@@ -0,0 +1,330 @@
1
+ /**
2
+ * Defense-in-depth egress firewall (LINUX-ONLY, cloud workers) — milestone S6 of
3
+ * spec `sandbox-brokered-http-egress`.
4
+ *
5
+ * ── What this is ────────────────────────────────────────────────────────────
6
+ * A *belt-and-suspenders* kernel-level egress drop for the guest `deno run`
7
+ * subprocess. The BASELINE SSRF defense is the brokered `http.fetch` path: the
8
+ * guest runs with `--allow-net=[]` and cannot open sockets at all, so it has no
9
+ * network reach to lose. This layer adds a SECOND, independent boundary at the
10
+ * KERNEL: even if a future change granted the guest raw `--allow-net`, or a
11
+ * low-level escape (e.g. a Deno/V8 bug, a smuggled `--allow-import` host that
12
+ * rebinds) opened a socket, the kernel DROPS every packet to a
13
+ * private/link-local/loopback/metadata address. Only the resolved per-run
14
+ * allowlist (plus the host broker) is permitted. This is what Deno Subhosting
15
+ * does for its isolates.
16
+ *
17
+ * ── Platform honesty ────────────────────────────────────────────────────────
18
+ * Network namespaces + nftables are a LINUX KERNEL feature. There is NO
19
+ * equivalent on macOS or Windows, and the kernel-drop behavior CANNOT be
20
+ * functionally verified off Linux. Therefore this module is designed to be
21
+ * GRACEFULLY ABSENT: {@link planEgressFirewall} returns a structured plan that
22
+ * the supervisor consults. On non-Linux — or on Linux when the required tools
23
+ * (`unshare`, `nft`, `ip`) or the `CAP_NET_ADMIN`/`CAP_SYS_ADMIN` capabilities
24
+ * are absent — the plan is `{ applied:false, reason }` and the supervisor spawns
25
+ * the guest EXACTLY as before. The local Mac/Windows workers are unaffected; the
26
+ * baseline stays safe via the brokered fetch.
27
+ *
28
+ * ── This file is PURE + testable ────────────────────────────────────────────
29
+ * Like `server-internals.ts`, this module holds NO Deno globals and NO top-level
30
+ * side effects, so it lands inside the package `tsconfig` `include` and is
31
+ * unit-tested under `bun:test`. The tests assert the LOGIC — the exact nftables
32
+ * ruleset text and the exact wrapped argv — which is what carries the security
33
+ * guarantee. They do NOT (and on macOS cannot) assert the kernel actually drops
34
+ * the packet; that is verified manually on a Linux worker (see the spec/task
35
+ * `## Verify` and the module-level Linux repro note below).
36
+ *
37
+ * The Deno-only wiring (platform/tool/cap probing + the real spawn wrap) lives
38
+ * in `sandbox-server.ts`, which is `@ts-nocheck` and excluded from the typecheck.
39
+ *
40
+ * ── LINUX MANUAL VERIFICATION (documented-only; needs a Linux worker) ────────
41
+ * 1. Run the sandbox-server on a Linux host (root or with CAP_NET_ADMIN +
42
+ * CAP_SYS_ADMIN; `unshare`, `nft`, `ip` on PATH).
43
+ * 2. POST /run a guest that is (for the test) granted `--allow-net` and tries
44
+ * `await fetch("http://169.254.169.254/latest/meta-data/")`.
45
+ * 3. Even though the broker is bypassed, the connect() is DROPPED at the
46
+ * kernel by the nft `output` chain → the guest's fetch fails with a network
47
+ * error; the metadata service is never reached.
48
+ * 4. A guest fetch to an allowlisted PUBLIC IP succeeds (it is in the per-run
49
+ * `accept` set). A fetch to any other public IP is also dropped (default
50
+ * policy is DROP; only the allowlist + broker are accepted).
51
+ * On macOS/Windows: {@link planEgressFirewall} returns `applied:false` and the
52
+ * guest spawns unchanged — the kernel drop is NOT exercised here.
53
+ */
54
+
55
+ /**
56
+ * Private / link-local / loopback / metadata ranges the firewall DROPS.
57
+ *
58
+ * These MUST stay in lock-step with the classifier in `net-validation.ts`
59
+ * (`isPrivateIpv4` / `isPrivateIpv6`) — the request-time allowlist check and the
60
+ * kernel drop must cover the SAME address space, or the two layers disagree.
61
+ * CIDR form (what `nft` consumes) of the exact same set.
62
+ */
63
+ export const BLOCKED_IPV4_CIDRS: readonly string[] = [
64
+ "0.0.0.0/8", // "this host" / unspecified
65
+ "10.0.0.0/8", // RFC-1918 private
66
+ "100.64.0.0/10", // RFC-6598 CGNAT
67
+ "127.0.0.0/8", // loopback
68
+ "169.254.0.0/16", // link-local (incl. 169.254.169.254 cloud metadata)
69
+ "172.16.0.0/12", // RFC-1918 private
70
+ "192.168.0.0/16", // RFC-1918 private
71
+ ];
72
+
73
+ /**
74
+ * IPv6 counterparts. `::ffff:0:0/96` (IPv4-mapped) is dropped wholesale so a
75
+ * mapped private literal cannot sneak through; allowlisted public targets are
76
+ * accepted by their NATIVE v4/v6 address, not a mapped form.
77
+ */
78
+ export const BLOCKED_IPV6_CIDRS: readonly string[] = [
79
+ "::1/128", // loopback
80
+ "::/128", // unspecified
81
+ "::ffff:0:0/96", // IPv4-mapped (covers ::ffff:127.0.0.1, ::ffff:169.254.169.254, …)
82
+ "fc00::/7", // unique-local (incl. fd00:ec2::254 cloud metadata)
83
+ "fe80::/10", // link-local
84
+ ];
85
+
86
+ /** Name of the nft table the firewall installs (per-run, torn down with the netns). */
87
+ export const NFT_TABLE = "questpie_sandbox_egress";
88
+
89
+ /**
90
+ * A single resolved allowlist endpoint the guest is permitted to reach. The
91
+ * supervisor resolves each per-run allowlist host to its validated public IP(s)
92
+ * (reusing `net-validation.ts`) and pins them here, so the kernel `accept` rules
93
+ * reference IPs — never hostnames (nft cannot match DNS, and pinning the IP is
94
+ * itself part of the anti-rebind guarantee).
95
+ */
96
+ export interface AllowedEndpoint {
97
+ /** Validated public IP literal (v4 or v6) the guest may egress to. */
98
+ ip: string;
99
+ /** Optional destination port to scope the accept to (e.g. 443). Omit = any port. */
100
+ port?: number;
101
+ }
102
+
103
+ export interface EgressFirewallPlanInput {
104
+ /** `globalThis.Deno.build.os` (or `process.platform`-mapped) — the only OS gate. */
105
+ os: string;
106
+ /**
107
+ * Per-run resolved allowlist + broker IP(s), already validated as PUBLIC by
108
+ * `net-validation.ts`. These become the kernel `accept` rules. Empty = the
109
+ * guest may reach nothing (default-deny), which is the correct posture for the
110
+ * brokered path (`--allow-net=[]`): nothing to allow, everything dropped.
111
+ */
112
+ allow: readonly AllowedEndpoint[];
113
+ /**
114
+ * Which required tools were found on PATH (probed by the supervisor with
115
+ * `Deno.Command(...).spawn` / `which`). All must be present to apply the
116
+ * firewall; a missing tool → graceful no-op with a logged notice.
117
+ */
118
+ tools: { unshare: boolean; nft: boolean; ip: boolean };
119
+ /**
120
+ * Whether the supervisor holds the namespace/firewall capabilities
121
+ * (root, or CAP_NET_ADMIN + CAP_SYS_ADMIN). Probed by the supervisor; when
122
+ * false the firewall is a graceful no-op (the netns/nft syscalls would EPERM).
123
+ */
124
+ hasCaps: boolean;
125
+ }
126
+
127
+ /** The skip outcome: the guest spawns unchanged; the supervisor logs `reason`. */
128
+ export interface EgressFirewallSkip {
129
+ applied: false;
130
+ /** Human-readable why-skipped, logged once by the supervisor as a notice. */
131
+ reason: string;
132
+ }
133
+
134
+ /** The apply outcome: the supervisor wraps the guest argv with {@link wrapWithNetns}. */
135
+ export interface EgressFirewallApply {
136
+ applied: true;
137
+ /** The nftables ruleset to load inside the guest's netns (an `nft -f -` script). */
138
+ nftRuleset: string;
139
+ /** Count of accept rules emitted (diagnostic; 0 = pure default-deny). */
140
+ allowCount: number;
141
+ }
142
+
143
+ export type EgressFirewallPlan = EgressFirewallSkip | EgressFirewallApply;
144
+
145
+ /** True only for Linux. macOS (`darwin`) / Windows (`windows`) → false. */
146
+ export function isLinux(os: string): boolean {
147
+ return os === "linux";
148
+ }
149
+
150
+ /**
151
+ * `process.platform` → `Deno.build.os` vocabulary, so the supervisor and any
152
+ * Bun/Node probe agree on one OS string. Deno uses `darwin` / `windows` / `linux`.
153
+ */
154
+ export function normalizeOs(platform: string): string {
155
+ if (platform === "win32") return "windows";
156
+ return platform; // darwin, linux already match Deno's vocabulary
157
+ }
158
+
159
+ /** Basic IPv4-literal test (mirrors net-validation, kept local to stay pure). */
160
+ function looksIpv4(ip: string): boolean {
161
+ const parts = ip.split(".");
162
+ return (
163
+ parts.length === 4 &&
164
+ parts.every((p) => /^\d{1,3}$/.test(p) && Number(p) >= 0 && Number(p) <= 255)
165
+ );
166
+ }
167
+
168
+ /**
169
+ * Build the nftables ruleset (an `nft -f -` script) for ONE run.
170
+ *
171
+ * Shape (an `inet` table → one `output` hook chain, default policy DROP):
172
+ * - ACCEPT established/related + loopback `lo` (DNS/own-process plumbing).
173
+ * - DROP every {@link BLOCKED_IPV4_CIDRS} / {@link BLOCKED_IPV6_CIDRS} dest.
174
+ * (Listed BEFORE the allowlist so a misclassified allow can never re-open a
175
+ * private range — drop wins.)
176
+ * - ACCEPT each resolved {@link AllowedEndpoint} (the per-run allowlist +
177
+ * broker), pinned to its validated public IP (and port, if given).
178
+ * - Final implicit DROP via the chain's `policy drop`.
179
+ *
180
+ * Deterministic: same input → byte-identical output, so it is unit-testable.
181
+ */
182
+ export function buildNftRuleset(allow: readonly AllowedEndpoint[]): string {
183
+ const lines: string[] = [];
184
+ lines.push(`# QUESTPIE sandbox egress firewall (defense-in-depth, per-run).`);
185
+ lines.push(`# Default-deny: only the resolved allowlist + broker may egress.`);
186
+ lines.push(`table inet ${NFT_TABLE} {`);
187
+ lines.push(`\tchain output {`);
188
+ lines.push(`\t\ttype filter hook output priority 0; policy drop;`);
189
+ // Keep own-process plumbing + reply traffic working.
190
+ lines.push(`\t\tct state established,related accept`);
191
+ lines.push(`\t\toif "lo" accept`);
192
+
193
+ // DROP the private/metadata space FIRST — drop must win over any allow.
194
+ for (const cidr of BLOCKED_IPV4_CIDRS) {
195
+ lines.push(`\t\tip daddr ${cidr} drop`);
196
+ }
197
+ for (const cidr of BLOCKED_IPV6_CIDRS) {
198
+ lines.push(`\t\tip6 daddr ${cidr} drop`);
199
+ }
200
+
201
+ // ACCEPT the resolved per-run allowlist (public IPs only — already validated).
202
+ for (const ep of allow) {
203
+ const fam = looksIpv4(ep.ip) ? "ip" : "ip6";
204
+ const portClause =
205
+ typeof ep.port === "number" && Number.isInteger(ep.port)
206
+ ? ` tcp dport ${ep.port}`
207
+ : "";
208
+ lines.push(`\t\t${fam} daddr ${ep.ip}${portClause} accept`);
209
+ }
210
+
211
+ lines.push(`\t}`);
212
+ lines.push(`}`);
213
+ return lines.join("\n") + "\n";
214
+ }
215
+
216
+ /**
217
+ * Decide whether to apply the firewall for this run, and build its artifacts.
218
+ *
219
+ * Returns `{ applied:false, reason }` (graceful no-op) when:
220
+ * - the OS is not Linux (macOS / Windows local workers), OR
221
+ * - a required tool (`unshare`/`nft`/`ip`) is missing, OR
222
+ * - the supervisor lacks the netns/firewall capabilities.
223
+ *
224
+ * Otherwise returns `{ applied:true, nftRuleset, allowCount }`; the supervisor
225
+ * then wraps the guest argv with {@link wrapWithNetns}.
226
+ *
227
+ * NOTE: an empty `allow` is VALID and still applies on Linux — it yields a pure
228
+ * default-deny netns, the correct posture for the brokered (`--allow-net=[]`)
229
+ * path. The guest has nothing to reach over raw sockets; the kernel enforces it.
230
+ */
231
+ export function planEgressFirewall(input: EgressFirewallPlanInput): EgressFirewallPlan {
232
+ if (!isLinux(input.os)) {
233
+ return {
234
+ applied: false,
235
+ reason: `non-Linux platform (${input.os}); netns+nftables egress firewall unavailable — baseline stays safe via brokered fetch`,
236
+ };
237
+ }
238
+ const missing: string[] = [];
239
+ if (!input.tools.unshare) missing.push("unshare");
240
+ if (!input.tools.nft) missing.push("nft");
241
+ if (!input.tools.ip) missing.push("ip");
242
+ if (missing.length > 0) {
243
+ return {
244
+ applied: false,
245
+ reason: `required tool(s) not found on PATH: ${missing.join(", ")} — skipping kernel egress firewall (brokered fetch still enforces SSRF policy)`,
246
+ };
247
+ }
248
+ if (!input.hasCaps) {
249
+ return {
250
+ applied: false,
251
+ reason: `supervisor lacks CAP_NET_ADMIN/CAP_SYS_ADMIN (not root / no caps) — netns+nftables cannot be created; skipping (brokered fetch still enforces SSRF policy)`,
252
+ };
253
+ }
254
+ return {
255
+ applied: true,
256
+ nftRuleset: buildNftRuleset(input.allow),
257
+ allowCount: input.allow.length,
258
+ };
259
+ }
260
+
261
+ /**
262
+ * Wrap a guest `deno run …` argv so it executes inside a fresh NETWORK
263
+ * NAMESPACE with the egress firewall loaded, via a small bootstrap run under
264
+ * `unshare`.
265
+ *
266
+ * The produced argv is:
267
+ *
268
+ * unshare --net --map-root-user -- /bin/sh -c '<bootstrap> && exec "$@"' -- <guest argv…>
269
+ *
270
+ * where `<bootstrap>` brings up loopback (`ip link set lo up`), loads the
271
+ * per-run ruleset (`nft -f <rulesetPath>`), then `exec`s the unchanged guest
272
+ * command. Running the guest as PID-in-its-own-netns means killing the
273
+ * supervisor's child still reaps it (the existing SIGTERM/SIGKILL path is
274
+ * unchanged — this only prefixes the command).
275
+ *
276
+ * `--map-root-user` lets an UNPRIVILEGED supervisor create the netns via a user
277
+ * namespace where it is root *inside* (modern kernels with unprivileged userns).
278
+ * On hosts where that is disabled, the supervisor's cap probe returns false and
279
+ * we never get here — the firewall no-ops instead (see {@link planEgressFirewall}).
280
+ *
281
+ * IMPORTANT: this is a PURE argv transform (no syscalls), so it is unit-testable
282
+ * off Linux. The actual namespace/firewall effect is exercised only on Linux.
283
+ *
284
+ * @param guestArgv the original `[DENO_BIN, "run", …flags, entry]` the
285
+ * supervisor would have spawned.
286
+ * @param rulesetPath absolute path to the ruleset file the supervisor wrote
287
+ * (from {@link EgressFirewallApply.nftRuleset}).
288
+ * @returns `{ cmd, args }` for `new Deno.Command(cmd, { args, … })`.
289
+ */
290
+ export function wrapWithNetns(
291
+ guestArgv: readonly string[],
292
+ rulesetPath: string,
293
+ ): { cmd: string; args: string[] } {
294
+ // Bootstrap that runs INSIDE the new netns before the guest:
295
+ // 0. set a deterministic PATH so `nft`/`ip` resolve. The supervisor spawns
296
+ // `unshare` with `clearEnv:true`, so the inner shell would otherwise get
297
+ // execvp's default PATH (no `/sbin` or `/usr/sbin`, where nft/ip usually
298
+ // live) → "command not found". Pinning PATH here is robust + deterministic.
299
+ // 1. bring loopback up (so the guest's own intra-process plumbing works),
300
+ // 2. load the egress ruleset,
301
+ // 3. exec the guest argv (passed as positional "$@").
302
+ // `set -e` so a failed nft load aborts BEFORE the guest runs (fail-closed:
303
+ // we never run the guest in a netns whose firewall did not load).
304
+ const bootstrap = [
305
+ "set -e",
306
+ "PATH=/usr/sbin:/usr/bin:/sbin:/bin:$PATH",
307
+ "ip link set lo up",
308
+ `nft -f ${shQuote(rulesetPath)}`,
309
+ 'exec "$@"',
310
+ ].join("; ");
311
+
312
+ return {
313
+ cmd: "unshare",
314
+ args: [
315
+ "--net", // fresh network namespace (no interfaces but lo)
316
+ "--map-root-user", // root-inside via userns for unprivileged hosts
317
+ "--",
318
+ "/bin/sh",
319
+ "-c",
320
+ bootstrap,
321
+ "sh", // $0 for the inner shell
322
+ ...guestArgv, // become "$@" → exec'd as the guest
323
+ ],
324
+ };
325
+ }
326
+
327
+ /** Minimal POSIX single-quote escaping for embedding a path in the `sh -c` bootstrap. */
328
+ function shQuote(s: string): string {
329
+ return `'${s.replace(/'/g, `'\\''`)}'`;
330
+ }
@@ -0,0 +1,146 @@
1
+ /**
2
+ * Guest bindings proxy — the UNTRUSTED-side typed surface a sandboxed mini-app
3
+ * uses to call the app's primitives WITHOUT importing the app.
4
+ *
5
+ * Design: `.private/knowledge-miniapps-mvp.md` §13 (TRUSTED vs UNTRUSTED) + §14
6
+ * (the primitive surface). Mirrors the server `ctx` names (consistent-naming
7
+ * principle) so guest code reads like a server handler:
8
+ *
9
+ * const orders = await globalThis.questpie.collections.orders.find({ where });
10
+ * const note = await globalThis.questpie.files.read({ path });
11
+ *
12
+ * Every method just does `await hostCall(method, args)`. `hostCall` is INJECTED
13
+ * (it writes a framed RPC to the supervisor over stdio and awaits the brokered
14
+ * result) — this module holds NO transport and NO secrets, so it is pure and
15
+ * unit-testable with a fake `hostCall`. The guest never sees the per-run token
16
+ * or a broker URL; the only thing it can do is call these methods, which the
17
+ * trusted host broker enforces per call (default-deny).
18
+ *
19
+ * This file is plain TS (no Deno globals) so it typechecks + unit-tests in Bun;
20
+ * `guest-entry.ts` (Deno) imports it and supplies the real stdio `hostCall`.
21
+ */
22
+
23
+ /** Transport: send one binding RPC, resolve with its brokered value or reject. */
24
+ export type HostCall = (method: string, args: unknown) => Promise<unknown>;
25
+
26
+ /**
27
+ * Per-collection access surface. Reads (`find`/`findOne`) are always available;
28
+ * writes (`create`/`update`/`delete`) are dispatched ONLY where the host wires a
29
+ * safe handler (the `document_store` store-grant clamp, or a collection with an
30
+ * explicit write rule — Decision 8 / §7). An ungranted/unwired call rejects host-side.
31
+ */
32
+ export interface GuestCollection {
33
+ find(args?: unknown): Promise<unknown>;
34
+ findOne(args?: unknown): Promise<unknown>;
35
+ create(args?: unknown): Promise<unknown>;
36
+ update(args?: unknown): Promise<unknown>;
37
+ delete(args?: unknown): Promise<unknown>;
38
+ }
39
+
40
+ /**
41
+ * Sugar surface for ONE `document_store` namespace. Every op auto-injects
42
+ * `store: <name>`; the host still clamps to the run's granted stores (the sugar is
43
+ * a convenience, NOT a second enforcement path — `questpie.store.posts.create(x)`
44
+ * is exactly `questpie.collections.document_store.create({ ...x, store:"posts" })`).
45
+ */
46
+ export interface GuestStore {
47
+ find(args?: Record<string, unknown>): Promise<unknown>;
48
+ findOne(args?: Record<string, unknown>): Promise<unknown>;
49
+ create(args: { key: string; data?: unknown }): Promise<unknown>;
50
+ update(args: {
51
+ where?: unknown;
52
+ data?: Record<string, unknown>;
53
+ }): Promise<unknown>;
54
+ delete(args?: { where?: unknown }): Promise<unknown>;
55
+ }
56
+
57
+ /** The `globalThis.questpie` surface handed to untrusted guest code. */
58
+ export interface GuestBindings {
59
+ /** Files (file-as-DB), scoped by the run's `capabilities.files`. */
60
+ files: {
61
+ read(args: { path: string; scope?: unknown }): Promise<unknown>;
62
+ write(args: {
63
+ path: string;
64
+ content: string;
65
+ title?: string;
66
+ mime_type?: string;
67
+ scope?: unknown;
68
+ }): Promise<unknown>;
69
+ list(args?: { path?: string; scope?: unknown }): Promise<unknown>;
70
+ };
71
+ /** Per-collection read/write access, scoped by `capabilities.data.collections`. */
72
+ collections: Record<string, GuestCollection>;
73
+ /**
74
+ * Per-namespace `document_store` sugar, scoped by `capabilities.data.stores`.
75
+ * `questpie.store.<name>.<op>` injects `store:<name>` onto the underlying
76
+ * `collections.document_store.<op>` call.
77
+ */
78
+ store: Record<string, GuestStore>;
79
+ }
80
+
81
+ /**
82
+ * Build the guest bindings proxy over an injected {@link HostCall}.
83
+ *
84
+ * `collections` is a Proxy so any collection NAME resolves to a `{ find,
85
+ * findOne }` shim — capability enforcement for the specific collection happens
86
+ * HOST-SIDE in the broker (the guest can't pre-know which names are granted, and
87
+ * the proxy must not leak that). An out-of-scope collection call simply rejects
88
+ * when the host denies it.
89
+ */
90
+ export function buildGuestBindings(hostCall: HostCall): GuestBindings {
91
+ const files: GuestBindings["files"] = {
92
+ read: (args) => hostCall("files.read", args),
93
+ write: (args) => hostCall("files.write", args),
94
+ list: (args) => hostCall("files.list", args ?? {}),
95
+ };
96
+
97
+ const collections = new Proxy(
98
+ {},
99
+ {
100
+ get(_t, name: string | symbol): GuestCollection | undefined {
101
+ if (typeof name !== "string") return undefined;
102
+ return {
103
+ find: (args) => hostCall(`collections.${name}.find`, args ?? {}),
104
+ findOne: (args) =>
105
+ hostCall(`collections.${name}.findOne`, args ?? {}),
106
+ create: (args) => hostCall(`collections.${name}.create`, args ?? {}),
107
+ update: (args) => hostCall(`collections.${name}.update`, args ?? {}),
108
+ delete: (args) => hostCall(`collections.${name}.delete`, args ?? {}),
109
+ };
110
+ },
111
+ },
112
+ ) as Record<string, GuestCollection>;
113
+
114
+ // `store.<name>` sugar → `collections.document_store.<op>` scoped to ONE store.
115
+ // CREATE injects a top-level `store` (the row field the host stamps/validates);
116
+ // FIND/UPDATE/DELETE inject `where.store` (so the call targets only that store —
117
+ // the host then ANDs it with `store ∈ granted`). Enforcement stays HOST-SIDE
118
+ // (the store-grant clamp); this is a pure convenience, so an ungranted store
119
+ // still rejects host-side.
120
+ const store = new Proxy(
121
+ {},
122
+ {
123
+ get(_t, name: string | symbol): GuestStore | undefined {
124
+ if (typeof name !== "string") return undefined;
125
+ const withStoreWhere = (args: Record<string, unknown>) => {
126
+ const where =
127
+ args.where && typeof args.where === "object"
128
+ ? { ...(args.where as Record<string, unknown>), store: name }
129
+ : { store: name };
130
+ return { ...args, where };
131
+ };
132
+ const ds = (op: string, args: unknown) =>
133
+ hostCall(`collections.document_store.${op}`, args);
134
+ return {
135
+ find: (args = {}) => ds("find", withStoreWhere(args)),
136
+ findOne: (args = {}) => ds("findOne", withStoreWhere(args)),
137
+ create: (args) => ds("create", { ...(args ?? {}), store: name }),
138
+ update: (args) => ds("update", withStoreWhere(args ?? {})),
139
+ delete: (args = {}) => ds("delete", withStoreWhere(args)),
140
+ };
141
+ },
142
+ },
143
+ ) as Record<string, GuestStore>;
144
+
145
+ return { files, collections, store };
146
+ }