@quo-systems/dock 0.1.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/README.md +9 -2
  2. package/beings/avatar.ts +6 -2
  3. package/beings/carry.ts +101 -0
  4. package/beings/desk.ts +2 -2
  5. package/beings/index.ts +2 -0
  6. package/beings/link.ts +54 -0
  7. package/beings/look.ts +96 -0
  8. package/beings/quo-dock.md +185 -38
  9. package/beings/setup.ts +3 -1
  10. package/beings/side.ts +10 -1
  11. package/beings/user.ts +31 -5
  12. package/cli/daemon.ts +98 -44
  13. package/cli/estate/Caddyfile +25 -0
  14. package/cli/estate/quo.service +36 -0
  15. package/cli/estate.ts +44 -0
  16. package/cli/quo.ts +14 -3
  17. package/dist/beings/avatar.js +7 -2
  18. package/dist/beings/carry.d.ts +10 -0
  19. package/dist/beings/carry.js +106 -0
  20. package/dist/beings/desk.d.ts +1 -0
  21. package/dist/beings/desk.js +1 -1
  22. package/dist/beings/index.d.ts +2 -0
  23. package/dist/beings/index.js +2 -0
  24. package/dist/beings/link.d.ts +7 -0
  25. package/dist/beings/link.js +42 -0
  26. package/dist/beings/look.d.ts +27 -0
  27. package/dist/beings/look.js +71 -0
  28. package/dist/beings/setup.js +4 -1
  29. package/dist/beings/side.d.ts +8 -1
  30. package/dist/beings/user.d.ts +26 -2
  31. package/dist/beings/user.js +32 -5
  32. package/dist/cli/daemon.d.ts +1 -1
  33. package/dist/cli/daemon.js +98 -44
  34. package/dist/cli/estate/Caddyfile +25 -0
  35. package/dist/cli/estate/quo.service +36 -0
  36. package/dist/cli/estate.d.ts +5 -0
  37. package/dist/cli/estate.js +46 -0
  38. package/dist/cli/quo.js +14 -3
  39. package/dist/harbor/edge/exercise.js +3 -1
  40. package/dist/harbor/edge/platform.d.ts +28 -0
  41. package/dist/human/guest.d.ts +3 -0
  42. package/dist/human/guest.js +25 -0
  43. package/dist/human/html.d.ts +10 -2
  44. package/dist/human/html.js +61 -10
  45. package/dist/human/screen.d.ts +8 -3
  46. package/dist/human/screen.js +25 -6
  47. package/dist/human/tab.d.ts +2 -0
  48. package/dist/human/tab.js +127 -42
  49. package/dist/mcp/http.d.ts +4 -3
  50. package/dist/mcp/http.js +6 -6
  51. package/dist/mcp/oauth.d.ts +11 -4
  52. package/dist/mcp/oauth.js +32 -21
  53. package/dist/mcp/pilot.d.ts +3 -4
  54. package/dist/mcp/pilot.js +19 -65
  55. package/dist/mcp/server.d.ts +6 -4
  56. package/dist/mcp/server.js +48 -11
  57. package/dist/mcp/web/exchange.d.ts +5 -2
  58. package/dist/mcp/web/exchange.js +21 -7
  59. package/harbor/edge/exercise.ts +2 -1
  60. package/harbor/quo-harbor.md +37 -0
  61. package/human/guest.ts +26 -0
  62. package/human/html.ts +58 -10
  63. package/human/quo-human.md +123 -66
  64. package/human/screen.ts +28 -7
  65. package/human/tab.ts +153 -51
  66. package/mcp/http.ts +10 -9
  67. package/mcp/oauth.ts +39 -23
  68. package/mcp/pilot.ts +26 -65
  69. package/mcp/quo-mcp.md +34 -19
  70. package/mcp/server.ts +52 -19
  71. package/mcp/web/exchange.ts +23 -9
  72. package/package.json +7 -3
package/human/html.ts CHANGED
@@ -11,6 +11,7 @@
11
11
  // page(model) the whole page: title, notice, forms, views, pushes
12
12
  import type { Ask, Blueprint, Json, JsonObject } from '@quo-systems/quo';
13
13
  import { SILENCE_TEXT, UNREACHED_TEXT, wordText, type Word } from '../beings/side.ts';
14
+ import { hint, hintFor, sanitise, groups as grouped, type Look, type Hint } from '../beings/look.ts';
14
15
 
15
16
  // One property of an input schema, as the form needs it.
16
17
  export type Property = { type?: string; description?: string; enum?: Json[]; format?: string; default?: Json };
