@quo-systems/dock 0.2.7 → 0.2.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -19,7 +19,7 @@ its Caddyfile, and a package file that depends on the dock and nothing
19
19
  else. Add your beings to `droplet/classes/`, and your placement to the
20
20
  routes and agents files.
21
21
 
22
- `beings/quo-dock.md` is the truth of this package and the trunk of its four
22
+ `beings/quo-dock.md` is the truth of this package and the trunk of its five
23
23
  documents; read it first. It knows nothing the spec of Quo does not say,
24
24
  and adds no word to harbor, ward or being.
25
25
 
package/api/quo-api.md ADDED
@@ -0,0 +1,74 @@
1
+ # The api route
2
+
3
+ This is the fourth rendering of one describe. The screen speaks a being's
4
+ describe as forms, the model side speaks it as tools, the CLI speaks it as
5
+ lines, and this route speaks it as plain HTTP and JSON to anyone at all. It
6
+ is one route of the daemon described in `packages/dock/beings/quo-dock.md`,
7
+ which owns the routes, the askers and the shared invariants; this document
8
+ assumes all of that and adds only what the route does.
9
+
10
+ ## What it is for
11
+
12
+ Everything a device hears is one of three askers, mapped once, at the
13
+ edge, and the route is that edge for what arrives as HTTP. An anonymous
14
+ request that is safe to repeat, a page rendering server side, a crawler, a
15
+ tab on another origin, is an ask at a ward's public being as `{}`, which
16
+ is what every stranger is at every door. The route judges nothing and
17
+ names nobody: the public being answers as she answers any stranger, by her
18
+ own gate, and her obligation that the answer be safe to repeat is the same
19
+ here as at her sealed door. Nothing is admitted on this side and no
20
+ occupant is made here.
21
+
22
+ A world is a ward, so the route names one. `main` holds the desk, who is
23
+ public for the first hello and answers a stranger nothing else, so a
24
+ world's public reads live in a ward of their own, and the route is what
25
+ makes a second ward reachable by name from outside: the corpus of a court
26
+ watcher, a catalogue, a board.
27
+
28
+ ## The mapping
29
+
30
+ `packages/dock/api/route.ts` is the route the daemon mounts under `/api`,
31
+ and the proxy maps it from the api. hostname.
32
+
33
+ | request | Quo |
34
+ | ---------------------------------- | ------------------------------------------------------------ |
35
+ | `GET /api/<ward>` | the empty ask at the public being as `{}`: her describe |
36
+ | `GET /api/<ward>/<ask>?k=v` | that ask as `{}`, the query as args, every value a string |
37
+ | `POST /api/<ward>/<ask>` JSON body | that ask as `{}`, the object as args |
38
+ | `OPTIONS` | the preflight, any origin |
39
+
40
+ What she says crosses as the model side crosses it:
41
+
42
+ | she answered | the client gets |
43
+ | ------------------------------ | ------------------------------------------------------ |
44
+ | an object | 200 with that JSON |
45
+ | `{ error }` | 400 with that JSON |
46
+ | silence, a throw, a wait spent | 503 `{ error: 'silence' }`: the work may have happened |
47
+ | an ask not hers for a stranger | 400 `{ error: 'unknown ask' }`, her own answer |
48
+
49
+ And what the route says on its own: 404 for a ward not here, for a ward
50
+ with nobody at the door and for a path that is not one of the three
51
+ shapes, 400 for a body that is not a JSON object, 413 for a body past a
52
+ megabyte, 405 for any other method. Every answer is JSON and open to any
53
+ origin: the route carries nothing a stranger can use that she did not
54
+ choose to say.
55
+
56
+ The ask is bounded to the ward's default allowance, thirty seconds, and a
57
+ wait past it is silence, as a sealed ask of hers would be. The ward is saved
58
+ after every ask, as the daemon saves after any ask it drives in process:
59
+ a public being may write while answering, a tally, a board, and what she
60
+ wrote is hers to keep.
61
+
62
+ ## What is not here
63
+
64
+ A delivery that must be named and once-only, a vendor's webhook, is not a
65
+ stranger's ask and does not land as `{}`: it is a sealed ask from a
66
+ standing the root minted for that source, its keys the route's own on the
67
+ device, the way `oauth.json` is the mcp route's. That half stands when its
68
+ first user does.
69
+
70
+ ## The proof
71
+
72
+ `packages/dock/test/api.test.ts`, over a real daemon on loopback: a world
73
+ with a public board described and asked by anyone, args by query and by
74
+ body, what she wrote kept across a reboot, and every refusal.
package/api/route.ts ADDED
@@ -0,0 +1,87 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // The api. route: HTTP as the fourth rendering of one describe, served by
3
+ // the daemon under `/api` and mapped by the proxy from the api. hostname.
4
+ // Beside forms, tools and the CLI, it speaks a public being's describe and
5
+ // asks as plain JSON to anyone at all, and it is the one place a device
6
+ // maps an anonymous source, a browser fetch, a crawler, a page rendering
7
+ // server side, onto the asker every stranger is: `{}` at the named ward's
8
+ // public being. A world is a ward, so `/api/<ward>` is her describe for
9
+ // nobody and `/api/<ward>/<ask>` is one ask, args from the query on a GET
10
+ // or a JSON object on a POST. The route judges nothing and names nobody:
11
+ // the public being's answer must be safe to repeat, which is her
12
+ // obligation at every door, and what she writes while answering is saved
13
+ // as the daemon saves after any ask it drives in process.
14
+ //
15
+ // What she says crosses as the model side crosses it: an object is 200
16
+ // with that JSON, an error object she answered is 400 with that JSON, and
17
+ // silence, a throw, a wait that ran out, is 503 `{ error: 'silence' }`,
18
+ // which is the ward's own answer for a throw at a public being. A world
19
+ // that is not here or has nobody at the door is 404.
20
+ import type { Handler } from '../cli/http.ts';
21
+ import { readAll } from '../cli/http.ts';
22
+ import type { DiskHarbor, Hosted } from '../harbor/disk.ts';
23
+ import { isSilence, isWord, type BeingLike, type JsonObject } from '@quo-systems/quo';
24
+
25
+ // The ward's own default allowance for an ask, which is what a sealed ask
26
+ // of hers would have carried: a wait past it is silence.
27
+ export const ALLOWANCE = 30_000;
28
+
29
+ const open = { 'access-control-allow-origin': '*', 'access-control-allow-methods': 'GET, POST, OPTIONS', 'access-control-allow-headers': 'content-type' };
30
+
31
+ export function apiRoute(harbor: DiskHarbor): Handler {
32
+ const publicOf = (h: Hosted): BeingLike | undefined => {
33
+ const key = (h.partition as { public?: string | null }).public ?? null;
34
+ return key === null ? undefined : h.being(key);
35
+ };
36
+ return async (req, res, rest) => {
37
+ const json = (status: number, body: unknown) => {
38
+ res.writeHead(status, { 'content-type': 'application/json', ...open });
39
+ res.end(JSON.stringify(body));
40
+ };
41
+ if (req.method === 'OPTIONS') {
42
+ res.writeHead(204, open);
43
+ return void res.end();
44
+ }
45
+ const [, ward = '', ask = '', ...more] = rest.split('/');
46
+ if (req.method !== 'GET' && req.method !== 'POST') return json(405, { error: 'GET or POST' });
47
+ if (!/^[\w.-]+$/.test(ward) || more.length > 0 || (ask !== '' && !/^[\w-]+$/.test(ask))) return json(404, { error: 'GET /api/<ward> for the describe, GET or POST /api/<ward>/<ask> for one ask' });
48
+ const hosted = harbor.wards.get(ward);
49
+ if (!hosted) return json(404, { error: 'no such world' });
50
+ const being = publicOf(hosted);
51
+ if (!being) return json(404, { error: 'nobody is home' });
52
+ let args: JsonObject;
53
+ if (req.method === 'POST') {
54
+ const raw = await readAll(req as AsyncIterable<Buffer>);
55
+ if (raw === undefined) return json(413, { error: 'too much' });
56
+ let parsed: unknown = {};
57
+ try {
58
+ parsed = raw.length === 0 ? {} : JSON.parse(raw.toString('utf8'));
59
+ } catch {
60
+ return json(400, { error: 'the body is a JSON object' });
61
+ }
62
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) return json(400, { error: 'the body is a JSON object' });
63
+ args = parsed as JsonObject;
64
+ } else {
65
+ const url = new URL(req.url ?? '/', 'http://localhost');
66
+ args = Object.fromEntries(url.searchParams.entries());
67
+ }
68
+ // Asked as `{}`, bounded as her door would bound it, and every throw or
69
+ // word is what the door says of one: silence.
70
+ let timer: ReturnType<typeof setTimeout> | undefined;
71
+ const late = new Promise<typeof silent>((ok) => (timer = setTimeout(() => ok(silent), ALLOWANCE)));
72
+ let out: unknown;
73
+ try {
74
+ out = await Promise.race([Promise.resolve(ask === '' ? being.answer({}) : being.answer({}, ask, args)), late]);
75
+ } catch {
76
+ out = silent;
77
+ } finally {
78
+ clearTimeout(timer);
79
+ await hosted.save();
80
+ }
81
+ if (out === silent || isSilence(out) || isWord(out) || out === null || typeof out !== 'object') return json(503, { error: 'silence' });
82
+ if (typeof (out as { error?: unknown }).error === 'string') return json(400, out);
83
+ return json(200, out);
84
+ };
85
+ }
86
+
87
+ const silent = Symbol('silence');
@@ -2,7 +2,7 @@
2
2
 
