@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,124 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // The tab's ear while the tab is closed. A ring from a world's doorbell
3
+ // arrives here, at the service worker on that world's origin, and this is
4
+ // the one piece of a tab that runs when nothing of the tab is open.
5
+ //
6
+ // A device is one harbor, and the lock says who holds it. A page that is
7
+ // open holds it, so this worker asks that page to wake and drain and tells
8
+ // the human what came back; with no page open the worker is the device's
9
+ // harbor for the length of the wake, boots over the same database, dials
10
+ // the world, drains and closes.
11
+ //
12
+ // The line is the world's own. A Web Push body is encrypted under RFC 8291
13
+ // to keys only this browser holds, so the ring arrives here already opened
14
+ // and the push service that carried it saw an endpoint, a signature and
15
+ // ciphertext. A ring that carried no line leaves the line to what was
16
+ // drained, which is objects that crossed sealed over Quo.
17
+ //
18
+ // Its config is the world's, written above the bundle by the route that
19
+ // serves it: the quo. route to dial and the wards of this world by pk.
20
+ import type { JsonObject } from '@nervur-org/nervur';
21
+ import { BrowserHarbor } from '../harbor/browser.ts';
22
+ import { drained, notice } from './wake.ts';
23
+ import type { Config } from './tab.ts';
24
+
25
+ // What a service worker is, in the little of it this file uses. The DOM's
26
+ // own `self` is a window, and a worker's is not, so it is asked for by the
27
+ // shape it has here rather than by pulling a second global library in.
28
+ type Client = { postMessage(message: unknown, transfer?: unknown[]): void };
29
+ type Worker = {
30
+ quo?: Config;
31
+ clients: { matchAll(o?: { type?: string; includeUncontrolled?: boolean }): Promise<Client[]>; claim(): Promise<void> };
32
+ registration: { showNotification(title: string, options?: { body?: string; tag?: string }): Promise<void> };
33
+ skipWaiting(): Promise<void>;
34
+ addEventListener(type: string, fn: (event: { waitUntil(p: Promise<unknown>): void; data?: { json(): unknown } | null }) => void): void;
35
+ };
36
+
37
+ // The line the ring carried, when it carried one. A Web Push body is
38
+ // encrypted to this subscription's own keys and the browser has already
39
+ // opened it by the time it arrives here, so this is the world's own line
40
+ // and not a guess at one. A ring that carried nothing, and a body that is
41
+ // not a line, are the same thing: nothing to show yet.
42
+ const carried = (event: { data?: { json(): unknown } | null }): { title: string; body: string } | null => {
43
+ try {
44
+ const said = event.data?.json() as { title?: unknown; body?: unknown } | null;
45
+ if (!said || typeof said.title !== 'string') return null;
46
+ return { title: said.title, body: typeof said.body === 'string' ? said.body : '' };
47
+ } catch {
48
+ return null;
49
+ }
50
+ };
51
+
52
+ const sw = globalThis as unknown as Worker;
53
+
54
+ // Every step of a wake is bounded: a push event lives for as long as the
55
+ // platform gives it, and a wake that found nothing shows nothing.
56
+ const within = <T>(ms: number, work: Promise<T>, floor: T): Promise<T> => Promise.race([work, new Promise<T>((ok) => setTimeout(() => ok(floor), ms))]);
57
+
58
+ // A page that is open holds the harbor. It is handed a port, it drains on
59
+ // its own harbor and answers what it found; the first page to answer is the
60
+ // one that held it, and no page answering means none did.
61
+ async function fromPage(): Promise<JsonObject[] | null> {
62
+ const pages = await sw.clients.matchAll({ type: 'window', includeUncontrolled: true });
63
+ if (pages.length === 0) return null;
64
+ return within(
65
+ 5000,
66
+ new Promise<JsonObject[] | null>((ok) => {
67
+ for (const page of pages) {
68
+ const channel = new MessageChannel();
69
+ channel.port1.onmessage = (e: MessageEvent) => {
70
+ const said = (e.data ?? {}) as { missed?: unknown };
71
+ if (Array.isArray(said.missed)) ok(said.missed as JsonObject[]);
72
+ };
73
+ page.postMessage({ quo: 'wake' }, [channel.port2]);
74
+ }
75
+ }),
76
+ null,
77
+ );
78
+ }
79
+
80
+ // No page open: this worker is the device's harbor for the length of the
81
+ // wake. A boot that meets the lock is a page that holds it and did not
82
+ // answer, and this worker leaves it alone rather than opening a second
83
+ // harbor over one seed.
84
+ async function here(cfg: Config): Promise<JsonObject[]> {
85
+ const harbor = new BrowserHarbor('quo');
86
+ try {
87
+ await harbor.boot();
88
+ } catch {
89
+ return [];
90
+ }
91
+ try {
92
+ harbor.dial(cfg.quo);
93
+ return await within(
94
+ 20000,
95
+ drained(
96
+ harbor,
97
+ Object.values(cfg.wards).map((w) => w.pk),
98
+ ),
99
+ [],
100
+ );
101
+ } finally {
102
+ await harbor.close();
103
+ }
104
+ }
105
+
106
+ async function woken(cfg: Config, said: { title: string; body: string } | null): Promise<void> {
107
+ const objects = (await fromPage()) ?? (await here(cfg));
108
+ // What the world sent is what the human reads, since the world knows what
109
+ // it rang about and this worker knows only what it managed to drain. What
110
+ // was drained composes the line when the ring carried none, and a ring
111
+ // with neither is a device that was already told: a notification saying
112
+ // so would be the doorbell inventing news.
113
+ if (!said && objects.length === 0) return;
114
+ const { title, body } = said ?? notice(objects);
115
+ // Bounded like every other step, because a browser build with no
116
+ // notification platform under it answers this call neither way, and a
117
+ // wake that waits on it is a worker the browser keeps alive for nothing.
118
+ await within(5000, sw.registration.showNotification(title, { body, tag: 'quo' }), undefined);
119
+ }
120
+
121
+ const cfg = sw.quo;
122
+ sw.addEventListener('install', () => void sw.skipWaiting());
123
+ sw.addEventListener('activate', (e) => e.waitUntil(sw.clients.claim()));
124
+ if (cfg) sw.addEventListener('push', (e) => e.waitUntil(woken(cfg, carried(e))));
@@ -0,0 +1,127 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // The worlds in a tab, and the relations in each. A tab is a device with a
3
+ // harbor of its own, and it keeps one local ward per far world, named by
4
+ // the far ward's pk; inside it, one avatar per relation, since an avatar is
5
+ // one standing and nothing more. An invitation names its ward by pk, so an
6
+ // invitation is self-addressing: the local ward is chosen by the invitation
7
+ // and never by the page it was opened on, and a fresh invitation is always a
8
+ // fresh avatar, never a join on one that already holds her standing. Two
9
+ // humans on one family tablet are two relations in one world; a shop and a
10
+ // friend in one far ward are two as well. Nothing here touches a document:
11
+ // `tab.ts` is the shell over this, and this is what the memory harbor proves.
12
+ import type { Harbor, Hosted } from '@nervur-org/nervur/harbor';
13
+ import { Avatar, USER } from '../beings/avatar.ts';
14
+ import { settled } from '../dock/faculty.ts';
15
+
16
+ // Which worlds a device has joined, and where each one lives. A tab learns
17
+ // this from the page it is on, one world per origin, and needs it only to
18
+ // list the others; an app is one origin and every world is somewhere else,
19
+ // so the list is the only way back to any of them. It is addresses and
20
+ // pks, nothing secret: what lets a device into a world is the standing in
21
+ // its ward, and that is in the harbor.
22
+ //
23
+ // The pk is the key, because that is what an invitation names. A world met
24
+ // again at a new address is the same world and keeps its ward. `home` is
25
+ // whether the being reached there is her user being, which the user being
26
+ // says in her describe, so a switcher can put home first.
27
+ export type World = { pk: string; at: string; name: string; home?: boolean };
28
+ export type Joined = { read(): World[]; write(worlds: World[]): void };
29
+
30
+ // The list as a browser keeps it, the tab's and the app's alike: one item,
31
+ // under the key the tab has always used. A device with no storage still
32
+ // works and forgets, which is why every read has a floor.
33
+ export const KEY = 'quo-worlds';
34
+ export function inStorage(storage: Pick<Storage, 'getItem' | 'setItem'>): Joined {
35
+ return {
36
+ read: () => {
37
+ try {
38
+ const raw: unknown = JSON.parse(storage.getItem(KEY) ?? 'null');
39
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return [];
40
+ return Object.entries(raw as Record<string, unknown>).flatMap(([pk, v]) => {
41
+ const w = (v ?? {}) as { at?: unknown; url?: unknown; name?: unknown; home?: unknown };
42
+ const at = typeof w.at === 'string' ? w.at : typeof w.url === 'string' ? w.url : '';
43
+ return at ? [{ pk, at, name: typeof w.name === 'string' ? w.name : '', ...(w.home === true ? { home: true } : {}) }] : [];
44
+ });
45
+ } catch {
46
+ return [];
47
+ }
48
+ },
49
+ write: (worlds) => {
50
+ try {
51
+ storage.setItem(KEY, JSON.stringify(Object.fromEntries(worlds.map((w) => [w.pk, { at: w.at, url: w.at, name: w.name, ...(w.home ? { home: true } : {}) }]))));
52
+ } catch {
53
+ /* forgets */
54
+ }
55
+ },
56
+ };
57
+ }
58
+
59
+ // One world joined, or met again: the same pk keeps its place in the list
60
+ // and takes the newer address, any name it has gained and whether it is a
61
+ // home, and a world not seen before goes to the end. The list is in the
62
+ // order they were joined; `ordered` is how a switcher shows them.
63
+ export function joined(list: World[], met: World): World[] {
64
+ const at = list.findIndex((w) => w.pk === met.pk);
65
+ if (at === -1) return [...list, met];
66
+ const out = [...list];
67
+ out[at] = { ...out[at], at: met.at, ...(met.name ? { name: met.name } : {}), ...(met.home !== undefined ? { home: met.home } : {}) };
68
+ return out;
69
+ }
70
+
71
+ // Home first, memberships behind it, each in the order joined: what a
72
+ // person thinks of as "me" before the worlds that know an id of hers. A
73
+ // membership with no home stands where it is.
74
+ export function ordered(list: World[]): World[] {
75
+ return [...list.filter((w) => w.home), ...list.filter((w) => !w.home)];
76
+ }
77
+
78
+ // The local ward for a far world, by its pk: created on first sight, on
79
+ // the tab's own seed, empty.
80
+ export const localName = (pk: string): string => `w-${pk.slice(0, 16)}`;
81
+ export async function world(harbor: Harbor, pk: string): Promise<Hosted> {
82
+ const name = localName(pk);
83
+ return harbor.wards.get(name) ?? (await harbor.create(name, 'me'));
84
+ }
85
+
86
+ // The relations held in a local ward: every avatar there who holds her
87
+ // standing, by key, in the order they were born.
88
+ export type Relation = { key: string; avatar: Avatar };
89
+ export function relations(ward: Hosted): Relation[] {
90
+ const out: Relation[] = [];
91
+ for (const key of ward.keys()) {
92
+ const a = ward.being(key) as Avatar | undefined;
93
+ if (a instanceof Avatar && a.standings[USER]) out.push({ key, avatar: a });
94
+ }
95
+ return out;
96
+ }
97
+
98
+ // A presence being for a relation not yet made: one who holds no standing
99
+ // at a world, whether left by a guest who never came in or booted now under
100
+ // the next free key. She is the one who knocks, as a guest or with an
101
+ // invitation, and she becomes a relation the moment she takes her standing.
102
+ // She is the being of that world who lives on this device, holding what the
103
+ // box lends from her birth, and a bare tab lends her nothing.
104
+ export async function fresh(ward: Hosted): Promise<Relation> {
105
+ for (const key of ward.keys()) {
106
+ const a = ward.being(key) as Avatar | undefined;
107
+ if (a instanceof Avatar && !a.standings[USER]) return { key, avatar: a };
108
+ }
109
+ // The next free key, found by asking rather than by reading a table: boot
110
+ // refuses a key that has a row, so the ward is the one that knows, and a
111
+ // row whose class did not construct this run is a taken key the harbor
112
+ // has no object for. The bound is there because a loop that asks forever
113
+ // is a tab that hangs.
114
+ for (let n = 1; n <= 64; n++) {
115
+ const key = `r${n}`;
116
+ const out = (await ward.ask('boot', { key, class: 'Presence' })) as { error?: string };
117
+ if (out.error) continue;
118
+ // She takes what the box lends at her birth, and birth is synchronous
119
+ // while taking a standing is not. She is handed back when they have
120
+ // landed, not before: a screen painted from her describe a moment early
121
+ // is a page missing every faculty of the box, and nothing paints it
122
+ // again until the person navigates.
123
+ await settled();
124
+ return { key, avatar: ward.being(key) as Avatar };
125
+ }
126
+ throw new Error('the tab could not boot an avatar: every key it tries is taken');
127
+ }
package/mcp/agent.ts ADDED
@@ -0,0 +1,94 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // The model side as an event-driven agent: the world is the loop. The user
3
+ // being holds a standing to the agent's avatar and asks her when something
4
+ // happens; that ask is a push, and her `notify` answers at once, so the ask
5
+ // is never held open. The push lands in her inbox, which is the queue, in
6
+ // her cells. This side drains the inbox one event at a time: it starts a
7
+ // run with the event, and when the run ends she asks the user being back on
8
+ // her own standing with the result. One run at a time, in the order the
9
+ // events came, across a restart too, because the queue is in the partition
10
+ // and not in this process. What a run may do is the gate; how it behaves
11
+ // is its constitution, the folder it runs in.
12
+ import { spawn } from 'node:child_process';
13
+ import type { Json, JsonObject } from '@nervur-org/nervur';
14
+ import type { Avatar } from '../beings/avatar.ts';
15
+ import { word, wordText, SILENCE_TEXT, UNREACHED_TEXT, type Serving } from '../beings/side.ts';
16
+
17
+ // A run: the event in, a result out. Two exist here: any function, and a
18
+ // process in a folder. The runner of runner.ts is a third when handed as a
19
+ // function.
20
+ export type Run = (event: JsonObject) => Promise<Json>;
21
+
22
+ // The ask on the user being that takes the result. Gated to devices; the
23
+ // agent's client identity is what the gate reads.
24
+ export const REPORT = 'report';
25
+
26
+ // `claude -p` or any CLI agent: the command runs in the agent's folder with
27
+ // the event, as JSON, as its last argument. The result is what it printed.
28
+ // A non-zero exit is an error object with what it said on stderr.
29
+ export function processRun(command: string, args: string[], dir: string): Run {
30
+ return (event) =>
31
+ new Promise((ok) => {
32
+ const child = spawn(command, [...args, JSON.stringify(event)], { cwd: dir, stdio: ['ignore', 'pipe', 'pipe'] });
33
+ let out = '';
34
+ let err = '';
35
+ child.stdout.on('data', (c: Buffer) => (out += c.toString()));
36
+ child.stderr.on('data', (c: Buffer) => (err += c.toString()));
37
+ child.on('error', (e) => ok({ error: e.message }));
38
+ child.on('close', (code) => ok(code === 0 ? { output: out.trim() } : { error: `exit ${code}`, stderr: err.trim() }));
39
+ });
40
+ }
41
+
42
+ export type Agent = Serving & {
43
+ // Resolves when the queue is empty and no run is in flight.
44
+ idle(): Promise<void>;
45
+ };
46
+
47
+ // `after` runs when her cells changed and when an ask is done, as in the
48
+ // other sides: a harbor that must write what the ward changed hooks it.
49
+ export function agentSide(avatar: Avatar, run: Run, after: () => Promise<void> = async () => {}): Agent {
50
+ let draining: Promise<void> = Promise.resolve();
51
+ let open = true;
52
+ const inbox = () => avatar.cells.inbox as JsonObject[];
53
+
54
+ async function one(event: JsonObject): Promise<void> {
55
+ let result: Json;
56
+ try {
57
+ result = await run(event);
58
+ } catch (e) {
59
+ result = { error: e instanceof Error ? e.message : String(e) };
60
+ }
61
+ // The report is an ask like any other. Silence and unreached are told
62
+ // apart and dropped: a report is never retried, and never held.
63
+ const w = word(await avatar.call(REPORT, { event, result }));
64
+ if (w.word === 'silence' || w.word === 'unreached' || w.word === 'quo') say({ dropped: w.word === 'silence' ? SILENCE_TEXT : w.word === 'quo' ? wordText(w.name) : UNREACHED_TEXT });
65
+ await after();
66
+ }
67
+
68
+ // One at a time, first in first out, until the inbox is empty.
69
+ function drain(): Promise<void> {
70
+ draining = draining.then(async () => {
71
+ while (open && inbox().length > 0) {
72
+ const event = inbox().shift()!;
73
+ await after(); // the event left the queue before the run starts: a crash mid-run loses it, and never runs it twice
74
+ await one(event);
75
+ }
76
+ });
77
+ return draining;
78
+ }
79
+
80
+ // A dropped report is told to her other sides, if any: the agent itself has no ear for it.
81
+ const say = (object: JsonObject) => { for (const ear of avatar.ears) if (ear !== ear0) ear(object); };
82
+ const ear0 = () => void drain();
83
+ avatar.ears.add(ear0);
84
+ void drain(); // whatever waited while no side was up
85
+
86
+ return {
87
+ idle: () => draining,
88
+ close: async () => {
89
+ open = false;
90
+ avatar.ears.delete(ear0);
91
+ await draining;
92
+ },
93
+ };
94
+ }
package/mcp/allow.ts ADDED
@@ -0,0 +1,131 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // The allow page, the one page of the model side's credential exchange,
3
+ // mounted on the web route. It ends the credential exchange the way every
4
+ // way in ends: with a knock. Nothing is typed to get in but an invitation,
5
+ // the one the root minted for this client on the user being with `quo
6
+ // invite` and sent as a link or a line; the avatar the identity boots
7
+ // knocks with it, and the OAuth request completes with the identity the
8
+ // human chose. No password, no cookie, no session: the invitation is the
9
+ // credential, it is used once, and from the knock on there is no token
10
+ // anywhere but the route's own grant.
11
+ //
12
+ // It is hand-written, and that is forced rather than chosen. The screen
13
+ // paints a being's describe through a standing, and here there is neither:
14
+ // a human at this page is a stranger, holding nothing, and the world she is
15
+ // about to let something into is named by an invitation she has not typed
16
+ // yet. The page grammar cannot say this page either, since its `form` node
17
+ // is one of a being's asks by name and this form is nobody's ask. So it is
18
+ // written out, like the door page and for the same reason: the claim that
19
+ // no world writes a page is about worlds, and this page is Quo's.
20
+ //
21
+ // What it does not do is dress itself. It wears the one stylesheet every
22
+ // screen wears, so a design that swaps the face swaps this page with it,
23
+ // and there is no second look here to drift from the first.
24
+ //
25
+ // GET /allow?request=ID who is asking, the identity it will be, and
26
+ // the field for the invitation
27
+ // POST /allow allow as that identity with that invitation,
28
+ // or deny
29
+ //
30
+ // The invitation names its ward, so the world the client lands in is the
31
+ // one the invitation was minted on, and there is nothing to choose.
32
+ import type { Invitation } from '@nervur-org/nervur';
33
+ import type { OAuth } from './oauth.ts';
34
+ import { readForm } from './oauth.ts';
35
+ import { invitationOf } from '../beings/link.ts';
36
+ import { CSS } from '../human/style.ts';
37
+ import { POLICY } from '../human/door.ts';
38
+ import { MARK_SVG } from '../human/mark.ts';
39
+
40
+ export type Join = (identity: string, invitation: Invitation, ward: string) => Promise<{ error?: string }>;
41
+ export type Options = {
42
+ oauth: OAuth;
43
+ join: Join; // the daemon: boot or find the avatar for this identity in that world, and knock
44
+ worlds: () => { ward: string; pk: string; user: string }[]; // the worlds of this harbor, by name and pk
45
+ };
46
+
47
+ const esc = (s: string) => s.replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c] ?? c);
48
+ const word = (s: unknown) => (typeof s === 'string' && /^[\w.-]{1,40}$/.test(s) ? s : null);
49
+ // A client's name as an identity: lowercase words joined by dashes.
50
+ export const suggest = (name: string) =>
51
+ name
52
+ .toLowerCase()
53
+ .replace(/[^a-z0-9]+/g, '-')
54
+ .replace(/^-|-$/g, '')
55
+ .slice(0, 40) || 'client';
56
+
57
+
58
+ export class Exchange {
59
+ readonly o: Options;
60
+ constructor(o: Options) {
61
+ this.o = o;
62
+ }
63
+
64
+ // A page in, a page out: this side answers as the endpoint beside it does,
65
+ // and a path that is not one of its two is nothing it declines by
66
+ // answering nothing.
67
+ async handle(req: Request, rest: string): Promise<Response | undefined> {
68
+ const url = new URL(req.url);
69
+ const page = (status: number, body: string): Response =>
70
+ new Response(shell(body), { status, headers: { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store', 'content-security-policy': POLICY, 'referrer-policy': 'no-referrer' } });
71
+ const go = (to: string): Response => new Response(null, { status: 302, headers: { location: to, 'cache-control': 'no-store' } });
72
+
73
+ if (rest === '/allow' && req.method === 'GET') {
74
+ const request = url.searchParams.get('request') ?? '';
75
+ const p = this.o.oauth.pending(request);
76
+ if (!p) return page(400, `<h1>Nothing to allow</h1><p>This request is gone. Start again from the app that sent you here.</p>`);
77
+ return page(200, allowForm(request, p.client.client_name, p.redirect_uri, suggest(p.client.client_name), this.o.worlds()));
78
+ }
79
+ if (rest === '/allow' && req.method === 'POST') {
80
+ const f = await readForm(req);
81
+ const request = f.get('request') ?? '';
82
+ const p = this.o.oauth.pending(request);
83
+ if (!p) return page(400, `<h1>Nothing to allow</h1><p>This request is gone.</p>`);
84
+ if (f.get('decision') !== 'allow') {
85
+ const out = await this.o.oauth.deny(request);
86
+ return 'redirect' in out ? go(out.redirect) : page(400, `<h1>Gone</h1>`);
87
+ }
88
+ const worlds = this.o.worlds();
89
+ const again = (err: string) => page(400, allowForm(request, p.client.client_name, p.redirect_uri, word(f.get('identity')) ?? suggest(p.client.client_name), worlds, err));
90
+ const inv = invitationOf(f.get('invitation') ?? '');
91
+ if (!inv) return again('That is not an invitation.');
92
+ const world = worlds.find((w) => w.pk === inv.ward);
93
+ if (!world) return again('That invitation is for a world that does not live here.');
94
+ const identity = word(f.get('identity'));
95
+ if (identity === null || identity === world.user || identity === 'desk') return again('An identity is one word, and not the user or the desk.');
96
+ const joined = await this.o.join(identity, inv, world.ward);
97
+ if (joined.error) return again(`Not in: ${joined.error}.`);
98
+ const out = await this.o.oauth.complete(request, identity, world.ward);
99
+ return 'redirect' in out ? go(out.redirect) : page(400, `<h1>Gone</h1>`);
100
+ }
101
+ return undefined;
102
+ }
103
+ }
104
+
105
+ // The one stylesheet, and no colour of this page's own. The policy is the
106
+ // one every page Quo writes itself is served under, `human/door.ts`, since
107
+ // this is one of them: it runs no script of its own and wants none.
108
+ const shell = (body: string) => `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>quo</title>
109
+ <style>${CSS}</style>
110
+ </head><body><main class="door">${body}</main></body></html>`;
111
+
112
+ // The one page, and every sentence on it is something a human needs in
113
+ // order to decide. Who is asking, what it will be able to see, that it is
114
+ // bounded by a gate rather than by a promise, that she can end it, and
115
+ // where it goes when she is done. The world is the invitation's, so the
116
+ // page says which worlds live here and whose they are, and asks her to
117
+ // choose none of them.
118
+ const allowForm = (request: string, client: string, redirect: string, identity: string, worlds: { ward: string; user: string }[], err = '') => {
119
+ const whose = worlds.map((w) => `<strong>${esc(w.user)}</strong> in ${esc(w.ward)}`).join(', ');
120
+ return `${MARK_SVG}
121
+ <h1 class="t-title">Allow ${esc(client)}?</h1>
122
+ <p class="t-lead"><strong>${esc(client)}</strong> asks to be let into ${whose || 'a world here'}, as an occupant under a name you give it.</p>
123
+ <p>It will see exactly what that world shows that name, decided by her gate and by nothing it can ask for. It cannot become anything else later, and it can be taken out of the world at any time.</p>
124
+ <p class="t-quiet">The invitation below is the whole credential. It is spent once, here, and nothing is stored afterwards: no password, no cookie, no session. If you did not mint it for ${esc(client)} yourself, close this page.</p>
125
+ <p class="t-quiet">When you are done it returns to <code>${esc(redirect)}</code>.</p>
126
+ ${err ? `<p class="answer error">${esc(err)}</p>` : ''}
127
+ <form method="post" action=""><input type="hidden" name="request" value="${esc(request)}">
128
+ <p><label for="i">The name it will be known by</label><input id="i" name="identity" value="${esc(identity)}" pattern="[\\w.-]{1,40}" required></p>
129
+ <p><label for="v">The invitation you minted for it</label><textarea id="v" name="invitation" autofocus required placeholder="as the link, or as printed"></textarea></p>
130
+ <p><button type="submit" name="decision" value="allow">Allow</button> <button type="submit" name="decision" value="deny">Deny</button></p></form>`;
131
+ };
package/mcp/http.ts ADDED
@@ -0,0 +1,92 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // The MCP endpoint on the mcp route: streamable HTTP, one session per
3
+ // client connection, one avatar per client identity. A bearer names the
4
+ // identity, the identity names the avatar, and the side from `server.ts`
5
+ // runs beside her exactly as it does over stdio. A session belongs to the
6
+ // identity that opened it; a bearer of another identity on the same session
7
+ // is refused. Sessions live in memory and die with the daemon, which is
8
+ // what a reconnect is for: the standing is the state, and it is on disk. A
9
+ // client that goes away without a DELETE leaves a session behind; one idle
10
+ // for an hour is dropped, since HTTP tells the server nothing else.
11
+ import { WebStandardStreamableHTTPServerTransport as StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js';
12
+ import type { Avatar } from '../beings/avatar.ts';
13
+ import type { Serving } from '../beings/side.ts';
14
+ import { mcpSide } from './server.ts';
15
+
16
+ export type Session = { identity: string; ward: string; transport: StreamableHTTPServerTransport; serving: Serving; touched: number };
17
+ export const SESSION_IDLE = 60 * 60 * 1000;
18
+ // An identity in a world: one harbor holds many, and the grant names which.
19
+ export type Resolve = (identity: string, ward: string) => Promise<{ avatar?: Avatar; error?: string }>;
20
+
21
+ export class McpHttp {
22
+ readonly sessions = new Map<string, Session>();
23
+ readonly resolve: Resolve;
24
+ readonly after: () => Promise<void>;
25
+ now: () => number = () => Date.now();
26
+ // What to do with an identity the user being has removed: the route's
27
+ // revoke, so the client's tokens go with the occupant. Set by whoever
28
+ // mounts the route beside the credential exchange.
29
+ gone: (identity: string, ward: string) => Promise<void> = async () => {};
30
+ constructor(resolve: Resolve, after: () => Promise<void> = async () => {}) {
31
+ this.resolve = resolve;
32
+ this.after = after;
33
+ }
34
+
35
+ // `identity` is what the bearer named; the caller has already turned a
36
+ // stranger away. A request on a known session goes to it. A request with
37
+ // no session opens one, if it is an initialize; anything else is 400.
38
+ async handle(req: Request, identity: string, ward: string): Promise<Response> {
39
+ const sid = req.headers.get('mcp-session-id') ?? undefined;
40
+ const json = (status: number, body: unknown) => Response.json(body, { status });
41
+ this.sweep();
42
+ if (sid !== undefined) {
43
+ const s = this.sessions.get(sid);
44
+ if (!s) return json(404, { jsonrpc: '2.0', error: { code: -32001, message: 'no such session' }, id: null });
45
+ if (s.identity !== identity || s.ward !== ward) return json(403, { jsonrpc: '2.0', error: { code: -32003, message: 'not your session' }, id: null });
46
+ s.touched = this.now();
47
+ const out = await s.transport.handleRequest(req);
48
+ if (req.method === 'DELETE') this.drop(sid);
49
+ return out;
50
+ }
51
+ if (req.method !== 'POST') return json(400, { jsonrpc: '2.0', error: { code: -32000, message: 'no session' }, id: null });
52
+ const found = await this.resolve(identity, ward);
53
+ // A session opens on her describe, and admit already asked it. The one
54
+ // word the door says for an identity the user being removed is
55
+ // `removed`, under the key it bound for her avatar: in MCP's vocabulary
56
+ // that is 401, the client drops its token and starts the exchange again,
57
+ // and the route forgets the grant.
58
+ if (found.error === 'removed') {
59
+ await this.gone(identity, ward);
60
+ for (const [key, s] of this.sessions) if (s.identity === identity && s.ward === ward) this.drop(key);
61
+ return json(401, { jsonrpc: '2.0', error: { code: -32001, message: 'removed: the user being removed this identity' }, id: null });
62
+ }
63
+ if (!found.avatar) return json(500, { jsonrpc: '2.0', error: { code: -32002, message: found.error ?? 'no avatar' }, id: null });
64
+ const transport = new StreamableHTTPServerTransport({
65
+ // the web crypto's, not node's: a session id is a fresh uuid on every
66
+ // runtime and this endpoint names no terrain
67
+ sessionIdGenerator: () => globalThis.crypto.randomUUID(),
68
+ onsessioninitialized: (s) => {
69
+ this.sessions.set(s, { identity, ward, transport, serving, touched: this.now() });
70
+ },
71
+ onsessionclosed: (s) => this.drop(s),
72
+ });
73
+ const serving = await mcpSide(found.avatar, transport, this.after);
74
+ return transport.handleRequest(req);
75
+ }
76
+
77
+ sweep() {
78
+ const now = this.now();
79
+ for (const [sid, s] of this.sessions) if (now - s.touched > SESSION_IDLE) this.drop(sid);
80
+ }
81
+
82
+ drop(sid: string) {
83
+ const s = this.sessions.get(sid);
84
+ if (!s) return;
85
+ this.sessions.delete(sid);
86
+ void s.serving.close().catch(() => {});
87
+ }
88
+
89
+ async close() {
90
+ for (const sid of [...this.sessions.keys()]) this.drop(sid);
91
+ }
92
+ }