@quo-systems/dock 0.1.0 → 0.2.1
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 +9 -2
- package/beings/avatar.ts +6 -2
- package/beings/carry.ts +101 -0
- package/beings/desk.ts +2 -2
- package/beings/index.ts +2 -0
- package/beings/link.ts +54 -0
- package/beings/look.ts +96 -0
- package/beings/quo-dock.md +185 -38
- package/beings/setup.ts +3 -1
- package/beings/side.ts +10 -1
- package/beings/user.ts +31 -5
- package/cli/daemon.ts +98 -44
- package/cli/estate/Caddyfile +25 -0
- package/cli/estate/quo.service +36 -0
- package/cli/estate.ts +44 -0
- package/cli/quo.ts +14 -3
- package/dist/beings/avatar.js +7 -2
- package/dist/beings/carry.d.ts +10 -0
- package/dist/beings/carry.js +106 -0
- package/dist/beings/desk.d.ts +1 -0
- package/dist/beings/desk.js +1 -1
- package/dist/beings/index.d.ts +2 -0
- package/dist/beings/index.js +2 -0
- package/dist/beings/link.d.ts +7 -0
- package/dist/beings/link.js +42 -0
- package/dist/beings/look.d.ts +27 -0
- package/dist/beings/look.js +71 -0
- package/dist/beings/setup.js +4 -1
- package/dist/beings/side.d.ts +8 -1
- package/dist/beings/user.d.ts +26 -2
- package/dist/beings/user.js +32 -5
- package/dist/cli/daemon.d.ts +1 -1
- package/dist/cli/daemon.js +98 -44
- package/dist/cli/estate/Caddyfile +25 -0
- package/dist/cli/estate/quo.service +36 -0
- package/dist/cli/estate.d.ts +5 -0
- package/dist/cli/estate.js +46 -0
- package/dist/cli/quo.js +14 -3
- package/dist/harbor/edge/exercise.js +3 -1
- package/dist/harbor/edge/platform.d.ts +28 -0
- package/dist/human/guest.d.ts +3 -0
- package/dist/human/guest.js +25 -0
- package/dist/human/html.d.ts +10 -2
- package/dist/human/html.js +61 -10
- package/dist/human/screen.d.ts +8 -3
- package/dist/human/screen.js +25 -6
- package/dist/human/tab.d.ts +2 -0
- package/dist/human/tab.js +127 -42
- package/dist/mcp/http.d.ts +4 -3
- package/dist/mcp/http.js +6 -6
- package/dist/mcp/oauth.d.ts +11 -4
- package/dist/mcp/oauth.js +32 -21
- package/dist/mcp/pilot.d.ts +3 -4
- package/dist/mcp/pilot.js +19 -65
- package/dist/mcp/server.d.ts +6 -4
- package/dist/mcp/server.js +48 -11
- package/dist/mcp/web/exchange.d.ts +5 -2
- package/dist/mcp/web/exchange.js +21 -7
- package/harbor/edge/exercise.ts +2 -1
- package/harbor/quo-harbor.md +37 -0
- package/human/guest.ts +26 -0
- package/human/html.ts +58 -10
- package/human/quo-human.md +123 -66
- package/human/screen.ts +28 -7
- package/human/tab.ts +153 -51
- package/mcp/http.ts +10 -9
- package/mcp/oauth.ts +39 -23
- package/mcp/pilot.ts +26 -65
- package/mcp/quo-mcp.md +34 -19
- package/mcp/server.ts +52 -19
- package/mcp/web/exchange.ts +23 -9
- package/package.json +7 -3
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { Blueprint, Json } from '@quo-systems/quo';
|
|
2
|
+
export type Hint = {
|
|
3
|
+
title?: string;
|
|
4
|
+
readOnly?: boolean;
|
|
5
|
+
destructive?: boolean;
|
|
6
|
+
idempotent?: boolean;
|
|
7
|
+
icon?: string;
|
|
8
|
+
};
|
|
9
|
+
export type Look = {
|
|
10
|
+
name?: string;
|
|
11
|
+
logo?: string;
|
|
12
|
+
accent?: string;
|
|
13
|
+
background?: string;
|
|
14
|
+
foreground?: string;
|
|
15
|
+
font?: string;
|
|
16
|
+
radius?: number;
|
|
17
|
+
order?: string[];
|
|
18
|
+
asks?: Record<string, Hint>;
|
|
19
|
+
};
|
|
20
|
+
export declare function sanitise(v: Json | undefined): Look;
|
|
21
|
+
export declare const hint: (l: Look | undefined, name: string) => Hint;
|
|
22
|
+
export type Group = {
|
|
23
|
+
asks?: string[];
|
|
24
|
+
look?: Json;
|
|
25
|
+
};
|
|
26
|
+
export declare function groups(bp: Blueprint | null): Record<string, Group>;
|
|
27
|
+
export declare function hintFor(bp: Blueprint | null, mine: Look, name: string): Hint;
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
const COLOUR = /^#[0-9a-fA-F]{3,8}$/;
|
|
2
|
+
const FONT = /^[\w\s,'"-]{1,80}$/;
|
|
3
|
+
const LOGO = /^data:image\/(svg\+xml|png|jpeg|webp);base64,[A-Za-z0-9+/=]{1,40000}$/;
|
|
4
|
+
const NAME = /^[^<>&]{1,60}$/;
|
|
5
|
+
const ICON = /^[^<>&"']{1,8}$/;
|
|
6
|
+
const str = (v, re) => (typeof v === 'string' && re.test(v) ? v : undefined);
|
|
7
|
+
// The look as a side may use it: every token held to its shape, everything
|
|
8
|
+
// else gone. A value that is not an object is no look at all.
|
|
9
|
+
export function sanitise(v) {
|
|
10
|
+
if (v === null || v === undefined || typeof v !== 'object' || Array.isArray(v))
|
|
11
|
+
return {};
|
|
12
|
+
const l = {};
|
|
13
|
+
const name = str(v.name, NAME);
|
|
14
|
+
if (name !== undefined)
|
|
15
|
+
l.name = name;
|
|
16
|
+
const logo = str(v.logo, LOGO);
|
|
17
|
+
if (logo !== undefined)
|
|
18
|
+
l.logo = logo;
|
|
19
|
+
for (const k of ['accent', 'background', 'foreground']) {
|
|
20
|
+
const c = str(v[k], COLOUR);
|
|
21
|
+
if (c !== undefined)
|
|
22
|
+
l[k] = c;
|
|
23
|
+
}
|
|
24
|
+
const font = str(v.font, FONT);
|
|
25
|
+
if (font !== undefined)
|
|
26
|
+
l.font = font;
|
|
27
|
+
if (typeof v.radius === 'number' && Number.isFinite(v.radius) && v.radius >= 0 && v.radius <= 40)
|
|
28
|
+
l.radius = Math.round(v.radius);
|
|
29
|
+
if (Array.isArray(v.order))
|
|
30
|
+
l.order = v.order.filter((x) => typeof x === 'string');
|
|
31
|
+
if (v.asks !== null && typeof v.asks === 'object' && !Array.isArray(v.asks)) {
|
|
32
|
+
const asks = {};
|
|
33
|
+
for (const [ask, h] of Object.entries(v.asks)) {
|
|
34
|
+
if (h === null || typeof h !== 'object' || Array.isArray(h))
|
|
35
|
+
continue;
|
|
36
|
+
const hint = {};
|
|
37
|
+
const title = str(h.title, NAME);
|
|
38
|
+
if (title !== undefined)
|
|
39
|
+
hint.title = title;
|
|
40
|
+
const icon = str(h.icon, ICON);
|
|
41
|
+
if (icon !== undefined)
|
|
42
|
+
hint.icon = icon;
|
|
43
|
+
for (const k of ['readOnly', 'destructive', 'idempotent'])
|
|
44
|
+
if (typeof h[k] === 'boolean')
|
|
45
|
+
hint[k] = h[k];
|
|
46
|
+
if (Object.keys(hint).length)
|
|
47
|
+
asks[ask] = hint;
|
|
48
|
+
}
|
|
49
|
+
if (Object.keys(asks).length)
|
|
50
|
+
l.asks = asks;
|
|
51
|
+
}
|
|
52
|
+
return l;
|
|
53
|
+
}
|
|
54
|
+
// The one hint a side reads first: whether this ask is in the look at all.
|
|
55
|
+
export const hint = (l, name) => l?.asks?.[name] ?? {};
|
|
56
|
+
export function groups(bp) {
|
|
57
|
+
const n = bp?.notes;
|
|
58
|
+
const g = n !== null && n !== undefined && typeof n === 'object' && !Array.isArray(n) ? n.standings : undefined;
|
|
59
|
+
return g !== null && g !== undefined && typeof g === 'object' && !Array.isArray(g) ? g : {};
|
|
60
|
+
}
|
|
61
|
+
// The hint for one ask, hers or a standing's: a carried ask is looked up
|
|
62
|
+
// under its bare name in that standing's look.
|
|
63
|
+
export function hintFor(bp, mine, name) {
|
|
64
|
+
for (const [id, g] of Object.entries(groups(bp))) {
|
|
65
|
+
if (!g.asks?.includes(name))
|
|
66
|
+
continue;
|
|
67
|
+
const prefix = `${id}-`;
|
|
68
|
+
return hint(sanitise(g.look), name.startsWith(prefix) ? name.slice(prefix.length) : name);
|
|
69
|
+
}
|
|
70
|
+
return hint(mine, name);
|
|
71
|
+
}
|
package/dist/beings/setup.js
CHANGED
|
@@ -7,7 +7,10 @@ export async function setup(hosted, user) {
|
|
|
7
7
|
if (out.error)
|
|
8
8
|
throw new Error(`init: ${out.error}`);
|
|
9
9
|
};
|
|
10
|
-
await boot({ key: 'desk', class: 'Desk'
|
|
10
|
+
await boot({ key: 'desk', class: 'Desk' });
|
|
11
|
+
const pub = (await hosted.ask('public', { key: 'desk' }));
|
|
12
|
+
if (pub.error)
|
|
13
|
+
throw new Error(`init: ${pub.error}`);
|
|
11
14
|
await boot({ key: user, class: 'User' });
|
|
12
15
|
const inv = (await hosted.ask('invite', { being: user, id: DESK }));
|
|
13
16
|
const placed = (await hosted.ask('knock', { being: 'desk', id: `user:${user}`, invitation: inv, method: 'hello' }));
|
package/dist/beings/side.d.ts
CHANGED
|
@@ -1,9 +1,16 @@
|
|
|
1
|
-
import type { Answer, Json, JsonObject, WordName } from '@quo-systems/quo';
|
|
1
|
+
import type { Answer, Blueprint, Json, JsonObject, Wanted, WordName } from '@quo-systems/quo';
|
|
2
2
|
import type { Avatar } from './avatar.ts';
|
|
3
3
|
export type Serving = {
|
|
4
4
|
close(): Promise<void>;
|
|
5
5
|
};
|
|
6
6
|
export type Side = (avatar: Avatar) => Promise<Serving>;
|
|
7
|
+
export type Subject = {
|
|
8
|
+
tools(): Promise<Blueprint | {
|
|
9
|
+
error: string;
|
|
10
|
+
}>;
|
|
11
|
+
call(name: string, args?: JsonObject, wanted?: Wanted): Promise<Answer>;
|
|
12
|
+
ears: Set<(object: JsonObject) => void>;
|
|
13
|
+
};
|
|
7
14
|
export type Word = {
|
|
8
15
|
word: 'object';
|
|
9
16
|
value: Json;
|
package/dist/beings/user.d.ts
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
|
-
import { Being } from '@quo-systems/quo';
|
|
2
1
|
import type { Asker, JsonObject, OccupantRecord } from '@quo-systems/quo';
|
|
2
|
+
import { Carrier } from './carry.ts';
|
|
3
3
|
export declare const DESK = "desk";
|
|
4
4
|
declare const isDesk: (occ: OccupantRecord | undefined) => boolean;
|
|
5
5
|
declare const isDevice: (occ: OccupantRecord | undefined) => boolean;
|
|
6
6
|
declare const mayWake: (occ: OccupantRecord | undefined) => boolean;
|
|
7
|
-
export declare class User extends
|
|
7
|
+
export declare class User extends Carrier {
|
|
8
|
+
static carries(occ: OccupantRecord | undefined): boolean;
|
|
8
9
|
static cells: {
|
|
9
10
|
name: string;
|
|
10
11
|
reports: JsonObject[];
|
|
@@ -39,6 +40,9 @@ export declare class User extends Being {
|
|
|
39
40
|
wake: {
|
|
40
41
|
type: string;
|
|
41
42
|
};
|
|
43
|
+
reach: {
|
|
44
|
+
type: string;
|
|
45
|
+
};
|
|
42
46
|
};
|
|
43
47
|
required: string[];
|
|
44
48
|
};
|
|
@@ -67,6 +71,19 @@ export declare class User extends Being {
|
|
|
67
71
|
};
|
|
68
72
|
for: (occ: OccupantRecord | undefined) => boolean;
|
|
69
73
|
};
|
|
74
|
+
forget: {
|
|
75
|
+
description: string;
|
|
76
|
+
input: {
|
|
77
|
+
type: string;
|
|
78
|
+
properties: {
|
|
79
|
+
client: {
|
|
80
|
+
type: string;
|
|
81
|
+
};
|
|
82
|
+
};
|
|
83
|
+
required: string[];
|
|
84
|
+
};
|
|
85
|
+
for: typeof isDesk;
|
|
86
|
+
};
|
|
70
87
|
report: {
|
|
71
88
|
description: string;
|
|
72
89
|
input: {
|
|
@@ -98,6 +115,13 @@ export declare class User extends Being {
|
|
|
98
115
|
error?: undefined;
|
|
99
116
|
pushed: import("@quo-systems/quo").Json;
|
|
100
117
|
}>;
|
|
118
|
+
forget(args: JsonObject): {
|
|
119
|
+
error: string;
|
|
120
|
+
forgot?: undefined;
|
|
121
|
+
} | {
|
|
122
|
+
error?: undefined;
|
|
123
|
+
forgot: string;
|
|
124
|
+
};
|
|
101
125
|
chores(): {
|
|
102
126
|
chores: string[];
|
|
103
127
|
};
|
package/dist/beings/user.js
CHANGED
|
@@ -5,21 +5,30 @@
|
|
|
5
5
|
// special only by the id the root chose for them: `desk`, the front desk,
|
|
6
6
|
// who may ask her to invite a device; and each device, whose id is the
|
|
7
7
|
// client identity the desk established.
|
|
8
|
-
import {
|
|
8
|
+
import { isSilence, isWord, wordOf } from '@quo-systems/quo';
|
|
9
|
+
import { Carrier } from './carry.js';
|
|
9
10
|
export const DESK = 'desk';
|
|
10
11
|
const isDesk = (occ) => occ?.id === DESK;
|
|
11
12
|
const isDevice = (occ) => occ !== undefined && occ.id !== DESK;
|
|
12
13
|
const client = (occ) => (typeof occ?.notes.client === 'string' ? occ.notes.client : null);
|
|
13
14
|
// A device the human allowed to wake her other devices: the note says so.
|
|
14
15
|
const mayWake = (occ) => isDesk(occ) || occ?.notes.wake === true;
|
|
15
|
-
|
|
16
|
+
// She carries her standings, acme, the calendar, the house, for a device the
|
|
17
|
+
// human allowed to reach them at the exchange: the note says so. A device
|
|
18
|
+
// without the note sees her own asks alone, and a model sees acme only
|
|
19
|
+
// because the human said it may.
|
|
20
|
+
export class User extends Carrier {
|
|
21
|
+
static carries(occ) {
|
|
22
|
+
return isDevice(occ) && occ?.notes.reach === true;
|
|
23
|
+
}
|
|
16
24
|
static cells = { name: '', reports: [] };
|
|
17
25
|
static asks = {
|
|
18
26
|
hello: { description: 'say hello, and hand back an invitation so she can reach you', input: { type: 'object', properties: { invitation: { type: 'object' } } } },
|
|
19
27
|
whoami: { description: 'who she thinks you are', input: { type: 'object' }, for: isDevice },
|
|
20
|
-
device: { description: 'mint an invitation for a device', input: { type: 'object', properties: { client: { type: 'string' }, wake: { type: 'boolean' } }, required: ['client'] }, for: isDesk },
|
|
28
|
+
device: { description: 'mint an invitation for a device', input: { type: 'object', properties: { client: { type: 'string' }, wake: { type: 'boolean' }, reach: { type: 'boolean' } }, required: ['client'] }, for: isDesk },
|
|
21
29
|
push: { description: 'push an object to a device: wake it with an event', input: { type: 'object', properties: { client: { type: 'string' }, object: { type: 'object' } }, required: ['client', 'object'] }, for: mayWake },
|
|
22
30
|
chores: { description: 'what the agent may run', input: { type: 'object' }, for: (occ) => client(occ) === 'agent' },
|
|
31
|
+
forget: { description: 'revoke a device: drop its way in and her way back to it, in one act', input: { type: 'object', properties: { client: { type: 'string' } }, required: ['client'] }, for: isDesk },
|
|
23
32
|
report: { description: 'what a run of yours found', input: { type: 'object', properties: { event: { type: 'object' }, result: {} }, required: ['event', 'result'] }, for: isDevice },
|
|
24
33
|
};
|
|
25
34
|
// Anyone may say hello. A device that hands her an invitation in the args
|
|
@@ -38,7 +47,8 @@ export class User extends Being {
|
|
|
38
47
|
}
|
|
39
48
|
// The front desk asks; she mints. The client identity goes into the
|
|
40
49
|
// occupant's notes, and that is what every gate reads. `wake` is the
|
|
41
|
-
// human's word at the exchange that this device may wake her others
|
|
50
|
+
// human's word at the exchange that this device may wake her others, and
|
|
51
|
+
// `reach` that it may see and ask what she holds: her standings, carried.
|
|
42
52
|
async device(args) {
|
|
43
53
|
const c = typeof args.client === 'string' ? args.client : null;
|
|
44
54
|
if (c === null)
|
|
@@ -51,17 +61,34 @@ export class User extends Being {
|
|
|
51
61
|
rec.notes.client = c;
|
|
52
62
|
if (args.wake === true)
|
|
53
63
|
rec.notes.wake = true;
|
|
64
|
+
if (args.reach === true)
|
|
65
|
+
rec.notes.reach = true;
|
|
54
66
|
}
|
|
55
67
|
return inv;
|
|
56
68
|
}
|
|
69
|
+
// Her way out and the device's way in are two relations, and Quo keeps
|
|
70
|
+
// them apart. She joins them herself, here, as her own rule: she does
|
|
71
|
+
// not reach a device she no longer admits. Without it a revoked device
|
|
72
|
+
// stops being able to ask her and keeps receiving everything she pushes.
|
|
57
73
|
async push(args) {
|
|
58
74
|
const c = typeof args.client === 'string' ? args.client : null;
|
|
59
|
-
const st = c === null ? undefined : this.standings[`to:${c}`];
|
|
75
|
+
const st = c === null || !this.cells.occupants[c] ? undefined : this.standings[`to:${c}`];
|
|
60
76
|
if (!st)
|
|
61
77
|
return { error: 'no such device, or it gave no way back' };
|
|
62
78
|
const out = await st.ask('notify', args.object ?? {});
|
|
63
79
|
return isSilence(out) ? { error: 'silence' } : isWord(out) ? { error: wordOf(out) } : { pushed: out };
|
|
64
80
|
}
|
|
81
|
+
// Revocation is one act at her, because only she knows both ids. A side
|
|
82
|
+
// that had to remove two relations by hand could leave half of one
|
|
83
|
+
// standing, and every side would have to remember which half.
|
|
84
|
+
forget(args) {
|
|
85
|
+
const c = typeof args.client === 'string' ? args.client : null;
|
|
86
|
+
if (c === null)
|
|
87
|
+
return { error: 'client is a string' };
|
|
88
|
+
this.occupants.remove(c);
|
|
89
|
+
this.standings.remove(`to:${c}`);
|
|
90
|
+
return { forgot: c };
|
|
91
|
+
}
|
|
65
92
|
chores() {
|
|
66
93
|
return { chores: ['census', 'report'] };
|
|
67
94
|
}
|
package/dist/cli/daemon.d.ts
CHANGED
|
@@ -50,7 +50,7 @@ export type Options = {
|
|
|
50
50
|
};
|
|
51
51
|
export declare const sockPath: (dir: string) => string;
|
|
52
52
|
export declare const sidePath: (dir: string) => string;
|
|
53
|
-
export declare function admit(hosted: Hosted, identity: string, kind: 'local' | 'web', wake?: boolean): Promise<{
|
|
53
|
+
export declare function admit(hosted: Hosted, identity: string, kind: 'local' | 'web', wake?: boolean, reach?: boolean): Promise<{
|
|
54
54
|
avatar?: Avatar;
|
|
55
55
|
error?: string;
|
|
56
56
|
}>;
|
package/dist/cli/daemon.js
CHANGED
|
@@ -121,7 +121,7 @@ async function readAll(req) {
|
|
|
121
121
|
// `user` and nothing is minted, so `wake`, the human's word that this
|
|
122
122
|
// device may wake her others, is read the first time only; to change it,
|
|
123
123
|
// remove the occupant and allow again. The one path for every side.
|
|
124
|
-
export async function admit(hosted, identity, kind, wake = false) {
|
|
124
|
+
export async function admit(hosted, identity, kind, wake = false, reach = false) {
|
|
125
125
|
if (!/^[\w.-]+$/.test(identity) || identity === hosted.record.user || identity === 'desk')
|
|
126
126
|
return { error: 'an identity is a word, and not a being of the ward' };
|
|
127
127
|
const key = `avatar:${identity}`;
|
|
@@ -133,7 +133,7 @@ export async function admit(hosted, identity, kind, wake = false) {
|
|
|
133
133
|
avatar = hosted.being(key);
|
|
134
134
|
}
|
|
135
135
|
const nonce = randomBytes(16).toString('hex');
|
|
136
|
-
NONCES.set(nonce,
|
|
136
|
+
NONCES.set(nonce, { kind, user: hosted.record.user, client: identity, ...(wake ? { wake: true } : {}), ...(reach ? { reach: true } : {}) });
|
|
137
137
|
const entered = await avatar.enter({ ward: hosted.pk }, { kind, nonce });
|
|
138
138
|
NONCES.delete(nonce);
|
|
139
139
|
await hosted.save(); // the knock went through the ward's own door, which the harbor never sees
|
|
@@ -178,31 +178,41 @@ export async function serve(dir, options = {}) {
|
|
|
178
178
|
mountQuo(harbor, http, server, quo);
|
|
179
179
|
const routes = options.routes ?? (await readRoutes(harbor.dir));
|
|
180
180
|
const password = options.password ?? (() => process.env.QUO_OWNER_PASSWORD);
|
|
181
|
-
const main = harbor.wards.get('main');
|
|
182
181
|
const here = `http://${http.host}:${http.port}`;
|
|
183
|
-
// the
|
|
184
|
-
const tab =
|
|
182
|
+
// the worlds' pages: always, on the daemon's own door when no route names a public one
|
|
183
|
+
const tab = worldPages(harbor, { quo: routes?.quo ?? `${here}/quo`, web: routes?.web ?? `${here}/web` }, password);
|
|
185
184
|
if (routes) {
|
|
186
|
-
// the
|
|
187
|
-
|
|
185
|
+
// the worlds a client may be allowed into: every ward with a public being, main first
|
|
186
|
+
const worlds = () => [...harbor.wards]
|
|
187
|
+
.filter(([, h]) => h.partition.public !== null)
|
|
188
|
+
.sort(([a], [b]) => (a === 'main' ? -1 : b === 'main' ? 1 : a.localeCompare(b)))
|
|
189
|
+
.map(([ward, h]) => ({ ward, user: h.record.user }));
|
|
190
|
+
// the MCP endpoint: a bearer names an identity in a world, the identity names her avatar there, the side runs beside her
|
|
191
|
+
mcp = new McpHttp(async (identity, ward) => {
|
|
192
|
+
const hosted = harbor.wards.get(ward);
|
|
193
|
+
return hosted ? admit(hosted, identity, 'web') : { error: 'no such world' };
|
|
194
|
+
}, async () => {
|
|
195
|
+
for (const h of harbor.wards.values())
|
|
196
|
+
await h.save();
|
|
197
|
+
});
|
|
188
198
|
oauth = await mountOAuth(harbor.dir, http, routes, mcp);
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
}
|
|
200
|
-
}
|
|
201
|
-
}
|
|
199
|
+
mcp.gone = (identity, ward) => oauth.revoke(identity, ward); // removal at the ward ends the grant at the route
|
|
200
|
+
exchange = new Exchange({
|
|
201
|
+
oauth,
|
|
202
|
+
password,
|
|
203
|
+
worlds,
|
|
204
|
+
admit: async (identity, wake, reach, ward) => {
|
|
205
|
+
const hosted = harbor.wards.get(ward);
|
|
206
|
+
if (!hosted)
|
|
207
|
+
return { error: 'no such world' };
|
|
208
|
+
const r = await admit(hosted, identity, 'web', wake, reach);
|
|
209
|
+
return r.error ? { error: r.error } : {};
|
|
210
|
+
},
|
|
211
|
+
});
|
|
202
212
|
}
|
|
203
213
|
const ex = exchange;
|
|
204
214
|
http.mount('/web', async (req, res, rest) => {
|
|
205
|
-
if (
|
|
215
|
+
if (await tab(req, res, rest))
|
|
206
216
|
return;
|
|
207
217
|
if (ex && (await ex.handle(req, res, rest)))
|
|
208
218
|
return;
|
|
@@ -419,36 +429,76 @@ async function readRoutes(dir) {
|
|
|
419
429
|
out.quo = r.quo.replace(/\/$/, '');
|
|
420
430
|
return out;
|
|
421
431
|
}
|
|
422
|
-
// The
|
|
423
|
-
//
|
|
424
|
-
//
|
|
425
|
-
//
|
|
426
|
-
//
|
|
427
|
-
//
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
432
|
+
// The worlds on the web route. A world is a ward with a public being, and
|
|
433
|
+
// its address is `/web/<ward>`: the tab page, `human/tab.ts`, told which
|
|
434
|
+
// ward and which pk, so that its guest is that ward's public being rendered
|
|
435
|
+
// by the screen, whatever class she is. `/web/` lists the worlds. The bundle
|
|
436
|
+
// is built once from the source beside this file, `.ts` in the tree, `.js`
|
|
437
|
+
// once emitted into the package's dist. `/web/<ward>/login` is the one call
|
|
438
|
+
// of the dock's own way in a tab makes: the owner password for a nonce the
|
|
439
|
+
// desk honours under `tab`. The avatar that knocks with it lives in the tab,
|
|
440
|
+
// not here, so nothing is admitted on this side.
|
|
441
|
+
//
|
|
442
|
+
// The page carries a content security policy: scripts from this origin
|
|
443
|
+
// only and never inline, connections to this origin and the quo. route the
|
|
444
|
+
// tab dials, images from data URIs and this origin, and nothing else. So
|
|
445
|
+
// even a bug in a renderer cannot become a script, and no look can reach a
|
|
446
|
+
// server. The config crosses in a JSON script, which the policy allows.
|
|
447
|
+
const RESERVED_PATHS = new Set(['login', 'allow', 'tab.js']);
|
|
448
|
+
function worldPages(harbor, at, password) {
|
|
431
449
|
const tabEntry = () => {
|
|
432
450
|
const js = fileURLToPath(new URL('../human/tab.js', import.meta.url));
|
|
433
451
|
return existsSync(js) ? js : fileURLToPath(new URL('../human/tab.ts', import.meta.url));
|
|
434
452
|
};
|
|
435
453
|
let bundle;
|
|
436
454
|
const built = () => (bundle ??= build({ entryPoints: [tabEntry()], bundle: true, format: 'esm', platform: 'browser', target: 'es2023', write: false }).then((o) => o.outputFiles[0].text));
|
|
437
|
-
const
|
|
438
|
-
|
|
439
|
-
|
|
455
|
+
const quoOrigin = (() => {
|
|
456
|
+
try {
|
|
457
|
+
const u = new URL(at.quo);
|
|
458
|
+
return `${u.origin} ${u.origin.replace(/^http/, 'ws')}`;
|
|
459
|
+
}
|
|
460
|
+
catch {
|
|
461
|
+
return '';
|
|
462
|
+
}
|
|
463
|
+
})();
|
|
464
|
+
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'`;
|
|
465
|
+
const shell = (body) => `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>quo</title>
|
|
466
|
+
<style>${CSS}</style>
|
|
467
|
+
</head><body>${body}</body></html>`;
|
|
468
|
+
const esc = (v) => v.replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c] ?? c);
|
|
469
|
+
const publicOf = (h) => h.partition.public ?? null;
|
|
470
|
+
const html = (status, res, body) => {
|
|
471
|
+
res.writeHead(status, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store', 'content-security-policy': policy, 'referrer-policy': 'no-referrer' });
|
|
472
|
+
res.end(shell(body));
|
|
473
|
+
return true;
|
|
474
|
+
};
|
|
440
475
|
return async (req, res, rest) => {
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
476
|
+
const parts = rest.split('/').filter(Boolean);
|
|
477
|
+
if (req.method === 'GET' && (rest === '' || rest === '/')) {
|
|
478
|
+
const list = [...harbor.wards]
|
|
479
|
+
.filter(([, h]) => publicOf(h) !== null)
|
|
480
|
+
.map(([n, h]) => `<li><a href="${at.web}/${encodeURIComponent(n)}">${esc(n)}</a> <small>${esc(publicOf(h) ?? '')} at the door, <code>${h.pk.slice(0, 16)}…</code></small></li>`)
|
|
481
|
+
.join('');
|
|
482
|
+
return html(200, res, `<h1>worlds</h1>${list ? `<ul>${list}</ul>` : '<p>no ward here has a public being</p>'}`);
|
|
445
483
|
}
|
|
446
484
|
if (rest === '/tab.js' && req.method === 'GET') {
|
|
447
485
|
res.writeHead(200, { 'content-type': 'text/javascript; charset=utf-8', 'cache-control': 'no-store' });
|
|
448
486
|
res.end(await built());
|
|
449
487
|
return true;
|
|
450
488
|
}
|
|
451
|
-
|
|
489
|
+
const wardName = parts[0] ?? '';
|
|
490
|
+
if (!wardName || RESERVED_PATHS.has(wardName))
|
|
491
|
+
return false;
|
|
492
|
+
const hosted = harbor.wards.get(wardName);
|
|
493
|
+
if (!hosted)
|
|
494
|
+
return html(404, res, `<h1>no such world</h1><p>no ward named ${esc(wardName)} on this harbor.</p>`);
|
|
495
|
+
if (parts.length === 1 && req.method === 'GET') {
|
|
496
|
+
if (publicOf(hosted) === null)
|
|
497
|
+
return html(404, res, `<h1>not a world</h1><p>ward ${esc(wardName)} has no public being, so nobody is at its door.</p>`);
|
|
498
|
+
const cfg = { quo: at.quo, web: at.web, ward: wardName, pk: hosted.pk };
|
|
499
|
+
return html(200, res, `<script id="quo" type="application/json">${JSON.stringify(cfg).replace(/</g, '\\u003c')}</script><script type="module" src="${at.web}/tab.js"></script>`);
|
|
500
|
+
}
|
|
501
|
+
if (parts.length === 2 && parts[1] === 'login' && req.method === 'POST') {
|
|
452
502
|
const raw = await readAll(req);
|
|
453
503
|
let body = {};
|
|
454
504
|
try {
|
|
@@ -471,15 +521,19 @@ function tabPages(main, at, password) {
|
|
|
471
521
|
return json(401, { error: 'that is not the password' });
|
|
472
522
|
}
|
|
473
523
|
const identity = typeof body.identity === 'string' ? body.identity : '';
|
|
474
|
-
if (!/^[\w.-]{1,40}$/.test(identity) || identity ===
|
|
524
|
+
if (!/^[\w.-]{1,40}$/.test(identity) || identity === hosted.record.user || identity === 'desk')
|
|
475
525
|
return json(400, { error: 'an identity is one word, and not the user or the desk' });
|
|
476
526
|
const nonce = randomBytes(16).toString('hex');
|
|
477
|
-
NONCES.set(nonce, { kind: 'tab', user:
|
|
478
|
-
return json(200, { nonce, ward:
|
|
527
|
+
NONCES.set(nonce, { kind: 'tab', user: hosted.record.user, client: identity, reach: true });
|
|
528
|
+
return json(200, { nonce, ward: hosted.pk });
|
|
479
529
|
}
|
|
480
530
|
return false;
|
|
481
531
|
};
|
|
482
532
|
}
|
|
533
|
+
// The tab's stylesheet: one, light and dark, honouring the variables a look
|
|
534
|
+
// sets on a section. The page owns layout; a far being paints inside her
|
|
535
|
+
// section and nowhere else.
|
|
536
|
+
const CSS = ":root{color-scheme:light dark;--accent:#3b6ef5;--bg:transparent;--fg:inherit;--font:system-ui,sans-serif;--radius:6px}body{font:16px/1.5 system-ui,sans-serif;max-width:40rem;margin:2rem auto;padding:0 1rem}nav.worlds{display:flex;flex-wrap:wrap;gap:.5rem 1rem;font-size:.9rem;opacity:.8}nav.worlds a[aria-current]{font-weight:600}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}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}fieldset{border:1px solid color-mix(in srgb,currentColor 20%,transparent);border-radius:var(--radius);margin:.5rem 0}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}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}";
|
|
483
537
|
// The quo. route. A request carries one ask to a pk; an upgrade is a held
|
|
484
538
|
// socket. Bytes from here go to an own door or a held socket, never onward.
|
|
485
539
|
function mountQuo(harbor, http, server, quo) {
|
|
@@ -569,10 +623,10 @@ async function mountOAuth(dir, http, routes, mcp) {
|
|
|
569
623
|
if (await oauth.handle(req, res, rest))
|
|
570
624
|
return;
|
|
571
625
|
if (rest === '/mcp') {
|
|
572
|
-
const
|
|
573
|
-
if (
|
|
626
|
+
const grant = oauth.bearer(req);
|
|
627
|
+
if (grant === null || !mcp)
|
|
574
628
|
return oauth.challenge(res);
|
|
575
|
-
return mcp.handle(req, res, identity);
|
|
629
|
+
return mcp.handle(req, res, grant.identity, grant.ward);
|
|
576
630
|
}
|
|
577
631
|
res.writeHead(404, { 'content-type': 'application/json' });
|
|
578
632
|
res.end(JSON.stringify({ error: 'no such route' }));
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# Three hostnames, one daemon, one loopback port. Caddy faces the world and
|
|
2
|
+
# terminates TLS; the daemon never does. Each hostname is a route, and a
|
|
3
|
+
# route is a path on the daemon's HTTP door. Replace DOMAIN.
|
|
4
|
+
#
|
|
5
|
+
# mcp. the model side: streamable HTTP MCP, with the credential exchange in front
|
|
6
|
+
# web. the bundle for plain tabs, and the exchange pages
|
|
7
|
+
# quo. the socket door for other harbors: requests in, sockets held, the rendezvous
|
|
8
|
+
#
|
|
9
|
+
# There is no cli. route. The root owner is the unix socket, reached on the
|
|
10
|
+
# device or over SSH, and never through here.
|
|
11
|
+
|
|
12
|
+
mcp.DOMAIN {
|
|
13
|
+
reverse_proxy 127.0.0.1:8787
|
|
14
|
+
rewrite * /mcp{uri}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
web.DOMAIN {
|
|
18
|
+
reverse_proxy 127.0.0.1:8787
|
|
19
|
+
rewrite * /web{uri}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
quo.DOMAIN {
|
|
23
|
+
reverse_proxy 127.0.0.1:8787
|
|
24
|
+
rewrite * /quo{uri}
|
|
25
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# The daemon on a droplet, as one user, forever. Install with:
|
|
2
|
+
# sudo cp droplet/quo.service /etc/systemd/system/quo.service
|
|
3
|
+
# sudo systemctl enable --now quo
|
|
4
|
+
# The unit runs as the `quo` user, whose home holds the estate folder and
|
|
5
|
+
# the harbor folder, so the seed, the lease and the sockets are that user's
|
|
6
|
+
# alone. The HTTP door is loopback only; Caddy fronts it. See Caddyfile
|
|
7
|
+
# beside this file.
|
|
8
|
+
[Unit]
|
|
9
|
+
Description=quo: one harbor on this device
|
|
10
|
+
After=network.target
|
|
11
|
+
|
|
12
|
+
[Service]
|
|
13
|
+
User=quo
|
|
14
|
+
Group=quo
|
|
15
|
+
Environment=QUO_DIR=/home/quo/.quo
|
|
16
|
+
Environment=QUO_HTTP=8787
|
|
17
|
+
# QUO_OWNER_PASSWORD, root-only. Without it the exchange pages are closed.
|
|
18
|
+
# CLAUDE_CODE_OAUTH_TOKEN, when an agent folder runs `claude -p`: the agent
|
|
19
|
+
# reads its credential from the device, never from cells.
|
|
20
|
+
EnvironmentFile=-/etc/quo/env
|
|
21
|
+
WorkingDirectory=/home/quo/ESTATE
|
|
22
|
+
ExecStart=/usr/bin/node /home/quo/ESTATE/node_modules/@quo-systems/dock/dist/cli/quo.js serve
|
|
23
|
+
Restart=always
|
|
24
|
+
RestartSec=2
|
|
25
|
+
KillSignal=SIGTERM
|
|
26
|
+
TimeoutStopSec=10
|
|
27
|
+
NoNewPrivileges=true
|
|
28
|
+
ProtectSystem=strict
|
|
29
|
+
# Agents run in their folders and Claude Code keeps its own state in the
|
|
30
|
+
# user's home; both must be writable for a run to happen, and a harbor
|
|
31
|
+
# with no agent has neither.
|
|
32
|
+
ReadWritePaths=/home/quo/.quo -/home/quo/agents -/home/quo/.claude -/home/quo/.claude.json /tmp
|
|
33
|
+
PrivateTmp=false
|
|
34
|
+
|
|
35
|
+
[Install]
|
|
36
|
+
WantedBy=multi-user.target
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// `quo estate DIR --domain D`: an estate folder, written once, of the shape
|
|
3
|
+
// every estate has. One folder per harbor, here the first, `droplet/`: the
|
|
4
|
+
// quo directory that device runs minus what it mints, its routes under the
|
|
5
|
+
// domain, no agents, no classes of its own, and that device's unit and
|
|
6
|
+
// Caddyfile. A package file that depends on this dock and nothing else, and
|
|
7
|
+
// one document to fill in. An estate needs nothing the dock does not give
|
|
8
|
+
// it, and this is the dock giving it.
|
|
9
|
+
import { existsSync } from 'node:fs';
|
|
10
|
+
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
11
|
+
import { basename, join } from 'node:path';
|
|
12
|
+
import { fileURLToPath } from 'node:url';
|
|
13
|
+
// This package's own version, read beside the emitted or the source tree.
|
|
14
|
+
async function version() {
|
|
15
|
+
for (const rel of ['../package.json', '../../package.json']) {
|
|
16
|
+
const at = fileURLToPath(new URL(rel, import.meta.url));
|
|
17
|
+
if (!existsSync(at))
|
|
18
|
+
continue;
|
|
19
|
+
const pkg = JSON.parse(await readFile(at, 'utf8'));
|
|
20
|
+
if (pkg.name === '@quo-systems/dock' && pkg.version)
|
|
21
|
+
return pkg.version;
|
|
22
|
+
}
|
|
23
|
+
return '0.1.0';
|
|
24
|
+
}
|
|
25
|
+
const template = (name) => readFile(fileURLToPath(new URL(`estate/${name}`, import.meta.url)), 'utf8');
|
|
26
|
+
export async function estate(dir, domain) {
|
|
27
|
+
if (!/^[a-z0-9.-]+\.[a-z]{2,}$/i.test(domain))
|
|
28
|
+
throw new Error(`estate: ${domain} is not a domain`);
|
|
29
|
+
if (existsSync(join(dir, 'package.json')))
|
|
30
|
+
throw new Error(`estate: ${dir} already holds a package`);
|
|
31
|
+
const name = basename(dir);
|
|
32
|
+
const files = {
|
|
33
|
+
'package.json': JSON.stringify({ name, private: true, description: `The ${name} estate: one owner's harbors, built on the dock and nothing else.`, type: 'module', dependencies: { '@quo-systems/dock': `^${await version()}` } }, null, 2) + '\n',
|
|
34
|
+
[`${name}.md`]: `# ${name}\n\nAn estate: all of one owner's harbors. One folder per harbor, each the quo\ndirectory that device runs minus what it mints, plus that device's unit.\n\`droplet/\` is the first: its routes under \`${domain}\`, its agents, its\nclasses, its systemd unit and its Caddyfile. This folder depends on the\ndock and nothing else.\n\n## The droplet\n\nWhere it is, how it is reached, and what was done to stand it up: yours\nto write.\n`,
|
|
35
|
+
'droplet/routes.json': JSON.stringify({ mcp: `https://mcp.${domain}`, web: `https://web.${domain}`, quo: `https://quo.${domain}` }) + '\n',
|
|
36
|
+
'droplet/agents.json': '{}\n',
|
|
37
|
+
'droplet/classes/index.ts': '// The classes this harbor holds beside the dock\'s built-in ones, exported\n// by name.\nexport {};\n',
|
|
38
|
+
'droplet/quo.service': (await template('quo.service')).replaceAll('ESTATE', name),
|
|
39
|
+
'droplet/Caddyfile': (await template('Caddyfile')).replaceAll('DOMAIN', domain),
|
|
40
|
+
};
|
|
41
|
+
for (const [rel, text] of Object.entries(files)) {
|
|
42
|
+
await mkdir(join(dir, rel, '..'), { recursive: true });
|
|
43
|
+
await writeFile(join(dir, rel), text);
|
|
44
|
+
}
|
|
45
|
+
return { dir, name, files: Object.keys(files) };
|
|
46
|
+
}
|