@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 @@
1
+ export {};
@@ -0,0 +1,81 @@
1
+ import { BrowserHarbor } from '../harbor/browser.js';
2
+ import { drained, notice } from './wake.js';
3
+ // The line the ring carried, when it carried one. A Web Push body is
4
+ // encrypted to this subscription's own keys and the browser has already
5
+ // opened it by the time it arrives here, so this is the world's own line
6
+ // and not a guess at one. A ring that carried nothing, and a body that is
7
+ // not a line, are the same thing: nothing to show yet.
8
+ const carried = (event) => {
9
+ try {
10
+ const said = event.data?.json();
11
+ if (!said || typeof said.title !== 'string')
12
+ return null;
13
+ return { title: said.title, body: typeof said.body === 'string' ? said.body : '' };
14
+ }
15
+ catch {
16
+ return null;
17
+ }
18
+ };
19
+ const sw = globalThis;
20
+ // Every step of a wake is bounded: a push event lives for as long as the
21
+ // platform gives it, and a wake that found nothing shows nothing.
22
+ const within = (ms, work, floor) => Promise.race([work, new Promise((ok) => setTimeout(() => ok(floor), ms))]);
23
+ // A page that is open holds the harbor. It is handed a port, it drains on
24
+ // its own harbor and answers what it found; the first page to answer is the
25
+ // one that held it, and no page answering means none did.
26
+ async function fromPage() {
27
+ const pages = await sw.clients.matchAll({ type: 'window', includeUncontrolled: true });
28
+ if (pages.length === 0)
29
+ return null;
30
+ return within(5000, new Promise((ok) => {
31
+ for (const page of pages) {
32
+ const channel = new MessageChannel();
33
+ channel.port1.onmessage = (e) => {
34
+ const said = (e.data ?? {});
35
+ if (Array.isArray(said.missed))
36
+ ok(said.missed);
37
+ };
38
+ page.postMessage({ quo: 'wake' }, [channel.port2]);
39
+ }
40
+ }), null);
41
+ }
42
+ // No page open: this worker is the device's harbor for the length of the
43
+ // wake. A boot that meets the lock is a page that holds it and did not
44
+ // answer, and this worker leaves it alone rather than opening a second
45
+ // harbor over one seed.
46
+ async function here(cfg) {
47
+ const harbor = new BrowserHarbor('quo');
48
+ try {
49
+ await harbor.boot();
50
+ }
51
+ catch {
52
+ return [];
53
+ }
54
+ try {
55
+ harbor.dial(cfg.quo);
56
+ return await within(20000, drained(harbor, Object.values(cfg.wards).map((w) => w.pk)), []);
57
+ }
58
+ finally {
59
+ await harbor.close();
60
+ }
61
+ }
62
+ async function woken(cfg, said) {
63
+ const objects = (await fromPage()) ?? (await here(cfg));
64
+ // What the world sent is what the human reads, since the world knows what
65
+ // it rang about and this worker knows only what it managed to drain. What
66
+ // was drained composes the line when the ring carried none, and a ring
67
+ // with neither is a device that was already told: a notification saying
68
+ // so would be the doorbell inventing news.
69
+ if (!said && objects.length === 0)
70
+ return;
71
+ const { title, body } = said ?? notice(objects);
72
+ // Bounded like every other step, because a browser build with no
73
+ // notification platform under it answers this call neither way, and a
74
+ // wake that waits on it is a worker the browser keeps alive for nothing.
75
+ await within(5000, sw.registration.showNotification(title, { body, tag: 'quo' }), undefined);
76
+ }
77
+ const cfg = sw.quo;
78
+ sw.addEventListener('install', () => void sw.skipWaiting());
79
+ sw.addEventListener('activate', (e) => e.waitUntil(sw.clients.claim()));
80
+ if (cfg)
81
+ sw.addEventListener('push', (e) => e.waitUntil(woken(cfg, carried(e))));
@@ -0,0 +1,24 @@
1
+ import type { Harbor, Hosted } from '@nervur-org/nervur/harbor';
2
+ import { Avatar } from '../beings/avatar.ts';
3
+ export type World = {
4
+ pk: string;
5
+ at: string;
6
+ name: string;
7
+ home?: boolean;
8
+ };
9
+ export type Joined = {
10
+ read(): World[];
11
+ write(worlds: World[]): void;
12
+ };
13
+ export declare const KEY = "quo-worlds";
14
+ export declare function inStorage(storage: Pick<Storage, 'getItem' | 'setItem'>): Joined;
15
+ export declare function joined(list: World[], met: World): World[];
16
+ export declare function ordered(list: World[]): World[];
17
+ export declare const localName: (pk: string) => string;
18
+ export declare function world(harbor: Harbor, pk: string): Promise<Hosted>;
19
+ export type Relation = {
20
+ key: string;
21
+ avatar: Avatar;
22
+ };
23
+ export declare function relations(ward: Hosted): Relation[];
24
+ export declare function fresh(ward: Hosted): Promise<Relation>;
@@ -0,0 +1,99 @@
1
+ import { Avatar, USER } from '../beings/avatar.js';
2
+ import { settled } from '../dock/faculty.js';
3
+ // The list as a browser keeps it, the tab's and the app's alike: one item,
4
+ // under the key the tab has always used. A device with no storage still
5
+ // works and forgets, which is why every read has a floor.
6
+ export const KEY = 'quo-worlds';
7
+ export function inStorage(storage) {
8
+ return {
9
+ read: () => {
10
+ try {
11
+ const raw = JSON.parse(storage.getItem(KEY) ?? 'null');
12
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw))
13
+ return [];
14
+ return Object.entries(raw).flatMap(([pk, v]) => {
15
+ const w = (v ?? {});
16
+ const at = typeof w.at === 'string' ? w.at : typeof w.url === 'string' ? w.url : '';
17
+ return at ? [{ pk, at, name: typeof w.name === 'string' ? w.name : '', ...(w.home === true ? { home: true } : {}) }] : [];
18
+ });
19
+ }
20
+ catch {
21
+ return [];
22
+ }
23
+ },
24
+ write: (worlds) => {
25
+ try {
26
+ 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 } : {}) }]))));
27
+ }
28
+ catch {
29
+ /* forgets */
30
+ }
31
+ },
32
+ };
33
+ }
34
+ // One world joined, or met again: the same pk keeps its place in the list
35
+ // and takes the newer address, any name it has gained and whether it is a
36
+ // home, and a world not seen before goes to the end. The list is in the
37
+ // order they were joined; `ordered` is how a switcher shows them.
38
+ export function joined(list, met) {
39
+ const at = list.findIndex((w) => w.pk === met.pk);
40
+ if (at === -1)
41
+ return [...list, met];
42
+ const out = [...list];
43
+ out[at] = { ...out[at], at: met.at, ...(met.name ? { name: met.name } : {}), ...(met.home !== undefined ? { home: met.home } : {}) };
44
+ return out;
45
+ }
46
+ // Home first, memberships behind it, each in the order joined: what a
47
+ // person thinks of as "me" before the worlds that know an id of hers. A
48
+ // membership with no home stands where it is.
49
+ export function ordered(list) {
50
+ return [...list.filter((w) => w.home), ...list.filter((w) => !w.home)];
51
+ }
52
+ // The local ward for a far world, by its pk: created on first sight, on
53
+ // the tab's own seed, empty.
54
+ export const localName = (pk) => `w-${pk.slice(0, 16)}`;
55
+ export async function world(harbor, pk) {
56
+ const name = localName(pk);
57
+ return harbor.wards.get(name) ?? (await harbor.create(name, 'me'));
58
+ }
59
+ export function relations(ward) {
60
+ const out = [];
61
+ for (const key of ward.keys()) {
62
+ const a = ward.being(key);
63
+ if (a instanceof Avatar && a.standings[USER])
64
+ out.push({ key, avatar: a });
65
+ }
66
+ return out;
67
+ }
68
+ // A presence being for a relation not yet made: one who holds no standing
69
+ // at a world, whether left by a guest who never came in or booted now under
70
+ // the next free key. She is the one who knocks, as a guest or with an
71
+ // invitation, and she becomes a relation the moment she takes her standing.
72
+ // She is the being of that world who lives on this device, holding what the
73
+ // box lends from her birth, and a bare tab lends her nothing.
74
+ export async function fresh(ward) {
75
+ for (const key of ward.keys()) {
76
+ const a = ward.being(key);
77
+ if (a instanceof Avatar && !a.standings[USER])
78
+ return { key, avatar: a };
79
+ }
80
+ // The next free key, found by asking rather than by reading a table: boot
81
+ // refuses a key that has a row, so the ward is the one that knows, and a
82
+ // row whose class did not construct this run is a taken key the harbor
83
+ // has no object for. The bound is there because a loop that asks forever
84
+ // is a tab that hangs.
85
+ for (let n = 1; n <= 64; n++) {
86
+ const key = `r${n}`;
87
+ const out = (await ward.ask('boot', { key, class: 'Presence' }));
88
+ if (out.error)
89
+ continue;
90
+ // She takes what the box lends at her birth, and birth is synchronous
91
+ // while taking a standing is not. She is handed back when they have
92
+ // landed, not before: a screen painted from her describe a moment early
93
+ // is a page missing every faculty of the box, and nothing paints it
94
+ // again until the person navigates.
95
+ await settled();
96
+ return { key, avatar: ward.being(key) };
97
+ }
98
+ throw new Error('the tab could not boot an avatar: every key it tries is taken');
99
+ }
@@ -0,0 +1,10 @@
1
+ import type { Json, JsonObject } from '@nervur-org/nervur';
2
+ import type { Avatar } from '../beings/avatar.ts';
3
+ import { type Serving } from '../beings/side.ts';
4
+ export type Run = (event: JsonObject) => Promise<Json>;
5
+ export declare const REPORT = "report";
6
+ export declare function processRun(command: string, args: string[], dir: string): Run;
7
+ export type Agent = Serving & {
8
+ idle(): Promise<void>;
9
+ };
10
+ export declare function agentSide(avatar: Avatar, run: Run, after?: () => Promise<void>): Agent;
@@ -0,0 +1,78 @@
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 { word, wordText, SILENCE_TEXT, UNREACHED_TEXT } from '../beings/side.js';
14
+ // The ask on the user being that takes the result. Gated to devices; the
15
+ // agent's client identity is what the gate reads.
16
+ export const REPORT = 'report';
17
+ // `claude -p` or any CLI agent: the command runs in the agent's folder with
18
+ // the event, as JSON, as its last argument. The result is what it printed.
19
+ // A non-zero exit is an error object with what it said on stderr.
20
+ export function processRun(command, args, dir) {
21
+ return (event) => new Promise((ok) => {
22
+ const child = spawn(command, [...args, JSON.stringify(event)], { cwd: dir, stdio: ['ignore', 'pipe', 'pipe'] });
23
+ let out = '';
24
+ let err = '';
25
+ child.stdout.on('data', (c) => (out += c.toString()));
26
+ child.stderr.on('data', (c) => (err += c.toString()));
27
+ child.on('error', (e) => ok({ error: e.message }));
28
+ child.on('close', (code) => ok(code === 0 ? { output: out.trim() } : { error: `exit ${code}`, stderr: err.trim() }));
29
+ });
30
+ }
31
+ // `after` runs when her cells changed and when an ask is done, as in the
32
+ // other sides: a harbor that must write what the ward changed hooks it.
33
+ export function agentSide(avatar, run, after = async () => { }) {
34
+ let draining = Promise.resolve();
35
+ let open = true;
36
+ const inbox = () => avatar.cells.inbox;
37
+ async function one(event) {
38
+ let result;
39
+ try {
40
+ result = await run(event);
41
+ }
42
+ catch (e) {
43
+ result = { error: e instanceof Error ? e.message : String(e) };
44
+ }
45
+ // The report is an ask like any other. Silence and unreached are told
46
+ // apart and dropped: a report is never retried, and never held.
47
+ const w = word(await avatar.call(REPORT, { event, result }));
48
+ if (w.word === 'silence' || w.word === 'unreached' || w.word === 'quo')
49
+ say({ dropped: w.word === 'silence' ? SILENCE_TEXT : w.word === 'quo' ? wordText(w.name) : UNREACHED_TEXT });
50
+ await after();
51
+ }
52
+ // One at a time, first in first out, until the inbox is empty.
53
+ function drain() {
54
+ draining = draining.then(async () => {
55
+ while (open && inbox().length > 0) {
56
+ const event = inbox().shift();
57
+ await after(); // the event left the queue before the run starts: a crash mid-run loses it, and never runs it twice
58
+ await one(event);
59
+ }
60
+ });
61
+ return draining;
62
+ }
63
+ // A dropped report is told to her other sides, if any: the agent itself has no ear for it.
64
+ const say = (object) => { for (const ear of avatar.ears)
65
+ if (ear !== ear0)
66
+ ear(object); };
67
+ const ear0 = () => void drain();
68
+ avatar.ears.add(ear0);
69
+ void drain(); // whatever waited while no side was up
70
+ return {
71
+ idle: () => draining,
72
+ close: async () => {
73
+ open = false;
74
+ avatar.ears.delete(ear0);
75
+ await draining;
76
+ },
77
+ };
78
+ }
@@ -0,0 +1,20 @@
1
+ import type { Invitation } from '@nervur-org/nervur';
2
+ import type { OAuth } from './oauth.ts';
3
+ export type Join = (identity: string, invitation: Invitation, ward: string) => Promise<{
4
+ error?: string;
5
+ }>;
6
+ export type Options = {
7
+ oauth: OAuth;
8
+ join: Join;
9
+ worlds: () => {
10
+ ward: string;
11
+ pk: string;
12
+ user: string;
13
+ }[];
14
+ };
15
+ export declare const suggest: (name: string) => string;
16
+ export declare class Exchange {
17
+ readonly o: Options;
18
+ constructor(o: Options);
19
+ handle(req: Request, rest: string): Promise<Response | undefined>;
20
+ }
@@ -0,0 +1,88 @@
1
+ import { readForm } from './oauth.js';
2
+ import { invitationOf } from '../beings/link.js';
3
+ import { CSS } from '../human/style.js';
4
+ import { POLICY } from '../human/door.js';
5
+ import { MARK_SVG } from '../human/mark.js';
6
+ const esc = (s) => s.replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c] ?? c);
7
+ const word = (s) => (typeof s === 'string' && /^[\w.-]{1,40}$/.test(s) ? s : null);
8
+ // A client's name as an identity: lowercase words joined by dashes.
9
+ export const suggest = (name) => name
10
+ .toLowerCase()
11
+ .replace(/[^a-z0-9]+/g, '-')
12
+ .replace(/^-|-$/g, '')
13
+ .slice(0, 40) || 'client';
14
+ export class Exchange {
15
+ o;
16
+ constructor(o) {
17
+ this.o = o;
18
+ }
19
+ // A page in, a page out: this side answers as the endpoint beside it does,
20
+ // and a path that is not one of its two is nothing it declines by
21
+ // answering nothing.
22
+ async handle(req, rest) {
23
+ const url = new URL(req.url);
24
+ const page = (status, body) => 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' } });
25
+ const go = (to) => new Response(null, { status: 302, headers: { location: to, 'cache-control': 'no-store' } });
26
+ if (rest === '/allow' && req.method === 'GET') {
27
+ const request = url.searchParams.get('request') ?? '';
28
+ const p = this.o.oauth.pending(request);
29
+ if (!p)
30
+ return page(400, `<h1>Nothing to allow</h1><p>This request is gone. Start again from the app that sent you here.</p>`);
31
+ return page(200, allowForm(request, p.client.client_name, p.redirect_uri, suggest(p.client.client_name), this.o.worlds()));
32
+ }
33
+ if (rest === '/allow' && req.method === 'POST') {
34
+ const f = await readForm(req);
35
+ const request = f.get('request') ?? '';
36
+ const p = this.o.oauth.pending(request);
37
+ if (!p)
38
+ return page(400, `<h1>Nothing to allow</h1><p>This request is gone.</p>`);
39
+ if (f.get('decision') !== 'allow') {
40
+ const out = await this.o.oauth.deny(request);
41
+ return 'redirect' in out ? go(out.redirect) : page(400, `<h1>Gone</h1>`);
42
+ }
43
+ const worlds = this.o.worlds();
44
+ const again = (err) => page(400, allowForm(request, p.client.client_name, p.redirect_uri, word(f.get('identity')) ?? suggest(p.client.client_name), worlds, err));
45
+ const inv = invitationOf(f.get('invitation') ?? '');
46
+ if (!inv)
47
+ return again('That is not an invitation.');
48
+ const world = worlds.find((w) => w.pk === inv.ward);
49
+ if (!world)
50
+ return again('That invitation is for a world that does not live here.');
51
+ const identity = word(f.get('identity'));
52
+ if (identity === null || identity === world.user || identity === 'desk')
53
+ return again('An identity is one word, and not the user or the desk.');
54
+ const joined = await this.o.join(identity, inv, world.ward);
55
+ if (joined.error)
56
+ return again(`Not in: ${joined.error}.`);
57
+ const out = await this.o.oauth.complete(request, identity, world.ward);
58
+ return 'redirect' in out ? go(out.redirect) : page(400, `<h1>Gone</h1>`);
59
+ }
60
+ return undefined;
61
+ }
62
+ }
63
+ // The one stylesheet, and no colour of this page's own. The policy is the
64
+ // one every page Quo writes itself is served under, `human/door.ts`, since
65
+ // this is one of them: it runs no script of its own and wants none.
66
+ const shell = (body) => `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>quo</title>
67
+ <style>${CSS}</style>
68
+ </head><body><main class="door">${body}</main></body></html>`;
69
+ // The one page, and every sentence on it is something a human needs in
70
+ // order to decide. Who is asking, what it will be able to see, that it is
71
+ // bounded by a gate rather than by a promise, that she can end it, and
72
+ // where it goes when she is done. The world is the invitation's, so the
73
+ // page says which worlds live here and whose they are, and asks her to
74
+ // choose none of them.
75
+ const allowForm = (request, client, redirect, identity, worlds, err = '') => {
76
+ const whose = worlds.map((w) => `<strong>${esc(w.user)}</strong> in ${esc(w.ward)}`).join(', ');
77
+ return `${MARK_SVG}
78
+ <h1 class="t-title">Allow ${esc(client)}?</h1>
79
+ <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>
80
+ <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>
81
+ <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>
82
+ <p class="t-quiet">When you are done it returns to <code>${esc(redirect)}</code>.</p>
83
+ ${err ? `<p class="answer error">${esc(err)}</p>` : ''}
84
+ <form method="post" action=""><input type="hidden" name="request" value="${esc(request)}">
85
+ <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>
86
+ <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>
87
+ <p><button type="submit" name="decision" value="allow">Allow</button> <button type="submit" name="decision" value="deny">Deny</button></p></form>`;
88
+ };
@@ -0,0 +1,27 @@
1
+ import { WebStandardStreamableHTTPServerTransport as StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js';
2
+ import type { Avatar } from '../beings/avatar.ts';
3
+ import type { Serving } from '../beings/side.ts';
4
+ export type Session = {
5
+ identity: string;
6
+ ward: string;
7
+ transport: StreamableHTTPServerTransport;
8
+ serving: Serving;
9
+ touched: number;
10
+ };
11
+ export declare const SESSION_IDLE: number;
12
+ export type Resolve = (identity: string, ward: string) => Promise<{
13
+ avatar?: Avatar;
14
+ error?: string;
15
+ }>;
16
+ export declare class McpHttp {
17
+ readonly sessions: Map<string, Session>;
18
+ readonly resolve: Resolve;
19
+ readonly after: () => Promise<void>;
20
+ now: () => number;
21
+ gone: (identity: string, ward: string) => Promise<void>;
22
+ constructor(resolve: Resolve, after?: () => Promise<void>);
23
+ handle(req: Request, identity: string, ward: string): Promise<Response>;
24
+ sweep(): void;
25
+ drop(sid: string): void;
26
+ close(): Promise<void>;
27
+ }
@@ -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 { mcpSide } from './server.js';
13
+ export const SESSION_IDLE = 60 * 60 * 1000;
14
+ export class McpHttp {
15
+ sessions = new Map();
16
+ resolve;
17
+ after;
18
+ now = () => Date.now();
19
+ // What to do with an identity the user being has removed: the route's
20
+ // revoke, so the client's tokens go with the occupant. Set by whoever
21
+ // mounts the route beside the credential exchange.
22
+ gone = async () => { };
23
+ constructor(resolve, after = async () => { }) {
24
+ this.resolve = resolve;
25
+ this.after = after;
26
+ }
27
+ // `identity` is what the bearer named; the caller has already turned a
28
+ // stranger away. A request on a known session goes to it. A request with
29
+ // no session opens one, if it is an initialize; anything else is 400.
30
+ async handle(req, identity, ward) {
31
+ const sid = req.headers.get('mcp-session-id') ?? undefined;
32
+ const json = (status, body) => Response.json(body, { status });
33
+ this.sweep();
34
+ if (sid !== undefined) {
35
+ const s = this.sessions.get(sid);
36
+ if (!s)
37
+ return json(404, { jsonrpc: '2.0', error: { code: -32001, message: 'no such session' }, id: null });
38
+ if (s.identity !== identity || s.ward !== ward)
39
+ return json(403, { jsonrpc: '2.0', error: { code: -32003, message: 'not your session' }, id: null });
40
+ s.touched = this.now();
41
+ const out = await s.transport.handleRequest(req);
42
+ if (req.method === 'DELETE')
43
+ this.drop(sid);
44
+ return out;
45
+ }
46
+ if (req.method !== 'POST')
47
+ return json(400, { jsonrpc: '2.0', error: { code: -32000, message: 'no session' }, id: null });
48
+ const found = await this.resolve(identity, ward);
49
+ // A session opens on her describe, and admit already asked it. The one
50
+ // word the door says for an identity the user being removed is
51
+ // `removed`, under the key it bound for her avatar: in MCP's vocabulary
52
+ // that is 401, the client drops its token and starts the exchange again,
53
+ // and the route forgets the grant.
54
+ if (found.error === 'removed') {
55
+ await this.gone(identity, ward);
56
+ for (const [key, s] of this.sessions)
57
+ if (s.identity === identity && s.ward === ward)
58
+ this.drop(key);
59
+ return json(401, { jsonrpc: '2.0', error: { code: -32001, message: 'removed: the user being removed this identity' }, id: null });
60
+ }
61
+ if (!found.avatar)
62
+ return json(500, { jsonrpc: '2.0', error: { code: -32002, message: found.error ?? 'no avatar' }, id: null });
63
+ const transport = new StreamableHTTPServerTransport({
64
+ // the web crypto's, not node's: a session id is a fresh uuid on every
65
+ // runtime and this endpoint names no terrain
66
+ sessionIdGenerator: () => globalThis.crypto.randomUUID(),
67
+ onsessioninitialized: (s) => {
68
+ this.sessions.set(s, { identity, ward, transport, serving, touched: this.now() });
69
+ },
70
+ onsessionclosed: (s) => this.drop(s),
71
+ });
72
+ const serving = await mcpSide(found.avatar, transport, this.after);
73
+ return transport.handleRequest(req);
74
+ }
75
+ sweep() {
76
+ const now = this.now();
77
+ for (const [sid, s] of this.sessions)
78
+ if (now - s.touched > SESSION_IDLE)
79
+ this.drop(sid);
80
+ }
81
+ drop(sid) {
82
+ const s = this.sessions.get(sid);
83
+ if (!s)
84
+ return;
85
+ this.sessions.delete(sid);
86
+ void s.serving.close().catch(() => { });
87
+ }
88
+ async close() {
89
+ for (const sid of [...this.sessions.keys()])
90
+ this.drop(sid);
91
+ }
92
+ }
@@ -0,0 +1,91 @@
1
+ import { type Grants } from '../dock/grants.ts';
2
+ export { ACCESS_TTL, REFRESH_TTL } from '../dock/grants.ts';
3
+ export declare const CODE_TTL: number;
4
+ export type Client = {
5
+ client_id: string;
6
+ client_name: string;
7
+ redirect_uris: string[];
8
+ exp: number;
9
+ };
10
+ export type Pending = {
11
+ client_id: string;
12
+ redirect_uri: string;
13
+ challenge: string;
14
+ state: string | null;
15
+ resource: string | null;
16
+ exp: number;
17
+ };
18
+ export type Dance = {
19
+ clients: Record<string, Client>;
20
+ pending: Record<string, Pending>;
21
+ codes: Record<string, Pending & {
22
+ identity: string;
23
+ ward: string;
24
+ }>;
25
+ };
26
+ export declare const emptyDance: () => Dance;
27
+ export type Store = {
28
+ load(): Promise<Dance>;
29
+ save(dance: Dance): Promise<void>;
30
+ };
31
+ export type Options = {
32
+ issuer: string;
33
+ resource: string;
34
+ finish: (request: string) => string;
35
+ store: Store;
36
+ grants: Grants;
37
+ now?: () => number;
38
+ };
39
+ export declare class OAuth {
40
+ readonly o: Options;
41
+ readonly dance: Dance;
42
+ static open(o: Options): Promise<OAuth>;
43
+ constructor(o: Options, dance: Dance);
44
+ keep(): Promise<void>;
45
+ now(): number;
46
+ metadata(): {
47
+ issuer: string;
48
+ authorization_endpoint: string;
49
+ token_endpoint: string;
50
+ registration_endpoint: string;
51
+ response_types_supported: string[];
52
+ grant_types_supported: string[];
53
+ code_challenge_methods_supported: string[];
54
+ token_endpoint_auth_methods_supported: string[];
55
+ scopes_supported: string[];
56
+ };
57
+ protectedResource(): {
58
+ resource: string;
59
+ authorization_servers: string[];
60
+ bearer_methods_supported: string[];
61
+ };
62
+ register(body: unknown): Promise<Client | {
63
+ error: string;
64
+ }>;
65
+ client(id: string): Client | undefined;
66
+ authorize(q: URLSearchParams): Promise<{
67
+ redirect: string;
68
+ } | {
69
+ error: string;
70
+ }>;
71
+ pending(id: string): (Pending & {
72
+ client: Client;
73
+ }) | null;
74
+ complete(id: string, identity: string, ward: string): Promise<{
75
+ redirect: string;
76
+ } | {
77
+ error: string;
78
+ }>;
79
+ deny(id: string): Promise<{
80
+ redirect: string;
81
+ } | {
82
+ error: string;
83
+ }>;
84
+ token(body: URLSearchParams): Promise<Record<string, unknown>>;
85
+ issue(identity: string, ward: string, client_id: string): Promise<import("../dock/grants.ts").Tokens>;
86
+ sweep(): void;
87
+ handle(req: Request, rest: string): Promise<Response | undefined>;
88
+ challenge(): Response;
89
+ }
90
+ export declare function readBody(req: Request, limit?: number): Promise<string>;
91
+ export declare function readForm(req: Request): Promise<URLSearchParams>;