@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/dock/agents.ts ADDED
@@ -0,0 +1,121 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // The box's agents: `agents`, one faculty of the dock ward, standing for the
3
+ // one thing a box does on an event as the timer does on a moment: run a
4
+ // process. What she holds is the table of agents, in her cells and in no
5
+ // file, placed by the root with `keep` the way a schedule is placed on the
6
+ // clock, so a box has one place its state is and the daemon reads no recipe.
7
+ //
8
+ // A row names a client identity, the ward she is admitted into, and what
9
+ // runs for her, one of two: a process, a command with its args in its
10
+ // folder, or a model that speaks function calling, a base URL and a model
11
+ // name, OpenRouter, LM Studio, anything with a tools array. At her birth
12
+ // on a daemon she admits each identity into its ward as an avatar, through
13
+ // the box's own way in, and stands the side her row says over that avatar.
14
+ // A push from the user being is the event either way: the agent side runs
15
+ // the command with it and reports what it printed; the runner side turns it
16
+ // into a turn of the model's conversation with her describe as tools. The
17
+ // queue is in the avatar's cells, so it survives a restart; the row is in
18
+ // hers, so the agent comes back with the box.
19
+ //
20
+ // The daemon hands her how to run a row, as it hands the grants faculty how
21
+ // to persist: running an agent is admitting through a socket only a daemon
22
+ // has, and this file is not in the dock's index, since a tab has no process
23
+ // to spawn. A box with no runner keeps the table and runs nobody.
24
+ import { OWNER } from '@nervur-org/nervur';
25
+ import type { Asker, JsonObject, OccupantRecord } from '@nervur-org/nervur';
26
+ import { Faculty } from './faculty.ts';
27
+
28
+ // The name the dock ward holds her under.
29
+ export const AGENTS = 'agents';
30
+
31
+ // One agent. `id` is the client identity the avatar is known by in her ward.
32
+ // A process row and a model row, told apart by what they name.
33
+ export type Process = { command: string; args: string[]; dir: string };
34
+ export type Model = { url: string; model: string; key?: string; turns?: number };
35
+ export type Row = { id: string; ward: string } & (Process | Model);
36
+ export const isProcess = (r: Row): r is { id: string; ward: string } & Process => 'command' in r;
37
+
38
+ // What a daemon stands for a row: a side over her avatar, closed when the
39
+ // row is dropped or the box goes down.
40
+ export type Side = { close(): Promise<void> };
41
+ export type Runner = (row: Row) => Promise<Side>;
42
+
43
+ // The root and nobody else, as the clock gates `keep`: an ask gated this way
44
+ // is the device's own and cannot be reached through a door.
45
+ const root = (_occ: OccupantRecord | undefined, asker: Asker) => asker.id === OWNER;
46
+
47
+ const ROW = {
48
+ type: 'object',
49
+ properties: { id: { type: 'string' }, ward: { type: 'string' }, command: { type: 'string' }, args: { type: 'array', items: { type: 'string' } }, dir: { type: 'string' }, url: { type: 'string' }, model: { type: 'string' }, key: { type: 'string' }, turns: { type: 'number' } },
50
+ required: ['id', 'ward'],
51
+ };
52
+
53
+ export class Agents extends Faculty {
54
+ static override cells: JsonObject = { watched: {}, minted: 0, agents: {} };
55
+ static override asks = {
56
+ keep: { description: 'add an agent, or replace the one of that id: an identity, her ward, and what runs for her, a command with args in a folder, or a model at a url', input: ROW, for: root },
57
+ drop: { description: 'stop an agent and forget her row; the avatar in her ward stays until the root removes her', input: { type: 'object', properties: { id: { type: 'string' } }, required: ['id'] }, for: root },
58
+ list: { description: 'every agent she holds', input: { type: 'object' }, for: root },
59
+ };
60
+
61
+ runner: Runner | undefined;
62
+ readonly running = new Map<string, Side>();
63
+
64
+ private get rows(): Record<string, Row> {
65
+ return this.cells.agents as unknown as Record<string, Row>;
66
+ }
67
+
68
+ // Every row up, once the daemon has handed the runner. Called after the
69
+ // wards are hosted, since an avatar is admitted into one of them.
70
+ async start(runner: Runner): Promise<void> {
71
+ this.runner = runner;
72
+ for (const row of Object.values(this.rows)) await this.#run(row);
73
+ }
74
+
75
+ async #run(row: Row): Promise<void> {
76
+ if (!this.runner) return;
77
+ await this.running.get(row.id)?.close();
78
+ this.running.set(row.id, await this.runner(row));
79
+ }
80
+
81
+ async keep(args: JsonObject): Promise<JsonObject> {
82
+ const id = args.id,
83
+ ward = args.ward;
84
+ if (typeof id !== 'string' || !/^[\w.-]+$/.test(id)) return { error: 'id is a word' };
85
+ if (typeof ward !== 'string') return { error: 'ward names the ward she is admitted into' };
86
+ let row: Row;
87
+ if (typeof args.command === 'string' && typeof args.dir === 'string') {
88
+ row = { id, ward, command: args.command, args: Array.isArray(args.args) ? args.args.filter((a): a is string => typeof a === 'string') : [], dir: args.dir };
89
+ } else if (typeof args.url === 'string' && typeof args.model === 'string') {
90
+ row = { id, ward, url: args.url, model: args.model, ...(typeof args.key === 'string' ? { key: args.key } : {}), ...(typeof args.turns === 'number' ? { turns: args.turns } : {}) };
91
+ } else {
92
+ return { error: 'a row runs a process, { command, args?, dir }, or a model, { url, model, key?, turns? }' };
93
+ }
94
+ try {
95
+ await this.#run(row);
96
+ } catch (e) {
97
+ return { error: e instanceof Error ? e.message : String(e) };
98
+ }
99
+ this.rows[id] = row;
100
+ return { kept: id };
101
+ }
102
+
103
+ async drop(args: JsonObject): Promise<JsonObject> {
104
+ const id = args.id;
105
+ if (typeof id !== 'string' || !this.rows[id]) return { error: 'no such agent' };
106
+ await this.running.get(id)?.close();
107
+ this.running.delete(id);
108
+ delete this.rows[id];
109
+ return { dropped: id };
110
+ }
111
+
112
+ list(): JsonObject {
113
+ return { agents: Object.values(this.rows) as unknown as JsonObject[] };
114
+ }
115
+
116
+ // The device puts the box down: every side closes, the rows stay.
117
+ async close(): Promise<void> {
118
+ for (const a of this.running.values()) await a.close();
119
+ this.running.clear();
120
+ }
121
+ }
package/dock/apns.ts ADDED
@@ -0,0 +1,138 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // APNs as a bell ground: Apple's way of waking an app, and the first of the
3
+ // two the stores own. What makes a ring Apple's is a token signed with a
4
+ // key the team minted at the portal, so unlike Web Push this ground cannot
5
+ // be stood up by whoever wants one: it wants an account and a key file, and
6
+ // that is the whole reason a bell service exists at all.
7
+ //
8
+ // A ring is one of two things, and the banner decides which. With no
9
+ // banner it is a background push, `content-available`, priority 5, expiring
10
+ // at once: the app wakes, dials, drains what it missed over Quo and
11
+ // composes what the human reads from that. A background push is best effort
12
+ // by Apple's own rules, throttled and never delivered to an app the human
13
+ // force quit, so that path is best effort and says so.
14
+ //
15
+ // With a banner it is an alert, `mutable-content`, priority 10, and the
16
+ // device's own notification service extension opens the seal and rewrites
17
+ // the line before anything is drawn. Apple carries ciphertext and sees a
18
+ // device woken, never a byte of why, on either path.
19
+ //
20
+ // One thing this asks of wherever it is put, and it is the reason a bell
21
+ // service lives where it does. The APNs provider API is HTTP/2 only and
22
+ // refuses HTTP/1.1, and not every `fetch` speaks HTTP/2: a worker's does
23
+ // and Node's does not. So this file is the ring for a terrain whose
24
+ // `fetch` speaks it, `http2.ts` beside it is the same ring over
25
+ // `node:http2` for a terrain whose does not, and what they send is written
26
+ // once here and used by both. Which terrains are which is
27
+ // `quo-harbor.md`'s to say and not this file's. A stand-in in a test is an
28
+ // ordinary HTTP/1.1 server, so what a test proves is the shape and the
29
+ // signing rather than the transport, as the tab's push service did.
30
+ import type { Ring, Token } from './push.ts';
31
+
32
+ const subtle = () => globalThis.crypto.subtle;
33
+ const ES256 = { name: 'ECDSA', namedCurve: 'P-256' } as const;
34
+
35
+ const b64url = (bytes: Uint8Array): string => {
36
+ let binary = '';
37
+ for (const b of bytes) binary += String.fromCharCode(b);
38
+ return btoa(binary).replaceAll('+', '-').replaceAll('/', '_').replaceAll('=', '');
39
+ };
40
+ const utf8 = (v: string) => new TextEncoder().encode(v);
41
+
42
+ // A `.p8` from the portal is PKCS#8 in a PEM wrapper. The wrapper is
43
+ // stripped rather than parsed: the base64 between the two lines is the DER.
44
+ export function pkcs8(pem: string): Uint8Array {
45
+ const body = pem.replace(/-----(BEGIN|END)[^-]*-----/g, '').replace(/\s+/g, '');
46
+ if (!body) throw new Error('an APNs key is a PKCS#8 PEM');
47
+ const binary = atob(body);
48
+ const out = new Uint8Array(binary.length);
49
+ for (let at = 0; at < binary.length; at += 1) out[at] = binary.charCodeAt(at);
50
+ return out;
51
+ }
52
+
53
+ // What a publisher holds. The key is per team and not per app, so one of
54
+ // these rings every app id under that team; `topic` is which app is being
55
+ // rung, and it is the bundle id.
56
+ export type Apns = { key: string; keyId: string; teamId: string; topic: string };
57
+
58
+ // Apple's two hosts. A token is minted by one of them and means nothing to
59
+ // the other, and only the device knows which: a build signed for
60
+ // development gets a sandbox token, a build from a store gets a production
61
+ // one, and nothing in the token itself says so. So the device says it, in
62
+ // the token it hands over.
63
+ export const HOSTS = { production: 'https://api.push.apple.com', sandbox: 'https://api.sandbox.push.apple.com' };
64
+
65
+ // Apple wants this refreshed no more often than every twenty minutes and no
66
+ // less than every sixty, so it is minted once and kept for forty-five.
67
+ const LIFE = 45 * 60 * 1000;
68
+
69
+ // The provider token, and the request one ring makes. Both are here and
70
+ // neither is a transport, because Apple is reached two ways in this kit and
71
+ // what is sent must be the same both times: a `fetch` where a `fetch`
72
+ // speaks HTTP/2, and `node:http2` in `http2.ts` where it does not.
73
+ export async function provider(a: Apns, made: { at: number; jwt: string } | null): Promise<{ at: number; jwt: string }> {
74
+ const now = Date.now();
75
+ if (made && now - made.at < LIFE) return made;
76
+ const key = await subtle().importKey('pkcs8', pkcs8(a.key) as BufferSource, ES256, false, ['sign']);
77
+ const head = b64url(utf8(JSON.stringify({ alg: 'ES256', kid: a.keyId })));
78
+ const claims = b64url(utf8(JSON.stringify({ iss: a.teamId, iat: Math.floor(now / 1000) })));
79
+ const signed = await subtle().sign({ name: 'ECDSA', hash: 'SHA-256' }, key, utf8(`${head}.${claims}`));
80
+ return { at: now, jwt: `${head}.${claims}.${b64url(new Uint8Array(signed))}` };
81
+ }
82
+
83
+ // The device a token names, or nothing a ring may send. It is checked
84
+ // where a human can read why rather than by Apple two hops later.
85
+ export function device(token: Token): string {
86
+ const device = token.device;
87
+ if (typeof device !== 'string' || !/^[0-9a-fA-F]{8,200}$/.test(device)) throw new Error('an apns token is a device token');
88
+ return device;
89
+ }
90
+
91
+ // One ring, as Apple reads it: the path, the headers and the body. A ring
92
+ // with a banner is an alert Apple must show, and `mutable-content` is what
93
+ // sends it through the device's own extension first, which is the only
94
+ // process that can open the seal. The title and body Apple carries are the
95
+ // placeholder the extension replaces, and they say nothing: they are what a
96
+ // person sees if her own phone cannot open its own banner. A ring with no
97
+ // banner stays a background wake, where the device drains what it missed
98
+ // over Quo and composes its own line.
99
+ export function request(a: Apns, jwt: string, token: Token, sealed?: string): { path: string; headers: Record<string, string>; body: string } {
100
+ const said =
101
+ sealed === undefined
102
+ ? { aps: { 'content-available': 1 } }
103
+ : { aps: { alert: { 'loc-key': 'quo.ring' }, 'mutable-content': 1, sound: 'default' }, sealed };
104
+ return {
105
+ path: `/3/device/${device(token)}`,
106
+ headers: {
107
+ authorization: `bearer ${jwt}`,
108
+ 'apns-topic': a.topic,
109
+ 'apns-push-type': sealed === undefined ? 'background' : 'alert',
110
+ 'apns-priority': sealed === undefined ? '5' : '10',
111
+ 'apns-expiration': '0',
112
+ },
113
+ body: JSON.stringify(said),
114
+ };
115
+ }
116
+
117
+ // What Apple said. 410 is Apple saying this device token is gone. Nothing
118
+ // here forgets it, as nothing forgets a spent Web Push endpoint: a device
119
+ // sends a fresh token whenever it comes to the foreground, and a world that
120
+ // dropped one would stop ringing a phone that is merely off.
121
+ export function answered(status: number, text: string): void {
122
+ if (status < 200 || status > 299) throw new Error(`apns answered ${status} ${text.slice(0, 200)}`);
123
+ }
124
+
125
+ // The ground. An `apns` token is a device token and which of Apple's two
126
+ // hosts minted it; `at` is what a test points somewhere else.
127
+ export function apnsPush(a: Apns, at: typeof HOSTS = HOSTS): Ring {
128
+ let made: { at: number; jwt: string } | null = null;
129
+ return {
130
+ async ring(token: Token, sealed?: string) {
131
+ made = await provider(a, made);
132
+ const host = token.sandbox === true ? at.sandbox : at.production;
133
+ const req = request(a, made.jwt, token, sealed);
134
+ const res = await fetch(`${host}${req.path}`, { method: 'POST', headers: req.headers, body: req.body });
135
+ answered(res.status, await res.text());
136
+ },
137
+ };
138
+ }
@@ -0,0 +1,23 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // The box, as one being. A device has no surface of its own, so a box that
3
+ // has joined no world has nothing to draw: the Console is that page and only
4
+ // that. She carries the faculties her harbor stood, showing each one's asks
5
+ // as her own, and the ordinary screen draws her exactly as it draws a
6
+ // world's user being, through one avatar holding one standing.
7
+ //
8
+ // She exists because a fresh install otherwise asks for location and
9
+ // notifications in its manifest and shows nothing that ever asks for them,
10
+ // which is a store reviewer's first question and a human's second.
11
+ //
12
+ // She carries for whoever the harbor's root let in, and for nobody else: the
13
+ // root is the box, and it invites the one avatar its own screen runs.
14
+ import { Carrier } from '../beings/carry.ts';
15
+ import type { Asker, OccupantRecord } from '@nervur-org/nervur';
16
+
17
+ export const CONSOLE = 'console';
18
+
19
+ export class Console extends Carrier {
20
+ static override carries(occupant: OccupantRecord | undefined, _asker: Asker): boolean {
21
+ return occupant !== undefined;
22
+ }
23
+ }
@@ -0,0 +1,85 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // The box's dialers: `dialers`, one faculty of the dock ward, standing for
3
+ // the sockets this box holds open to other harbors' `quo.` routes. A Mac
4
+ // behind NAT, a phone on the mobile network, a droplet dialing home: each
5
+ // is a dialer, and what it dials is a row in her cells, placed once by the
6
+ // root with `keep` the way a schedule is placed on the clock and an agent
7
+ // on the agents faculty, in no file.
8
+ //
9
+ // A row is one URL, the `quo.` route of the harbor dialed. She holds the
10
+ // socket from her birth on, as every faculty holds what the box holds: one
11
+ // line per row, announcing this harbor's wards on open, binding the far
12
+ // side's pks, reconnecting with backoff when the line drops, and closed
13
+ // when the row is dropped or the box goes down. The dialer itself is the
14
+ // library's, written on the standard socket surface, so this faculty holds
15
+ // on every terrain that has a socket; the daemon hands her the harbor to
16
+ // dial for, as it hands the agents faculty how to run a row.
17
+ import { OWNER } from '@nervur-org/nervur';
18
+ import type { Asker, JsonObject, OccupantRecord } from '@nervur-org/nervur';
19
+ import { dial, type Dialer, type Harbor } from '@nervur-org/nervur/harbor';
20
+ import { Faculty } from './faculty.ts';
21
+
22
+ // The name the dock ward holds her under.
23
+ export const DIALERS = 'dialers';
24
+
25
+ export type Row = { url: string };
26
+
27
+ // The root and nobody else: a line held open is the device's own to place.
28
+ const root = (_occ: OccupantRecord | undefined, asker: Asker) => asker.id === OWNER;
29
+
30
+ const URL_ = { type: 'object', properties: { url: { type: 'string' } }, required: ['url'] };
31
+
32
+ export class Dialers extends Faculty {
33
+ static override cells: JsonObject = { watched: {}, minted: 0, dialers: {} };
34
+ static override asks = {
35
+ keep: { description: 'hold a socket to that quo. route, from now and from every boot, until it is dropped', input: URL_, for: root },
36
+ drop: { description: 'close the socket to that route and forget it', input: URL_, for: root },
37
+ list: { description: 'every route she dials, and whether the line is open now', input: { type: 'object' }, for: root },
38
+ };
39
+
40
+ harbor: Harbor | undefined;
41
+ readonly open = new Map<string, Dialer>();
42
+
43
+ private get rows(): Record<string, Row> {
44
+ return this.cells.dialers as unknown as Record<string, Row>;
45
+ }
46
+
47
+ // Every row dialed, once the daemon has handed the harbor to dial for.
48
+ start(harbor: Harbor): void {
49
+ this.harbor = harbor;
50
+ for (const row of Object.values(this.rows)) this.#dial(row.url);
51
+ }
52
+
53
+ #dial(url: string): void {
54
+ if (!this.harbor || this.open.has(url)) return;
55
+ this.open.set(url, dial(this.harbor, url));
56
+ }
57
+
58
+ keep(args: JsonObject): JsonObject {
59
+ const url = args.url;
60
+ if (typeof url !== 'string' || !/^https?:\/\/\S+$/.test(url)) return { error: 'url is the quo. route of the harbor dialed, over http or https' };
61
+ const clean = url.replace(/\/$/, '');
62
+ this.rows[clean] = { url: clean };
63
+ this.#dial(clean);
64
+ return { kept: clean };
65
+ }
66
+
67
+ drop(args: JsonObject): JsonObject {
68
+ const url = typeof args.url === 'string' ? args.url.replace(/\/$/, '') : '';
69
+ if (!this.rows[url]) return { error: 'no such route' };
70
+ this.open.get(url)?.close();
71
+ this.open.delete(url);
72
+ delete this.rows[url];
73
+ return { dropped: url };
74
+ }
75
+
76
+ list(): JsonObject {
77
+ return { dialers: Object.keys(this.rows).map((url) => ({ url, open: this.open.get(url)?.socket !== null && this.open.has(url) })) };
78
+ }
79
+
80
+ // The device puts the box down: every line closes, the rows stay.
81
+ close(): void {
82
+ for (const d of this.open.values()) d.close();
83
+ this.open.clear();
84
+ }
85
+ }
@@ -0,0 +1,148 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // A faculty: one being of the dock ward, standing for one thing the box can
3
+ // do. She is a being and nothing else, so what is here is not a second kind
4
+ // of object but the two moves every faculty shares.
5
+ //
6
+ // Out: whoever holds a standing at her asks her methods, and the occupant's
7
+ // notes say what that caller is, a prefix on a store, an account at a
8
+ // vendor. Her own cells say what the thing is.
9
+ //
10
+ // Back: `watch` is the one subscribe verb every faculty has. A being who
11
+ // wants waking mints an invitation and hands it over, this takes it as a
12
+ // standing, and from then on she is asked at her own door as the occupant
13
+ // she named. `occupants.remove` on her side is the whole of unsubscribe: the
14
+ // next wake hears `removed` and the row goes.
15
+ //
16
+ // She holds what the box holds and begins holding it at birth: the
17
+ // connection, the interval, the worker, the route are opened in her
18
+ // constructor, and she is born whether or not the thing is reachable,
19
+ // answering an error object while it is not.
20
+ import { Being, isInvitation, isSilence, isWord, wordOf } from '@nervur-org/nervur';
21
+ import type { Asker, JsonObject } from '@nervur-org/nervur';
22
+
23
+ // One subscription, as she files it: her own id for the relation, who asked
24
+ // for it, and what to ask them.
25
+ export type Watched = { by: string; method: string; args: JsonObject };
26
+
27
+ // What one round of waking did, per subscription.
28
+ export type Woke = { id: string; by: string; out: unknown };
29
+
30
+ export class Faculty extends Being {
31
+ static override cells: JsonObject = { watched: {}, minted: 0 };
32
+
33
+ // Someone wants waking. She hands the invitation she minted and says what
34
+ // to ask her; this knocks it, takes it under an id of this being's own,
35
+ // and files it. The id never crosses: it is this side's name for the
36
+ // relation, and the caller's name for the same relation is her own.
37
+ //
38
+ // The empty ask is the knock, because every being answers one and asking
39
+ // for a named ask would make every subscriber owe a method she does not
40
+ // otherwise need.
41
+ async watch(args: JsonObject, asker: Asker): Promise<JsonObject> {
42
+ const inv = args.invitation;
43
+ if (!isInvitation(inv) || typeof args.method !== 'string') return { error: 'an invitation and a method' };
44
+ const extra = args.args;
45
+ if (extra !== undefined && (extra === null || typeof extra !== 'object' || Array.isArray(extra))) return { error: 'args is one object' };
46
+ const out = await this.knock(inv);
47
+ if (isSilence(out) || isWord(out)) return { error: 'no way back' };
48
+ const cells = this.cells as unknown as { watched: Record<string, Watched>; minted: number };
49
+ const id = await this.take(`wake:${(cells.minted += 1)}`, inv);
50
+ if (id === null) return { error: 'not taken' };
51
+ cells.watched[id] = { by: asker.id ?? '', method: args.method, args: extra ?? {} };
52
+ await this.welcomed(id);
53
+ return { watching: id };
54
+ }
55
+
56
+ // Somebody new is filed, and the answer to her `watch` waits for this. A
57
+ // faculty that owes a new subscriber something at once says it here: the
58
+ // timer wakes her, learns when she is next due and arms the box for it, so
59
+ // a being born long after the box was up is not waiting for a round nobody
60
+ // scheduled. Most faculties owe nothing, and this is why they say nothing.
61
+ protected async welcomed(_id: string): Promise<void> {}
62
+
63
+ // One round of waking. Each subscriber is asked what she asked to be
64
+ // asked, with whatever this round adds. A subscriber who removed the
65
+ // occupant says `removed` once, and her row goes with the standing; there
66
+ // is nothing to retry and nothing to remember.
67
+ //
68
+ // `only` is one subscriber of the round's own choosing, named by this
69
+ // being's own id for the relation, and it is the same round down to one
70
+ // row: a faculty that wakes somebody just filed learns exactly what a full
71
+ // round would have learned about her.
72
+ //
73
+ // `dropped` is this being herself: her ward took her out, so the standings
74
+ // she holds reach nobody. Null says so, and whoever holds the interval puts
75
+ // it down rather than waking an empty room for as long as the box is up.
76
+ protected async woke(round: (w: Watched) => JsonObject = (w) => w.args, only?: string): Promise<Woke[] | null> {
77
+ const cells = this.cells as unknown as { watched: Record<string, Watched> };
78
+ const out: Woke[] = [];
79
+ for (const [id, w] of Object.entries(cells.watched)) {
80
+ if (only !== undefined && id !== only) continue;
81
+ const said = await this.standings[id]?.ask(w.method, round(w));
82
+ if (said === undefined || (isWord(said) && wordOf(said) === 'dropped')) return null;
83
+ if (isWord(said) && wordOf(said) === 'removed') {
84
+ this.standings.remove(id);
85
+ delete cells.watched[id];
86
+ continue;
87
+ }
88
+ out.push({ id, by: w.by, out: said });
89
+ }
90
+ return out;
91
+ }
92
+
93
+ // Who is waiting, by this being's own id for the relation. For a device's
94
+ // own code and for a suite; it crosses no door.
95
+ get watching(): Record<string, Watched> {
96
+ return this.cells.watched as unknown as Record<string, Watched>;
97
+ }
98
+ }
99
+
100
+ // The three moves a being makes at every birth to hold what her box lends.
101
+ // A standing points at a being and not at a box, so one she wakes up holding
102
+ // names a being on the box she was on when it was made; a restart and a
103
+ // migration are the same silent event to her, so she does these again every
104
+ // time she is constructed and is always pointed at the box she is on.
105
+ //
106
+ // `watch` says she wants waking too: her ward mints the occupant, the
107
+ // faculty takes the standing it makes, and from then on the wake arrives at
108
+ // her door under the id she chose.
109
+ // Every join in flight on this box. A being takes what her box lends at her
110
+ // birth, and birth is synchronous while taking a standing is not, so a
111
+ // harbor that answered "up" before they landed would be a box whose first
112
+ // tick woke nobody and whose first ring reached no platform. The terrain
113
+ // waits on this once, after its wards are hosted; nothing else reads it, and
114
+ // a join that fails leaves nothing behind but a being who asks again.
115
+ const flight = new Set<Promise<unknown>>();
116
+ export const settled = async (): Promise<void> => {
117
+ while (flight.size) {
118
+ const now = [...flight];
119
+ await Promise.allSettled(now);
120
+ for (const p of now) flight.delete(p);
121
+ }
122
+ };
123
+
124
+ export function join(b: Being, name: string, watch?: { method: string; args?: JsonObject }, id = name): Promise<string | null> {
125
+ const p = joining(b, name, watch, id);
126
+ flight.add(p);
127
+ void p.catch(() => {});
128
+ return p;
129
+ }
130
+
131
+ async function joining(b: Being, name: string, watch: { method: string; args?: JsonObject } | undefined, id: string): Promise<string | null> {
132
+ // One turn, before anything is asked. A being is called at her door and
133
+ // holds her stance from her first line, but her ward sets that door after
134
+ // her constructor returns, so a stance call made inside it reaches a being
135
+ // her ward does not hold yet and is answered as one taken out. Every
136
+ // faculty relation is made at birth, so the wait belongs here rather than
137
+ // in each of them.
138
+ await Promise.resolve();
139
+ b.standings.remove(id);
140
+ b.occupants.remove(`${id}:wake`);
141
+ const got = await b.lend(name, id);
142
+ if (got === null) return null;
143
+ if (!watch) return got;
144
+ const back = await b.invite(`${id}:wake`);
145
+ if (!back) return got;
146
+ await b.standings[id]?.ask('watch', { invitation: back, method: watch.method, ...(watch.args ? { args: watch.args } : {}) });
147
+ return got;
148
+ }
package/dock/fcm.ts ADDED
@@ -0,0 +1,103 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // FCM as a bell ground: Google's way of waking an app, and the second of
3
+ // the two the stores own. Like APNs it wants an account, a project and a
4
+ // key, which is why it lives behind a bell service and not in a world.
5
+ //
6
+ // Two hops rather than one. Google does not take a signed token at the send
7
+ // door the way Apple does: a service account's key signs a JWT, that JWT is
8
+ // exchanged at Google's OAuth door for an access token, and the access
9
+ // token sends. The exchange is what is cached, for an hour less a minute,
10
+ // and one exchange serves every ring in that hour.
11
+ //
12
+ // The ring carries nothing, so the message is data only with an empty data
13
+ // map and no `notification` key: a message carrying a notification would be
14
+ // displayed by Android itself, out of the app's hands and out of the
15
+ // human's, saying whatever the sender put in it. Data only wakes the app,
16
+ // which dials, drains what it missed over Quo, sealed, and composes what
17
+ // the human reads. High priority is what wakes a dozing device; it is the
18
+ // same promise Apple's background push makes, which is best effort.
19
+ import type { Ring, Token } from './push.ts';
20
+
21
+ const subtle = () => globalThis.crypto.subtle;
22
+ const RS256 = { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' } as const;
23
+
24
+ const b64url = (bytes: Uint8Array): string => {
25
+ let binary = '';
26
+ for (const b of bytes) binary += String.fromCharCode(b);
27
+ return btoa(binary).replaceAll('+', '-').replaceAll('/', '_').replaceAll('=', '');
28
+ };
29
+ const utf8 = (v: string) => new TextEncoder().encode(v);
30
+
31
+ const der = (pem: string): Uint8Array => {
32
+ const body = pem.replace(/-----(BEGIN|END)[^-]*-----/g, '').replace(/\s+/g, '');
33
+ if (!body) throw new Error('a service account key is a PKCS#8 PEM');
34
+ const binary = atob(body);
35
+ const out = new Uint8Array(binary.length);
36
+ for (let at = 0; at < binary.length; at += 1) out[at] = binary.charCodeAt(at);
37
+ return out;
38
+ };
39
+
40
+ // The three fields of the JSON Google hands out when a service account is
41
+ // given a key. The whole file has more in it and none of the rest is read
42
+ // here, so what a publisher keeps as a secret is the file as it came.
43
+ export type Account = { project_id: string; client_email: string; private_key: string };
44
+
45
+ export function isAccount(v: unknown): v is Account {
46
+ const a = v as Partial<Account> | null;
47
+ return a !== null && typeof a === 'object' && typeof a.project_id === 'string' && typeof a.client_email === 'string' && typeof a.private_key === 'string';
48
+ }
49
+
50
+ // Where the two doors are, so a test may point them somewhere else.
51
+ export type Doors = { oauth: string; fcm: string };
52
+ export const DOORS: Doors = { oauth: 'https://oauth2.googleapis.com/token', fcm: 'https://fcm.googleapis.com' };
53
+
54
+ const SCOPE = 'https://www.googleapis.com/auth/firebase.messaging';
55
+ const LIFE = 59 * 60 * 1000;
56
+
57
+ async function access(a: Account, at: Doors, held: { at: number; token: string } | null): Promise<{ at: number; token: string }> {
58
+ const now = Date.now();
59
+ if (held && now - held.at < LIFE) return held;
60
+ const key = await subtle().importKey('pkcs8', der(a.private_key) as BufferSource, RS256, false, ['sign']);
61
+ const iat = Math.floor(now / 1000);
62
+ const head = b64url(utf8(JSON.stringify({ alg: 'RS256', typ: 'JWT' })));
63
+ const claims = b64url(utf8(JSON.stringify({ iss: a.client_email, scope: SCOPE, aud: at.oauth, iat, exp: iat + 3600 })));
64
+ const signed = await subtle().sign(RS256, key, utf8(`${head}.${claims}`));
65
+ const assertion = `${head}.${claims}.${b64url(new Uint8Array(signed))}`;
66
+ const res = await fetch(at.oauth, {
67
+ method: 'POST',
68
+ headers: { 'content-type': 'application/x-www-form-urlencoded' },
69
+ body: new URLSearchParams({ grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer', assertion }).toString(),
70
+ });
71
+ if (!res.ok) throw new Error(`google would not exchange the key: ${res.status} ${(await res.text()).slice(0, 200)}`);
72
+ const out = (await res.json()) as { access_token?: unknown };
73
+ if (typeof out.access_token !== 'string') throw new Error('google answered no access token');
74
+ return { at: now, token: out.access_token };
75
+ }
76
+
77
+ // The ground. An `fcm` token is the registration token the device was
78
+ // handed by Google's own sdk, which is opaque and is not inspected here
79
+ // beyond being a string with something in it.
80
+ export function fcmPush(a: Account, at: Doors = DOORS): Ring {
81
+ let held: { at: number; token: string } | null = null;
82
+ return {
83
+ async ring(token: Token, sealed?: string) {
84
+ const device = token.device;
85
+ if (typeof device !== 'string' || device.length < 8) throw new Error('an fcm token is a registration token');
86
+ held = await access(a, at, held);
87
+ // The seal rides in the data map, as it rides in the payload on the
88
+ // other ground, so what Google carries is ciphertext and the line is
89
+ // drawn on the device by the service that opens it. A ring with no
90
+ // seal carries an empty map and is a wake and nothing more, which is
91
+ // what a device that draws no banner still needs.
92
+ const res = await fetch(`${at.fcm}/v1/projects/${a.project_id}/messages:send`, {
93
+ method: 'POST',
94
+ headers: { authorization: `Bearer ${held.token}`, 'content-type': 'application/json' },
95
+ body: JSON.stringify({ message: { token: device, android: { priority: 'HIGH' }, data: sealed === undefined ? {} : { sealed } } }),
96
+ });
97
+ // 404 is Google saying this registration token is gone. Nothing here
98
+ // forgets it, for the reason the other two grounds do not: a device
99
+ // sends a fresh one whenever it comes to the foreground.
100
+ if (!res.ok) throw new Error(`fcm answered ${res.status} ${(await res.text()).slice(0, 200)}`);
101
+ },
102
+ };
103
+ }