@quo-systems/dock 0.2.0 → 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.
Files changed (76) hide show
  1. package/beings/avatar.ts +9 -3
  2. package/beings/carry.ts +101 -0
  3. package/beings/desk.ts +2 -2
  4. package/beings/index.ts +3 -1
  5. package/beings/link.ts +54 -0
  6. package/beings/look.ts +96 -0
  7. package/beings/quo-dock.md +163 -277
  8. package/beings/side.ts +10 -1
  9. package/beings/user.ts +67 -13
  10. package/cli/daemon.ts +58 -251
  11. package/cli/http.ts +70 -0
  12. package/cli/quo.ts +13 -5
  13. package/dist/beings/avatar.js +10 -3
  14. package/dist/beings/carry.d.ts +10 -0
  15. package/dist/beings/carry.js +106 -0
  16. package/dist/beings/desk.d.ts +1 -0
  17. package/dist/beings/desk.js +1 -1
  18. package/dist/beings/index.d.ts +2 -0
  19. package/dist/beings/index.js +3 -1
  20. package/dist/beings/link.d.ts +7 -0
  21. package/dist/beings/link.js +42 -0
  22. package/dist/beings/look.d.ts +27 -0
  23. package/dist/beings/look.js +71 -0
  24. package/dist/beings/side.d.ts +8 -1
  25. package/dist/beings/user.d.ts +29 -3
  26. package/dist/beings/user.js +71 -14
  27. package/dist/cli/daemon.d.ts +7 -19
  28. package/dist/cli/daemon.js +51 -240
  29. package/dist/cli/http.d.ts +17 -0
  30. package/dist/cli/http.js +60 -0
  31. package/dist/cli/quo.js +15 -5
  32. package/dist/harbor/quo.d.ts +4 -0
  33. package/dist/harbor/quo.js +53 -0
  34. package/dist/human/door.d.ts +5 -0
  35. package/dist/human/door.js +19 -0
  36. package/dist/human/guest.d.ts +3 -0
  37. package/dist/human/guest.js +25 -0
  38. package/dist/human/html.d.ts +10 -2
  39. package/dist/human/html.js +61 -10
  40. package/dist/human/screen.d.ts +8 -3
  41. package/dist/human/screen.js +26 -6
  42. package/dist/human/tab.d.ts +5 -0
  43. package/dist/human/tab.js +161 -43
  44. package/dist/human/web.d.ts +9 -0
  45. package/dist/human/web.js +95 -0
  46. package/dist/human/worlds.d.ts +10 -0
  47. package/dist/human/worlds.js +38 -0
  48. package/dist/mcp/http.d.ts +4 -3
  49. package/dist/mcp/http.js +6 -6
  50. package/dist/mcp/oauth.d.ts +9 -4
  51. package/dist/mcp/oauth.js +15 -14
  52. package/dist/mcp/pilot.js +1 -1
  53. package/dist/mcp/route.d.ts +12 -0
  54. package/dist/mcp/route.js +34 -0
  55. package/dist/mcp/server.d.ts +5 -10
  56. package/dist/mcp/server.js +35 -4
  57. package/dist/mcp/web/exchange.d.ts +5 -2
  58. package/dist/mcp/web/exchange.js +21 -7
  59. package/harbor/quo-harbor.md +67 -30
  60. package/harbor/quo.ts +67 -0
  61. package/human/door.ts +26 -0
  62. package/human/guest.ts +26 -0
  63. package/human/html.ts +58 -10
  64. package/human/quo-human.md +158 -67
  65. package/human/screen.ts +34 -8
  66. package/human/tab.ts +193 -53
  67. package/human/web.ts +123 -0
  68. package/human/worlds.ts +52 -0
  69. package/mcp/http.ts +10 -9
  70. package/mcp/oauth.ts +20 -17
  71. package/mcp/pilot.ts +1 -1
  72. package/mcp/quo-mcp.md +20 -2
  73. package/mcp/route.ts +39 -0
  74. package/mcp/server.ts +37 -18
  75. package/mcp/web/exchange.ts +23 -9
  76. package/package.json +2 -2
@@ -0,0 +1,25 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // A guest at a world's door: the world's public being, whatever class she
3
+ // is, as a subject the screen can render. Her describe for a stranger is the
4
+ // guest page; a form on it is a knock on the public invitation; and an
5
+ // answer that is an invitation is the way in, which the tab takes by
6
+ // joining. Nothing here knows the desk: the desk's `device` is one ask
7
+ // that answers an invitation, and any being may write another.
8
+ import { isSilence, isWord, wordOf } from '@quo-systems/quo';
9
+ export function guest(avatar, ward) {
10
+ const at = { ward };
11
+ return {
12
+ tools: async () => {
13
+ const bp = await avatar.knock(at);
14
+ if (isSilence(bp))
15
+ return { error: 'silence' };
16
+ if (isWord(bp))
17
+ return { error: wordOf(bp) };
18
+ if (bp === null || typeof bp !== 'object' || Array.isArray(bp) || !Array.isArray(bp.asks))
19
+ return { error: 'nobody is home' };
20
+ return bp;
21
+ },
22
+ call: (name, args = {}, wanted) => avatar.knock(at, name, args, wanted),
23
+ ears: new Set(),
24
+ };
25
+ }
@@ -1,5 +1,6 @@
1
1
  import type { Ask, Blueprint, Json, JsonObject } from '@quo-systems/quo';