3
3
  This is the trunk of the dock: what every estate on Quo needs and nobody writes
4
4
  twice. `packages/quo/SPEC.md` is the truth of `packages/quo/src` and knows
5
- nothing of this folder; the four documents under `packages/dock/` are the truth
5
+ nothing of this folder; the five documents under `packages/dock/` are the truth
6
6
  of their folders in the same way, and where any of them disagrees with
7
7
  `packages/quo/SPEC.md`, the spec wins and the document is rewritten. Nothing
8
8
  here adds a word to harbor, ward or being.
@@ -34,13 +34,14 @@ avatar, the harbors on real terrains, the model sides and the screen, the
34
34
  credential exchange. It is general by two rules, under "Generality", and it
35
35
  is published so an adopter installs it once and writes only their estate.
36
36
 
37
- The four documents of the dock:
37
+ The five documents of the dock:
38
38
 
39
39
  | document | owns |
40
40
  | ---------------------- | ------------------------------------------------ |
41
41
  | `beings/quo-dock.md` | this: the beings and rules every side shares |
42
42
  | `human/quo-human.md` | the human side: a blueprint spoken as HTML |
43
43
  | `mcp/quo-mcp.md` | the model side: a blueprint spoken as tools |
44
+ | `api/quo-api.md` | the api route: a public being spoken as HTTP |
44
45
  | `harbor/quo-harbor.md` | transports: harbor core, reach, store, directory |