@@ -47,12 +48,14 @@ export function field(name: string, p: Property, must: boolean): string {
47
48
 
48
49
  // The ask as a form: its name is the button, its description the legend's
49
50
  // small print, and `data-ask` is how the surface says which ask was sent.
50
- export function form(ask: Ask): string {
51
+ export function form(ask: Ask, h: Hint = {}): string {
51
52
  const must = required(ask.input);
52
53
  const fields = props(ask.input)
53
54
  .map(([name, p]) => field(name, p, must.has(name)))
54
55
  .join('');
55
- 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>`;
56
+ const label = `${h.icon ? `${escape(h.icon)} ` : ''}${escape(h.title ?? ask.name)}`;
57
+ const care = h.destructive ? ' data-confirm="true"' : '';
58
+ 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>`;
56
59
  }
57
60
 
58
61
  // ---- the form heard back
@@ -136,6 +139,7 @@ export function face(w: Word, schema?: JsonObject): string {
136
139
  // hands it here after every change; the page is a function of it.
137
140
  export type Model = {
138
141
  blueprint: Blueprint | null; // her describe for this human, or nothing yet
142
+ look: Look; // her own look, from her `look` ask, or nothing
139
143
  notice: string; // one line about where the human stands: in, not in, an error before an ask
140
144
  answers: Record<string, Word>; // the last answer per ask, shown under its form
141
145
  pushes: JsonObject[]; // every push from the world, newest last
@@ -143,22 +147,66 @@ export type Model = {
143
147
 
144
148
  // The title is the one hint the notes may carry: a string named `name`.
145
149
  // The rest of the notes is shown as a view and read as nothing else.
146
- export function title(bp: Blueprint | null): string {
150
+ export function title(bp: Blueprint | null, l: Look = {}): string {
151
+ if (l.name) return l.name;
147
152
  const n = bp?.notes;
148
153
  return n !== null && typeof n === 'object' && !Array.isArray(n) && typeof n.name === 'string' && n.name ? n.name : 'quo';
149
154
  }
150
155
 
156
+ // ---- a look
157
+
158
+ // A look as the page paints it: the tokens become CSS variables on one
159
+ // section, and the name and the logo its heading. The shape of every token
160
+ // is the dock's, `beings/look.ts`; this only writes what survived it.
161
+ export function look(l: Look | undefined): { style: string; head: string } {
162
+ if (!l) return { style: '', head: '' };
163
+ const vars: string[] = [];
164
+ if (l.accent) vars.push(`--accent:${l.accent}`);
165
+ if (l.background) vars.push(`--bg:${l.background}`);
166
+ if (l.foreground) vars.push(`--fg:${l.foreground}`);
167
+ if (l.font) vars.push(`--font:${l.font}`);
168
+ if (l.radius !== undefined) vars.push(`--radius:${l.radius}px`);
169
+ const logo = l.logo ? `<img class="logo" alt="" src="${l.logo}">` : '';
170
+ const name = l.name ? escape(l.name) : '';
171
+ return { style: vars.length ? ` style="${vars.join(';')}"` : '', head: logo || name ? `<h2>${logo}${name}</h2>` : '' };
172
+ }
173
+
174
+ // Her asks in the order her look asks for, the rest after in her own order.
175
+ export function ordered(asks: Ask[], l: Look | undefined): Ask[] {
176
+ const want = l?.order ?? [];
177
+ return [...want.map((n) => asks.find((a) => a.name === n)).filter((a): a is Ask => a !== undefined), ...asks.filter((a) => !want.includes(a.name))];
178
+ }
179
+
180
+ export { hintFor };
181
+
151
182
  export function page(m: Model): string {
152
183
  const bp = m.blueprint;
153
- const asks = bp
154
- ? bp.asks
155
- .map((a) => {
156
- const w = m.answers[a.name];
157
- return `<section>${form(a)}${w ? face(w, a.output) : ''}</section>`;
184
+ const one = (l: Look | undefined, prefix = '') => (a: Ask) => {
185
+ const w = m.answers[a.name];
186
+ const h = hint(l, a.name.startsWith(prefix) ? a.name.slice(prefix.length) : a.name);
187
+ return `<section>${form(a, h)}${w ? face(w, a.output) : ''}</section>`;
188
+ };
189
+ const groups = grouped(bp);
190
+ const taken = new Set(Object.values(groups).flatMap((g) => g.asks ?? []));
191
+ const own = bp ? ordered(bp.asks.filter((a) => !taken.has(a.name) && a.name !== 'look'), m.look).map(one(m.look)).join('') : '';
192
+ const far = bp
193
+ ? Object.entries(groups)
194
+ .map(([id, g]) => {
195
+ const asks = bp.asks.filter((a) => g.asks?.includes(a.name));
196
+ if (!asks.length) return '';
197
+ const kept = sanitise(g.look);
198
+ const l = look(kept);
199
+ const prefix = `${id}-`;
200
+ const want = (kept.order ?? []).map((n) => prefix + n);
201
+ const inOrder = ordered(asks, { order: want });
202
+ return `<section class="standing" data-standing="${escape(id)}"${l.style}>${l.head || `<h2>${escape(id)}</h2>`}${inOrder.map(one(kept, prefix)).join('')}</section>`;
158
203
  })
159
204
  .join('')
160
205
  : '';
161
- 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>` : '';
206
+ const asks = own + far;
207
+ 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;
208
+ const notes = bp && shown !== null && shown !== undefined && !(typeof shown === 'object' && !Array.isArray(shown) && !Object.keys(shown).length) ? `<aside class="notes">${view(shown as Json)}</aside>` : '';
162
209
  const pushes = m.pushes.length ? `<section class="pushes"><h2>pushes</h2><ol>${m.pushes.map((p) => `<li>${view(p)}</li>`).join('')}</ol></section>` : '';
163
- return `<header><h1>${escape(title(bp))}</h1><p class="notice">${escape(m.notice)}</p></header>${notes}<main>${asks}</main>${pushes}`;
210
+ const mine = look(m.look);
211
+ 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}`;
164
212
  }
@@ -1,86 +1,143 @@
1
1
  # The human side
2
2
 
3
3
  This is how a Quo world speaks to a human, and how a human speaks back. It
4
- is one side of the avatar described in `packages/dock/beings/quo-dock.md`, which
5
- owns the avatar, the front desk, the credential exchange and the shared
6
- invariants; this document assumes all of that and adds only what a screen
7
- needs. It names no transport and no model. A tab is a device with a harbor
8
- of its own, and where its bytes go is `packages/dock/harbor/quo-harbor.md`.
4
+ is one side of the avatar described in `packages/dock/beings/quo-dock.md`,
5
+ which owns the avatar, the front desk, the credential exchange, carrying,
6
+ the look and the shared invariants; this document assumes all of that and
7
+ adds only what a screen needs. It names no transport and no model. A tab is
8
+ a device with a harbor of its own, and where its bytes go is
9
+ `packages/dock/harbor/quo-harbor.md`.
9
10
 
10
- The claim it will make good on: a human needs no page written for them. A
11
- screen is a blueprint spoken as HTML. The user being's describe for this
12
- asker becomes the forms the human sees, a submitted form becomes a named
13
- ask, an answer becomes a view, a push becomes a notification, and the three
14
- words for "no object" each have a face. Nothing the screen shows exists
15
- anywhere but in a blueprint, so every world gets a screen for free and no
16
- world gets a screen it did not describe.
11
+ The claim, made good on: a human needs no page written for them. A screen
12
+ is a blueprint spoken as HTML. A being's describe for this asker becomes the
13
+ forms the human sees, a submitted form becomes a named ask, an answer
14
+ becomes a view, a push becomes a notification, and the three words for "no
15
+ object" each have a face. Nothing the screen shows exists anywhere but in a
16
+ blueprint and a look, so every world gets a screen for free and no world
17
+ gets a screen it did not describe.
17
18
 
18
- What this document decides, in its own sitting:
19
+ Three sides, one describe. The CLI, the model side and the screen are three
20
+ views of the same beings, and the screen adds no vocabulary of its own:
21
+ what a being can be asked is her blueprint, how she wants to be shown is
22
+ her `look`, and both are values that travel through the relation. A world
23
+ that wants a page of its own writes any HTML, CSS and JavaScript it likes on
24
+ its own origin, against the avatar's handles and nothing else; a world that
25
+ writes none gets this screen.
19
26
 
20
- - how a JSON Schema input becomes a form without a designer;
21
- - how an output schema, and an answer, become a view;
22
- - what the blueprint's notes may carry as rendering hints without becoming
23
- state, and what they may never carry;
24
- - how a standing is a page, and a digest change is a refreshed page;
25
- - how the same screen is a guest one minute and an occupant the next, and
26
- what the human sees at each of the three words;
27
- - how a native app shows many worlds: one screen per ward, a switcher
28
- between them, and the device's own beings offered to each through gates;
29
- - what one avatar class, one `side` implementation and one suite look like
30
- for a screen, proven on the memory harbor with no browser at all.
27
+ ## What the twelfth sitting decided
31
28
 
32
- Until that sitting, the only rule in force is the one law from the trunk:
33
- the human is an occupant, reached through an avatar, seeing what the gate
34
- shows.
29
+ Read with the trunk's "Carrying" and "The look", which it applies.
35
30
 
36
- ## The screen today, minimal on purpose
31
+ - **A form from a schema, without a designer.** An input schema is a form
32
+ with one field per property by type: a string is a text input, and
33
+ `password`, `date`, `email` and `time` are the input's type; a number is a
34
+ number input; a boolean a checkbox; an enum a select; an object or an
35
+ array a box for JSON. The submitted strings come back as arguments typed by
36
+ the schema, an empty optional field left out. A hint from her look puts a
37
+ title and an icon on the button and marks a destructive ask.
38
+ - **A view from an answer.** A primitive is text, an object a table of its
39
+ keys in the output schema's order when she declared one, a list of objects
40
+ one table, null the word nothing. An error object is marked; silence and
41
+ each of the ward's words say what the side says of them.
42
+ - **A standing is a section.** The user being carries her standings, and
43
+ her notes say which asks are whose. The page draws her own asks first,
44
+ then one section per standing, headed by that being's name and logo and
45
+ painted with her tokens as CSS variables on that section and nowhere
46
+ else. A form in acme's section is an ask on the human's standing at acme,
47
+ and acme sees the human, never the device. The same section appears on
48
+ acme's own web, under the same look: one component, two relations.
49
+ - **The page follows the digest.** The side asks the describe again after
50
+ every call and asks `look` once per digest; a digest that moved is a page
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.
53
+ - **What a look may carry, and never.** The trunk's table is the whole
54
+ list: a name, a logo as a data URI, three colours, a font, a radius, an
55
+ order, a hint per ask. No CSS, no URL, no script, ever. A token that fails
56
+ its shape is dropped. The page owns layout, typography and its own
57
+ palette; a far being paints inside her section and cannot reach out of it.
58
+ - **The tab shell.** One stylesheet honouring the variables, light and dark,
59
+ served by the daemon beside the bundle. Nothing else is designed.
60
+ - **A world is a ward with a public being, and the guest page is her
61
+ describe.** Whatever class she is. The tab knocks the ward's public
62
+ invitation, its pk alone, with the empty ask, and renders what she shows
63
+ strangers with this same screen: the desk shows `hello` and `device`, a
64
+ shop could show its availability, a garden its `enter`. A form on that
65
+ page is a knock on the public invitation, and an answer that is an
66
+ invitation is the way in: the side hands it to `admit`, the avatar joins,
67
+ and the page becomes hers. Nothing in the screen knows the word desk.
68
+ - **Worlds have addresses.** `web./<ward>` is that world's page, `web./`
69
+ lists the worlds, and a hostname per world is one proxy line, the
70
+ estate's choice. `quo.` stays one per harbor, since bytes route by pk.
71
+ - **A link is the page with the invitation in the fragment**, under the one
72
+ reserved key `quo`, the invitation compact as `ward.heir.secret` or
73
+ `ward` alone: `web.acme.com/shop#quo=...`. The fragment never leaves the
74
+ browser, where a query string would reach the server, its logs and every
75
+ referer. The tab reads it, strips it from the address bar before anything
76
+ else runs, and joins. A page's own routing keeps the query, the path and
77
+ the rest of the fragment; only `quo` is taken. `beings/link.ts`.
78
+ - **The tab is a browser of worlds.** One harbor per origin, one ward per
79
+ world joined, each on the tab's seed with one avatar, and a switcher over
80
+ them. A world on another origin is another page, and the switcher links
81
+ there.
82
+ - **The policy.** The page carries a content security policy: scripts from
83
+ its origin only and never inline, connections to its origin and the `quo.`
84
+ route it dials, images from data URIs and its origin, no frames, no base.
85
+ The config crosses in a JSON script, which the policy allows. A bug in a
86
+ renderer cannot become a script, and no look can reach a server.
37
87
 
38
- The sitting above has not happened. What stands is the least screen that
39
- speaks a blueprint as HTML with no page written for any world, so that the
40
- rest of the dock is not held up by a design. Three pieces under
41
- `packages/dock/human/`, and the tab that holds them:
88
+ ## The pieces
89
+
90
+ Three files under `packages/dock/human/`, and the tab that holds them:
42
91
 
43
92
  - `html.ts` is pure, strings in and strings out, and is the screen's whole
44
- vocabulary. An input schema is a form with one field per property by
45
- type: a string is a text input, and `password`, `date`, `email` and
46
- `time` are the input's type; a number is a number input; a boolean is a
47
- checkbox; an enum is a select; an object or an array is a box for JSON.
48
- The submitted strings come back as arguments typed by the schema, an
49
- empty optional field left out. An answer is a view: a primitive is text,
50
- an object a table of its keys in the output schema's order when she
51
- declared one, a list of objects one table, null the word nothing. The
52
- three words for "no object" each have a face, an error object marked,
53
- silence and each of the ward's words saying what the side says of them.
54
- The notes may carry one hint, a string `name` that titles the page; the
55
- rest is shown as a view and read as nothing else.
93
+ vocabulary: `form`, `values`, `view`, `face`, `look`, `page`. It reads the
94
+ look's vocabulary from `beings/look.ts` and adds none.
56
95
  - `screen.ts` is the side: one avatar and one surface, where a surface can
57
- only show a page and hand back a submitted form. The side keeps the
58
- model, speaks it as a page after every change, calls her when a form
59
- comes back, and re-asks her describe after every call, so a digest that
60
- moved is a page that moved. A push is appended and shown as it lands. A
61
- describe that fails leaves the last page standing and says so in the
62
- notice.
96
+ only show a page and hand back a submitted form. It keeps the model,
97
+ speaks it as a page after every change, calls her when a form comes back,
98
+ re-asks her describe after every call, asks her look once per digest, and
99
+ runs her read-only asks on open. A push is appended and shown as it
100
+ lands. A describe that fails leaves the last page standing and says so in
101
+ the notice.
63
102
  - `dom.ts` is the surface on an element, the one file that touches one.
64
- - `tab.ts` is the harbor in the tab and the exchange, unchanged from the
65
- first screen, and hands the avatar to the side.
103
+ - `guest.ts` is a world's public being as a subject the screen renders:
104
+ her describe for a stranger, a form as a knock on the public invitation.
105
+ - `tab.ts` is the harbor in the tab, the worlds and the three ways in, and
106
+ hands the avatar to the side.
66
107
 
67
108
  Proven in `packages/dock/test/human.test.ts` on the memory harbor with a fake
68
- surface and no browser, and in `packages/dock/test/terrain/browser.test.ts` in a
69
- real Chromium against a daemon on loopback, behind `npm run check:terrain`.
109
+ 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, and the link read, stripped
111
+ and refused; and in `packages/dock/test/terrain/browser.test.ts` in a real
112
+ Chromium against a daemon on loopback, behind `npm run check:terrain`: the
113
+ guest page, the password way in, a push, a reload, and the policy header.
114
+
115
+ ## Security, by structure
70
116
 
71
- What it leaves to the sitting above, deliberately: any design, a switcher
72
- between worlds, the guest's page before the exchange, the native app, and
73
- every rendering hint beyond `name`. None of it blocks a world from having
74
- a screen today.
117
+ - Nothing executable crosses. What a screen receives is I-JSON, by the
118
+ spec: asks, args, answers, the three words, and a look. Every string is
119
+ escaped before it is written into the page; a JSON box is parsed and never
120
+ evaluated.
121
+ - A look is a closed vocabulary held to shapes. A logo is a data URI, so no
122
+ request leaves the page towards a far being's server; a colour is a hex
123
+ colour; there is no token for a stylesheet, a URL or a script.
124
+ - The gate is the permission. What a page can ask is what the being shows
125
+ that device, already. No page adds a right, and a form for an ask she does
126
+ not show is nothing.
127
+ - Origin is the boundary. A world's own page runs only on that world's
128
+ origin; on the human's own web only the dock's bundle and the estate's
129
+ classes run. A world can only ever damage itself.
75
130
 
76
131
  ## The tab
77
132
 
78
- The tab is a device. The page boots a browser harbor on a seed minted into
79
- the tab's store the first time, one ward with one avatar in it, and dials
80
- the world's `quo.` route. The exchange happens once: the owner password
81
- goes to `/tab/login`, which answers a nonce and the ward pk, and the
82
- avatar in the tab knocks the front desk over the socket with the nonce
83
- under the `tab` proof kind, is handed the invitation the user being minted,
84
- and joins her. From then on there is no cookie and no token anywhere, and
133
+ 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 world
135
+ with one avatar in it, and dials the world's `quo.` route. Three ways in,
136
+ one act: a link's invitation, joined at once; a guest form on the public
137
+ being's page whose answer is an invitation; and, when the desk is at the
138
+ door, the dock's own way, the owner password to `web./<ward>/login` for a
139
+ nonce, and the desk's `device` form submitted with it under the `tab` proof
140
+ kind. A tab opened with the owner password reaches what she holds, since the
141
+ owner opened it. From then on there is no cookie and no token anywhere, and
85
142
  a reload asks nothing: the seed and the standing are in the tab's store.
86
- Live on the lab at `web.lab.quo.systems/tab`.
143
+ Live on the lab at `web.lab.quo.systems/main`.
package/human/screen.ts CHANGED
@@ -8,9 +8,13 @@
8
8
  // moved. A push is appended and shown the moment it lands.
9
9
  import { digest } from '@quo-systems/quo';
10
10
  import type { Blueprint, JsonObject } from '@quo-systems/quo';
11
- import type { Avatar } from '../beings/avatar.ts';
12
- import { word, type Serving } from '../beings/side.ts';
13
- import { page, values, type Model, type Raw } from './html.ts';
11
+ import { word, type Serving, type Subject } from '../beings/side.ts';
12
+ import { isInvitation } from '../beings/link.ts';
13
+ import type { Invitation } from '@quo-systems/quo';
14
+ import { page, values, hintFor, type Model, type Raw } from './html.ts';
15
+ import { sanitise } from '../beings/look.ts';
16
+ import { isSilence, isWord } from '@quo-systems/quo';
17
+ import type { Json } from '@quo-systems/quo';
14
18
 
15
19
  export type Surface = {
16
20
  show(html: string): void;
@@ -20,9 +24,14 @@ export type Surface = {
20
24
  };
21
25
 
22
26
  // `after` runs when a call is done, as it does for the model side: a harbor
23
- // that must write what the ward changed hooks it.
24
- export async function screenSide(avatar: Avatar, surface: Surface, after: () => Promise<void> = async () => {}, notice = ''): Promise<Serving & { model: Model; refresh(): Promise<void> }> {
25
- const model: Model = { blueprint: null, notice, answers: {}, pushes: [] };
27
+ // that must write what the ward changed hooks it. `admit` is what to do
28
+ // with an answer that is an invitation: a guest at a world's door is let in
29
+ // by it, and a side with no `admit` shows it as any answer.
30
+ export type Options = { after?: () => Promise<void>; notice?: string; admit?: (invitation: Invitation) => Promise<void> };
31
+ export async function screenSide(avatar: Subject, surface: Surface, options: Options = {}): Promise<Serving & { model: Model; refresh(): Promise<void> }> {
32
+ const after = options.after ?? (async () => {});
33
+ const notice = options.notice ?? '';
34
+ const model: Model = { blueprint: null, look: {}, notice, answers: {}, pushes: [] };
26
35
  let seen: string | null = null;
27
36
  const show = () => surface.show(page(model));
28
37
 
@@ -39,6 +48,16 @@ export async function screenSide(avatar: Avatar, surface: Surface, after: () =>
39
48
  seen = d;
40
49
  model.blueprint = bp as Blueprint;
41
50
  for (const k of Object.keys(model.answers)) if (!(bp as Blueprint).asks.some((a) => a.name === k)) delete model.answers[k];
51
+ // her look, once per digest: how she wants her page painted, and what each ask is
52
+ if ((bp as Blueprint).asks.some((a) => a.name === 'look')) {
53
+ const l = await avatar.call('look');
54
+ model.look = isSilence(l) || isWord(l) ? {} : sanitise(l as Json);
55
+ } else model.look = {};
56
+ // a read-only ask that needs nothing typed is run on her behalf, so the page opens with what it shows
57
+ for (const a of (bp as Blueprint).asks) {
58
+ const needs = ((a.input as { required?: string[] }).required ?? []).length > 0;
59
+ if (hintFor(bp as Blueprint, model.look, a.name).readOnly && !needs && !(a.name in model.answers)) model.answers[a.name] = word(await avatar.call(a.name, {}));
60
+ }
42
61
  }
43
62
  }
44
63
  show();
@@ -53,8 +72,10 @@ export async function screenSide(avatar: Avatar, surface: Surface, after: () =>
53
72
  model.answers[name] = { word: 'error', value: { error: v.error } };
54
73
  return show();
55
74
  }
56
- model.answers[name] = word(await avatar.call(name, v.args));
75
+ const out = await avatar.call(name, v.args);
57
76
  await after();
77
+ if (options.admit && isInvitation(out)) return options.admit(out);
78
+ model.answers[name] = word(out);
58
79
  await refresh();
59
80
  };
60
81
 
package/human/tab.ts CHANGED
@@ -1,23 +1,58 @@
1
1
  // SPDX-License-Identifier: Apache-2.0
2
- // The tab: a page in a tab that is a world. The tab boots a browser harbor
3
- // on a seed minted into IndexedDB the first time, a ward with one avatar in
4
- // it, and dials the world's quo. route. The exchange, once: the owner
5
- // password goes to the web route, which mints a nonce the desk honours
6
- // under the `tab` proof kind; the avatar in the tab knocks the desk over the
7
- // socket with it, joins the user being, and from then on there is no cookie
8
- // and no token anywhere. The second time the page opens, she already holds
9
- // `user` and nothing is asked.
2
+ // The tab: a page on a world's web. that is a device of its own. The tab
3
+ // boots a browser harbor on a seed minted into IndexedDB the first time,
4
+ // and keeps one ward per world it has joined, each with one avatar, so the
5
+ // tab is a browser of worlds: a switcher between them is a switch of ward
6
+ // and nothing more. It dials the world's quo. route.
10
7
  //
11
- // The screen itself is the human side in `screen.ts` over the DOM surface
12
- // in `dom.ts`: her describe as forms, an answer as a view, a push on the
13
- // page. This file is the harbor and the exchange, and hands her over.
14
- import type { Invitation } from '@quo-systems/quo';
8
+ // Three ways in, all the same act, a knock that ends in a standing:
9
+ //
10
+ // a link the page with the invitation in its fragment, `#quo=...`,
11
+ // read and stripped before anything else runs, then joined.
12
+ // a guest the world's public being, whatever class she is, rendered
13
+ // as a page by the same screen; a form on it whose answer is
14
+ // 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
+ //
20
+ // From then on there is no cookie and no token anywhere; the seed and the
21
+ // standing are in the tab's store, and a reload asks nothing. The screen
22
+ // is the human side in `screen.ts` over the DOM surface in `dom.ts`. This
23
+ // file is the harbor, the worlds and the ways in, and hands the avatar over.
24
+ import type { Invitation, JsonObject } from '@quo-systems/quo';
15
25
  import { BrowserHarbor } from '../harbor/browser.ts';
16
26
  import { Avatar, USER } from '../beings/avatar.ts';
27
+ import { parse, strip, isInvitation } from '../beings/link.ts';
17
28
  import { screenSide } from './screen.ts';
18
29
  import { domSurface } from './dom.ts';
30
+ import { guest } from './guest.ts';
31
+ import { escape } from './html.ts';
32
+
33
+ // What the page is told by the daemon that served it: the world's routes,
34
+ // this ward's name on that harbor, and its pk.
35
+ export type Config = { quo: string; web: string; ward: string; pk: string };
19
36
 
20
- export type Config = { quo: string; web: string };
37
+ // The worlds this tab has joined, by pk: where each lives and what it is
38
+ // called, kept beside the harbor so the switcher can list them. A world on
39
+ // another origin is another page, and the switcher links to it.
40
+ type Worlds = Record<string, { url: string; name: string }>;
41
+ const WORLDS = 'quo-worlds';
42
+ const worlds = (): Worlds => {
43
+ try {
44
+ return JSON.parse(localStorage.getItem(WORLDS) ?? '{}') as Worlds;
45
+ } catch {
46
+ return {};
47
+ }
48
+ };
49
+ const remember = (pk: string, url: string, name: string) => {
50
+ try {
51
+ localStorage.setItem(WORLDS, JSON.stringify({ ...worlds(), [pk]: { url, name } }));
52
+ } catch {
53
+ /* a tab with no storage still works, and forgets */
54
+ }
55
+ };
21
56
 
22
57
  const el = <K extends keyof HTMLElementTagNameMap>(tag: K, text = '', attrs: Record<string, string> = {}): HTMLElementTagNameMap[K] => {
23
58
  const e = document.createElement(tag);
@@ -27,58 +62,125 @@ const el = <K extends keyof HTMLElementTagNameMap>(tag: K, text = '', attrs: Rec
27
62
  };
28
63
 
29
64
  export async function start(cfg: Config, root: HTMLElement = document.body): Promise<void> {
65
+ // The link, first and once: the invitation leaves the address bar before
66
+ // any other code sees it.
67
+ const linked = parse(location.hash);
68
+ if (linked) history.replaceState(null, '', location.pathname + location.search + strip(location.hash));
69
+
70
+ const nav = el('nav', '', { class: 'worlds' });
30
71
  const status = el('p', 'booting');
31
72
  const screen = el('div');
32
- root.append(status, screen);
73
+ root.append(nav, status, screen);
33
74
  const say = (s: string) => (status.textContent = s);
34
75
 
35
- // The harbor in the tab: one database, one ward, one avatar, for good.
76
+ // The harbor in the tab: one database per origin, one ward per world.
36
77
  const harbor = new BrowserHarbor('quo');
37
78
  await harbor.boot();
38
- const main = harbor.wards.get('main') ?? (await harbor.create('main', 'me'));
39
- if (!main.being('me')) await main.ask('boot', { key: 'me', class: 'Avatar' });
40
- const me = main.being('me') as Avatar;
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;
41
83
  harbor.dial(cfg.quo);
42
84
 
43
- // The screen: every call rotates her keys and a same-ward ask never
85
+ const switcher = () => {
86
+ nav.replaceChildren();
87
+ const known = worlds();
88
+ for (const [pk, w] of Object.entries(known)) {
89
+ const a = el('a', w.name || pk.slice(0, 8), { href: w.url });
90
+ if (pk === cfg.pk) a.setAttribute('aria-current', 'page');
91
+ nav.append(a);
92
+ }
93
+ const here = worlds()[cfg.pk];
94
+ if (!here) nav.append(el('span', `${cfg.ward} at ${new URL(cfg.web).host}`));
95
+ };
96
+ switcher();
97
+
98
+ // Joined: her page. Every call rotates her keys and a same-ward ask never
44
99
  // crosses the harbor, so the side saves after each one.
45
- const show = async (notice: string) => {
100
+ const inside = async (notice: string) => {
46
101
  status.remove();
47
- await screenSide(me, domSurface(screen), () => main.save(), notice);
102
+ screen.replaceChildren();
103
+ const side = await screenSide(me, domSurface(screen), { after: () => ward.save(), notice });
104
+ remember(cfg.pk, location.origin + location.pathname, side.model.look.name || (typeof (side.model.blueprint?.notes as JsonObject | null)?.name === 'string' ? ((side.model.blueprint!.notes as JsonObject).name as string) : cfg.ward));
105
+ switcher();
48
106
  };
49
107
 
50
- // Already in: reconnect, nothing minted.
51
- const had = await me.tools();
52
- if ('asks' in had) return show('in, as before');
53
-
54
- // The exchange, once: the owner password for a nonce, the nonce for an
55
- // invitation the user being mints, the invitation for a standing.
56
- const form = el('form');
57
- const identity = el('input', '', { name: 'identity', value: 'tab', placeholder: 'identity' });
58
- const password = el('input', '', { name: 'password', type: 'password', placeholder: 'owner password' });
59
- const go = el('button', 'enter');
60
- form.append(identity, password, go);
61
- root.insertBefore(form, screen);
62
- say(`not in (${had.error}): the owner password opens the world`);
63
- form.onsubmit = async (ev) => {
64
- ev.preventDefault();
65
- say('asking the world');
66
- let res: Response;
67
- try {
68
- res = await fetch(`${cfg.web}/tab/login`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ password: password.value, identity: identity.value }) });
69
- } catch (e) {
70
- return say(`the world did not answer: ${(e as Error).message}`);
71
- }
72
- if (!res.ok) return say(`refused: ${(await res.json().catch(() => ({ error: res.status })) as { error?: string }).error ?? res.status}`);
73
- const { nonce, ward } = (await res.json()) as { nonce: string; ward: string };
74
- password.value = '';
108
+ // The way in, from any of the three: join, save, and be inside.
109
+ const admit = async (inv: Invitation, notice: string) => {
75
110
  say('knocking');
76
- const got = await me.enter({ ward } as Invitation, { kind: 'tab', nonce });
77
- await main.save(); // the knock went through her own door, which the harbor never sees
78
- if (!('asks' in got)) return say(`not in: ${got.error}`);
79
- form.remove();
80
- await show(`in, as ${identity.value}`);
111
+ const got = await me.join(inv);
112
+ await ward.save(); // the knock went through her own door, which the harbor never sees
113
+ if (!('asks' in got)) {
114
+ say(`not in: ${got.error}`);
115
+ return;
116
+ }
117
+ await inside(notice);
81
118
  };
119
+
120
+ // Already in: reconnect, nothing minted.
121
+ if (me.standings[USER]) {
122
+ const had = await me.tools();
123
+ if ('asks' in had) return inside('in, as before');
124
+ say(`not in (${had.error})`);
125
+ }
126
+ if (linked) return admit(linked, 'in, by the link');
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
+ }
82
179
  }
83
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) void start(JSON.parse(config.textContent) as Config);
185
+
84
186
  export { USER };