2
2
  import { type Word } from '../beings/side.ts';
3
+ import { hintFor, type Look, type Hint } from '../beings/look.ts';
3
4
  export type Property = {
4
5
  type?: string;
5
6
  description?: string;
@@ -13,7 +14,7 @@ export type Schema = {
13
14
  };
14
15
  export declare const escape: (s: string) => string;
15
16
  export declare function field(name: string, p: Property, must: boolean): string;
16
- export declare function form(ask: Ask): string;
17
+ export declare function form(ask: Ask, h?: Hint): string;
17
18
  export type Raw = Record<string, string>;
18
19
  export declare function values(ask: Ask, raw: Raw): {
19
20
  args: JsonObject;
@@ -24,9 +25,16 @@ export declare function view(value: Json, schema?: JsonObject): string;
24
25
  export declare function face(w: Word, schema?: JsonObject): string;
25
26
  export type Model = {
26
27
  blueprint: Blueprint | null;
28
+ look: Look;
27
29
  notice: string;
28
30
  answers: Record<string, Word>;
29
31
  pushes: JsonObject[];
30
32
  };
31
- export declare function title(bp: Blueprint | null): string;
33
+ export declare function title(bp: Blueprint | null, l?: Look): string;
34
+ export declare function look(l: Look | undefined): {
35
+ style: string;
36
+ head: string;
37
+ };
38
+ export declare function ordered(asks: Ask[], l: Look | undefined): Ask[];
39
+ export { hintFor };
32
40
  export declare function page(m: Model): string;
@@ -1,4 +1,5 @@
1
1
  import { SILENCE_TEXT, UNREACHED_TEXT, wordText } from '../beings/side.js';
2
+ import { hint, hintFor, sanitise, groups as grouped } from '../beings/look.js';
2
3
  export const escape = (s) => s.replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c] ?? c);
3
4
  const props = (schema) => Object.entries((schema?.properties ?? {}));
4
5
  const required = (schema) => new Set((schema?.required ?? []));
@@ -28,12 +29,14 @@ export function field(name, p, must) {
28
29
  }
29
30
  // The ask as a form: its name is the button, its description the legend's
30
31
  // small print, and `data-ask` is how the surface says which ask was sent.