45
46
 
46
47
  Each names only its folder. The human document never says WebSocket or
@@ -124,7 +125,7 @@ that every arrival is named and judged.
124
125
  second optional ask, `page`, answers her page as a tree of values in the
125
126
  screen's closed grammar; only a screen reads it.
126
127
  - **Route.** A hostname a reverse proxy sends to one process: `web.`, `quo.`,
127
- `mcp.`. Routes are deployment, never boundaries.
128
+ `mcp.`, `api.`. Routes are deployment, never boundaries.
128
129
 
129
130
  ## The ids
130
131
 
@@ -159,7 +160,7 @@ she keeps the last one. A name is a word, held to the same shape as a key.
159
160
 
160
161
  ## Architecture
161
162
 
162
- One droplet, one harbor, three routes. Every other placement is a subset.
163
+ One droplet, one harbor, four routes. Every other placement is a subset.
163
164
 
164
165
  ```
165
166
  acme.com, or razvan.com: the dialable part of an estate
@@ -167,6 +168,7 @@ acme.com, or razvan.com: the dialable part of an estate
167
168
  web. one page per world, /<ward>, the credential exchange, ends with an invitation
168
169
  quo. the harbor's socket door: the rendezvous for every dialer
169
170
  mcp. the model side over HTTP, a credential exchange in front
171
+ api. a world's public being as plain JSON, asked by anyone as {}
170
172
  quo serve: one process, one harbor, the ask pointer
171
173
  unix socket owner asks, local only, never behind the proxy
172
174
  localhost ports the routes above
@@ -426,8 +428,8 @@ and nothing else, holds no class body, and never speaks to a ward except
426
428
  through the ask pointer or an avatar, the two doors everyone has. The
427
429
  daemon's folder holds the process and its doors, the two sockets and the
428
430
  HTTP listener; each route on that listener lives in the folder whose truth
429
- it is, `human/web.ts`, `mcp/route.ts`, `harbor/quo.ts`, and the daemon only
430
- mounts them.
431
+ it is, `human/web.ts`, `mcp/route.ts`, `harbor/quo.ts`, `api/route.ts`, and
432
+ the daemon only mounts them.
431
433
 
432
434
  ```
