@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.
- package/README.md +17 -15
- package/api/route.ts +4 -72
- package/beings/avatar.ts +11 -7
- package/beings/carry.ts +129 -116
- package/beings/clock.ts +11 -7
- package/beings/desk.ts +7 -6
- package/beings/doorbell.ts +36 -7
- package/beings/index.ts +17 -4
- package/beings/join.ts +7 -4
- package/beings/link.ts +1 -1
- package/beings/porter.ts +5 -1
- package/beings/presence.ts +15 -5
- package/beings/setup.ts +27 -21
- package/beings/user.ts +26 -17
- package/beings/way.ts +1 -1
- package/{dock → box}/agents.ts +37 -44
- package/{dock → box}/apns.ts +4 -4
- package/box/box.ts +31 -0
- package/box/contract.ts +41 -0
- package/box/dialers.ts +88 -0
- package/box/dock.ts +148 -0
- package/box/envoy.ts +96 -0
- package/box/faculty.ts +111 -0
- package/{dock → box}/fcm.ts +1 -1
- package/box/grants.ts +90 -0
- package/box/http.ts +93 -0
- package/{dock → box}/http2.ts +7 -7
- package/box/index.ts +201 -0
- package/box/join.ts +78 -0
- package/box/local.ts +37 -0
- package/{dock → box}/location.ts +15 -3
- package/{dock → box}/notifications.ts +17 -5
- package/box/push.ts +85 -0
- package/box/ring.ts +85 -0
- package/{dock → box}/service.ts +2 -2
- package/box/timer.ts +150 -0
- package/box/tree.ts +22 -0
- package/{dock → box}/webpush.ts +2 -2
- package/cli/client.ts +1 -1
- package/cli/daemon.ts +101 -103
- package/cli/estate.ts +2 -3
- package/cli/nervur.ts +44 -25
- package/cli/stand.ts +1 -1
- package/dist/api/route.d.ts +1 -2
- package/dist/api/route.js +4 -76
- package/dist/beings/avatar.d.ts +4 -3
- package/dist/beings/avatar.js +9 -6
- package/dist/beings/carry.d.ts +10 -12
- package/dist/beings/carry.js +119 -127
- package/dist/beings/clock.js +10 -7
- package/dist/beings/desk.js +8 -7
- package/dist/beings/doorbell.d.ts +3 -1
- package/dist/beings/doorbell.js +37 -4
- package/dist/beings/index.d.ts +24 -4
- package/dist/beings/index.js +15 -4
- package/dist/beings/join.d.ts +1 -0
- package/dist/beings/join.js +6 -4
- package/dist/beings/link.d.ts +0 -1
- package/dist/beings/link.js +1 -1
- package/dist/beings/porter.js +5 -1
- package/dist/beings/presence.d.ts +1 -0
- package/dist/beings/presence.js +12 -3
- package/dist/beings/setup.d.ts +5 -2
- package/dist/beings/setup.js +16 -15
- package/dist/beings/user.d.ts +4 -3
- package/dist/beings/user.js +18 -11
- package/dist/{dock → box}/agents.d.ts +7 -4
- package/dist/{dock → box}/agents.js +30 -37
- package/dist/{dock → box}/apns.d.ts +1 -1
- package/dist/{dock → box}/apns.js +3 -3
- package/dist/box/box.d.ts +9 -0
- package/dist/box/box.js +18 -0
- package/dist/box/contract.d.ts +6 -0
- package/dist/box/contract.js +35 -0
- package/dist/{dock → box}/dialers.d.ts +7 -4
- package/dist/box/dialers.js +76 -0
- package/dist/box/dock.d.ts +182 -0
- package/dist/box/dock.js +128 -0
- package/dist/box/envoy.d.ts +44 -0
- package/dist/box/envoy.js +75 -0
- package/dist/box/faculty.d.ts +68 -0
- package/dist/box/faculty.js +97 -0
- package/dist/{dock → box}/fcm.d.ts +1 -1
- package/dist/{dock → box}/grants.d.ts +4 -3
- package/dist/box/grants.js +78 -0
- package/dist/box/http.d.ts +26 -0
- package/dist/box/http.js +89 -0
- package/dist/{dock → box}/http2.d.ts +1 -1
- package/dist/{dock → box}/http2.js +6 -6
- package/dist/box/index.d.ts +46 -0
- package/dist/box/index.js +179 -0
- package/dist/box/join.d.ts +19 -0
- package/dist/box/join.js +67 -0
- package/dist/{dock → box}/local.d.ts +3 -2
- package/dist/box/local.js +27 -0
- package/dist/{dock → box}/location.d.ts +3 -0
- package/dist/box/location.js +21 -0
- package/dist/{dock → box}/notifications.d.ts +3 -0
- package/dist/{dock → box}/notifications.js +14 -4
- package/dist/{dock → box}/push.d.ts +12 -18
- package/dist/box/push.js +83 -0
- package/dist/box/ring.d.ts +18 -0
- package/dist/box/ring.js +55 -0
- package/dist/{dock → box}/service.d.ts +1 -1
- package/dist/{dock → box}/service.js +1 -1
- package/dist/{dock → box}/timer.d.ts +2 -0
- package/dist/box/timer.js +127 -0
- package/dist/box/tree.d.ts +5 -0
- package/dist/box/tree.js +14 -0
- package/dist/{dock → box}/webpush.d.ts +1 -1
- package/dist/cli/client.d.ts +1 -0
- package/dist/cli/daemon.d.ts +2 -8
- package/dist/cli/daemon.js +102 -107
- package/dist/cli/estate.js +2 -3
- package/dist/cli/nervur.js +44 -24
- package/dist/cli/stand.d.ts +0 -14
- package/dist/cli/stand.js +1 -1
- package/dist/harbor/browser.d.ts +8 -5
- package/dist/harbor/browser.js +20 -21
- package/dist/harbor/capacitor.d.ts +3 -3
- package/dist/harbor/capacitor.js +9 -8
- package/dist/harbor/disk.d.ts +9 -9
- package/dist/harbor/disk.js +56 -51
- package/dist/harbor/edge/alarm.d.ts +4 -2
- package/dist/harbor/edge/alarm.js +9 -19
- package/dist/harbor/edge/edge.d.ts +7 -6
- package/dist/harbor/edge/edge.js +58 -53
- package/dist/harbor/edge/exercise.js +4 -4
- package/dist/harbor/edge/given.d.ts +0 -1
- package/dist/harbor/edge/given.js +0 -29
- package/dist/harbor/edge/storage.d.ts +0 -2
- package/dist/harbor/edge/storage.js +2 -2
- package/dist/harbor/http.js +4 -2
- package/dist/harbor/sealed.d.ts +0 -5
- package/dist/harbor/sealed.js +0 -39
- package/dist/harbor/tauri.d.ts +2 -2
- package/dist/harbor/tauri.js +13 -10
- package/dist/human/app.d.ts +1 -1
- package/dist/human/app.js +10 -12
- package/dist/human/door.d.ts +0 -1
- package/dist/human/door.js +1 -1
- package/dist/human/html.js +1 -1
- package/dist/human/move.js +1 -1
- package/dist/human/tab.bundle.txt +3629 -2824
- package/dist/human/wake.d.ts +5 -2
- package/dist/human/wake.js +23 -14
- package/dist/human/web.d.ts +0 -1
- package/dist/human/web.js +3 -3
- package/dist/human/worker.bundle.txt +2945 -2118
- package/dist/human/worlds.js +3 -3
- package/dist/mcp/agent.d.ts +0 -1
- package/dist/mcp/agent.js +1 -1
- package/dist/mcp/allow.js +3 -2
- package/dist/mcp/http.d.ts +0 -1
- package/dist/mcp/http.js +1 -1
- package/dist/mcp/oauth.d.ts +3 -4
- package/dist/mcp/oauth.js +4 -4
- package/dist/mcp/route.d.ts +1 -1
- package/dist/mcp/runner.d.ts +0 -1
- package/dist/mcp/runner.js +1 -1
- package/dist/mcp/server.d.ts +0 -1
- package/dist/mcp/server.js +1 -1
- package/harbor/browser.ts +22 -24
- package/harbor/capacitor.ts +9 -8
- package/harbor/disk.ts +53 -48
- package/harbor/edge/alarm.ts +15 -12
- package/harbor/edge/edge.ts +61 -55
- package/harbor/edge/exercise.ts +4 -4
- package/harbor/edge/given.ts +3 -33
- package/harbor/edge/storage.ts +2 -2
- package/harbor/http.ts +5 -3
- package/harbor/sealed.ts +0 -35
- package/harbor/tauri.ts +13 -10
- package/human/app.ts +12 -13
- package/human/door.ts +1 -1
- package/human/html.ts +1 -1
- package/human/local.ts +1 -1
- package/human/move.ts +1 -1
- package/human/tab.bundle.txt +3629 -2824
- package/human/wake.ts +36 -16
- package/human/web.ts +4 -4
- package/human/worker.bundle.txt +2945 -2118
- package/human/worlds.ts +3 -3
- package/mcp/agent.ts +1 -1
- package/mcp/allow.ts +2 -1
- package/mcp/http.ts +1 -1
- package/mcp/oauth.ts +4 -4
- package/mcp/route.ts +1 -1
- package/mcp/runner.ts +1 -1
- package/mcp/server.ts +1 -1
- package/package.json +10 -18
- package/GETTING_STARTED.md +0 -138
- package/GLOSSARY.md +0 -118
- package/WORLDS.md +0 -569
- package/beings/envoy.ts +0 -218
- package/dist/beings/envoy.d.ts +0 -54
- package/dist/beings/envoy.js +0 -211
- package/dist/dock/console.d.ts +0 -6
- package/dist/dock/console.js +0 -20
- package/dist/dock/dialers.js +0 -75
- package/dist/dock/faculty.d.ts +0 -24
- package/dist/dock/faculty.js +0 -142
- package/dist/dock/grants.js +0 -99
- package/dist/dock/index.d.ts +0 -41
- package/dist/dock/index.js +0 -113
- package/dist/dock/local.js +0 -44
- package/dist/dock/location.js +0 -11
- package/dist/dock/push.js +0 -132
- package/dist/dock/timer.js +0 -149
- package/dock/console.ts +0 -23
- package/dock/dialers.ts +0 -85
- package/dock/faculty.ts +0 -148
- package/dock/grants.ts +0 -111
- package/dock/index.ts +0 -133
- package/dock/local.ts +0 -48
- package/dock/push.ts +0 -161
- package/dock/timer.ts +0 -174
- package/quo-api.md +0 -196
- package/quo-app-bells.md +0 -280
- package/quo-dock.md +0 -1945
- package/quo-harbor.md +0 -874
- package/quo-human.md +0 -1042
- package/quo-mcp.md +0 -343
- /package/dist/{dock → box}/fcm.js +0 -0
- /package/dist/{dock → box}/webpush.js +0 -0
package/{dock → box}/http2.ts
RENAMED
|
@@ -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 `
|
|
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 './
|
|
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
|
|
60
|
-
let
|
|
61
|
-
stream.on('response', (h) => (
|
|
62
|
-
stream.on('data', (chunk: string) => (
|
|
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
|
+
}
|
package/{dock → box}/location.ts
RENAMED
|
@@ -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
|
-
|
|
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
|
-
|
|
38
|
-
|
|
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
|
|
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
|
+
}
|
package/{dock → box}/service.ts
RENAMED
|
@@ -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 './
|
|
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
|
|
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
|