31
- export function form(ask) {
32
+ export function form(ask, h = {}) {
32
33
  const must = required(ask.input);
33
34
  const fields = props(ask.input)
34
35
  .map(([name, p]) => field(name, p, must.has(name)))
35
36
  .join('');
36
- return `<form data-ask="${escape(ask.name)}"><fieldset><legend>${escape(ask.name)}${ask.description ? ` <small>${escape(ask.description)}</small>` : ''}</legend>${fields}<p><button>${escape(ask.name)}</button></p></fieldset></form>`;
37
+ const label = `${h.icon ? `${escape(h.icon)} ` : ''}${escape(h.title ?? ask.name)}`;
38
+ const care = h.destructive ? ' data-confirm="true"' : '';
39
+ return `<form data-ask="${escape(ask.name)}"${care}><fieldset><legend>${label}${ask.description ? ` <small>${escape(ask.description)}</small>` : ''}</legend>${fields}<p><button>${label}</button></p></fieldset></form>`;
37
40
  }
38
41
  // The strings typed by the schema. An empty field that is not required is
39
42
  // left out, so the being sees what the human said and nothing else; an
@@ -116,21 +119,69 @@ export function face(w, schema) {
116
119
  }
117
120
  // The title is the one hint the notes may carry: a string named `name`.
118
121
  // The rest of the notes is shown as a view and read as nothing else.
119
- export function title(bp) {
122
+ export function title(bp, l = {}) {
123
+ if (l.name)
124
+ return l.name;
120
125
  const n = bp?.notes;
121
126
  return n !== null && typeof n === 'object' && !Array.isArray(n) && typeof n.name === 'string' && n.name ? n.name : 'quo';
122
127
  }
128
+ // ---- a look
129
+ // A look as the page paints it: the tokens become CSS variables on one
130
+ // section, and the name and the logo its heading. The shape of every token
131
+ // is the dock's, `beings/look.ts`; this only writes what survived it.
132
+ export function look(l) {
133
+ if (!l)
134
+ return { style: '', head: '' };
135
+ const vars = [];
136
+ if (l.accent)
137
+ vars.push(`--accent:${l.accent}`);
138
+ if (l.background)
139
+ vars.push(`--bg:${l.background}`);
140
+ if (l.foreground)
141
+ vars.push(`--fg:${l.foreground}`);
142
+ if (l.font)
143
+ vars.push(`--font:${l.font}`);
144
+ if (l.radius !== undefined)
145
+ vars.push(`--radius:${l.radius}px`);
146
+ const logo = l.logo ? `<img class="logo" alt="" src="${l.logo}">` : '';
147
+ const name = l.name ? escape(l.name) : '';
148
+ return { style: vars.length ? ` style="${vars.join(';')}"` : '', head: logo || name ? `<h2>${logo}${name}</h2>` : '' };
149
+ }
150
+ // Her asks in the order her look asks for, the rest after in her own order.
151
+ export function ordered(asks, l) {
152
+ const want = l?.order ?? [];
153
+ return [...want.map((n) => asks.find((a) => a.name === n)).filter((a) => a !== undefined), ...asks.filter((a) => !want.includes(a.name))];
154
+ }
155
+ export { hintFor };
123
156
  export function page(m) {
124
157
  const bp = m.blueprint;
125
- const asks = bp
126
- ? bp.asks
127
- .map((a) => {
128
- const w = m.answers[a.name];
129
- return `<section>${form(a)}${w ? face(w, a.output) : ''}</section>`;
158
+ const one = (l, prefix = '') => (a) => {
159
+ const w = m.answers[a.name];
160
+ const h = hint(l, a.name.startsWith(prefix) ? a.name.slice(prefix.length) : a.name);
161
+ return `<section>${form(a, h)}${w ? face(w, a.output) : ''}</section>`;
162
+ };
163
+ const groups = grouped(bp);
164
+ const taken = new Set(Object.values(groups).flatMap((g) => g.asks ?? []));
165
+ const own = bp ? ordered(bp.asks.filter((a) => !taken.has(a.name) && a.name !== 'look'), m.look).map(one(m.look)).join('') : '';
166
+ const far = bp
167
+ ? Object.entries(groups)
168
+ .map(([id, g]) => {
169
+ const asks = bp.asks.filter((a) => g.asks?.includes(a.name));
170
+ if (!asks.length)
171
+ return '';
172
+ const kept = sanitise(g.look);
173
+ const l = look(kept);
174
+ const prefix = `${id}-`;
175
+ const want = (kept.order ?? []).map((n) => prefix + n);
176
+ const inOrder = ordered(asks, { order: want });
177
+ return `<section class="standing" data-standing="${escape(id)}"${l.style}>${l.head || `<h2>${escape(id)}</h2>`}${inOrder.map(one(kept, prefix)).join('')}</section>`;
130
178
  })
131
179
  .join('')
132
180
  : '';
133
- const notes = bp && bp.notes !== null && !(typeof bp.notes === 'object' && !Array.isArray(bp.notes) && !Object.keys(bp.notes).length) ? `<aside class="notes">${view(bp.notes)}</aside>` : '';
181
+ const asks = own + far;
182
+ const shown = bp && bp.notes !== null && typeof bp.notes === 'object' && !Array.isArray(bp.notes) ? Object.fromEntries(Object.entries(bp.notes).filter(([k]) => k !== 'standings' && k !== 'name')) : bp?.notes;
183
+ const notes = bp && shown !== null && shown !== undefined && !(typeof shown === 'object' && !Array.isArray(shown) && !Object.keys(shown).length) ? `<aside class="notes">${view(shown)}</aside>` : '';
134
184
  const pushes = m.pushes.length ? `<section class="pushes"><h2>pushes</h2><ol>${m.pushes.map((p) => `<li>${view(p)}</li>`).join('')}</ol></section>` : '';
135
- return `<header><h1>${escape(title(bp))}</h1><p class="notice">${escape(m.notice)}</p></header>${notes}<main>${asks}</main>${pushes}`;
185
+ const mine = look(m.look);
186
+ return `<header${mine.style}>${m.look.logo ? `<img class="logo" alt="" src="${m.look.logo}">` : ''}<h1>${escape(title(bp, m.look))}</h1><p class="notice">${escape(m.notice)}</p></header>${notes}<main${mine.style}>${asks}</main>${pushes}`;
136
187
  }
@@ -1,11 +1,16 @@
1
- import type { Avatar } from '../beings/avatar.ts';
2
- import { type Serving } from '../beings/side.ts';
1
+ import { type Serving, type Subject } from '../beings/side.ts';
2
+ import type { Invitation } from '@quo-systems/quo';
3
3
  import { type Model, type Raw } from './html.ts';
4
4
  export type Surface = {
5
5
  show(html: string): void;
6
6
  onSubmit: ((ask: string, raw: Raw) => void) | undefined;
7
7
  };
8
- export declare function screenSide(avatar: Avatar, surface: Surface, after?: () => Promise<void>, notice?: string): Promise<Serving & {
8
+ export type Options = {
9
+ after?: () => Promise<void>;
10
+ notice?: string;
11
+ admit?: (invitation: Invitation) => Promise<void>;
12
+ };
13
+ export declare function screenSide(avatar: Subject, surface: Surface, options?: Options): Promise<Serving & {
9
14
  model: Model;
10
15
  refresh(): Promise<void>;
11
16
  }>;
@@ -8,11 +8,14 @@
8
8
  // moved. A push is appended and shown the moment it lands.
9
9
  import { digest } from '@quo-systems/quo';
10
10
  import { word } from '../beings/side.js';
11
- import { page, values } from './html.js';
12
- // `after` runs when a call is done, as it does for the model side: a harbor
13
- // that must write what the ward changed hooks it.
14
- export async function screenSide(avatar, surface, after = async () => { }, notice = '') {
15
- const model = { blueprint: null, notice, answers: {}, pushes: [] };
11
+ import { isInvitation } from '../beings/link.js';
12
+ import { page, values, hintFor } from './html.js';
13
+ import { sanitise } from '../beings/look.js';
14
+ import { isSilence, isWord } from '@quo-systems/quo';
15
+ export async function screenSide(avatar, surface, options = {}) {
16
+ const after = options.after ?? (async () => { });
17
+ const notice = options.notice ?? '';
18
+ const model = { blueprint: null, look: {}, notice, answers: {}, pushes: [] };
16
19
  let seen = null;
17
20
  const show = () => surface.show(page(model));
18
21
  // Her describe, again: the page follows the digest.
@@ -31,8 +34,22 @@ export async function screenSide(avatar, surface, after = async () => { }, notic
31
34
  for (const k of Object.keys(model.answers))
32
35
  if (!bp.asks.some((a) => a.name === k))
33
36
  delete model.answers[k];
37
+ // her look, once per digest: how she wants her page painted, and what each ask is
38
+ if (bp.asks.some((a) => a.name === 'look')) {
39
+ const l = await avatar.call('look');
40
+ model.look = isSilence(l) || isWord(l) ? {} : sanitise(l);
41
+ }
42
+ else
43
+ model.look = {};
44
+ // a read-only ask that needs nothing typed is run on her behalf, so the page opens with what it shows
45
+ for (const a of bp.asks) {
46
+ const needs = (a.input.required ?? []).length > 0;
47
+ if (hintFor(bp, model.look, a.name).readOnly && !needs && !(a.name in model.answers))
48
+ model.answers[a.name] = word(await avatar.call(a.name, {}));
49
+ }
34
50
  }
35
51
  }
52
+ await after();
36
53
  show();
37
54
  };
38
55
  surface.onSubmit = (name, raw) => void submit(name, raw);
@@ -45,8 +62,11 @@ export async function screenSide(avatar, surface, after = async () => { }, notic
45
62
  model.answers[name] = { word: 'error', value: { error: v.error } };
46
63
  return show();
47
64
  }
48
- model.answers[name] = word(await avatar.call(name, v.args));
65
+ const out = await avatar.call(name, v.args);
49
66
  await after();
67
+ if (options.admit && isInvitation(out))
68
+ return options.admit(out);
69
+ model.answers[name] = word(out);
50
70
  await refresh();
51
71
  };
52
72
  const ear = (object) => {
@@ -2,6 +2,11 @@ import { USER } from '../beings/avatar.ts';
2
2
  export type Config = {
3
3
  quo: string;
4
4
  web: string;
5
+ wards: Record<string, {
6
+ pk: string;
7
+ public: boolean;
8
+ }>;
9
+ ward?: string;
5
10
  };
6
11
  export declare function start(cfg: Config, root?: HTMLElement): Promise<void>;
7
12
  export { USER };
package/dist/human/tab.js CHANGED
@@ -1,7 +1,32 @@
1
1
  import { BrowserHarbor } from '../harbor/browser.js';
2
- import { Avatar, USER } from '../beings/avatar.js';
2
+ import { USER } from '../beings/avatar.js';
3
+ import { parse, strip } from '../beings/link.js';
3
4
  import { screenSide } from './screen.js';
4
5
  import { domSurface } from './dom.js';
6
+ import { guest } from './guest.js';
7
+ import { door } from './door.js';
8
+ import { world, relations, fresh } from './worlds.js';
9
+ const kept = (key, fallback) => {
10
+ try {
11
+ return JSON.parse(localStorage.getItem(key) ?? 'null') ?? fallback;
12
+ }
13
+ catch {
14
+ return fallback;
15
+ }
16
+ };
17
+ const keep = (key, value) => {
18
+ try {
19
+ localStorage.setItem(key, JSON.stringify(value));
20
+ }
21
+ catch {
22
+ /* forgets */
23
+ }
24
+ };
25
+ const WORLDS = 'quo-worlds';
26
+ const worlds = () => kept(WORLDS, {});
27
+ const remember = (pk, url, name) => keep(WORLDS, { ...worlds(), [pk]: { url, name } });
28
+ const AT = (pk) => `quo-at:${pk}`;
29
+ const NAMES = (pk) => `quo-names:${pk}`;
5
30
  const el = (tag, text = '', attrs = {}) => {
6
31
  const e = document.createElement(tag);
7
32
  if (text)
@@ -11,58 +36,151 @@ const el = (tag, text = '', attrs = {}) => {
11
36
  return e;
12
37
  };
13
38
  export async function start(cfg, root = document.body) {
39
+ // The link, first and once: the invitation leaves the address bar before
40
+ // any other code sees it.
41
+ const linked = parse(location.hash);
42
+ if (linked)
43
+ history.replaceState(null, '', location.pathname + location.search + strip(location.hash));
44
+ const nav = el('nav', '', { class: 'worlds' });
45
+ const who = el('nav', '', { class: 'relations' });
14
46
  const status = el('p', 'booting');
15
47
  const screen = el('div');
16
- root.append(status, screen);
48
+ root.append(nav, who, status, screen);
17
49
  const say = (s) => (status.textContent = s);
18
- // The harbor in the tab: one database, one ward, one avatar, for good.
50
+ // The harbor in the tab: one database per origin, one ward per world.
19
51
  const harbor = new BrowserHarbor('quo');
20
52
  await harbor.boot();
21
- const main = harbor.wards.get('main') ?? (await harbor.create('main', 'me'));
22
- if (!main.being('me'))
23
- await main.ask('boot', { key: 'me', class: 'Avatar' });
24
- const me = main.being('me');
25
53
  harbor.dial(cfg.quo);
26
- // The screen: every call rotates her keys and a same-ward ask never
27
- // crosses the harbor, so the side saves after each one.
28
- const show = async (notice) => {
29
- status.remove();
30
- await screenSide(me, domSurface(screen), () => main.save(), notice);
54
+ // The world this page is about: the link's, since an invitation names
55
+ // its ward, or the page's own. A link for a world not on this harbor is
56
+ // a link to the wrong page, and says so.
57
+ const nameOf = (pk) => Object.keys(cfg.wards).find((n) => cfg.wards[n].pk === pk);
58
+ const pk = linked?.ward ?? (cfg.ward ? cfg.wards[cfg.ward]?.pk : undefined);
59
+ const wardName = pk ? nameOf(pk) : undefined;
60
+ const pageOf = (name) => `${new URL(cfg.web).pathname.replace(/\/$/, '')}/${encodeURIComponent(name)}`;
61
+ const switcher = () => {
62
+ nav.replaceChildren();
63
+ const known = worlds();
64
+ for (const [k, w] of Object.entries(known)) {
65
+ const a = el('a', w.name || k.slice(0, 8), { href: w.url });
66
+ if (k === pk)
67
+ a.setAttribute('aria-current', 'page');
68
+ nav.append(a);
69
+ }
70
+ if (pk && !known[pk] && wardName)
71
+ nav.append(el('span', `${wardName} at ${new URL(cfg.web).host}`));
31
72
  };
32
- // Already in: reconnect, nothing minted.
33
- const had = await me.tools();
34
- if ('asks' in had)
35
- return show('in, as before');
36
- // The exchange, once: the owner password for a nonce, the nonce for an
37
- // invitation the user being mints, the invitation for a standing.
38
- const form = el('form');
39
- const identity = el('input', '', { name: 'identity', value: 'tab', placeholder: 'identity' });
40
- const password = el('input', '', { name: 'password', type: 'password', placeholder: 'owner password' });
41
- const go = el('button', 'enter');
42
- form.append(identity, password, go);
43
- root.insertBefore(form, screen);
44
- say(`not in (${had.error}): the owner password opens the world`);
45
- form.onsubmit = async (ev) => {
46
- ev.preventDefault();
47
- say('asking the world');
48
- let res;
49
- try {
50
- res = await fetch(`${cfg.web}/tab/login`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ password: password.value, identity: identity.value }) });
73
+ switcher();
74
+ if (!pk || !wardName) {
75
+ say(linked ? 'this link is for a world that does not live here' : 'choose a world');
76
+ return;
77
+ }
78
+ if (linked && location.pathname !== pageOf(wardName))
79
+ history.replaceState(null, '', pageOf(wardName) + location.search + location.hash);
80
+ const ward = await world(harbor, pk);
81
+ const at = { name: wardName, pk, public: cfg.wards[wardName].public };
82
+ // The relations in this world, and which one is on screen.
83
+ let side = null;
84
+ let current = null;
85
+ const names = () => kept(NAMES(pk), {});
86
+ const people = () => {
87
+ who.replaceChildren();
88
+ const rels = relations(ward);
89
+ const called = names();
90
+ for (const r of rels) {
91
+ const b = el('button', called[r.key] ?? r.key, { type: 'button' });
92
+ if (r.key === current?.key)
93
+ b.setAttribute('aria-current', 'true');
94
+ b.onclick = () => void inside(r, `in, as ${called[r.key] ?? r.key}`, called[r.key] ?? r.key);
95
+ who.append(b);
51
96
  }
52
- catch (e) {
53
- return say(`the world did not answer: ${e.message}`);
97
+ if (rels.length) {
98
+ const more = el('button', 'another way in', { type: 'button', class: 'another' });
99
+ more.onclick = () => void atDoor();
100
+ who.append(more);
54
101
  }
55
- if (!res.ok)
56
- return say(`refused: ${(await res.json().catch(() => ({ error: res.status }))).error ?? res.status}`);
57
- const { nonce, ward } = (await res.json());
58
- password.value = '';
102
+ };
103
+ // In, as one relation: her page. Every call rotates her keys and a
104
+ // same-ward ask never crosses the harbor, so the side saves after each.
105
+ const inside = async (rel, notice, called) => {
106
+ await side?.close();
107
+ status.remove();
108
+ root.querySelector('form.password')?.remove();
109
+ screen.replaceChildren();
110
+ current = rel;
111
+ keep(AT(pk), rel.key);
112
+ const s = await screenSide(rel.avatar, domSurface(screen), { after: () => ward.save(), notice });
113
+ side = s;
114
+ const bp = s.model.blueprint;
115
+ const notesName = typeof bp?.notes?.name === 'string' ? bp.notes.name : '';
116
+ remember(pk, location.origin + pageOf(at.name), s.model.look.name || notesName || at.name);
117
+ if (!names()[rel.key])
118
+ keep(NAMES(pk), { ...names(), [rel.key]: called });
119
+ switcher();
120
+ people();
121
+ };
122
+ // The way in, from any of the three: a fresh avatar joins, the ward is
123
+ // saved, and she is the one on screen.
124
+ const admit = async (inv, notice, called) => {
59
125
  say('knocking');
60
- const got = await me.enter({ ward }, { kind: 'tab', nonce });
61
- await main.save(); // the knock went through her own door, which the harbor never sees
62
- if (!('asks' in got))
63
- return say(`not in: ${got.error}`);
64
- form.remove();
65
- await show(`in, as ${identity.value}`);
126
+ const rel = await fresh(ward);
127
+ const got = await rel.avatar.join(inv);
128
+ await ward.save(); // the knock went through her own door, which the harbor never sees
129
+ if (!('asks' in got)) {
130
+ say(`not in: ${got.error}`);
131
+ return false;
132
+ }
133
+ await inside(rel, notice, called);
134
+ return true;
135
+ };
136
+ // At the door. With a public being there, her describe as a page, and a
137
+ // form whose answer is an invitation lets the guest in. With nobody
138
+ // there, the door page: a link is the only way in, and nothing to type.
139
+ const atDoor = async () => {
140
+ await side?.close();
141
+ side = null;
142
+ current = null;
143
+ screen.replaceChildren();
144
+ if (!status.isConnected)
145
+ root.insertBefore(status, screen);
146
+ people();
147
+ if (!at.public) {
148
+ status.remove();
149
+ screen.innerHTML = door({ world: at.name, host: new URL(cfg.web).host });
150
+ return;
151
+ }
152
+ say(`at the door of ${at.name}`);
153
+ const rel = await fresh(ward);
154
+ const gate = guest(rel.avatar, pk);
155
+ const s = await screenSide(gate, domSurface(screen), {
156
+ after: () => ward.save(),
157
+ notice: `a guest at ${at.name}: what she shows strangers`,
158
+ admit: async (inv) => {
159
+ await s.close();
160
+ await admit(inv, `in, as a guest of ${at.name}`, 'guest');
161
+ },
162
+ });
163
+ side = s;
66
164
  };
165
+ // A link: one more relation, whoever else is in. Then whoever was on
166
+ // screen last, or the door.
167
+ if (linked && (await admit(linked, 'in, by the link', 'link')))
168
+ return;
169
+ const rels = relations(ward);
170
+ const last = kept(AT(pk), null);
171
+ const back = rels.find((r) => r.key === last) ?? rels[rels.length - 1];
172
+ if (back) {
173
+ const had = await back.avatar.tools();
174
+ await ward.save(); // her keys rotated on that ask, whatever it answered
175
+ if ('asks' in had)
176
+ return inside(back, linked ? `not in by the link; in, as before` : 'in, as before', names()[back.key] ?? back.key);
177
+ say(`not in (${had.error})`);
178
+ }
179
+ await atDoor();
67
180
  }
181
+ // The page hands the config in a JSON script, which a content security
182
+ // policy allows where an inline script is not.
183
+ const config = document.getElementById('quo');
184
+ if (config?.textContent)
185
+ void start(JSON.parse(config.textContent));
68
186
  export { USER };
@@ -0,0 +1,9 @@
1
+ import type { IncomingMessage, ServerResponse } from 'node:http';
2
+ import type { DiskHarbor } from '../harbor/disk.ts';
3
+ export type WebOptions = {
4
+ at: {
5
+ quo: string;
6
+ web: string;
7
+ };
8
+ };
9
+ export declare function webRoute(harbor: DiskHarbor, o: WebOptions): (req: IncomingMessage, res: ServerResponse, rest: string) => Promise<boolean>;
@@ -0,0 +1,95 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { fileURLToPath } from 'node:url';
3
+ import { build } from 'esbuild';
4
+ import { door } from './door.js';
5
+ // The paths under `/web` that are the exchange's, never a ward's name.
6
+ const RESERVED_PATHS = new Set(['login', 'allow', 'tab.js']);
7
+ export function webRoute(harbor, o) {
8
+ const tabEntry = () => {
9
+ const js = fileURLToPath(new URL('./tab.js', import.meta.url));
10
+ return existsSync(js) ? js : fileURLToPath(new URL('./tab.ts', import.meta.url));
11
+ };
12
+ let bundle;
13
+ const built = () => (bundle ??= build({ entryPoints: [tabEntry()], bundle: true, format: 'esm', platform: 'browser', target: 'es2023', write: false }).then((out) => out.outputFiles[0].text));
14
+ const quoOrigin = (() => {
15
+ try {
16
+ const u = new URL(o.at.quo);
17
+ return `${u.origin} ${u.origin.replace(/^http/, 'ws')}`;
18
+ }
19
+ catch {
20
+ return '';
21
+ }
22
+ })();
23
+ const host = (() => {
24
+ try {
25
+ return new URL(o.at.web).host;
26
+ }
27
+ catch {
28
+ return o.at.web;
29
+ }
30
+ })();
31
+ const policy = `default-src 'none'; script-src 'self'; style-src 'unsafe-inline'; img-src 'self' data:; connect-src 'self' ${quoOrigin}; form-action 'self'; base-uri 'none'; frame-ancestors 'none'`;
32
+ 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>
33
+ <style>${CSS}</style>
34
+ </head><body>${body}</body></html>`;
35
+ const esc = (v) => v.replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c] ?? c);
36
+ const publicOf = (h) => h.partition.public ?? null;
37
+ const html = (status, res, body) => {
38
+ res.writeHead(status, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store', 'content-security-policy': policy, 'referrer-policy': 'no-referrer' });
39
+ res.end(shell(body));
40
+ return true;
41
+ };
42
+ const wards = () => Object.fromEntries([...harbor.wards].map(([n, h]) => [n, { pk: h.pk, public: publicOf(h) !== null }]));
43
+ const tab = (ward) => {
44
+ const cfg = { quo: o.at.quo, web: o.at.web, wards: wards(), ...(ward ? { ward } : {}) };
45
+ return `<script id="quo" type="application/json">${JSON.stringify(cfg).replace(/</g, '\\u003c')}</script><script type="module" src="${o.at.web}/tab.js"></script>`;
46
+ };
47
+ return async (req, res, rest) => {
48
+ const parts = rest.split('/').filter(Boolean);
49
+ if (req.method === 'GET' && (rest === '' || rest === '/')) {
50
+ const list = [...harbor.wards]
51
+ .filter(([, h]) => publicOf(h) !== null)
52
+ .map(([n, h]) => `<li><a href="${o.at.web}/${encodeURIComponent(n)}">${esc(n)}</a> <small>${esc(publicOf(h) ?? '')} at the door, <code>${h.pk.slice(0, 16)}…</code></small></li>`)
53
+ .join('');
54
+ return html(200, res, `${list ? `<h1>worlds</h1><ul>${list}</ul>` : door({ host })}${tab()}`);
55
+ }
56
+ if (rest === '/tab.js' && req.method === 'GET') {
57
+ res.writeHead(200, { 'content-type': 'text/javascript; charset=utf-8', 'cache-control': 'no-store' });
58
+ res.end(await built());
59
+ return true;
60
+ }
61
+ const wardName = parts[0] ?? '';
62
+ if (!wardName || RESERVED_PATHS.has(wardName))
63
+ return false;
64
+ const hosted = harbor.wards.get(wardName);
65
+ if (!hosted)
66
+ return html(404, res, `<h1>no such world</h1><p>no ward named ${esc(wardName)} on this harbor.</p>`);
67
+ if (parts.length === 1 && req.method === 'GET')
68
+ return html(200, res, tab(wardName));
69
+ return false;
70
+ };
71
+ }
72
+ // The tab's stylesheet: one, light and dark, honouring the variables a look
73
+ // sets on a section. The page owns layout; a far being paints inside her
74
+ // section and nowhere else. The door page is the one designed thing in it.
75
+ const CSS = [
76
+ ':root{color-scheme:light dark;--accent:#3b6ef5;--bg:transparent;--fg:inherit;--font:system-ui,sans-serif;--radius:6px;--ink:#1c1b22;--paper:#fbfaf7;--mute:#6b6a73}',
77
+ '@media(prefers-color-scheme:dark){:root{--ink:#ecebe6;--paper:#141318;--mute:#9a99a2}}',
78
+ 'html{background:var(--paper);color:var(--ink)}',
79
+ 'body{font:16px/1.5 system-ui,sans-serif;max-width:40rem;margin:2rem auto;padding:0 1rem}',
80
+ 'nav.worlds{display:flex;flex-wrap:wrap;gap:.5rem 1rem;font-size:.9rem;opacity:.8}nav.worlds a[aria-current]{font-weight:600}',
81
+ 'nav.relations{display:flex;flex-wrap:wrap;gap:.25rem;margin:.5rem 0}nav.relations button{background:transparent;color:inherit;border:1px solid color-mix(in srgb,currentColor 30%,transparent)}nav.relations button[aria-current]{border-color:var(--accent);font-weight:600}',
82
+ 'header,main{background:var(--bg);color:var(--fg);font-family:var(--font)}header{display:flex;flex-wrap:wrap;align-items:center;gap:.5rem 1rem}header .notice{width:100%;margin:0}',
83
+ 'input,select,textarea,button{font:inherit;padding:.4rem;margin:.2rem;border-radius:var(--radius)}button{background:var(--accent);color:#fff;border:0;padding:.4rem .9rem}',
84
+ 'fieldset{border:1px solid color-mix(in srgb,currentColor 20%,transparent);border-radius:var(--radius);margin:.5rem 0}',
85
+ 'section.standing{background:var(--bg);color:var(--fg);font-family:var(--font);border-left:4px solid var(--accent);border-radius:var(--radius);padding:.25rem 1rem;margin:1.5rem 0}section.standing h2{display:flex;align-items:center;gap:.5rem;font-size:1.1rem}img.logo{height:1.6rem}',
86
+ 'table{border-collapse:collapse}td,th{padding:.15rem .5rem;text-align:left}.answer{margin:.5rem 0 1rem;padding:.5rem;border-left:3px solid var(--accent)}.answer.error{border-color:#c33}.answer.silence,.answer.word,.answer.unreached{border-color:#c93}pre{padding:.75rem;overflow:auto}',
87
+ // the door page: one column, generous air, the mark, a word, a sentence
88
+ 'main.door{min-height:70vh;display:flex;flex-direction:column;justify-content:center;align-items:flex-start;max-width:32rem;margin:0 auto;padding:3rem 0;font-family:ui-serif,Georgia,"Times New Roman",serif}',
89
+ 'main.door .mark{width:3.5rem;height:3.5rem;color:var(--ink);opacity:.9;margin-bottom:1.25rem}',
90
+ 'main.door h1{font-size:3rem;font-weight:400;letter-spacing:-.02em;line-height:1;margin:0 0 .5rem}',
91
+ 'main.door .where{font-family:system-ui,sans-serif;font-size:.85rem;letter-spacing:.08em;text-transform:uppercase;color:var(--mute);margin:0 0 2rem}main.door .where span{text-transform:none;letter-spacing:0}',
92
+ 'main.door .lead{font-size:1.5rem;line-height:1.3;margin:0 0 1rem}',
93
+ 'main.door p{font-size:1.05rem;line-height:1.6;margin:0 0 1rem;max-width:30rem}',
94
+ 'main.door .quiet{color:var(--mute);font-size:.95rem;border-top:1px solid color-mix(in srgb,currentColor 15%,transparent);padding-top:1rem;margin-top:1rem}',
95
+ ].join('');
@@ -0,0 +1,10 @@
1
+ import type { Harbor, Hosted } from '@quo-systems/quo/harbor';
2
+ import { Avatar } from '../beings/avatar.ts';
3
+ export declare const localName: (pk: string) => string;
4
+ export declare function world(harbor: Harbor, pk: string): Promise<Hosted>;
5
+ export type Relation = {
6
+ key: string;
7
+ avatar: Avatar;
8
+ };
9
+ export declare function relations(ward: Hosted): Relation[];
10
+ export declare function fresh(ward: Hosted): Promise<Relation>;
@@ -0,0 +1,38 @@
1
+ import { Avatar, USER } from '../beings/avatar.js';
2
+ // The local ward for a far world, by its pk: created on first sight, on
3
+ // the tab's own seed, empty.
4
+ export const localName = (pk) => `w-${pk.slice(0, 16)}`;
5
+ export async function world(harbor, pk) {
6
+ const name = localName(pk);
7
+ return harbor.wards.get(name) ?? (await harbor.create(name, 'me'));
8
+ }
9
+ export function relations(ward) {
10
+ const beings = ward.partition.beings ?? {};
11
+ const out = [];
12
+ for (const key of Object.keys(beings)) {
13
+ const a = ward.being(key);
14
+ if (a instanceof Avatar && a.standings[USER])
15
+ out.push({ key, avatar: a });
16
+ }
17
+ return out;
18
+ }
19
+ // An avatar for a relation not yet made: one who holds no standing, whether
20
+ // left by a guest who never came in or booted now under the next free key.
21
+ // She is the one who knocks, as a guest or with an invitation, and she
22
+ // becomes a relation the moment she takes her standing.
23
+ export async function fresh(ward) {
24
+ const beings = ward.partition.beings ?? {};
25
+ for (const key of Object.keys(beings)) {
26
+ const a = ward.being(key);
27
+ if (a instanceof Avatar && !a.standings[USER])
28
+ return { key, avatar: a };
29
+ }
30
+ let n = 1;
31
+ while (beings[`r${n}`])
32
+ n++;
33
+ const key = `r${n}`;
34
+ const out = (await ward.ask('boot', { key, class: 'Avatar' }));
35
+ if (out.error)
36
+ throw new Error(`the tab could not boot an avatar: ${out.error}`);
37
+ return { key, avatar: ward.being(key) };
38
+ }