433
435
  quo init [--dir D] mint a seed, boot a harbor and one ward over a disk store, write both
package/cli/daemon.ts CHANGED
@@ -41,7 +41,8 @@
41
41
  // path, and each lives in the folder whose truth it is: `/web`, the worlds'
42
42
  // pages and the exchange, in `human/web.ts` and `mcp/web/`; `/mcp`, the
43
43
  // model side behind its credential exchange, in `mcp/route.ts`; `/quo`, the
44
- // socket door, in `harbor/quo.ts`. It listens only when asked, and only on
44
+ // socket door, in `harbor/quo.ts`; `/api`, a public being's describe and
45
+ // asks as plain JSON for anyone, in `api/route.ts`. It listens only when asked, and only on
45
46
  // loopback: the proxy is what faces the world, and the daemon never does.
46
47
  // With `--dial URL`, or `<dir>/dial.json`, this daemon is itself a dialer:
47
48
  // it holds one socket to each URL, announces its wards, and reconnects with
@@ -69,6 +70,7 @@ import { McpHttp } from '../mcp/http.ts';
69
70
  import { mcpRoute, type Routes } from '../mcp/route.ts';
70
71
  import { webRoute } from '../human/web.ts';
71
72
  import { quoRoute } from '../harbor/quo.ts';
73
+ import { apiRoute } from '../api/route.ts';
72
74
  import { listenHttp, type Http, type Quo } from './http.ts';
73
75
 
74
76
  export type { Handler, Http, Quo } from './http.ts';
@@ -201,6 +203,7 @@ export async function serve(dir: string, options: Options = {}): Promise<Serving
201
203
  servers.push(server);
202
204
  http = door;
203
205
  http.mount('/quo', quoRoute(harbor, server as HttpServer, quo));
206
+ http.mount('/api', apiRoute(harbor)); // anyone's: a public being asked as {}
204
207
  const routes = options.routes ?? (await readRoutes(harbor.dir));
205
208
  const here = `http://${http.host}:${http.port}`;
206
209
  // the worlds' pages: always, on the daemon's own door when no route names a public one
