@nervur-org/dock 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (285) hide show
  1. package/GETTING_STARTED.md +128 -0
  2. package/LICENSE +202 -0
  3. package/NOTICE +6 -0
  4. package/README.md +37 -0
  5. package/api/quo-api.md +196 -0
  6. package/api/route.ts +227 -0
  7. package/beings/GLOSSARY.md +118 -0
  8. package/beings/WORLDS.md +567 -0
  9. package/beings/avatar.ts +101 -0
  10. package/beings/carry.ts +159 -0
  11. package/beings/clock.ts +552 -0
  12. package/beings/courier.ts +107 -0
  13. package/beings/desk.ts +128 -0
  14. package/beings/doorbell.ts +129 -0
  15. package/beings/envoy.ts +218 -0
  16. package/beings/index.ts +16 -0
  17. package/beings/join.ts +51 -0
  18. package/beings/link.ts +104 -0
  19. package/beings/look.ts +139 -0
  20. package/beings/porter.ts +43 -0
  21. package/beings/presence.ts +39 -0
  22. package/beings/quo-app-bells.md +280 -0
  23. package/beings/quo-dock.md +1942 -0
  24. package/beings/setup.ts +45 -0
  25. package/beings/side.ts +60 -0
  26. package/beings/user.ts +459 -0
  27. package/beings/way.ts +65 -0
  28. package/cli/bundles.ts +20 -0
  29. package/cli/client.ts +73 -0
  30. package/cli/daemon.ts +581 -0
  31. package/cli/estate/Caddyfile +33 -0
  32. package/cli/estate/quo.service +35 -0
  33. package/cli/estate/systems.quo.plist +44 -0
  34. package/cli/estate.ts +48 -0
  35. package/cli/folder.ts +27 -0
  36. package/cli/oauth.ts +32 -0
  37. package/cli/pilot.ts +38 -0
  38. package/cli/quo-dock.md +1942 -0
  39. package/cli/quo.ts +203 -0
  40. package/cli/stand.ts +160 -0
  41. package/dist/api/route.d.ts +21 -0
  42. package/dist/api/route.js +193 -0
  43. package/dist/beings/avatar.d.ts +32 -0
  44. package/dist/beings/avatar.js +100 -0
  45. package/dist/beings/carry.d.ts +12 -0
  46. package/dist/beings/carry.js +174 -0
  47. package/dist/beings/clock.d.ts +161 -0
  48. package/dist/beings/clock.js +528 -0
  49. package/dist/beings/courier.d.ts +50 -0
  50. package/dist/beings/courier.js +96 -0
  51. package/dist/beings/desk.d.ts +53 -0
  52. package/dist/beings/desk.js +135 -0
  53. package/dist/beings/doorbell.d.ts +78 -0
  54. package/dist/beings/doorbell.js +133 -0
  55. package/dist/beings/envoy.d.ts +54 -0
  56. package/dist/beings/envoy.js +211 -0
  57. package/dist/beings/index.d.ts +14 -0
  58. package/dist/beings/index.js +16 -0
  59. package/dist/beings/join.d.ts +15 -0
  60. package/dist/beings/join.js +42 -0
  61. package/dist/beings/link.d.ts +14 -0
  62. package/dist/beings/link.js +91 -0
  63. package/dist/beings/look.d.ts +30 -0
  64. package/dist/beings/look.js +104 -0
  65. package/dist/beings/porter.d.ts +14 -0
  66. package/dist/beings/porter.js +41 -0
  67. package/dist/beings/presence.d.ts +8 -0
  68. package/dist/beings/presence.js +21 -0
  69. package/dist/beings/setup.d.ts +3 -0
  70. package/dist/beings/setup.js +39 -0
  71. package/dist/beings/side.d.ts +32 -0
  72. package/dist/beings/side.js +37 -0
  73. package/dist/beings/user.d.ts +315 -0
  74. package/dist/beings/user.js +462 -0
  75. package/dist/beings/way.d.ts +19 -0
  76. package/dist/beings/way.js +28 -0
  77. package/dist/cli/bundles.d.ts +5 -0
  78. package/dist/cli/bundles.js +17 -0
  79. package/dist/cli/client.d.ts +15 -0
  80. package/dist/cli/client.js +58 -0
  81. package/dist/cli/daemon.d.ts +50 -0
  82. package/dist/cli/daemon.js +584 -0
  83. package/dist/cli/estate/Caddyfile +33 -0
  84. package/dist/cli/estate/quo.service +35 -0
  85. package/dist/cli/estate/systems.quo.plist +44 -0
  86. package/dist/cli/estate.d.ts +5 -0
  87. package/dist/cli/estate.js +50 -0
  88. package/dist/cli/folder.d.ts +2 -0
  89. package/dist/cli/folder.js +25 -0
  90. package/dist/cli/oauth.d.ts +2 -0
  91. package/dist/cli/oauth.js +32 -0
  92. package/dist/cli/pilot.d.ts +5 -0
  93. package/dist/cli/pilot.js +22 -0
  94. package/dist/cli/quo.d.ts +2 -0
  95. package/dist/cli/quo.js +201 -0
  96. package/dist/cli/stand.d.ts +37 -0
  97. package/dist/cli/stand.js +110 -0
  98. package/dist/dock/agents.d.ts +102 -0
  99. package/dist/dock/agents.js +105 -0
  100. package/dist/dock/apns.d.ts +27 -0
  101. package/dist/dock/apns.js +98 -0
  102. package/dist/dock/console.d.ts +6 -0
  103. package/dist/dock/console.js +20 -0
  104. package/dist/dock/dialers.d.ts +56 -0
  105. package/dist/dock/dialers.js +75 -0
  106. package/dist/dock/faculty.d.ts +24 -0
  107. package/dist/dock/faculty.js +142 -0
  108. package/dist/dock/fcm.d.ts +13 -0
  109. package/dist/dock/fcm.js +77 -0
  110. package/dist/dock/grants.d.ts +41 -0
  111. package/dist/dock/grants.js +99 -0
  112. package/dist/dock/http2.d.ts +4 -0
  113. package/dist/dock/http2.js +68 -0
  114. package/dist/dock/index.d.ts +41 -0
  115. package/dist/dock/index.js +113 -0
  116. package/dist/dock/local.d.ts +13 -0
  117. package/dist/dock/local.js +44 -0
  118. package/dist/dock/location.d.ts +19 -0
  119. package/dist/dock/location.js +11 -0
  120. package/dist/dock/notifications.d.ts +38 -0
  121. package/dist/dock/notifications.js +60 -0
  122. package/dist/dock/push.d.ts +79 -0
  123. package/dist/dock/push.js +132 -0
  124. package/dist/dock/service.d.ts +4 -0
  125. package/dist/dock/service.js +107 -0
  126. package/dist/dock/timer.d.ts +52 -0
  127. package/dist/dock/timer.js +149 -0
  128. package/dist/dock/webpush.d.ts +12 -0
  129. package/dist/dock/webpush.js +144 -0
  130. package/dist/harbor/browser.d.ts +15 -0
  131. package/dist/harbor/browser.js +82 -0
  132. package/dist/harbor/capacitor.d.ts +28 -0
  133. package/dist/harbor/capacitor.js +218 -0
  134. package/dist/harbor/disk.d.ts +27 -0
  135. package/dist/harbor/disk.js +249 -0
  136. package/dist/harbor/edge/alarm.d.ts +10 -0
  137. package/dist/harbor/edge/alarm.js +38 -0
  138. package/dist/harbor/edge/edge.d.ts +54 -0
  139. package/dist/harbor/edge/edge.js +370 -0
  140. package/dist/harbor/edge/exercise.d.ts +7 -0
  141. package/dist/harbor/edge/exercise.js +232 -0
  142. package/dist/harbor/edge/given.d.ts +26 -0
  143. package/dist/harbor/edge/given.js +42 -0
  144. package/dist/harbor/edge/platform.d.ts +39 -0
  145. package/dist/harbor/edge/storage.d.ts +30 -0
  146. package/dist/harbor/edge/storage.js +180 -0
  147. package/dist/harbor/edge/worker.d.ts +17 -0
  148. package/dist/harbor/edge/worker.js +67 -0
  149. package/dist/harbor/files.d.ts +15 -0
  150. package/dist/harbor/files.js +220 -0
  151. package/dist/harbor/http.d.ts +19 -0
  152. package/dist/harbor/http.js +177 -0
  153. package/dist/harbor/idb.d.ts +15 -0
  154. package/dist/harbor/idb.js +0 -0
  155. package/dist/harbor/quo.d.ts +10 -0
  156. package/dist/harbor/quo.js +42 -0
  157. package/dist/harbor/resolve.d.ts +16 -0
  158. package/dist/harbor/resolve.js +17 -0
  159. package/dist/harbor/seal.d.ts +3 -0
  160. package/dist/harbor/seal.js +25 -0
  161. package/dist/harbor/sealed.d.ts +35 -0
  162. package/dist/harbor/sealed.js +162 -0
  163. package/dist/harbor/tauri.d.ts +16 -0
  164. package/dist/harbor/tauri.js +59 -0
  165. package/dist/harbor/ward.d.ts +9 -0
  166. package/dist/harbor/ward.js +15 -0
  167. package/dist/human/app.d.ts +32 -0
  168. package/dist/human/app.js +527 -0
  169. package/dist/human/apps.d.ts +10 -0
  170. package/dist/human/apps.js +29 -0
  171. package/dist/human/dom.d.ts +2 -0
  172. package/dist/human/dom.js +15 -0
  173. package/dist/human/door.d.ts +19 -0
  174. package/dist/human/door.js +92 -0
  175. package/dist/human/guest.d.ts +3 -0
  176. package/dist/human/guest.js +33 -0
  177. package/dist/human/html.d.ts +55 -0
  178. package/dist/human/html.js +451 -0
  179. package/dist/human/local.d.ts +7 -0
  180. package/dist/human/local.js +10 -0
  181. package/dist/human/mark.d.ts +8 -0
  182. package/dist/human/mark.js +47 -0
  183. package/dist/human/move.d.ts +17 -0
  184. package/dist/human/move.js +90 -0
  185. package/dist/human/screen.d.ts +19 -0
  186. package/dist/human/screen.js +119 -0
  187. package/dist/human/style.d.ts +2 -0
  188. package/dist/human/style.js +60 -0
  189. package/dist/human/tab.bundle.txt +4775 -0
  190. package/dist/human/tab.d.ts +15 -0
  191. package/dist/human/tab.js +409 -0
  192. package/dist/human/tree.d.ts +50 -0
  193. package/dist/human/tree.js +157 -0
  194. package/dist/human/wake.d.ts +34 -0
  195. package/dist/human/wake.js +179 -0
  196. package/dist/human/web.d.ts +33 -0
  197. package/dist/human/web.js +184 -0
  198. package/dist/human/worker.bundle.txt +3868 -0
  199. package/dist/human/worker.d.ts +1 -0
  200. package/dist/human/worker.js +81 -0
  201. package/dist/human/worlds.d.ts +24 -0
  202. package/dist/human/worlds.js +99 -0
  203. package/dist/mcp/agent.d.ts +10 -0
  204. package/dist/mcp/agent.js +78 -0
  205. package/dist/mcp/allow.d.ts +20 -0
  206. package/dist/mcp/allow.js +88 -0
  207. package/dist/mcp/http.d.ts +27 -0
  208. package/dist/mcp/http.js +92 -0
  209. package/dist/mcp/oauth.d.ts +91 -0
  210. package/dist/mcp/oauth.js +241 -0
  211. package/dist/mcp/route.d.ts +15 -0
  212. package/dist/mcp/route.js +42 -0
  213. package/dist/mcp/runner.d.ts +45 -0
  214. package/dist/mcp/runner.js +99 -0
  215. package/dist/mcp/server.d.ts +11 -0
  216. package/dist/mcp/server.js +102 -0
  217. package/dock/agents.ts +121 -0
  218. package/dock/apns.ts +138 -0
  219. package/dock/console.ts +23 -0
  220. package/dock/dialers.ts +85 -0
  221. package/dock/faculty.ts +148 -0
  222. package/dock/fcm.ts +103 -0
  223. package/dock/grants.ts +111 -0
  224. package/dock/http2.ts +70 -0
  225. package/dock/index.ts +133 -0
  226. package/dock/local.ts +48 -0
  227. package/dock/location.ts +22 -0
  228. package/dock/notifications.ts +67 -0
  229. package/dock/push.ts +161 -0
  230. package/dock/quo-app-bells.md +280 -0
  231. package/dock/quo-dock.md +1942 -0
  232. package/dock/service.ts +100 -0
  233. package/dock/timer.ts +174 -0
  234. package/dock/webpush.ts +182 -0
  235. package/harbor/browser.ts +97 -0
  236. package/harbor/capacitor.ts +223 -0
  237. package/harbor/disk.ts +237 -0
  238. package/harbor/edge/alarm.ts +48 -0
  239. package/harbor/edge/edge.ts +407 -0
  240. package/harbor/edge/exercise.ts +241 -0
  241. package/harbor/edge/given.ts +106 -0
  242. package/harbor/edge/platform.d.ts +39 -0
  243. package/harbor/edge/storage.ts +179 -0
  244. package/harbor/edge/text.d.ts +15 -0
  245. package/harbor/edge/worker.ts +70 -0
  246. package/harbor/edge/wrangler.toml +30 -0
  247. package/harbor/files.ts +221 -0
  248. package/harbor/http.ts +188 -0
  249. package/harbor/idb.ts +0 -0
  250. package/harbor/quo-harbor.md +850 -0
  251. package/harbor/quo.ts +44 -0
  252. package/harbor/resolve.ts +31 -0
  253. package/harbor/seal.ts +26 -0
  254. package/harbor/sealed.ts +183 -0
  255. package/harbor/tauri.ts +79 -0
  256. package/harbor/ward.ts +28 -0
  257. package/human/app.ts +580 -0
  258. package/human/apps.ts +47 -0
  259. package/human/dom.ts +21 -0
  260. package/human/door.ts +113 -0
  261. package/human/guest.ts +34 -0
  262. package/human/html.ts +490 -0
  263. package/human/local.ts +30 -0
  264. package/human/mark.ts +53 -0
  265. package/human/move.ts +122 -0
  266. package/human/quo-human.md +1042 -0
  267. package/human/screen.ts +136 -0
  268. package/human/style.ts +63 -0
  269. package/human/tab.bundle.txt +4775 -0
  270. package/human/tab.ts +446 -0
  271. package/human/tree.ts +217 -0
  272. package/human/wake.ts +191 -0
  273. package/human/web.ts +259 -0
  274. package/human/worker.bundle.txt +3868 -0
  275. package/human/worker.ts +124 -0
  276. package/human/worlds.ts +127 -0
  277. package/mcp/agent.ts +94 -0
  278. package/mcp/allow.ts +131 -0
  279. package/mcp/http.ts +92 -0
  280. package/mcp/oauth.ts +270 -0
  281. package/mcp/quo-mcp.md +343 -0
  282. package/mcp/route.ts +45 -0
  283. package/mcp/runner.ts +136 -0
  284. package/mcp/server.ts +101 -0
  285. package/package.json +121 -0
