@quo-systems/dock 0.2.1 → 0.2.2
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/beings/avatar.ts +3 -1
- package/beings/index.ts +1 -1
- package/beings/quo-dock.md +77 -301
- package/beings/user.ts +39 -11
- package/cli/daemon.ts +26 -273
- package/cli/http.ts +70 -0
- package/cli/quo.ts +10 -3
- package/dist/beings/avatar.js +3 -1
- package/dist/beings/index.js +1 -1
- package/dist/beings/user.d.ts +3 -1
- package/dist/beings/user.js +42 -12
- package/dist/cli/daemon.d.ts +6 -18
- package/dist/cli/daemon.js +22 -265
- package/dist/cli/http.d.ts +17 -0
- package/dist/cli/http.js +60 -0
- package/dist/cli/quo.js +12 -3
- package/dist/harbor/quo.d.ts +4 -0
- package/dist/harbor/quo.js +53 -0
- package/dist/human/door.d.ts +5 -0
- package/dist/human/door.js +19 -0
- package/dist/human/screen.js +1 -0
- package/dist/human/tab.d.ts +5 -2
- package/dist/human/tab.js +121 -88
- package/dist/human/web.d.ts +9 -0
- package/dist/human/web.js +95 -0
- package/dist/human/worlds.d.ts +10 -0
- package/dist/human/worlds.js +38 -0
- package/dist/mcp/route.d.ts +12 -0
- package/dist/mcp/route.js +34 -0
- package/harbor/quo-harbor.md +30 -30
- package/harbor/quo.ts +67 -0
- package/human/door.ts +26 -0
- package/human/quo-human.md +58 -24
- package/human/screen.ts +7 -2
- package/human/tab.ts +140 -102
- package/human/web.ts +123 -0
- package/human/worlds.ts +52 -0
- package/mcp/quo-mcp.md +3 -1
- package/mcp/route.ts +39 -0
- package/package.json +2 -2
package/human/web.ts
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// The web route: the worlds' pages, served by the daemon under `/web` and
|
|
3
|
+
// mapped by the proxy from the web. hostname. A world is a ward with a
|
|
4
|
+
// public being, and its address is `/web/<ward>`: the tab page, `tab.ts`,
|
|
5
|
+
// told which ward this is, and every ward on the harbor by name, pk and
|
|
6
|
+
// whether a public being is at its door, so that its guest is that ward's
|
|
7
|
+
// public being rendered by the screen, whatever class she is. Every ward
|
|
8
|
+
// has a page, since a link may point into one with nobody at the door;
|
|
9
|
+
// `/web/` lists the worlds and carries the tab too, so a link at the root
|
|
10
|
+
// lands in the ward its invitation names, and with no world to list it is
|
|
11
|
+
// the door page, `door.ts`. The bundle is built once from the source
|
|
12
|
+
// beside this file, `.ts` in the tree, `.js` once emitted into the
|
|
13
|
+
// package's dist. Nothing here authenticates and nothing is admitted on
|
|
14
|
+
// this side: the way into a world is an invitation, carried by a link, and
|
|
15
|
+
// the knock is the tab's. Paths this route does not take fall through to
|
|
16
|
+
// the exchange pages, which share the prefix.
|
|
17
|
+
//
|
|
18
|
+
// The page carries a content security policy: scripts from this origin
|
|
19
|
+
// only and never inline, connections to this origin and the quo. route the
|
|
20
|
+
// tab dials, images from data URIs and this origin, and nothing else. So
|
|
21
|
+
// even a bug in a renderer cannot become a script, and no look can reach a
|
|
22
|
+
// server. The config crosses in a JSON script, which the policy allows.
|
|
23
|
+
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
24
|
+
import { existsSync } from 'node:fs';
|
|
25
|
+
import { fileURLToPath } from 'node:url';
|
|
26
|
+
import { build } from 'esbuild';
|
|
27
|
+
import type { DiskHarbor, Hosted } from '../harbor/disk.ts';
|
|
28
|
+
import { door } from './door.ts';
|
|
29
|
+
|
|
30
|
+
export type WebOptions = {
|
|
31
|
+
// where the routes are, as a tab sees them: what it dials and where it is
|
|
32
|
+
at: { quo: string; web: string };
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
// The paths under `/web` that are the exchange's, never a ward's name.
|
|
36
|
+
const RESERVED_PATHS = new Set(['login', 'allow', 'tab.js']);
|
|
37
|
+
|
|
38
|
+
export function webRoute(harbor: DiskHarbor, o: WebOptions): (req: IncomingMessage, res: ServerResponse, rest: string) => Promise<boolean> {
|
|
39
|
+
const tabEntry = () => {
|
|
40
|
+
const js = fileURLToPath(new URL('./tab.js', import.meta.url));
|
|
41
|
+
return existsSync(js) ? js : fileURLToPath(new URL('./tab.ts', import.meta.url));
|
|
42
|
+
};
|
|
43
|
+
let bundle: Promise<string> | undefined;
|
|
44
|
+
const built = () =>
|
|
45
|
+
(bundle ??= build({ entryPoints: [tabEntry()], bundle: true, format: 'esm', platform: 'browser', target: 'es2023', write: false }).then((out) => out.outputFiles[0]!.text));
|
|
46
|
+
const quoOrigin = (() => {
|
|
47
|
+
try {
|
|
48
|
+
const u = new URL(o.at.quo);
|
|
49
|
+
return `${u.origin} ${u.origin.replace(/^http/, 'ws')}`;
|
|
50
|
+
} catch {
|
|
51
|
+
return '';
|
|
52
|
+
}
|
|
53
|
+
})();
|
|
54
|
+
const host = (() => {
|
|
55
|
+
try {
|
|
56
|
+
return new URL(o.at.web).host;
|
|
57
|
+
} catch {
|
|
58
|
+
return o.at.web;
|
|
59
|
+
}
|
|
60
|
+
})();
|
|
61
|
+
const policy = `default-src 'none'; script-src 'self'; style-src 'unsafe-inline'; img-src 'self' data:; connect-src 'self' ${quoOrigin}; form-action 'self'; base-uri 'none'; frame-ancestors 'none'`;
|
|
62
|
+
const shell = (body: string) => `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>quo</title>
|
|
63
|
+
<style>${CSS}</style>
|
|
64
|
+
</head><body>${body}</body></html>`;
|
|
65
|
+
const esc = (v: string) => v.replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c] ?? c);
|
|
66
|
+
const publicOf = (h: Hosted) => (h.partition as { public?: string | null }).public ?? null;
|
|
67
|
+
const html = (status: number, res: ServerResponse, body: string) => {
|
|
68
|
+
res.writeHead(status, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store', 'content-security-policy': policy, 'referrer-policy': 'no-referrer' });
|
|
69
|
+
res.end(shell(body));
|
|
70
|
+
return true;
|
|
71
|
+
};
|
|
72
|
+
const wards = () => Object.fromEntries([...harbor.wards].map(([n, h]) => [n, { pk: h.pk, public: publicOf(h) !== null }]));
|
|
73
|
+
const tab = (ward?: string) => {
|
|
74
|
+
const cfg = { quo: o.at.quo, web: o.at.web, wards: wards(), ...(ward ? { ward } : {}) };
|
|
75
|
+
return `<script id="quo" type="application/json">${JSON.stringify(cfg).replace(/</g, '\\u003c')}</script><script type="module" src="${o.at.web}/tab.js"></script>`;
|
|
76
|
+
};
|
|
77
|
+
return async (req, res, rest) => {
|
|
78
|
+
const parts = rest.split('/').filter(Boolean);
|
|
79
|
+
if (req.method === 'GET' && (rest === '' || rest === '/')) {
|
|
80
|
+
const list = [...harbor.wards]
|
|
81
|
+
.filter(([, h]) => publicOf(h) !== null)
|
|
82
|
+
.map(([n, h]) => `<li><a href="${o.at.web}/${encodeURIComponent(n)}">${esc(n)}</a> <small>${esc(publicOf(h) ?? '')} at the door, <code>${h.pk.slice(0, 16)}…</code></small></li>`)
|
|
83
|
+
.join('');
|
|
84
|
+
return html(200, res, `${list ? `<h1>worlds</h1><ul>${list}</ul>` : door({ host })}${tab()}`);
|
|
85
|
+
}
|
|
86
|
+
if (rest === '/tab.js' && req.method === 'GET') {
|
|
87
|
+
res.writeHead(200, { 'content-type': 'text/javascript; charset=utf-8', 'cache-control': 'no-store' });
|
|
88
|
+
res.end(await built());
|
|
89
|
+
return true;
|
|
90
|
+
}
|
|
91
|
+
const wardName = parts[0] ?? '';
|
|
92
|
+
if (!wardName || RESERVED_PATHS.has(wardName)) return false;
|
|
93
|
+
const hosted = harbor.wards.get(wardName);
|
|
94
|
+
if (!hosted) return html(404, res, `<h1>no such world</h1><p>no ward named ${esc(wardName)} on this harbor.</p>`);
|
|
95
|
+
if (parts.length === 1 && req.method === 'GET') return html(200, res, tab(wardName));
|
|
96
|
+
return false;
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// The tab's stylesheet: one, light and dark, honouring the variables a look
|
|
101
|
+
// sets on a section. The page owns layout; a far being paints inside her
|
|
102
|
+
// section and nowhere else. The door page is the one designed thing in it.
|
|
103
|
+
const CSS = [
|
|
104
|
+
':root{color-scheme:light dark;--accent:#3b6ef5;--bg:transparent;--fg:inherit;--font:system-ui,sans-serif;--radius:6px;--ink:#1c1b22;--paper:#fbfaf7;--mute:#6b6a73}',
|
|
105
|
+
'@media(prefers-color-scheme:dark){:root{--ink:#ecebe6;--paper:#141318;--mute:#9a99a2}}',
|
|
106
|
+
'html{background:var(--paper);color:var(--ink)}',
|
|
107
|
+
'body{font:16px/1.5 system-ui,sans-serif;max-width:40rem;margin:2rem auto;padding:0 1rem}',
|
|
108
|
+
'nav.worlds{display:flex;flex-wrap:wrap;gap:.5rem 1rem;font-size:.9rem;opacity:.8}nav.worlds a[aria-current]{font-weight:600}',
|
|
109
|
+
'nav.relations{display:flex;flex-wrap:wrap;gap:.25rem;margin:.5rem 0}nav.relations button{background:transparent;color:inherit;border:1px solid color-mix(in srgb,currentColor 30%,transparent)}nav.relations button[aria-current]{border-color:var(--accent);font-weight:600}',
|
|
110
|
+
'header,main{background:var(--bg);color:var(--fg);font-family:var(--font)}header{display:flex;flex-wrap:wrap;align-items:center;gap:.5rem 1rem}header .notice{width:100%;margin:0}',
|
|
111
|
+
'input,select,textarea,button{font:inherit;padding:.4rem;margin:.2rem;border-radius:var(--radius)}button{background:var(--accent);color:#fff;border:0;padding:.4rem .9rem}',
|
|
112
|
+
'fieldset{border:1px solid color-mix(in srgb,currentColor 20%,transparent);border-radius:var(--radius);margin:.5rem 0}',
|
|
113
|
+
'section.standing{background:var(--bg);color:var(--fg);font-family:var(--font);border-left:4px solid var(--accent);border-radius:var(--radius);padding:.25rem 1rem;margin:1.5rem 0}section.standing h2{display:flex;align-items:center;gap:.5rem;font-size:1.1rem}img.logo{height:1.6rem}',
|
|
114
|
+
'table{border-collapse:collapse}td,th{padding:.15rem .5rem;text-align:left}.answer{margin:.5rem 0 1rem;padding:.5rem;border-left:3px solid var(--accent)}.answer.error{border-color:#c33}.answer.silence,.answer.word,.answer.unreached{border-color:#c93}pre{padding:.75rem;overflow:auto}',
|
|
115
|
+
// the door page: one column, generous air, the mark, a word, a sentence
|
|
116
|
+
'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}',
|
|
117
|
+
'main.door .mark{width:3.5rem;height:3.5rem;color:var(--ink);opacity:.9;margin-bottom:1.25rem}',
|
|
118
|
+
'main.door h1{font-size:3rem;font-weight:400;letter-spacing:-.02em;line-height:1;margin:0 0 .5rem}',
|
|
119
|
+
'main.door .where{font-family:system-ui,sans-serif;font-size:.85rem;letter-spacing:.08em;text-transform:uppercase;color:var(--mute);margin:0 0 2rem}main.door .where span{text-transform:none;letter-spacing:0}',
|
|
120
|
+
'main.door .lead{font-size:1.5rem;line-height:1.3;margin:0 0 1rem}',
|
|
121
|
+
'main.door p{font-size:1.05rem;line-height:1.6;margin:0 0 1rem;max-width:30rem}',
|
|
122
|
+
'main.door .quiet{color:var(--mute);font-size:.95rem;border-top:1px solid color-mix(in srgb,currentColor 15%,transparent);padding-top:1rem;margin-top:1rem}',
|
|
123
|
+
].join('');
|
package/human/worlds.ts
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// The worlds in a tab, and the relations in each. A tab is a device with a
|
|
3
|
+
// harbor of its own, and it keeps one local ward per far world, named by
|
|
4
|
+
// the far ward's pk; inside it, one avatar per relation, since an avatar is
|
|
5
|
+
// one standing and nothing more. An invitation names its ward by pk, so an
|
|
6
|
+
// invitation is self-addressing: the local ward is chosen by the invitation
|
|
7
|
+
// and never by the page it was opened on, and a fresh invitation is always a
|
|
8
|
+
// fresh avatar, never a join on one that already holds her standing. Two
|
|
9
|
+
// humans on one family tablet are two relations in one world; a shop and a
|
|
10
|
+
// friend in one far ward are two as well. Nothing here touches a document:
|
|
11
|
+
// `tab.ts` is the shell over this, and this is what the memory harbor proves.
|
|
12
|
+
import type { Harbor, Hosted } from '@quo-systems/quo/harbor';
|
|
13
|
+
import { Avatar, USER } from '../beings/avatar.ts';
|
|
14
|
+
|
|
15
|
+
// The local ward for a far world, by its pk: created on first sight, on
|
|
16
|
+
// the tab's own seed, empty.
|
|
17
|
+
export const localName = (pk: string): string => `w-${pk.slice(0, 16)}`;
|
|
18
|
+
export async function world(harbor: Harbor, pk: string): Promise<Hosted> {
|
|
19
|
+
const name = localName(pk);
|
|
20
|
+
return harbor.wards.get(name) ?? (await harbor.create(name, 'me'));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// The relations held in a local ward: every avatar there who holds her
|
|
24
|
+
// standing, by key, in the order they were born.
|
|
25
|
+
export type Relation = { key: string; avatar: Avatar };
|
|
26
|
+
export function relations(ward: Hosted): Relation[] {
|
|
27
|
+
const beings = (ward.partition as { beings?: Record<string, { class?: string }> }).beings ?? {};
|
|
28
|
+
const out: Relation[] = [];
|
|
29
|
+
for (const key of Object.keys(beings)) {
|
|
30
|
+
const a = ward.being(key) as Avatar | undefined;
|
|
31
|
+
if (a instanceof Avatar && a.standings[USER]) out.push({ key, avatar: a });
|
|
32
|
+
}
|
|
33
|
+
return out;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// An avatar for a relation not yet made: one who holds no standing, whether
|
|
37
|
+
// left by a guest who never came in or booted now under the next free key.
|
|
38
|
+
// She is the one who knocks, as a guest or with an invitation, and she
|
|
39
|
+
// becomes a relation the moment she takes her standing.
|
|
40
|
+
export async function fresh(ward: Hosted): Promise<Relation> {
|
|
41
|
+
const beings = (ward.partition as { beings?: Record<string, { class?: string }> }).beings ?? {};
|
|
42
|
+
for (const key of Object.keys(beings)) {
|
|
43
|
+
const a = ward.being(key) as Avatar | undefined;
|
|
44
|
+
if (a instanceof Avatar && !a.standings[USER]) return { key, avatar: a };
|
|
45
|
+
}
|
|
46
|
+
let n = 1;
|
|
47
|
+
while (beings[`r${n}`]) n++;
|
|
48
|
+
const key = `r${n}`;
|
|
49
|
+
const out = (await ward.ask('boot', { key, class: 'Avatar' })) as { error?: string };
|
|
50
|
+
if (out.error) throw new Error(`the tab could not boot an avatar: ${out.error}`);
|
|
51
|
+
return { key, avatar: ward.being(key) as Avatar };
|
|
52
|
+
}
|
package/mcp/quo-mcp.md
CHANGED
|
@@ -70,7 +70,9 @@ the two words give it what it needs to decide.
|
|
|
70
70
|
One harbor holds many worlds, and the allow page names the one the client
|
|
71
71
|
is let into; the grant remembers it, so a bearer is an identity in a world
|
|
72
72
|
and a session is that identity's there. `/mcp` stays one endpoint per
|
|
73
|
-
harbor.
|
|
73
|
+
harbor: `mcp/route.ts` is the route the daemon mounts, the exchange in
|
|
74
|
+
`oauth.ts` in front and the endpoint in `http.ts` behind it, with the
|
|
75
|
+
route's store, `<dir>/oauth.json`, the route's own and never a ward's.
|
|
74
76
|
|
|
75
77
|
```
|
|
76
78
|
client mcp. route front desk user being avatar
|
package/mcp/route.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// The mcp. route: the credential exchange and the MCP endpoint, served by
|
|
3
|
+
// the daemon under `/mcp` and mapped by the proxy from the mcp. hostname.
|
|
4
|
+
// The OAuth store is `<dir>/oauth.json`, the route's own and never a
|
|
5
|
+
// ward's: clients, pending requests, codes and tokens, each mapping to a
|
|
6
|
+
// client identity at most. A bearer names an identity in a world; the
|
|
7
|
+
// endpoint hands both to the model side, which runs beside her avatar.
|
|
8
|
+
import { readFile, writeFile, rename } from 'node:fs/promises';
|
|
9
|
+
import { existsSync } from 'node:fs';
|
|
10
|
+
import { join } from 'node:path';
|
|
11
|
+
import { OAuth, emptyStore, type Store } from './oauth.ts';
|
|
12
|
+
import type { McpHttp } from './http.ts';
|
|
13
|
+
import type { Handler } from '../cli/http.ts';
|
|
14
|
+
|
|
15
|
+
// The routes' public faces, as the exchange and the metadata name them.
|
|
16
|
+
export type Routes = { mcp: string; web: string; quo?: string };
|
|
17
|
+
|
|
18
|
+
export async function mcpRoute(dir: string, routes: Routes, mcp: McpHttp | null): Promise<{ oauth: OAuth; handler: Handler }> {
|
|
19
|
+
const file = join(dir, 'oauth.json');
|
|
20
|
+
const store: Store = existsSync(file) ? { ...emptyStore(), ...(JSON.parse(await readFile(file, 'utf8')) as Partial<Store>) } : emptyStore();
|
|
21
|
+
let queue = Promise.resolve();
|
|
22
|
+
const persist = (s: Store) =>
|
|
23
|
+
(queue = queue.then(async () => {
|
|
24
|
+
await writeFile(file + '.tmp', JSON.stringify(s), { mode: 0o600 });
|
|
25
|
+
await rename(file + '.tmp', file);
|
|
26
|
+
}));
|
|
27
|
+
const oauth = new OAuth({ issuer: routes.mcp, resource: `${routes.mcp}/mcp`, finish: (id) => `${routes.web}/login?request=${id}`, store, persist });
|
|
28
|
+
const handler: Handler = async (req, res, rest) => {
|
|
29
|
+
if (await oauth.handle(req, res, rest)) return;
|
|
30
|
+
if (rest === '/mcp') {
|
|
31
|
+
const grant = oauth.bearer(req);
|
|
32
|
+
if (grant === null || !mcp) return oauth.challenge(res);
|
|
33
|
+
return mcp.handle(req, res, grant.identity, grant.ward);
|
|
34
|
+
}
|
|
35
|
+
res.writeHead(404, { 'content-type': 'application/json' });
|
|
36
|
+
res.end(JSON.stringify({ error: 'no such route' }));
|
|
37
|
+
};
|
|
38
|
+
return { oauth, handler };
|
|
39
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@quo-systems/dock",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.2",
|
|
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",
|
|
@@ -68,7 +68,7 @@
|
|
|
68
68
|
},
|
|
69
69
|
"dependencies": {
|
|
70
70
|
"@modelcontextprotocol/sdk": "^1.30.0",
|
|
71
|
-
"@quo-systems/quo": "^0.2.
|
|
71
|
+
"@quo-systems/quo": "^0.2.2",
|
|
72
72
|
"esbuild": "^0.28.2",
|
|
73
73
|
"ws": "^8.21.3"
|
|
74
74
|
},
|