@quo-systems/dock 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/NOTICE +6 -0
- package/README.md +27 -0
- package/beings/avatar.ts +84 -0
- package/beings/desk.ts +46 -0
- package/beings/index.ts +5 -0
- package/beings/quo-dock.md +698 -0
- package/beings/setup.ts +23 -0
- package/beings/side.ts +51 -0
- package/beings/user.ts +78 -0
- package/cli/client.ts +65 -0
- package/cli/daemon.ts +582 -0
- package/cli/quo.ts +126 -0
- package/dist/beings/avatar.d.ts +32 -0
- package/dist/beings/avatar.js +82 -0
- package/dist/beings/desk.d.ts +42 -0
- package/dist/beings/desk.js +43 -0
- package/dist/beings/index.d.ts +3 -0
- package/dist/beings/index.js +5 -0
- package/dist/beings/setup.d.ts +2 -0
- package/dist/beings/setup.js +16 -0
- package/dist/beings/side.d.ts +25 -0
- package/dist/beings/side.js +37 -0
- package/dist/beings/user.d.ts +108 -0
- package/dist/beings/user.js +74 -0
- package/dist/cli/client.d.ts +14 -0
- package/dist/cli/client.js +51 -0
- package/dist/cli/daemon.d.ts +57 -0
- package/dist/cli/daemon.js +581 -0
- package/dist/cli/quo.d.ts +2 -0
- package/dist/cli/quo.js +121 -0
- package/dist/harbor/browser.d.ts +13 -0
- package/dist/harbor/browser.js +50 -0
- package/dist/harbor/disk.d.ts +19 -0
- package/dist/harbor/disk.js +126 -0
- package/dist/harbor/edge/edge.d.ts +26 -0
- package/dist/harbor/edge/edge.js +123 -0
- package/dist/harbor/edge/exercise.d.ts +7 -0
- package/dist/harbor/edge/exercise.js +179 -0
- package/dist/harbor/edge/storage.d.ts +20 -0
- package/dist/harbor/edge/storage.js +81 -0
- package/dist/harbor/edge/worker.d.ts +14 -0
- package/dist/harbor/edge/worker.js +47 -0
- package/dist/harbor/files.d.ts +14 -0
- package/dist/harbor/files.js +83 -0
- package/dist/harbor/idb.d.ts +15 -0
- package/dist/harbor/idb.js +85 -0
- package/dist/human/dom.d.ts +2 -0
- package/dist/human/dom.js +15 -0
- package/dist/human/html.d.ts +32 -0
- package/dist/human/html.js +136 -0
- package/dist/human/screen.d.ts +11 -0
- package/dist/human/screen.js +66 -0
- package/dist/human/tab.d.ts +7 -0
- package/dist/human/tab.js +68 -0
- package/dist/mcp/agent.d.ts +10 -0
- package/dist/mcp/agent.js +78 -0
- package/dist/mcp/http.d.ts +27 -0
- package/dist/mcp/http.js +96 -0
- package/dist/mcp/oauth.d.ts +98 -0
- package/dist/mcp/oauth.js +241 -0
- package/dist/mcp/pilot.d.ts +6 -0
- package/dist/mcp/pilot.js +68 -0
- package/dist/mcp/runner.d.ts +45 -0
- package/dist/mcp/runner.js +94 -0
- package/dist/mcp/server.d.ts +9 -0
- package/dist/mcp/server.js +63 -0
- package/dist/mcp/web/exchange.d.ts +23 -0
- package/dist/mcp/web/exchange.js +136 -0
- package/harbor/browser.ts +63 -0
- package/harbor/disk.ts +120 -0
- package/harbor/edge/edge.ts +147 -0
- package/harbor/edge/exercise.ts +187 -0
- package/harbor/edge/platform.d.ts +28 -0
- package/harbor/edge/storage.ts +82 -0
- package/harbor/edge/worker.ts +47 -0
- package/harbor/edge/wrangler.toml +21 -0
- package/harbor/files.ts +89 -0
- package/harbor/idb.ts +90 -0
- package/harbor/quo-harbor.md +403 -0
- package/human/dom.ts +21 -0
- package/human/html.ts +164 -0
- package/human/quo-human.md +86 -0
- package/human/screen.ts +76 -0
- package/human/tab.ts +84 -0
- package/mcp/agent.ts +94 -0
- package/mcp/http.ts +96 -0
- package/mcp/oauth.ts +260 -0
- package/mcp/pilot.ts +73 -0
- package/mcp/quo-mcp.md +262 -0
- package/mcp/runner.ts +131 -0
- package/mcp/server.ts +67 -0
- package/mcp/web/exchange.ts +146 -0
- package/package.json +89 -0
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// The owner pilot: `quo pilot`. An MCP server over stdio that reaches a
|
|
3
|
+
// ward's owner asks through the daemon's root socket, and exposes four tools
|
|
4
|
+
// and no more, plus the one read. Whoever runs this process is the owner, by
|
|
5
|
+
// the device's own rules: the root of the ward here, or, with `via`, an
|
|
6
|
+
// owner at another ward's door, on a standing the user being here holds
|
|
7
|
+
// there. Every call is logged with what it made. The owner creates and
|
|
8
|
+
// places; the work goes through `quo side`, under a gate.
|
|
9
|
+
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
10
|
+
import { ListToolsRequestSchema, CallToolRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
|
11
|
+
import { ask } from '../cli/client.js';
|
|
12
|
+
export const PILOT_TOOLS = [
|
|
13
|
+
{
|
|
14
|
+
name: 'census',
|
|
15
|
+
description: 'the empty ask: the ward pk and every being, with class, public and digest',
|
|
16
|
+
inputSchema: { type: 'object', properties: {} },
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
name: 'boot',
|
|
20
|
+
description: 'boot a being by class name under a key; public marks the one public being',
|
|
21
|
+
inputSchema: { type: 'object', properties: { key: { type: 'string' }, class: { type: 'string' }, public: { type: 'boolean' } }, required: ['key', 'class'] },
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
name: 'invite',
|
|
25
|
+
description: 'mint an invitation on a being of the ward, under an id she will know the occupant by',
|
|
26
|
+
inputSchema: { type: 'object', properties: { being: { type: 'string' }, id: { type: 'string' } }, required: ['being', 'id'] },
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
name: 'knock',
|
|
30
|
+
description: 'knock for a being of the ward with an invitation, and take the standing under id if answered',
|
|
31
|
+
inputSchema: {
|
|
32
|
+
type: 'object',
|
|
33
|
+
properties: {
|
|
34
|
+
being: { description: 'a key already booted, or { boot: class, key } to boot her first' },
|
|
35
|
+
id: { type: 'string' },
|
|
36
|
+
invitation: { type: 'object' },
|
|
37
|
+
method: { type: 'string' },
|
|
38
|
+
args: { type: 'object' },
|
|
39
|
+
wanted: { type: 'object', properties: { time: { type: 'number' } } },
|
|
40
|
+
},
|
|
41
|
+
required: ['being', 'id', 'invitation'],
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
name: 'remove',
|
|
46
|
+
description: 'take a relation out of a being of the ward by id, occupant or standing; on the ward pk, an owner, by the root alone',
|
|
47
|
+
inputSchema: { type: 'object', properties: { being: { type: 'string' }, id: { type: 'string' } }, required: ['being', 'id'] },
|
|
48
|
+
},
|
|
49
|
+
];
|
|
50
|
+
const isRecord = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
51
|
+
export async function pilotSide(dir, ward, transport, log = () => { }, via) {
|
|
52
|
+
const server = new Server({ name: 'quo-pilot', version: '0.0.0' }, { capabilities: { tools: {} } });
|
|
53
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: PILOT_TOOLS }));
|
|
54
|
+
server.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
55
|
+
const name = req.params.name;
|
|
56
|
+
const args = req.params.arguments ?? {};
|
|
57
|
+
if (!PILOT_TOOLS.some((t) => t.name === name))
|
|
58
|
+
return { content: [{ type: 'text', text: JSON.stringify({ error: 'unknown ask' }) }], structuredContent: { error: 'unknown ask' }, isError: true };
|
|
59
|
+
const out = await ask(dir, name === 'census' ? undefined : name, args, ward, via);
|
|
60
|
+
const value = 'error' in out ? { error: out.error } : out.result;
|
|
61
|
+
const failed = 'error' in out || (typeof value === 'object' && value !== null && 'error' in value);
|
|
62
|
+
log(`${via === undefined ? ward : `${ward} via ${via}`} ${name} ${JSON.stringify(args)} -> ${JSON.stringify(value)}`);
|
|
63
|
+
const structured = isRecord(value) ? { structuredContent: value } : {};
|
|
64
|
+
return { content: [{ type: 'text', text: JSON.stringify(value) }], ...structured, ...(failed ? { isError: true } : {}) };
|
|
65
|
+
});
|
|
66
|
+
await server.connect(transport);
|
|
67
|
+
return { close: () => server.close() };
|
|
68
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { Blueprint, JsonObject } from '@quo-systems/quo';
|
|
2
|
+
import type { Avatar } from '../beings/avatar.ts';
|
|
3
|
+
import { type Serving } from '../beings/side.ts';
|
|
4
|
+
export type Model = {
|
|
5
|
+
url: string;
|
|
6
|
+
model: string;
|
|
7
|
+
key?: string;
|
|
8
|
+
turns?: number;
|
|
9
|
+
};
|
|
10
|
+
export declare const TURNS = 10;
|
|
11
|
+
export type Tool = {
|
|
12
|
+
type: 'function';
|
|
13
|
+
function: {
|
|
14
|
+
name: string;
|
|
15
|
+
description?: string;
|
|
16
|
+
parameters: JsonObject;
|
|
17
|
+
};
|
|
18
|
+
};
|
|
19
|
+
export type ToolCall = {
|
|
20
|
+
id: string;
|
|
21
|
+
type: 'function';
|
|
22
|
+
function: {
|
|
23
|
+
name: string;
|
|
24
|
+
arguments: string;
|
|
25
|
+
};
|
|
26
|
+
};
|
|
27
|
+
export type Message = {
|
|
28
|
+
role: 'system' | 'user';
|
|
29
|
+
content: string;
|
|
30
|
+
} | {
|
|
31
|
+
role: 'assistant';
|
|
32
|
+
content: string | null;
|
|
33
|
+
tool_calls?: ToolCall[];
|
|
34
|
+
} | {
|
|
35
|
+
role: 'tool';
|
|
36
|
+
tool_call_id: string;
|
|
37
|
+
content: string;
|
|
38
|
+
};
|
|
39
|
+
export declare function tools(bp: Blueprint): Tool[];
|
|
40
|
+
export declare function result(avatar: Avatar, call: ToolCall): Promise<string>;
|
|
41
|
+
export type Runner = Serving & {
|
|
42
|
+
say(text: string): Promise<string | null>;
|
|
43
|
+
readonly messages: readonly Message[];
|
|
44
|
+
};
|
|
45
|
+
export declare function runnerSide(avatar: Avatar, model: Model, hear?: (text: string) => void, after?: () => Promise<void>): Runner;
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { word, wordText, SILENCE_TEXT, UNREACHED_TEXT } from '../beings/side.js';
|
|
2
|
+
export const TURNS = 10;
|
|
3
|
+
// Her describe, spoken as a tools array. Name, description and input are
|
|
4
|
+
// verbatim, with the one narrowing an endpoint has asked for: an ask that
|
|
5
|
+
// declares no properties is sent with an empty `properties`, because
|
|
6
|
+
// LM Studio refuses a parameters schema without one.
|
|
7
|
+
export function tools(bp) {
|
|
8
|
+
return bp.asks.map((a) => {
|
|
9
|
+
const t = { type: 'function', function: { name: a.name, parameters: { properties: {}, ...a.input, type: 'object' } } };
|
|
10
|
+
if (a.description !== undefined)
|
|
11
|
+
t.function.description = a.description;
|
|
12
|
+
return t;
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
// One tool call, one ask, one tool message: the three words cross as the
|
|
16
|
+
// table in quo-mcp.md says, and a tool message is text, so the two words
|
|
17
|
+
// that carry nothing cross as the text the trunk gives them.
|
|
18
|
+
export async function result(avatar, call) {
|
|
19
|
+
let args = {};
|
|
20
|
+
try {
|
|
21
|
+
const parsed = call.function.arguments ? JSON.parse(call.function.arguments) : {};
|
|
22
|
+
if (parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed))
|
|
23
|
+
args = parsed;
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
return JSON.stringify({ error: 'arguments are not JSON' });
|
|
27
|
+
}
|
|
28
|
+
const w = word(await avatar.call(call.function.name, args));
|
|
29
|
+
if (w.word === 'object' || w.word === 'error')
|
|
30
|
+
return JSON.stringify(w.value);
|
|
31
|
+
return w.word === 'silence' ? SILENCE_TEXT : w.word === 'quo' ? wordText(w.name) : UNREACHED_TEXT;
|
|
32
|
+
}
|
|
33
|
+
// `hear` gets every final text the model says, whoever started the turn.
|
|
34
|
+
// `after` runs when an ask is done, as in the MCP server: a harbor that must
|
|
35
|
+
// write what the ward changed hooks it.
|
|
36
|
+
export function runnerSide(avatar, model, hear = () => { }, after = async () => { }) {
|
|
37
|
+
const messages = [];
|
|
38
|
+
const ceiling = model.turns ?? TURNS;
|
|
39
|
+
let busy = Promise.resolve();
|
|
40
|
+
async function complete(t) {
|
|
41
|
+
const headers = { 'content-type': 'application/json' };
|
|
42
|
+
if (model.key !== undefined)
|
|
43
|
+
headers.authorization = `Bearer ${model.key}`;
|
|
44
|
+
const res = await fetch(`${model.url.replace(/\/$/, '')}/chat/completions`, { method: 'POST', headers, body: JSON.stringify({ model: model.model, messages, tools: t }) });
|
|
45
|
+
const body = (await res.json());
|
|
46
|
+
const m = body.choices?.[0]?.message;
|
|
47
|
+
if (!res.ok || !m)
|
|
48
|
+
throw new Error(`model: ${res.status} ${JSON.stringify(body.error ?? body)}`);
|
|
49
|
+
return m;
|
|
50
|
+
}
|
|
51
|
+
// One turn: from the last appended message to the model's final text.
|
|
52
|
+
async function turn() {
|
|
53
|
+
const bp = await avatar.tools();
|
|
54
|
+
const t = 'asks' in bp ? tools(bp) : [];
|
|
55
|
+
for (let i = 0; i < ceiling; i++) {
|
|
56
|
+
const m = await complete(t);
|
|
57
|
+
const reply = { role: 'assistant', content: m.content };
|
|
58
|
+
if (m.tool_calls?.length)
|
|
59
|
+
reply.tool_calls = m.tool_calls;
|
|
60
|
+
messages.push(reply);
|
|
61
|
+
if (!m.tool_calls?.length) {
|
|
62
|
+
const text = m.content ?? '';
|
|
63
|
+
hear(text);
|
|
64
|
+
return text;
|
|
65
|
+
}
|
|
66
|
+
for (const call of m.tool_calls) {
|
|
67
|
+
messages.push({ role: 'tool', tool_call_id: call.id, content: await result(avatar, call) });
|
|
68
|
+
await after();
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
// Turns never overlap: one conversation, one train of thought.
|
|
74
|
+
function start(message) {
|
|
75
|
+
const next = busy.then(async () => {
|
|
76
|
+
messages.push(message);
|
|
77
|
+
return turn();
|
|
78
|
+
});
|
|
79
|
+
busy = next.catch(() => { });
|
|
80
|
+
return next;
|
|
81
|
+
}
|
|
82
|
+
const ear = (object) => {
|
|
83
|
+
void start({ role: 'user', content: JSON.stringify(object) }).catch(() => { });
|
|
84
|
+
};
|
|
85
|
+
avatar.ears.add(ear);
|
|
86
|
+
return {
|
|
87
|
+
messages,
|
|
88
|
+
say: (text) => start({ role: 'user', content: text }),
|
|
89
|
+
close: async () => {
|
|
90
|
+
avatar.ears.delete(ear);
|
|
91
|
+
await busy;
|
|
92
|
+
},
|
|
93
|
+
};
|
|
94
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
|
|
2
|
+
import { type Tool } from '@modelcontextprotocol/sdk/types.js';
|
|
3
|
+
import type { Blueprint } from '@quo-systems/quo';
|
|
4
|
+
import type { Avatar } from '../beings/avatar.ts';
|
|
5
|
+
import { type Serving } from '../beings/side.ts';
|
|
6
|
+
export declare const NAME = "quo";
|
|
7
|
+
export declare const VERSION = "0.0.0";
|
|
8
|
+
export declare function tools(bp: Blueprint): Tool[];
|
|
9
|
+
export declare function mcpSide(avatar: Avatar, transport: Transport, after?: () => Promise<void>): Promise<Serving>;
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// The model side as an MCP server: one avatar, one transport. The mapping is
|
|
3
|
+
// total and needs nothing beyond this envelope. tools/list is her describe,
|
|
4
|
+
// tools/call is a named ask on her one standing, a push is a logging
|
|
5
|
+
// notification, and the three words for "no object" each cross as the table
|
|
6
|
+
// in quo-mcp.md says. The transport is whatever the caller connected: the
|
|
7
|
+
// SDK's in-memory pair in a test, stdio for a local client, HTTP on a route.
|
|
8
|
+
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
9
|
+
import { ListToolsRequestSchema, CallToolRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
|
10
|
+
import { word, wordText, SILENCE_TEXT, UNREACHED_TEXT } from '../beings/side.js';
|
|
11
|
+
export const NAME = 'quo';
|
|
12
|
+
export const VERSION = '0.0.0';
|
|
13
|
+
// Her describe, spoken as tools. Name, description and input are verbatim;
|
|
14
|
+
// an output schema crosses when she declared one.
|
|
15
|
+
export function tools(bp) {
|
|
16
|
+
return bp.asks.map((a) => {
|
|
17
|
+
const t = { name: a.name, inputSchema: { ...a.input, type: 'object' } };
|
|
18
|
+
if (a.description !== undefined)
|
|
19
|
+
t.description = a.description;
|
|
20
|
+
if (a.output !== undefined)
|
|
21
|
+
t.outputSchema = { ...a.output, type: 'object' };
|
|
22
|
+
return t;
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
// `after` runs when a call is done: a harbor that must write what the ward
|
|
26
|
+
// changed hooks it, since a same-ward ask never crosses the harbor.
|
|
27
|
+
export async function mcpSide(avatar, transport, after = async () => { }) {
|
|
28
|
+
const server = new Server({ name: NAME, version: VERSION }, { capabilities: { tools: { listChanged: true }, logging: {} } });
|
|
29
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
30
|
+
const bp = await avatar.tools();
|
|
31
|
+
if ('error' in bp && !('asks' in bp))
|
|
32
|
+
return { tools: [] }; // not joined: nothing to show, and nothing to call
|
|
33
|
+
return { tools: tools(bp) };
|
|
34
|
+
});
|
|
35
|
+
server.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
36
|
+
const w = word(await avatar.call(req.params.name, (req.params.arguments ?? {})));
|
|
37
|
+
await after();
|
|
38
|
+
if (w.word === 'object') {
|
|
39
|
+
const structured = w.value !== null && typeof w.value === 'object' && !Array.isArray(w.value) ? { structuredContent: w.value } : {};
|
|
40
|
+
return { content: [{ type: 'text', text: JSON.stringify(w.value) }], ...structured };
|
|
41
|
+
}
|
|
42
|
+
if (w.word === 'error')
|
|
43
|
+
return { content: [{ type: 'text', text: JSON.stringify(w.value) }], structuredContent: w.value, isError: true };
|
|
44
|
+
if (w.word === 'silence')
|
|
45
|
+
return { content: [{ type: 'text', text: SILENCE_TEXT }], structuredContent: { error: 'silence' }, isError: true };
|
|
46
|
+
if (w.word === 'quo')
|
|
47
|
+
return { content: [{ type: 'text', text: wordText(w.name) }], structuredContent: { error: w.name }, isError: true };
|
|
48
|
+
return { content: [{ type: 'text', text: UNREACHED_TEXT }], structuredContent: { error: 'unreached' }, isError: true };
|
|
49
|
+
});
|
|
50
|
+
// A push from the user being is a notification to the client. If the
|
|
51
|
+
// transport is gone, the push is dropped, as the side says it is.
|
|
52
|
+
const ear = (object) => {
|
|
53
|
+
void server.sendLoggingMessage({ level: 'info', logger: NAME, data: object }).catch(() => { });
|
|
54
|
+
};
|
|
55
|
+
avatar.ears.add(ear);
|
|
56
|
+
await server.connect(transport);
|
|
57
|
+
return {
|
|
58
|
+
close: async () => {
|
|
59
|
+
avatar.ears.delete(ear);
|
|
60
|
+
await server.close();
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
2
|
+
import type { OAuth } from '../oauth.ts';
|
|
3
|
+
export declare const SESSION_TTL: number;
|
|
4
|
+
export type Admit = (identity: string, wake: boolean) => Promise<{
|
|
5
|
+
error?: string;
|
|
6
|
+
}>;
|
|
7
|
+
export type Options = {
|
|
8
|
+
oauth: OAuth;
|
|
9
|
+
password: () => string | undefined;
|
|
10
|
+
admit: Admit;
|
|
11
|
+
user: string;
|
|
12
|
+
now?: () => number;
|
|
13
|
+
};
|
|
14
|
+
export declare const suggest: (name: string) => string;
|
|
15
|
+
export declare class Exchange {
|
|
16
|
+
readonly o: Options;
|
|
17
|
+
readonly secret: NonSharedBuffer;
|
|
18
|
+
constructor(o: Options);
|
|
19
|
+
now(): number;
|
|
20
|
+
mint(): string;
|
|
21
|
+
valid(req: IncomingMessage): boolean;
|
|
22
|
+
handle(req: IncomingMessage, res: ServerResponse, rest: string): Promise<boolean>;
|
|
23
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// The exchange pages on the web route. Two static pages, hand-written, and
|
|
3
|
+
// explicitly not the human side: they render no blueprint. They end the
|
|
4
|
+
// credential exchange the way the trunk says every route does: a proof is
|
|
5
|
+
// made, the front desk trades it for an invitation, the avatar knocks, and
|
|
6
|
+
// the OAuth request completes with the client identity the human chose.
|
|
7
|
+
//
|
|
8
|
+
// GET /login?request=ID the owner password, from QUO_OWNER_PASSWORD
|
|
9
|
+
// POST /login sets a short session cookie, goes to /allow
|
|
10
|
+
// GET /allow?request=ID who is asking, and the identity they will be
|
|
11
|
+
// POST /allow allow as that identity, or deny
|
|
12
|
+
//
|
|
13
|
+
// The password is the device's, read from the environment the way an envoy
|
|
14
|
+
// reads a secret, never through cells. With none set, the exchange is closed.
|
|
15
|
+
import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
|
|
16
|
+
import { readForm } from '../oauth.js';
|
|
17
|
+
export const SESSION_TTL = 10 * 60 * 1000; // a login lives as long as a request: ten minutes
|
|
18
|
+
const COOKIE = 'quo_exchange';
|
|
19
|
+
const esc = (s) => s.replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c] ?? c);
|
|
20
|
+
const word = (s) => (typeof s === 'string' && /^[\w.-]{1,40}$/.test(s) ? s : null);
|
|
21
|
+
// A client's name as an identity: lowercase words joined by dashes.
|
|
22
|
+
export const suggest = (name) => name
|
|
23
|
+
.toLowerCase()
|
|
24
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
25
|
+
.replace(/^-|-$/g, '')
|
|
26
|
+
.slice(0, 40) || 'client';
|
|
27
|
+
export class Exchange {
|
|
28
|
+
o;
|
|
29
|
+
secret = randomBytes(32); // sessions die with the daemon, and that is fine: they live ten minutes
|
|
30
|
+
constructor(o) {
|
|
31
|
+
this.o = o;
|
|
32
|
+
}
|
|
33
|
+
now() {
|
|
34
|
+
return this.o.now?.() ?? Date.now();
|
|
35
|
+
}
|
|
36
|
+
// ---- the session cookie: an expiry, signed
|
|
37
|
+
mint() {
|
|
38
|
+
const exp = String(this.now() + SESSION_TTL);
|
|
39
|
+
return `${exp}.${createHmac('sha256', this.secret).update(exp).digest('base64url')}`;
|
|
40
|
+
}
|
|
41
|
+
valid(req) {
|
|
42
|
+
const m = /(?:^|;\s*)quo_exchange=([^;]+)/.exec(req.headers.cookie ?? '');
|
|
43
|
+
const [exp, sig] = (m?.[1] ?? '').split('.');
|
|
44
|
+
if (!exp || !sig)
|
|
45
|
+
return false;
|
|
46
|
+
const want = createHmac('sha256', this.secret).update(exp).digest('base64url');
|
|
47
|
+
return sig.length === want.length && timingSafeEqual(Buffer.from(sig), Buffer.from(want)) && Number(exp) > this.now();
|
|
48
|
+
}
|
|
49
|
+
async handle(req, res, rest) {
|
|
50
|
+
const url = new URL(req.url ?? '/', 'http://localhost');
|
|
51
|
+
const page = (status, body, headers = {}) => {
|
|
52
|
+
res.writeHead(status, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store', ...headers });
|
|
53
|
+
res.end(shell(body));
|
|
54
|
+
};
|
|
55
|
+
const go = (to, headers = {}) => {
|
|
56
|
+
res.writeHead(302, { location: to, 'cache-control': 'no-store', ...headers });
|
|
57
|
+
res.end();
|
|
58
|
+
};
|
|
59
|
+
if (rest === '/login' && req.method === 'GET') {
|
|
60
|
+
const request = url.searchParams.get('request') ?? '';
|
|
61
|
+
const p = this.o.oauth.pending(request);
|
|
62
|
+
if (!p)
|
|
63
|
+
return page(400, `<h1>Nothing to allow</h1><p>This request is gone. Start again from the app that sent you here.</p>`), true;
|
|
64
|
+
if (this.o.password() === undefined)
|
|
65
|
+
return page(503, `<h1>Closed</h1><p>This world takes no logins: no owner password is set.</p>`), true;
|
|
66
|
+
if (this.valid(req))
|
|
67
|
+
return go(`/allow?request=${encodeURIComponent(request)}`), true;
|
|
68
|
+
return page(200, loginForm(request, p.client.client_name)), true;
|
|
69
|
+
}
|
|
70
|
+
if (rest === '/login' && req.method === 'POST') {
|
|
71
|
+
const f = await readForm(req);
|
|
72
|
+
const request = f.get('request') ?? '';
|
|
73
|
+
const p = this.o.oauth.pending(request);
|
|
74
|
+
if (!p)
|
|
75
|
+
return page(400, `<h1>Nothing to allow</h1><p>This request is gone.</p>`), true;
|
|
76
|
+
const want = this.o.password();
|
|
77
|
+
const got = f.get('password') ?? '';
|
|
78
|
+
if (want === undefined)
|
|
79
|
+
return page(503, `<h1>Closed</h1>`), true;
|
|
80
|
+
if (got.length !== want.length || !timingSafeEqual(Buffer.from(got), Buffer.from(want))) {
|
|
81
|
+
await new Promise((ok) => setTimeout(ok, 300));
|
|
82
|
+
return page(401, loginForm(request, p.client.client_name, 'That is not the password.')), true;
|
|
83
|
+
}
|
|
84
|
+
return go(`/allow?request=${encodeURIComponent(request)}`, { 'set-cookie': `${COOKIE}=${this.mint()}; Path=/; HttpOnly; Secure; SameSite=Strict; Max-Age=${SESSION_TTL / 1000}` }), true;
|
|
85
|
+
}
|
|
86
|
+
if (rest === '/allow' && req.method === 'GET') {
|
|
87
|
+
const request = url.searchParams.get('request') ?? '';
|
|
88
|
+
const p = this.o.oauth.pending(request);
|
|
89
|
+
if (!p)
|
|
90
|
+
return page(400, `<h1>Nothing to allow</h1><p>This request is gone.</p>`), true;
|
|
91
|
+
if (!this.valid(req))
|
|
92
|
+
return go(`/login?request=${encodeURIComponent(request)}`), true;
|
|
93
|
+
return page(200, allowForm(request, p.client.client_name, p.redirect_uri, suggest(p.client.client_name), this.o.user)), true;
|
|
94
|
+
}
|
|
95
|
+
if (rest === '/allow' && req.method === 'POST') {
|
|
96
|
+
const f = await readForm(req);
|
|
97
|
+
const request = f.get('request') ?? '';
|
|
98
|
+
const p = this.o.oauth.pending(request);
|
|
99
|
+
if (!p)
|
|
100
|
+
return page(400, `<h1>Nothing to allow</h1><p>This request is gone.</p>`), true;
|
|
101
|
+
if (!this.valid(req))
|
|
102
|
+
return go(`/login?request=${encodeURIComponent(request)}`), true;
|
|
103
|
+
if (f.get('decision') !== 'allow') {
|
|
104
|
+
const out = await this.o.oauth.deny(request);
|
|
105
|
+
return 'redirect' in out ? go(out.redirect) : page(400, `<h1>Gone</h1>`), true;
|
|
106
|
+
}
|
|
107
|
+
const identity = word(f.get('identity'));
|
|
108
|
+
if (identity === null || identity === this.o.user || identity === 'desk')
|
|
109
|
+
return page(400, allowForm(request, p.client.client_name, p.redirect_uri, suggest(p.client.client_name), this.o.user, 'An identity is one word, and not the user or the desk.')), true;
|
|
110
|
+
const admitted = await this.o.admit(identity, f.get('wake') === 'on');
|
|
111
|
+
if (admitted.error)
|
|
112
|
+
return page(500, `<h1>Not admitted</h1><p>${esc(admitted.error)}</p>`), true;
|
|
113
|
+
const out = await this.o.oauth.complete(request, identity);
|
|
114
|
+
return 'redirect' in out ? go(out.redirect, { 'set-cookie': `${COOKIE}=; Path=/; Max-Age=0` }) : page(400, `<h1>Gone</h1>`), true;
|
|
115
|
+
}
|
|
116
|
+
return false;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
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>
|
|
120
|
+
<style>body{font:16px/1.5 system-ui,sans-serif;max-width:32rem;margin:4rem auto;padding:0 1rem;color:#222}h1{font-size:1.4rem}label{display:block;margin:1rem 0 .25rem}input{font:inherit;padding:.5rem;width:100%;box-sizing:border-box}button{font:inherit;padding:.5rem 1rem;margin:1rem .5rem 0 0}.err{color:#b00}.who{background:#f4f4f4;padding:.75rem 1rem;border-radius:.5rem}code{word-break:break-all}</style>
|
|
121
|
+
</head><body>${body}</body></html>`;
|
|
122
|
+
const loginForm = (request, client, err = '') => `<h1>${esc(client)} wants in</h1>
|
|
123
|
+
<p>Log in as the owner of this world to decide.</p>
|
|
124
|
+
${err ? `<p class="err">${esc(err)}</p>` : ''}
|
|
125
|
+
<form method="post" action="/login"><input type="hidden" name="request" value="${esc(request)}">
|
|
126
|
+
<label for="p">Owner password</label><input id="p" name="password" type="password" autocomplete="current-password" autofocus required>
|
|
127
|
+
<button type="submit">Log in</button></form>`;
|
|
128
|
+
const allowForm = (request, client, redirect, identity, user, err = '') => `<h1>Allow ${esc(client)}?</h1>
|
|
129
|
+
<div class="who"><p><strong>${esc(client)}</strong> asks to be an occupant of <strong>${esc(user)}</strong>.</p>
|
|
130
|
+
<p>It will see exactly what ${esc(user)} shows the identity below, and nothing else. You can remove it any time.</p>
|
|
131
|
+
<p>It returns to <code>${esc(redirect)}</code>.</p></div>
|
|
132
|
+
${err ? `<p class="err">${esc(err)}</p>` : ''}
|
|
133
|
+
<form method="post" action="/allow"><input type="hidden" name="request" value="${esc(request)}">
|
|
134
|
+
<label for="i">Identity</label><input id="i" name="identity" value="${esc(identity)}" pattern="[\\w.-]{1,40}" required>
|
|
135
|
+
<label for="w"><input id="w" name="wake" type="checkbox" style="width:auto"> May wake your other devices: hand an agent an event through ${esc(user)}</label>
|
|
136
|
+
<button type="submit" name="decision" value="allow">Allow</button><button type="submit" name="decision" value="deny">Deny</button></form>`;
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// The browser harbor: the core over IndexedDB, in a tab. A dialer and never
|
|
3
|
+
// a listener: it holds one socket per world it is connected to, and every
|
|
4
|
+
// ask to a pk it does not hold goes down a socket to the listener, which is
|
|
5
|
+
// the rendezvous. The lease is a web lock on the database name, held for
|
|
6
|
+
// the life of the tab: a second tab on one origin meets the lock and is a
|
|
7
|
+
// screen, not a harbor. The classes are the estate's beings, handed in
|
|
8
|
+
// with the bundle, plus whatever a page hands in beside them.
|
|
9
|
+
import type { BeingClass } from '@quo-systems/quo';
|
|
10
|
+
import { User, Desk, Avatar } from '../beings/index.ts';
|
|
11
|
+
import { Harbor, dial, type Dialer } from '@quo-systems/quo/harbor';
|
|
12
|
+
import { Idb } from './idb.ts';
|
|
13
|
+
|
|
14
|
+
export const BUILT_IN: Record<string, BeingClass> = { User, Desk, Avatar };
|
|
15
|
+
|
|
16
|
+
export class BrowserHarbor extends Harbor {
|
|
17
|
+
readonly db: Idb;
|
|
18
|
+
readonly dialers: Dialer[] = [];
|
|
19
|
+
#release: (() => void) | undefined;
|
|
20
|
+
|
|
21
|
+
constructor(name = 'quo', classes: Record<string, BeingClass> = {}) {
|
|
22
|
+
const db = new Idb(name);
|
|
23
|
+
super(db, async () => ({ ...BUILT_IN, ...classes }));
|
|
24
|
+
this.db = db;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Take the lock and boot every ward kept here. A lock already held is
|
|
28
|
+
// another tab's harbor over this seed, and this one refuses to boot.
|
|
29
|
+
override async boot(): Promise<void> {
|
|
30
|
+
await this.#lease();
|
|
31
|
+
await super.boot();
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Hold one socket to a world's quo. route.
|
|
35
|
+
dial(url: string): Dialer {
|
|
36
|
+
const d = dial(this, url);
|
|
37
|
+
this.dialers.push(d);
|
|
38
|
+
return d;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async close(): Promise<void> {
|
|
42
|
+
for (const d of this.dialers.splice(0)) d.close();
|
|
43
|
+
this.db.close();
|
|
44
|
+
this.#release?.();
|
|
45
|
+
this.#release = undefined;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async #lease(): Promise<void> {
|
|
49
|
+
const locks = (globalThis.navigator as Navigator | undefined)?.locks;
|
|
50
|
+
if (!locks) return; // no web locks on this terrain: nothing vouches, as in the memory harbor
|
|
51
|
+
const granted = await new Promise<boolean>((ok) => {
|
|
52
|
+
void locks.request(`quo:${this.db.name}`, { ifAvailable: true }, (lock) => {
|
|
53
|
+
if (!lock) {
|
|
54
|
+
ok(false);
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
ok(true);
|
|
58
|
+
return new Promise<void>((release) => (this.#release = release)); // held until close
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
if (!granted) throw new Error(`harbor ${this.db.name} is held by another tab`);
|
|
62
|
+
}
|
|
63
|
+
}
|
package/harbor/disk.ts
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// The node daemon's harbor: the core over a folder on disk. The store is
|
|
3
|
+
// `files.ts`, one folder per ward; the class bodies come from a module the
|
|
4
|
+
// ward record names, loaded from the folder; the lease is a pid file. The
|
|
5
|
+
// estate's own beings, the user being, the front desk and the avatar, are
|
|
6
|
+
// held by every harbor here: they are what every side needs, and a folder's
|
|
7
|
+
// class source adds to them.
|
|
8
|
+
//
|
|
9
|
+
// <dir>/
|
|
10
|
+
// lease pid of the one process that holds this harbor
|
|
11
|
+
// classes/index.ts the default class source, a module exporting classes
|
|
12
|
+
// wards/<name>/ seed, partition.json, ward.json: see files.ts
|
|
13
|
+
// reach.json the directory's hints
|
|
14
|
+
import { mkdir, readFile, writeFile, unlink, stat } from 'node:fs/promises';
|
|
15
|
+
import { existsSync } from 'node:fs';
|
|
16
|
+
import { join, resolve, isAbsolute } from 'node:path';
|
|
17
|
+
import { pathToFileURL } from 'node:url';
|
|
18
|
+
import process from 'node:process';
|
|
19
|
+
import type { BeingClass } from '@quo-systems/quo';
|
|
20
|
+
import { User, Desk, Avatar } from '../beings/index.ts';
|
|
21
|
+
import { setup } from '../beings/setup.ts';
|
|
22
|
+
import { Harbor, DEFAULT_CODE, type Hosted } from '@quo-systems/quo/harbor';
|
|
23
|
+
import { Files } from './files.ts';
|
|
24
|
+
|
|
25
|
+
export type { Hosted, Bound } from '@quo-systems/quo/harbor';
|
|
26
|
+
export type { WardRecord } from '@quo-systems/quo/harbor';
|
|
27
|
+
export const BUILT_IN: Record<string, BeingClass> = { User, Desk, Avatar };
|
|
28
|
+
|
|
29
|
+
// Folders held by a harbor in this process. The lease file names a pid, and
|
|
30
|
+
// a second harbor in the same pid would pass that check while still being a
|
|
31
|
+
// second ward with one pk.
|
|
32
|
+
const HELD = new Set<string>();
|
|
33
|
+
|
|
34
|
+
// The class source is a module. Every export that is a class is a class the
|
|
35
|
+
// harbor holds, under its export name. The harbor never sees a body: it
|
|
36
|
+
// constructs when a ward names a class, and never chooses one.
|
|
37
|
+
async function loadClasses(path: string): Promise<Record<string, BeingClass>> {
|
|
38
|
+
if (!existsSync(path)) return {};
|
|
39
|
+
// Keyed by the file's own time, so a source that changed on disk is read
|
|
40
|
+
// again and a harbor boots on what it pins, not on what it once loaded.
|
|
41
|
+
const { mtimeMs } = await stat(path);
|
|
42
|
+
const mod = (await import(`${pathToFileURL(path).href}?v=${mtimeMs}`)) as Record<string, unknown>;
|
|
43
|
+
const out: Record<string, BeingClass> = {};
|
|
44
|
+
for (const [name, v] of Object.entries(mod)) if (typeof v === 'function' && 'prototype' in v) out[name] = v as BeingClass;
|
|
45
|
+
return out;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export class DiskHarbor extends Harbor {
|
|
49
|
+
readonly dir: string;
|
|
50
|
+
#held = false;
|
|
51
|
+
|
|
52
|
+
constructor(dir: string) {
|
|
53
|
+
const abs = resolve(dir);
|
|
54
|
+
super(new Files(abs), async (rec) => ({ ...BUILT_IN, ...(await loadClasses(isAbsolute(rec.code) ? rec.code : join(abs, rec.code))) }));
|
|
55
|
+
this.dir = abs;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Create a harbor folder with one ward, and the root's setup in it: the
|
|
59
|
+
// front desk, public, and one user being, with the desk holding a standing
|
|
60
|
+
// to her placed by the root's knock. Boots the ward once, with no lease, to
|
|
61
|
+
// do that and to learn its pk, and writes the record. Refuses a folder that
|
|
62
|
+
// has one.
|
|
63
|
+
static async init(dir: string, name = 'main', user = 'me'): Promise<{ dir: string; name: string; pk: string; user: string }> {
|
|
64
|
+
const h = new DiskHarbor(dir);
|
|
65
|
+
if (existsSync(join(h.dir, 'wards', name, 'seed'))) throw new Error(`ward ${name} already exists in ${h.dir}`);
|
|
66
|
+
await mkdir(join(h.dir, 'classes'), { recursive: true });
|
|
67
|
+
const classes = join(h.dir, DEFAULT_CODE);
|
|
68
|
+
if (!existsSync(classes)) await writeFile(classes, '// The classes this harbor holds beside the built-in ones. Export each one by name.\nexport {};\n');
|
|
69
|
+
const hosted = await h.create(name, user);
|
|
70
|
+
await setup(hosted, user);
|
|
71
|
+
return { dir: h.dir, name, pk: hosted.pk, user };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Take the lease and boot every ward in the folder. The lease is the
|
|
75
|
+
// harbor's vouch made a store property: two processes over one seed are two
|
|
76
|
+
// wards with one pk, so a held lease refuses the boot.
|
|
77
|
+
override async boot(): Promise<void> {
|
|
78
|
+
await this.#lease();
|
|
79
|
+
await super.boot();
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async close(): Promise<void> {
|
|
83
|
+
if (!this.#held) return;
|
|
84
|
+
await unlink(join(this.dir, 'lease')).catch(() => {});
|
|
85
|
+
HELD.delete(this.dir);
|
|
86
|
+
this.#held = false;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Boot a ward whose folder was placed here by hand.
|
|
90
|
+
async adoptFolder(name: string): Promise<Hosted> {
|
|
91
|
+
const kept = await this.store.load(name);
|
|
92
|
+
if (!kept) throw new Error(`no ward ${name} in ${this.dir}`);
|
|
93
|
+
return this.host(name, kept);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async #lease(): Promise<void> {
|
|
97
|
+
if (HELD.has(this.dir)) throw new Error(`harbor ${this.dir} is held by pid ${process.pid}`);
|
|
98
|
+
const path = join(this.dir, 'lease');
|
|
99
|
+
if (existsSync(path)) {
|
|
100
|
+
const pid = Number(await readFile(path, 'utf8'));
|
|
101
|
+
let alive = pid !== process.pid; // our own pid with no entry in HELD is a lease we left behind
|
|
102
|
+
if (alive) {
|
|
103
|
+
try {
|
|
104
|
+
process.kill(pid, 0);
|
|
105
|
+
} catch (e) {
|
|
106
|
+
alive = (e as { code?: string }).code === 'EPERM'; // EPERM: somebody is home, and not us. anything else: a corpse
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
if (alive) throw new Error(`harbor ${this.dir} is held by pid ${pid}`);
|
|
110
|
+
await unlink(path).catch(() => {}); // the corpse, so the write below can be exclusive
|
|
111
|
+
}
|
|
112
|
+
try {
|
|
113
|
+
await writeFile(path, String(process.pid), { flag: 'wx', mode: 0o600 });
|
|
114
|
+
} catch {
|
|
115
|
+
throw new Error(`harbor ${this.dir} is held: another daemon took the lease first`);
|
|
116
|
+
}
|
|
117
|
+
HELD.add(this.dir);
|
|
118
|
+
this.#held = true;
|
|
119
|
+
}
|
|
120
|
+
}
|