@@ -383,7 +386,7 @@ async function handle(harbor: DiskHarbor, line: string): Promise<string> {
383
386
  }
384
387
  }
385
388
 
386
- // `<dir>/routes.json`: { mcp, web, quo? }, the public origins the proxy serves.
389
+ // `<dir>/routes.json`: { mcp, web, quo?, api? }, the public origins the proxy serves.
387
390
  export async function readRoutes(dir: string): Promise<Routes | null> {
388
391
  const p = join(dir, 'routes.json');
389
392
  if (!existsSync(p)) return null;
@@ -391,6 +394,7 @@ export async function readRoutes(dir: string): Promise<Routes | null> {
391
394
  if (typeof r.mcp !== 'string' || typeof r.web !== 'string') return null;
392
395
  const out: Routes = { mcp: r.mcp.replace(/\/$/, ''), web: r.web.replace(/\/$/, '') };
393
396
  if (typeof r.quo === 'string') out.quo = r.quo.replace(/\/$/, '');
397
+ if (typeof r.api === 'string') out.api = r.api.replace(/\/$/, '');
394
398
  return out;
395
399
  }
396
400
 
@@ -0,0 +1,4 @@
1
+ import type { Handler } from '../cli/http.ts';
2
+ import type { DiskHarbor } from '../harbor/disk.ts';
3
+ export declare const ALLOWANCE = 30000;
4
+ export declare function apiRoute(harbor: DiskHarbor): Handler;
@@ -0,0 +1,74 @@
1
+ import { readAll } from '../cli/http.js';
2
+ import { isSilence, isWord } from '@quo-systems/quo';
3
+ // The ward's own default allowance for an ask, which is what a sealed ask
4
+ // of hers would have carried: a wait past it is silence.
5
+ export const ALLOWANCE = 30_000;
6
+ const open = { 'access-control-allow-origin': '*', 'access-control-allow-methods': 'GET, POST, OPTIONS', 'access-control-allow-headers': 'content-type' };
7
+ export function apiRoute(harbor) {
8
+ const publicOf = (h) => {
9
+ const key = h.partition.public ?? null;
10
+ return key === null ? undefined : h.being(key);
11
+ };
12
+ return async (req, res, rest) => {
13
+ const json = (status, body) => {
14
+ res.writeHead(status, { 'content-type': 'application/json', ...open });
15
+ res.end(JSON.stringify(body));
16
+ };
17
+ if (req.method === 'OPTIONS') {
18
+ res.writeHead(204, open);
19
+ return void res.end();
20
+ }
21
+ const [, ward = '', ask = '', ...more] = rest.split('/');
22
+ if (req.method !== 'GET' && req.method !== 'POST')
23
+ return json(405, { error: 'GET or POST' });
24
+ if (!/^[\w.-]+$/.test(ward) || more.length > 0 || (ask !== '' && !/^[\w-]+$/.test(ask)))
25
+ return json(404, { error: 'GET /api/<ward> for the describe, GET or POST /api/<ward>/<ask> for one ask' });
26
+ const hosted = harbor.wards.get(ward);
27
+ if (!hosted)
28
+ return json(404, { error: 'no such world' });
29
+ const being = publicOf(hosted);
30
+ if (!being)
31
+ return json(404, { error: 'nobody is home' });
32
+ let args;
33
+ if (req.method === 'POST') {
34
+ const raw = await readAll(req);
35
+ if (raw === undefined)
36
+ return json(413, { error: 'too much' });
37
+ let parsed = {};
38
+ try {
39
+ parsed = raw.length === 0 ? {} : JSON.parse(raw.toString('utf8'));
40
+ }
41
+ catch {
42
+ return json(400, { error: 'the body is a JSON object' });
43
+ }
44
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed))
45
+ return json(400, { error: 'the body is a JSON object' });
46
+ args = parsed;
47
+ }
48
+ else {
49
+ const url = new URL(req.url ?? '/', 'http://localhost');
50
+ args = Object.fromEntries(url.searchParams.entries());
51
+ }
52
+ // Asked as `{}`, bounded as her door would bound it, and every throw or
53
+ // word is what the door says of one: silence.
54
+ let timer;
55
+ const late = new Promise((ok) => (timer = setTimeout(() => ok(silent), ALLOWANCE)));
56
+ let out;
57
+ try {
58
+ out = await Promise.race([Promise.resolve(ask === '' ? being.answer({}) : being.answer({}, ask, args)), late]);
59
+ }
60
+ catch {
61
+ out = silent;
62
+ }
63
+ finally {
64
+ clearTimeout(timer);
65
+ await hosted.save();
66
+ }
67
+ if (out === silent || isSilence(out) || isWord(out) || out === null || typeof out !== 'object')
68
+ return json(503, { error: 'silence' });
69
+ if (typeof out.error === 'string')
70
+ return json(400, out);
71
+ return json(200, out);
72
+ };
73
+ }
74
+ const silent = Symbol('silence');
@@ -41,7 +41,8 @@
41
41
  // path, and each lives in the folder whose truth it is: `/web`, the worlds'
