@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,144 @@
1
+ const subtle = () => globalThis.crypto.subtle;
2
+ const ES256 = { name: 'ECDSA', namedCurve: 'P-256' };
3
+ const b64url = (bytes) => {
4
+ let binary = '';
5
+ for (const b of bytes)
6
+ binary += String.fromCharCode(b);
7
+ return btoa(binary).replaceAll('+', '-').replaceAll('/', '_').replaceAll('=', '');
8
+ };
9
+ const unb64url = (v) => {
10
+ const binary = atob(v.replaceAll('-', '+').replaceAll('_', '/'));
11
+ const out = new Uint8Array(binary.length);
12
+ for (let at = 0; at < binary.length; at += 1)
13
+ out[at] = binary.charCodeAt(at);
14
+ return out;
15
+ };
16
+ const utf8 = (v) => new TextEncoder().encode(v);
17
+ // The key a page subscribes with, `applicationServerKey`: the public half
18
+ // as the 65 uncompressed bytes, base64url. It is public by definition and
19
+ // travels in the world's config beside its routes.
20
+ export function publicOf(key) {
21
+ if (typeof key.x !== 'string' || typeof key.y !== 'string')
22
+ throw new Error('a vapid key is a P-256 JWK');
23
+ const x = unb64url(key.x), y = unb64url(key.y);
24
+ const raw = new Uint8Array(65);
25
+ raw[0] = 4;
26
+ raw.set(x, 1);
27
+ raw.set(y, 33);
28
+ return b64url(raw);
29
+ }
30
+ // A world's own keypair, minted once and kept as its secret. The private
31
+ // half is the JWK; the public half is what a page subscribes with.
32
+ export async function mintVapid() {
33
+ const pair = await subtle().generateKey(ES256, true, ['sign', 'verify']);
34
+ const key = await subtle().exportKey('jwk', pair.privateKey);
35
+ return { key, publicKey: publicOf(key) };
36
+ }
37
+ // The signed token one push service is handed, good for twelve hours. The
38
+ // audience is that service's origin and nothing finer: a token minted for
39
+ // one service says nothing to another, and it names no endpoint, so it
40
+ // cannot be read back into which device was rung.
41
+ async function vapidToken(v, endpoint) {
42
+ const key = await subtle().importKey('jwk', v.key, ES256, false, ['sign']);
43
+ const head = b64url(utf8(JSON.stringify({ typ: 'JWT', alg: 'ES256' })));
44
+ const claims = b64url(utf8(JSON.stringify({ aud: new URL(endpoint).origin, exp: Math.floor(Date.now() / 1000) + 12 * 3600, sub: v.subject })));
45
+ const signed = await subtle().sign({ name: 'ECDSA', hash: 'SHA-256' }, key, utf8(`${head}.${claims}`));
46
+ return `${head}.${claims}.${b64url(new Uint8Array(signed))}`;
47
+ }
48
+ // What a push service will carry, RFC 8291's own ceiling, and what the
49
+ // record costs before a byte of the line: the salt, the record size, the
50
+ // length of the key, the key itself, the padding delimiter and the GCM tag.
51
+ const RECORD = 4096;
52
+ const OVERHEAD = 16 + 4 + 1 + 65 + 1 + 16;
53
+ const hmac = async (key, data) => {
54
+ const k = await subtle().importKey('raw', key, { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
55
+ return new Uint8Array(await subtle().sign('HMAC', k, data));
56
+ };
57
+ const joined = (...parts) => {
58
+ const out = new Uint8Array(parts.reduce((n, p) => n + p.length, 0));
59
+ let at = 0;
60
+ for (const p of parts) {
61
+ out.set(p, at);
62
+ at += p.length;
63
+ }
64
+ return out;
65
+ };
66
+ // The keys a subscription hands a world, which RFC 8291 encrypts to: the
67
+ // browser's own P-256 public point and the sixteen bytes of authentication
68
+ // secret it minted with it. A token without them is a subscription made
69
+ // before this or one a browser gave no keys for, and it is rung with no
70
+ // body rather than refused.
71
+ function keysOf(token) {
72
+ if (typeof token.p256dh !== 'string' || typeof token.auth !== 'string')
73
+ return null;
74
+ try {
75
+ const ua = unb64url(token.p256dh), auth = unb64url(token.auth);
76
+ return ua.length === 65 && ua[0] === 4 && auth.length === 16 ? { ua, auth } : null;
77
+ }
78
+ catch {
79
+ return null;
80
+ }
81
+ }
82
+ // One record, `aes128gcm`, RFC 8188 with the key derivation of RFC 8291.
83
+ // The world mints a keypair for this ring alone, agrees with the browser's,
84
+ // and salts each record freshly, so two rings to one device share no key
85
+ // and no nonce.
86
+ export async function encrypt(line, ua, auth) {
87
+ const pair = await subtle().generateKey({ name: 'ECDH', namedCurve: 'P-256' }, true, ['deriveBits']);
88
+ const as = new Uint8Array(await subtle().exportKey('raw', pair.publicKey));
89
+ const theirs = await subtle().importKey('raw', ua, { name: 'ECDH', namedCurve: 'P-256' }, false, []);
90
+ const shared = new Uint8Array(await subtle().deriveBits({ name: 'ECDH', public: theirs }, pair.privateKey, 256));
91
+ // The input keying material is the agreement, stretched under the
92
+ // subscription's auth secret and bound to both public keys, so a record
93
+ // cannot be replayed at a device it was not encrypted for.
94
+ const ikm = await hmac(await hmac(auth, shared), joined(utf8('WebPush: info\0'), ua, as, Uint8Array.of(1)));
95
+ const salt = globalThis.crypto.getRandomValues(new Uint8Array(16));
96
+ const prk = await hmac(salt, ikm);
97
+ const cek = (await hmac(prk, joined(utf8('Content-Encoding: aes128gcm\0'), Uint8Array.of(1)))).slice(0, 16);
98
+ const nonce = (await hmac(prk, joined(utf8('Content-Encoding: nonce\0'), Uint8Array.of(1)))).slice(0, 12);
99
+ // One record, so the padding delimiter is 2 and there is nothing after
100
+ // it. A line too long for what a push service carries is cut at the body,
101
+ // which is where a lock screen cuts it anyway.
102
+ let text = utf8(JSON.stringify(line));
103
+ if (text.length > RECORD - OVERHEAD) {
104
+ const room = RECORD - OVERHEAD - utf8(JSON.stringify({ ...line, body: '' })).length;
105
+ text = utf8(JSON.stringify({ ...line, body: new TextDecoder().decode(utf8(line.body).slice(0, Math.max(0, room))) }));
106
+ }
107
+ const key = await subtle().importKey('raw', cek, 'AES-GCM', false, ['encrypt']);
108
+ const sealed = new Uint8Array(await subtle().encrypt({ name: 'AES-GCM', iv: nonce }, key, joined(text, Uint8Array.of(2))));
109
+ const head = new Uint8Array(21);
110
+ head.set(salt, 0);
111
+ new DataView(head.buffer).setUint32(16, RECORD);
112
+ head[20] = as.length;
113
+ return joined(head, as, sealed);
114
+ }
115
+ // The ground. A `web` token is an endpoint and, when the browser gave them,
116
+ // the two keys of its own subscription, which is what a line is encrypted
117
+ // to. A token with no keys is rung with no body.
118
+ export function webPush(v) {
119
+ const publicKey = publicOf(v.key);
120
+ return {
121
+ async ring(token, _sealed, line) {
122
+ const endpoint = token.endpoint;
123
+ if (typeof endpoint !== 'string' || !/^https?:\/\//.test(endpoint))
124
+ throw new Error('a web token is an endpoint');
125
+ const keys = line ? keysOf(token) : null;
126
+ const body = keys && line ? await encrypt(line, keys.ua, keys.auth) : undefined;
127
+ const res = await fetch(endpoint, {
128
+ method: 'POST',
129
+ headers: {
130
+ authorization: `vapid t=${await vapidToken(v, endpoint)}, k=${publicKey}`,
131
+ ttl: '60',
132
+ ...(body ? { 'content-encoding': 'aes128gcm', 'content-type': 'application/octet-stream' } : {}),
133
+ },
134
+ ...(body ? { body: body } : {}),
135
+ });
136
+ // 404 and 410 are the push service saying this endpoint is gone. The
137
+ // device sends a fresh one when it next comes up, so nothing here
138
+ // forgets a token: a world that dropped one would stop ringing a
139
+ // device that is merely off.
140
+ if (!res.ok)
141
+ throw new Error(`the push service answered ${res.status}`);
142
+ },
143
+ };
144
+ }
@@ -0,0 +1,15 @@
1
+ import type { BeingClass } from '@nervur-org/nervur';
2
+ import { Harbor, type Dialer, type Lend, type Store } from '@nervur-org/nervur/harbor';
3
+ export declare const BUILT_IN: Record<string, BeingClass>;
4
+ export declare class BrowserHarbor extends Harbor {
5
+ #private;
6
+ readonly name: string;
7
+ readonly dialers: Dialer[];
8
+ constructor(store?: string | Store, classes?: Record<string, BeingClass>, lend?: Lend);
9
+ lent: Record<string, string> | undefined;
10
+ stand(lent: Record<string, string>): Promise<void>;
11
+ boot(): Promise<void>;
12
+ dial(url: string): Dialer;
13
+ wake(): void;
14
+ close(): Promise<void>;
15
+ }
@@ -0,0 +1,82 @@
1
+ import { User, Desk, Avatar, Presence, Courier } from '../beings/index.js';
2
+ import { Harbor, dial } from '@nervur-org/nervur/harbor';
3
+ import { FACULTY_CLASSES, settled, stand } from '../dock/index.js';
4
+ import { Idb } from './idb.js';
5
+ export const BUILT_IN = { User, Desk, Avatar, Presence, Courier, ...FACULTY_CLASSES };
6
+ export class BrowserHarbor extends Harbor {
7
+ name;
8
+ dialers = [];
9
+ #locked;
10
+ #release;
11
+ // `lend` is what this box lends the beings of the wards it hosts: a name
12
+ // to an invitation on a being of its own ward. `stand` builds one; a bare
13
+ // tab hands none and every name answers null.
14
+ constructor(store = 'quo', classes = {}, lend) {
15
+ const s = typeof store === 'string' ? new Idb(store) : store;
16
+ super(s, async () => ({ ...BUILT_IN, ...classes }), lend);
17
+ this.name = typeof store === 'string' ? store : 'quo';
18
+ this.#locked = typeof store === 'string';
19
+ }
20
+ // What this box lends, by name. A bare tab says nothing, stands no ward of
21
+ // its own and lends nothing.
22
+ lent;
23
+ // What this box lends is said before it boots, and standing the ward is
24
+ // part of booting: a being born in any other ward lends from her first
25
+ // line, and a ground that could not reach it yet would leave her holding
26
+ // nothing until her next birth.
27
+ async stand(lent) {
28
+ this.lent = lent;
29
+ if (this.wards.size > 0)
30
+ this.lend = await stand(this, lent);
31
+ }
32
+ // Take the lock and boot every ward kept here. A lock already held is
33
+ // another tab's harbor over this seed, and this one refuses to boot.
34
+ async boot() {
35
+ if (this.#locked)
36
+ await this.#lease();
37
+ if (this.lent)
38
+ this.lend = await stand(this, this.lent);
39
+ await super.boot();
40
+ // Every being who takes what this box lends does it at her birth, and
41
+ // birth is synchronous while taking a standing is not. A box is up when
42
+ // they have landed, not before, here as on a daemon: a page painted
43
+ // ahead of them shows a device that can do nothing.
44
+ await settled();
45
+ }
46
+ // Hold one socket to a world's quo. route.
47
+ dial(url) {
48
+ const d = dial(this, url);
49
+ this.dialers.push(d);
50
+ return d;
51
+ }
52
+ // The device is back: a phone out of a pocket, an app to the foreground.
53
+ // Every socket is told, and one that died silently is dialed again.
54
+ wake() {
55
+ for (const d of this.dialers)
56
+ d.wake();
57
+ }
58
+ async close() {
59
+ for (const d of this.dialers.splice(0))
60
+ d.close();
61
+ this.store.close?.();
62
+ this.#release?.();
63
+ this.#release = undefined;
64
+ }
65
+ async #lease() {
66
+ const locks = globalThis.navigator?.locks;
67
+ if (!locks)
68
+ return; // no web locks on this terrain: nothing vouches, as in the memory harbor
69
+ const granted = await new Promise((ok) => {
70
+ void locks.request(`quo:${this.name}`, { ifAvailable: true }, (lock) => {
71
+ if (!lock) {
72
+ ok(false);
73
+ return;
74
+ }
75
+ ok(true);
76
+ return new Promise((release) => (this.#release = release)); // held until close
77
+ });
78
+ });
79
+ if (!granted)
80
+ throw new Error(`harbor ${this.name} is held by another tab`);
81
+ }
82
+ }
@@ -0,0 +1,28 @@
1
+ import { Directory } from '@capacitor/filesystem';
2
+ import type { BeingClass, JsonObject } from '@nervur-org/nervur';
3
+ import { Location, Notifications, type Token, type Where } from '../dock/index.ts';
4
+ import { BrowserHarbor } from './browser.ts';
5
+ import { Sealed, type Files, type Secrets } from './sealed.ts';
6
+ export declare class PhoneLocation extends Location {
7
+ where(): Promise<Where | JsonObject>;
8
+ }
9
+ export declare class PhoneNotifications extends Notifications {
10
+ notify(args: JsonObject): Promise<JsonObject>;
11
+ protected plant(pair: {
12
+ secret: Uint8Array;
13
+ }): Promise<void>;
14
+ }
15
+ export declare const PHONE_FACULTIES: Record<string, BeingClass>;
16
+ export declare const phoneLent: Record<string, string>;
17
+ export declare function phoneEar(sandbox?: boolean, within?: number, named?: {
18
+ bell?: string;
19
+ app?: string;
20
+ }): () => Promise<Token | null>;
21
+ export declare function nativeHarbor(name?: string, classes?: Record<string, BeingClass>): Promise<BrowserHarbor>;
22
+ export declare const directory: Directory;
23
+ export declare const phoneFiles: Files;
24
+ export declare const phoneSecrets: Secrets;
25
+ export declare const Native: {
26
+ open: (harbor: string) => Promise<Sealed>;
27
+ wipe: (harbor: string) => Promise<void>;
28
+ };
@@ -0,0 +1,218 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // The phone: the sealed store over Capacitor's Filesystem and a secure
3
+ // storage plugin, the browser harbor over that store, and the phone's
4
+ // faculties as its plugins give them. The files sit in the folder iCloud
5
+ // does not copy on iOS and in the app's own data folder on Android, whose
6
+ // manifest says no backup; the key sits in the Keychain or the Keystore,
7
+ // this device only. The custody rule is the sealed store's, `sealed.ts`,
8
+ // and this file hands it the two seams and nothing more.
9
+ //
10
+ // The harbor on a phone is the browser harbor over that store: the app
11
+ // process is its lease, and the App plugin's foreground is its wake.
12
+ import { Capacitor } from '@capacitor/core';
13
+ import { App } from '@capacitor/app';
14
+ import { Filesystem, Directory, Encoding } from '@capacitor/filesystem';
15
+ import { SecureStorage, KeychainAccess } from '@aparajita/capacitor-secure-storage';
16
+ import { Geolocation } from '@capacitor/geolocation';
17
+ import { LocalNotifications } from '@capacitor/local-notifications';
18
+ import { PushNotifications } from '@capacitor/push-notifications';
19
+ import { LOCATION, NOTIFICATIONS, Location, Notifications } from '../dock/index.js';
20
+ import { BrowserHarbor } from './browser.js';
21
+ import { Sealed } from './sealed.js';
22
+ // The phone's faculties, as its plugins give them: the two defaults extended,
23
+ // and handed to the harbor as its dock. The platform's permission is the
24
+ // faculty's: a prompt is slow work, since it waits on the human, so the first
25
+ // ask puts the prompt up and answers at once that the human is being asked,
26
+ // and a refusal is the error object every ask after answers with.
27
+ // The secure storage item the banner key's secret half is copied into. The
28
+ // account the keychain holds it under is this name behind the plugin's own
29
+ // prefix, `capacitor-storage_banner`, and the extension reads it there.
30
+ const BANNER = 'banner';
31
+ const asked = (what) => new Error(`the human is being asked to allow ${what} on this device; ask again`);
32
+ const refused = (what) => new Error(`${what} is not allowed on this device`);
33
+ export class PhoneLocation extends Location {
34
+ async where() {
35
+ const { location } = await Geolocation.checkPermissions();
36
+ if (location === 'prompt' || location === 'prompt-with-rationale') {
37
+ void Geolocation.requestPermissions().catch(() => { });
38
+ throw asked('location');
39
+ }
40
+ if (location !== 'granted')
41
+ throw refused('location');
42
+ const p = await Geolocation.getCurrentPosition({ enableHighAccuracy: true, timeout: 20_000 });
43
+ return { lat: p.coords.latitude, lng: p.coords.longitude, accuracy: p.coords.accuracy, at: p.timestamp };
44
+ }
45
+ }
46
+ export class PhoneNotifications extends Notifications {
47
+ async notify(args) {
48
+ const title = typeof args.title === 'string' ? args.title : '';
49
+ const body = typeof args.body === 'string' ? args.body : '';
50
+ const { display } = await LocalNotifications.checkPermissions();
51
+ if (display === 'prompt' || display === 'prompt-with-rationale') {
52
+ void LocalNotifications.requestPermissions().catch(() => { });
53
+ throw asked('notifications');
54
+ }
55
+ if (display !== 'granted')
56
+ throw refused('notifications');
57
+ // `isExactNotification` defaults to true, and on Android 12 and up a
58
+ // notification that wants an exact alarm without the permission for one
59
+ // opens the Alarms and reminders settings screen and never answers, so
60
+ // the ask goes late. This one is shown now and waits for no clock, and
61
+ // the permission it would otherwise want is the kind a store asks an
62
+ // alarm app to justify.
63
+ await LocalNotifications.schedule({ notifications: [{ id: Date.now() % 2147483647, title, body, isExactNotification: false }] });
64
+ return { shown: true };
65
+ }
66
+ // Where a phone's notification process reads the banner key. The process
67
+ // that draws a banner here is not this one: an extension runs between the
68
+ // platform and the screen and cannot reach a ward at all, so the secret
69
+ // half goes into the keychain group the two share. The pair itself stays
70
+ // in the faculty's cells, which is the truth of it, and this is a copy of
71
+ // one half put somewhere a platform can find it.
72
+ //
73
+ // It goes through the same secure storage the store keeps its key in, and
74
+ // not through a plugin of the app's own. An item written there lands in
75
+ // the app's first keychain group, which is the group the extension is in,
76
+ // so the two meet with nothing written to make them meet. A plugin would
77
+ // have had to be registered before the page loads, which on this platform
78
+ // is a list a `cap sync` rewrites, so the app would have carried a hand
79
+ // edit that the next sync undoes.
80
+ //
81
+ // The platform's permission is asked for here, in the same act. A device
82
+ // that has just minted a banner key is a device about to be rung, and the
83
+ // ring that will carry that banner is an alert the platform draws, which
84
+ // it draws for nobody who has not allowed it. Registering for remote
85
+ // notifications asked for nothing, deliberately, so this is the one place
86
+ // on this terrain where the person is asked at all.
87
+ //
88
+ // A refusal leaves the key where it is and costs the device the line and
89
+ // not the wake: the seal is planted, the world is admitted, and what
90
+ // arrives is a push the human never sees until the app is opened. So the
91
+ // answer is not waited on and not read: the person may say yes later in
92
+ // Settings and everything already planted still works.
93
+ async plant(pair) {
94
+ const hex = Array.from(pair.secret, (b) => b.toString(16).padStart(2, '0')).join('');
95
+ await SecureStorage.set(BANNER, hex, false, false, KeychainAccess.afterFirstUnlockThisDeviceOnly);
96
+ const { receive } = await PushNotifications.checkPermissions().catch(() => ({ receive: 'denied' }));
97
+ if (receive === 'prompt' || receive === 'prompt-with-rationale')
98
+ await PushNotifications.requestPermissions().catch(() => ({ receive: 'denied' }));
99
+ }
100
+ }
101
+ // What a phone lends: the two faculties above under the names every box
102
+ // lends them under, and nothing a phone has not. The harbor holds these
103
+ // class bodies for its own ward, and boots one being of each in it.
104
+ export const PHONE_FACULTIES = { Location: PhoneLocation, Notifications: PhoneNotifications };
105
+ export const phoneLent = { [LOCATION]: 'Location', [NOTIFICATIONS]: 'Notifications' };
106
+ // The phone's ear, as `human/app.ts` asks for one: the token this device is
107
+ // reachable at while it sleeps, or null.
108
+ //
109
+ // It asks the human for nothing, and it does not have to. Registering for
110
+ // remote notifications shows no prompt and wants no permission: what a
111
+ // permission gates is showing a human a banner, and a doorbell shows
112
+ // nobody anything. It carries no title, no body and no id, it wakes the
113
+ // app and nothing more, and what the human eventually reads is composed
114
+ // here afterwards out of objects that crossed sealed. So a device whose
115
+ // human has allowed nothing is still reachable, and misses only the line
116
+ // it would have read; the permission it wants for that line is the
117
+ // Notifications faculty's ask and not this one's.
118
+ //
119
+ // The token arrives as an event and not as an answer, so the registration
120
+ // is awaited once and kept: the platform hands the same token back on every
121
+ // later call, and a new one whenever it decides to, which is why the shell
122
+ // asks again on every foreground.
123
+ //
124
+ // `named` is what the build says of this shell, the way `sandbox` is: the
125
+ // bell service holding this app's keys and the app id, so a world rings
126
+ // this phone through the right service whoever shipped the app.
127
+ export function phoneEar(sandbox = false, within = 10_000, named = {}) {
128
+ let token = null;
129
+ return async () => {
130
+ if (!token) {
131
+ token = await new Promise((answer) => {
132
+ const done = setTimeout(() => answer(null), within);
133
+ void PushNotifications.addListener('registration', ({ value }) => {
134
+ clearTimeout(done);
135
+ answer(value);
136
+ });
137
+ void PushNotifications.addListener('registrationError', () => {
138
+ clearTimeout(done);
139
+ answer(null);
140
+ });
141
+ void PushNotifications.register().catch(() => {
142
+ clearTimeout(done);
143
+ answer(null);
144
+ });
145
+ });
146
+ }
147
+ if (!token)
148
+ return null;
149
+ // Which of Apple's two push hosts minted it. Nothing in a device token
150
+ // says so, and a development token means nothing to the production
151
+ // host, so the build that knows what it is is the one that says.
152
+ const says = { ...(named.bell ? { bell: named.bell } : {}), ...(named.app ? { app: named.app } : {}) };
153
+ const apple = { kind: 'apns', device: token, sandbox, ...says };
154
+ const google = { kind: 'fcm', device: token, ...says };
155
+ return Capacitor.getPlatform() === 'ios' ? apple : google;
156
+ };
157
+ }
158
+ // The app's one harbor, booted, lending the phone's faculties to its wards,
159
+ // told `wake` every time the app comes to the foreground, since a phone
160
+ // asleep loses its sockets silently.
161
+ export async function nativeHarbor(name = 'quo', classes = {}) {
162
+ const h = new BrowserHarbor(await Native.open(name), { ...PHONE_FACULTIES, ...classes });
163
+ await h.stand(phoneLent);
164
+ await h.boot();
165
+ await App.addListener('appStateChange', ({ isActive }) => {
166
+ if (isActive)
167
+ h.wake();
168
+ });
169
+ return h;
170
+ }
171
+ // Where the files live: the folder iCloud does not copy on iOS, the app's
172
+ // own files on Android, whose manifest says no backup.
173
+ export const directory = Capacitor.getPlatform() === 'ios' ? Directory.LibraryNoCloud : Directory.Data;
174
+ // The two seams, as the plugins give them. Two things the plugins taught:
175
+ // the secure store keeps JSON, so a value is read with the call that
176
+ // parses; and mkdir refuses a folder that exists, recursive or not.
177
+ export const phoneFiles = {
178
+ exists: async (path) => {
179
+ try {
180
+ await Filesystem.stat({ path, directory });
181
+ return true;
182
+ }
183
+ catch {
184
+ return false;
185
+ }
186
+ },
187
+ read: async (path) => (await Filesystem.readFile({ path, directory, encoding: Encoding.UTF8 })).data,
188
+ write: async (path, data) => {
189
+ await Filesystem.writeFile({ path, directory, data, encoding: Encoding.UTF8 });
190
+ },
191
+ remove: async (path) => {
192
+ await Filesystem.deleteFile({ path, directory });
193
+ },
194
+ mkdir: async (path) => {
195
+ await Filesystem.mkdir({ path, directory, recursive: true });
196
+ },
197
+ rmdir: async (path) => {
198
+ await Filesystem.rmdir({ path, directory, recursive: true });
199
+ },
200
+ list: async (path) => (await Filesystem.readdir({ path, directory })).files.map((f) => f.name),
201
+ };
202
+ export const phoneSecrets = {
203
+ get: async (item) => {
204
+ const got = await SecureStorage.get(item, false, false);
205
+ return typeof got === 'string' ? got : undefined;
206
+ },
207
+ set: async (item, value) => {
208
+ await SecureStorage.set(item, value, false, false, KeychainAccess.afterFirstUnlockThisDeviceOnly);
209
+ },
210
+ remove: async (item) => {
211
+ await SecureStorage.remove(item);
212
+ },
213
+ };
214
+ // The phone's store: the sealed store over the two seams above.
215
+ export const Native = {
216
+ open: (harbor) => Sealed.open(harbor, phoneFiles, phoneSecrets),
217
+ wipe: (harbor) => Sealed.wipe(harbor, phoneFiles, phoneSecrets),
218
+ };
@@ -0,0 +1,27 @@
1
+ import type { BeingClass } from '@nervur-org/nervur';
2
+ import { Harbor, type Hosted, type Lend } from '@nervur-org/nervur/harbor';
3
+ export type { Hosted, Bound } from '@nervur-org/nervur/harbor';
4
+ export type { WardRecord } from '@nervur-org/nervur/harbor';
5
+ export declare const BUILT_IN: Record<string, BeingClass>;
6
+ export declare function defaultWard(marked: string[], hosted: string[]): string;
7
+ export declare class DiskHarbor extends Harbor {
8
+ #private;
9
+ readonly dir: string;
10
+ defaultWard: string;
11
+ static setDefault(dir: string, name: string): Promise<void>;
12
+ static setShown(dir: string, name: string, shown: boolean): Promise<void>;
13
+ static isShown(dir: string, name: string): boolean;
14
+ static marked(dir: string): Promise<string[]>;
15
+ static defaultOf(dir: string): Promise<string>;
16
+ constructor(dir: string, lend?: Lend, own?: Record<string, BeingClass>);
17
+ lent: Record<string, string> | undefined;
18
+ static init(dir: string, name?: string, user?: string, cls?: string, mark?: boolean, shown?: boolean): Promise<{
19
+ dir: string;
20
+ name: string;
21
+ pk: string;
22
+ user: string;
23
+ }>;
24
+ boot(): Promise<void>;
25
+ close(): Promise<void>;
26
+ adoptFolder(name: string): Promise<Hosted>;
27
+ }