@quo-systems/dock 0.2.6 → 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 +1 -1
- package/api/quo-api.md +74 -0
- package/api/route.ts +87 -0
- package/beings/quo-dock.md +9 -7
- package/cli/daemon.ts +6 -2
- package/dist/api/route.d.ts +4 -0
- package/dist/api/route.js +74 -0
- package/dist/cli/daemon.js +7 -2
- package/dist/harbor/capacitor.d.ts +2 -0
- package/dist/harbor/capacitor.js +1 -1
- package/dist/harbor/disk.js +4 -0
- package/dist/human/html.d.ts +2 -0
- package/dist/human/html.js +12 -6
- package/dist/human/screen.d.ts +2 -0
- package/dist/human/screen.js +1 -1
- package/dist/human/tab.d.ts +1 -0
- package/dist/human/tab.js +4 -2
- package/dist/human/web.js +10 -3
- package/dist/mcp/route.d.ts +1 -0
- package/harbor/capacitor.ts +1 -1
- package/harbor/disk.ts +4 -1
- package/harbor/quo-harbor.md +69 -13
- package/human/html.ts +14 -6
- package/human/quo-human.md +11 -3
- package/human/screen.ts +6 -3
- package/human/tab.ts +4 -3
- package/human/web.ts +9 -3
- package/mcp/route.ts +1 -1
- package/package.json +3 -2
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
|
|
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');
|
package/beings/quo-dock.md
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
@@ -83,7 +84,7 @@ Three roles exist, and they are held by where a thing runs, not by config:
|
|
|
83
84
|
| role | what it can do | who can hold it |
|
|
84
85
|
| --------- | ------------------------------------------- | --------------------------------------------------- |
|
|
85
86
|
| occupant | ask a being what her gate shows this asker | anyone, local or remote |
|
|
86
|
-
| owner |
|
|
87
|
+
| owner | reach into a being; boot is every being's | the root, on the device; or a ward the root invited |
|
|
87
88
|
| developer | write a being class the harbor will hold | only a process on the device |
|
|
88
89
|
|
|
89
90
|
Whoever holds all three keeps them apart: the owner creates and places, and
|
|
@@ -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,
|
|
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
|
|
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
|
|
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,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');
|
package/dist/cli/daemon.js
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
|
|
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.
|
|
@@ -1,7 +1,9 @@
|
|
|
1
|
+
import { Directory } from '@capacitor/filesystem';
|
|
1
2
|
import type { BeingClass } from '@quo-systems/quo';
|
|
2
3
|
import type { Kept, Store, WardRecord } from '@quo-systems/quo/harbor';
|
|
3
4
|
import { BrowserHarbor } from './browser.ts';
|
|
4
5
|
export declare function nativeHarbor(name?: string, classes?: Record<string, BeingClass>): Promise<BrowserHarbor>;
|
|
6
|
+
export declare const directory: Directory;
|
|
5
7
|
export declare class Native implements Store {
|
|
6
8
|
#private;
|
|
7
9
|
readonly harbor: string;
|
package/dist/harbor/capacitor.js
CHANGED
|
@@ -36,7 +36,7 @@ export async function nativeHarbor(name = 'quo', classes = {}) {
|
|
|
36
36
|
const { hex, unhex } = arithmetic;
|
|
37
37
|
// Where the files live: the folder iCloud does not copy on iOS, the app's
|
|
38
38
|
// own files on Android, whose manifest says no backup.
|
|
39
|
-
const directory = Capacitor.getPlatform() === 'ios' ? Directory.LibraryNoCloud : Directory.Data;
|
|
39
|
+
export const directory = Capacitor.getPlatform() === 'ios' ? Directory.LibraryNoCloud : Directory.Data;
|
|
40
40
|
async function exists(path) {
|
|
41
41
|
try {
|
|
42
42
|
await Filesystem.stat({ path, directory });
|
package/dist/harbor/disk.js
CHANGED
|
@@ -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
|
package/dist/human/html.d.ts
CHANGED
package/dist/human/html.js
CHANGED
|
@@ -192,6 +192,8 @@ export function page(m) {
|
|
|
192
192
|
const l = look(kept);
|
|
193
193
|
const prefix = `${id}-`;
|
|
194
194
|
const tree = m.pages[id];
|
|
195
|
+
// where her own page is, when the world's page is known and this is not it already
|
|
196
|
+
const own = m.at !== undefined && m.focus !== id ? `<a class="open" href="${escape(`${m.at}/${encodeURIComponent(id)}`)}">open</a>` : '';
|
|
195
197
|
if (tree) {
|
|
196
198
|
const hers = {
|
|
197
199
|
form: (name) => {
|
|
@@ -202,11 +204,11 @@ export function page(m) {
|
|
|
202
204
|
standing: () => '',
|
|
203
205
|
standings: () => '',
|
|
204
206
|
};
|
|
205
|
-
return `<section class="standing" data-standing="${escape(id)}"${l.style}><div class="page">${paint(tree, hers)}</div
|
|
207
|
+
return `<section class="standing" data-standing="${escape(id)}"${l.style}><div class="page">${paint(tree, hers)}</div>${own}</section>`;
|
|
206
208
|
}
|
|
207
209
|
const want = (kept.order ?? []).map((n) => prefix + n);
|
|
208
210
|
const inOrder = ordered(asks, { order: want });
|
|
209
|
-
return `<section class="standing" data-standing="${escape(id)}"${l.style}>${l.head || `<h2>${escape(id)}</h2>`}${inOrder.map(one(kept, prefix)).join('')}</section>`;
|
|
211
|
+
return `<section class="standing" data-standing="${escape(id)}"${l.style}>${l.head || `<h2>${escape(id)}</h2>`}${inOrder.map(one(kept, prefix)).join('')}${own}</section>`;
|
|
210
212
|
};
|
|
211
213
|
const far = Object.keys(groups).map(section).join('');
|
|
212
214
|
// Her page, when she answered one: painted from her tree, each name it
|
|
@@ -220,13 +222,17 @@ export function page(m) {
|
|
|
220
222
|
standing: section,
|
|
221
223
|
standings: () => far,
|
|
222
224
|
};
|
|
223
|
-
const
|
|
224
|
-
|
|
225
|
+
const mineAsks = bp ? ordered(bp.asks.filter((a) => !taken.has(a.name) && !pres.has(a.name)), m.look).map(one(m.look)).join('') : '';
|
|
226
|
+
// A being's page: one standing she carries as the whole page, and nothing of hers around it. A key her
|
|
227
|
+
// describe does not carry paints as nothing, the way a name on a page does: the address adds no right.
|
|
228
|
+
const focused = m.focus !== undefined;
|
|
229
|
+
const asks = focused ? section(m.focus) : m.tree ? `<div class="page">${paint(m.tree, painter)}</div>` : mineAsks + far;
|
|
225
230
|
const shown = bp && bp.notes !== null && typeof bp.notes === 'object' && !Array.isArray(bp.notes) ? Object.fromEntries(Object.entries(bp.notes).filter(([k]) => k !== 'standings' && k !== 'name')) : bp?.notes;
|
|
226
231
|
const notes = bp && shown !== null && shown !== undefined && !(typeof shown === 'object' && !Array.isArray(shown) && !Object.keys(shown).length) ? `<aside class="notes">${view(shown)}</aside>` : '';
|
|
227
232
|
const pushes = m.pushes.length ? `<section class="pushes"><h2>pushes</h2><ol>${m.pushes.map((p) => `<li>${view(p)}</li>`).join('')}</ol></section>` : '';
|
|
228
233
|
const mine = look(m.look);
|
|
229
234
|
// a page carries its own title, so the header keeps only the notice; a being painted as forms is headed by her name
|
|
230
|
-
const
|
|
231
|
-
|
|
235
|
+
const back = focused && m.at !== undefined ? `<a class="back" href="${escape(m.at)}">${escape(title(bp, m.look))}</a>` : '';
|
|
236
|
+
const head = focused ? back : m.tree ? '' : `${m.look.logo ? `<img class="logo" alt="" src="${m.look.logo}">` : ''}<h1>${escape(title(bp, m.look))}</h1>`;
|
|
237
|
+
return `<header${mine.style}>${head}<p class="notice">${escape(m.notice)}</p></header>${focused ? '' : notes}<main${focused ? ' class="focus"' : ''}${mine.style}>${asks}</main>${pushes}`;
|
|
232
238
|
}
|
package/dist/human/screen.d.ts
CHANGED
|
@@ -9,6 +9,8 @@ export type Options = {
|
|
|
9
9
|
after?: () => Promise<void>;
|
|
10
10
|
notice?: string;
|
|
11
11
|
admit?: (invitation: Invitation) => Promise<void>;
|
|
12
|
+
focus?: string;
|
|
13
|
+
at?: string;
|
|
12
14
|
};
|
|
13
15
|
export declare function screenSide(avatar: Subject, surface: Surface, options?: Options): Promise<Serving & {
|
|
14
16
|
model: Model;
|
package/dist/human/screen.js
CHANGED
|
@@ -16,7 +16,7 @@ import { isSilence, isWord } from '@quo-systems/quo';
|
|
|
16
16
|
export async function screenSide(avatar, surface, options = {}) {
|
|
17
17
|
const after = options.after ?? (async () => { });
|
|
18
18
|
const notice = options.notice ?? '';
|
|
19
|
-
const model = { blueprint: null, look: {}, tree: null, pages: {}, notice, answers: {}, pushes: [] };
|
|
19
|
+
const model = { blueprint: null, look: {}, tree: null, pages: {}, notice, answers: {}, pushes: [], ...(options.focus !== undefined ? { focus: options.focus } : {}), ...(options.at !== undefined ? { at: options.at } : {}) };
|
|
20
20
|
let seen = null;
|
|
21
21
|
const show = () => surface.show(page(model));
|
|
22
22
|
// Her describe, again: the page follows the digest.
|
package/dist/human/tab.d.ts
CHANGED
package/dist/human/tab.js
CHANGED
|
@@ -162,7 +162,8 @@ export async function start(cfg, root = document.body) {
|
|
|
162
162
|
keep(AT(pk), rel.key);
|
|
163
163
|
const mine = el('div', '', { class: 'far' });
|
|
164
164
|
screen.append(mine);
|
|
165
|
-
|
|
165
|
+
// the page's address: the world's page, or one being she carries as the whole page
|
|
166
|
+
const s = await screenSide(rel.avatar, domSurface(mine), { after: () => ward.save(), notice, at: pageOf(at.name), ...(cfg.being ? { focus: cfg.being } : {}) });
|
|
166
167
|
side = s;
|
|
167
168
|
const bp = s.model.blueprint;
|
|
168
169
|
const notesName = typeof bp?.notes?.name === 'string' ? bp.notes.name : '';
|
|
@@ -171,7 +172,8 @@ export async function start(cfg, root = document.body) {
|
|
|
171
172
|
keep(NAMES(pk), { ...names(), [rel.key]: called });
|
|
172
173
|
switcher();
|
|
173
174
|
people();
|
|
174
|
-
|
|
175
|
+
if (!cfg.being)
|
|
176
|
+
await boot(rel); // a being's page is hers alone: the world's tab beings stay on the world's page
|
|
175
177
|
};
|
|
176
178
|
// The way in, from any of the three: a fresh avatar joins, the ward is
|
|
177
179
|
// saved, and she is the one on screen.
|
package/dist/human/web.js
CHANGED
|
@@ -54,8 +54,8 @@ export function webRoute(harbor, o) {
|
|
|
54
54
|
return true;
|
|
55
55
|
};
|
|
56
56
|
const wards = () => Object.fromEntries([...harbor.wards].map(([n, h]) => [n, { pk: h.pk, public: publicOf(h) !== null }]));
|
|
57
|
-
const tab = (ward) => {
|
|
58
|
-
const cfg = { quo: o.at.quo, web: o.at.web, wards: wards(), beings: existsSync(beingsEntry), ...(ward ? { ward } : {}) };
|
|
57
|
+
const tab = (ward, being) => {
|
|
58
|
+
const cfg = { quo: o.at.quo, web: o.at.web, wards: wards(), beings: existsSync(beingsEntry), ...(ward ? { ward } : {}), ...(being ? { being } : {}) };
|
|
59
59
|
return `<script id="quo" type="application/json">${JSON.stringify(cfg).replace(/</g, '\\u003c')}</script><script type="module" src="${o.at.web}/tab.js"></script>`;
|
|
60
60
|
};
|
|
61
61
|
return async (req, res, rest) => {
|
|
@@ -88,6 +88,10 @@ export function webRoute(harbor, o) {
|
|
|
88
88
|
return html(404, res, `<h1>no such world</h1><p>no ward named ${esc(wardName)} on this harbor.</p>`);
|
|
89
89
|
if (parts.length === 1 && req.method === 'GET')
|
|
90
90
|
return html(200, res, tab(wardName));
|
|
91
|
+
// a being's page: one standing the user being carries, by its id, as the whole page. No router:
|
|
92
|
+
// the path is a key, and the tab paints nothing for a key her describe does not carry
|
|
93
|
+
if (parts.length === 2 && req.method === 'GET' && /^[\w.-]{1,80}$/.test(parts[1]))
|
|
94
|
+
return html(200, res, tab(wardName, parts[1]));
|
|
91
95
|
return false;
|
|
92
96
|
};
|
|
93
97
|
}
|
|
@@ -116,8 +120,11 @@ const CSS = [
|
|
|
116
120
|
'.page .picture{max-height:6rem}.cards{display:flex;flex-wrap:wrap;gap:.75rem}.card{border:1px solid color-mix(in srgb,currentColor 20%,transparent);border-radius:var(--radius);padding:.5rem .75rem}',
|
|
117
121
|
// a row with a picture is a line about someone or something: the picture small and the words beside it, centred
|
|
118
122
|
'.page .row:has(>.picture){align-items:center;flex-wrap:nowrap}.page .row>.picture{max-height:2.75rem;flex:none}.page .row>.stack{gap:0;min-width:0}.page .row>.stack>p{margin:0}',
|
|
119
|
-
// a far page inside a standing's section: her title is a section's, not the page's
|
|
123
|
+
// a far page inside a standing's section: her title is a section's, not the page's; a link to her own page
|
|
120
124
|
'section.standing .page{padding:.5rem 0}section.standing .page .t-title{font-size:1.3rem;margin:.25rem 0}',
|
|
125
|
+
'a.open{display:inline-block;font-size:.85rem;color:var(--mute);margin:.25rem 0 .5rem}a.back{font-size:.9rem;color:var(--mute);text-decoration:none}a.back::before{content:"\\2190 "}',
|
|
126
|
+
// a being\'s page, the whole page: her section is the page and her title the page\'s
|
|
127
|
+
'main.focus section.standing{border:0;padding:0;margin:0}main.focus section.standing .page .t-title{font-size:2rem;margin:.5rem 0}',
|
|
121
128
|
// the door page: one column, generous air, the mark, a word, a sentence
|
|
122
129
|
'main.door{min-height:70vh;display:flex;flex-direction:column;justify-content:center;align-items:flex-start;max-width:32rem;margin:0 auto;padding:3rem 0;font-family:ui-serif,Georgia,"Times New Roman",serif}',
|
|
123
130
|
'main.door .picture{width:3.5rem;height:3.5rem;color:var(--ink);opacity:.9;margin-bottom:1.25rem}',
|
package/dist/mcp/route.d.ts
CHANGED
package/harbor/capacitor.ts
CHANGED
|
@@ -41,7 +41,7 @@ type Blob = { seed: string; partition: Record<string, unknown>; record: WardReco
|
|
|
41
41
|
|
|
42
42
|
// Where the files live: the folder iCloud does not copy on iOS, the app's
|
|
43
43
|
// own files on Android, whose manifest says no backup.
|
|
44
|
-
const directory = Capacitor.getPlatform() === 'ios' ? Directory.LibraryNoCloud : Directory.Data;
|
|
44
|
+
export const directory = Capacitor.getPlatform() === 'ios' ? Directory.LibraryNoCloud : Directory.Data;
|
|
45
45
|
|
|
46
46
|
async function exists(path: string): Promise<boolean> {
|
|
47
47
|
try {
|
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
|
|
package/harbor/quo-harbor.md
CHANGED
|
@@ -435,18 +435,72 @@ the App plugin's foreground event tells it `wake`, which tells every
|
|
|
435
435
|
dialer, because a phone asleep loses its sockets silently and the wake is
|
|
436
436
|
what dials them back.
|
|
437
437
|
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
438
|
+
On Android the same store stands on the same two plugins: the key in the
|
|
439
|
+
Keystore-backed secure store, the files in the app's own data folder,
|
|
440
|
+
which the manifest excludes from backup with `allowBackup` false, so a
|
|
441
|
+
restore finds nothing there either.
|
|
442
|
+
|
|
443
|
+
The proof is two tests behind `npm run check:terrain` on one stage,
|
|
444
|
+
`packages/dock/test/terrain/phone.ts`: `ios.test.ts` inside the real app
|
|
445
|
+
in the iOS Simulator and `android.test.ts` inside it in the Android
|
|
446
|
+
emulator. Each runs the store suite, untouched, and the custody rule
|
|
447
|
+
against a real secret store and a real folder; the whole conformance
|
|
448
|
+
suite over two harbors on native stores, both dialing a daemon on the
|
|
449
|
+
Mac's loopback through the tab's own probe; and the wake, the app sent
|
|
450
|
+
behind Settings and brought back, its dialer told and its socket held
|
|
451
|
+
again. The app is synced with the stage's origin as its page, built with
|
|
452
|
+
xcodebuild or gradle, installed fresh and launched with simctl or adb; the
|
|
453
|
+
page loads the bundled exercise, `native.ts`, runs it and posts the list
|
|
454
|
+
back. The device names the Mac `localhost`, the Simulator because it
|
|
455
|
+
shares the Mac's network and the emulator because adb reverses the
|
|
456
|
+
stage's two ports, and it has to be that name: a webview grants WebCrypto
|
|
457
|
+
to a plain-http page only on `localhost`, the one origin it counts secure
|
|
458
|
+
without TLS. Through adb's reversed port a socket that Node dropped stays
|
|
459
|
+
open on the device's side, so the stage answers every request with the
|
|
460
|
+
connection closed and every fetch the page makes carries a timeout. Two
|
|
461
|
+
things the plugins taught, held in the store: the secure store keeps
|
|
462
|
+
JSON, so a value is read with the call that parses; and mkdir refuses a
|
|
463
|
+
folder that exists, recursive or not.
|
|
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.
|
|
450
504
|
|
|
451
505
|
## The link
|
|
452
506
|
|
|
@@ -469,17 +523,19 @@ device
|
|
|
469
523
|
/mcp the model side; mcp/route.ts
|
|
470
524
|
/web the worlds' pages and the exchange; human/web.ts, mcp/web/
|
|
471
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
|
|
472
527
|
reverse proxy
|
|
473
528
|
mcp.example.com -> 127.0.0.1:8787/mcp
|
|
474
529
|
web.example.com -> 127.0.0.1:8787/web
|
|
475
530
|
quo.example.com -> 127.0.0.1:8787/quo
|
|
531
|
+
api.example.com -> 127.0.0.1:8787/api
|
|
476
532
|
```
|
|
477
533
|
|
|
478
534
|
The daemon listens on loopback only, and only when asked, with `--http PORT`
|
|
479
535
|
or `QUO_HTTP`. The proxy faces the world and terminates TLS; the daemon never
|
|
480
536
|
does. `estates/lab/droplet/` holds a systemd unit that runs the daemon as
|
|
481
537
|
one user forever, a launchd agent that does the same on a Mac, and a
|
|
482
|
-
Caddyfile that maps the
|
|
538
|
+
Caddyfile that maps the hostnames onto the one port by path.
|
|
483
539
|
Unattended, the daemon is that unit: restarted if it dies, the lease
|
|
484
540
|
released on SIGTERM, and every ward rebooted from its folder on the next
|
|
485
541
|
start with relations intact. A Mac is a dialer and not a listener: its
|
package/human/html.ts
CHANGED
|
@@ -143,6 +143,8 @@ export type Model = {
|
|
|
143
143
|
look: Look; // her own look, from her `look` ask, or nothing
|
|
144
144
|
tree: Node | null; // her page, from her `page` ask, or nothing: then her asks are painted as forms
|
|
145
145
|
pages: Record<string, Node | null>; // the page of each standing she carries, by id, from its carried `page` ask
|
|
146
|
+
focus?: string; // one standing she carries shown as the whole page, its id: the page at `web./<ward>/<being>`
|
|
147
|
+
at?: string; // the world's page path, so a section can say where its own page is and a focused page where back is
|
|
146
148
|
notice: string; // one line about where the human stands: in, not in, an error before an ask
|
|
147
149
|
answers: Record<string, Word>; // the last answer per ask, shown under its form
|
|
148
150
|
pushes: JsonObject[]; // every push from the world, newest last
|
|
@@ -215,6 +217,8 @@ export function page(m: Model): string {
|
|
|
215
217
|
const l = look(kept);
|
|
216
218
|
const prefix = `${id}-`;
|
|
217
219
|
const tree = m.pages[id];
|
|
220
|
+
// where her own page is, when the world's page is known and this is not it already
|
|
221
|
+
const own = m.at !== undefined && m.focus !== id ? `<a class="open" href="${escape(`${m.at}/${encodeURIComponent(id)}`)}">open</a>` : '';
|
|
218
222
|
if (tree) {
|
|
219
223
|
const hers: Painter = {
|
|
220
224
|
form: (name) => {
|
|
@@ -225,11 +229,11 @@ export function page(m: Model): string {
|
|
|
225
229
|
standing: () => '',
|
|
226
230
|
standings: () => '',
|
|
227
231
|
};
|
|
228
|
-
return `<section class="standing" data-standing="${escape(id)}"${l.style}><div class="page">${paint(tree, hers)}</div
|
|
232
|
+
return `<section class="standing" data-standing="${escape(id)}"${l.style}><div class="page">${paint(tree, hers)}</div>${own}</section>`;
|
|
229
233
|
}
|
|
230
234
|
const want = (kept.order ?? []).map((n) => prefix + n);
|
|
231
235
|
const inOrder = ordered(asks, { order: want });
|
|
232
|
-
return `<section class="standing" data-standing="${escape(id)}"${l.style}>${l.head || `<h2>${escape(id)}</h2>`}${inOrder.map(one(kept, prefix)).join('')}</section>`;
|
|
236
|
+
return `<section class="standing" data-standing="${escape(id)}"${l.style}>${l.head || `<h2>${escape(id)}</h2>`}${inOrder.map(one(kept, prefix)).join('')}${own}</section>`;
|
|
233
237
|
};
|
|
234
238
|
const far = Object.keys(groups).map(section).join('');
|
|
235
239
|
// Her page, when she answered one: painted from her tree, each name it
|
|
@@ -243,13 +247,17 @@ export function page(m: Model): string {
|
|
|
243
247
|
standing: section,
|
|
244
248
|
standings: () => far,
|
|
245
249
|
};
|
|
246
|
-
const
|
|
247
|
-
|
|
250
|
+
const mineAsks = bp ? ordered(bp.asks.filter((a) => !taken.has(a.name) && !pres.has(a.name)), m.look).map(one(m.look)).join('') : '';
|
|
251
|
+
// A being's page: one standing she carries as the whole page, and nothing of hers around it. A key her
|
|
252
|
+
// describe does not carry paints as nothing, the way a name on a page does: the address adds no right.
|
|
253
|
+
const focused = m.focus !== undefined;
|
|
254
|
+
const asks = focused ? section(m.focus!) : m.tree ? `<div class="page">${paint(m.tree, painter)}</div>` : mineAsks + far;
|
|
248
255
|
const shown = bp && bp.notes !== null && typeof bp.notes === 'object' && !Array.isArray(bp.notes) ? Object.fromEntries(Object.entries(bp.notes).filter(([k]) => k !== 'standings' && k !== 'name')) : bp?.notes;
|
|
249
256
|
const notes = bp && shown !== null && shown !== undefined && !(typeof shown === 'object' && !Array.isArray(shown) && !Object.keys(shown).length) ? `<aside class="notes">${view(shown as Json)}</aside>` : '';
|
|
250
257
|
const pushes = m.pushes.length ? `<section class="pushes"><h2>pushes</h2><ol>${m.pushes.map((p) => `<li>${view(p)}</li>`).join('')}</ol></section>` : '';
|
|
251
258
|
const mine = look(m.look);
|
|
252
259
|
// a page carries its own title, so the header keeps only the notice; a being painted as forms is headed by her name
|
|
253
|
-
const
|
|
254
|
-
|
|
260
|
+
const back = focused && m.at !== undefined ? `<a class="back" href="${escape(m.at)}">${escape(title(bp, m.look))}</a>` : '';
|
|
261
|
+
const head = focused ? back : m.tree ? '' : `${m.look.logo ? `<img class="logo" alt="" src="${m.look.logo}">` : ''}<h1>${escape(title(bp, m.look))}</h1>`;
|
|
262
|
+
return `<header${mine.style}>${head}<p class="notice">${escape(m.notice)}</p></header>${focused ? '' : notes}<main${focused ? ' class="focus"' : ''}${mine.style}>${asks}</main>${pushes}`;
|
|
255
263
|
}
|
package/human/quo-human.md
CHANGED
|
@@ -159,9 +159,17 @@ Read with the trunk's "Carrying" and "The look", which this applies.
|
|
|
159
159
|
page is a knock on the public invitation, and an answer that is an
|
|
160
160
|
invitation is the way in: the side hands it to `admit`, the avatar joins,
|
|
161
161
|
and the page becomes hers. Nothing in the screen knows the word desk.
|
|
162
|
-
- **Worlds have addresses
|
|
163
|
-
lists the worlds, and a hostname per world
|
|
164
|
-
estate's choice. `quo.` stays one per harbor,
|
|
162
|
+
- **Worlds have addresses, and so do the beings one holds.** `web./<ward>`
|
|
163
|
+
is that world's page, `web./` lists the worlds, and a hostname per world
|
|
164
|
+
is one proxy line, the estate's choice. `quo.` stays one per harbor,
|
|
165
|
+
since bytes route by pk. `web./<ward>/<being>` is the page of one
|
|
166
|
+
standing the user being carries, by its id, as the whole page: her
|
|
167
|
+
section is the page, her title the page's, the shell's own asks and the
|
|
168
|
+
world's tab beings stay on the world's page, and a line at the top leads
|
|
169
|
+
back. No router: the path is a key, the server hands it to the tab as it
|
|
170
|
+
hands the ward's name, and a key her describe does not carry for this
|
|
171
|
+
device paints as nothing, since an address adds no right. Every section
|
|
172
|
+
on the world's page says where its own page is.
|
|
165
173
|
- **A link is the page with the invitation in the fragment**, under the one
|
|
166
174
|
reserved key `quo`, the invitation compact as `ward.heir.secret` or
|
|
167
175
|
`ward` alone: `web.acme.com/shop#quo=...`. The fragment never leaves the
|
package/human/screen.ts
CHANGED
|
@@ -31,12 +31,15 @@ export type Surface = {
|
|
|
31
31
|
// would bring her back from a reload with a stale count, and the far door
|
|
32
32
|
// would refuse her. `admit` is what to do
|
|
33
33
|
// with an answer that is an invitation: a guest at a world's door is let in
|
|
34
|
-
// by it, and a side with no `admit` shows it as any answer.
|
|
35
|
-
|
|
34
|
+
// by it, and a side with no `admit` shows it as any answer. `focus` is one
|
|
35
|
+
// standing she carries shown as the whole page, the page at
|
|
36
|
+
// `web./<ward>/<being>`, and `at` the world's page path the sections and a
|
|
37
|
+
// focused page link to.
|
|
38
|
+
export type Options = { after?: () => Promise<void>; notice?: string; admit?: (invitation: Invitation) => Promise<void>; focus?: string; at?: string };
|
|
36
39
|
export async function screenSide(avatar: Subject, surface: Surface, options: Options = {}): Promise<Serving & { model: Model; refresh(): Promise<void> }> {
|
|
37
40
|
const after = options.after ?? (async () => {});
|
|
38
41
|
const notice = options.notice ?? '';
|
|
39
|
-
const model: Model = { blueprint: null, look: {}, tree: null, pages: {}, notice, answers: {}, pushes: [] };
|
|
42
|
+
const model: Model = { blueprint: null, look: {}, tree: null, pages: {}, notice, answers: {}, pushes: [], ...(options.focus !== undefined ? { focus: options.focus } : {}), ...(options.at !== undefined ? { at: options.at } : {}) };
|
|
40
43
|
let seen: string | null = null;
|
|
41
44
|
const show = () => surface.show(page(model));
|
|
42
45
|
|
package/human/tab.ts
CHANGED
|
@@ -40,7 +40,7 @@ import type { BeingClass } from '@quo-systems/quo';
|
|
|
40
40
|
// every ward on that harbor by name, its pk and whether a public being is
|
|
41
41
|
// at its door, which of them this page is, if it is one's, and whether the
|
|
42
42
|
// world serves code for the tab.
|
|
43
|
-
export type Config = { quo: string; web: string; wards: Record<string, { pk: string; public: boolean }>; ward?: string; beings?: boolean };
|
|
43
|
+
export type Config = { quo: string; web: string; wards: Record<string, { pk: string; public: boolean }>; ward?: string; being?: string; beings?: boolean };
|
|
44
44
|
|
|
45
45
|
// What the tab remembers between pages, beside the harbor: the worlds it
|
|
46
46
|
// has joined, by pk, where each lives and what it is called; which relation
|
|
@@ -190,7 +190,8 @@ export async function start(cfg: Config, root: HTMLElement = document.body): Pro
|
|
|
190
190
|
keep(AT(pk), rel.key);
|
|
191
191
|
const mine = el('div', '', { class: 'far' });
|
|
192
192
|
screen.append(mine);
|
|
193
|
-
|
|
193
|
+
// the page's address: the world's page, or one being she carries as the whole page
|
|
194
|
+
const s = await screenSide(rel.avatar, domSurface(mine), { after: () => ward.save(), notice, at: pageOf(at.name), ...(cfg.being ? { focus: cfg.being } : {}) });
|
|
194
195
|
side = s;
|
|
195
196
|
const bp = s.model.blueprint;
|
|
196
197
|
const notesName = typeof (bp?.notes as JsonObject | null)?.name === 'string' ? ((bp!.notes as JsonObject).name as string) : '';
|
|
@@ -198,7 +199,7 @@ export async function start(cfg: Config, root: HTMLElement = document.body): Pro
|
|
|
198
199
|
if (!names()[rel.key]) keep(NAMES(pk), { ...names(), [rel.key]: called });
|
|
199
200
|
switcher();
|
|
200
201
|
people();
|
|
201
|
-
await boot(rel);
|
|
202
|
+
if (!cfg.being) await boot(rel); // a being's page is hers alone: the world's tab beings stay on the world's page
|
|
202
203
|
};
|
|
203
204
|
|
|
204
205
|
// The way in, from any of the three: a fresh avatar joins, the ward is
|
package/human/web.ts
CHANGED
|
@@ -84,8 +84,8 @@ export function webRoute(harbor: DiskHarbor, o: WebOptions): (req: IncomingMessa
|
|
|
84
84
|
return true;
|
|
85
85
|
};
|
|
86
86
|
const wards = () => Object.fromEntries([...harbor.wards].map(([n, h]) => [n, { pk: h.pk, public: publicOf(h) !== null }]));
|
|
87
|
-
const tab = (ward?: string) => {
|
|
88
|
-
const cfg = { quo: o.at.quo, web: o.at.web, wards: wards(), beings: existsSync(beingsEntry), ...(ward ? { ward } : {}) };
|
|
87
|
+
const tab = (ward?: string, being?: string) => {
|
|
88
|
+
const cfg = { quo: o.at.quo, web: o.at.web, wards: wards(), beings: existsSync(beingsEntry), ...(ward ? { ward } : {}), ...(being ? { being } : {}) };
|
|
89
89
|
return `<script id="quo" type="application/json">${JSON.stringify(cfg).replace(/</g, '\\u003c')}</script><script type="module" src="${o.at.web}/tab.js"></script>`;
|
|
90
90
|
};
|
|
91
91
|
return async (req, res, rest) => {
|
|
@@ -114,6 +114,9 @@ export function webRoute(harbor: DiskHarbor, o: WebOptions): (req: IncomingMessa
|
|
|
114
114
|
const hosted = harbor.wards.get(wardName);
|
|
115
115
|
if (!hosted) return html(404, res, `<h1>no such world</h1><p>no ward named ${esc(wardName)} on this harbor.</p>`);
|
|
116
116
|
if (parts.length === 1 && req.method === 'GET') return html(200, res, tab(wardName));
|
|
117
|
+
// a being's page: one standing the user being carries, by its id, as the whole page. No router:
|
|
118
|
+
// the path is a key, and the tab paints nothing for a key her describe does not carry
|
|
119
|
+
if (parts.length === 2 && req.method === 'GET' && /^[\w.-]{1,80}$/.test(parts[1]!)) return html(200, res, tab(wardName, parts[1]!));
|
|
117
120
|
return false;
|
|
118
121
|
};
|
|
119
122
|
}
|
|
@@ -143,8 +146,11 @@ const CSS = [
|
|
|
143
146
|
'.page .picture{max-height:6rem}.cards{display:flex;flex-wrap:wrap;gap:.75rem}.card{border:1px solid color-mix(in srgb,currentColor 20%,transparent);border-radius:var(--radius);padding:.5rem .75rem}',
|
|
144
147
|
// a row with a picture is a line about someone or something: the picture small and the words beside it, centred
|
|
145
148
|
'.page .row:has(>.picture){align-items:center;flex-wrap:nowrap}.page .row>.picture{max-height:2.75rem;flex:none}.page .row>.stack{gap:0;min-width:0}.page .row>.stack>p{margin:0}',
|
|
146
|
-
// a far page inside a standing's section: her title is a section's, not the page's
|
|
149
|
+
// a far page inside a standing's section: her title is a section's, not the page's; a link to her own page
|
|
147
150
|
'section.standing .page{padding:.5rem 0}section.standing .page .t-title{font-size:1.3rem;margin:.25rem 0}',
|
|
151
|
+
'a.open{display:inline-block;font-size:.85rem;color:var(--mute);margin:.25rem 0 .5rem}a.back{font-size:.9rem;color:var(--mute);text-decoration:none}a.back::before{content:"\\2190 "}',
|
|
152
|
+
// a being\'s page, the whole page: her section is the page and her title the page\'s
|
|
153
|
+
'main.focus section.standing{border:0;padding:0;margin:0}main.focus section.standing .page .t-title{font-size:2rem;margin:.5rem 0}',
|
|
148
154
|
// the door page: one column, generous air, the mark, a word, a sentence
|
|
149
155
|
'main.door{min-height:70vh;display:flex;flex-direction:column;justify-content:center;align-items:flex-start;max-width:32rem;margin:0 auto;padding:3rem 0;font-family:ui-serif,Georgia,"Times New Roman",serif}',
|
|
150
156
|
'main.door .picture{width:3.5rem;height:3.5rem;color:var(--ink);opacity:.9;margin-bottom:1.25rem}',
|
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.
|
|
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.
|
|
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"
|