42
42
  // pages and the exchange, in `human/web.ts` and `mcp/web/`; `/mcp`, the
43
43
  // model side behind its credential exchange, in `mcp/route.ts`; `/quo`, the
44
- // socket door, in `harbor/quo.ts`. It listens only when asked, and only on
44
+ // socket door, in `harbor/quo.ts`; `/api`, a public being's describe and
45
+ // asks as plain JSON for anyone, in `api/route.ts`. It listens only when asked, and only on
45
46
  // loopback: the proxy is what faces the world, and the daemon never does.
46
47
  // With `--dial URL`, or `<dir>/dial.json`, this daemon is itself a dialer:
47
48
  // it holds one socket to each URL, announces its wards, and reconnects with
@@ -65,6 +66,7 @@ import { McpHttp } from '../mcp/http.js';
65
66
  import { mcpRoute } from '../mcp/route.js';
66
67
  import { webRoute } from '../human/web.js';
67
68
  import { quoRoute } from '../harbor/quo.js';
69
+ import { apiRoute } from '../api/route.js';
68
70
  import { listenHttp } from './http.js';
69
71
  // The sockets live in the harbor's folder, where the folder's own permissions
70
72
  // guard them. A unix socket path is short by law, about a hundred bytes on
@@ -187,6 +189,7 @@ export async function serve(dir, options = {}) {
187
189
  servers.push(server);
188
190
  http = door;
189
191
  http.mount('/quo', quoRoute(harbor, server, quo));
192
+ http.mount('/api', apiRoute(harbor)); // anyone's: a public being asked as {}
190
193
  const routes = options.routes ?? (await readRoutes(harbor.dir));
191
194
  const here = `http://${http.host}:${http.port}`;
192
195
  // the worlds' pages: always, on the daemon's own door when no route names a public one
@@ -384,7 +387,7 @@ async function handle(harbor, line) {
384
387
  return JSON.stringify({ id, error: e instanceof Error ? e.message : String(e) });
385
388
  }
386
389
  }
