@nervur-org/dock 0.4.1 → 0.6.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 (225) hide show
  1. package/README.md +17 -15
  2. package/api/route.ts +4 -72
  3. package/beings/avatar.ts +11 -7
  4. package/beings/carry.ts +129 -116
  5. package/beings/clock.ts +11 -7
  6. package/beings/desk.ts +7 -6
  7. package/beings/doorbell.ts +36 -7
  8. package/beings/index.ts +17 -4
  9. package/beings/join.ts +7 -4
  10. package/beings/link.ts +1 -1
  11. package/beings/porter.ts +5 -1
  12. package/beings/presence.ts +15 -5
  13. package/beings/setup.ts +27 -21
  14. package/beings/user.ts +26 -17
  15. package/beings/way.ts +1 -1
  16. package/{dock → box}/agents.ts +37 -44
  17. package/{dock → box}/apns.ts +4 -4
  18. package/box/box.ts +31 -0
  19. package/box/contract.ts +41 -0
  20. package/box/dialers.ts +88 -0
  21. package/box/dock.ts +148 -0
  22. package/box/envoy.ts +96 -0
  23. package/box/faculty.ts +111 -0
  24. package/{dock → box}/fcm.ts +1 -1
  25. package/box/grants.ts +90 -0
  26. package/box/http.ts +93 -0
  27. package/{dock → box}/http2.ts +7 -7
  28. package/box/index.ts +201 -0
  29. package/box/join.ts +78 -0
  30. package/box/local.ts +37 -0
  31. package/{dock → box}/location.ts +15 -3
  32. package/{dock → box}/notifications.ts +17 -5
  33. package/box/push.ts +85 -0
  34. package/box/ring.ts +85 -0
  35. package/{dock → box}/service.ts +2 -2
  36. package/box/timer.ts +150 -0
  37. package/box/tree.ts +22 -0
  38. package/{dock → box}/webpush.ts +2 -2
  39. package/cli/client.ts +1 -1
  40. package/cli/daemon.ts +101 -103
  41. package/cli/estate.ts +2 -3
  42. package/cli/nervur.ts +44 -25
  43. package/cli/stand.ts +1 -1
  44. package/dist/api/route.d.ts +1 -2
  45. package/dist/api/route.js +4 -76
  46. package/dist/beings/avatar.d.ts +4 -3
  47. package/dist/beings/avatar.js +9 -6
  48. package/dist/beings/carry.d.ts +10 -12
  49. package/dist/beings/carry.js +119 -127
  50. package/dist/beings/clock.js +10 -7
  51. package/dist/beings/desk.js +8 -7
  52. package/dist/beings/doorbell.d.ts +3 -1
  53. package/dist/beings/doorbell.js +37 -4
  54. package/dist/beings/index.d.ts +24 -4
  55. package/dist/beings/index.js +15 -4
  56. package/dist/beings/join.d.ts +1 -0
  57. package/dist/beings/join.js +6 -4
  58. package/dist/beings/link.d.ts +0 -1
  59. package/dist/beings/link.js +1 -1
  60. package/dist/beings/porter.js +5 -1
  61. package/dist/beings/presence.d.ts +1 -0
  62. package/dist/beings/presence.js +12 -3
  63. package/dist/beings/setup.d.ts +5 -2
  64. package/dist/beings/setup.js +16 -15
  65. package/dist/beings/user.d.ts +4 -3
  66. package/dist/beings/user.js +18 -11
  67. package/dist/{dock → box}/agents.d.ts +7 -4
  68. package/dist/{dock → box}/agents.js +30 -37
  69. package/dist/{dock → box}/apns.d.ts +1 -1
  70. package/dist/{dock → box}/apns.js +3 -3
  71. package/dist/box/box.d.ts +9 -0
  72. package/dist/box/box.js +18 -0
  73. package/dist/box/contract.d.ts +6 -0
  74. package/dist/box/contract.js +35 -0
  75. package/dist/{dock → box}/dialers.d.ts +7 -4
  76. package/dist/box/dialers.js +76 -0
  77. package/dist/box/dock.d.ts +182 -0
  78. package/dist/box/dock.js +128 -0
  79. package/dist/box/envoy.d.ts +44 -0
  80. package/dist/box/envoy.js +75 -0
  81. package/dist/box/faculty.d.ts +68 -0
  82. package/dist/box/faculty.js +97 -0
  83. package/dist/{dock → box}/fcm.d.ts +1 -1
  84. package/dist/{dock → box}/grants.d.ts +4 -3
  85. package/dist/box/grants.js +78 -0
  86. package/dist/box/http.d.ts +26 -0
  87. package/dist/box/http.js +89 -0
  88. package/dist/{dock → box}/http2.d.ts +1 -1
  89. package/dist/{dock → box}/http2.js +6 -6
  90. package/dist/box/index.d.ts +46 -0
  91. package/dist/box/index.js +179 -0
  92. package/dist/box/join.d.ts +19 -0
  93. package/dist/box/join.js +67 -0
  94. package/dist/{dock → box}/local.d.ts +3 -2
  95. package/dist/box/local.js +27 -0
  96. package/dist/{dock → box}/location.d.ts +3 -0
  97. package/dist/box/location.js +21 -0
  98. package/dist/{dock → box}/notifications.d.ts +3 -0
  99. package/dist/{dock → box}/notifications.js +14 -4
  100. package/dist/{dock → box}/push.d.ts +12 -18
  101. package/dist/box/push.js +83 -0
  102. package/dist/box/ring.d.ts +18 -0
  103. package/dist/box/ring.js +55 -0
  104. package/dist/{dock → box}/service.d.ts +1 -1
  105. package/dist/{dock → box}/service.js +1 -1
  106. package/dist/{dock → box}/timer.d.ts +2 -0
  107. package/dist/box/timer.js +127 -0
  108. package/dist/box/tree.d.ts +5 -0
  109. package/dist/box/tree.js +14 -0
  110. package/dist/{dock → box}/webpush.d.ts +1 -1
  111. package/dist/cli/client.d.ts +1 -0
  112. package/dist/cli/daemon.d.ts +2 -8
  113. package/dist/cli/daemon.js +102 -107
  114. package/dist/cli/estate.js +2 -3
  115. package/dist/cli/nervur.js +44 -24
  116. package/dist/cli/stand.d.ts +0 -14
  117. package/dist/cli/stand.js +1 -1
  118. package/dist/harbor/browser.d.ts +8 -5
  119. package/dist/harbor/browser.js +20 -21
  120. package/dist/harbor/capacitor.d.ts +3 -3
  121. package/dist/harbor/capacitor.js +9 -8
  122. package/dist/harbor/disk.d.ts +9 -9
  123. package/dist/harbor/disk.js +56 -51
  124. package/dist/harbor/edge/alarm.d.ts +4 -2
  125. package/dist/harbor/edge/alarm.js +9 -19
  126. package/dist/harbor/edge/edge.d.ts +7 -6
  127. package/dist/harbor/edge/edge.js +58 -53
  128. package/dist/harbor/edge/exercise.js +4 -4
  129. package/dist/harbor/edge/given.d.ts +0 -1
  130. package/dist/harbor/edge/given.js +0 -29
  131. package/dist/harbor/edge/storage.d.ts +0 -2
  132. package/dist/harbor/edge/storage.js +2 -2
  133. package/dist/harbor/http.js +4 -2
  134. package/dist/harbor/sealed.d.ts +0 -5
  135. package/dist/harbor/sealed.js +0 -39
  136. package/dist/harbor/tauri.d.ts +2 -2
  137. package/dist/harbor/tauri.js +13 -10
  138. package/dist/human/app.d.ts +1 -1
  139. package/dist/human/app.js +10 -12
  140. package/dist/human/door.d.ts +0 -1
  141. package/dist/human/door.js +1 -1
  142. package/dist/human/html.js +1 -1
  143. package/dist/human/move.js +1 -1
  144. package/dist/human/tab.bundle.txt +3629 -2824
  145. package/dist/human/wake.d.ts +5 -2
  146. package/dist/human/wake.js +23 -14
  147. package/dist/human/web.d.ts +0 -1
  148. package/dist/human/web.js +3 -3
  149. package/dist/human/worker.bundle.txt +2945 -2118
  150. package/dist/human/worlds.js +3 -3
  151. package/dist/mcp/agent.d.ts +0 -1
  152. package/dist/mcp/agent.js +1 -1
  153. package/dist/mcp/allow.js +3 -2
  154. package/dist/mcp/http.d.ts +0 -1
  155. package/dist/mcp/http.js +1 -1
  156. package/dist/mcp/oauth.d.ts +3 -4
  157. package/dist/mcp/oauth.js +4 -4
  158. package/dist/mcp/route.d.ts +1 -1
  159. package/dist/mcp/runner.d.ts +0 -1
  160. package/dist/mcp/runner.js +1 -1
  161. package/dist/mcp/server.d.ts +0 -1
  162. package/dist/mcp/server.js +1 -1
  163. package/harbor/browser.ts +22 -24
  164. package/harbor/capacitor.ts +9 -8
  165. package/harbor/disk.ts +53 -48
  166. package/harbor/edge/alarm.ts +15 -12
  167. package/harbor/edge/edge.ts +61 -55
  168. package/harbor/edge/exercise.ts +4 -4
  169. package/harbor/edge/given.ts +3 -33
  170. package/harbor/edge/storage.ts +2 -2
  171. package/harbor/http.ts +5 -3
  172. package/harbor/sealed.ts +0 -35
  173. package/harbor/tauri.ts +13 -10
  174. package/human/app.ts +12 -13
  175. package/human/door.ts +1 -1
  176. package/human/html.ts +1 -1
  177. package/human/local.ts +1 -1
  178. package/human/move.ts +1 -1
  179. package/human/tab.bundle.txt +3629 -2824
  180. package/human/wake.ts +36 -16
  181. package/human/web.ts +4 -4
  182. package/human/worker.bundle.txt +2945 -2118
  183. package/human/worlds.ts +3 -3
  184. package/mcp/agent.ts +1 -1
  185. package/mcp/allow.ts +2 -1
  186. package/mcp/http.ts +1 -1
  187. package/mcp/oauth.ts +4 -4
  188. package/mcp/route.ts +1 -1
  189. package/mcp/runner.ts +1 -1
  190. package/mcp/server.ts +1 -1
  191. package/package.json +10 -18
  192. package/GETTING_STARTED.md +0 -138
  193. package/GLOSSARY.md +0 -118
  194. package/WORLDS.md +0 -569
  195. package/beings/envoy.ts +0 -218
  196. package/dist/beings/envoy.d.ts +0 -54
  197. package/dist/beings/envoy.js +0 -211
  198. package/dist/dock/console.d.ts +0 -6
  199. package/dist/dock/console.js +0 -20
  200. package/dist/dock/dialers.js +0 -75
  201. package/dist/dock/faculty.d.ts +0 -24
  202. package/dist/dock/faculty.js +0 -142
  203. package/dist/dock/grants.js +0 -99
  204. package/dist/dock/index.d.ts +0 -41
  205. package/dist/dock/index.js +0 -113
  206. package/dist/dock/local.js +0 -44
  207. package/dist/dock/location.js +0 -11
  208. package/dist/dock/push.js +0 -132
  209. package/dist/dock/timer.js +0 -149
  210. package/dock/console.ts +0 -23
  211. package/dock/dialers.ts +0 -85
  212. package/dock/faculty.ts +0 -148
  213. package/dock/grants.ts +0 -111
  214. package/dock/index.ts +0 -133
  215. package/dock/local.ts +0 -48
  216. package/dock/push.ts +0 -161
  217. package/dock/timer.ts +0 -174
  218. package/quo-api.md +0 -196
  219. package/quo-app-bells.md +0 -280
  220. package/quo-dock.md +0 -1945
  221. package/quo-harbor.md +0 -874
  222. package/quo-human.md +0 -1042
  223. package/quo-mcp.md +0 -343
  224. /package/dist/{dock → box}/fcm.js +0 -0
  225. /package/dist/{dock → box}/webpush.js +0 -0
