@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,147 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// The edge harbor: the core over one Durable Object. The object is the
|
|
3
|
+
// harbor, and the platform is its lease: one instance of it runs at a time,
|
|
4
|
+
// which is the single writer every ward needs. The store is the object's
|
|
5
|
+
// storage with the seed sealed under a platform secret; the code is the
|
|
6
|
+
// deployed worker, the built-in beings plus whatever the worker hands in.
|
|
7
|
+
// It is a listener and never a dialer: reached by request at its route,
|
|
8
|
+
// holding the sockets dialers open to it, the rendezvous for them. It wakes
|
|
9
|
+
// per request, and a held socket keeps it awake.
|
|
10
|
+
//
|
|
11
|
+
// Its owner door is a route, not a socket: the platform has no local
|
|
12
|
+
// process, so the root is whoever holds `QUO_ROOT`, a platform secret, and
|
|
13
|
+
// the root's asks arrive as one POST under it. That is the device's own
|
|
14
|
+
// rule on this terrain, as file permissions are on a disk. The first thing
|
|
15
|
+
// a root does is invite an owner on the ward's pk, and from then on the
|
|
16
|
+
// edge is piloted from elsewhere over a standing, like any ward.
|
|
17
|
+
//
|
|
18
|
+
// POST <route>/quo/<pk> one sealed ask to a pk held here, or on a socket here
|
|
19
|
+
// GET <route>/quo upgrade a dialer's held socket, its pks bound at announce
|
|
20
|
+
// GET <route>/health the wards, the directory, the sockets
|
|
21
|
+
// POST <route>/root { init: { ward, user } } | { ward?, method?, args? }, under the root secret
|
|
22
|
+
import type { BeingClass } from '@quo-systems/quo';
|
|
23
|
+
import { isSilence } from '@quo-systems/quo';
|
|
24
|
+
import { User, Desk, Avatar } from '../../beings/index.ts';
|
|
25
|
+
import { setup } from '../../beings/setup.ts';
|
|
26
|
+
import { Harbor, Socket as Held, SUITE, type Line } from '@quo-systems/quo/harbor';
|
|
27
|
+
import { DurableStorage, sealKey } from './storage.ts';
|
|
28
|
+
import type { State, Env } from './platform.d.ts';
|
|
29
|
+
|
|
30
|
+
export const BUILT_IN: Record<string, BeingClass> = { User, Desk, Avatar };
|
|
31
|
+
const json = (status: number, v: unknown, headers: Record<string, string> = {}) => new Response(JSON.stringify(v), { status, headers: { 'content-type': 'application/json', ...headers } });
|
|
32
|
+
const open = { 'access-control-allow-origin': '*', 'access-control-allow-methods': 'POST, OPTIONS', 'access-control-allow-headers': 'content-type, quo-suite' };
|
|
33
|
+
|
|
34
|
+
export class EdgeHarbor extends Harbor {
|
|
35
|
+
readonly sockets = new Set<Held>();
|
|
36
|
+
readonly root: string | undefined;
|
|
37
|
+
#booted: Promise<void> | undefined;
|
|
38
|
+
|
|
39
|
+
static async open(state: State, env: Env, classes: Record<string, BeingClass> = {}): Promise<EdgeHarbor> {
|
|
40
|
+
if (!env.QUO_SEED_KEY) throw new Error('no QUO_SEED_KEY: a seed is never kept plain on the edge');
|
|
41
|
+
return new EdgeHarbor(new DurableStorage(state.storage, await sealKey(env.QUO_SEED_KEY)), env.QUO_ROOT, classes);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
constructor(store: DurableStorage, root: string | undefined, classes: Record<string, BeingClass>) {
|
|
45
|
+
super(store, async () => ({ ...BUILT_IN, ...classes }));
|
|
46
|
+
this.root = root;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Once per instance, on the first request: the platform may have woken a
|
|
50
|
+
// fresh instance, and every ward boots from storage before anything is
|
|
51
|
+
// answered.
|
|
52
|
+
ready(): Promise<void> {
|
|
53
|
+
return (this.#booted ??= this.boot());
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// The worker hands the request here with the path after the harbor's
|
|
57
|
+
// prefix: `/quo/<pk>`, `/quo`, `/health`, `/root`.
|
|
58
|
+
async fetch(request: Request, path: string): Promise<Response> {
|
|
59
|
+
await this.ready();
|
|
60
|
+
if (path === '/health') return json(200, this.health(), { 'access-control-allow-origin': '*' });
|
|
61
|
+
if (path === '/root') return this.rootAsk(request);
|
|
62
|
+
if (path === '/quo' || path === '/quo/') {
|
|
63
|
+
if (request.headers.get('upgrade')?.toLowerCase() === 'websocket') return this.upgrade();
|
|
64
|
+
return json(404, { error: 'POST /quo/<pk>' }, open);
|
|
65
|
+
}
|
|
66
|
+
if (path.startsWith('/quo/')) {
|
|
67
|
+
if (request.method === 'OPTIONS') return new Response(null, { status: 204, headers: open });
|
|
68
|
+
const pk = path.slice(5);
|
|
69
|
+
if (request.method !== 'POST' || !/^[0-9a-f]{128}$/.test(pk)) return json(404, { error: 'POST /quo/<pk>' }, open);
|
|
70
|
+
// The wire suite, read before anything is carried: a caller speaking
|
|
71
|
+
// another one is answered nothing delivered, as the daemon's door does.
|
|
72
|
+
const suite = request.headers.get('quo-suite');
|
|
73
|
+
if (suite !== null && suite !== String(SUITE)) return json(404, { error: `this door speaks wire suite ${SUITE}` }, open);
|
|
74
|
+
const back = await this.deliver(pk, new Uint8Array(await request.arrayBuffer()));
|
|
75
|
+
if (back === undefined) return json(404, { error: 'no reach for that pk' }, open);
|
|
76
|
+
return new Response(back as unknown as BodyInit, { status: 200, headers: { 'content-type': 'application/octet-stream', ...open } });
|
|
77
|
+
}
|
|
78
|
+
return json(404, { error: 'no such route' });
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
health() {
|
|
82
|
+
return {
|
|
83
|
+
ok: true,
|
|
84
|
+
wards: Object.fromEntries([...this.wards].map(([n, h]) => [n, h.pk])),
|
|
85
|
+
reaches: Object.fromEntries([...this.reaches].map(([pk, b]) => [pk, b.held ? 'held' : 'reach'])),
|
|
86
|
+
sockets: this.sockets.size,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// A dialer's socket: the server end of a pair, held here, its pks bound
|
|
91
|
+
// at announce and unbound at close. The platform keeps the object awake
|
|
92
|
+
// while the socket is open.
|
|
93
|
+
upgrade(): Response {
|
|
94
|
+
const pair = new WebSocketPair();
|
|
95
|
+
const server = pair[1];
|
|
96
|
+
server.accept();
|
|
97
|
+
const s: Held = new Held(
|
|
98
|
+
server as unknown as Line,
|
|
99
|
+
(pk, bytes) => this.deliver(pk, bytes),
|
|
100
|
+
(far) => void this.bind(far, s, true),
|
|
101
|
+
() => {
|
|
102
|
+
this.unbind(s);
|
|
103
|
+
this.sockets.delete(s);
|
|
104
|
+
},
|
|
105
|
+
);
|
|
106
|
+
this.sockets.add(s);
|
|
107
|
+
s.announce([...this.doors.keys()]);
|
|
108
|
+
return new Response(null, { status: 101, webSocket: pair[0] });
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// The root's door. Under the secret only; without one configured the
|
|
112
|
+
// edge has no root at all, and every owner it will ever have was invited
|
|
113
|
+
// by a root that existed when it did.
|
|
114
|
+
async rootAsk(request: Request): Promise<Response> {
|
|
115
|
+
const bearer = request.headers.get('authorization')?.replace(/^Bearer\s+/i, '') ?? '';
|
|
116
|
+
if (request.method !== 'POST' || !this.root || bearer.length !== this.root.length || !same(bearer, this.root)) return json(401, { error: 'the root holds the secret' });
|
|
117
|
+
let body: { init?: { ward?: unknown; user?: unknown }; ward?: unknown; method?: unknown; args?: unknown } = {};
|
|
118
|
+
try {
|
|
119
|
+
body = (await request.json()) as typeof body;
|
|
120
|
+
} catch {
|
|
121
|
+
return json(400, { error: 'a root ask is JSON' });
|
|
122
|
+
}
|
|
123
|
+
try {
|
|
124
|
+
if (body.init) {
|
|
125
|
+
const name = typeof body.init.ward === 'string' ? body.init.ward : 'main';
|
|
126
|
+
const user = typeof body.init.user === 'string' ? body.init.user : 'me';
|
|
127
|
+
if (this.wards.has(name)) return json(409, { error: `ward ${name} already exists here` });
|
|
128
|
+
const hosted = await this.create(name, user);
|
|
129
|
+
await setup(hosted, user);
|
|
130
|
+
return json(200, { name, pk: hosted.pk, user });
|
|
131
|
+
}
|
|
132
|
+
const hosted = this.wards.get(typeof body.ward === 'string' ? body.ward : 'main');
|
|
133
|
+
if (!hosted) return json(404, { error: 'no such ward' });
|
|
134
|
+
const out = await hosted.ask(typeof body.method === 'string' ? body.method : undefined, (body.args ?? {}) as Record<string, unknown>);
|
|
135
|
+
return json(200, { result: isSilence(out) ? { silence: true } : out });
|
|
136
|
+
} catch (e) {
|
|
137
|
+
return json(500, { error: e instanceof Error ? e.message : String(e) });
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Constant time, as far as a comparison in a worker can be.
|
|
143
|
+
function same(a: string, b: string): boolean {
|
|
144
|
+
let out = 0;
|
|
145
|
+
for (let i = 0; i < a.length; i++) out |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
146
|
+
return out === 0;
|
|
147
|
+
}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// What the edge terrain runs, inside the platform: the library's
|
|
3
|
+
// conformance suite against two edge harbors in one object, each over its
|
|
4
|
+
// own prefixed view of the object's storage, reaching each other by request
|
|
5
|
+
// through the worker's own origin under `/x/<sub>/quo`, so every relation
|
|
6
|
+
// crosses the route. Down is the reach refused on both; migrate lifts seed,
|
|
7
|
+
// partition and record out of one view into the other and boots there. It
|
|
8
|
+
// reports rather than throws, so the worker hands the list back and Node
|
|
9
|
+
// asserts on it. Only with `QUO_EXERCISE` set, which a deployment never sets.
|
|
10
|
+
import { conform, conformStore, conformReach } from '@quo-systems/quo/conformance';
|
|
11
|
+
import { request as byRequest } from '@quo-systems/quo/harbor';
|
|
12
|
+
import type { World, Handle, Census } from '@quo-systems/quo/conformance';
|
|
13
|
+
import { Printer, Shop, Customer, Echo, Member } from '@quo-systems/quo/conformance';
|
|
14
|
+
import type { BeingClass, Cells, Invitation, JsonObject } from '@quo-systems/quo';
|
|
15
|
+
import { EdgeHarbor } from './edge.ts';
|
|
16
|
+
import { DurableStorage, sealKey } from './storage.ts';
|
|
17
|
+
import type { Hosted } from '@quo-systems/quo/harbor';
|
|
18
|
+
import type { State, Env } from './platform.d.ts';
|
|
19
|
+
|
|
20
|
+
export type Result = { name: string; skip?: string; error?: string };
|
|
21
|
+
const assert = {
|
|
22
|
+
equal(a: unknown, b: unknown, m: string) {
|
|
23
|
+
if (a !== b) throw new Error(`${m}: ${JSON.stringify(a)} is not ${JSON.stringify(b)}`);
|
|
24
|
+
},
|
|
25
|
+
deepEqual(a: unknown, b: unknown, m = 'not equal') {
|
|
26
|
+
if (JSON.stringify(a) !== JSON.stringify(b)) throw new Error(`${m}: ${JSON.stringify(a)} is not ${JSON.stringify(b)}`);
|
|
27
|
+
},
|
|
28
|
+
};
|
|
29
|
+
type Partition = { beings: Record<string, Cells>; bind: Record<string, { standings: Record<string, { ward: string; heir: string | null; seq: number; current: string | null; next: string | null }>; occupants: Record<string, string>; knocks: Record<string, { current: string | null; next: string | null }>; answered: Record<string, unknown>; minted: string[] }>; heirs: Record<string, { being: string; id: string; current: string; announced: string | null; fresh: boolean; mark: number; spent: number[] }>; public: string | null };
|
|
30
|
+
const CLASSES: Record<string, BeingClass> = { Printer, Shop, Customer, Echo, Member };
|
|
31
|
+
|
|
32
|
+
// The harbors the exercise made, by sub name, for the router below.
|
|
33
|
+
const subs = new Map<string, EdgeHarbor>();
|
|
34
|
+
|
|
35
|
+
async function pair(state: State, env: Env, run: number): Promise<{ harbors: EdgeHarbor[]; names: string[] }> {
|
|
36
|
+
const key = await sealKey(env.QUO_SEED_KEY ?? '');
|
|
37
|
+
const names = [`a-${run}-${Date.now()}`, `b-${run}-${Date.now()}`];
|
|
38
|
+
const harbors = names.map((n) => new EdgeHarbor(new DurableStorage(state.storage, key, `x:${n}:`), undefined, CLASSES));
|
|
39
|
+
for (const [i, h] of harbors.entries()) {
|
|
40
|
+
await h.ready();
|
|
41
|
+
subs.set(names[i]!, h);
|
|
42
|
+
}
|
|
43
|
+
return { harbors, names };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function probe(base: string, state: State, env: Env): () => Promise<World> {
|
|
47
|
+
let n = 0;
|
|
48
|
+
return async () => {
|
|
49
|
+
const p = await pair(state, env, n++);
|
|
50
|
+
const wardOf = new Map<string, string>();
|
|
51
|
+
const where = new Map<string, number>();
|
|
52
|
+
let k = 0;
|
|
53
|
+
const at = (name: string) => {
|
|
54
|
+
const h = p.harbors[where.get(name)!]!;
|
|
55
|
+
const w = h.wards.get(name)!;
|
|
56
|
+
return { h, w, p: w.partition as unknown as Partition };
|
|
57
|
+
};
|
|
58
|
+
// the far harbor learns the pk by a hint: the link's reach hint, this world's route
|
|
59
|
+
const learn = (i: number, w: Hosted) => p.harbors[1 - i]!.hint(w.pk, `${base}/x/${p.names[i]}/quo`);
|
|
60
|
+
return {
|
|
61
|
+
oneWard: false,
|
|
62
|
+
async boot<B>(key: string, Class: BeingClass, cells: JsonObject = {}, { isPublic = false } = {}): Promise<Handle<B>> {
|
|
63
|
+
for (const h of p.harbors) h.classes[Class.name] = Class;
|
|
64
|
+
const name = `W-${key}`;
|
|
65
|
+
const i = where.get(name) ?? k++ % 2;
|
|
66
|
+
const h = p.harbors[i]!;
|
|
67
|
+
const w = h.wards.get(name) ?? (await h.create(name));
|
|
68
|
+
if (!where.has(name)) {
|
|
69
|
+
where.set(name, i);
|
|
70
|
+
learn(i, w);
|
|
71
|
+
}
|
|
72
|
+
wardOf.set(key, name);
|
|
73
|
+
assert.deepEqual(await w.ask('boot', { key, class: Class.name, public: isPublic }), { booted: key });
|
|
74
|
+
if (Object.keys(cells).length) Object.assign(at(name).p.beings[key]!, cells);
|
|
75
|
+
return {
|
|
76
|
+
pk: w.pk,
|
|
77
|
+
get cells() {
|
|
78
|
+
return at(name).p.beings[key]!;
|
|
79
|
+
},
|
|
80
|
+
get being() {
|
|
81
|
+
return at(name).w.being(key) as B;
|
|
82
|
+
},
|
|
83
|
+
minted: { has: (pk: string) => at(name).p.bind[key]!.minted.includes(pk) },
|
|
84
|
+
forgeKnock: (inv: Invitation) => void ((at(name).p.bind[key]!.knocks as Record<string, unknown>)[`${inv.ward}:${inv.heir}`] = { current: 'ab'.repeat(32), next: null, spoke: true, sent: true, seq: 0 }),
|
|
85
|
+
set down(v: boolean) {
|
|
86
|
+
for (const each of p.harbors) {
|
|
87
|
+
if (v) each.down.add(at(name).w.pk);
|
|
88
|
+
else each.down.delete(at(name).w.pk);
|
|
89
|
+
}
|
|
90
|
+
},
|
|
91
|
+
get down() {
|
|
92
|
+
return p.harbors[0]!.down.has(at(name).w.pk);
|
|
93
|
+
},
|
|
94
|
+
};
|
|
95
|
+
},
|
|
96
|
+
heir(inv: Invitation) {
|
|
97
|
+
for (const h of p.harbors)
|
|
98
|
+
for (const w of h.wards.values())
|
|
99
|
+
if (w.pk === inv.ward) {
|
|
100
|
+
const r = (w.partition as unknown as Partition).heirs[inv.heir!];
|
|
101
|
+
return r && { current: r.current, announced: r.announced, fresh: r.fresh };
|
|
102
|
+
}
|
|
103
|
+
return undefined;
|
|
104
|
+
},
|
|
105
|
+
census(): Census {
|
|
106
|
+
const c: Census = { wards: {}, binds: {}, cells: {} };
|
|
107
|
+
for (const h of p.harbors)
|
|
108
|
+
for (const w of h.wards.values()) {
|
|
109
|
+
const pt = w.partition as unknown as Partition;
|
|
110
|
+
c.wards[w.pk] = { beings: Object.keys(pt.beings), public: pt.public, heirs: Object.fromEntries(Object.entries(pt.heirs).map(([pk, r]) => [pk, { being: r.being, id: r.id, current: r.current, announced: r.announced, fresh: r.fresh, mark: r.mark, spent: [...r.spent] }])) };
|
|
111
|
+
for (const [key, b] of Object.entries(pt.bind)) {
|
|
112
|
+
c.binds[key] = {
|
|
113
|
+
ward: w.pk,
|
|
114
|
+
standings: Object.fromEntries(Object.entries(b.standings).map(([id, s]) => [id, { ward: s.ward, heir: s.heir, seq: s.seq }])),
|
|
115
|
+
occupants: { ...b.occupants },
|
|
116
|
+
knocks: Object.keys(b.knocks),
|
|
117
|
+
answered: Object.keys(b.answered),
|
|
118
|
+
minted: [...b.minted],
|
|
119
|
+
secrets: [...Object.values(b.standings).flatMap((s) => [s.current, s.next]), ...Object.values(b.knocks).flatMap((s) => [s.current, s.next])].filter((x): x is string => x !== null),
|
|
120
|
+
};
|
|
121
|
+
c.cells[key] = pt.beings[key]!;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return c;
|
|
125
|
+
},
|
|
126
|
+
// seed, partition and record lifted out of one object's storage into
|
|
127
|
+
// the other's, same seed, same pk; the old copy gone before the new boots
|
|
128
|
+
async migrate(key: string): Promise<void> {
|
|
129
|
+
const name = wardOf.get(key)!;
|
|
130
|
+
const from = where.get(name)!,
|
|
131
|
+
to = 1 - from;
|
|
132
|
+
const pk = p.harbors[from]!.wards.get(name)!.pk;
|
|
133
|
+
const kept = (await p.harbors[from]!.drop(name))!;
|
|
134
|
+
const w = await p.harbors[to]!.adopt(name, kept);
|
|
135
|
+
assert.equal(w.pk, pk, 'a ward that moved harbor changed its pk');
|
|
136
|
+
where.set(name, to);
|
|
137
|
+
p.harbors[from]!.reaches.delete(pk);
|
|
138
|
+
learn(to, w);
|
|
139
|
+
},
|
|
140
|
+
};
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// `/exercise` runs the suite; `/x/<sub>/quo/<pk>` is a sub-harbor's route,
|
|
145
|
+
// which is how the two reach each other through the worker.
|
|
146
|
+
export async function exercise(request: Request, base: string, path: string, state: State, env: Env): Promise<Response> {
|
|
147
|
+
const sub = /^\/x\/([\w.-]+)(\/.*)$/.exec(path);
|
|
148
|
+
if (sub) {
|
|
149
|
+
const h = subs.get(sub[1]!);
|
|
150
|
+
if (!h) return new Response(JSON.stringify({ error: 'no such harbor' }), { status: 404, headers: { 'content-type': 'application/json' } });
|
|
151
|
+
return h.fetch(request, sub[2]!);
|
|
152
|
+
}
|
|
153
|
+
const queued: { name: string; skip: string | false; fn: () => Promise<void> | void }[] = [];
|
|
154
|
+
const test = (name: string, opts: { skip?: string | false }, fn: () => Promise<void> | void) => {
|
|
155
|
+
queued.push({ name, skip: opts.skip ?? false, fn });
|
|
156
|
+
};
|
|
157
|
+
const key = await sealKey(env.QUO_SEED_KEY ?? '');
|
|
158
|
+
let views = 0;
|
|
159
|
+
conformStore('durable storage', async () => new DurableStorage(state.storage, key, `s:${views++}:${Date.now()}:`), { test });
|
|
160
|
+
let lines = 0;
|
|
161
|
+
conformReach(
|
|
162
|
+
'request, through the worker',
|
|
163
|
+
async () => {
|
|
164
|
+
const name = `r-${lines++}-${Date.now()}`;
|
|
165
|
+
const h = new EdgeHarbor(new DurableStorage(state.storage, key, `x:${name}:`), undefined, CLASSES);
|
|
166
|
+
await h.ready();
|
|
167
|
+
subs.set(name, h);
|
|
168
|
+
return { reach: byRequest(`${base}/x/${name}/quo`), hold: async (pk, door) => void h.doors.set(pk, async (b) => ({ bytes: await door(b), heard: true })), drop: async () => void subs.delete(name) };
|
|
169
|
+
},
|
|
170
|
+
{ test },
|
|
171
|
+
);
|
|
172
|
+
conform('edge, two harbors in one object by request', probe(base, state, env), { canDown: true, test });
|
|
173
|
+
const results: Result[] = [];
|
|
174
|
+
for (const { name, skip, fn } of queued) {
|
|
175
|
+
if (skip) {
|
|
176
|
+
results.push({ name, skip });
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
try {
|
|
180
|
+
await fn();
|
|
181
|
+
results.push({ name });
|
|
182
|
+
} catch (e) {
|
|
183
|
+
results.push({ name, error: String((e as Error)?.stack ?? e) });
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
return new Response(JSON.stringify({ results }), { headers: { 'content-type': 'application/json' } });
|
|
187
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// The edge platform, as much of it as the harbor touches and no more. The
|
|
3
|
+
// vendor's own type package declares every global a worker has, and it
|
|
4
|
+
// collides with the DOM library this tree compiles against; these few
|
|
5
|
+
// lines are what the edge harbor and worker actually call.
|
|
6
|
+
export type Storage = {
|
|
7
|
+
get<T = unknown>(key: string): Promise<T | undefined>;
|
|
8
|
+
get<T = unknown>(keys: string[]): Promise<Map<string, T>>;
|
|
9
|
+
put(key: string, value: unknown): Promise<void>;
|
|
10
|
+
delete(key: string): Promise<boolean>;
|
|
11
|
+
list<T = unknown>(options?: { prefix?: string }): Promise<Map<string, T>>;
|
|
12
|
+
};
|
|
13
|
+
export type State = { id: { toString(): string; name?: string }; storage: Storage };
|
|
14
|
+
export type Stub = { fetch(input: string | Request, init?: RequestInit): Promise<Response> };
|
|
15
|
+
export type Namespace = { idFromName(name: string): unknown; get(id: unknown): Stub };
|
|
16
|
+
export type Env = { HARBOR: Namespace; QUO_SEED_KEY?: string; QUO_ROOT?: string; QUO_EXERCISE?: string };
|
|
17
|
+
// The server end of a WebSocketPair: the standard surface plus accept.
|
|
18
|
+
export type ServerSocket = { accept(): void; readyState: number; binaryType: string; send(data: string | Uint8Array): void; close(): void; addEventListener(type: string, fn: (e: never) => void): void };
|
|
19
|
+
declare global {
|
|
20
|
+
// eslint-disable-next-line no-var
|
|
21
|
+
var WebSocketPair: new () => { 0: ServerSocket; 1: ServerSocket };
|
|
22
|
+
interface ResponseInit {
|
|
23
|
+
webSocket?: unknown;
|
|
24
|
+
}
|
|
25
|
+
interface Response {
|
|
26
|
+
readonly webSocket?: unknown;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// The store as Durable Object storage: one object per harbor, its storage
|
|
3
|
+
// holding one row per ward under `ward:<name>`, seed, partition and record,
|
|
4
|
+
// and one row per hint under `hint:<pk>`. The seed is kept sealed under a
|
|
5
|
+
// key from the platform's secrets, `QUO_SEED_KEY`, so the storage holds
|
|
6
|
+
// ciphertext and the secret store holds the one key, which is what the
|
|
7
|
+
// harbor document's table says the edge does. Values cross as JSON, as
|
|
8
|
+
// they do into a file, because the ward hands the partition out through a
|
|
9
|
+
// guard that structured clone refuses.
|
|
10
|
+
import { arithmetic } from '@quo-systems/quo/ward';
|
|
11
|
+
import { values, type Kept, type Store, type WardRecord } from '@quo-systems/quo/harbor';
|
|
12
|
+
import type { Storage } from './platform.d.ts';
|
|
13
|
+
|
|
14
|
+
type Row = { seed: string; partition: Record<string, unknown>; record: WardRecord };
|
|
15
|
+
// Bytes as the platform's crypto wants them: over a plain ArrayBuffer.
|
|
16
|
+
const plain = (b: Uint8Array): Uint8Array<ArrayBuffer> => new Uint8Array(b);
|
|
17
|
+
const { hex, unhex } = arithmetic;
|
|
18
|
+
|
|
19
|
+
// The seal on a seed: AES-GCM under the platform key, a fresh nonce each
|
|
20
|
+
// time, nonce and ciphertext together as hex.
|
|
21
|
+
export async function sealKey(secret: string): Promise<CryptoKey> {
|
|
22
|
+
if (!/^[0-9a-f]{64}$/.test(secret)) throw new Error('QUO_SEED_KEY is 32 bytes as hex');
|
|
23
|
+
return crypto.subtle.importKey('raw', plain(unhex(secret)), 'AES-GCM', false, ['encrypt', 'decrypt']);
|
|
24
|
+
}
|
|
25
|
+
export async function seal(key: CryptoKey, seed: Uint8Array): Promise<string> {
|
|
26
|
+
const iv = crypto.getRandomValues(new Uint8Array(12));
|
|
27
|
+
const ct = new Uint8Array(await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, plain(seed)));
|
|
28
|
+
return hex(iv) + hex(ct);
|
|
29
|
+
}
|
|
30
|
+
export async function open(key: CryptoKey, sealed: string): Promise<Uint8Array> {
|
|
31
|
+
const b = plain(unhex(sealed));
|
|
32
|
+
return new Uint8Array(await crypto.subtle.decrypt({ name: 'AES-GCM', iv: b.subarray(0, 12) }, key, b.subarray(12)));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// `prefix` keeps more than one harbor apart in one object's storage: the
|
|
36
|
+
// exercise does that, a deployment never does.
|
|
37
|
+
export class DurableStorage implements Store {
|
|
38
|
+
readonly storage: Storage;
|
|
39
|
+
readonly key: CryptoKey;
|
|
40
|
+
readonly prefix: string;
|
|
41
|
+
constructor(storage: Storage, key: CryptoKey, prefix = '') {
|
|
42
|
+
this.storage = storage;
|
|
43
|
+
this.key = key;
|
|
44
|
+
this.prefix = prefix;
|
|
45
|
+
}
|
|
46
|
+
#k(kind: 'ward' | 'hint', name: string) {
|
|
47
|
+
return `${this.prefix}${kind}:${name}`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async list(): Promise<string[]> {
|
|
51
|
+
const head = this.#k('ward', '');
|
|
52
|
+
return [...(await this.storage.list({ prefix: head })).keys()].map((k) => k.slice(head.length));
|
|
53
|
+
}
|
|
54
|
+
async load(name: string): Promise<Kept | undefined> {
|
|
55
|
+
const row = await this.storage.get<Row>(this.#k('ward', name));
|
|
56
|
+
return row && { seed: await open(this.key, row.seed), partition: values(row.partition), record: row.record };
|
|
57
|
+
}
|
|
58
|
+
async put(name: string, kept: Kept): Promise<void> {
|
|
59
|
+
if ((await this.storage.get(this.#k('ward', name))) !== undefined) throw new Error(`ward ${name} already exists here`);
|
|
60
|
+
await this.storage.put(this.#k('ward', name), { seed: await seal(this.key, kept.seed), partition: values(kept.partition), record: kept.record } satisfies Row);
|
|
61
|
+
}
|
|
62
|
+
async save(name: string, partition: Record<string, unknown>): Promise<void> {
|
|
63
|
+
const row = await this.storage.get<Row>(this.#k('ward', name));
|
|
64
|
+
if (row) await this.storage.put(this.#k('ward', name), { ...row, partition: values(partition) } satisfies Row);
|
|
65
|
+
}
|
|
66
|
+
async record(name: string, record: WardRecord): Promise<void> {
|
|
67
|
+
const row = await this.storage.get<Row>(this.#k('ward', name));
|
|
68
|
+
if (row) await this.storage.put(this.#k('ward', name), { ...row, record } satisfies Row);
|
|
69
|
+
}
|
|
70
|
+
async take(name: string): Promise<Kept | undefined> {
|
|
71
|
+
const kept = await this.load(name);
|
|
72
|
+
if (kept) await this.storage.delete(this.#k('ward', name));
|
|
73
|
+
return kept;
|
|
74
|
+
}
|
|
75
|
+
async hints(): Promise<Record<string, string>> {
|
|
76
|
+
const head = this.#k('hint', '');
|
|
77
|
+
return Object.fromEntries([...(await this.storage.list<string>({ prefix: head }))].map(([k, v]) => [k.slice(head.length), v]));
|
|
78
|
+
}
|
|
79
|
+
async hint(pk: string, url: string): Promise<void> {
|
|
80
|
+
await this.storage.put(this.#k('hint', pk), url);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// The worker: the edge harbor deployed. One Durable Object class, `Quo`,
|
|
3
|
+
// one instance per harbor name, and the worker routes by the first path
|
|
4
|
+
// segment: `/h/<name>/...` goes to that harbor. A world's quo. route on the
|
|
5
|
+
// edge is `<origin>/h/<name>/quo`, and its health `<origin>/h/<name>/health`.
|
|
6
|
+
// `wrangler.toml` beside this file names the class and the secrets.
|
|
7
|
+
//
|
|
8
|
+
// One thing lives here for the terrain test alone: with `QUO_EXERCISE` set,
|
|
9
|
+
// which no deployment sets, `/exercise` runs the library's conformance
|
|
10
|
+
// suite inside the platform against two harbors in this one object, over
|
|
11
|
+
// two prefixed views of its storage, reaching each other by request
|
|
12
|
+
// through the worker under `/x/<sub>/quo`. One object cannot touch
|
|
13
|
+
// another's storage, the platform says so, and so the two harbors share an
|
|
14
|
+
// object the way two tabs share a page.
|
|
15
|
+
import { EdgeHarbor } from './edge.ts';
|
|
16
|
+
import { exercise } from './exercise.ts';
|
|
17
|
+
import type { State, Env } from './platform.d.ts';
|
|
18
|
+
|
|
19
|
+
export class Quo {
|
|
20
|
+
readonly state: State;
|
|
21
|
+
readonly env: Env;
|
|
22
|
+
#harbor: Promise<EdgeHarbor> | undefined;
|
|
23
|
+
constructor(state: State, env: Env) {
|
|
24
|
+
this.state = state;
|
|
25
|
+
this.env = env;
|
|
26
|
+
}
|
|
27
|
+
harbor(): Promise<EdgeHarbor> {
|
|
28
|
+
return (this.#harbor ??= EdgeHarbor.open(this.state, this.env));
|
|
29
|
+
}
|
|
30
|
+
async fetch(request: Request): Promise<Response> {
|
|
31
|
+
const url = new URL(request.url);
|
|
32
|
+
const m = /^\/h\/([\w.-]+)(\/.*)?$/.exec(url.pathname);
|
|
33
|
+
if (!m) return new Response(JSON.stringify({ error: 'no such route' }), { status: 404, headers: { 'content-type': 'application/json' } });
|
|
34
|
+
const path = m[2] ?? '/';
|
|
35
|
+
if (this.env.QUO_EXERCISE && (path === '/exercise' || path.startsWith('/x/'))) return exercise(request, `${url.origin}/h/${m[1]!}`, path, this.state, this.env);
|
|
36
|
+
return (await this.harbor()).fetch(request, path);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export default {
|
|
41
|
+
async fetch(request: Request, env: Env): Promise<Response> {
|
|
42
|
+
const url = new URL(request.url);
|
|
43
|
+
const m = /^\/h\/([\w.-]+)(\/.*)?$/.exec(url.pathname);
|
|
44
|
+
if (!m) return new Response(JSON.stringify({ error: 'a harbor is /h/<name>/...' }), { status: 404, headers: { 'content-type': 'application/json' } });
|
|
45
|
+
return env.HARBOR.get(env.HARBOR.idFromName(m[1]!)).fetch(request);
|
|
46
|
+
},
|
|
47
|
+
};
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# The edge harbor as a worker. Deploy from `dock/` with
|
|
2
|
+
# npx wrangler deploy --config harbor/edge/wrangler.toml
|
|
3
|
+
# and set the two secrets once, never in this file:
|
|
4
|
+
# npx wrangler secret put QUO_SEED_KEY --config harbor/edge/wrangler.toml 32 bytes as hex
|
|
5
|
+
# npx wrangler secret put QUO_ROOT --config harbor/edge/wrangler.toml the root's bearer
|
|
6
|
+
name = "quo-edge"
|
|
7
|
+
main = "worker.ts"
|
|
8
|
+
compatibility_date = "2026-09-01"
|
|
9
|
+
# The lab's edge answers on the account's workers.dev subdomain; a custom
|
|
10
|
+
# domain under lab.quo.systems wants a token with the zone's Workers Routes
|
|
11
|
+
# permission, which the lab's token does not hold. A world's quo. route on
|
|
12
|
+
# it is <origin>/h/main/quo.
|
|
13
|
+
workers_dev = true
|
|
14
|
+
|
|
15
|
+
[[durable_objects.bindings]]
|
|
16
|
+
name = "HARBOR"
|
|
17
|
+
class_name = "Quo"
|
|
18
|
+
|
|
19
|
+
[[migrations]]
|
|
20
|
+
tag = "v1"
|
|
21
|
+
new_sqlite_classes = ["Quo"]
|
package/harbor/files.ts
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// The store as a folder on disk, one folder per ward:
|
|
3
|
+
//
|
|
4
|
+
// <dir>/wards/<name>/seed 32 bytes, hex, mode 0600
|
|
5
|
+
// <dir>/wards/<name>/partition.json
|
|
6
|
+
// <dir>/wards/<name>/ward.json the ward record
|
|
7
|
+
// <dir>/reach.json the directory's hints
|
|
8
|
+
//
|
|
9
|
+
// Every write of the partition goes through a temp file and a rename, and
|
|
10
|
+
// writes are queued per ward so two calls never race on one file.
|
|
11
|
+
import { mkdir, readFile, writeFile, rename, readdir, rm, chmod } from 'node:fs/promises';
|
|
12
|
+
import { existsSync } from 'node:fs';
|
|
13
|
+
import { join } from 'node:path';
|
|
14
|
+
import { arithmetic } from '@quo-systems/quo/ward';
|
|
15
|
+
import type { Kept, Store, WardRecord } from '@quo-systems/quo/harbor';
|
|
16
|
+
|
|
17
|
+
const { hex, unhex } = arithmetic;
|
|
18
|
+
|
|
19
|
+
export class Files implements Store {
|
|
20
|
+
readonly dir: string;
|
|
21
|
+
readonly #queues = new Map<string, Promise<void>>();
|
|
22
|
+
constructor(dir: string) {
|
|
23
|
+
this.dir = dir;
|
|
24
|
+
}
|
|
25
|
+
#ward(name: string) {
|
|
26
|
+
return join(this.dir, 'wards', name);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async list(): Promise<string[]> {
|
|
30
|
+
const wards = join(this.dir, 'wards');
|
|
31
|
+
return existsSync(wards) ? (await readdir(wards)).filter((n) => existsSync(join(wards, n, 'seed'))) : [];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async load(name: string): Promise<Kept | undefined> {
|
|
35
|
+
const wd = this.#ward(name);
|
|
36
|
+
if (!existsSync(join(wd, 'seed'))) return undefined;
|
|
37
|
+
return {
|
|
38
|
+
seed: unhex((await readFile(join(wd, 'seed'), 'utf8')).trim()),
|
|
39
|
+
partition: JSON.parse(await readFile(join(wd, 'partition.json'), 'utf8')) as Record<string, unknown>,
|
|
40
|
+
record: JSON.parse(await readFile(join(wd, 'ward.json'), 'utf8')) as WardRecord,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async put(name: string, kept: Kept): Promise<void> {
|
|
45
|
+
const wd = this.#ward(name);
|
|
46
|
+
if (existsSync(join(wd, 'seed'))) throw new Error(`ward ${name} already exists in ${this.dir}`);
|
|
47
|
+
await mkdir(wd, { recursive: true, mode: 0o700 });
|
|
48
|
+
await writeFile(join(wd, 'seed'), hex(kept.seed), { mode: 0o600 });
|
|
49
|
+
await chmod(join(wd, 'seed'), 0o600);
|
|
50
|
+
await writeFile(join(wd, 'partition.json'), JSON.stringify(kept.partition) + '\n', { mode: 0o600 });
|
|
51
|
+
await writeFile(join(wd, 'ward.json'), JSON.stringify(kept.record, null, 2) + '\n', { mode: 0o600 });
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
save(name: string, partition: Record<string, unknown>): Promise<void> {
|
|
55
|
+
const wd = this.#ward(name);
|
|
56
|
+
if (!existsSync(join(wd, 'seed'))) return Promise.resolve(); // a name not kept is nothing
|
|
57
|
+
const next = (this.#queues.get(name) ?? Promise.resolve()).then(async () => {
|
|
58
|
+
const tmp = join(wd, 'partition.json.tmp');
|
|
59
|
+
await writeFile(tmp, JSON.stringify(partition) + '\n', { mode: 0o600 });
|
|
60
|
+
await rename(tmp, join(wd, 'partition.json'));
|
|
61
|
+
});
|
|
62
|
+
this.#queues.set(name, next.catch(() => {}));
|
|
63
|
+
return next;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async record(name: string, record: WardRecord): Promise<void> {
|
|
67
|
+
if (!existsSync(join(this.#ward(name), 'seed'))) return;
|
|
68
|
+
await writeFile(join(this.#ward(name), 'ward.json'), JSON.stringify(record, null, 2) + '\n', { mode: 0o600 });
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async take(name: string): Promise<Kept | undefined> {
|
|
72
|
+
const kept = await this.load(name);
|
|
73
|
+
if (!kept) return undefined;
|
|
74
|
+
await this.#queues.get(name);
|
|
75
|
+
await rm(this.#ward(name), { recursive: true, force: true });
|
|
76
|
+
return kept;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async hints(): Promise<Record<string, string>> {
|
|
80
|
+
const p = join(this.dir, 'reach.json');
|
|
81
|
+
return existsSync(p) ? (JSON.parse(await readFile(p, 'utf8')) as Record<string, string>) : {};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async hint(pk: string, url: string): Promise<void> {
|
|
85
|
+
const all = await this.hints();
|
|
86
|
+
all[pk] = url;
|
|
87
|
+
await writeFile(join(this.dir, 'reach.json'), JSON.stringify(all, null, 2) + '\n', { mode: 0o600 });
|
|
88
|
+
}
|
|
89
|
+
}
|