@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
package/box/dialers.ts ADDED
@@ -0,0 +1,88 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // The box's lines: a socket held to another box's quo. route, one per row,
3
+ // announcing this harbor's wards on open and reconnecting when a line drops.
4
+ // The rows are her cells, placed by the root with `keep`. The harbor she
5
+ // dials for is the terrain's, handed at birth in the class body it brings;
6
+ // this body dials nothing and stands nowhere. Every row is dialed once the
7
+ // box is up, and a row kept after that is dialed at once.
8
+ import { OWNER } from 'nervur';
9
+ import type { Asker, JsonObject, OccupantRecord, Stance } from 'nervur';
10
+ import { dial, type Dialer, type Harbor } from 'nervur/harbor';
11
+ import type { Harborage } from './box.ts';
12
+ import { contract } from './contract.ts';
13
+ import { Faculty } from './faculty.ts';
14
+
15
+ export type Row = { url: string };
16
+
17
+ const root = (_occ: OccupantRecord | undefined, asker: Asker) => asker.id === OWNER;
18
+
19
+ const URL_ = { type: 'object', properties: { url: { type: 'string' } }, required: ['url'] };
20
+
21
+ const ASKS = {
22
+ keep: { description: 'hold a socket to that quo. route, from now and from every boot, until it is dropped', input: URL_, for: root },
23
+ drop: { description: 'close the socket to that route and forget it', input: URL_, for: root },
24
+ list: { description: 'every route she dials, and whether the line is open now', input: { type: 'object' }, for: root },
25
+ };
26
+
27
+ // What the box's own code needs from the faculty that holds its lines.
28
+ export const DIALERS = contract('dialers', ASKS);
29
+
30
+ export class Dialers extends Faculty {
31
+ static override cells: JsonObject = { watched: {}, minted: 0, dialers: {} };
32
+ static override asks = ASKS;
33
+ static override fulfils = [DIALERS];
34
+ static override stands(): boolean {
35
+ return false;
36
+ }
37
+
38
+ readonly harbor: Harbor | undefined;
39
+ readonly open = new Map<string, Dialer>();
40
+ #up = false;
41
+
42
+ constructor(stance: Stance, box?: Harborage, harbor?: Harbor) {
43
+ super(stance, box);
44
+ this.harbor = harbor;
45
+ this.box.up(() => {
46
+ this.#up = true;
47
+ for (const row of Object.values(this.rows)) this.#dial(row.url);
48
+ return Promise.resolve();
49
+ });
50
+ }
51
+
52
+ private get rows(): Record<string, Row> {
53
+ return this.cells.dialers as unknown as Record<string, Row>;
54
+ }
55
+
56
+ #dial(url: string): void {
57
+ if (!this.harbor || !this.#up || this.open.has(url)) return;
58
+ this.open.set(url, dial(this.harbor, url));
59
+ }
60
+
61
+ keep(args: JsonObject): JsonObject {
62
+ const url = args.url;
63
+ if (typeof url !== 'string' || !/^https?:\/\/\S+$/.test(url)) return { error: 'url is the quo. route of the harbor dialed, over http or https' };
64
+ const clean = url.replace(/\/$/, '');
65
+ this.rows[clean] = { url: clean };
66
+ this.#dial(clean);
67
+ return { kept: clean };
68
+ }
69
+
70
+ drop(args: JsonObject): JsonObject {
71
+ const url = typeof args.url === 'string' ? args.url.replace(/\/$/, '') : '';
72
+ if (!this.rows[url]) return { error: 'no such route' };
73
+ this.open.get(url)?.close();
74
+ this.open.delete(url);
75
+ delete this.rows[url];
76
+ return { dropped: url };
77
+ }
78
+
79
+ list(): JsonObject {
80
+ return { dialers: Object.keys(this.rows).map((url) => ({ url, open: this.open.get(url)?.socket !== null && this.open.has(url) })) };
81
+ }
82
+
83
+ // The device puts the box down: every line closes, the rows stay.
84
+ close(): void {
85
+ for (const d of this.open.values()) d.close();
86
+ this.open.clear();
87
+ }
88
+ }
package/box/dock.ts ADDED
@@ -0,0 +1,148 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // The box ward's own being, and so the harbor's. She is the kit's Box, the
3
+ // box as one standing that every hosted ward holds, and two things more.
4
+ //
5
+ // She lends by contract: a being asks her for a contract's digest, and she
6
+ // holds each faculty under its key, so her cells keep the index from digest
7
+ // to key, written whole by the root at every boot from what this run's
8
+ // classes say. A name she is asked to offer is a digest first and a key
9
+ // second, and then it is the kit's offer.
10
+ //
11
+ // And she carries: she holds a standing at every faculty her harbor stood
12
+ // and shows their asks as her own to whoever the root let in, so the one
13
+ // screen a device has with no world joined draws her the way it draws any
14
+ // being.
15
+ import { OWNER } from 'nervur';
16
+ import { Box, BOX_SEED } from 'nervur/harbor';
17
+ import type { Asker, JsonObject, OccupantRecord, Reply } from 'nervur';
18
+ import { carry } from '../beings/carry.ts';
19
+
20
+ export { BOX } from 'nervur/harbor';
21
+
22
+ const root = (_occ: OccupantRecord | undefined, asker: Asker) => asker.id === OWNER;
23
+
24
+ // The public origins a proxy serves for this box, `mcp.`, `web.`, `quo.`
25
+ // and `api.`, by the one convention: under one domain, over https. A domain
26
+ // is said once and the names follow.
27
+ export type Routes = { mcp: string; web: string; quo?: string; api?: string };
28
+ export const DOMAIN = /^[a-z0-9.-]+\.[a-z]{2,}$/i;
29
+ export function routesFor(domain: string): Required<Routes> {
30
+ if (!DOMAIN.test(domain)) throw new Error(`${domain} is not a domain`);
31
+ return { mcp: `https://mcp.${domain}`, web: `https://web.${domain}`, quo: `https://quo.${domain}`, api: `https://api.${domain}` };
32
+ }
33
+
34
+ // The apps this box's links open, as the two platforms want them named. A
35
+ // box that names none serves no association file, and its links open the
36
+ // tab.
37
+ export type Apps = { apple: string[]; android: { package: string; fingerprints: string[] }[] };
38
+
39
+ // What the box is, as the root placed it through her asks and as the
40
+ // harbor reads it at boot: its routes, its apps and the ward a caller means
41
+ // when naming none. Null is unsaid.
42
+ export type BoxCells = { routes: Routes | null; apps: Apps | null; default: string | null };
43
+
44
+ const APPS = { type: 'object', properties: { apple: { type: 'array', items: { type: 'string' } }, android: { type: 'array', items: { type: 'object', properties: { package: { type: 'string' }, fingerprints: { type: 'array', items: { type: 'string' } } }, required: ['package', 'fingerprints'] } } } };
45
+
46
+ export class Dock extends Box {
47
+ static carries(occupant: OccupantRecord | undefined): boolean {
48
+ return occupant !== undefined;
49
+ }
50
+ // She holds every faculty twice: under its key, which is the lend seam
51
+ // and plumbing, and under the word the first contract it is lent under
52
+ // calls itself, which is the section a screen shows. A key is a path
53
+ // through the class tree and always carries a slash; a title never does.
54
+ // Both are this run's: `stood` writes the index whole and puts down any
55
+ // title standing this run does not say.
56
+ static skips(id: string): boolean {
57
+ return id.includes('/');
58
+ }
59
+
60
+ static override cells: JsonObject = { lends: {}, titles: {}, clashes: {}, hosted: [], routes: null, apps: null, default: null };
61
+ static override asks = {
62
+ ...Box.asks,
63
+ box: { description: 'what this box is: its routes, its apps and its default ward, each null until said', input: { type: 'object' }, for: root },
64
+ domain: { description: 'the four routes by the convention, mcp., web., quo. and api. under this domain over https, kept until said again', input: { type: 'object', properties: { domain: { type: 'string' } }, required: ['domain'] }, for: root },
65
+ apps: { description: 'the apps this box’s links open, apple app ids and android packages with their fingerprints; neither named forgets them', input: APPS, for: root },
66
+ default: { description: 'the ward a caller means when naming none; the harbor holds it to the wards it hosts at boot', input: { type: 'object', properties: { ward: { type: 'string' } }, required: ['ward'] }, for: root },
67
+ lends: { description: 'which faculty is lent under each contract, by digest to key, and which contracts are lent to nobody because two classes clashed under them', input: { type: 'object' }, for: root },
68
+ stood: {
69
+ description: 'what this boot stood: each contract, by digest, to the faculty that fulfils it, each title to the faculty shown under it, and each clash to the keys that clashed; a title held that this does not say goes',
70
+ input: { type: 'object', properties: { lends: { type: 'object' }, titles: { type: 'object' }, clashes: { type: 'object' } }, required: ['lends', 'titles', 'clashes'] },
71
+ for: root,
72
+ },
73
+ };
74
+
75
+ override answer(asker: Asker, method?: string, args: JsonObject = {}): Promise<Reply> {
76
+ return carry(this, asker, method, args, (a, m, g) => super.answer(a, m, g));
77
+ }
78
+
79
+ #key(name: string): string {
80
+ const index = this.cells.lends as Record<string, string>;
81
+ return Object.hasOwn(index, name) ? index[name] : name;
82
+ }
83
+
84
+ override offer(args: JsonObject): Promise<JsonObject> {
85
+ return super.offer({ name: this.#key(args.name as string) });
86
+ }
87
+ override retract(args: JsonObject): Promise<JsonObject> {
88
+ return super.retract({ ...args, name: this.#key(args.name as string) });
89
+ }
90
+
91
+ lends(): JsonObject {
92
+ return { lends: this.cells.lends, clashes: this.cells.clashes ?? {} };
93
+ }
94
+
95
+ // What the box is, placed by the root and read by the harbor: nothing here
96
+ // comes from a file.
97
+ box(): JsonObject {
98
+ return { routes: this.cells.routes ?? null, apps: this.cells.apps ?? null, default: this.cells.default ?? null };
99
+ }
100
+ domain(args: JsonObject): JsonObject {
101
+ if (typeof args.domain !== 'string' || !DOMAIN.test(args.domain)) return { error: `${JSON.stringify(args.domain)} is not a domain` };
102
+ this.cells.routes = routesFor(args.domain);
103
+ return { routes: this.cells.routes };
104
+ }
105
+ apps(args: JsonObject): JsonObject {
106
+ const apple = Array.isArray(args.apple) ? args.apple.filter((x): x is string => typeof x === 'string') : [];
107
+ const android = Array.isArray(args.android)
108
+ ? (args.android as { package?: unknown; fingerprints?: unknown }[])
109
+ .filter((a): a is { package: string; fingerprints: unknown[] } => typeof a?.package === 'string' && Array.isArray(a.fingerprints))
110
+ .map((a) => ({ package: a.package, fingerprints: a.fingerprints.filter((f): f is string => typeof f === 'string') }))
111
+ : [];
112
+ this.cells.apps = apple.length === 0 && android.length === 0 ? null : { apple, android };
113
+ return { apps: this.cells.apps };
114
+ }
115
+ default(args: JsonObject): JsonObject {
116
+ if (typeof args.ward !== 'string' || args.ward === '' || args.ward === BOX_SEED) return { error: 'a ward is a name, and the box ward is never the default' };
117
+ this.cells.default = args.ward;
118
+ return { default: args.ward };
119
+ }
120
+
121
+ // The index, written whole at every boot: what stands is what this run's
122
+ // classes say, and nothing an earlier run said, the clashes with it, by
123
+ // the word the contract calls itself to the keys that clashed. Her title
124
+ // standings are held to it: one under a title this run shows nobody
125
+ // under, or that now names another faculty, goes here, and the root
126
+ // removes the far end. She answers what she holds and what went, so the
127
+ // root places only what is not there.
128
+ stood(args: JsonObject): JsonObject {
129
+ const { lends, titles, clashes } = args;
130
+ if (!isIndex(lends)) return { error: 'lends is a digest to a key' };
131
+ if (!isIndex(titles)) return { error: 'titles is a title to a key' };
132
+ if (clashes === null || typeof clashes !== 'object' || Array.isArray(clashes) || !Object.values(clashes).every((ks) => Array.isArray(ks) && ks.every((k) => typeof k === 'string'))) return { error: 'clashes is a word to keys' };
133
+ this.cells.clashes = clashes;
134
+ const was = (this.cells.titles ?? {}) as Record<string, string>;
135
+ const pruned: string[] = [];
136
+ for (const id of Object.keys(this.cells.standings)) {
137
+ if (id.includes('/')) continue;
138
+ if (Object.hasOwn(titles, id) && was[id] === titles[id]) continue;
139
+ this.standings.remove(id);
140
+ pruned.push(id);
141
+ }
142
+ this.cells.lends = lends;
143
+ this.cells.titles = titles;
144
+ return { lends, held: Object.keys(this.cells.standings), pruned };
145
+ }
146
+ }
147
+
148
+ const isIndex = (v: unknown): v is Record<string, string> => v !== null && typeof v === 'object' && !Array.isArray(v) && Object.values(v).every((k) => typeof k === 'string');
package/box/envoy.ts ADDED
@@ -0,0 +1,96 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // The rung for a faculty that stands for an account elsewhere: a bank, a
3
+ // booking service, any vendor. A leaf is one class per vendor, its asks out,
4
+ // and three hooks for what the vendor sends back.
5
+ //
6
+ // Out, the account is the being's: the credential crosses on her ask, is
7
+ // used once, and is kept nowhere here, so one faculty serves every account
8
+ // on the box and an account moves with the being who owns it.
9
+ //
10
+ // In, bytes arrive for an account. A being who wants them watches with her
11
+ // account id and the signing secret the vendor gave her; both are kept on
12
+ // her watch and go with it. `inbound` reads which account the bytes are for,
13
+ // verifies them with that watcher's secret before anything else is trusted,
14
+ // decodes them into one ask, answers `{ ok }`, and then wakes her with it as
15
+ // the occupant she named. A delivery seen before is `{ ok }` and wakes
16
+ // nobody twice. The being woken cannot tell it from a timer's tick.
17
+ import type { Asker, JsonObject } from 'nervur';
18
+ import { Faculty, WATCH, type Watched } from './faculty.ts';
19
+
20
+ // A watch for an account: the invitation back, the ask to wake her with,
21
+ // her account at the vendor and the secret the vendor signs with.
22
+ export const WATCH_ACCOUNT = {
23
+ description: 'wake me for my account: an invitation back, the ask to wake me with, the account id and its signing secret',
24
+ input: { type: 'object', properties: { ...WATCH.input.properties, account: { type: 'string' }, secret: { type: 'string' } }, required: ['invitation', 'method', 'account', 'secret'] },
25
+ };
26
+
27
+ // One delivery as a leaf decodes it: the vendor's id for it, and the ask
28
+ // that wakes the watcher.
29
+ export type Delivery = { id: string; method: string; args: JsonObject };
30
+
31
+ type Account = Watched & { account: string; secret: string };
32
+
33
+ const SEEN = 200;
34
+
35
+ export class EnvoyFaculty extends Faculty {
36
+ static override cells: JsonObject = { watched: {}, minted: 0, seen: [] };
37
+
38
+ override async watch(args: JsonObject, asker: Asker): Promise<JsonObject> {
39
+ if (typeof args.account !== 'string' || !args.account || typeof args.secret !== 'string' || !args.secret) return { error: 'an account and its secret' };
40
+ const out = await super.watch(args, asker);
41
+ const id = out.watching;
42
+ if (typeof id === 'string') Object.assign((this.cells.watched as Record<string, Account>)[id], { account: args.account, secret: args.secret });
43
+ return out;
44
+ }
45
+
46
+ #waking: Promise<void> = Promise.resolve();
47
+ // The wake that went out after the last `{ ok }`, for the device's own
48
+ // code and a suite.
49
+ get waking(): Promise<void> {
50
+ return this.#waking;
51
+ }
52
+
53
+ // The vendor's bytes, as they arrived on a path she claims, handed by the
54
+ // http faculty; nothing in them is trusted before `verify`.
55
+ override async inbound(args: JsonObject): Promise<JsonObject> {
56
+ const body = typeof args.body === 'string' ? args.body : '';
57
+ const headers = (args.headers !== null && typeof args.headers === 'object' && !Array.isArray(args.headers) ? args.headers : {}) as Record<string, string>;
58
+ const account = this.account(body, headers);
59
+ const watched = this.cells.watched as Record<string, Account>;
60
+ const mine = account === null ? [] : Object.entries(watched).filter(([, w]) => w.account === account);
61
+ if (mine.length === 0) return { error: 'nobody watches that account' };
62
+ // every watcher of the account whose secret the bytes were signed with
63
+ const at: string[] = [];
64
+ for (const [id, w] of mine) if (await this.verify(body, headers, w.secret)) at.push(id);
65
+ if (at.length === 0) return { error: 'bad signature' };
66
+ const d = this.decode(body, headers);
67
+ if ('error' in d) return d;
68
+ const seen = this.cells.seen as string[];
69
+ if (seen.includes(d.id)) return { ok: true };
70
+ seen.push(d.id);
71
+ while (seen.length > SEEN) seen.shift();
72
+ this.#waking = (async () => {
73
+ for (const id of at) await this.standings[id]?.ask(d.method, d.args);
74
+ })();
75
+ return { ok: true };
76
+ }
77
+
78
+ // Which account the bytes are for: the one thing read before they are
79
+ // verified, since it names whose secret verifies them.
80
+ protected account(_body: string, _headers: Record<string, string>): string | null {
81
+ return null;
82
+ }
83
+ protected verify(_body: string, _headers: Record<string, string>, _secret: string): Promise<boolean> | boolean {
84
+ return false;
85
+ }
86
+ protected decode(_body: string, _headers: Record<string, string>): Delivery | { error: string } {
87
+ return { error: 'this faculty is not called in' };
88
+ }
89
+
90
+ // A signature over text with a shared secret, hex: the shape most vendors
91
+ // sign in, here once so a leaf writes only the vendor's framing.
92
+ protected async hmac(secret: string, text: string): Promise<string> {
93
+ const key = await crypto.subtle.importKey('raw', new TextEncoder().encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
94
+ return [...new Uint8Array(await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(text)))].map((x) => x.toString(16).padStart(2, '0')).join('');
95
+ }
96
+ }
package/box/faculty.ts ADDED
@@ -0,0 +1,111 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // A faculty: a being of the box ward standing for one thing outside every
3
+ // being. She says everything about herself on her class: the contracts she
4
+ // fulfils, whether she can work on this runtime, and the paths she claims on
5
+ // the box's wire. She is born holding her harbor's box. What every faculty
6
+ // shares is here: `watch`, the one way a being asks to be woken, and
7
+ // `woke`, one round of waking whoever asked.
8
+ import { Lent, isInvitation, isSilence, isWord, wordOf } from 'nervur';
9
+ import type { Asker, JsonObject, OccupantRecord, Stance } from 'nervur';
10
+ import type { Contract } from './contract.ts';
11
+ import { ALONE, type Harborage } from './box.ts';
12
+
13
+ // One subscription, as she files it: who asked for it, and what to ask them.
14
+ export type Watched = { by: string; method: string; args: JsonObject };
15
+
16
+ // What one round of waking did, per subscription.
17
+ export type Woke = { id: string; by: string; out: unknown };
18
+
19
+ // The ask the http faculty makes of a faculty that claims a path, and only
20
+ // the http faculty: the root places her one occupant per claimed path,
21
+ // `http:<path>`.
22
+ export const HTTP = 'http:';
23
+ export const INBOUND = {
24
+ description: 'bytes that arrived on a path this faculty claims',
25
+ input: { type: 'object', properties: { body: { type: 'string' }, headers: { type: 'object' }, query: { type: 'string' }, method: { type: 'string' } }, required: ['body', 'headers'] },
26
+ for: (occ: OccupantRecord | undefined) => occ?.id.startsWith(HTTP) === true,
27
+ };
28
+
29
+ export const WATCH = {
30
+ description: 'wake me: an invitation back, the ask to wake me with, and what to add to it',
31
+ input: { type: 'object', properties: { invitation: { type: 'object' }, method: { type: 'string' }, args: { type: 'object' } }, required: ['invitation', 'method'] },
32
+ };
33
+
34
+ export class Faculty extends Lent {
35
+ static override cells: JsonObject = { watched: {}, minted: 0 };
36
+
37
+ // The contracts she fulfils; the box lends her under each one's digest.
38
+ static fulfils: Contract[] = [];
39
+
40
+ // Whether she can work on the runtime reading this, answered honestly.
41
+ // One that cannot is never booted and never lent.
42
+ static stands(): boolean {
43
+ return true;
44
+ }
45
+
46
+ // The paths on the box's wire she answers. The http faculty hands her the
47
+ // bytes that arrive on one as `inbound`, and nobody else may ask it.
48
+ static claims: string[] = [];
49
+
50
+ // What her harbor is to her, handed at birth.
51
+ readonly box: Harborage;
52
+ constructor(stance: Stance, box: Harborage = ALONE) {
53
+ super(stance);
54
+ this.box = box;
55
+ }
56
+
57
+ // Bytes on a path she claims: the body as text, the headers, the query and
58
+ // the method. This body is called in by nobody; a faculty that claims a
59
+ // path says what the bytes mean.
60
+ inbound(_args: JsonObject): Promise<JsonObject> | JsonObject {
61
+ return { error: 'this faculty is not called in' };
62
+ }
63
+
64
+ // Someone wants waking. She hands an invitation back and says what to ask
65
+ // her; this knocks it, takes it under an id of this being's own, and files
66
+ // it. The empty ask is the knock, because every being answers one.
67
+ async watch(args: JsonObject, asker: Asker): Promise<JsonObject> {
68
+ const inv = args.invitation;
69
+ if (!isInvitation(inv) || typeof args.method !== 'string') return { error: 'an invitation and a method' };
70
+ const extra = args.args;
71
+ if (extra !== undefined && (extra === null || typeof extra !== 'object' || Array.isArray(extra))) return { error: 'args is one object' };
72
+ const out = await this.knock(inv);
73
+ if (isSilence(out) || isWord(out)) return { error: 'no way back' };
74
+ const cells = this.cells as unknown as { watched: Record<string, Watched>; minted: number };
75
+ const id = await this.take(`wake:${(cells.minted += 1)}`, inv);
76
+ if (id === null) return { error: 'not taken' };
77
+ cells.watched[id] = { by: asker.id ?? '', method: args.method, args: extra ?? {} };
78
+ await this.welcomed(id);
79
+ return { watching: id };
80
+ }
81
+
82
+ // Somebody new is filed, and the answer to her `watch` waits for this. A
83
+ // faculty that owes a new subscriber something at once says it here.
84
+ protected async welcomed(_id: string): Promise<void> {}
85
+
86
+ // One round. Each subscriber is asked what she asked to be asked, with
87
+ // whatever this round adds; `only` is one of them. A subscriber who removed
88
+ // the occupant says `removed` once and her row goes. Null is this being
89
+ // herself taken out of her ward, so whoever holds an interval puts it down.
90
+ protected async woke(round: (w: Watched) => JsonObject = (w) => w.args, only?: string): Promise<Woke[] | null> {
91
+ const watched = this.cells.watched as unknown as Record<string, Watched>;
92
+ const out: Woke[] = [];
93
+ for (const [id, w] of Object.entries(watched)) {
94
+ if (only !== undefined && id !== only) continue;
95
+ const said = await this.standings[id]?.ask(w.method, round(w));
96
+ if (said === undefined || (isWord(said) && wordOf(said) === 'dropped')) return null;
97
+ if (isWord(said) && wordOf(said) === 'removed') {
98
+ this.standings.remove(id);
99
+ delete watched[id];
100
+ continue;
101
+ }
102
+ out.push({ id, by: w.by, out: said });
103
+ }
104
+ return out;
105
+ }
106
+
107
+ // Who is waiting, by this being's own id for the relation.
108
+ get watching(): Record<string, Watched> {
109
+ return this.cells.watched as unknown as Record<string, Watched>;
110
+ }
111
+ }
@@ -16,7 +16,7 @@
16
16
  // which dials, drains what it missed over Quo, sealed, and composes what
17
17
  // the human reads. High priority is what wakes a dozing device; it is the
18
18
  // same promise Apple's background push makes, which is best effort.
19
- import type { Ring, Token } from './push.ts';
19
+ import type { Ring, Token } from './ring.ts';
20
20
 
21
21
  const subtle = () => globalThis.crypto.subtle;
22
22
  const RS256 = { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' } as const;
package/box/grants.ts ADDED
@@ -0,0 +1,90 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // The box's grants: a bearer to an identity in a world, honoured on every
3
+ // route alike. A route says the grammar a caller speaks and a grant says who
4
+ // the caller is. A grant maps a token to an identity and nothing else; no
5
+ // secret of Quo's is ever here.
6
+ //
7
+ // Her tokens are her cells. Issuing, refreshing and revoking are the routes'
8
+ // to call on her in this process, and a token issued is a cell written
9
+ // outside any ask, so she keeps her ward through her box. Her clock is the
10
+ // box's. An access token lives an hour and a refresh token thirty days, and
11
+ // refresh rotates.
12
+ import { contract } from './contract.ts';
13
+ import { Faculty } from './faculty.ts';
14
+
15
+ // What a route needs from the box's grants. She lends nothing to beings, so
16
+ // the contract has no ask; its title is what the box's own code finds her by.
17
+ export const GRANTS = contract('grants', {});
18
+
19
+ export const ACCESS_TTL = 60 * 60 * 1000;
20
+ export const REFRESH_TTL = 30 * 24 * 60 * 60 * 1000;
21
+
22
+ // Own keys only: a token is a string the far side offers.
23
+ const own = <T>(rec: Record<string, T>, key: string): T | undefined => (Object.hasOwn(rec, key) ? rec[key] : undefined);
24
+ // Thirty-two random bytes as base64url, from what every runtime has.
25
+ const token = () => {
26
+ const bytes = globalThis.crypto.getRandomValues(new Uint8Array(32));
27
+ let binary = '';
28
+ for (const b of bytes) binary += String.fromCharCode(b);
29
+ return btoa(binary).replaceAll('+', '-').replaceAll('/', '_').replaceAll('=', '');
30
+ };
31
+
32
+ export type Grant = { identity: string; ward: string; client: string; exp: number };
33
+ type Kept = { access: Record<string, Grant>; refresh: Record<string, Grant> };
34
+ export type Tokens = { access_token: string; token_type: 'Bearer'; expires_in: number; refresh_token: string; scope: 'nervur' };
35
+
36
+ export class Grants extends Faculty {
37
+ static override cells = { ...Faculty.cells, access: {}, refresh: {} };
38
+ static override asks = {};
39
+ static override fulfils = [GRANTS];
40
+
41
+ get kept(): Kept {
42
+ return this.cells as unknown as Kept;
43
+ }
44
+
45
+ // The box's moment, which is hers.
46
+ now(): number {
47
+ return this.box.now();
48
+ }
49
+
50
+ async issue(identity: string, ward: string, client: string): Promise<Tokens> {
51
+ const access_token = token(),
52
+ refresh_token = token();
53
+ this.kept.access[access_token] = { identity, ward, client, exp: this.now() + ACCESS_TTL };
54
+ this.kept.refresh[refresh_token] = { identity, ward, client, exp: this.now() + REFRESH_TTL };
55
+ this.sweep();
56
+ await this.box.save();
57
+ return { access_token, token_type: 'Bearer', expires_in: ACCESS_TTL / 1000, refresh_token, scope: 'nervur' };
58
+ }
59
+
60
+ async refresh(refresh_token: string): Promise<Tokens | null> {
61
+ const r = own(this.kept.refresh, refresh_token);
62
+ if (!r || r.exp < this.now()) return null;
63
+ delete this.kept.refresh[refresh_token];
64
+ return this.issue(r.identity, r.ward, r.client);
65
+ }
66
+
67
+ bearer(req: Request): { identity: string; ward: string } | null {
68
+ const g = own(this.kept.access, offered(req));
69
+ return g && g.exp >= this.now() && typeof g.identity === 'string' ? { identity: g.identity, ward: g.ward } : null;
70
+ }
71
+
72
+ async revoke(identity: string, ward: string): Promise<void> {
73
+ for (const k of ['access', 'refresh'] as const) for (const [t, g] of Object.entries(this.kept[k])) if (g.identity === identity && g.ward === ward) delete this.kept[k][t];
74
+ await this.box.save();
75
+ }
76
+
77
+ sweep(): void {
78
+ const now = this.now();
79
+ for (const k of ['access', 'refresh'] as const) for (const [t, g] of Object.entries(this.kept[k])) if (!(g.exp >= now)) delete this.kept[k][t];
80
+ }
81
+ }
82
+
83
+ // What a route calls on the box's grants, in this process.
84
+ export type Bearers = Pick<Grants, 'issue' | 'refresh' | 'bearer' | 'revoke' | 'now'>;
85
+
86
+ // What a request offered as its bearer, or nothing.
87
+ export const offered = (req: Request): string => {
88
+ const h = req.headers.get('authorization') ?? '';
89
+ return h.startsWith('Bearer ') ? h.slice(7) : '';
90
+ };
package/box/http.ts ADDED
@@ -0,0 +1,93 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // The http faculty: the box's wire. A terrain that owns a wire hands her every
3
+ // request no route of its own took, and she hands the bytes on a claimed
4
+ // path to the faculty that claims it, through a standing the root placed at
5
+ // unpacking, as the occupant `http:<path>`. Nobody asks with owner rights:
6
+ // the faculty called in is asked by her, and answers in her own words.
7
+ //
8
+ // What crosses back is what every route here speaks: an object is 200, an
9
+ // error object 400, and no object at all 503. A path nobody claims is 404, a
10
+ // method but GET and POST is 405, and a body past a megabyte is 413. A GET
11
+ // is where a redirect lands, the code in the query, and a GET from a browser
12
+ // is a human sent back from somewhere: she is shown a page saying whether it
13
+ // finished, and never what the faculty answered a machine.
14
+ import { isSilence, isWord, OWNER } from 'nervur';
15
+ import type { Asker, JsonObject, OccupantRecord } from 'nervur';
16
+ import { landed, POLICY } from '../human/door.ts';
17
+ import { CSS } from '../human/style.ts';
18
+ import { contract } from './contract.ts';
19
+ import { Faculty } from './faculty.ts';
20
+
21
+ // The box's wire. She lends nothing to beings, so her contract has no ask;
22
+ // its title is what unpacking finds her by.
23
+ export const WIRE = contract('http', {});
24
+
25
+ const LIMIT = 1 << 20;
26
+
27
+ const json = (status: number, body: unknown) => Response.json(body, { status });
28
+
29
+ // The one page this wire serves, wearing the one stylesheet every screen
30
+ // wears, and allowed no script, since it runs none.
31
+ const page = (status: number, body: string) =>
32
+ new Response(`<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>nervur</title>\n<style>${CSS}</style>\n</head><body>${body}</body></html>`, {
33
+ status,
34
+ headers: { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store', 'content-security-policy': POLICY, 'referrer-policy': 'no-referrer' },
35
+ });
36
+
37
+ const root = (_occ: OccupantRecord | undefined, asker: Asker) => asker.id === OWNER;
38
+
39
+ export class Http extends Faculty {
40
+ static override fulfils = [WIRE];
41
+ static override cells: JsonObject = { watched: {}, minted: 0, claimed: {} };
42
+ static override asks = {
43
+ wired: {
44
+ description: 'what this boot claims: each path to the faculty that claims it; a path held that this does not say goes',
45
+ input: { type: 'object', properties: { claimed: { type: 'object' } }, required: ['claimed'] },
46
+ for: root,
47
+ },
48
+ };
49
+
50
+ // The paths this run placed, held to: a standing under a path nobody
51
+ // claims now, or that another faculty claims now, goes here, and the
52
+ // root removes the far end. She answers what she holds and what went.
53
+ wired(args: JsonObject): JsonObject {
54
+ const claimed = args.claimed;
55
+ if (claimed === null || typeof claimed !== 'object' || Array.isArray(claimed) || !Object.values(claimed).every((k) => typeof k === 'string')) return { error: 'claimed is a path to a key' };
56
+ const was = (this.cells.claimed ?? {}) as Record<string, string>;
57
+ const pruned: string[] = [];
58
+ for (const p of Object.keys(this.cells.standings)) {
59
+ if (Object.hasOwn(claimed, p) && was[p] === claimed[p]) continue;
60
+ this.standings.remove(p);
61
+ pruned.push(p);
62
+ }
63
+ this.cells.claimed = claimed;
64
+ return { held: Object.keys(this.cells.standings), pruned };
65
+ }
66
+
67
+ // The terrain's own call, crossing no door: one request, one response.
68
+ // The longest claimed path the request's path begins with, at a segment
69
+ // boundary, is the faculty it goes to.
70
+ async handle(req: Request): Promise<Response> {
71
+ const url = new URL(req.url);
72
+ const claimed = Object.keys(this.cells.standings)
73
+ .filter((p) => url.pathname === p || url.pathname.startsWith(`${p}/`))
74
+ .sort((a, b) => b.length - a.length)[0];
75
+ const at = claimed === undefined ? undefined : this.standings[claimed];
76
+ if (!at) return json(404, { error: 'nobody claims this path' });
77
+ if (req.method !== 'GET' && req.method !== 'POST') return json(405, { error: 'POST, or GET for a redirect' });
78
+ const bytes = req.method === 'GET' ? new ArrayBuffer(0) : await req.arrayBuffer();
79
+ if (bytes.byteLength > LIMIT) return json(413, { error: 'too big' });
80
+ const headers: Record<string, string> = {};
81
+ req.headers.forEach((v, k) => (headers[k] = v));
82
+ const out = await at.ask('inbound', { body: new TextDecoder().decode(bytes), headers, query: url.search.slice(1), method: req.method });
83
+ const object = out !== undefined && !isSilence(out) && !isWord(out) && out !== null && typeof out === 'object' && !Array.isArray(out) ? out : null;
84
+ if (req.method === 'GET' && (req.headers.get('accept') ?? '').includes('text/html')) {
85
+ const ok = object !== null && typeof object.error !== 'string';
86
+ // No host is printed: a Host header is the caller's to write, and a
87
+ // page that printed one would print whatever a caller wanted read.
88
+ return page(ok ? 200 : 400, landed({ host: '', ok }));
89
+ }
90
+ if (object === null) return json(503, { error: 'silence' });
91
+ return json(typeof object.error === 'string' ? 400 : 200, object);
92
+ }
93
+ }