@@ -9,14 +9,14 @@
9
9
  // It is its own file for one reason, and the reason is the import. A worker
10
10
  // bundle that pulled `node:http2` in would not build, so nothing terrain
11
11
  // neutral may name this file: it is imported by the Node terrain and by
12
- // nothing else, and it is not exported from `dock/index.ts`.
12
+ // nothing else, and it is not exported from `box/index.ts`.
13
13
  //
14
14
  // A session is one TCP connection carrying every ring to one of Apple's two
15
15
  // hosts, which is what HTTP/2 is for and what Apple asks of a provider that
16
16
  // rings more than once. It is kept while it lives, made again when it dies,
17
17
  // and unref'd, so an idle bell is not a process that will not exit.
18
18
  import { connect, type ClientHttp2Session } from 'node:http2';
19
- import type { Ring, Token } from './push.ts';
19
+ import type { Ring, Token } from './ring.ts';
20
20
  import { HOSTS, answered, provider, request, type Apns } from './apns.ts';
21
21
 
22
22
  // What a Node box holds if it is a bell: the team's key from the portal and
@@ -56,12 +56,12 @@ export function apnsHttp2(a: Apns, at: typeof HOSTS = HOSTS, within = WITHIN): R
56
56
  stream.setEncoding('utf8');
57
57
  stream.setTimeout(within, () => stream.destroy(new Error('apns did not answer')));