@@ -0,0 +1,15 @@
1
+ import { USER } from '../beings/avatar.ts';
2
+ export type Config = {
3
+ quo: string;
4
+ web: string;
5
+ wards: Record<string, {
6
+ pk: string;
7
+ public: boolean;
8
+ }>;
9
+ ward?: string;
10
+ being?: string;
11
+ beings?: boolean;
12
+ bell?: string;
13
+ };
14
+ export declare function start(cfg: Config, root?: HTMLElement): Promise<void>;
15
+ export { USER };
@@ -0,0 +1,409 @@
1
+ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
2
+ if (typeof path === "string" && /^\.\.?\//.test(path)) {
3
+ return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) {
4
+ return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : (d + ext + "." + cm.toLowerCase() + "js");
5
+ });
6
+ }
7
+ return path;
8
+ };
9
+ import { BrowserHarbor } from '../harbor/browser.js';
10
+ import { USER } from '../beings/avatar.js';
11
+ import { isInvitation } from '@nervur-org/nervur';
12
+ import { parse, parseProof, strip } from '../beings/link.js';
13
+ import { giveWorld } from './move.js';
14
+ import { drained, subscribe } from './wake.js';
15
+ import { screenSide } from './screen.js';
16
+ import { refused } from './html.js';
17
+ import { domSurface } from './dom.js';
18
+ import { guest } from './guest.js';
19
+ import { door, held } from './door.js';
20
+ import { world, relations, fresh, localName } from './worlds.js';
21
+ import { local } from './local.js';
22
+ const kept = (key, fallback) => {
23
+ try {
24
+ return JSON.parse(localStorage.getItem(key) ?? 'null') ?? fallback;
25
+ }
26
+ catch {
27
+ return fallback;
28
+ }
29
+ };
30
+ const keep = (key, value) => {
31
+ try {
32
+ localStorage.setItem(key, JSON.stringify(value));
33
+ }
34
+ catch {
35
+ /* forgets */
36
+ }
37
+ };
38
+ const WORLDS = 'quo-worlds';
39
+ const worlds = () => kept(WORLDS, {});
40
+ const remember = (pk, url, name) => keep(WORLDS, { ...worlds(), [pk]: { url, name } });
41
+ const AT = (pk) => `quo-at:${pk}`;
42
+ const NAMES = (pk) => `quo-names:${pk}`;
43
+ const el = (tag, text = '', attrs = {}) => {
44
+ const e = document.createElement(tag);
45
+ if (text)
46
+ e.textContent = text;
47
+ for (const [k, v] of Object.entries(attrs))
48
+ e.setAttribute(k, v);
49
+ return e;
50
+ };
51
+ export async function start(cfg, root = document.body) {
52
+ // The link, first and once: the invitation, or the proof a world sent
53
+ // out, leaves the address bar before any other code sees it.
54
+ const linked = parse(location.hash);
55
+ const proved = parseProof(location.hash);
56
+ if (linked || proved)
57
+ history.replaceState(null, '', location.pathname + location.search + strip(location.hash));
58
+ const nav = el('nav', '', { class: 'worlds nv-strip' });
59
+ const who = el('nav', '', { class: 'relations nv-row' });
60
+ const status = el('p', 'booting', { class: 'state nv-t-quiet' });
61
+ const screen = el('div');
62
+ root.append(nav, who, status, screen);
63
+ const say = (s) => (status.textContent = s);
64
+ // The harbor in the tab: one database per origin, one ward per world.
65
+ // The world's code first, when its origin serves any: the classes for
66
+ // the tab, handed to the harbor beside the built-in ones before any ward
67
+ // boots, since a being kept from the last visit is booted by class name.
68
+ // A world that serves none has no beings in the tab, and the page is the
69
+ // far being's alone.
70
+ const harbor = new BrowserHarbor('quo');
71
+ const classes = cfg.beings
72
+ ? await import(__rewriteRelativeImportExtension(`${cfg.web}/beings.js`)).then((mod) => {
73
+ const out = {};
74
+ for (const [name, v] of Object.entries(mod))
75
+ if (typeof v === 'function' && 'prototype' in v)
76
+ out[name] = v;
77
+ return out;
78
+ })
79
+ : {};
80
+ Object.assign(harbor.classes, classes);
81
+ // A device is one harbor, and this browser may already be holding it in
82
+ // another tab. That tab is the device; this one is a page, and it says
83
+ // so here rather than sitting on the word `booting` with the refusal in a
84
+ // console nobody has open.
85
+ try {
86
+ await harbor.boot();
87
+ }
88
+ catch (e) {
89
+ status.remove();
90
+ screen.innerHTML = held({ world: cfg.ward, host: new URL(cfg.web).host });
91
+ throw e; // the terrain still hears it: a page that says so is not a page that succeeded
92
+ }
93
+ harbor.dial(cfg.quo);
94
+ // The doorbell. A world with a bell key is a world that can wake this
95
+ // device when it is closed, and the ear is the service worker on this
96
+ // origin; a device already subscribed says so again silently, and one
97
+ // that has never been asked is offered a button rather than a prompt on
98
+ // arrival. The ring arrives at the worker: with this page open the worker
99
+ // hands it a port, and the page wakes its dialers, drains and answers
100
+ // what it found, since the harbor is held here and nowhere else.
101
+ const pks = Object.values(cfg.wards).map((w) => w.pk);
102
+ let bell = cfg.bell ? await subscribe(cfg.web, cfg.bell) : null;
103
+ globalThis.navigator?.serviceWorker?.addEventListener('message', (e) => {
104
+ if (e.data?.quo !== 'wake')
105
+ return;
106
+ const port = e.ports[0];
107
+ void (async () => {
108
+ harbor.wake();
109
+ port?.postMessage({ missed: await drained(harbor, pks) });
110
+ })();
111
+ });
112
+ // The world this page is about: the link's, since an invitation names
113
+ // its ward, or the page's own. A link for a world not on this harbor is
114
+ // a link to the wrong page, and says so.
115
+ const nameOf = (pk) => Object.keys(cfg.wards).find((n) => cfg.wards[n].pk === pk);
116
+ const pk = linked?.ward ?? (cfg.ward ? cfg.wards[cfg.ward]?.pk : undefined);
117
+ const wardName = pk ? nameOf(pk) : undefined;
118
+ const pageOf = (name) => `${new URL(cfg.web).pathname.replace(/\/$/, '')}/${encodeURIComponent(name)}`;
119
+ // The same page, whole. What leaves this tab, a remembered world or a link
120
+ // for another device, carries the address and not the path.
121
+ const addressOf = (name) => location.origin + pageOf(name);
122
+ const switcher = () => {
123
+ nav.replaceChildren();
124
+ const known = worlds();
125
+ for (const [k, w] of Object.entries(known)) {
126
+ const a = el('a', w.name || k.slice(0, 8), { href: w.url, class: 'nv-chip' });
127
+ if (k === pk)
128
+ a.setAttribute('aria-current', 'page');
129
+ nav.append(a);
130
+ }
131
+ // a world this device is not in yet is named by its own page, not by the strip
132
+ if (!nav.childElementCount)
133
+ nav.remove();
134
+ else if (!nav.isConnected)
135
+ root.insertBefore(nav, root.firstChild);
136
+ };
137
+ switcher();
138
+ if (!pk || !wardName) {
139
+ say(linked ? 'this link is for a world that does not live here' : 'choose a world');
140
+ return;
141
+ }
142
+ if (linked && location.pathname !== pageOf(wardName))
143
+ history.replaceState(null, '', pageOf(wardName) + location.search + location.hash);
144
+ const ward = await world(harbor, pk);
145
+ const at = { name: wardName, pk, public: cfg.wards[wardName].public };
146
+ // The relations in this world, and which one is on screen.
147
+ let side = null;
148
+ let current = null;
149
+ const names = () => kept(NAMES(pk), {});
150
+ const people = () => {
151
+ who.replaceChildren();
152
+ const rels = relations(ward);
153
+ const called = names();
154
+ for (const r of rels) {
155
+ const b = el('button', called[r.key] ?? r.key, { type: 'button', class: 'nv-chip' });
156
+ if (r.key === current?.key)
157
+ b.setAttribute('aria-current', 'true');
158
+ b.onclick = () => void inside(r, `in, as ${called[r.key] ?? r.key}`, called[r.key] ?? r.key);
159
+ who.append(b);
160
+ }
161
+ if (rels.length) {
162
+ const more = el('button', 'another way in', { type: 'button', class: 'another nv-chip' });
163
+ more.onclick = () => void atDoor();
164
+ who.append(more);
165
+ }
166
+ moving.hidden = rels.length === 0; // there is nothing to move out of a world this device is not in
167
+ };
168
+ // The world's beings in the tab, for one relation: one of each class,
169
+ // booted under her key the first time and found there after, since their
170
+ // cells are in the ward and come back with it. Each is a subject of her
171
+ // own with her own side in her own section, so a note she keeps and a
172
+ // form she shows are hers, painted by the same painter as the far page.
173
+ const locals = [];
174
+ const boot = async (rel) => {
175
+ for (const name of Object.keys(classes)) {
176
+ const key = `${rel.key}-${name}`;
177
+ // Booted the first time and found after. Boot refuses a key that has a
178
+ // row, so asking is how a caller learns she is already there, and a
179
+ // refusal is the same nothing-to-do as finding her.
180
+ if (!ward.being(key)) {
181
+ const out = (await ward.ask('boot', { key, class: name }));
182
+ if (out.error)
183
+ continue;
184
+ await ward.save();
185
+ }
186
+ if (!ward.being(key))
187
+ continue;
188
+ const section = el('section', '', { class: 'local', 'data-being': key });
189
+ screen.append(section);
190
+ // The ask pointer saves before it returns, so the side has nothing to do after one.
191
+ locals.push(await screenSide(local(ward, key), domSurface(section), { at: addressOf(at.name) }));
192
+ }
193
+ };
194
+ // In, as one relation: her page, then the world's beings for her. Every
195
+ // call rotates her keys and a same-ward ask never crosses the harbor, so
196
+ // the side saves after each.
197
+ const inside = async (rel, notice, called) => {
198
+ await side?.close();
199
+ for (const l of locals.splice(0))
200
+ await l.close();
201
+ status.remove();
202
+ root.querySelector('form.password')?.remove();
203
+ screen.replaceChildren();
204
+ current = rel;
205
+ keep(AT(pk), rel.key);
206
+ const mine = el('div', '', { class: 'far' });
207
+ screen.append(mine);
208
+ // the page's address: the world's page, or one being she carries as the whole page
209
+ const s = await screenSide(rel.avatar, domSurface(mine), {
210
+ after: () => ward.save(),
211
+ notice,
212
+ at: addressOf(at.name),
213
+ // A world that ended the relation is over, and the one act left is
214
+ // leaving. The tab says so and puts the way out where she is looking;
215
+ // it drops nothing, since one answer at one moment is never enough to
216
+ // delete a ward and the far world can never be asked to confirm it.
217
+ ended: async () => {
218
+ leave.textContent = 'leave this world';
219
+ leave.classList.add('only');
220
+ moving.scrollIntoView({ block: 'nearest' });
221
+ },
222
+ ...(cfg.being ? { focus: cfg.being } : {}),
223
+ });
224
+ side = s;
225
+ const bp = s.model.blueprint;
226
+ const notesName = typeof bp?.notes?.name === 'string' ? bp.notes.name : '';
227
+ remember(pk, addressOf(at.name), s.model.look.name || notesName || at.name);
228
+ if (!names()[rel.key])
229
+ keep(NAMES(pk), { ...names(), [rel.key]: called });
230
+ // How this world wakes this device, said again whenever a relation comes
231
+ // on screen: a push service may hand a browser a new endpoint at any
232
+ // time, and a stale one rings an empty house.
233
+ if (bell)
234
+ await rel.avatar.call('bell', { token: bell }).then(() => ward.save());
235
+ // The device is back, so it drains: whatever was pushed while this tab
236
+ // was closed is on her under this relation, and it lands on the avatar
237
+ // where a push would have landed. A tab with nothing waiting spends one
238
+ // ask and shows the same page.
239
+ await drained(harbor, [pk]);
240
+ switcher();
241
+ people();
242
+ if (!cfg.being)
243
+ await boot(rel); // a being's page is hers alone: the world's tab beings stay on the world's page
244
+ };
245
+ // The way in, from any of the three: a fresh avatar joins, the ward is
246
+ // saved, and she is the one on screen.
247
+ const admit = async (inv, notice, called) => {
248
+ say('knocking');
249
+ const rel = await fresh(ward);
250
+ const got = await rel.avatar.join(inv);
251
+ await ward.save(); // the knock went through her own door, which the harbor never sees
252
+ if (!('asks' in got)) {
253
+ say(`not in: ${got.error}`);
254
+ return false;
255
+ }
256
+ await inside(rel, notice, called);
257
+ return true;
258
+ };
259
+ // At the door. With a public being there, her describe as a page, and a
260
+ // form whose answer is an invitation lets the guest in. With nobody
261
+ // there, the door page: a link is the only way in, and nothing to type.
262
+ const atDoor = async () => {
263
+ await side?.close();
264
+ for (const l of locals.splice(0))
265
+ await l.close();
266
+ side = null;
267
+ current = null;
268
+ screen.replaceChildren();
269
+ if (!status.isConnected)
270
+ root.insertBefore(status, screen);
271
+ people();
272
+ if (!at.public) {
273
+ status.remove();
274
+ screen.innerHTML = door({ world: at.name, host: new URL(cfg.web).host });
275
+ return;
276
+ }
277
+ say(`at the door of ${at.name}`);
278
+ const rel = await fresh(ward);
279
+ const gate = guest(rel.avatar, pk);
280
+ const s = await screenSide(gate, domSurface(screen), {
281
+ after: () => ward.save(),
282
+ notice: `a guest at ${at.name}: what she shows strangers`,
283
+ at: addressOf(at.name),
284
+ admit: async (inv) => {
285
+ await s.close();
286
+ await admit(inv, `in, as a guest of ${at.name}`, 'guest');
287
+ },
288
+ });
289
+ side = s;
290
+ status.remove(); // her page is up and says where the human stands; the status was for the wait
291
+ };
292
+ // Moving this world to another device. What the tab holds of a world is
293
+ // one local ward, and every standing won here is in it, so the move is
294
+ // that ward and nothing else: the world is never asked and never told.
295
+ // The other device shows an invitation, the human carries it here, and
296
+ // this tab is out of the world the moment the other one has it.
297
+ // Leaving. What this tab holds of a world is one local ward, and every
298
+ // standing won here is in it, so leaving is that ward dropped whole, seed
299
+ // and all, and the world forgotten from the list. The far world is never
300
+ // asked and never told, as WORLDS has it of a lost phone: it sees one
301
+ // device gone and nothing else. Nothing here is recoverable afterwards, so
302
+ // it is one press and never a consequence of an answer: a world that says
303
+ // the relation ended offers this and does not take it.
304
+ const forget = async () => {
305
+ await side?.close();
306
+ for (const l of locals.splice(0))
307
+ await l.close();
308
+ side = null;
309
+ keep(WORLDS, Object.fromEntries(Object.entries(worlds()).filter(([k]) => k !== pk)));
310
+ localStorage.removeItem(AT(pk));
311
+ localStorage.removeItem(NAMES(pk));
312
+ };
313
+ const leave = el('button', 'leave this world', { type: 'button', class: 'leave nv-button', 'data-variant': 'danger' });
314
+ leave.onclick = () => void (async () => {
315
+ await forget();
316
+ await harbor.drop(localName(pk));
317
+ location.reload(); // at the door of a world this device is out of
318
+ })();
319
+ const moving = el('div', '', { class: 'move' });
320
+ const offer = el('p', '', { class: 'invitation' });
321
+ const said = el('p', '', { class: 'quiet' });
322
+ const give = el('button', 'move this world to another device', { type: 'button', class: 'nv-button', 'data-variant': 'quiet' });
323
+ // Being woken is asked for and never sprung: a page that asks a browser
324
+ // for notifications the moment it opens is a page every browser punishes,
325
+ // and a human who has not said yet is offered a button instead.
326
+ const doorbell = el('button', 'let this world wake me', { type: 'button', class: 'doorbell nv-button', 'data-variant': 'quiet' });
327
+ doorbell.hidden = !cfg.bell || bell !== null;
328
+ doorbell.onclick = () => void (async () => {
329
+ bell = cfg.bell ? await subscribe(cfg.web, cfg.bell, true) : null;
330
+ doorbell.hidden = bell !== null;
331
+ if (bell && current)
332
+ await current.avatar.call('bell', { token: bell }).then(() => ward.save());
333
+ })();
334
+ moving.append(give, leave, offer, said, doorbell);
335
+ let giving = null;
336
+ const stopGiving = async () => {
337
+ const g = giving;
338
+ giving = null;
339
+ offer.textContent = '';
340
+ give.textContent = 'move this world to another device';
341
+ await g?.close();
342
+ };
343
+ give.onclick = () => void (async () => {
344
+ if (giving)
345
+ return stopGiving();
346
+ said.textContent = '';
347
+ giving = await giveWorld(harbor, { pk, at: addressOf(at.name), name: at.name }, cfg.quo);
348
+ offer.textContent = giving.offer;
349
+ give.textContent = 'stop offering it';
350
+ void giving.moved.then(async () => {
351
+ // it is on the other device now, and this one is out of the world:
352
+ // the standings went with the ward and nothing here is in it
353
+ await forget(); // the ward went with the move, so nothing is dropped here
354
+ location.reload(); // the page reloads at the door of a world this device is out of
355
+ });
356
+ })().catch((e) => (said.textContent = String(e)));
357
+ root.append(moving);
358
+ // A proof: the world's own way in, spent at her door. A proof names no
359
+ // ward, so it is for the world this page is, and what she trades it for
360
+ // is an invitation, joined like any other.
361
+ //
362
+ // `device` is the ask, and that name is a convention of the dock's rather
363
+ // than the desk's alone: a proof arrives in a fragment and not as a form,
364
+ // so there is nothing to draw and a name is the only way to hand it over.
365
+ // A public being of any class takes proofs by writing that ask; one that
366
+ // writes none answers this as an unknown ask, which is a world with no
367
+ // proof way in and is refused like any other refusal. What a proof is worth is the
368
+ // way in's word and never the desk's: the desk routes it by kind, and the
369
+ // being who verifies it says who it is for, how far she may go and
370
+ // whether it may be spent again. A world with nobody at her door trades
371
+ // nothing, and a refusal of any of those kinds leaves the human at the
372
+ // door with nothing taken and one line saying so.
373
+ if (proved && at.public) {
374
+ say('at the door');
375
+ const rel = await fresh(ward);
376
+ const got = await rel.avatar.knock({ ward: pk }, 'device', { proof: { kind: proved.kind, token: proved.token } });
377
+ await ward.save();
378
+ if (isInvitation(got) && (await admit(got, 'in, by the link', 'link')))
379
+ return;
380
+ // What came back and not a guess at it. A world refuses a way in for
381
+ // several different reasons and only one of them is that it was spent;
382
+ // `id taken` in particular is a device of that name already in this
383
+ // world, which is what a human meets when she has lost the device that
384
+ // held it, and telling her it was spent sends her looking for the wrong
385
+ // thing.
386
+ say(refused(got));
387
+ }
388
+ // A link: one more relation, whoever else is in. Then whoever was on
389
+ // screen last, or the door.
390
+ if (linked && (await admit(linked, 'in, by the link', 'link')))
391
+ return;
392
+ const rels = relations(ward);
393
+ const last = kept(AT(pk), null);
394
+ const back = rels.find((r) => r.key === last) ?? rels[rels.length - 1];
395
+ if (back) {
396
+ const had = await back.avatar.tools();
397
+ await ward.save(); // her keys rotated on that ask, whatever it answered
398
+ if ('asks' in had)
399
+ return inside(back, linked ? `not in by the link; in, as before` : 'in, as before', names()[back.key] ?? back.key);
400
+ say(`not in (${had.error})`);
401
+ }
402
+ await atDoor();
403
+ }
404
+ // The page hands the config in a JSON script, which a content security
405
+ // policy allows where an inline script is not.
406
+ const config = document.getElementById('quo');
407
+ if (config?.textContent)
408
+ void start(JSON.parse(config.textContent));
409
+ export { USER };
@@ -0,0 +1,50 @@
1
+ import type { Json } from '@nervur-org/nervur';
2
+ export type Role = 'title' | 'lead' | 'body' | 'quiet' | 'label';
3
+ export type As = 'auto' | 'text' | 'table' | 'cards' | 'list' | 'people' | 'feed';
4
+ export type Layout = 'list' | 'grid' | 'hero' | 'feed';
5
+ export type Node = {
6
+ kind: 'stack';
7
+ of: Node[];
8
+ as?: Layout;
9
+ } | {
10
+ kind: 'row';
11
+ of: Node[];
12
+ as?: 'person';
13
+ } | {
14
+ kind: 'text';
15
+ text: string;
16
+ role?: Role;
17
+ } | {
18
+ kind: 'image';
19
+ src: string;
20
+ alt?: string;
21
+ } | {
22
+ kind: 'link';
23
+ href: string;
24
+ text?: string;
25
+ } | {
26
+ kind: 'form';
27
+ ask: string;
28
+ } | {
29
+ kind: 'answer';
30
+ ask: string;
31
+ as?: As;
32
+ } | {
33
+ kind: 'standing';
34
+ id: string;
35
+ as?: 'door';
36
+ } | {
37
+ kind: 'standings';
38
+ };
39
+ export declare function sanitiseTree(v: Json, depth?: number): Node | null;
40
+ export declare function answersIn(n: Node, out?: string[]): string[];
41
+ export type Painter = {
42
+ link(href: string, text: string): string;
43
+ form(ask: string): string;
44
+ answer(ask: string, as: As): string;
45
+ standing(id: string, as?: 'door'): string;
46
+ standings(): string;
47
+ };
48
+ export declare function titled(n: Node | null, depth?: number): boolean;
49
+ export declare function paint(n: Node, p: Painter): string;
50
+ export declare const nobody: Painter;
@@ -0,0 +1,157 @@
1
+ import { escape } from './html.js';
2
+ const ROLES = new Set(['title', 'lead', 'body', 'quiet', 'label']);
3
+ const AS = new Set(['auto', 'text', 'table', 'cards', 'list', 'people', 'feed']);
4
+ const LAYOUTS = new Set(['list', 'grid', 'hero', 'feed']);
5
+ const TEXT = 4000; // characters a text node may carry
6
+ const DEPTH = 12; // how deep a tree may nest
7
+ const WIDE = 200; // children a container may have
8
+ // A name in the grammar is a name in her describe, spelled as she spells
9
+ // it: an ask of her own, a standing's id, or a carried ask, `<id>-<name>`.
10
+ // A standing's id is the dock's to choose and carries a colon where the
11
+ // dock's own convention puts one, a way back to a device being `to:<id>`,
12
+ // so a shape that refused a colon would drop the section a device's own
13
+ // faculties are painted in. It stays a name and never a path: no slash, no
14
+ // space, and it is looked up in her describe before anything is painted.
15
+ const NAME = /^[\w.:-]{1,80}$/;
16
+ const HREF = 2000; // characters an address may be
17
+ // A value held to the grammar, or null. Children that fail are dropped and
18
+ // the container kept; a container with nothing left is kept empty, since an
19
+ // empty region is a design and not a fault.
20
+ export function sanitiseTree(v, depth = 0) {
21
+ if (depth > DEPTH || v === null || typeof v !== 'object' || Array.isArray(v))
22
+ return null;
23
+ const o = v;
24
+ switch (o.kind) {
25
+ case 'stack':
26
+ case 'row': {
27
+ const of = Array.isArray(o.of) ? o.of.slice(0, WIDE).map((c) => sanitiseTree(c, depth + 1)).filter((c) => c !== null) : [];
28
+ if (o.kind === 'row')
29
+ return typeof o.as === 'string' && o.as === 'person' ? { kind: 'row', of, as: 'person' } : { kind: 'row', of };
30
+ return typeof o.as === 'string' && LAYOUTS.has(o.as) ? { kind: 'stack', of, as: o.as } : { kind: 'stack', of };
31
+ }
32
+ case 'text': {
33
+ if (typeof o.text !== 'string')
34
+ return null;
35
+ const n = { kind: 'text', text: o.text.slice(0, TEXT) };
36
+ if (typeof o.role === 'string' && ROLES.has(o.role))
37
+ n.role = o.role;
38
+ return n;
39
+ }
40
+ case 'image': {
41
+ if (typeof o.src !== 'string' || !/^data:image\/[a-z0-9.+-]+;base64,[A-Za-z0-9+/=]+$/i.test(o.src))
42
+ return null;
43
+ const n = { kind: 'image', src: o.src };
44
+ if (typeof o.alt === 'string')
45
+ n.alt = o.alt.slice(0, 200);
46
+ return n;
47
+ }
48
+ case 'link': {
49
+ // The shape only: an absolute address on the web, parsed rather than
50
+ // matched, so nothing that merely looks like one gets through. Where
51
+ // it may point is the painter's, not this file's.
52
+ if (typeof o.href !== 'string' || o.href.length > HREF)
53
+ return null;
54
+ let url;
55
+ try {
56
+ url = new URL(o.href);
57
+ }
58
+ catch {
59
+ return null;
60
+ }
61
+ if (url.protocol !== 'https:' && url.protocol !== 'http:')
62
+ return null;
63
+ const n = { kind: 'link', href: url.href };
64
+ if (typeof o.text === 'string' && o.text.trim())
65
+ n.text = o.text.slice(0, 200);
66
+ return n;
67
+ }
68
+ case 'form':
69
+ return typeof o.ask === 'string' && NAME.test(o.ask) ? { kind: 'form', ask: o.ask } : null;
70
+ case 'answer': {
71
+ if (typeof o.ask !== 'string' || !NAME.test(o.ask))
72
+ return null;
73
+ const n = { kind: 'answer', ask: o.ask };
74
+ if (typeof o.as === 'string' && AS.has(o.as))
75
+ n.as = o.as;
76
+ return n;
77
+ }
78
+ case 'standing':
79
+ if (typeof o.id !== 'string' || !NAME.test(o.id))
80
+ return null;
81
+ return o.as === 'door' ? { kind: 'standing', id: o.id, as: 'door' } : { kind: 'standing', id: o.id };
82
+ case 'standings':
83
+ return { kind: 'standings' };
84
+ default:
85
+ return null;
86
+ }
87
+ }
88
+ // The names a tree asks the answers of: what a side runs on her behalf so
89
+ // the page opens with what it shows, when the ask needs nothing typed.
90
+ export function answersIn(n, out = []) {
91
+ if (n.kind === 'answer')
92
+ out.push(n.ask);
93
+ if (n.kind === 'stack' || n.kind === 'row')
94
+ for (const c of n.of)
95
+ answersIn(c, out);
96
+ return out;
97
+ }
98
+ const ROLE_TAG = { title: 'h1', lead: 'p', body: 'p', quiet: 'p', label: 'p' };
99
+ // Whether a tree opens with a title of its own, at its top or one level
100
+ // in: then the page's bar says no name, since the page says it once.
101
+ export function titled(n, depth = 0) {
102
+ if (!n || depth > 2)
103
+ return false;
104
+ if (n.kind === 'text')
105
+ return n.role === 'title';
106
+ if (n.kind === 'stack' || n.kind === 'row')
107
+ return n.of.some((c) => titled(c, depth + 1));
108
+ return false;
109
+ }
110
+ // Each kind on the kit's pieces, and the painter holds the opinion: a list
111
+ // wraps each child as a row, a grid as a card, a person puts her picture in
112
+ // the round place a face has.
113
+ export function paint(n, p) {
114
+ switch (n.kind) {
115
+ case 'stack': {
116
+ const inner = (wrap) => n.of.map((c) => `<div class="${wrap}">${paint(c, p)}</div>`).join('');
117
+ if (n.as === 'list')
118
+ return `<div class="nv-list">${inner('nv-item')}</div>`;
119
+ if (n.as === 'grid')
120
+ return `<div class="nv-cards">${inner('nv-card')}</div>`;
121
+ if (n.as === 'hero')
122
+ return `<div class="nv-hero">${n.of.map((c) => paint(c, p)).join('')}</div>`;
123
+ if (n.as === 'feed')
124
+ return `<div class="nv-feed">${n.of.map((c) => paint(c, p)).join('')}</div>`;
125
+ return `<div class="nv-stack">${n.of.map((c) => paint(c, p)).join('')}</div>`;
126
+ }
127
+ case 'row': {
128
+ if (n.as === 'person') {
129
+ const face = n.of.find((c) => c.kind === 'image');
130
+ const rest = n.of.filter((c) => c !== face).map((c) => paint(c, p)).join('');
131
+ const words = n.of.find((c) => c.kind === 'text');
132
+ const initial = words && words.kind === 'text' ? escape(words.text.trim().charAt(0).toUpperCase()) : '';
133
+ return `<div class="nv-person"><span class="nv-avatar">${face ? paint(face, p) : initial}</span><div class="nv-stack">${rest}</div></div>`;
134
+ }
135
+ return `<div class="nv-row" data-align="center">${n.of.map((c) => paint(c, p)).join('')}</div>`;
136
+ }
137
+ case 'text': {
138
+ const role = n.role ?? 'body';
139
+ return `<${ROLE_TAG[role]} class="nv-t-${role}">${escape(n.text)}</${ROLE_TAG[role]}>`;
140
+ }
141
+ case 'image':
142
+ return `<img class="nv-picture" alt="${escape(n.alt ?? '')}" src="${n.src}">`;
143
+ case 'link':
144
+ return p.link(n.href, n.text ?? n.href);
145
+ case 'form':
146
+ return p.form(n.ask);
147
+ case 'answer':
148
+ return p.answer(n.ask, n.as ?? 'auto');
149
+ case 'standing':
150
+ return p.standing(n.id, n.as);
151
+ case 'standings':
152
+ return p.standings();
153
+ }
154
+ }
155
+ // A painter with nothing behind it: for a page that names no ask, the door
156
+ // page and any page painted before anyone is in.
157
+ export const nobody = { link: () => '', form: () => '', answer: () => '', standing: () => '', standings: () => '' };
@@ -0,0 +1,34 @@
1
+ import type { Invitation, JsonObject } from '@nervur-org/nervur';
2
+ import type { Harbor } from '@nervur-org/nervur/harbor';
3
+ import { type Token } from '../dock/index.ts';
4
+ export declare const webToken: (endpoint: string, p256dh?: string, auth?: string) => {
5
+ kind: string;
6
+ endpoint: string;
7
+ p256dh?: string | undefined;
8
+ auth?: string | undefined;
9
+ };
10
+ export type Bell = {
11
+ pk: string;
12
+ at: string;
13
+ };
14
+ export declare const DOORBELL = "doorbell";
15
+ export type Held = {
16
+ keep(token: Token): Promise<boolean>;
17
+ admit(): Promise<{
18
+ invitation: Invitation;
19
+ banner: string;
20
+ } | null>;
21
+ };
22
+ export declare function doorbell(harbor: Harbor, bell?: Bell): Promise<Held | null>;
23
+ export declare function told(rel: {
24
+ call(name: string, args?: JsonObject): Promise<unknown>;
25
+ }, at: Held | null, token?: Token | null): Promise<string | null>;
26
+ export declare function subscribe(web: string, key: string, ask?: boolean): Promise<{
27
+ kind: string;
28
+ endpoint: string;
29
+ } | null>;
30
+ export declare function drained(harbor: Harbor, pks: string[]): Promise<JsonObject[]>;
31
+ export declare function notice(objects: JsonObject[]): {
32
+ title: string;
33
+ body: string;
34
+ };