@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,102 @@
1
+ import type { Asker, JsonObject, OccupantRecord } from '@nervur-org/nervur';
2
+ import { Faculty } from './faculty.ts';
3
+ export declare const AGENTS = "agents";
4
+ export type Process = {
5
+ command: string;
6
+ args: string[];
7
+ dir: string;
8
+ };
9
+ export type Model = {
10
+ url: string;
11
+ model: string;
12
+ key?: string;
13
+ turns?: number;
14
+ };
15
+ export type Row = {
16
+ id: string;
17
+ ward: string;
18
+ } & (Process | Model);
19
+ export declare const isProcess: (r: Row) => r is {
20
+ id: string;
21
+ ward: string;
22
+ } & Process;
23
+ export type Side = {
24
+ close(): Promise<void>;
25
+ };
26
+ export type Runner = (row: Row) => Promise<Side>;
27
+ declare const root: (_occ: OccupantRecord | undefined, asker: Asker) => boolean;
28
+ export declare class Agents extends Faculty {
29
+ #private;
30
+ static cells: JsonObject;
31
+ static asks: {
32
+ keep: {
33
+ description: string;
34
+ input: {
35
+ type: string;
36
+ properties: {
37
+ id: {
38
+ type: string;
39
+ };
40
+ ward: {
41
+ type: string;
42
+ };
43
+ command: {
44
+ type: string;
45
+ };
46
+ args: {
47
+ type: string;
48
+ items: {
49
+ type: string;
50
+ };
51
+ };
52
+ dir: {
53
+ type: string;
54
+ };
55
+ url: {
56
+ type: string;
57
+ };
58
+ model: {
59
+ type: string;
60
+ };
61
+ key: {
62
+ type: string;
63
+ };
64
+ turns: {
65
+ type: string;
66
+ };
67
+ };
68
+ required: string[];
69
+ };
70
+ for: typeof root;
71
+ };
72
+ drop: {
73
+ description: string;
74
+ input: {
75
+ type: string;
76
+ properties: {
77
+ id: {
78
+ type: string;
79
+ };
80
+ };
81
+ required: string[];
82
+ };
83
+ for: typeof root;
84
+ };
85
+ list: {
86
+ description: string;
87
+ input: {
88
+ type: string;
89
+ };
90
+ for: typeof root;
91
+ };
92
+ };
93
+ runner: Runner | undefined;
94
+ readonly running: Map<string, Side>;
95
+ private get rows();
96
+ start(runner: Runner): Promise<void>;
97
+ keep(args: JsonObject): Promise<JsonObject>;
98
+ drop(args: JsonObject): Promise<JsonObject>;
99
+ list(): JsonObject;
100
+ close(): Promise<void>;
101
+ }
102
+ export {};
@@ -0,0 +1,105 @@
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 { Faculty } from './faculty.js';
26
+ // The name the dock ward holds her under.
27
+ export const AGENTS = 'agents';
28
+ export const isProcess = (r) => 'command' in r;
29
+ // The root and nobody else, as the clock gates `keep`: an ask gated this way
30
+ // is the device's own and cannot be reached through a door.
31
+ const root = (_occ, asker) => asker.id === OWNER;
32
+ const ROW = {
33
+ type: 'object',
34
+ 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' } },
35
+ required: ['id', 'ward'],
36
+ };
37
+ export class Agents extends Faculty {
38
+ static cells = { watched: {}, minted: 0, agents: {} };
39
+ static asks = {
40
+ 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 },
41
+ 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 },
42
+ list: { description: 'every agent she holds', input: { type: 'object' }, for: root },
43
+ };
44
+ runner;
45
+ running = new Map();
46
+ get rows() {
47
+ return this.cells.agents;
48
+ }
49
+ // Every row up, once the daemon has handed the runner. Called after the
50
+ // wards are hosted, since an avatar is admitted into one of them.
51
+ async start(runner) {
52
+ this.runner = runner;
53
+ for (const row of Object.values(this.rows))
54
+ await this.#run(row);
55
+ }
56
+ async #run(row) {
57
+ if (!this.runner)
58
+ return;
59
+ await this.running.get(row.id)?.close();
60
+ this.running.set(row.id, await this.runner(row));
61
+ }
62
+ async keep(args) {
63
+ const id = args.id, ward = args.ward;
64
+ if (typeof id !== 'string' || !/^[\w.-]+$/.test(id))
65
+ return { error: 'id is a word' };
66
+ if (typeof ward !== 'string')
67
+ return { error: 'ward names the ward she is admitted into' };
68
+ let row;
69
+ if (typeof args.command === 'string' && typeof args.dir === 'string') {
70
+ row = { id, ward, command: args.command, args: Array.isArray(args.args) ? args.args.filter((a) => typeof a === 'string') : [], dir: args.dir };
71
+ }
72
+ else if (typeof args.url === 'string' && typeof args.model === 'string') {
73
+ row = { id, ward, url: args.url, model: args.model, ...(typeof args.key === 'string' ? { key: args.key } : {}), ...(typeof args.turns === 'number' ? { turns: args.turns } : {}) };
74
+ }
75
+ else {
76
+ return { error: 'a row runs a process, { command, args?, dir }, or a model, { url, model, key?, turns? }' };
77
+ }
78
+ try {
79
+ await this.#run(row);
80
+ }
81
+ catch (e) {
82
+ return { error: e instanceof Error ? e.message : String(e) };
83
+ }
84
+ this.rows[id] = row;
85
+ return { kept: id };
86
+ }
87
+ async drop(args) {
88
+ const id = args.id;
89
+ if (typeof id !== 'string' || !this.rows[id])
90
+ return { error: 'no such agent' };
91
+ await this.running.get(id)?.close();
92
+ this.running.delete(id);
93
+ delete this.rows[id];
94
+ return { dropped: id };
95
+ }
96
+ list() {
97
+ return { agents: Object.values(this.rows) };
98
+ }
99
+ // The device puts the box down: every side closes, the rows stay.
100
+ async close() {
101
+ for (const a of this.running.values())
102
+ await a.close();
103
+ this.running.clear();
104
+ }
105
+ }
@@ -0,0 +1,27 @@
1
+ import type { Ring, Token } from './push.ts';
2
+ export declare function pkcs8(pem: string): Uint8Array;
3
+ export type Apns = {
4
+ key: string;
5
+ keyId: string;
6
+ teamId: string;
7
+ topic: string;
8
+ };
9
+ export declare const HOSTS: {
10
+ production: string;
11
+ sandbox: string;
12
+ };
13
+ export declare function provider(a: Apns, made: {
14
+ at: number;
15
+ jwt: string;
16
+ } | null): Promise<{
17
+ at: number;
18
+ jwt: string;
19
+ }>;
20
+ export declare function device(token: Token): string;
21
+ export declare function request(a: Apns, jwt: string, token: Token, sealed?: string): {
22
+ path: string;
23
+ headers: Record<string, string>;
24
+ body: string;
25
+ };
26
+ export declare function answered(status: number, text: string): void;
27
+ export declare function apnsPush(a: Apns, at?: typeof HOSTS): Ring;
@@ -0,0 +1,98 @@
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 utf8 = (v) => new TextEncoder().encode(v);
10
+ // A `.p8` from the portal is PKCS#8 in a PEM wrapper. The wrapper is
11
+ // stripped rather than parsed: the base64 between the two lines is the DER.
12
+ export function pkcs8(pem) {
13
+ const body = pem.replace(/-----(BEGIN|END)[^-]*-----/g, '').replace(/\s+/g, '');
14
+ if (!body)
15
+ throw new Error('an APNs key is a PKCS#8 PEM');
16
+ const binary = atob(body);
17
+ const out = new Uint8Array(binary.length);
18
+ for (let at = 0; at < binary.length; at += 1)
19
+ out[at] = binary.charCodeAt(at);
20
+ return out;
21
+ }
22
+ // Apple's two hosts. A token is minted by one of them and means nothing to
23
+ // the other, and only the device knows which: a build signed for
24
+ // development gets a sandbox token, a build from a store gets a production
25
+ // one, and nothing in the token itself says so. So the device says it, in
26
+ // the token it hands over.
27
+ export const HOSTS = { production: 'https://api.push.apple.com', sandbox: 'https://api.sandbox.push.apple.com' };
28
+ // Apple wants this refreshed no more often than every twenty minutes and no
29
+ // less than every sixty, so it is minted once and kept for forty-five.
30
+ const LIFE = 45 * 60 * 1000;
31
+ // The provider token, and the request one ring makes. Both are here and
32
+ // neither is a transport, because Apple is reached two ways in this kit and
33
+ // what is sent must be the same both times: a `fetch` where a `fetch`
34
+ // speaks HTTP/2, and `node:http2` in `http2.ts` where it does not.
35
+ export async function provider(a, made) {
36
+ const now = Date.now();
37
+ if (made && now - made.at < LIFE)
38
+ return made;
39
+ const key = await subtle().importKey('pkcs8', pkcs8(a.key), ES256, false, ['sign']);
40
+ const head = b64url(utf8(JSON.stringify({ alg: 'ES256', kid: a.keyId })));
41
+ const claims = b64url(utf8(JSON.stringify({ iss: a.teamId, iat: Math.floor(now / 1000) })));
42
+ const signed = await subtle().sign({ name: 'ECDSA', hash: 'SHA-256' }, key, utf8(`${head}.${claims}`));
43
+ return { at: now, jwt: `${head}.${claims}.${b64url(new Uint8Array(signed))}` };
44
+ }
45
+ // The device a token names, or nothing a ring may send. It is checked
46
+ // where a human can read why rather than by Apple two hops later.
47
+ export function device(token) {
48
+ const device = token.device;
49
+ if (typeof device !== 'string' || !/^[0-9a-fA-F]{8,200}$/.test(device))
50
+ throw new Error('an apns token is a device token');
51
+ return device;
52
+ }
53
+ // One ring, as Apple reads it: the path, the headers and the body. A ring
54
+ // with a banner is an alert Apple must show, and `mutable-content` is what
55
+ // sends it through the device's own extension first, which is the only
56
+ // process that can open the seal. The title and body Apple carries are the
57
+ // placeholder the extension replaces, and they say nothing: they are what a
58
+ // person sees if her own phone cannot open its own banner. A ring with no
59
+ // banner stays a background wake, where the device drains what it missed
60
+ // over Quo and composes its own line.
61
+ export function request(a, jwt, token, sealed) {
62
+ const said = sealed === undefined
63
+ ? { aps: { 'content-available': 1 } }
64
+ : { aps: { alert: { 'loc-key': 'quo.ring' }, 'mutable-content': 1, sound: 'default' }, sealed };
65
+ return {
66
+ path: `/3/device/${device(token)}`,
67
+ headers: {
68
+ authorization: `bearer ${jwt}`,
69
+ 'apns-topic': a.topic,
70
+ 'apns-push-type': sealed === undefined ? 'background' : 'alert',
71
+ 'apns-priority': sealed === undefined ? '5' : '10',
72
+ 'apns-expiration': '0',
73
+ },
74
+ body: JSON.stringify(said),
75
+ };
76
+ }
77
+ // What Apple said. 410 is Apple saying this device token is gone. Nothing
78
+ // here forgets it, as nothing forgets a spent Web Push endpoint: a device
79
+ // sends a fresh token whenever it comes to the foreground, and a world that
80
+ // dropped one would stop ringing a phone that is merely off.
81
+ export function answered(status, text) {
82
+ if (status < 200 || status > 299)
83
+ throw new Error(`apns answered ${status} ${text.slice(0, 200)}`);
84
+ }
85
+ // The ground. An `apns` token is a device token and which of Apple's two
86
+ // hosts minted it; `at` is what a test points somewhere else.
87
+ export function apnsPush(a, at = HOSTS) {
88
+ let made = null;
89
+ return {
90
+ async ring(token, sealed) {
91
+ made = await provider(a, made);
92
+ const host = token.sandbox === true ? at.sandbox : at.production;
93
+ const req = request(a, made.jwt, token, sealed);
94
+ const res = await fetch(`${host}${req.path}`, { method: 'POST', headers: req.headers, body: req.body });
95
+ answered(res.status, await res.text());
96
+ },
97
+ };
98
+ }
@@ -0,0 +1,6 @@
1
+ import { Carrier } from '../beings/carry.ts';
2
+ import type { Asker, OccupantRecord } from '@nervur-org/nervur';
3
+ export declare const CONSOLE = "console";
4
+ export declare class Console extends Carrier {
5
+ static carries(occupant: OccupantRecord | undefined, _asker: Asker): boolean;
6
+ }
@@ -0,0 +1,20 @@
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.js';
15
+ export const CONSOLE = 'console';
16
+ export class Console extends Carrier {
17
+ static carries(occupant, _asker) {
18
+ return occupant !== undefined;
19
+ }
20
+ }
@@ -0,0 +1,56 @@
1
+ import type { Asker, JsonObject, OccupantRecord } from '@nervur-org/nervur';
2
+ import { type Dialer, type Harbor } from '@nervur-org/nervur/harbor';
3
+ import { Faculty } from './faculty.ts';
4
+ export declare const DIALERS = "dialers";
5
+ export type Row = {
6
+ url: string;
7
+ };
8
+ declare const root: (_occ: OccupantRecord | undefined, asker: Asker) => boolean;
9
+ export declare class Dialers extends Faculty {
10
+ #private;
11
+ static cells: JsonObject;
12
+ static asks: {
13
+ keep: {
14
+ description: string;
15
+ input: {
16
+ type: string;
17
+ properties: {
18
+ url: {
19
+ type: string;
20
+ };
21
+ };
22
+ required: string[];
23
+ };
24
+ for: typeof root;
25
+ };
26
+ drop: {
27
+ description: string;
28
+ input: {
29
+ type: string;
30
+ properties: {
31
+ url: {
32
+ type: string;
33
+ };
34
+ };
35
+ required: string[];
36
+ };
37
+ for: typeof root;
38
+ };
39
+ list: {
40
+ description: string;
41
+ input: {
42
+ type: string;
43
+ };
44
+ for: typeof root;
45
+ };
46
+ };
47
+ harbor: Harbor | undefined;
48
+ readonly open: Map<string, Dialer>;
49
+ private get rows();
50
+ start(harbor: Harbor): void;
51
+ keep(args: JsonObject): JsonObject;
52
+ drop(args: JsonObject): JsonObject;
53
+ list(): JsonObject;
54
+ close(): void;
55
+ }
56
+ export {};
@@ -0,0 +1,75 @@
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 { dial } from '@nervur-org/nervur/harbor';
19
+ import { Faculty } from './faculty.js';
20
+ // The name the dock ward holds her under.
21
+ export const DIALERS = 'dialers';
22
+ // The root and nobody else: a line held open is the device's own to place.
23
+ const root = (_occ, asker) => asker.id === OWNER;
24
+ const URL_ = { type: 'object', properties: { url: { type: 'string' } }, required: ['url'] };
25
+ export class Dialers extends Faculty {
26
+ static cells = { watched: {}, minted: 0, dialers: {} };
27
+ static asks = {
28
+ keep: { description: 'hold a socket to that quo. route, from now and from every boot, until it is dropped', input: URL_, for: root },
29
+ drop: { description: 'close the socket to that route and forget it', input: URL_, for: root },
30
+ list: { description: 'every route she dials, and whether the line is open now', input: { type: 'object' }, for: root },
31
+ };
32
+ harbor;
33
+ open = new Map();
34
+ get rows() {
35
+ return this.cells.dialers;
36
+ }
37
+ // Every row dialed, once the daemon has handed the harbor to dial for.
38
+ start(harbor) {
39
+ this.harbor = harbor;
40
+ for (const row of Object.values(this.rows))
41
+ this.#dial(row.url);
42
+ }
43
+ #dial(url) {
44
+ if (!this.harbor || this.open.has(url))
45
+ return;
46
+ this.open.set(url, dial(this.harbor, url));
47
+ }
48
+ keep(args) {
49
+ const url = args.url;
50
+ if (typeof url !== 'string' || !/^https?:\/\/\S+$/.test(url))
51
+ return { error: 'url is the quo. route of the harbor dialed, over http or https' };
52
+ const clean = url.replace(/\/$/, '');
53
+ this.rows[clean] = { url: clean };
54
+ this.#dial(clean);
55
+ return { kept: clean };
56
+ }
57
+ drop(args) {
58
+ const url = typeof args.url === 'string' ? args.url.replace(/\/$/, '') : '';
59
+ if (!this.rows[url])
60
+ return { error: 'no such route' };
61
+ this.open.get(url)?.close();
62
+ this.open.delete(url);
63
+ delete this.rows[url];
64
+ return { dropped: url };
65
+ }
66
+ list() {
67
+ return { dialers: Object.keys(this.rows).map((url) => ({ url, open: this.open.get(url)?.socket !== null && this.open.has(url) })) };
68
+ }
69
+ // The device puts the box down: every line closes, the rows stay.
70
+ close() {
71
+ for (const d of this.open.values())
72
+ d.close();
73
+ this.open.clear();
74
+ }
75
+ }
@@ -0,0 +1,24 @@
1
+ import { Being } from '@nervur-org/nervur';
2
+ import type { Asker, JsonObject } from '@nervur-org/nervur';
3
+ export type Watched = {
4
+ by: string;
5
+ method: string;
6
+ args: JsonObject;
7
+ };
8
+ export type Woke = {
9
+ id: string;
10
+ by: string;
11
+ out: unknown;
12
+ };
13
+ export declare class Faculty extends Being {
14
+ static cells: JsonObject;
15
+ watch(args: JsonObject, asker: Asker): Promise<JsonObject>;
16
+ protected welcomed(_id: string): Promise<void>;
17
+ protected woke(round?: (w: Watched) => JsonObject, only?: string): Promise<Woke[] | null>;
18
+ get watching(): Record<string, Watched>;
19
+ }
20
+ export declare const settled: () => Promise<void>;
21
+ export declare function join(b: Being, name: string, watch?: {
22
+ method: string;
23
+ args?: JsonObject;
24
+ }, id?: string): Promise<string | null>;
@@ -0,0 +1,142 @@
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
+ export class Faculty extends Being {
22
+ static cells = { watched: {}, minted: 0 };
23
+ // Someone wants waking. She hands the invitation she minted and says what
24
+ // to ask her; this knocks it, takes it under an id of this being's own,
25
+ // and files it. The id never crosses: it is this side's name for the
26
+ // relation, and the caller's name for the same relation is her own.
27
+ //
28
+ // The empty ask is the knock, because every being answers one and asking
29
+ // for a named ask would make every subscriber owe a method she does not
30
+ // otherwise need.
31
+ async watch(args, asker) {
32
+ const inv = args.invitation;
33
+ if (!isInvitation(inv) || typeof args.method !== 'string')
34
+ return { error: 'an invitation and a method' };
35
+ const extra = args.args;
36
+ if (extra !== undefined && (extra === null || typeof extra !== 'object' || Array.isArray(extra)))
37
+ return { error: 'args is one object' };
38
+ const out = await this.knock(inv);
39
+ if (isSilence(out) || isWord(out))
40
+ return { error: 'no way back' };
41
+ const cells = this.cells;
42
+ const id = await this.take(`wake:${(cells.minted += 1)}`, inv);
43
+ if (id === null)
44
+ return { error: 'not taken' };
45
+ cells.watched[id] = { by: asker.id ?? '', method: args.method, args: extra ?? {} };
46
+ await this.welcomed(id);
47
+ return { watching: id };
48
+ }
49
+ // Somebody new is filed, and the answer to her `watch` waits for this. A
50
+ // faculty that owes a new subscriber something at once says it here: the
51
+ // timer wakes her, learns when she is next due and arms the box for it, so
52
+ // a being born long after the box was up is not waiting for a round nobody
53
+ // scheduled. Most faculties owe nothing, and this is why they say nothing.
54
+ async welcomed(_id) { }
55
+ // One round of waking. Each subscriber is asked what she asked to be
56
+ // asked, with whatever this round adds. A subscriber who removed the
57
+ // occupant says `removed` once, and her row goes with the standing; there
58
+ // is nothing to retry and nothing to remember.
59
+ //
60
+ // `only` is one subscriber of the round's own choosing, named by this
61
+ // being's own id for the relation, and it is the same round down to one
62
+ // row: a faculty that wakes somebody just filed learns exactly what a full
63
+ // round would have learned about her.
64
+ //
65
+ // `dropped` is this being herself: her ward took her out, so the standings
66
+ // she holds reach nobody. Null says so, and whoever holds the interval puts
67
+ // it down rather than waking an empty room for as long as the box is up.
68
+ async woke(round = (w) => w.args, only) {
69
+ const cells = this.cells;
70
+ const out = [];
71
+ for (const [id, w] of Object.entries(cells.watched)) {
72
+ if (only !== undefined && id !== only)
73
+ continue;
74
+ const said = await this.standings[id]?.ask(w.method, round(w));
75
+ if (said === undefined || (isWord(said) && wordOf(said) === 'dropped'))
76
+ return null;
77
+ if (isWord(said) && wordOf(said) === 'removed') {
78
+ this.standings.remove(id);
79
+ delete cells.watched[id];
80
+ continue;
81
+ }
82
+ out.push({ id, by: w.by, out: said });
83
+ }
84
+ return out;
85
+ }
86
+ // Who is waiting, by this being's own id for the relation. For a device's
87
+ // own code and for a suite; it crosses no door.
88
+ get watching() {
89
+ return this.cells.watched;
90
+ }
91
+ }
92
+ // The three moves a being makes at every birth to hold what her box lends.
93
+ // A standing points at a being and not at a box, so one she wakes up holding
94
+ // names a being on the box she was on when it was made; a restart and a
95
+ // migration are the same silent event to her, so she does these again every
96
+ // time she is constructed and is always pointed at the box she is on.
97
+ //
98
+ // `watch` says she wants waking too: her ward mints the occupant, the
99
+ // faculty takes the standing it makes, and from then on the wake arrives at
100
+ // her door under the id she chose.
101
+ // Every join in flight on this box. A being takes what her box lends at her
102
+ // birth, and birth is synchronous while taking a standing is not, so a
103
+ // harbor that answered "up" before they landed would be a box whose first
104
+ // tick woke nobody and whose first ring reached no platform. The terrain
105
+ // waits on this once, after its wards are hosted; nothing else reads it, and
106
+ // a join that fails leaves nothing behind but a being who asks again.
107
+ const flight = new Set();
108
+ export const settled = async () => {
109
+ while (flight.size) {
110
+ const now = [...flight];
111
+ await Promise.allSettled(now);
112
+ for (const p of now)
113
+ flight.delete(p);
114
+ }
115
+ };
116
+ export function join(b, name, watch, id = name) {
117
+ const p = joining(b, name, watch, id);
118
+ flight.add(p);
119
+ void p.catch(() => { });
120
+ return p;
121
+ }
122
+ async function joining(b, name, watch, id) {
123
+ // One turn, before anything is asked. A being is called at her door and
124
+ // holds her stance from her first line, but her ward sets that door after
125
+ // her constructor returns, so a stance call made inside it reaches a being
126
+ // her ward does not hold yet and is answered as one taken out. Every
127
+ // faculty relation is made at birth, so the wait belongs here rather than
128
+ // in each of them.
129
+ await Promise.resolve();
130
+ b.standings.remove(id);
131
+ b.occupants.remove(`${id}:wake`);
132
+ const got = await b.lend(name, id);
133
+ if (got === null)
134
+ return null;
135
+ if (!watch)
136
+ return got;
137
+ const back = await b.invite(`${id}:wake`);
138
+ if (!back)
139
+ return got;
140
+ await b.standings[id]?.ask('watch', { invitation: back, method: watch.method, ...(watch.args ? { args: watch.args } : {}) });
141
+ return got;
142
+ }
@@ -0,0 +1,13 @@
1
+ import type { Ring } from './push.ts';
2
+ export type Account = {
3
+ project_id: string;
4
+ client_email: string;
5
+ private_key: string;
6
+ };
7
+ export declare function isAccount(v: unknown): v is Account;
8
+ export type Doors = {
9
+ oauth: string;
10
+ fcm: string;
11
+ };
12
+ export declare const DOORS: Doors;
13
+ export declare function fcmPush(a: Account, at?: Doors): Ring;