58
58
  const { status, text } = await new Promise<{ status: number; text: string }>((ok, no) => {
59
- let status = 0;
60
- let text = '';
61
- stream.on('response', (h) => (status = Number(h[':status'] ?? 0)));
62
- stream.on('data', (chunk: string) => (text += chunk));
59
+ let code = 0;
60
+ let body = '';
61
+ stream.on('response', (h) => (code = h[':status'] ?? 0));
62
+ stream.on('data', (chunk: string) => (body += chunk));
63
63
  stream.on('error', no);
64
- stream.on('end', () => ok({ status, text }));
64
+ stream.on('end', () => ok({ status: code, text: body }));
65
65
  stream.end(req.body);
66
66
  });
67
67
  answered(status, text);
package/box/index.ts ADDED
@@ -0,0 +1,201 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // The dock: what a harbor stands for itself, in acts. A harbor is handed
3
+ // classes, a partition and a seed. Act one stands its own ward, the box
4
+ // ward: the Dock, then every faculty of the tree whose class stands, each
5
+ // born holding this box and held by the Dock under its key, then the index
6
+ // of which faculty fulfils which contract written into her cells, then
7
+ // every claimed path placed on the wire. Act two is the kit's: every other
8
+ // ward is handed one invitation on the Dock and takes it at boot, and a
9
+ // lend is an ask on her. Nothing here names a class a terrain or an estate
10
+ // brings: each faculty says on her class the contracts she fulfils, whether
11
+ // she stands on this runtime and the paths she claims.
12
+ import type { Harbor, WardPointers } from 'nervur/harbor';
13
+ import { BOX, hold, offerTo, BOX_SEED } from 'nervur/harbor';
14
+ import type { BeingClass, Stance } from 'nervur';
15
+ import { isInvitation } from 'nervur';
16
+ import { Dock, type BoxCells } from './dock.ts';
17
+ import { nameOf, titleOf, type Contract } from './contract.ts';
18
+ import { HTTP, type Faculty } from './faculty.ts';
19
+ import { WIRE } from './http.ts';
20
+ import type { Harborage } from './box.ts';
21
+ import { keyed, type Tree } from './tree.ts';
22
+
23
+ export { ALONE, harborageOver, type Harborage } from './box.ts';
24
+ export { Dock, BOX, DOMAIN, routesFor, type Routes, type Apps, type BoxCells } from './dock.ts';
25
+ export { BOX_SEED } from 'nervur/harbor';
26
+ export { Faculty, WATCH, INBOUND, HTTP, type Watched } from './faculty.ts';
27
+ export { Http, WIRE } from './http.ts';
28
+ export { Timer, TIMER } from './timer.ts';
29
+ export { Local, LOCAL } from './local.ts';
30
+ export { Grants, GRANTS, offered, ACCESS_TTL, REFRESH_TTL, type Grant, type Tokens, type Bearers } from './grants.ts';
31
+ export { Push, PUSH } from './push.ts';
32
+ export { Agents, AGENTS, isProcess, type Row as AgentRow, type Runner, type Side } from './agents.ts';
33
+ export { Dialers, DIALERS } from './dialers.ts';
34
+ export { EnvoyFaculty, WATCH_ACCOUNT, type Delivery } from './envoy.ts';
35
+ export { contract, covers, nameOf, titleOf, type Contract } from './contract.ts';
36
+ export { join, settled } from './join.ts';
37
+ export { Location, LOCATION, type Where } from './location.ts';
38
+ export { Notifications, NOTIFICATIONS } from './notifications.ts';
39
+ export { keyed, type Tree } from './tree.ts';
40
+ // What waking a device rings through: the push parts every terrain shares,
41
+ // the three grounds, and the bell service that holds a publisher's keys.
42
+ export { isToken, bellOf, banner, sealBanner, SEAL, type Token, type Ring, type Line } from './ring.ts';
43
+ export { webPush, mintVapid, publicOf, type Vapid } from './webpush.ts';
44
+ export { apnsPush, pkcs8, HOSTS, type Apns } from './apns.ts';
45
+ export { fcmPush, isAccount, DOORS, type Account, type Doors } from './fcm.ts';
46
+ export { handedTo, ringing, routed } from './service.ts';
47
+
48
+ // The classes this folder brings to every box ward, as a tree.
49
+ export const OWN: Tree = { box: { Dock } };
50
+
51
+ // A faculty as standing reads one: by the shape of its class, never by
52
+ // `instanceof`, so a class from a second copy of this package reads the same.
53
+ type FacultyClass = BeingClass & { fulfils: Contract[]; stands(): boolean; claims: string[] };
54
+ const isFaculty = (v: unknown): v is FacultyClass => typeof v === 'function' && Array.isArray((v as { fulfils?: unknown }).fulfils) && typeof (v as { stands?: unknown }).stands === 'function';
55
+
56
+ // The classes a box ward is handed, keyed where they live, every faculty
57
+ // among them born holding this box. The kit makes a being with her stance
58
+ // alone, so the class found under a faculty's key is her own class with the
59
+ // harborage handed on.
60
+ export function classes(tree: Tree, box: Harborage): Record<string, BeingClass> {
61
+ const out: Record<string, BeingClass> = keyed(OWN);
62
+ for (const [key, C] of Object.entries(keyed(tree))) {
63
+ if (!isFaculty(C)) continue;
64
+ const F = C as unknown as new (stance: Stance, box: Harborage) => Faculty;
65
+ out[key] = class extends F {
66
+ constructor(stance: Stance) {
67
+ super(stance, box);
68
+ }
69
+ };
70
+ }
71
+ return out;
72
+ }
73
+
74
+ // One faculty of this box, as the device's own code holds it: found the way
75
+ // a being finds one, by contract. The Dock is asked which key she lends the
76
+ // contract under, and the object under that key is taken, in this process
77
+ // and across no door. Nothing here names a class, so a terrain's subclass
78
+ // and an estate's leaf are found the same way the dock's own are.
79
+ type Boxed = { wards: ReadonlyMap<string, { ask: WardPointers['ask']; being(key: string): unknown }> };
80
+ export async function keyLent(harbor: Boxed, needs: Contract): Promise<string | undefined> {
81
+ const ward = harbor.wards.get(BOX_SEED);
82
+ if (!ward) return undefined;
83
+ const out = (await ward.ask('ask', { being: BOX, method: 'lends' })) as { lends?: Record<string, string> };
84
+ return out.lends?.[await nameOf(needs)];
85
+ }
86
+ export async function lent<F extends Faculty>(harbor: Boxed, needs: Contract): Promise<F | undefined> {
87
+ const key = await keyLent(harbor, needs);
88
+ return key === undefined ? undefined : (harbor.wards.get(BOX_SEED)?.being(key) as F | undefined);
89
+ }
90
+
91
+ // What the box is, asked of the Dock: its routes, its apps and its default
92
+ // ward, each null until the root said it. A harbor with no box ward is a
93
+ // box that is nothing yet.
94
+ export async function boxOf(harbor: Boxed): Promise<BoxCells> {
95
+ const ward = harbor.wards.get(BOX_SEED);
96
+ const out = ward ? ((await ward.ask('ask', { being: BOX, method: 'box' })) as Partial<BoxCells>) : {};
97
+ return { routes: out.routes ?? null, apps: out.apps ?? null, default: out.default ?? null };
98
+ }
99
+
100
+ // Act one on a harbor: its classes handed to it, each faculty born holding
101
+ // this box, the box's ward woken from what the store keeps or made, and
102
+ // stood. From here the harbor hands every other ward one invitation on the
103
+ // Dock, which is act two, the kit's.
104
+ export async function standHarbor(harbor: Harbor, tree: Tree, box: Harborage): Promise<void> {
105
+ Object.assign(harbor.classes, classes(tree, box));
106
+ const kept = await harbor.store.load(BOX_SEED);
107
+ const ward = harbor.wards.get(BOX_SEED) ?? (kept ? await harbor.host(BOX_SEED, kept) : await harbor.create(BOX_SEED, BOX));
108
+ await stand(ward, tree);
109
+ harbor.offering = (name) => offerTo(ward, name);
110
+ }
111
+
112
+ // A ward as its root holds it: the ask pointer, and a save where the terrain
113
+ // keeps one.
114
+ export type Rooted = { ask(method?: string, args?: Record<string, unknown>): Promise<unknown>; save?(): Promise<void> };
115
+
116
+ // Stand a box ward already made, in the order a harbor stands: her own
117
+ // being first, then every faculty of the tree whose class stands, each held
118
+ // by her under its key, then the index of which faculty fulfils which
119
+ // contract written into her cells, then every claimed path placed on the
120
+ // wire. A key already booted is refused and costs nothing, so standing a
121
+ // ward that woke from its partition finds every being as it was; and a run
122
+ // says only itself, so a title or a path an earlier run placed that this
123
+ // one does not is removed at both ends before anything is placed.
124
+ export async function stand(ward: Rooted, tree: Tree = {}): Promise<void> {
125
+ const own = keyed(OWN);
126
+ await ward.ask('boot', { key: BOX, class: Object.keys(own).find((k) => own[k] === Dock) });
127
+ const standing = Object.entries(keyed(tree)).filter((e): e is [string, FacultyClass] => isFaculty(e[1]) && e[1].stands());
128
+
129
+ // The index. Under each contract, the classes that fulfil it, and of
130
+ // those the ones no other extends, so a subclass wins over its base when
131
+ // both stand. One left is lent under it. Two left, with neither extending
132
+ // the other, are a clash: the contract is lent to neither, the Dock keeps
133
+ // the clash by name in her cells for the root to read and a terrain to
134
+ // print, and the box stays up, so one bad contract costs that contract
135
+ // and nothing else.
136
+ const under = new Map<string, { title: string; keys: [string, FacultyClass][] }>();
137
+ const names = new Map<string, string[]>();
138
+ for (const [key, C] of standing) {
139
+ for (const c of C.fulfils) {
140
+ const name = await nameOf(c);
141
+ names.set(key, [...(names.get(key) ?? []), name]);
142
+ const u = under.get(name) ?? { title: titleOf(c), keys: [] };
143
+ u.keys.push([key, C]);
144
+ under.set(name, u);
145
+ }
146
+ }
147
+ const lends: Record<string, string> = {};
148
+ const clashes: Record<string, string[]> = {};
149
+ for (const [name, u] of under) {
150
+ const leaves = u.keys.filter(([, C]) => !u.keys.some(([, D]) => D !== C && D.prototype instanceof C));
151
+ if (leaves.length === 1) lends[name] = leaves[0][0];
152
+ else clashes[u.title || name] = leaves.map(([k]) => k);
153
+ }
154
+ // The word a screen shows a faculty under: what the first contract she is
155
+ // lent under calls itself. A key carries a slash and a title never does.
156
+ const titles: Record<string, string> = {};
157
+ for (const [key] of standing) {
158
+ const first = (names.get(key) ?? []).find((n) => lends[n] === key);
159
+ const title = first === undefined ? '' : (under.get(first)?.title ?? '');
160
+ if (title && title !== key) titles[title] = key;
161
+ }
162
+
163
+ const claims = new Map<string, string[]>();
164
+ for (const [key, C] of standing) {
165
+ const made = (await ward.ask('boot', { key, class: key })) as { booted?: string };
166
+ if (made.booted === key) await hold(ward, key);
167
+ for (const p of C.claims ?? []) claims.set(p, [...(claims.get(p) ?? []), key]);
168
+ }
169
+
170
+ // The index written whole, and the Dock's title standings held to this
171
+ // run: she removes her end of one this run does not place, and the root
172
+ // removes the far end from every faculty she holds, before placing what
173
+ // she does not hold yet.
174
+ const stood = (await ward.ask('ask', { being: BOX, method: 'stood', args: { lends, titles, clashes } })) as { held?: string[]; pruned?: string[] };
175
+ const keys = (stood.held ?? []).filter((id) => id.includes('/'));
176
+ for (const t of stood.pruned ?? []) for (const key of keys) await ward.ask('remove', { being: key, id: `${BOX}:${t}` });
177
+ for (const [title, key] of Object.entries(titles)) {
178
+ if (stood.held?.includes(title)) continue;
179
+ const inv = await ward.ask('invite', { being: key, id: `${BOX}:${title}` });
180
+ if (isInvitation(inv)) await ward.ask('knock', { being: BOX, id: title, invitation: inv });
181
+ }
182
+
183
+ // Every claimed path, placed on the box's wire: the root invites on the
184
+ // faculty that claims it for the http faculty, who holds it under the
185
+ // path. A path two faculties claim is neither's, and the box stays up. A
186
+ // path she holds that this run does not claim, or that another faculty
187
+ // claims now, goes at both ends the same way a title does.
188
+ const wire = lends[await nameOf(WIRE)];
189
+ if (wire) {
190
+ const claimed: Record<string, string> = {};
191
+ for (const [p, ks] of claims) if (ks.length === 1) claimed[p] = ks[0];
192
+ const wired = (await ward.ask('ask', { being: wire, method: 'wired', args: { claimed } })) as { held?: string[]; pruned?: string[] };
193
+ for (const p of wired.pruned ?? []) for (const key of keys) await ward.ask('remove', { being: key, id: `${HTTP}${p}` });
194
+ for (const [p, key] of Object.entries(claimed)) {
195
+ if (wired.held?.includes(p)) continue;
196
+ const inv = await ward.ask('invite', { being: key, id: `${HTTP}${p}` });
197
+ if (isInvitation(inv)) await ward.ask('knock', { being: wire, id: p, invitation: inv });
198
+ }
199
+ }
200
+ await ward.save?.();
201
+ }
package/box/join.ts ADDED
@@ -0,0 +1,78 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // How a being takes what her box lends. She names the contract she needs and
3
+ // the id she holds it under; the box lends under the contract's digest; she
4
+ // reads the describe she was handed and keeps it only if it covers what she
5
+ // asked for. With `watch` she also asks to be woken, as the occupant she
6
+ // names `<id>:wake`, handing whatever the faculty needs to know her by; a
7
+ // watch refused puts the whole relation down.
8
+ //
9
+ // A being takes what her box lends at her birth, again at every birth, since
10
+ // a restart and a move are one event to her. Birth is synchronous and taking
11
+ // a standing is not, so every join in flight is kept on the being who made
12
+ // it, and a terrain waits on `settled` over its own wards once they are
13
+ // hosted: a box is up when its beings have landed, and another box's beings
14
+ // are no business of this one.
15
+ import { isBlueprint } from 'nervur';
16
+ import type { Being, JsonObject } from 'nervur';
17
+ import { covers, nameOf, type Contract } from './contract.ts';
18
+
19
+ const flight = new WeakMap<object, Set<Promise<unknown>>>();
20
+
21
+ // A ward as whoever holds it can walk it: the keys of the beings here this
22
+ // run, and the object under each.
23
+ export type Hosting = { keys(): string[]; being(key: string): unknown };
24
+ // The memory harbor's hand for a suite: every partition, and the object
25
+ // under each being's cells. A booted ward there is two pointers and a pk
26
+ // and nothing else, so the walk goes through the harbor.
27
+ type Memory = { partitions: ReadonlyMap<string, Record<string, unknown>>; objects: WeakMap<object, unknown> };
28
+
29
+ const beingsOf = (of: { wards: ReadonlyMap<string, Hosting> } | Hosting | Memory): unknown[] => {
30
+ if ('partitions' in of) return [...of.partitions.values()].flatMap((rows) => Object.values((rows.beings ?? {}) as Record<string, object>).map((cells) => of.objects.get(cells)));
31
+ const wards = 'wards' in of ? [...of.wards.values()] : [of];
32
+ return wards.flatMap((w) => w.keys().map((k) => w.being(k)));
33
+ };
34
+
35
+ // Every join in flight on one box's wards, or on one ward, landed. Walked
36
+ // again until nothing is in flight, since a being landing may boot another.
37
+ export async function settled(of: { wards: ReadonlyMap<string, Hosting> } | Hosting | Memory): Promise<void> {
38
+ for (;;) {
39
+ const now = beingsOf(of).flatMap((b) => [...(flight.get(b as object) ?? [])]);
40
+ if (now.length === 0) return;
41
+ await Promise.allSettled(now);
42
+ }
43
+ }
44
+
45
+ export function join(b: Being, needs: Contract, id: string, watch?: { method: string; args?: JsonObject; with?: JsonObject }): Promise<string | null> {
46
+ const p = joining(b, needs, id, watch);
47
+ const mine = flight.get(b) ?? new Set<Promise<unknown>>();
48
+ mine.add(p);
49
+ flight.set(b, mine);
50
+ const done = () => void mine.delete(p);
51
+ void p.then(done, done);
52
+ return p;
53
+ }
54
+
55
+ async function joining(b: Being, needs: Contract, id: string, watch: { method: string; args?: JsonObject; with?: JsonObject } | undefined): Promise<string | null> {
56
+ // One turn first: her ward sets her door after her constructor returns, so
57
+ // a stance call made inside it would reach a being her ward does not hold
58
+ // yet.
59
+ await Promise.resolve();
60
+ b.standings.remove(id);
61
+ b.occupants.remove(`${id}:wake`);
62
+ if ((await b.lend(await nameOf(needs), id)) === null) return null;
63
+ const held = await b.standings[id]?.ask();
64
+ if (!isBlueprint(held) || !covers(held, needs)) {
65
+ b.standings.remove(id);
66
+ return null;
67
+ }
68
+ if (watch) {
69
+ const back = await b.invite(`${id}:wake`);
70
+ const said = back ? await b.standings[id]?.ask('watch', { ...watch.with, invitation: back as unknown as JsonObject, method: watch.method, ...(watch.args ? { args: watch.args } : {}) }) : undefined;
71
+ if (!said || typeof said !== 'object' || 'error' in said) {
72
+ b.standings.remove(id);
73
+ b.occupants.remove(`${id}:wake`);
74
+ return null;
75
+ }
76
+ }
77
+ return id;
78
+ }
package/box/local.ts ADDED
@@ -0,0 +1,37 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // The box's way in: a process the device saw on a socket only its user can
3
+ // open, which is the whole of the vouch. She holds the nonces this box
4
+ // minted, in this process and never in cells, each honoured once. The
5
+ // device's own code mints on her directly; the desk asks `verify` at the
6
+ // standing she lends.
7
+ import type { JsonObject } from 'nervur';
8
+ import { VERIFY, type Verified } from '../beings/way.ts';
9
+ import { contract } from './contract.ts';
10
+ import { Faculty } from './faculty.ts';
11
+
12
+ // What a desk needs from a box that saw a process on its own socket.
13
+ export const LOCAL = contract('local', { verify: VERIFY });
14
+
15
+ export class Local extends Faculty {
16
+ static override asks = { verify: VERIFY };
17
+ static override fulfils = [LOCAL];
18
+
19
+ readonly #nonces = new Map<string, Verified>();
20
+
21
+ mint(nonce: string, who: Verified): void {
22
+ this.#nonces.set(nonce, who);
23
+ }
24
+ drop(nonce: string): void {
25
+ this.#nonces.delete(nonce);
26
+ }
27
+
28
+ verify(args: JsonObject): Verified | null {
29
+ const proof = args.proof;
30
+ if (!proof || typeof proof !== 'object' || Array.isArray(proof)) return null;
31
+ const nonce = proof.nonce;
32
+ const who = typeof nonce === 'string' ? this.#nonces.get(nonce) : undefined;
33
+ if (!who) return null;
34
+ this.#nonces.delete(nonce as string);
35
+ return who;
36
+ }
37
+ }
@@ -6,13 +6,25 @@
6
6
  // who asks for the name is lent nothing.
