@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
package/human/wake.ts ADDED
@@ -0,0 +1,191 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // The device's half of the doorbell, worn by every shell: the tab's, the
3
+ // worker's behind a closed tab, and the app's. A ring carries nothing but a
4
+ // sealed line, so waking is three moves and the first two are the harbor's
5
+ // own. The device tells its dialers it is back, since a socket that died
6
+ // while it slept died silently; it asks each relation in each world what it
7
+ // missed, which is an ordinary ask on an ordinary standing, sealed like
8
+ // every other; and it composes what the human sees from the objects that
9
+ // came back, here, on the device, from bytes no push service ever held.
10
+ //
11
+ // Each object lands on the avatar exactly where a push would have landed,
12
+ // so a page that is open shows what it missed in its own notices as well.
13
+ //
14
+ // A phone and a desk hold a doorbell of their own at a bell, and hand the
15
+ // platform's token to that doorbell and to nobody else; what a world is
16
+ // given is a standing at that doorbell and the banner key it seals to. A
17
+ // tab hands its browser endpoint instead and keeps no doorbell, because a
18
+ // browser endpoint is rung only by whoever holds the VAPID secret the
19
+ // subscription was made under, and that secret is the world's own.
20
+ import { isInvitation, isSilence, isWord } from '@nervur-org/nervur';
21
+ import type { Invitation, JsonObject, Standing } from '@nervur-org/nervur';
22
+ import type { Harbor } from '@nervur-org/nervur/harbor';
23
+ import { DOCK, CONSOLE, NOTIFICATIONS, facultyOf, type Token } from '../dock/index.ts';
24
+ import type { Notifications } from '../dock/notifications.ts';
25
+ import { banner } from '../dock/push.ts';
26
+ import { localName, relations } from './worlds.ts';
27
+
28
+ // The token a browser's push service hands a page: the endpoint, and the
29
+ // two keys of the subscription made at it. RFC 8291 encrypts a body to
30
+ // those keys, which is how a ring carries the world's own line, so a token
31
+ // without them is a device that is woken and told nothing.
32
+ export const webToken = (endpoint: string, p256dh?: string, auth?: string) => ({ kind: 'web', endpoint, ...(p256dh && auth ? { p256dh, auth } : {}) });
33
+
34
+ // Where this device's bell stands: the pk of its ward and the rendezvous it
35
+ // is reached over. Both are the build's and never a being's, exactly as the
36
+ // ear is and for the same reason: where a bell stands is a build's
37
+ // business. A shell handed none holds no doorbell and tells a world nothing.
38
+ export type Bell = { pk: string; at: string };
39
+
40
+ // The id this device holds its doorbell under, on the box's own being.
41
+ export const DOORBELL = 'doorbell';
42
+
43
+ // A doorbell this device holds. `keep` is the platform's token, said again
44
+ // whenever the platform hands out a new one, and no world is told: that is
45
+ // the whole of why a rotation costs one write. `admit` is one world allowed,
46
+ // the way in it will ring on and the banner key it seals to.
47
+ export type Held = {
48
+ keep(token: Token): Promise<boolean>;
49
+ admit(): Promise<{ invitation: Invitation; banner: string } | null>;
50
+ };
51
+
52
+ const said = (out: unknown): JsonObject | null => (out === undefined || isSilence(out) || isWord(out) || out === null || typeof out !== 'object' || Array.isArray(out) ? null : (out as JsonObject));
53
+
54
+ // The box's own being, as the device's own code reaches its own object in
55
+ // its own process. Nothing of this crosses an edge.
56
+ const box = (harbor: Harbor): { standings: { knock(i: Invitation, m?: string, a?: JsonObject): Promise<unknown> } & Record<string, Standing | undefined> } | undefined =>
57
+ harbor.wards.get(DOCK)?.being(CONSOLE) as never;
58
+
59
+ // The doorbell this device holds, taken the first time and kept after that.
60
+ // A stranger phone knocks the bell's public being, is booted a doorbell of
61
+ // its own and comes away with the one way in; the standing lives on the
62
+ // box's own being, because a doorbell is the device's and not any world's.
63
+ //
64
+ // Null is a device with no bell behind it, which is what a build that names
65
+ // none looks like and what a bell that will not answer looks like. Each of
66
+ // them works in every other way.
67
+ export async function doorbell(harbor: Harbor, bell?: Bell): Promise<Held | null> {
68
+ if (!bell) return null;
69
+ const ward = harbor.wards.get(DOCK);
70
+ const me = box(harbor);
71
+ if (!ward || !me) return null;
72
+ if (!me.standings[DOORBELL]) {
73
+ const hello = said(await me.standings.knock({ ward: bell.pk } as Invitation, 'hello', {}));
74
+ const inv = hello?.invitation;
75
+ if (!isInvitation(inv)) return null;
76
+ // The ward's own knock, which knocks and takes in one act: an
77
+ // invitation is taken only where it was answered, so the two halves are
78
+ // never split.
79
+ const took = said(await ward.ask('knock', { being: CONSOLE, id: DOORBELL, invitation: inv }));
80
+ await ward.save();
81
+ if (took?.taken !== DOORBELL) return null;
82
+ }
83
+ const mine = me.standings[DOORBELL];
84
+ if (!mine) return null;
85
+ return {
86
+ keep: async (token: Token) => {
87
+ const out = said(await mine.ask('keep', { token }));
88
+ await ward.save();
89
+ return typeof out?.kept === 'string';
90
+ },
91
+ admit: async () => {
92
+ // The banner key is minted on the first ask and kept, and a terrain
93
+ // whose banners are drawn by a second process plants the secret half
94
+ // where that process reads it while this is happening.
95
+ const keys = facultyOf<Notifications>(harbor, NOTIFICATIONS);
96
+ const key = keys ? said(await keys.banner({}))?.banner : undefined;
97
+ if (typeof key !== 'string') return null;
98
+ const out = said(await mine.ask('admit', {}));
99
+ await ward.save();
100
+ const inv = out?.invitation;
101
+ return isInvitation(inv) ? { invitation: inv, banner: key } : null;
102
+ },
103
+ };
104
+ }
105
+
106
+ // One world told how to wake this device. What a world is given is a
107
+ // standing at this device's doorbell and the banner key it seals to, never
108
+ // the platform's token: the same token at every world is a bearer
109
+ // credential, and a standing is not one.
110
+ //
111
+ // A standing does not rotate, so it is handed over once. The world is asked
112
+ // what it already holds and told nothing when it holds a doorbell, which is
113
+ // what keeps the doorbell's list of the worlds it allows a list a human can
114
+ // read and cut, rather than one row per time a page was opened. A browser
115
+ // endpoint is said every time, because a push service may hand out a new
116
+ // one at any moment and a stale one rings an empty house.
117
+ //
118
+ // A device with no bell behind it and no browser endpoint says nothing, and
119
+ // works in every other way.
120
+ export async function told(rel: { call(name: string, args?: JsonObject): Promise<unknown> }, at: Held | null, token?: Token | null): Promise<string | null> {
121
+ const has = said(await rel.call('bell', {}));
122
+ const kept = typeof has?.kept === 'string' ? has.kept : null;
123
+ if (at) {
124
+ if (kept === 'doorbell') return kept;
125
+ const got = await at.admit();
126
+ if (!got) return null;
127
+ const out = said(await rel.call('bell', { invitation: got.invitation, banner: got.banner }));
128
+ return typeof out?.kept === 'string' ? out.kept : null;
129
+ }
130
+ if (token?.kind !== 'web') return null;
131
+ const out = said(await rel.call('bell', { token }));
132
+ return typeof out?.kept === 'string' ? out.kept : null;
133
+ }
134
+
135
+ // The tab's doorbell: the worker registered on this world's origin, whose
136
+ // scope is the folder it is served from and so covers every world page
137
+ // there, and a subscription at whatever push service this browser keeps.
138
+ // The world's key goes over as the base64url string it already is. Null is
139
+ // a device with no doorbell, which is a browser with no push service, a
140
+ // human who was asked and said no, or a human who has not been asked; each
141
+ // of them works in every other way, and the last is why `ask` exists.
142
+ export async function subscribe(web: string, key: string, ask = false): Promise<{ kind: string; endpoint: string } | null> {
143
+ const workers = globalThis.navigator?.serviceWorker;
144
+ if (!workers || !('PushManager' in globalThis) || !('Notification' in globalThis)) return null;
145
+ try {
146
+ const registration = await workers.register(`${web}/sw.js`, { type: 'module' });
147
+ await workers.ready;
148
+ if (Notification.permission === 'default' && ask) await Notification.requestPermission();
149
+ if (Notification.permission !== 'granted') return null;
150
+ const held = await registration.pushManager.getSubscription();
151
+ const sub = held ?? (await registration.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: key }));
152
+ // The keys are read off the subscription as it writes them itself,
153
+ // base64url, which is the form a world encrypts to.
154
+ const keys = (sub.toJSON() as { keys?: { p256dh?: string; auth?: string } }).keys;
155
+ return webToken(sub.endpoint, keys?.p256dh, keys?.auth);
156
+ } catch {
157
+ return null; // a browser with no push service behind it: no doorbell, and everything else stands
158
+ }
159
+ }
160
+
161
+ // What every relation in every world here missed, in the order it was
162
+ // pushed, drained and gone. A world this device is not in has no relation
163
+ // and answers nothing.
164
+ export async function drained(harbor: Harbor, pks: string[]): Promise<JsonObject[]> {
165
+ const out: JsonObject[] = [];
166
+ for (const pk of pks) {
167
+ const ward = harbor.wards.get(localName(pk));
168
+ if (!ward) continue;
169
+ for (const rel of relations(ward)) {
170
+ const got = await rel.avatar.call('missed');
171
+ const objects = (got as { missed?: unknown }).missed;
172
+ if (!Array.isArray(objects)) continue;
173
+ for (const o of objects) {
174
+ if (o === null || typeof o !== 'object' || Array.isArray(o)) continue;
175
+ rel.avatar.notify(o as JsonObject); // where the push would have arrived
176
+ out.push(o as JsonObject);
177
+ }
178
+ }
179
+ await ward.save(); // her keys rotated on that ask, whatever it answered
180
+ }
181
+ return out;
182
+ }
183
+
184
+ // What the human reads on the lock screen, composed from the objects and
185
+ // from nothing else. It is the same line a world seals into a ring, made
186
+ // here from what actually crossed, and the human is told how many are
187
+ // behind the last one.
188
+ export function notice(objects: JsonObject[]): { title: string; body: string } {
189
+ const line = banner(objects[objects.length - 1] ?? {});
190
+ return { ...line, body: objects.length > 1 ? `${line.body} (and ${objects.length - 1} more)` : line.body };
191
+ }
package/human/web.ts ADDED
@@ -0,0 +1,259 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // The web route: the worlds' pages, served by the daemon under `/web` and
3
+ // mapped by the proxy from the web. hostname. A world is a ward with a
4
+ // public being, and its address is `/web/<ward>`: the tab page, `tab.ts`,
5
+ // told which ward this is, and every ward on the harbor by name, pk and
6
+ // whether a public being is at its door, so that its guest is that ward's
7
+ // public being rendered by the screen, whatever class she is. Every ward
8
+ // has a page, since a link may point into one with nobody at the door;
9
+ // `/web/` lists the worlds and carries the tab too, so a link at the root
10
+ // lands in the ward its invitation names, and with no world to list it is
11
+ // the door page, `door.ts`. Nothing here authenticates and nothing is
12
+ // admitted on this side: the way into a world is an invitation, carried by
13
+ // a link, and the knock is the tab's. Paths this route does not take fall
14
+ // through to the exchange pages, which share the prefix.
15
+ //
16
+ // The tab's code and the service worker's arrive here as strings, built
17
+ // once by whoever built this package and never by this route. A harbor's
18
+ // own tab code is the estate's to build the same way: the folder holds a
19
+ // built `tab/index.js`, and a folder holding source and no built file says
20
+ // so at the path that would have served it.
21
+ //
22
+ // Nothing here reads a file. What a world keeps beside its wards, the
23
+ // design it wears and its own code for the tab, is asked of the terrain
24
+ // through `Folder`, the way a ward's classes are asked of a loader. So this
25
+ // route runs wherever a harbor does, and a folder is one terrain's answer
26
+ // to it.
27
+ //
28
+ // The page carries a content security policy: scripts from this origin
29
+ // only and never inline, connections to this origin and the quo. route the
30
+ // tab dials, images from data URIs and this origin, and nothing else. So
31
+ // even a bug in a renderer cannot become a script, and no look can reach a
32
+ // server. The config crosses in a JSON script, which the policy allows.
33
+ import type { Handler } from '../harbor/http.ts';
34
+ import type { Hosted } from '@nervur-org/nervur/harbor';
35
+ import { DOCK } from '../dock/index.ts';
36
+ import { publicKey } from '../harbor/ward.ts';
37
+ import { ANDROID, APPLE, association, type Apps } from './apps.ts';
38
+ import { door } from './door.ts';
39
+ import { BARE, CSS } from './style.ts';
40
+ import type { Config } from './tab.ts';
41
+
42
+ // The apps a world's links may open: an estate names them and this route
43
+ // says so where the two platforms look, in the words `apps.ts` holds for
44
+ // every hostname that says it. A join link is a link to a world's page, so
45
+ // the domain a phone verifies is the world's own web. hostname, and the
46
+ // app that claims it names that hostname in its entitlements. An estate
47
+ // with no apps of its own names none, the files are not served, and every
48
+ // link opens the tab, which is the rented room and says so.
49
+ export type { Apps };
50
+
51
+ // What a world keeps beside its wards, asked of the terrain that holds it.
52
+ // It is asked at every request and never taken once at boot, because a
53
+ // folder is worn, built and changed while the daemon runs.
54
+ export type Folder = {
55
+ // which of a design's own stylesheets this world holds. `tokens.css` is
56
+ // the design itself and nothing is worn without it; `fonts.css` is there
57
+ // only when the design speaks in its own faces.
58
+ wearing(): Promise<{ tokens: boolean; fonts: boolean }>;
59
+ // one file of the design this world wears, by the name the route asked
60
+ // for, or nothing when the world holds no such file
61
+ design(file: string): Promise<Uint8Array<ArrayBuffer> | string | null>;
62
+ // whether this world means to serve its own code for the tab at all. A
63
+ // page is told, so a tab knows to ask for it.
64
+ serving(): Promise<boolean>;
65
+ // that code, built. Nothing when the world means to serve code and its
66
+ // build did not run, which is said at the path and never passed over.
67
+ beings(): Promise<string | null>;
68
+ };
69
+
70
+ // A world with nothing beside its wards: no design, no code of its own. It
71
+ // is what a terrain hands the route when it holds no folder at all.
72
+ export const BARE_FOLDER: Folder = {
73
+ wearing: () => Promise.resolve({ tokens: false, fonts: false }),
74
+ design: () => Promise.resolve(null),
75
+ serving: () => Promise.resolve(false),
76
+ beings: () => Promise.resolve(null),
77
+ };
78
+
79
+ export type WebOptions = {
80
+ // where the routes are, as a tab sees them: what it dials and where it is
81
+ at: { quo: string; web: string };
82
+ // the tab's bundle and the service worker's, built once by whoever built
83
+ // this package and handed here as code
84
+ code: { tab: string; worker: string };
85
+ // what the world keeps beside its wards; a world that keeps nothing is bare
86
+ folder?: Folder;
87
+ // the apps this world's links open, `app.json` in the harbor folder
88
+ apps?: Apps | null;
89
+ // the public half of this world's doorbell key, which a page subscribes
90
+ // to a push service with. A world whose device holds no bell key sends
91
+ // none and its pages register no worker.
92
+ bell?: string | null;
93
+ // whether a ward is shown at this route, by name; every ward when unsaid
94
+ shown?: (ward: string) => boolean;
95
+ };
96
+
97
+ // The paths under `/web` that are the exchange's, never a ward's name.
98
+ const RESERVED_PATHS = new Set(['allow', 'tab.js', 'beings.js', 'sw.js', 'design', '.well-known']);
99
+
100
+ // The design a world wears: `design/` in the harbor folder, the compiled
101
+ // face of one design and the fonts it speaks in, served under this route
102
+ // and linked by every page after the kit. A harbor with none wears the
103
+ // kit's own face. Only these files, by these names, and nothing else in
104
+ // the folder is reachable.
105
+ const DESIGN = 'design';
106
+ const DESIGN_FILES: Record<string, string> = { 'tokens.css': 'text/css; charset=utf-8', 'fonts.css': 'text/css; charset=utf-8' };
107
+ const FONT = /^fonts\/[\w.-]+\.woff2$/;
108
+
109
+ // The origins a Quo app's webview speaks from: Tauri's on the desk,
110
+ // Capacitor's on iOS, and the plain loopback Android's webview uses. They
111
+ // are the only origins a world tells its config to, and the fetch is a
112
+ // plain GET asking for JSON, which no browser preflights.
113
+ const APP_ORIGINS = new Set(['tauri://localhost', 'capacitor://localhost', 'http://localhost', 'https://localhost']);
114
+
115
+ const appAsking = (req: Request): string | null => {
116
+ const origin = req.headers.get('origin');
117
+ if (origin === null || !APP_ORIGINS.has(origin)) return null;
118
+ return (req.headers.get('accept') ?? '').includes('application/json') ? origin : null;
119
+ };
120
+
121
+ // The world's code for the tab: a module in the harbor folder, the twin of
122
+ // `classes/index.ts`, exporting by name the classes the tab boots into its
123
+ // local ward for a world of this harbor. Served bundled as `beings.js` on
124
+ // this origin, and one origin is one world's code. It is a built file: the
125
+ // estate that deploys this harbor bundles its own tab source and puts the
126
+ // result in the folder, because nothing here runs a compiler.
127
+ export const TAB_CODE = 'tab/index.js';
128
+
129
+ // The source that built file comes from. It is named here for one reason:
130
+ // a folder holding it and no built file has a build that did not run, and
131
+ // that is said at the path rather than passed over in silence.
132
+ export const TAB_SOURCE = 'tab/index.ts';
133
+
134
+ export function webRoute(harbor: { wards: ReadonlyMap<string, Hosted> }, o: WebOptions): Handler {
135
+ const folder = o.folder ?? BARE_FOLDER;
136
+ const quoOrigin = (() => {
137
+ try {
138
+ const u = new URL(o.at.quo);
139
+ return `${u.origin} ${u.origin.replace(/^http/, 'ws')}`;
140
+ } catch {
141
+ return '';
142
+ }
143
+ })();
144
+ const host = (() => {
145
+ try {
146
+ return new URL(o.at.web).host;
147
+ } catch {
148
+ return o.at.web;
149
+ }
150
+ })();
151
+ // `worker-src` is named because it falls back to `default-src`, which is
152
+ // none: without it the page's own service worker, the ear that hears the
153
+ // doorbell while the tab is closed, is refused by the policy.
154
+ const policy = `default-src 'none'; script-src 'self'; worker-src 'self'; style-src 'self' 'unsafe-inline'; font-src 'self'; img-src 'self' data:; connect-src 'self' ${quoOrigin}; form-action 'self'; base-uri 'none'; frame-ancestors 'none'`;
155
+ const design = async () => {
156
+ const worn = await folder.wearing();
157
+ if (!worn.tokens) return `<style>${CSS}</style>`;
158
+ const fonts = worn.fonts ? `<link rel="stylesheet" href="${o.at.web}/${DESIGN}/fonts.css">` : '';
159
+ return `<style>${BARE}</style>${fonts}<link rel="stylesheet" href="${o.at.web}/${DESIGN}/tokens.css">`;
160
+ };
161
+ const shell = async (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>
162
+ ${await design()}
163
+ </head><body>${body}</body></html>`;
164
+ const esc = (v: string) => v.replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c] ?? c);
165
+ const html = async (status: number, body: string) =>
166
+ new Response(await shell(body), { status, headers: { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store', 'content-security-policy': policy, 'referrer-policy': 'no-referrer' } });
167
+ // Each ward is asked which being it marks public: the harbor reads no
168
+ // partition and the mark is the ward's, so the config a page is handed is
169
+ // built from describes, one per world on this harbor.
170
+ // The box's own ward is not a world and is never offered as one: its
171
+ // beings are the harbor's, reached by `lend` from this box alone, and a
172
+ // page that listed it would be offering a stranger a device's own things.
173
+ // A ward is at its door for a stranger's page only when it is shown: the
174
+ // public being still answers a proof and a link still knocks, but a hidden
175
+ // ward's page is the plain door page and its root line is not written.
176
+ const shown = o.shown ?? (() => true);
177
+ const wards = async () => Object.fromEntries(await Promise.all([...harbor.wards].filter(([n]) => n !== DOCK).map(async ([n, h]) => [n, { pk: h.pk, public: shown(n) && (await publicKey(h)) !== null }] as const)));
178
+ const config = async (ward?: string, being?: string): Promise<Config> => ({ quo: o.at.quo, web: o.at.web, wards: await wards(), beings: await folder.serving(), ...(o.bell ? { bell: o.bell } : {}), ...(ward ? { ward } : {}), ...(being ? { being } : {}) });
179
+ const tab = async (ward?: string, being?: string) => {
180
+ return `<script id="quo" type="application/json">${JSON.stringify(await config(ward, being)).replace(/</g, '\\u003c')}</script><script type="module" src="${o.at.web}/tab.js"></script>`;
181
+ };
182
+ // The same config the tab is handed in its page, for an app that is not a
183
+ // tab: it learns a world by this one fetch and never loads the bundle,
184
+ // because its code is its own bundle and a world's code never runs on a
185
+ // phone. Only the app origins are answered, and only this; a browser on
186
+ // any other origin is told nothing, and gets the page instead.
187
+ const asJson = async (origin: string, ward?: string) =>
188
+ new Response(JSON.stringify(await config(ward)), { headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store', 'access-control-allow-origin': origin, vary: 'origin' } });
189
+ return async (req, rest) => {
190
+ const parts = rest.split('/').filter(Boolean);
191
+ if (req.method === 'GET' && (rest === '' || rest === '/')) {
192
+ // the harbor's own config, every ward on it: an app that was sent a
193
+ // link into a ward it does not know learns the whole harbor at once
194
+ const app = appAsking(req);
195
+ if (app !== null) return asJson(app);
196
+ const open: string[] = [];
197
+ for (const [n, h] of harbor.wards) {
198
+ const key = shown(n) ? await publicKey(h) : null;
199
+ if (key !== null) open.push(`<li class="nv-item nv-person"><span class="nv-avatar">${esc(n.charAt(0).toUpperCase())}</span><div class="nv-stack"><p class="nv-t-body"><a href="${o.at.web}/${encodeURIComponent(n)}">${esc(n)}</a></p><p class="nv-t-quiet">${esc(key)} at the door · <span class="nv-key">${h.pk.slice(0, 16)}…</span></p></div></li>`);
200
+ }
201
+ const list = open.join('');
202
+ return html(200, `${list ? `<main data-door><div class="nv-hero"><h1 class="nv-t-title">worlds</h1><p class="nv-t-lead">The worlds on this box that show a face. Each is entered by a link someone sends you.</p><ul class="nv-list" role="list">${list}</ul></div></main>` : door({ host })}${await tab()}`);
203
+ }
204
+ // Where the two platforms look before they let an app claim this
205
+ // hostname's links. The estate names its apps and nothing else is
206
+ // decided here: the file is the claim, the platform verifies it over
207
+ // TLS, and a hostname that names no app is a hostname whose links open
208
+ // the tab. The exchange page is not the app's on purpose: a credential
209
+ // exchange belongs in the browser that started it.
210
+ if (req.method === 'GET' && (rest === APPLE || rest === ANDROID)) {
211
+ const body = association(o.apps, rest, ['/allow*']);
212
+ if (body === null) return undefined;
213
+ return new Response(body, { headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' } });
214
+ }
215
+ if (rest === '/tab.js' && req.method === 'GET') return new Response(o.code.tab, { headers: { 'content-type': 'text/javascript; charset=utf-8', 'cache-control': 'no-store' } });
216
+ // The service worker: the page's ear while the page is closed. It is
217
+ // served from the root of this route so that its scope covers every
218
+ // world's page, and its config is written above the bundle, since a
219
+ // worker has no page to be handed a JSON script in. A world with no
220
+ // doorbell serves no worker, and its pages register none.
221
+ if (rest === '/sw.js' && req.method === 'GET') {
222
+ if (!o.bell) return undefined;
223
+ const said = `self.quo=${JSON.stringify(await config()).replace(/</g, '\\u003c')};\n${o.code.worker}`;
224
+ return new Response(said, { headers: { 'content-type': 'text/javascript; charset=utf-8', 'cache-control': 'no-store', 'service-worker-allowed': '/' } });
225
+ }
226
+ // the design this world wears, when the harbor folder holds one: its
227
+ // stylesheet, its font faces, and the font files those name
228
+ if (rest.startsWith(`/${DESIGN}/`) && req.method === 'GET') {
229
+ const file = rest.slice(DESIGN.length + 2);
230
+ const type = DESIGN_FILES[file] ?? (FONT.test(file) ? 'font/woff2' : null);
231
+ if (type === null) return undefined;
232
+ const bytes = await folder.design(file);
233
+ if (bytes === null) return undefined;
234
+ return new Response(bytes, { headers: { 'content-type': type, 'cache-control': 'public, max-age=86400' } });
235
+ }
236
+ // the world's code for the tab, when the harbor folder holds any; a
237
+ // harbor with none has no such path. A folder holding the source and no
238
+ // built file is a build that did not run, and the page is told so here
239
+ // rather than quietly booting a world with no classes in it.
240
+ if (rest === '/beings.js' && req.method === 'GET') {
241
+ if (!(await folder.serving())) return undefined;
242
+ const own = await folder.beings();
243
+ if (own === null) return new Response(`this world holds ${TAB_SOURCE} and no built ${TAB_CODE}; whoever deploys it emits that file`, { status: 500, headers: { 'content-type': 'text/plain; charset=utf-8', 'cache-control': 'no-store' } });
244
+ return new Response(own, { headers: { 'content-type': 'text/javascript; charset=utf-8', 'cache-control': 'no-store' } });
245
+ }
246
+ const wardName = parts[0] ?? '';
247
+ if (!wardName || RESERVED_PATHS.has(wardName)) return undefined;
248
+ const hosted = harbor.wards.get(wardName);
249
+ if (!hosted) return html(404, `<h1>no such world</h1><p>no ward named ${esc(wardName)} on this harbor.</p>`);
250
+ if (parts.length === 1 && req.method === 'GET') {
251
+ const app = appAsking(req);
252
+ return app === null ? html(200, await tab(wardName)) : asJson(app, wardName);
253
+ }
254
+ // a being's page: one standing the user being carries, by its id, as the whole page. No router:
255
+ // the path is a key, and the tab paints nothing for a key her describe does not carry
256
+ if (parts.length === 2 && req.method === 'GET' && /^[\w.-]{1,80}$/.test(parts[1])) return html(200, await tab(wardName, parts[1]));
257
+ return undefined;
258
+ };
259
+ }