387
- // `<dir>/routes.json`: { mcp, web, quo? }, the public origins the proxy serves.
390
+ // `<dir>/routes.json`: { mcp, web, quo?, api? }, the public origins the proxy serves.
388
391
  export async function readRoutes(dir) {
389
392
  const p = join(dir, 'routes.json');
390
393
  if (!existsSync(p))
@@ -395,6 +398,8 @@ export async function readRoutes(dir) {
395
398
  const out = { mcp: r.mcp.replace(/\/$/, ''), web: r.web.replace(/\/$/, '') };
396
399
  if (typeof r.quo === 'string')
397
400
  out.quo = r.quo.replace(/\/$/, '');
401
+ if (typeof r.api === 'string')
402
+ out.api = r.api.replace(/\/$/, '');
398
403
  return out;
399
404
  }
400
405
  // `<dir>/dial.json`: [url, ...], the quo. routes this daemon holds a socket to.
@@ -45,6 +45,10 @@ function hook() {
45
45
  return;
46
46
  hooked = true;
47
47
  const self = import.meta.url;
48
+ // bundled into one script, as the desk's sidecar is, the dock has no URL and no folder of
49
+ // packages beside it to resolve to: a class file there imports nothing bare
50
+ if (!self)
51
+ return;
48
52
  register(new URL(self.endsWith('.ts') ? './resolve.ts' : './resolve.js', self), { parentURL: self, data: { parent: self } });
49
53
  }
50
54
  // The class source is a module. Every export that is a class is a class the
@@ -5,6 +5,7 @@ export type Routes = {
5
5
  mcp: string;
6
6
  web: string;
7
7
  quo?: string;
8
+ api?: string;
8
9
  };
9
10
  export declare function mcpRoute(dir: string, routes: Routes, mcp: McpHttp | null): Promise<{
10
11
  oauth: OAuth;
package/harbor/disk.ts CHANGED
@@ -41,7 +41,10 @@ let hooked = false;
41
41
  function hook(): void {
42
42
  if (hooked) return;
43
43
  hooked = true;
44
- const self = import.meta.url;
44
+ const self = import.meta.url as string | undefined;
45
+ // bundled into one script, as the desk's sidecar is, the dock has no URL and no folder of
46
+ // packages beside it to resolve to: a class file there imports nothing bare
47
+ if (!self) return;
45
48
  register(new URL(self.endsWith('.ts') ? './resolve.ts' : './resolve.js', self), { parentURL: self, data: { parent: self } });
46
49
  }
47
50
 
@@ -462,6 +462,46 @@ things the plugins taught, held in the store: the secure store keeps
462
462
  JSON, so a value is read with the call that parses; and mkdir refuses a
463
463
  folder that exists, recursive or not.
464
464
 
465
+ ## The desk, as built
466
+
467
+ The desk app is a window onto the daemon that already runs, and the daemon
468
+ is the node daemon above, unchanged. `packages/app/tauri/` is the Tauri
469
+ project, and its Rust core does three things at start: it reads or mints
470
+ the one wrap key in the Mac Keychain, under the app's service name, this
471
+ device only; it looks for the lease in the harbor folder, `~/.quo` or
472
+ `QUO_DIR`, and attaches to the daemon behind it when its pid is alive; and
473
+ when nobody holds the lease it spawns the sidecar with the folder and the
474
+ key in its environment, `QUO_DIR` and `QUO_SEED_KEY`. The key is handed
475
+ always. A folder of plain files is refused by the sidecar itself, as the
476
+ files store says, and the window shows the refusal; a daemon already
477
+ running under launchd with plain files is attached to as it is, and the
478
+ key goes unused. The core then opens the side socket to the webview: the
479
+ hello line in and its answer back, every line after out as an event, and a
480
+ line from the window written through. The window is a surface and the
481
+ screen stays in the daemon; its page is a placeholder until the pipe
482
+ surface lands.
483
+
484
+ The sidecar is the daemon as one binary, `packages/app/sidecar/`: an entry
485
+ that reaches the dock by name, initialises an empty folder for the
486
+ device's user and serves, bundled with the dock and its two dependencies
487
+ into one CommonJS file and sealed into a copy of node as a single
488
+ executable. A single executable runs one CommonJS script and loads a
489
+ folder's class source from disk by dynamic import as the daemon does, so
490
+ nothing of the daemon is lost in the sealing; the web route's bundler is
491
+ inside and never called, since the sidecar opens no HTTP door. The sidecar
492
+ is the app's process: it leaves on the app's exit, asked with a signal so
493
+ that it releases its lease, and when the app is gone without a word it
494
+ notices its parent changed and leaves the same way.
495
+
496
+ The proof is `packages/app/test/sidecar.test.ts`, behind the app's
497
+ `check:terrain`: the sidecar built, spawned on an empty folder with a key,
498
+ its side socket answering a hello, its one ward kept sealed and nothing
499
+ plain beside it, then spawned again on that folder with no key and
500
+ refused. The Keychain, the lease and the bridge are proven live on the
501
+ operator's Mac: attached to its launchd daemon, the window's avatar shows
502
+ in that daemon's census; on a scratch folder, the spawned sidecar seals the
503
+ ward under the Keychain key and leaves with the app.
504
+
465
505
  ## The link
466
506
 
467
507
  An invitation carries the ward pk of the world that minted it, and not where
@@ -483,17 +523,19 @@ device
483
523
  /mcp the model side; mcp/route.ts
484
524
  /web the worlds' pages and the exchange; human/web.ts, mcp/web/
485
525
  /quo the socket door: request in, sockets held, the rendezvous; harbor/quo.ts
526
+ /api a world's public being as JSON, for anyone; api/route.ts
486
527
  reverse proxy
487
528
  mcp.example.com -> 127.0.0.1:8787/mcp
488
529
  web.example.com -> 127.0.0.1:8787/web
489
530
  quo.example.com -> 127.0.0.1:8787/quo
531
+ api.example.com -> 127.0.0.1:8787/api
490
532
  ```
491
533
 
492
534
  The daemon listens on loopback only, and only when asked, with `--http PORT`
493
535
  or `QUO_HTTP`. The proxy faces the world and terminates TLS; the daemon never
494
536
  does. `estates/lab/droplet/` holds a systemd unit that runs the daemon as
495
537
  one user forever, a launchd agent that does the same on a Mac, and a
496
- Caddyfile that maps the three hostnames onto the one port by path.
538
+ Caddyfile that maps the hostnames onto the one port by path.
497
539
  Unattended, the daemon is that unit: restarted if it dies, the lease
498
540
  released on SIGTERM, and every ward rebooted from its folder on the next
499
541
  start with relations intact. A Mac is a dialer and not a listener: its
package/mcp/route.ts CHANGED
@@ -13,7 +13,7 @@ import type { McpHttp } from './http.ts';
13
13
  import type { Handler } from '../cli/http.ts';
14
14
 
15
15
  // The routes' public faces, as the exchange and the metadata name them.
16
- export type Routes = { mcp: string; web: string; quo?: string };
16
+ export type Routes = { mcp: string; web: string; quo?: string; api?: string };
17
17
 
18
18
  export async function mcpRoute(dir: string, routes: Routes, mcp: McpHttp | null): Promise<{ oauth: OAuth; handler: Handler }> {
19
19
  const file = join(dir, 'oauth.json');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@quo-systems/dock",
3
- "version": "0.2.7",
3
+ "version": "0.2.8",
4
4
  "description": "The dock: what every estate on Quo needs and nobody writes twice. A daemon and the quo command, the front desk, the user being and the avatar, harbors on disk, in a tab and on the edge, the model sides and the screen.",
5
5
  "keywords": [
6
6
  "quo",
@@ -80,7 +80,7 @@
80
80
  "@capacitor/core": "^8.5.1",
81
81
  "@capacitor/filesystem": "^8.1.3",
82
82
  "@modelcontextprotocol/sdk": "^1.30.0",
83
- "@quo-systems/quo": "^0.2.7",
83
+ "@quo-systems/quo": "^0.2.8",
84
84
  "esbuild": "^0.28.2",
85
85
  "ws": "^8.21.3"
86
86
  },
@@ -98,6 +98,7 @@
98
98
  "harbor",
99
99
  "mcp",
100
100
  "human",
101
+ "api",
101
102
  "README.md",
102
103
  "LICENSE",
103
104
  "NOTICE"