7
7
  import type { JsonObject } from 'nervur';
8
8
  import { Faculty } from './faculty.ts';
9
+ import { contract } from './contract.ts';
9
10
 
10
11
  export type Where = { lat: number; lng: number; accuracy?: number; at: number };
11
12
 
13
+ const ASKS = {
14
+ where: { description: 'where this box is', input: { type: 'object' } },
15
+ };
16
+
17
+ // What a being needs from a box that can say where it is.
18
+ export const LOCATION = contract('location', ASKS);
19
+
12
20
  export class Location extends Faculty {
13
- static override asks = {
14
- where: { description: 'where this box is', input: { type: 'object' } },
15
- };
21
+ static override asks = ASKS;
22
+ static override fulfils = [LOCATION];
23
+ // This body says where nothing is, so it stands nowhere: a terrain whose
24
+ // platform can answer subclasses her and says so.
25
+ static override stands(): boolean {
26
+ return false;
27
+ }
16
28
 
17
29
  // The platform's own permission prompt is the first ask's, and a refusal is
18
30
  // the error object every ask after it answers.
@@ -24,6 +24,15 @@
24
24
  import { arithmetic } from 'nervur/ward';
25
25
  import type { JsonObject } from 'nervur';
26
26
  import { Faculty } from './faculty.ts';
27
+ import { contract } from './contract.ts';
28
+
29
+ const ASKS = {
30
+ notify: { description: 'show the human a line', input: { type: 'object', properties: { title: { type: 'string' }, body: { type: 'string' } }, required: ['title'] } },
31
+ banner: { description: "this device's banner key, the public half, which a world seals a notification to", input: { type: 'object' } },
32
+ };
33
+
34
+ // What a being needs from a box that can show the human a line.
35
+ export const NOTIFICATIONS = contract('notifications', ASKS);
27
36
 
28
37
  // A key is thirty two bytes of the platform's own randomness, as every seed
29
38
  // in this kit is.
@@ -33,10 +42,13 @@ export class Notifications extends Faculty {
33
42
  // The banner key, as hex: the seed it was made from, kept so the pair can
34
43
  // be made again, and the public half a device hands out.
35
44
  static override cells: JsonObject = { watched: {}, minted: 0, banner: null };
36
- static override asks = {
37
- notify: { description: 'show the human a line', input: { type: 'object', properties: { title: { type: 'string' }, body: { type: 'string' } }, required: ['title'] } },
38
- banner: { description: "this device's banner key, the public half, which a world seals a notification to", input: { type: 'object' } },
39
- };
45
+ static override asks = ASKS;
46
+ static override fulfils = [NOTIFICATIONS];
47
+ // This body shows nothing, so it stands nowhere: a terrain with a
48
+ // notification center subclasses her and says so.
49
+ static override stands(): boolean {
50
+ return false;
51
+ }
40
52
 
41
53
  notify(_args: JsonObject): Promise<JsonObject> {
42
54
  return Promise.resolve({ error: 'this box shows no notifications' });
@@ -50,7 +62,7 @@ export class Notifications extends Faculty {
50
62
  this.cells.banner = arithmetic.hex(seed());
51
63
  await this.plant(await arithmetic.sealingPair(arithmetic.unhex(this.cells.banner)));
52
64
  }
53
- return arithmetic.sealingPair(arithmetic.unhex(this.cells.banner as string));
65
+ return arithmetic.sealingPair(arithmetic.unhex(this.cells.banner));
54
66
  }
55
67
 
56
68
  async banner(_args: JsonObject): Promise<JsonObject> {
package/box/push.ts ADDED
@@ -0,0 +1,85 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // Waking a device that is not reachable, the one way its platform allows: a
3
+ // ring carrying the device's token and a banner sealed to its key. A token
4
+ // names its kind and a kind is one ring. The rings are live objects and not
5
+ // values, so a terrain that can ring brings a class that builds its own; this
6
+ // body rings nothing and stands nowhere.
7
+ //
8
+ // What she keeps: the world's doorbell key, a P-256 JWK she mints herself the
9
+ // first time a terrain asks, and the bell service a phone's ring is handed
10
+ // to when its token names none, a row the root keeps.
11
+ import { OWNER } from 'nervur';
12
+ import type { Asker, JsonObject, OccupantRecord } from 'nervur';
13
+ import { SEAL, isToken, type Line, type Ring } from './ring.ts';
14
+ import { mintVapid, publicOf } from './webpush.ts';
15
+ import { contract } from './contract.ts';
16
+ import { Faculty } from './faculty.ts';
17
+
18
+ const root = (_occ: OccupantRecord | undefined, asker: Asker) => asker.id === OWNER;
19
+
20
+ // What a holder is owed: a ring, and which kinds this box can ring.
21
+ const OWED = {
22
+ ring: { description: 'wake a device: its token, the banner sealed to it, or the line itself where the ground encrypts it', input: { type: 'object', properties: { token: { type: 'object' }, sealed: { type: 'string' }, line: { type: 'object', properties: { title: { type: 'string' }, body: { type: 'string' } }, required: ['title', 'body'] } }, required: ['token'] } },
23
+ kinds: { description: 'which kinds this box can ring', input: { type: 'object' } },
24
+ };
25
+
26
+ // What a being needs from a box that can wake a device.
27
+ export const PUSH = contract('push', OWED);
28
+
29
+ export class Push extends Faculty {
30
+ static override cells: JsonObject = { watched: {}, minted: 0, key: null, service: null };
31
+ static override asks = { ...OWED, bell: { description: 'the public half of this box’s doorbell key, minted at the first ask and kept in her cells', input: { type: 'object' }, for: root }, keep: { description: 'the bell service a phone is rung through when its token names none; null forgets it', input: { type: 'object', properties: { service: { type: ['string', 'null'] } }, required: ['service'] }, for: root } };
32
+ static override fulfils = [PUSH];
33
+ static override stands(): boolean {
34
+ return false;
35
+ }
36
+
37
+ // The doorbell key, minted at the root's first ask and kept; nobody mints
38
+ // it from outside. What crosses is the public half, which every page's
39
+ // config carries.
40
+ async bell(): Promise<JsonObject> {
41
+ if (this.cells.key === null) this.cells.key = (await mintVapid()).key as unknown as JsonObject;
42
+ return { bell: publicOf(this.cells.key as unknown as JsonWebKey) };
43
+ }
44
+ get key(): JsonWebKey | null {
45
+ return this.cells.key as unknown as JsonWebKey | null;
46
+ }
47
+ get service(): string | undefined {
48
+ return typeof this.cells.service === 'string' ? this.cells.service : undefined;
49
+ }
50
+
51
+ keep(args: JsonObject): JsonObject {
52
+ const service = args.service;
53
+ if (service !== null && (typeof service !== 'string' || !/^https?:\/\/\S+$/.test(service))) return { error: 'a bell service is a URL over http or https, or null' };
54
+ this.cells.service = service === null ? null : service.replace(/\/$/, '');
55
+ return { service: this.cells.service };
56
+ }
57
+
58
+ // What this box can ring. The base rings nothing.
59
+ protected get rings(): Readonly<Record<string, Ring>> {
60
+ return {};
61
+ }
62
+
63
+ kinds(): JsonObject {
64
+ return { kinds: Object.keys(this.rings) };
65
+ }
66
+
67
+ async ring(args: JsonObject): Promise<JsonObject> {
68
+ const token = args.token;
69
+ if (!isToken(token)) return { error: 'a token names its kind' };
70
+ const sealed = args.sealed;
71
+ if (sealed !== undefined && typeof sealed !== 'string') return { error: 'a seal is text' };
72
+ if (typeof sealed === 'string' && sealed.length > SEAL) return { error: `a seal is at most ${SEAL} bytes` };
73
+ const said = args.line;
74
+ if (said !== undefined && (said === null || typeof said !== 'object' || Array.isArray(said) || typeof said.title !== 'string' || typeof said.body !== 'string')) return { error: 'a line is a title and a body' };
75
+ const line = said as Line | undefined;
76
+ const r = Object.hasOwn(this.rings, token.kind) ? this.rings[token.kind] : undefined;
77
+ if (!r) return { error: `this box cannot ring a ${token.kind}` };
78
+ try {
79
+ await r.ring(token, sealed, line);
80
+ return { rang: token.kind };
81
+ } catch (e) {
82
+ return { error: e instanceof Error ? e.message : 'the ring threw' };
83
+ }
84
+ }
85
+ }
package/box/ring.ts ADDED
@@ -0,0 +1,85 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // What waking a device is made of, beside the push faculty: the token a
3
+ // device hands a world, the ring a platform rings through, the banner a
4
+ // device shows, and the seal only the device can open. A token names its
5
+ // kind, and a kind is one ring: `web` is Web Push under a keypair a world
6
+ // mints itself, `apns` and `fcm` are the two stores' own, handed on to the
7
+ // publisher's bell service by a world that holds no platform key.
8
+ import { arithmetic } from 'nervur/ward';
9
+ import type { JsonObject } from 'nervur';
10
+
11
+ // What a device hands a world so it can be woken, kept in that device's
12
+ // occupant notes and re-sent whenever the device comes to the foreground,
13
+ // since a push service may hand a device a new one at any time. A token of
14
+ // a store's kind names its bell, the address of the service holding the
15
+ // keys of the app that minted it, and that app, so a world rings a phone
16
+ // running an app it never shipped and holds no key; Web Push is the
17
+ // world's own key and names no service.
18
+ export type Token = { kind: string; bell?: string; app?: string } & JsonObject;
19
+ export const isToken = (v: unknown): v is Token => v !== null && typeof v === 'object' && !Array.isArray(v) && typeof (v as { kind?: unknown }).kind === 'string';
20
+
21
+ // The bell a token names, when it names one a ring can reach.
22
+ export function bellOf(token: Token): string | undefined {
23
+ if (typeof token.bell !== 'string') return undefined;
24
+ try {
25
+ const u = new URL(token.bell);
26
+ return u.protocol === 'https:' || u.protocol === 'http:' ? token.bell : undefined;
27
+ } catch {
28
+ return undefined;
29
+ }
30
+ }
31
+
32
+ // One platform's way of ringing. It is handed the token whole, because only
33
+ // the platform knows what is in one, and it says nothing back: a ring either
34
+ // left or threw.
35
+ //
36
+ // `sealed` is the banner, sealed to the device's banner key by the world
37
+ // that rang. Nothing between the world and the device can read it: not this
38
+ // box, not the bell service, not the platform that carries it. A ring with
39
+ // neither a seal nor a line wakes the device and says nothing, which is
40
+ // what a world with nothing to show sends.
41
+ //
42
+ // `line` is the same banner, unsealed, and only a ground whose own
43
+ // encryption the world performs may take one: Web Push, where RFC 8291
44
+ // encrypts the body to keys only that browser holds and the world holds the
45
+ // keypair the subscription was made under. A store's ground is handed the
46
+ // seal and never this, because what carries it is a service the world does
47
+ // not own.
48
+ export type Line = { title: string; body: string };
49
+ export type Ring = { ring(token: Token, sealed?: string, line?: Line): Promise<void> };
50
+
51
+ // What a platform will carry, so a seal too big is refused where a human
52
+ // can still read why rather than two hops later by a service nobody reads.
53
+ export const SEAL = 4096;
54
+
55
+ // The line a device shows when it was woken while nobody was looking,
56
+ // composed from one object. A pushed object is whatever the far being sent,
57
+ // so the two names a notification has are taken when they are there and the
58
+ // object is shown as it is when they are not.
59
+ export function banner(o: JsonObject): Line {
60
+ const text = (v: unknown) => (typeof v === 'string' && v.trim() ? v.trim() : '');
61
+ const title = text(o.title) || text(o.from) || 'Nervur';
62
+ const body =
63
+ text(o.body) ||
64
+ text(o.said) ||
65
+ Object.entries(o)
66
+ .filter(([k]) => k !== 'title' && k !== 'from')
67
+ .map(([k, v]) => `${k}: ${typeof v === 'string' ? v : JSON.stringify(v)}`)
68
+ .join(', ');
69
+ return { title, body };
70
+ }
71
+
72
+ // A banner sealed to a device's banner key, as hex, or null when it will
73
+ // not seal or will not fit. The world seals and the process that draws the
74
+ // banner opens it; no doorbell, no bell service and no platform between
75
+ // them holds either half, which is why a ring may carry a line at all.
76
+ export async function sealBanner(line: JsonObject, key: string): Promise<string | null> {
77
+ if (!/^[0-9a-f]{64}$/.test(key)) return null;
78
+ try {
79
+ const boxed = await arithmetic.box(new TextEncoder().encode(JSON.stringify(line)), arithmetic.unhex(key), globalThis.crypto.getRandomValues(new Uint8Array(32)));
80
+ const wire = arithmetic.hex(boxed.bytes);
81
+ return wire.length > SEAL ? null : wire;
82
+ } catch {
83
+ return null;
84
+ }
85
+ }
@@ -26,7 +26,7 @@
26
26
  // own. An account here would be a registry of worlds, which is the one
27
27
  // thing the app is built not to have. The day this is abused it wants a
28
28
  // rate limit at the edge, which is the terrain's and not a word of Quo.
29
- import { bellOf, type Ring, type Token } from './push.ts';
29
+ import { bellOf, type Ring, type Token } from './ring.ts';
30
30
 
31
31
  // The ring a world holds for a store's kind: the token names its bell and
32
32
  // the ring goes there, so a phone running an app this world never shipped
@@ -88,7 +88,7 @@ export function ringing(rings: Record<string, Ring>, apps?: readonly string[]):
88
88
  const app = (token as { app?: unknown }).app;
89
89
  if (apps && (typeof app !== 'string' || !apps.includes(app))) return answer(400, { error: `this service holds no key for ${typeof app === 'string' ? app : 'an app the token does not name'}` });
90
90
  try {
91
- await g.ring(token as Token, sealed as string | undefined);
91
+ await g.ring(token as Token, sealed);
92
92
  return answer(200, { rang: kind });
93
93
  } catch (e) {
94
94
  // The platform's own refusal, passed back as it came. A world reads