@quo-systems/dock 0.2.1 → 0.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/beings/avatar.ts +3 -1
- package/beings/index.ts +1 -1
- package/beings/quo-dock.md +77 -301
- package/beings/user.ts +39 -11
- package/cli/daemon.ts +26 -273
- package/cli/http.ts +70 -0
- package/cli/quo.ts +10 -3
- package/dist/beings/avatar.js +3 -1
- package/dist/beings/index.js +1 -1
- package/dist/beings/user.d.ts +3 -1
- package/dist/beings/user.js +42 -12
- package/dist/cli/daemon.d.ts +6 -18
- package/dist/cli/daemon.js +22 -265
- package/dist/cli/http.d.ts +17 -0
- package/dist/cli/http.js +60 -0
- package/dist/cli/quo.js +12 -3
- package/dist/harbor/quo.d.ts +4 -0
- package/dist/harbor/quo.js +53 -0
- package/dist/human/door.d.ts +5 -0
- package/dist/human/door.js +19 -0
- package/dist/human/screen.js +1 -0
- package/dist/human/tab.d.ts +5 -2
- package/dist/human/tab.js +121 -88
- package/dist/human/web.d.ts +9 -0
- package/dist/human/web.js +95 -0
- package/dist/human/worlds.d.ts +10 -0
- package/dist/human/worlds.js +38 -0
- package/dist/mcp/route.d.ts +12 -0
- package/dist/mcp/route.js +34 -0
- package/harbor/quo-harbor.md +30 -30
- package/harbor/quo.ts +67 -0
- package/human/door.ts +26 -0
- package/human/quo-human.md +58 -24
- package/human/screen.ts +7 -2
- package/human/tab.ts +140 -102
- package/human/web.ts +123 -0
- package/human/worlds.ts +52 -0
- package/mcp/quo-mcp.md +3 -1
- package/mcp/route.ts +39 -0
- package/package.json +2 -2
package/harbor/quo.ts
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// The quo. route: the socket door, served by the daemon under `/quo` and
|
|
3
|
+
// mapped by the proxy from the quo. hostname, so a URL of that route is the
|
|
4
|
+
// base of every reach. POST `/quo/<pk>` carries one ask to a pk this harbor
|
|
5
|
+
// holds or holds a socket for; an upgrade at `/quo` is a dialer's held
|
|
6
|
+
// socket, announcing its pks to be bound. Bytes from here go to an own door
|
|
7
|
+
// or a held socket, never onward. The listener half of the socket reach is
|
|
8
|
+
// the terrain's own, `ws` on Node, and lives here with it.
|
|
9
|
+
import type { IncomingMessage, Server as HttpServer } from 'node:http';
|
|
10
|
+
import { WebSocketServer } from 'ws';
|
|
11
|
+
import { Socket as Held, SUITE, type Line } from '@quo-systems/quo/harbor';
|
|
12
|
+
import type { DiskHarbor } from './disk.ts';
|
|
13
|
+
import { readAll, type Handler, type Quo } from '../cli/http.ts';
|
|
14
|
+
|
|
15
|
+
export function quoRoute(harbor: DiskHarbor, server: HttpServer, quo: Quo): Handler {
|
|
16
|
+
const pks = () => [...harbor.doors.keys()];
|
|
17
|
+
const wss = new WebSocketServer({ noServer: true });
|
|
18
|
+
server.on('upgrade', (req: IncomingMessage, socket, head) => {
|
|
19
|
+
const url = new URL(req.url ?? '/', 'http://localhost');
|
|
20
|
+
if (url.pathname.replace(/\/$/, '') !== '/quo') return void socket.destroy(); // the proxy rewrites the route root to /quo/
|
|
21
|
+
wss.handleUpgrade(req, socket, head, (ws) => {
|
|
22
|
+
const s: Held = new Held(
|
|
23
|
+
ws as unknown as Line,
|
|
24
|
+
(pk, bytes) => harbor.deliver(pk, bytes),
|
|
25
|
+
(far) => void harbor.bind(far, s, true), // a dialer's claims, each proven at its door: held here, reachable through this socket
|
|
26
|
+
() => {
|
|
27
|
+
harbor.unbind(s);
|
|
28
|
+
quo.sockets.delete(s);
|
|
29
|
+
},
|
|
30
|
+
);
|
|
31
|
+
quo.sockets.add(s);
|
|
32
|
+
s.announce(pks());
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
// The route is public and carries sealed bytes, so any origin may POST
|
|
36
|
+
// to it: a tab on a world's web. reaching another world's quo. is the
|
|
37
|
+
// ordinary case, and a binary POST needs the preflight answered.
|
|
38
|
+
const open = { 'access-control-allow-origin': '*', 'access-control-allow-methods': 'POST, OPTIONS', 'access-control-allow-headers': 'content-type, quo-suite' };
|
|
39
|
+
return async (req, res, rest) => {
|
|
40
|
+
if (req.method === 'OPTIONS') {
|
|
41
|
+
res.writeHead(204, open);
|
|
42
|
+
return void res.end();
|
|
43
|
+
}
|
|
44
|
+
// The wire suite the caller speaks. Absent is this one, because a caller
|
|
45
|
+
// older than the header is this one. Anything else this door cannot open
|
|
46
|
+
// and says so as nothing delivered, rather than taking bytes that will
|
|
47
|
+
// never open and answering a silence that names no reason.
|
|
48
|
+
const suite = req.headers['quo-suite'];
|
|
49
|
+
if (suite !== undefined && suite !== String(SUITE)) {
|
|
50
|
+
res.writeHead(404, { 'content-type': 'application/json', ...open });
|
|
51
|
+
return void res.end(JSON.stringify({ error: `this door speaks wire suite ${SUITE}` }));
|
|
52
|
+
}
|
|
53
|
+
const pk = rest.slice(1);
|
|
54
|
+
if (req.method !== 'POST' || !/^[0-9a-f]{128}$/.test(pk)) {
|
|
55
|
+
res.writeHead(404, { 'content-type': 'application/json', ...open });
|
|
56
|
+
return void res.end(JSON.stringify({ error: 'POST /quo/<pk>' }));
|
|
57
|
+
}
|
|
58
|
+
const raw = await readAll(req as AsyncIterable<Buffer>);
|
|
59
|
+
const back = raw === undefined ? undefined : await harbor.deliver(pk, new Uint8Array(raw));
|
|
60
|
+
if (back === undefined) {
|
|
61
|
+
res.writeHead(404, { 'content-type': 'application/json', ...open });
|
|
62
|
+
return void res.end(JSON.stringify({ error: 'no reach for that pk' }));
|
|
63
|
+
}
|
|
64
|
+
res.writeHead(200, { 'content-type': 'application/octet-stream', ...open });
|
|
65
|
+
res.end(Buffer.from(back));
|
|
66
|
+
};
|
|
67
|
+
}
|
package/human/door.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// The door page: what a stranger sees where nothing lets them in. No
|
|
3
|
+
// invitation in the fragment, no relation in this tab's harbor, and nobody
|
|
4
|
+
// at the ward's door; or a harbor's root with no world to list. It is
|
|
5
|
+
// Quo's page and not the estate's: it says what this is and that a world
|
|
6
|
+
// here is entered by a link someone sends you, and it asks for nothing,
|
|
7
|
+
// since there is nothing a stranger could type that would let them in. A
|
|
8
|
+
// world that wants a face of its own has a public being with a look, or
|
|
9
|
+
// its own page on its own origin; this is the default and nothing more.
|
|
10
|
+
// Pure, strings in and strings out, under the tab's policy: no script, no
|
|
11
|
+
// image that is not a data URI, styles from the one stylesheet.
|
|
12
|
+
import { escape } from './html.ts';
|
|
13
|
+
|
|
14
|
+
// The mark: a ring with a gap, one being's voice reaching another's door.
|
|
15
|
+
const MARK =
|
|
16
|
+
'data:image/svg+xml;base64,' +
|
|
17
|
+
btoa(
|
|
18
|
+
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none" stroke="currentColor" stroke-width="4" stroke-linecap="round"><circle cx="32" cy="32" r="22" stroke-dasharray="110 30" transform="rotate(-60 32 32)"/><circle cx="32" cy="32" r="5" fill="currentColor" stroke="none"/></svg>`,
|
|
19
|
+
);
|
|
20
|
+
|
|
21
|
+
export type Door = { world?: string; host: string };
|
|
22
|
+
|
|
23
|
+
export function door(at: Door): string {
|
|
24
|
+
const where = at.world ? `<p class="where">${escape(at.world)} <span>at ${escape(at.host)}</span></p>` : `<p class="where">${escape(at.host)}</p>`;
|
|
25
|
+
return `<main class="door"><img class="mark" alt="" src="${MARK}"><h1>quo</h1>${where}<p class="lead">${at.world ? 'This world is entered by invitation.' : 'The worlds here are entered by invitation.'}</p><p>An invitation is a link someone sends you. Open it here, and you are in: no account, no password, nothing to type. What you can do inside is what the world shows you, and it is yours to keep on this device.</p><p class="quiet">Nothing on this page asks anything of you. If you were sent here without a link, ask the person who sent you for one.</p></main>`;
|
|
26
|
+
}
|
package/human/quo-human.md
CHANGED
|
@@ -24,9 +24,9 @@ that wants a page of its own writes any HTML, CSS and JavaScript it likes on
|
|
|
24
24
|
its own origin, against the avatar's handles and nothing else; a world that
|
|
25
25
|
writes none gets this screen.
|
|
26
26
|
|
|
27
|
-
##
|
|
27
|
+
## How a screen is made
|
|
28
28
|
|
|
29
|
-
Read with the trunk's "Carrying" and "The look", which
|
|
29
|
+
Read with the trunk's "Carrying" and "The look", which this applies.
|
|
30
30
|
|
|
31
31
|
- **A form from a schema, without a designer.** An input schema is a form
|
|
32
32
|
with one field per property by type: a string is a text input, and
|
|
@@ -49,7 +49,10 @@ Read with the trunk's "Carrying" and "The look", which it applies.
|
|
|
49
49
|
- **The page follows the digest.** The side asks the describe again after
|
|
50
50
|
every call and asks `look` once per digest; a digest that moved is a page
|
|
51
51
|
that moved. A read-only ask with nothing to type is run on open, so a page
|
|
52
|
-
can open with what it shows and not only with buttons.
|
|
52
|
+
can open with what it shows and not only with buttons. Every one of those
|
|
53
|
+
asks rotates her keys, so the side saves after each, not only after a
|
|
54
|
+
form: a relation brought back from a reload with a stale count is refused
|
|
55
|
+
at the far door and hears silence.
|
|
53
56
|
- **What a look may carry, and never.** The trunk's table is the whole
|
|
54
57
|
list: a name, a logo as a data URI, three colours, a font, a radius, an
|
|
55
58
|
order, a hint per ask. No CSS, no URL, no script, ever. A token that fails
|
|
@@ -75,10 +78,24 @@ Read with the trunk's "Carrying" and "The look", which it applies.
|
|
|
75
78
|
referer. The tab reads it, strips it from the address bar before anything
|
|
76
79
|
else runs, and joins. A page's own routing keeps the query, the path and
|
|
77
80
|
the rest of the fragment; only `quo` is taken. `beings/link.ts`.
|
|
78
|
-
- **
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
81
|
+
- **An invitation is self-addressing.** It names its ward by pk, so the
|
|
82
|
+
path of the page it was opened on is a hint for humans and nothing more:
|
|
83
|
+
the tab joins in the local ward of the ward the invitation names, and
|
|
84
|
+
the address follows. `web./#quo=...` is a complete way in for anyone
|
|
85
|
+
holding an invitation to any ward the harbor hosts, and every ward has a
|
|
86
|
+
page, whether or not anyone is at its door, so that a link into one has
|
|
87
|
+
somewhere to land; a ward with no public being says it admits by
|
|
88
|
+
invitation only.
|
|
89
|
+
- **The tab is a browser of worlds, and of the relations in each.** One
|
|
90
|
+
harbor per origin, one local ward per world joined, and inside it one
|
|
91
|
+
avatar per relation, since an avatar is one standing: a fresh invitation
|
|
92
|
+
is always a fresh avatar, never a join on one that already holds hers. A
|
|
93
|
+
link opened while in is one more relation; two humans on one family
|
|
94
|
+
tablet are two relations in one world; a switcher over worlds and
|
|
95
|
+
another over relations, and a switch is a switch of avatar and nothing
|
|
96
|
+
more. A world on another origin is another page, and the switcher links
|
|
97
|
+
there. `worlds.ts` is this without a document, and the memory harbor
|
|
98
|
+
proves it.
|
|
82
99
|
- **The policy.** The page carries a content security policy: scripts from
|
|
83
100
|
its origin only and never inline, connections to its origin and the `quo.`
|
|
84
101
|
route it dials, images from data URIs and its origin, no frames, no base.
|
|
@@ -87,7 +104,7 @@ Read with the trunk's "Carrying" and "The look", which it applies.
|
|
|
87
104
|
|
|
88
105
|
## The pieces
|
|
89
106
|
|
|
90
|
-
|
|
107
|
+
Eight files under `packages/dock/human/`:
|
|
91
108
|
|
|
92
109
|
- `html.ts` is pure, strings in and strings out, and is the screen's whole
|
|
93
110
|
vocabulary: `form`, `values`, `view`, `face`, `look`, `page`. It reads the
|
|
@@ -102,15 +119,31 @@ Three files under `packages/dock/human/`, and the tab that holds them:
|
|
|
102
119
|
- `dom.ts` is the surface on an element, the one file that touches one.
|
|
103
120
|
- `guest.ts` is a world's public being as a subject the screen renders:
|
|
104
121
|
her describe for a stranger, a form as a knock on the public invitation.
|
|
105
|
-
- `
|
|
106
|
-
|
|
122
|
+
- `worlds.ts` is the tab's worlds and relations with no document in
|
|
123
|
+
sight: the local ward for a far pk, the relations in it, and a fresh
|
|
124
|
+
avatar for the next way in.
|
|
125
|
+
- `door.ts` is the door page, pure: what a stranger sees where nothing
|
|
126
|
+
lets them in, Quo's page and not the estate's, asking for nothing.
|
|
127
|
+
- `tab.ts` is the shell: the harbor in the tab, the config, the two
|
|
128
|
+
switchers and the two ways in, and hands one avatar at a time to the
|
|
129
|
+
side.
|
|
130
|
+
- `web.ts` is the web route the daemon mounts under `/web`: the list of
|
|
131
|
+
worlds or the door page at the root, a world's page with its config, the
|
|
132
|
+
bundle built from `tab.ts`, the policy header and the stylesheet. It
|
|
133
|
+
authenticates nothing and admits nobody; the knock is the tab's. Paths
|
|
134
|
+
it does not take fall through to the exchange pages.
|
|
107
135
|
|
|
108
136
|
Proven in `packages/dock/test/human.test.ts` on the memory harbor with a fake
|
|
109
137
|
surface and no browser: the user being carrying a shop with a look, a guest
|
|
110
|
-
at a door that is not a desk let in by a form,
|
|
111
|
-
and
|
|
112
|
-
|
|
113
|
-
|
|
138
|
+
at a door that is not a desk let in by a form, the link read, stripped and
|
|
139
|
+
refused, and the worlds and relations on a harbor core over the memory
|
|
140
|
+
store, two invitations into one world being two avatars; and in
|
|
141
|
+
`packages/dock/test/terrain/browser.test.ts` in a real Chromium against a
|
|
142
|
+
daemon on loopback, behind `npm run check:terrain`: the guest page, the
|
|
143
|
+
way in by a link the root minted, a push, a reload, the policy header, a
|
|
144
|
+
link while in as a second relation and the switch back, a link into another
|
|
145
|
+
ward landing on that ward's page, the door page where nobody is at the
|
|
146
|
+
door, and a link at the root.
|
|
114
147
|
|
|
115
148
|
## Security, by structure
|
|
116
149
|
|
|
@@ -131,13 +164,14 @@ guest page, the password way in, a push, a reload, and the policy header.
|
|
|
131
164
|
## The tab
|
|
132
165
|
|
|
133
166
|
The tab is a device. The page at `web./<ward>` boots a browser harbor on a
|
|
134
|
-
seed minted into the tab's store the first time, one ward for this
|
|
135
|
-
with
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
a reload asks nothing: the seed and the
|
|
143
|
-
|
|
167
|
+
seed minted into the tab's store the first time, one local ward for this
|
|
168
|
+
world with an avatar per relation in it, and dials the world's `quo.`
|
|
169
|
+
route. Two ways in, one act, each a fresh avatar: a link's invitation,
|
|
170
|
+
joined at once in the ward it names; and a guest form on the public being's
|
|
171
|
+
page whose answer is an invitation. Nothing is typed to get in, no password
|
|
172
|
+
and no account, the root included: the root mints its own first invitation
|
|
173
|
+
on the box and opens the link. A ward with nobody at its door shows the
|
|
174
|
+
door page, `door.ts`, and a link is the only way. From then on there is no
|
|
175
|
+
cookie and no token anywhere, and a reload asks nothing: the seed and the
|
|
176
|
+
standings are in the tab's store, and the relation last on screen is the
|
|
177
|
+
one shown.
|
package/human/screen.ts
CHANGED
|
@@ -23,8 +23,12 @@ export type Surface = {
|
|
|
23
23
|
onSubmit: ((ask: string, raw: Raw) => void) | undefined;
|
|
24
24
|
};
|
|
25
25
|
|
|
26
|
-
// `after` runs
|
|
27
|
-
//
|
|
26
|
+
// `after` runs after every ask the side makes, a submitted form, her
|
|
27
|
+
// describe, her look and the read-only asks run on open alike, as it does
|
|
28
|
+
// for the model side: every ask rotates her keys, and a harbor that must
|
|
29
|
+
// write what the ward changed hooks it. A side that saved only after a form
|
|
30
|
+
// would bring her back from a reload with a stale count, and the far door
|
|
31
|
+
// would refuse her. `admit` is what to do
|
|
28
32
|
// with an answer that is an invitation: a guest at a world's door is let in
|
|
29
33
|
// by it, and a side with no `admit` shows it as any answer.
|
|
30
34
|
export type Options = { after?: () => Promise<void>; notice?: string; admit?: (invitation: Invitation) => Promise<void> };
|
|
@@ -60,6 +64,7 @@ export async function screenSide(avatar: Subject, surface: Surface, options: Opt
|
|
|
60
64
|
}
|
|
61
65
|
}
|
|
62
66
|
}
|
|
67
|
+
await after();
|
|
63
68
|
show();
|
|
64
69
|
};
|
|
65
70
|
|
package/human/tab.ts
CHANGED
|
@@ -1,58 +1,68 @@
|
|
|
1
1
|
// SPDX-License-Identifier: Apache-2.0
|
|
2
2
|
// The tab: a page on a world's web. that is a device of its own. The tab
|
|
3
3
|
// boots a browser harbor on a seed minted into IndexedDB the first time,
|
|
4
|
-
// and
|
|
5
|
-
//
|
|
6
|
-
//
|
|
4
|
+
// and is a browser of worlds and of the relations in them, `worlds.ts`: one
|
|
5
|
+
// local ward per far world, one avatar per relation, a switcher over both.
|
|
6
|
+
// A switch is a switch of avatar and nothing more.
|
|
7
7
|
//
|
|
8
|
-
//
|
|
8
|
+
// Two ways in, the same act, a knock that ends in a standing, and each one
|
|
9
|
+
// a fresh avatar:
|
|
9
10
|
//
|
|
10
11
|
// a link the page with the invitation in its fragment, `#quo=...`,
|
|
11
12
|
// read and stripped before anything else runs, then joined.
|
|
13
|
+
// The invitation names its ward, so it is joined in that
|
|
14
|
+
// world's local ward whatever page it was opened on, and a
|
|
15
|
+
// link opened while already in is one more relation.
|
|
12
16
|
// a guest the world's public being, whatever class she is, rendered
|
|
13
17
|
// as a page by the same screen; a form on it whose answer is
|
|
14
18
|
// an invitation is the way in.
|
|
15
|
-
// a password the dock's own way, when the public being shows `device`:
|
|
16
|
-
// the owner password goes to the web route for a nonce the
|
|
17
|
-
// front desk honours under the `tab` proof kind, and the
|
|
18
|
-
// guest form `device({ proof })` is submitted for the human.
|
|
19
19
|
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
20
|
+
// Nothing is typed to get in, ever: no password, no account. A ward with
|
|
21
|
+
// nobody at its door shows the door page, `door.ts`, and a link is the only
|
|
22
|
+
// way. From then on there is no cookie and no token anywhere; the seed and
|
|
23
|
+
// the standings are in the tab's store, and a reload asks nothing. The screen
|
|
22
24
|
// is the human side in `screen.ts` over the DOM surface in `dom.ts`. This
|
|
23
|
-
// file is the harbor, the
|
|
25
|
+
// file is the shell: the harbor, the config, the switcher and the ways in,
|
|
26
|
+
// and it hands one avatar at a time to the side.
|
|
24
27
|
import type { Invitation, JsonObject } from '@quo-systems/quo';
|
|
25
28
|
import { BrowserHarbor } from '../harbor/browser.ts';
|
|
26
|
-
import {
|
|
27
|
-
import { parse, strip
|
|
29
|
+
import { USER } from '../beings/avatar.ts';
|
|
30
|
+
import { parse, strip } from '../beings/link.ts';
|
|
28
31
|
import { screenSide } from './screen.ts';
|
|
29
32
|
import { domSurface } from './dom.ts';
|
|
30
33
|
import { guest } from './guest.ts';
|
|
31
|
-
import {
|
|
34
|
+
import { door } from './door.ts';
|
|
35
|
+
import { world, relations, fresh, type Relation } from './worlds.ts';
|
|
32
36
|
|
|
33
37
|
// What the page is told by the daemon that served it: the world's routes,
|
|
34
|
-
//
|
|
35
|
-
|
|
38
|
+
// every ward on that harbor by name, its pk and whether a public being is
|
|
39
|
+
// at its door, and which of them this page is, if it is one's.
|
|
40
|
+
export type Config = { quo: string; web: string; wards: Record<string, { pk: string; public: boolean }>; ward?: string };
|
|
36
41
|
|
|
37
|
-
//
|
|
38
|
-
//
|
|
39
|
-
//
|
|
42
|
+
// What the tab remembers between pages, beside the harbor: the worlds it
|
|
43
|
+
// has joined, by pk, where each lives and what it is called; which relation
|
|
44
|
+
// is on screen in each; and what each relation is called. A tab with no
|
|
45
|
+
// storage still works, and forgets.
|
|
40
46
|
type Worlds = Record<string, { url: string; name: string }>;
|
|
41
|
-
const
|
|
42
|
-
const worlds = (): Worlds => {
|
|
47
|
+
const kept = <T>(key: string, fallback: T): T => {
|
|
43
48
|
try {
|
|
44
|
-
return JSON.parse(localStorage.getItem(
|
|
49
|
+
return (JSON.parse(localStorage.getItem(key) ?? 'null') as T | null) ?? fallback;
|
|
45
50
|
} catch {
|
|
46
|
-
return
|
|
51
|
+
return fallback;
|
|
47
52
|
}
|
|
48
53
|
};
|
|
49
|
-
const
|
|
54
|
+
const keep = (key: string, value: unknown) => {
|
|
50
55
|
try {
|
|
51
|
-
localStorage.setItem(
|
|
56
|
+
localStorage.setItem(key, JSON.stringify(value));
|
|
52
57
|
} catch {
|
|
53
|
-
/*
|
|
58
|
+
/* forgets */
|
|
54
59
|
}
|
|
55
60
|
};
|
|
61
|
+
const WORLDS = 'quo-worlds';
|
|
62
|
+
const worlds = (): Worlds => kept(WORLDS, {});
|
|
63
|
+
const remember = (pk: string, url: string, name: string) => keep(WORLDS, { ...worlds(), [pk]: { url, name } });
|
|
64
|
+
const AT = (pk: string) => `quo-at:${pk}`;
|
|
65
|
+
const NAMES = (pk: string) => `quo-names:${pk}`;
|
|
56
66
|
|
|
57
67
|
const el = <K extends keyof HTMLElementTagNameMap>(tag: K, text = '', attrs: Record<string, string> = {}): HTMLElementTagNameMap[K] => {
|
|
58
68
|
const e = document.createElement(tag);
|
|
@@ -68,114 +78,142 @@ export async function start(cfg: Config, root: HTMLElement = document.body): Pro
|
|
|
68
78
|
if (linked) history.replaceState(null, '', location.pathname + location.search + strip(location.hash));
|
|
69
79
|
|
|
70
80
|
const nav = el('nav', '', { class: 'worlds' });
|
|
81
|
+
const who = el('nav', '', { class: 'relations' });
|
|
71
82
|
const status = el('p', 'booting');
|
|
72
83
|
const screen = el('div');
|
|
73
|
-
root.append(nav, status, screen);
|
|
84
|
+
root.append(nav, who, status, screen);
|
|
74
85
|
const say = (s: string) => (status.textContent = s);
|
|
75
86
|
|
|
76
87
|
// The harbor in the tab: one database per origin, one ward per world.
|
|
77
88
|
const harbor = new BrowserHarbor('quo');
|
|
78
89
|
await harbor.boot();
|
|
79
|
-
const name = `w-${cfg.pk.slice(0, 16)}`;
|
|
80
|
-
const ward = harbor.wards.get(name) ?? (await harbor.create(name, 'me'));
|
|
81
|
-
if (!ward.being('me')) await ward.ask('boot', { key: 'me', class: 'Avatar' });
|
|
82
|
-
const me = ward.being('me') as Avatar;
|
|
83
90
|
harbor.dial(cfg.quo);
|
|
84
91
|
|
|
92
|
+
// The world this page is about: the link's, since an invitation names
|
|
93
|
+
// its ward, or the page's own. A link for a world not on this harbor is
|
|
94
|
+
// a link to the wrong page, and says so.
|
|
95
|
+
const nameOf = (pk: string) => Object.keys(cfg.wards).find((n) => cfg.wards[n]!.pk === pk);
|
|
96
|
+
const pk = linked?.ward ?? (cfg.ward ? cfg.wards[cfg.ward]?.pk : undefined);
|
|
97
|
+
const wardName = pk ? nameOf(pk) : undefined;
|
|
98
|
+
const pageOf = (name: string) => `${new URL(cfg.web).pathname.replace(/\/$/, '')}/${encodeURIComponent(name)}`;
|
|
99
|
+
|
|
85
100
|
const switcher = () => {
|
|
86
101
|
nav.replaceChildren();
|
|
87
102
|
const known = worlds();
|
|
88
|
-
for (const [
|
|
89
|
-
const a = el('a', w.name ||
|
|
90
|
-
if (
|
|
103
|
+
for (const [k, w] of Object.entries(known)) {
|
|
104
|
+
const a = el('a', w.name || k.slice(0, 8), { href: w.url });
|
|
105
|
+
if (k === pk) a.setAttribute('aria-current', 'page');
|
|
91
106
|
nav.append(a);
|
|
92
107
|
}
|
|
93
|
-
|
|
94
|
-
if (!here) nav.append(el('span', `${cfg.ward} at ${new URL(cfg.web).host}`));
|
|
108
|
+
if (pk && !known[pk] && wardName) nav.append(el('span', `${wardName} at ${new URL(cfg.web).host}`));
|
|
95
109
|
};
|
|
96
110
|
switcher();
|
|
97
111
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
112
|
+
if (!pk || !wardName) {
|
|
113
|
+
say(linked ? 'this link is for a world that does not live here' : 'choose a world');
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
if (linked && location.pathname !== pageOf(wardName)) history.replaceState(null, '', pageOf(wardName) + location.search + location.hash);
|
|
117
|
+
const ward = await world(harbor, pk);
|
|
118
|
+
const at = { name: wardName, pk, public: cfg.wards[wardName]!.public };
|
|
119
|
+
|
|
120
|
+
// The relations in this world, and which one is on screen.
|
|
121
|
+
let side: { close(): Promise<void> } | null = null;
|
|
122
|
+
let current: Relation | null = null;
|
|
123
|
+
const names = () => kept<Record<string, string>>(NAMES(pk), {});
|
|
124
|
+
const people = () => {
|
|
125
|
+
who.replaceChildren();
|
|
126
|
+
const rels = relations(ward);
|
|
127
|
+
const called = names();
|
|
128
|
+
for (const r of rels) {
|
|
129
|
+
const b = el('button', called[r.key] ?? r.key, { type: 'button' });
|
|
130
|
+
if (r.key === current?.key) b.setAttribute('aria-current', 'true');
|
|
131
|
+
b.onclick = () => void inside(r, `in, as ${called[r.key] ?? r.key}`, called[r.key] ?? r.key);
|
|
132
|
+
who.append(b);
|
|
133
|
+
}
|
|
134
|
+
if (rels.length) {
|
|
135
|
+
const more = el('button', 'another way in', { type: 'button', class: 'another' });
|
|
136
|
+
more.onclick = () => void atDoor();
|
|
137
|
+
who.append(more);
|
|
138
|
+
}
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
// In, as one relation: her page. Every call rotates her keys and a
|
|
142
|
+
// same-ward ask never crosses the harbor, so the side saves after each.
|
|
143
|
+
const inside = async (rel: Relation, notice: string, called: string) => {
|
|
144
|
+
await side?.close();
|
|
101
145
|
status.remove();
|
|
146
|
+
root.querySelector('form.password')?.remove();
|
|
102
147
|
screen.replaceChildren();
|
|
103
|
-
|
|
104
|
-
|
|
148
|
+
current = rel;
|
|
149
|
+
keep(AT(pk), rel.key);
|
|
150
|
+
const s = await screenSide(rel.avatar, domSurface(screen), { after: () => ward.save(), notice });
|
|
151
|
+
side = s;
|
|
152
|
+
const bp = s.model.blueprint;
|
|
153
|
+
const notesName = typeof (bp?.notes as JsonObject | null)?.name === 'string' ? ((bp!.notes as JsonObject).name as string) : '';
|
|
154
|
+
remember(pk, location.origin + pageOf(at.name), s.model.look.name || notesName || at.name);
|
|
155
|
+
if (!names()[rel.key]) keep(NAMES(pk), { ...names(), [rel.key]: called });
|
|
105
156
|
switcher();
|
|
157
|
+
people();
|
|
106
158
|
};
|
|
107
159
|
|
|
108
|
-
// The way in, from any of the three:
|
|
109
|
-
|
|
160
|
+
// The way in, from any of the three: a fresh avatar joins, the ward is
|
|
161
|
+
// saved, and she is the one on screen.
|
|
162
|
+
const admit = async (inv: Invitation, notice: string, called: string) => {
|
|
110
163
|
say('knocking');
|
|
111
|
-
const
|
|
164
|
+
const rel = await fresh(ward);
|
|
165
|
+
const got = await rel.avatar.join(inv);
|
|
112
166
|
await ward.save(); // the knock went through her own door, which the harbor never sees
|
|
113
167
|
if (!('asks' in got)) {
|
|
114
168
|
say(`not in: ${got.error}`);
|
|
169
|
+
return false;
|
|
170
|
+
}
|
|
171
|
+
await inside(rel, notice, called);
|
|
172
|
+
return true;
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
// At the door. With a public being there, her describe as a page, and a
|
|
176
|
+
// form whose answer is an invitation lets the guest in. With nobody
|
|
177
|
+
// there, the door page: a link is the only way in, and nothing to type.
|
|
178
|
+
const atDoor = async () => {
|
|
179
|
+
await side?.close();
|
|
180
|
+
side = null;
|
|
181
|
+
current = null;
|
|
182
|
+
screen.replaceChildren();
|
|
183
|
+
if (!status.isConnected) root.insertBefore(status, screen);
|
|
184
|
+
people();
|
|
185
|
+
if (!at.public) {
|
|
186
|
+
status.remove();
|
|
187
|
+
screen.innerHTML = door({ world: at.name, host: new URL(cfg.web).host });
|
|
115
188
|
return;
|
|
116
189
|
}
|
|
117
|
-
|
|
190
|
+
say(`at the door of ${at.name}`);
|
|
191
|
+
const rel = await fresh(ward);
|
|
192
|
+
const gate = guest(rel.avatar, pk);
|
|
193
|
+
const s = await screenSide(gate, domSurface(screen), {
|
|
194
|
+
after: () => ward.save(),
|
|
195
|
+
notice: `a guest at ${at.name}: what she shows strangers`,
|
|
196
|
+
admit: async (inv) => {
|
|
197
|
+
await s.close();
|
|
198
|
+
await admit(inv, `in, as a guest of ${at.name}`, 'guest');
|
|
199
|
+
},
|
|
200
|
+
});
|
|
201
|
+
side = s;
|
|
118
202
|
};
|
|
119
203
|
|
|
120
|
-
//
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
204
|
+
// A link: one more relation, whoever else is in. Then whoever was on
|
|
205
|
+
// screen last, or the door.
|
|
206
|
+
if (linked && (await admit(linked, 'in, by the link', 'link'))) return;
|
|
207
|
+
const rels = relations(ward);
|
|
208
|
+
const last = kept<string | null>(AT(pk), null);
|
|
209
|
+
const back = rels.find((r) => r.key === last) ?? rels[rels.length - 1];
|
|
210
|
+
if (back) {
|
|
211
|
+
const had = await back.avatar.tools();
|
|
212
|
+
await ward.save(); // her keys rotated on that ask, whatever it answered
|
|
213
|
+
if ('asks' in had) return inside(back, linked ? `not in by the link; in, as before` : 'in, as before', names()[back.key] ?? back.key);
|
|
124
214
|
say(`not in (${had.error})`);
|
|
125
215
|
}
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
// A guest: the public being's describe as a page; a form whose answer is
|
|
129
|
-
// an invitation lets the guest in.
|
|
130
|
-
say(`at the door of ${cfg.ward}`);
|
|
131
|
-
const door = guest(me, cfg.pk);
|
|
132
|
-
const side = await screenSide(door, domSurface(screen), {
|
|
133
|
-
after: () => ward.save(),
|
|
134
|
-
notice: `a guest at ${cfg.ward}: what she shows strangers`,
|
|
135
|
-
admit: async (inv) => {
|
|
136
|
-
await side.close();
|
|
137
|
-
await admit(inv, `in, as a guest of ${cfg.ward}`);
|
|
138
|
-
},
|
|
139
|
-
});
|
|
140
|
-
|
|
141
|
-
// The dock's own way in, when the desk is at the door: the owner password
|
|
142
|
-
// for a nonce, and the desk's `device` form submitted with it.
|
|
143
|
-
if (side.model.blueprint?.asks.some((a) => a.name === 'device')) {
|
|
144
|
-
const form = el('form', '', { class: 'password' });
|
|
145
|
-
const identity = el('input', '', { name: 'identity', value: 'tab', placeholder: 'identity' });
|
|
146
|
-
const password = el('input', '', { name: 'password', type: 'password', placeholder: 'owner password' });
|
|
147
|
-
const go = el('button', 'enter as the owner');
|
|
148
|
-
form.append(identity, password, go);
|
|
149
|
-
root.insertBefore(form, screen);
|
|
150
|
-
form.onsubmit = (ev) => {
|
|
151
|
-
ev.preventDefault();
|
|
152
|
-
void (async () => {
|
|
153
|
-
say('asking the world');
|
|
154
|
-
let res: Response;
|
|
155
|
-
try {
|
|
156
|
-
res = await fetch(`${cfg.web}/${encodeURIComponent(cfg.ward)}/login`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ password: password.value, identity: identity.value }) });
|
|
157
|
-
} catch (e) {
|
|
158
|
-
say(`the world did not answer: ${(e as Error).message}`);
|
|
159
|
-
return;
|
|
160
|
-
}
|
|
161
|
-
password.value = '';
|
|
162
|
-
if (!res.ok) {
|
|
163
|
-
say(`refused: ${((await res.json().catch(() => ({ error: res.status }))) as { error?: string }).error ?? res.status}`);
|
|
164
|
-
return;
|
|
165
|
-
}
|
|
166
|
-
const { nonce } = (await res.json()) as { nonce: string };
|
|
167
|
-
const inv = await door.call('device', { proof: { kind: 'tab', nonce } });
|
|
168
|
-
await ward.save();
|
|
169
|
-
if (!isInvitation(inv)) {
|
|
170
|
-
say(`not in: ${escape(JSON.stringify(inv))}`);
|
|
171
|
-
return;
|
|
172
|
-
}
|
|
173
|
-
form.remove();
|
|
174
|
-
await side.close();
|
|
175
|
-
await admit(inv, `in, as ${identity.value}`);
|
|
176
|
-
})();
|
|
177
|
-
};
|
|
178
|
-
}
|
|
216
|
+
await atDoor();
|
|
179
217
|
}
|
|
180
218
|
|
|
181
219
|
// The page hands the config in a JSON script, which a content security
|