@nervur-org/dock 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/GETTING_STARTED.md +128 -0
- package/LICENSE +202 -0
- package/NOTICE +6 -0
- package/README.md +37 -0
- package/api/quo-api.md +196 -0
- package/api/route.ts +227 -0
- package/beings/GLOSSARY.md +118 -0
- package/beings/WORLDS.md +567 -0
- package/beings/avatar.ts +101 -0
- package/beings/carry.ts +159 -0
- package/beings/clock.ts +552 -0
- package/beings/courier.ts +107 -0
- package/beings/desk.ts +128 -0
- package/beings/doorbell.ts +129 -0
- package/beings/envoy.ts +218 -0
- package/beings/index.ts +16 -0
- package/beings/join.ts +51 -0
- package/beings/link.ts +104 -0
- package/beings/look.ts +139 -0
- package/beings/porter.ts +43 -0
- package/beings/presence.ts +39 -0
- package/beings/quo-app-bells.md +280 -0
- package/beings/quo-dock.md +1942 -0
- package/beings/setup.ts +45 -0
- package/beings/side.ts +60 -0
- package/beings/user.ts +459 -0
- package/beings/way.ts +65 -0
- package/cli/bundles.ts +20 -0
- package/cli/client.ts +73 -0
- package/cli/daemon.ts +581 -0
- package/cli/estate/Caddyfile +33 -0
- package/cli/estate/quo.service +35 -0
- package/cli/estate/systems.quo.plist +44 -0
- package/cli/estate.ts +48 -0
- package/cli/folder.ts +27 -0
- package/cli/oauth.ts +32 -0
- package/cli/pilot.ts +38 -0
- package/cli/quo-dock.md +1942 -0
- package/cli/quo.ts +203 -0
- package/cli/stand.ts +160 -0
- package/dist/api/route.d.ts +21 -0
- package/dist/api/route.js +193 -0
- package/dist/beings/avatar.d.ts +32 -0
- package/dist/beings/avatar.js +100 -0
- package/dist/beings/carry.d.ts +12 -0
- package/dist/beings/carry.js +174 -0
- package/dist/beings/clock.d.ts +161 -0
- package/dist/beings/clock.js +528 -0
- package/dist/beings/courier.d.ts +50 -0
- package/dist/beings/courier.js +96 -0
- package/dist/beings/desk.d.ts +53 -0
- package/dist/beings/desk.js +135 -0
- package/dist/beings/doorbell.d.ts +78 -0
- package/dist/beings/doorbell.js +133 -0
- package/dist/beings/envoy.d.ts +54 -0
- package/dist/beings/envoy.js +211 -0
- package/dist/beings/index.d.ts +14 -0
- package/dist/beings/index.js +16 -0
- package/dist/beings/join.d.ts +15 -0
- package/dist/beings/join.js +42 -0
- package/dist/beings/link.d.ts +14 -0
- package/dist/beings/link.js +91 -0
- package/dist/beings/look.d.ts +30 -0
- package/dist/beings/look.js +104 -0
- package/dist/beings/porter.d.ts +14 -0
- package/dist/beings/porter.js +41 -0
- package/dist/beings/presence.d.ts +8 -0
- package/dist/beings/presence.js +21 -0
- package/dist/beings/setup.d.ts +3 -0
- package/dist/beings/setup.js +39 -0
- package/dist/beings/side.d.ts +32 -0
- package/dist/beings/side.js +37 -0
- package/dist/beings/user.d.ts +315 -0
- package/dist/beings/user.js +462 -0
- package/dist/beings/way.d.ts +19 -0
- package/dist/beings/way.js +28 -0
- package/dist/cli/bundles.d.ts +5 -0
- package/dist/cli/bundles.js +17 -0
- package/dist/cli/client.d.ts +15 -0
- package/dist/cli/client.js +58 -0
- package/dist/cli/daemon.d.ts +50 -0
- package/dist/cli/daemon.js +584 -0
- package/dist/cli/estate/Caddyfile +33 -0
- package/dist/cli/estate/quo.service +35 -0
- package/dist/cli/estate/systems.quo.plist +44 -0
- package/dist/cli/estate.d.ts +5 -0
- package/dist/cli/estate.js +50 -0
- package/dist/cli/folder.d.ts +2 -0
- package/dist/cli/folder.js +25 -0
- package/dist/cli/oauth.d.ts +2 -0
- package/dist/cli/oauth.js +32 -0
- package/dist/cli/pilot.d.ts +5 -0
- package/dist/cli/pilot.js +22 -0
- package/dist/cli/quo.d.ts +2 -0
- package/dist/cli/quo.js +201 -0
- package/dist/cli/stand.d.ts +37 -0
- package/dist/cli/stand.js +110 -0
- package/dist/dock/agents.d.ts +102 -0
- package/dist/dock/agents.js +105 -0
- package/dist/dock/apns.d.ts +27 -0
- package/dist/dock/apns.js +98 -0
- package/dist/dock/console.d.ts +6 -0
- package/dist/dock/console.js +20 -0
- package/dist/dock/dialers.d.ts +56 -0
- package/dist/dock/dialers.js +75 -0
- package/dist/dock/faculty.d.ts +24 -0
- package/dist/dock/faculty.js +142 -0
- package/dist/dock/fcm.d.ts +13 -0
- package/dist/dock/fcm.js +77 -0
- package/dist/dock/grants.d.ts +41 -0
- package/dist/dock/grants.js +99 -0
- package/dist/dock/http2.d.ts +4 -0
- package/dist/dock/http2.js +68 -0
- package/dist/dock/index.d.ts +41 -0
- package/dist/dock/index.js +113 -0
- package/dist/dock/local.d.ts +13 -0
- package/dist/dock/local.js +44 -0
- package/dist/dock/location.d.ts +19 -0
- package/dist/dock/location.js +11 -0
- package/dist/dock/notifications.d.ts +38 -0
- package/dist/dock/notifications.js +60 -0
- package/dist/dock/push.d.ts +79 -0
- package/dist/dock/push.js +132 -0
- package/dist/dock/service.d.ts +4 -0
- package/dist/dock/service.js +107 -0
- package/dist/dock/timer.d.ts +52 -0
- package/dist/dock/timer.js +149 -0
- package/dist/dock/webpush.d.ts +12 -0
- package/dist/dock/webpush.js +144 -0
- package/dist/harbor/browser.d.ts +15 -0
- package/dist/harbor/browser.js +82 -0
- package/dist/harbor/capacitor.d.ts +28 -0
- package/dist/harbor/capacitor.js +218 -0
- package/dist/harbor/disk.d.ts +27 -0
- package/dist/harbor/disk.js +249 -0
- package/dist/harbor/edge/alarm.d.ts +10 -0
- package/dist/harbor/edge/alarm.js +38 -0
- package/dist/harbor/edge/edge.d.ts +54 -0
- package/dist/harbor/edge/edge.js +370 -0
- package/dist/harbor/edge/exercise.d.ts +7 -0
- package/dist/harbor/edge/exercise.js +232 -0
- package/dist/harbor/edge/given.d.ts +26 -0
- package/dist/harbor/edge/given.js +42 -0
- package/dist/harbor/edge/platform.d.ts +39 -0
- package/dist/harbor/edge/storage.d.ts +30 -0
- package/dist/harbor/edge/storage.js +180 -0
- package/dist/harbor/edge/worker.d.ts +17 -0
- package/dist/harbor/edge/worker.js +67 -0
- package/dist/harbor/files.d.ts +15 -0
- package/dist/harbor/files.js +220 -0
- package/dist/harbor/http.d.ts +19 -0
- package/dist/harbor/http.js +177 -0
- package/dist/harbor/idb.d.ts +15 -0
- package/dist/harbor/idb.js +0 -0
- package/dist/harbor/quo.d.ts +10 -0
- package/dist/harbor/quo.js +42 -0
- package/dist/harbor/resolve.d.ts +16 -0
- package/dist/harbor/resolve.js +17 -0
- package/dist/harbor/seal.d.ts +3 -0
- package/dist/harbor/seal.js +25 -0
- package/dist/harbor/sealed.d.ts +35 -0
- package/dist/harbor/sealed.js +162 -0
- package/dist/harbor/tauri.d.ts +16 -0
- package/dist/harbor/tauri.js +59 -0
- package/dist/harbor/ward.d.ts +9 -0
- package/dist/harbor/ward.js +15 -0
- package/dist/human/app.d.ts +32 -0
- package/dist/human/app.js +527 -0
- package/dist/human/apps.d.ts +10 -0
- package/dist/human/apps.js +29 -0
- package/dist/human/dom.d.ts +2 -0
- package/dist/human/dom.js +15 -0
- package/dist/human/door.d.ts +19 -0
- package/dist/human/door.js +92 -0
- package/dist/human/guest.d.ts +3 -0
- package/dist/human/guest.js +33 -0
- package/dist/human/html.d.ts +55 -0
- package/dist/human/html.js +451 -0
- package/dist/human/local.d.ts +7 -0
- package/dist/human/local.js +10 -0
- package/dist/human/mark.d.ts +8 -0
- package/dist/human/mark.js +47 -0
- package/dist/human/move.d.ts +17 -0
- package/dist/human/move.js +90 -0
- package/dist/human/screen.d.ts +19 -0
- package/dist/human/screen.js +119 -0
- package/dist/human/style.d.ts +2 -0
- package/dist/human/style.js +60 -0
- package/dist/human/tab.bundle.txt +4775 -0
- package/dist/human/tab.d.ts +15 -0
- package/dist/human/tab.js +409 -0
- package/dist/human/tree.d.ts +50 -0
- package/dist/human/tree.js +157 -0
- package/dist/human/wake.d.ts +34 -0
- package/dist/human/wake.js +179 -0
- package/dist/human/web.d.ts +33 -0
- package/dist/human/web.js +184 -0
- package/dist/human/worker.bundle.txt +3868 -0
- package/dist/human/worker.d.ts +1 -0
- package/dist/human/worker.js +81 -0
- package/dist/human/worlds.d.ts +24 -0
- package/dist/human/worlds.js +99 -0
- package/dist/mcp/agent.d.ts +10 -0
- package/dist/mcp/agent.js +78 -0
- package/dist/mcp/allow.d.ts +20 -0
- package/dist/mcp/allow.js +88 -0
- package/dist/mcp/http.d.ts +27 -0
- package/dist/mcp/http.js +92 -0
- package/dist/mcp/oauth.d.ts +91 -0
- package/dist/mcp/oauth.js +241 -0
- package/dist/mcp/route.d.ts +15 -0
- package/dist/mcp/route.js +42 -0
- package/dist/mcp/runner.d.ts +45 -0
- package/dist/mcp/runner.js +99 -0
- package/dist/mcp/server.d.ts +11 -0
- package/dist/mcp/server.js +102 -0
- package/dock/agents.ts +121 -0
- package/dock/apns.ts +138 -0
- package/dock/console.ts +23 -0
- package/dock/dialers.ts +85 -0
- package/dock/faculty.ts +148 -0
- package/dock/fcm.ts +103 -0
- package/dock/grants.ts +111 -0
- package/dock/http2.ts +70 -0
- package/dock/index.ts +133 -0
- package/dock/local.ts +48 -0
- package/dock/location.ts +22 -0
- package/dock/notifications.ts +67 -0
- package/dock/push.ts +161 -0
- package/dock/quo-app-bells.md +280 -0
- package/dock/quo-dock.md +1942 -0
- package/dock/service.ts +100 -0
- package/dock/timer.ts +174 -0
- package/dock/webpush.ts +182 -0
- package/harbor/browser.ts +97 -0
- package/harbor/capacitor.ts +223 -0
- package/harbor/disk.ts +237 -0
- package/harbor/edge/alarm.ts +48 -0
- package/harbor/edge/edge.ts +407 -0
- package/harbor/edge/exercise.ts +241 -0
- package/harbor/edge/given.ts +106 -0
- package/harbor/edge/platform.d.ts +39 -0
- package/harbor/edge/storage.ts +179 -0
- package/harbor/edge/text.d.ts +15 -0
- package/harbor/edge/worker.ts +70 -0
- package/harbor/edge/wrangler.toml +30 -0
- package/harbor/files.ts +221 -0
- package/harbor/http.ts +188 -0
- package/harbor/idb.ts +0 -0
- package/harbor/quo-harbor.md +850 -0
- package/harbor/quo.ts +44 -0
- package/harbor/resolve.ts +31 -0
- package/harbor/seal.ts +26 -0
- package/harbor/sealed.ts +183 -0
- package/harbor/tauri.ts +79 -0
- package/harbor/ward.ts +28 -0
- package/human/app.ts +580 -0
- package/human/apps.ts +47 -0
- package/human/dom.ts +21 -0
- package/human/door.ts +113 -0
- package/human/guest.ts +34 -0
- package/human/html.ts +490 -0
- package/human/local.ts +30 -0
- package/human/mark.ts +53 -0
- package/human/move.ts +122 -0
- package/human/quo-human.md +1042 -0
- package/human/screen.ts +136 -0
- package/human/style.ts +63 -0
- package/human/tab.bundle.txt +4775 -0
- package/human/tab.ts +446 -0
- package/human/tree.ts +217 -0
- package/human/wake.ts +191 -0
- package/human/web.ts +259 -0
- package/human/worker.bundle.txt +3868 -0
- package/human/worker.ts +124 -0
- package/human/worlds.ts +127 -0
- package/mcp/agent.ts +94 -0
- package/mcp/allow.ts +131 -0
- package/mcp/http.ts +92 -0
- package/mcp/oauth.ts +270 -0
- package/mcp/quo-mcp.md +343 -0
- package/mcp/route.ts +45 -0
- package/mcp/runner.ts +136 -0
- package/mcp/server.ts +101 -0
- package/package.json +121 -0
package/beings/desk.ts
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// The front desk. The one public being of a world, and public for the first
|
|
3
|
+
// hello only: everything after is behind an invitation. It holds a standing
|
|
4
|
+
// to every user being, placed by the root, and trades a proof for a device
|
|
5
|
+
// invitation that the user being mints herself. The desk never mints one.
|
|
6
|
+
//
|
|
7
|
+
// A proof is what a route produced after authenticating by its own means. A
|
|
8
|
+
// way in turns a proof of one kind into who it is for: which user, which
|
|
9
|
+
// client identity. The ways in are beings, and the desk reaches each of
|
|
10
|
+
// them as she reaches anything, at a standing under `way:<kind>`: the
|
|
11
|
+
// world's own, placed by the root, and the box's, lent by the dock ward and
|
|
12
|
+
// taken at her birth, so a box with no local side offers no such kind.
|
|
13
|
+
//
|
|
14
|
+
// A kind whose proof the world has to hand out first answers `send` beside
|
|
15
|
+
// `verify`, and a stranger asks for one with `send({ kind, to })`. The desk
|
|
16
|
+
// judges nothing here: she does not know what an address is, whether one was
|
|
17
|
+
// sent to before, or what a link says. She keeps the ways in named in one
|
|
18
|
+
// place, so a side that renders her renders every way into this world.
|
|
19
|
+
import { Being, isSilence, isWord, wordOf } from '@nervur-org/nervur';
|
|
20
|
+
import type { Asker, Blueprint, JsonObject, Reply, Stance } from '@nervur-org/nervur';
|
|
21
|
+
import { join } from '../dock/faculty.ts';
|
|
22
|
+
import { LOCAL } from '../dock/local.ts';
|
|
23
|
+
import { WAY, kindOf, verified } from './way.ts';
|
|
24
|
+
|
|
25
|
+
export class Desk extends Being {
|
|
26
|
+
static override asks = {
|
|
27
|
+
hello: { description: 'how to get in', input: { type: 'object' } },
|
|
28
|
+
send: { description: 'send a way in to an address of yours', input: { type: 'object', properties: { kind: { type: 'string' }, to: { type: 'string' } }, required: ['kind', 'to'] } },
|
|
29
|
+
device: { description: 'trade a proof for a device invitation', input: { type: 'object', properties: { proof: { type: 'object' } }, required: ['proof'] } },
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
// The box's way in, taken at every birth as any being takes what her box
|
|
33
|
+
// lends. A box that lends none, a tab or the edge, leaves her holding
|
|
34
|
+
// nothing under that id and the kind is not offered.
|
|
35
|
+
constructor(stance: Stance) {
|
|
36
|
+
super(stance);
|
|
37
|
+
void join(this, LOCAL, undefined, WAY(LOCAL));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// The ids of the ways in she holds, in the order they were placed.
|
|
41
|
+
get ways(): { id: string; kind: string }[] {
|
|
42
|
+
const out: { id: string; kind: string }[] = [];
|
|
43
|
+
for (const id of Object.keys(this.cells.standings)) {
|
|
44
|
+
const kind = kindOf(id);
|
|
45
|
+
if (kind !== null && this.standings[id]) out.push({ id, kind });
|
|
46
|
+
}
|
|
47
|
+
return out;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Before she is described, she learns what her ways in can be asked. A
|
|
51
|
+
// standing's blueprint is filled by an empty ask and by nothing else, and
|
|
52
|
+
// a way in placed on her a moment ago has none yet; each is asked once,
|
|
53
|
+
// and a way in that will not describe stays a kind she can only verify.
|
|
54
|
+
override async answer(asker: Asker, method?: string, args: JsonObject = {}): Promise<Reply> {
|
|
55
|
+
if (method === undefined) for (const w of this.ways) if (this.cells.standings[w.id]?.blueprint === null) await this.standings[w.id]?.ask();
|
|
56
|
+
return await super.answer(asker, method, args);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// The kinds are the ways in she holds and change with them, so the form a
|
|
60
|
+
// stranger sees is shaped now rather than declared once: `send` is there
|
|
61
|
+
// only where a way in says it can be asked to send, and it names the kinds
|
|
62
|
+
// that can. A world with none shows no such form, and a link is the only
|
|
63
|
+
// way in, as it always was.
|
|
64
|
+
override describe(asker: Asker): Blueprint {
|
|
65
|
+
const bp = super.describe(asker);
|
|
66
|
+
const kinds = this.ways.filter((w) => this.cells.standings[w.id]?.blueprint?.asks.some((a) => a.name === 'send')).map((w) => w.kind);
|
|
67
|
+
const asks = bp.asks.flatMap((a) => {
|
|
68
|
+
if (a.name !== 'send') return [a];
|
|
69
|
+
if (kinds.length === 0) return [];
|
|
70
|
+
return [{ ...a, input: { type: 'object', properties: { kind: { type: 'string', enum: kinds, default: kinds[0] }, to: { type: 'string' } }, required: ['kind', 'to'] } }];
|
|
71
|
+
});
|
|
72
|
+
return { ...bp, asks };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// A stranger learns the proof kinds and nothing else. Who lives on this
|
|
76
|
+
// world is not the caller's to know: the way in names the user, so
|
|
77
|
+
// nobody at the door ever has to, and a world with a thousand of them
|
|
78
|
+
// would otherwise hand the whole list to anyone who knocked.
|
|
79
|
+
hello(_args: JsonObject, asker: Asker) {
|
|
80
|
+
return { desk: true, asker: asker.id ?? null, kinds: this.ways.map((w) => w.kind) };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// A stranger asks for a way in to an address that is hers. Nothing of this
|
|
84
|
+
// world changes here and nothing is minted: the way in sends, and the
|
|
85
|
+
// proof that comes back is what admits her.
|
|
86
|
+
async send(args: JsonObject) {
|
|
87
|
+
const { kind, to } = args;
|
|
88
|
+
if (typeof kind !== 'string' || typeof to !== 'string') return { error: 'kind and to are words' };
|
|
89
|
+
const way = this.standings[WAY(kind)];
|
|
90
|
+
if (!way) return { error: 'no such way in' };
|
|
91
|
+
const out = await way.ask('send', { to });
|
|
92
|
+
if (isSilence(out) || isWord(out)) return { error: 'no such way in' };
|
|
93
|
+
return out as JsonObject;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async device(args: JsonObject) {
|
|
97
|
+
const proof = args.proof;
|
|
98
|
+
if (!proof || typeof proof !== 'object' || Array.isArray(proof) || typeof proof.kind !== 'string') return { error: 'proof has a kind' };
|
|
99
|
+
// A kind this world holds no way in for and a way in that says no are
|
|
100
|
+
// one refusal, because the difference between them is a list a stranger
|
|
101
|
+
// could walk. Only a way in that sends for itself is named at this door,
|
|
102
|
+
// in its own form's kinds; a way in that only verifies, as a device's
|
|
103
|
+
// local nonce does, is named nowhere, and two errors here would name it
|
|
104
|
+
// to anyone who guessed the word.
|
|
105
|
+
const way = this.standings[WAY(proof.kind)];
|
|
106
|
+
const said = way ? await way.ask('verify', { proof }) : null;
|
|
107
|
+
const who = said === null || isSilence(said) || isWord(said) ? null : verified(said);
|
|
108
|
+
if (!who) return { error: 'refused' };
|
|
109
|
+
const id = `user:${who.user}`;
|
|
110
|
+
// A way in that brings its own people: the first proof for a user nobody
|
|
111
|
+
// has booted makes her, and the desk holds her standing because she is
|
|
112
|
+
// the desk's to make. She learns her name the way the root's setup names
|
|
113
|
+
// her, by the desk's first hello, since a being does not know her own
|
|
114
|
+
// key. A way in that does not say `make` reaches an existing household
|
|
115
|
+
// and nothing else, which is what the local kind on a device does.
|
|
116
|
+
if (!this.standings[id] && (who.make === true || typeof who.make === 'string')) {
|
|
117
|
+
if (!/^[\w.-]{1,80}$/.test(who.user) || who.user === 'desk') return { error: 'a user is a word, and not desk' };
|
|
118
|
+
if ((await this.boot(typeof who.make === 'string' ? who.make : 'User', who.user, id)) === null) return { error: 'no such user' };
|
|
119
|
+
await this.standings[id]!.ask('hello', { name: who.user });
|
|
120
|
+
}
|
|
121
|
+
const user = this.standings[id];
|
|
122
|
+
if (!user) return { error: 'no such user' };
|
|
123
|
+
const inv = await user.ask('device', { client: who.client, ...(who.wake === true ? { wake: true } : {}), ...(who.reach === true ? { reach: true } : {}), ...(who.mint === true ? { mint: true } : {}), ...(who.revoke === true ? { revoke: true } : {}) });
|
|
124
|
+
if (isSilence(inv)) return { error: 'silence' };
|
|
125
|
+
if (isWord(inv)) return { error: wordOf(inv) };
|
|
126
|
+
return inv;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// One device's doorbell. The platform token lives in her cells and leaves
|
|
3
|
+
// for nowhere: no world ever sees it, so no world can copy it, hand it on,
|
|
4
|
+
// or keep ringing after it is told to stop.
|
|
5
|
+
//
|
|
6
|
+
// Her occupants are the worlds that device allowed. `device` is the phone
|
|
7
|
+
// itself, and every other id is one world, invited by the phone and by
|
|
8
|
+
// nobody else. The pair, this device and that world, is a row here, held by
|
|
9
|
+
// neither the world that rings nor the publisher who holds the platform
|
|
10
|
+
// key. That row is the whole of the permission.
|
|
11
|
+
//
|
|
12
|
+
// She is made by the porter, who holds a standing to her and asks once for
|
|
13
|
+
// the device's way in. After a device has claimed it there is nothing the
|
|
14
|
+
// porter can do here at all, which is why `claim` closes itself.
|
|
15
|
+
//
|
|
16
|
+
// A ring carries a banner sealed to the device's banner key. She checks
|
|
17
|
+
// that it fits what the platform will carry and hands it on, and could not
|
|
18
|
+
// read it if she tried.
|
|
19
|
+
//
|
|
20
|
+
// She never learns that key, and there is nothing here for it to be kept
|
|
21
|
+
// in. The device hands its banner key to a world itself, beside the
|
|
22
|
+
// invitation to this doorbell, so a bell that held both halves of the
|
|
23
|
+
// relation is a bell that could read a banner, and this one cannot.
|
|
24
|
+
import { Being, isSilence, isWord, wordOf } from '@nervur-org/nervur';
|
|
25
|
+
import type { Asker, JsonObject, OccupantRecord } from '@nervur-org/nervur';
|
|
26
|
+
|
|
27
|
+
const DEVICE = 'device';
|
|
28
|
+
// Her standing at the box's push faculty, under an id of her own. The ward
|
|
29
|
+
// knocks and takes it for her, and the invitation never reaches her.
|
|
30
|
+
const MINE = 'push';
|
|
31
|
+
// What a platform will carry. It is the faculty's number, because the
|
|
32
|
+
// faculty is the one that hands a ring to a platform.
|
|
33
|
+
import { SEAL } from '../dock/push.ts';
|
|
34
|
+
|
|
35
|
+
const isDevice = (occ: OccupantRecord | undefined) => occ?.id === DEVICE;
|
|
36
|
+
const isWorld = (occ: OccupantRecord | undefined) => occ !== undefined && occ.id !== DEVICE;
|
|
37
|
+
const isMaker = (occ: OccupantRecord | undefined) => occ !== undefined;
|
|
38
|
+
|
|
39
|
+
export class Doorbell extends Being {
|
|
40
|
+
static override cells: JsonObject = { token: null, admitted: 0, claimed: false };
|
|
41
|
+
static override asks = {
|
|
42
|
+
claim: { description: 'the one way in for this doorbell, handed out once', input: { type: 'object' }, for: isMaker },
|
|
43
|
+
keep: { description: 'the device hands its platform token, for nobody else', input: { type: 'object', properties: { token: { type: 'object' } }, required: ['token'] }, for: isDevice },
|
|
44
|
+
admit: { description: 'the device allows one world to wake it', input: { type: 'object' }, for: isDevice },
|
|
45
|
+
drop: { description: 'the device disallows one world, and only that one', input: { type: 'object', properties: { world: { type: 'string' } }, required: ['world'] }, for: isDevice },
|
|
46
|
+
allowed: { description: 'which worlds this device allows', input: { type: 'object' }, for: isDevice },
|
|
47
|
+
ring: { description: 'wake this device: a sealed payload and nothing else', input: { type: 'object', properties: { sealed: { type: 'string' } }, required: ['sealed'] }, for: isWorld },
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
// Once a device holds this doorbell there is no way in left to hand out,
|
|
51
|
+
// so the ask goes from every blueprint rather than sitting there dead.
|
|
52
|
+
override describe(asker: Parameters<Being['describe']>[0]) {
|
|
53
|
+
const out = super.describe(asker);
|
|
54
|
+
if (this.cells.claimed !== true) return out;
|
|
55
|
+
return { ...out, asks: out.asks.filter((a) => a.name !== 'claim') };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// The way in, handed out once and then gone. Whoever made her asks for it
|
|
59
|
+
// and gives it to the phone; a second ask is refused, so there is no
|
|
60
|
+
// second key to this device anywhere.
|
|
61
|
+
async claim(_args: JsonObject, asker: Asker): Promise<JsonObject> {
|
|
62
|
+
if (this.cells.claimed === true) return { error: 'this doorbell has a device' };
|
|
63
|
+
const inv = await this.invite(DEVICE);
|
|
64
|
+
if (inv === null) return { error: 'no way in' };
|
|
65
|
+
this.cells.claimed = true;
|
|
66
|
+
// And she leaves. Whoever made this doorbell has nothing here once the
|
|
67
|
+
// way in is handed out, so she is not left standing in the list of who
|
|
68
|
+
// may wake this phone.
|
|
69
|
+
const maker = this.occupant(asker);
|
|
70
|
+
if (maker) this.occupants.remove(maker.id);
|
|
71
|
+
return { invitation: inv as unknown as JsonObject };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Written once and rewritten whenever the platform hands the phone a new
|
|
75
|
+
// one. No world is told anything, because no world was ever given it.
|
|
76
|
+
keep(args: JsonObject): JsonObject {
|
|
77
|
+
const token = args.token;
|
|
78
|
+
if (token === null || typeof token !== 'object' || Array.isArray(token) || typeof (token as { kind?: unknown }).kind !== 'string') return { error: 'a token names its kind' };
|
|
79
|
+
this.cells.token = token;
|
|
80
|
+
return { kept: (token as { kind: string }).kind };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// The device allows a world by asking for a way in and handing it over.
|
|
84
|
+
// So a world can only ring a phone that chose it.
|
|
85
|
+
async admit(_args: JsonObject): Promise<JsonObject> {
|
|
86
|
+
const admitted = typeof this.cells.admitted === 'number' ? this.cells.admitted : 0;
|
|
87
|
+
const world = `world:${admitted + 1}`;
|
|
88
|
+
const inv = await this.invite(world);
|
|
89
|
+
if (inv === null) return { error: 'no way in' };
|
|
90
|
+
this.cells.admitted = admitted + 1;
|
|
91
|
+
return { world, invitation: inv as unknown as JsonObject };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Disallowing is removing one occupant. The heir is forgotten, that world
|
|
95
|
+
// hears `removed` once at the door, and her other worlds are untouched.
|
|
96
|
+
drop(args: JsonObject): JsonObject {
|
|
97
|
+
const world = args.world;
|
|
98
|
+
if (typeof world !== 'string' || world === DEVICE) return { error: 'a world is named, and the device is not one' };
|
|
99
|
+
if (!Object.hasOwn(this.cells.occupants, world)) return { error: 'no such world' };
|
|
100
|
+
this.occupants.remove(world);
|
|
101
|
+
return { dropped: world };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
allowed(_args: JsonObject): JsonObject {
|
|
105
|
+
return { worlds: Object.keys(this.cells.occupants).filter((id) => id !== DEVICE) };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Who is ringing is the standing it arrived on, so the ask carries no
|
|
109
|
+
// world, no pk and no token, and there is nothing in it to forge.
|
|
110
|
+
async ring(args: JsonObject): Promise<JsonObject> {
|
|
111
|
+
const sealed = args.sealed;
|
|
112
|
+
if (typeof sealed !== 'string' || sealed.length === 0) return { error: 'a ring carries one sealed payload' };
|
|
113
|
+
if (sealed.length > SEAL) return { error: `a sealed payload is at most ${SEAL} bytes` };
|
|
114
|
+
const token = this.cells.token;
|
|
115
|
+
if (token === null) return { error: 'this device has handed no token' };
|
|
116
|
+
const at = this.standings[MINE] ?? (await this.lend(MINE, MINE).then(() => this.standings[MINE]));
|
|
117
|
+
if (!at) return { error: 'this box rings nothing' };
|
|
118
|
+
const out = await at.ask('ring', { token, sealed });
|
|
119
|
+
if (out === undefined || isSilence(out)) return { error: 'this box rings nothing' };
|
|
120
|
+
if (isWord(out)) return { error: wordOf(out) };
|
|
121
|
+
// What the faculty says is the answer, and a refusal is one of the things
|
|
122
|
+
// it says: a box holding no key for this kind, or a service that would
|
|
123
|
+
// not take the ring. Passing that on is the whole of the promise a bell
|
|
124
|
+
// makes, because a ring reported as rung and never sent is worse than one
|
|
125
|
+
// that plainly failed: the world stops trying and the human is never told.
|
|
126
|
+
if (out !== null && typeof out === 'object' && !Array.isArray(out) && typeof (out as JsonObject).error === 'string') return { error: (out as JsonObject).error as string };
|
|
127
|
+
return { rang: true };
|
|
128
|
+
}
|
|
129
|
+
}
|
package/beings/envoy.ts
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// The envoy base: what every envoy is and no envoy has alone. An envoy is
|
|
3
|
+
// a being of the org's standing in for a ward she does not run, an account
|
|
4
|
+
// at a thing that speaks no Quo, and the trunk's envoy chapter says the
|
|
5
|
+
// seven things she is. This is those seven as one class an author extends,
|
|
6
|
+
// so that a vendor's envoy is her asks out, her signature and her decoding,
|
|
7
|
+
// and nothing else.
|
|
8
|
+
//
|
|
9
|
+
// Two gates, one credential, one way out, one way in, one memory, one way
|
|
10
|
+
// back. `credential` and `forget` are the owner's; `deliver`, for an envoy
|
|
11
|
+
// that is called in, is the owner's too, and the daemon asks it with the
|
|
12
|
+
// vendor's raw bytes and headers. What an occupant may ask is the class's
|
|
13
|
+
// asks out, gated by `occupant` or by her own reading of the notes.
|
|
14
|
+
//
|
|
15
|
+
// Nothing here knows a vendor's name. The signature is `verify`, the
|
|
16
|
+
// vocabulary is `decode`, and what a delivery wakes is `wake`, each a
|
|
17
|
+
// method the class fills; the base keeps the discipline around them: the
|
|
18
|
+
// bytes checked before anything is parsed, the id kept bounded so a retry
|
|
19
|
+
// lands once, `{ ok }` answered before the wake goes out, a wake that meets
|
|
20
|
+
// silence written down and never retried.
|
|
21
|
+
import { Being, isSilence, isWord, wordOf } from '@nervur-org/nervur';
|
|
22
|
+
import type { Asker, AskSpec, JsonObject, OccupantRecord } from '@nervur-org/nervur';
|
|
23
|
+
|
|
24
|
+
// The two gates. The owner is the ward's ask pointer, `{ id: 'OWNER' }`,
|
|
25
|
+
// which is how the daemon asks `deliver` and how the root places a
|
|
26
|
+
// credential; an occupant is anyone holding a standing at her.
|
|
27
|
+
export const owner = (_rec: OccupantRecord | undefined, asker: Asker): boolean => asker.id === 'OWNER';
|
|
28
|
+
export const occupant = (rec: OccupantRecord | undefined): boolean => rec !== undefined;
|
|
29
|
+
|
|
30
|
+
// The third gate: who may place what the vendor gave this account. WORLDS
|
|
31
|
+
// has placing an envoy as an ask on home, shown by the gate to the org's
|
|
32
|
+
// presences and to its model, with the box never logged into for it, so
|
|
33
|
+
// this cannot be the owner alone. It is the owner or an occupant whose
|
|
34
|
+
// note says she may, placed with her standing the way every other note is,
|
|
35
|
+
// and it is never a default: an org that marked nobody has the root's
|
|
36
|
+
// shell and nothing else, which is where this began.
|
|
37
|
+
export const places = (rec: OccupantRecord | undefined, asker: Asker): boolean => asker.id === 'OWNER' || rec?.notes.credential === true;
|
|
38
|
+
|
|
39
|
+
// A credential as a form, for a class that wants one. `at` is never among
|
|
40
|
+
// the fields and cannot be: it is where this box sends the org's key, so a
|
|
41
|
+
// form that set it would be a form that points the box at anywhere and
|
|
42
|
+
// makes it fetch there with the account's own secret. Where a vendor
|
|
43
|
+
// answers is the class's, since the class is the thing that speaks that
|
|
44
|
+
// vendor, and only the owner may name another.
|
|
45
|
+
export const CREDENTIAL = (properties: Record<string, JsonObject>, required: string[]): AskSpec => ({
|
|
46
|
+
description: 'what the vendor gave this account: kept in her cells, and never shown again',
|
|
47
|
+
input: { type: 'object', properties, required },
|
|
48
|
+
for: places,
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
// One delivery, decoded by the class into what the base needs: the
|
|
52
|
+
// vendor's id for it, its kind in the vendor's words, which vendor id it
|
|
53
|
+
// is about, and the values the class read off it.
|
|
54
|
+
export type Delivery = { id: string; kind: string; about: string | null; data: JsonObject };
|
|
55
|
+
// What a delivery wakes: one ask on the way back to whoever the vendor id
|
|
56
|
+
// was remembered for, or nothing.
|
|
57
|
+
export type Wake = { method: string; args: JsonObject };
|
|
58
|
+
// What a way out heard: the status, and the body as an object, `{}` when
|
|
59
|
+
// there was none or it was not JSON.
|
|
60
|
+
export type Heard = { ok: boolean; status: number; body: JsonObject };
|
|
61
|
+
|
|
62
|
+
const SEEN = 200;
|
|
63
|
+
const FRESH = 5 * 60_000;
|
|
64
|
+
|
|
65
|
+
export class Envoy extends Being {
|
|
66
|
+
static override cells: JsonObject = { credential: null, about: {}, seen: [], unwoken: [] };
|
|
67
|
+
static override asks: Record<string, AskSpec> = {
|
|
68
|
+
credential: { description: 'what the vendor gave the account: kept in her cells, never shown', input: { type: 'object' }, for: places },
|
|
69
|
+
forget: { description: 'forget the credential', input: { type: 'object' }, for: places },
|
|
70
|
+
};
|
|
71
|
+
// The one ask in, for an envoy the vendor calls: a class that is called
|
|
72
|
+
// in spreads it into her asks, `{ ...Envoy.asks, ...Envoy.IN, ...hers }`,
|
|
73
|
+
// and a one-way envoy shows no deliver at all.
|
|
74
|
+
static IN: Record<string, AskSpec> = {
|
|
75
|
+
deliver: { description: 'the vendor delivers: its raw bytes and headers, from the daemon as the owner', input: { type: 'object', properties: { body: { type: 'string' }, headers: { type: 'object' } }, required: ['body', 'headers'] }, for: owner },
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
// 1. Her credential is in her cells, and she gets it herself. The owner
|
|
79
|
+
// places what the vendor gave, values only; `check` is the class's word
|
|
80
|
+
// on what it must hold, a reason or null.
|
|
81
|
+
protected check(c: JsonObject): string | null {
|
|
82
|
+
for (const [k, v] of Object.entries(c)) if (typeof v !== 'string' || v === '') return `${k} is a non-empty string`;
|
|
83
|
+
return Object.keys(c).length ? null : 'a credential has at least one field';
|
|
84
|
+
}
|
|
85
|
+
// Where the vendor answers, as the class that speaks that vendor knows
|
|
86
|
+
// it. A class that leaves it null has an owner who must name one once.
|
|
87
|
+
static at: string | null = null;
|
|
88
|
+
|
|
89
|
+
credential(args: JsonObject, asker: Asker): JsonObject {
|
|
90
|
+
// `at` is the one field a form may not carry. From anyone but the
|
|
91
|
+
// owner it is dropped rather than refused, since a human pasting a key
|
|
92
|
+
// has said nothing about it either way, and what stands is the one
|
|
93
|
+
// already placed or the class's own.
|
|
94
|
+
const { at: named, ...rest } = args;
|
|
95
|
+
const at = asker.id === 'OWNER' && typeof named === 'string' ? named : ((this.held?.at as string | undefined) ?? (this.constructor as typeof Envoy).at);
|
|
96
|
+
const c: JsonObject = { ...rest, ...(typeof at === 'string' ? { at } : {}) };
|
|
97
|
+
const why = this.check(c);
|
|
98
|
+
if (why) return { error: why };
|
|
99
|
+
this.cells.credential = c;
|
|
100
|
+
return { kept: true };
|
|
101
|
+
}
|
|
102
|
+
forget(): JsonObject {
|
|
103
|
+
this.cells.credential = null;
|
|
104
|
+
return { kept: false };
|
|
105
|
+
}
|
|
106
|
+
protected get held(): JsonObject | null {
|
|
107
|
+
return (this.cells.credential as JsonObject | null) ?? null;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// 2. Her asks out are the vendor's surface, narrowed: one fetch, from
|
|
111
|
+
// `at` in her credential with `key` as the bearer, the answer as values.
|
|
112
|
+
// The class reshapes what it heard and passes nothing through raw.
|
|
113
|
+
protected async out(path: string, init: { method?: string; body?: JsonObject; headers?: Record<string, string> } = {}): Promise<Heard> {
|
|
114
|
+
const c = this.held;
|
|
115
|
+
if (!c || typeof c.at !== 'string') return { ok: false, status: 0, body: { error: 'no credential: the owner has not placed one' } };
|
|
116
|
+
let r: Response;
|
|
117
|
+
try {
|
|
118
|
+
r = await fetch(`${c.at}${path}`, {
|
|
119
|
+
method: init.method ?? (init.body ? 'POST' : 'GET'),
|
|
120
|
+
headers: { ...(typeof c.key === 'string' ? { authorization: `Bearer ${c.key}` } : {}), ...(init.body ? { 'content-type': 'application/json' } : {}), ...(init.headers ?? {}) },
|
|
121
|
+
...(init.body ? { body: JSON.stringify(init.body) } : {}),
|
|
122
|
+
});
|
|
123
|
+
} catch {
|
|
124
|
+
return { ok: false, status: 0, body: { error: 'the vendor is unreachable' } };
|
|
125
|
+
}
|
|
126
|
+
const v = (await r.json().catch(() => ({}))) as unknown;
|
|
127
|
+
const body = v !== null && typeof v === 'object' && !Array.isArray(v) ? (v as JsonObject) : {};
|
|
128
|
+
return { ok: r.ok, status: r.status, body };
|
|
129
|
+
}
|
|
130
|
+
// The vendor's no, as an error object in her own words.
|
|
131
|
+
protected refused(h: Heard): JsonObject {
|
|
132
|
+
return h.status === 0 ? h.body : { error: `the vendor refused: ${h.status}${typeof h.body.message === 'string' ? ` ${h.body.message}` : ''}` };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// 4. Her cells are what the vendor told her: the map from the vendor's id
|
|
136
|
+
// to whom it is about, written when she asked out, read when called in.
|
|
137
|
+
protected remember(vendorId: string, by: string, more: JsonObject = {}): void {
|
|
138
|
+
(this.cells.about as Record<string, JsonObject>)[vendorId] = { by, ...more };
|
|
139
|
+
}
|
|
140
|
+
protected recall(vendorId: string): (JsonObject & { by: string }) | undefined {
|
|
141
|
+
const m = this.cells.about as Record<string, JsonObject>;
|
|
142
|
+
return Object.hasOwn(m, vendorId) ? (m[vendorId] as JsonObject & { by: string }) : undefined;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// 3. `deliver` is her one ask in. The daemon asks it as the owner with
|
|
146
|
+
// the raw body and the headers; an empty body is a redirect landing,
|
|
147
|
+
// the query in the headers under `:query`. The signature is checked
|
|
148
|
+
// before anything is parsed; a delivery she has seen is `{ ok }` at once
|
|
149
|
+
// and wakes nobody twice; and 6, `{ ok }` goes back before the wake.
|
|
150
|
+
async deliver(args: JsonObject): Promise<JsonObject> {
|
|
151
|
+
const body = typeof args.body === 'string' ? args.body : '';
|
|
152
|
+
const headers = (args.headers && typeof args.headers === 'object' && !Array.isArray(args.headers) ? args.headers : {}) as Record<string, string>;
|
|
153
|
+
if (body === '') return this.landed(headers[':query'] ?? '', headers);
|
|
154
|
+
if (!(await this.verify(body, headers))) return { error: 'bad signature' };
|
|
155
|
+
const d = this.decode(body, headers);
|
|
156
|
+
if ('error' in d) return d as JsonObject;
|
|
157
|
+
const seen = this.cells.seen as string[];
|
|
158
|
+
const mark = `${d.kind}:${d.id}`;
|
|
159
|
+
if (seen.includes(mark)) return { ok: true };
|
|
160
|
+
seen.push(mark);
|
|
161
|
+
while (seen.length > SEEN) seen.shift();
|
|
162
|
+
void this.#wake(d);
|
|
163
|
+
return { ok: true };
|
|
164
|
+
}
|
|
165
|
+
// The class's word on the vendor's signature, over the bytes as sent.
|
|
166
|
+
protected verify(_body: string, _headers: Record<string, string>): Promise<boolean> | boolean {
|
|
167
|
+
return false;
|
|
168
|
+
}
|
|
169
|
+
// The class's reading of the vendor's vocabulary.
|
|
170
|
+
protected decode(_body: string, _headers: Record<string, string>): Delivery | { error: string } {
|
|
171
|
+
return { error: 'not called in' };
|
|
172
|
+
}
|
|
173
|
+
// Where a redirect lands: an OAuth code in the query, for a class that
|
|
174
|
+
// begins its account that way. The base takes nothing from it.
|
|
175
|
+
protected landed(_query: string, _headers: Record<string, string>): JsonObject | Promise<JsonObject> {
|
|
176
|
+
return { ok: true };
|
|
177
|
+
}
|
|
178
|
+
// A signature over text with a shared secret, hex, the shape most
|
|
179
|
+
// vendors sign in: here once so a class writes the vendor's framing only.
|
|
180
|
+
protected async hmac(secret: string, text: string): Promise<string> {
|
|
181
|
+
const key = await crypto.subtle.importKey('raw', new TextEncoder().encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
|
|
182
|
+
return [...new Uint8Array(await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(text)))].map((x) => x.toString(16).padStart(2, '0')).join('');
|
|
183
|
+
}
|
|
184
|
+
// Whether a vendor's timestamp is within five minutes of now, in seconds
|
|
185
|
+
// or in milliseconds, since vendors sign in either and a class should
|
|
186
|
+
// not have to know which.
|
|
187
|
+
protected fresh(ts: string | number): boolean {
|
|
188
|
+
const t = Number(ts);
|
|
189
|
+
if (!Number.isFinite(t)) return false;
|
|
190
|
+
const ms = t < 1e11 ? t * 1000 : t;
|
|
191
|
+
return Math.abs(Date.now() - ms) <= FRESH;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// 5. Her way back is a standing placed like any other, `to:<id>` for the
|
|
195
|
+
// occupant a vendor id was remembered for, handed to her at the knock.
|
|
196
|
+
// A delivery is one ask on it, in her own words, said by `wake`; the
|
|
197
|
+
// being woken cannot tell it from a clock.
|
|
198
|
+
protected wake(_d: Delivery, _about: (JsonObject & { by: string }) | undefined): Wake | null {
|
|
199
|
+
return null;
|
|
200
|
+
}
|
|
201
|
+
async #wake(d: Delivery): Promise<void> {
|
|
202
|
+
const about = d.about === null ? undefined : this.recall(d.about);
|
|
203
|
+
const w = this.wake(d, about);
|
|
204
|
+
if (!w) return;
|
|
205
|
+
const unwoken = this.cells.unwoken as JsonObject[];
|
|
206
|
+
// a wake the class shaped wrong is written down, never dropped on the floor
|
|
207
|
+
if (typeof w.method !== 'string' || !w.method || w.args === null || typeof w.args !== 'object') return void unwoken.push({ id: d.id, kind: d.kind, why: 'a wake is { method, args }' });
|
|
208
|
+
const back = about ? this.standings[`to:${about.by}`] : undefined;
|
|
209
|
+
if (!back) return void unwoken.push({ id: d.id, kind: d.kind, why: about ? `no way back to ${about.by}` : 'about nobody she remembers' });
|
|
210
|
+
const out = await back.ask(w.method, w.args);
|
|
211
|
+
if (isSilence(out) || out === undefined) unwoken.push({ id: d.id, kind: d.kind, why: 'silence' });
|
|
212
|
+
else if (isWord(out)) unwoken.push({ id: d.id, kind: d.kind, why: wordOf(out) });
|
|
213
|
+
}
|
|
214
|
+
// What she could not wake, for the device's own code and a suite.
|
|
215
|
+
get unwoken(): JsonObject[] {
|
|
216
|
+
return this.cells.unwoken as JsonObject[];
|
|
217
|
+
}
|
|
218
|
+
}
|
package/beings/index.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// The beings every side shares. See quo-dock.md, the trunk.
|
|
3
|
+
export { User, DESK, HOME, BELL } from './user.ts';
|
|
4
|
+
export { Desk } from './desk.ts';
|
|
5
|
+
export { WAY, kindOf, verified, VERIFY, SEND, type Proof, type Verified } from './way.ts';
|
|
6
|
+
export { Avatar, USER, PUSHER } from './avatar.ts';
|
|
7
|
+
export { Carrier } from './carry.ts';
|
|
8
|
+
export { Presence } from './presence.ts';
|
|
9
|
+
export { Courier, MOVER, isMoving, type Moving } from './courier.ts';
|
|
10
|
+
export { Porter } from './porter.ts';
|
|
11
|
+
export { Doorbell } from './doorbell.ts';
|
|
12
|
+
export { Envoy, owner, occupant, type Delivery, type Wake, type Heard } from './envoy.ts';
|
|
13
|
+
export { DOCK, CONSOLE, LOCAL, LOCATION, NOTIFICATIONS, PUSH, TIMER, FACULTIES, FACULTY_CLASSES, Console, Faculty, Local, Location, Notifications, Push, Timer, isToken, join, settled, stand, standOn, lending, facultyOf, type Ring, type Token, type Where, type Watched, type Woke, type Rooted } from '../dock/index.ts';
|
|
14
|
+
export { webPush, mintVapid, publicOf, apnsPush, pkcs8, HOSTS, fcmPush, isAccount, DOORS, handedTo, ringing, routed, type Vapid, type Apns, type Account, type Doors } from '../dock/index.ts';
|
|
15
|
+
export { Clock, CLOCK, setupClock, placeClock, readSchedule, parseCron, nextFire, civil, fromCivil, type Schedule, type Fire, type Cron, type Civil } from './clock.ts';
|
|
16
|
+
export { sanitise, hint, hintFor, groups, type Look, type Hint } from './look.ts';
|
package/beings/join.ts
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// An identity in a world, as a route needs her: her avatar found or booted,
|
|
3
|
+
// her way in with an invitation, and her avatar as she already stands. It is
|
|
4
|
+
// three asks at a ward's own door and nothing of any terrain, so the daemon
|
|
5
|
+
// and the worker at the edge hand the same functions to the same routes.
|
|
6
|
+
//
|
|
7
|
+
// A way in that is the box's own, a nonce on the `local` faculty, is not
|
|
8
|
+
// here: it is a process the device saw on its own socket, which only a
|
|
9
|
+
// terrain with processes has, and the daemon keeps it.
|
|
10
|
+
import type { Hosted } from '@nervur-org/nervur/harbor';
|
|
11
|
+
import type { Invitation } from '@nervur-org/nervur';
|
|
12
|
+
import type { Avatar } from './avatar.ts';
|
|
13
|
+
|
|
14
|
+
// Her avatar in a ward, found or booted under the identity's key. An
|
|
15
|
+
// identity is a word, and never a being of the ward.
|
|
16
|
+
export async function avatarOf(hosted: Hosted, identity: string): Promise<{ avatar?: Avatar; error?: string }> {
|
|
17
|
+
if (!/^[\w.-]+$/.test(identity) || identity === hosted.record.user || identity === 'desk') return { error: 'an identity is a word, and not a being of the ward' };
|
|
18
|
+
const key = `avatar:${identity}`;
|
|
19
|
+
let avatar = hosted.being(key) as Avatar | undefined;
|
|
20
|
+
if (!avatar) {
|
|
21
|
+
const out = (await hosted.ask('boot', { key, class: 'Avatar' })) as { error?: string };
|
|
22
|
+
if (out.error) return { error: out.error };
|
|
23
|
+
avatar = hosted.being(key) as Avatar;
|
|
24
|
+
}
|
|
25
|
+
return { avatar };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Join a client identity to a ward with an invitation the root minted for
|
|
29
|
+
// it: find or boot her avatar, and she knocks. The one path for an identity
|
|
30
|
+
// from elsewhere, the exchange page. Nothing is typed to get in but the
|
|
31
|
+
// invitation, and the knock spends it.
|
|
32
|
+
export async function letIn(hosted: Hosted, identity: string, invitation: Invitation): Promise<{ avatar?: Avatar; error?: string }> {
|
|
33
|
+
const found = await avatarOf(hosted, identity);
|
|
34
|
+
if (!found.avatar) return found;
|
|
35
|
+
const joined = await found.avatar.join(invitation);
|
|
36
|
+
await hosted.save();
|
|
37
|
+
if ('error' in joined && !('asks' in joined)) return { error: joined.error };
|
|
38
|
+
return { avatar: found.avatar };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Her avatar as she stands, for a session opening under a grant: she is in
|
|
42
|
+
// or she is not, and nothing is minted here. An identity the user being
|
|
43
|
+
// removed hears `removed` on her describe, which the endpoint speaks as 401.
|
|
44
|
+
export async function find(hosted: Hosted, identity: string): Promise<{ avatar?: Avatar; error?: string }> {
|
|
45
|
+
const avatar = hosted.being(`avatar:${identity}`) as Avatar | undefined;
|
|
46
|
+
if (!avatar) return { error: 'not joined' };
|
|
47
|
+
const bp = await avatar.tools();
|
|
48
|
+
await hosted.save();
|
|
49
|
+
if ('error' in bp && !('asks' in bp)) return { error: bp.error };
|
|
50
|
+
return { avatar };
|
|
51
|
+
}
|
package/beings/link.ts
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// A link: a world's page with an invitation in the fragment. The page is the
|
|
3
|
+
// hint, where the world lives; the fragment is the invitation, which never
|
|
4
|
+
// leaves the browser: a query string reaches the server, its logs and every
|
|
5
|
+
// referer, and an invitation is a capability. One reserved key, `quo`, the
|
|
6
|
+
// one word no page uses for anything else, and the value is the invitation
|
|
7
|
+
// compact: `ward.heir.secret`, three hex strings, or `ward` alone for a
|
|
8
|
+
// world's public being. The dock's human module reads it, strips it before
|
|
9
|
+
// any page code runs, and joins. One other key is reserved, `proof`, for the
|
|
10
|
+
// way in a world sent out itself, and it is read the same way.
|
|
11
|
+
import type { Invitation } from '@nervur-org/nervur';
|
|
12
|
+
|
|
13
|
+
export const KEY = 'quo';
|
|
14
|
+
// The other reserved key: a proof, for a way in the world sent out itself.
|
|
15
|
+
// A proof is not a capability into a ward, it is a word one door will trade
|
|
16
|
+
// once for an invitation, and it rides in the fragment for the same reason:
|
|
17
|
+
// a query string is in every log and every referer, and one read of it is
|
|
18
|
+
// one way in taken. `<kind>.<token>`, the kind the desk knows it by and the
|
|
19
|
+
// token that door minted.
|
|
20
|
+
export const PROOF = 'proof';
|
|
21
|
+
const HEX = (n: number) => `[0-9a-f]{${n}}`;
|
|
22
|
+
const FULL = new RegExp(`^(${HEX(128)})\\.(${HEX(64)})\\.(${HEX(64)})$`);
|
|
23
|
+
const PUBLIC = new RegExp(`^${HEX(128)}$`);
|
|
24
|
+
|
|
25
|
+
export function format(inv: Invitation): string {
|
|
26
|
+
return inv.heir && inv.secret ? `${inv.ward}.${inv.heir}.${inv.secret}` : inv.ward;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// The invitation in a fragment, or null. Anything else in the fragment is
|
|
30
|
+
// the page's own and is left alone.
|
|
31
|
+
export function parse(hash: string): Invitation | null {
|
|
32
|
+
const m = /(?:^#|&)quo=([^&]+)/.exec(hash.startsWith('#') ? hash : `#${hash}`);
|
|
33
|
+
if (!m) return null;
|
|
34
|
+
const v = decodeURIComponent(m[1]);
|
|
35
|
+
const full = FULL.exec(v);
|
|
36
|
+
if (full) return { ward: full[1], heir: full[2], secret: full[3] };
|
|
37
|
+
return PUBLIC.test(v) ? { ward: v } : null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// The proof in a fragment, or null. The kind is a word and the token is
|
|
41
|
+
// whatever the door that minted it wrote, opaque here and never read.
|
|
42
|
+
export function parseProof(hash: string): { kind: string; token: string } | null {
|
|
43
|
+
const m = new RegExp(`(?:^#|&)${PROOF}=([^&]+)`).exec(hash.startsWith('#') ? hash : `#${hash}`);
|
|
44
|
+
if (!m) return null;
|
|
45
|
+
const v = decodeURIComponent(m[1]);
|
|
46
|
+
const at = v.indexOf('.');
|
|
47
|
+
if (at < 1 || at === v.length - 1) return null;
|
|
48
|
+
const kind = v.slice(0, at);
|
|
49
|
+
return /^[\w-]{1,40}$/.test(kind) ? { kind, token: v.slice(at + 1) } : null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// The fragment without what the dock reserves, so the page keeps whatever
|
|
53
|
+
// else it put there and neither capability is left in the address bar.
|
|
54
|
+
export function strip(hash: string): string {
|
|
55
|
+
const rest = (hash.startsWith('#') ? hash.slice(1) : hash)
|
|
56
|
+
.split('&')
|
|
57
|
+
.filter((p) => !p.startsWith(`${KEY}=`) && !p.startsWith(`${PROOF}=`))
|
|
58
|
+
.join('&');
|
|
59
|
+
return rest ? `#${rest}` : '';
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function link(page: string, inv: Invitation): string {
|
|
63
|
+
return `${page}#${KEY}=${format(inv)}`;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// A world's own way in, as it goes out in a mail or a message: the page of
|
|
67
|
+
// the world it is for, with the proof in the fragment.
|
|
68
|
+
export function proofLink(page: string, kind: string, token: string): string {
|
|
69
|
+
return `${page}#${PROOF}=${encodeURIComponent(`${kind}.${token}`)}`;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// The invitation as a human hands it over: the compact form, the whole link
|
|
73
|
+
// it came in, or the JSON value `quo invite` printed. Wherever a person
|
|
74
|
+
// types or pastes an invitation, this is what reads it, so the three forms
|
|
75
|
+
// are one form everywhere.
|
|
76
|
+
export function invitationOf(text: string): Invitation | null {
|
|
77
|
+
const t = text.trim();
|
|
78
|
+
const hash = t.indexOf('#');
|
|
79
|
+
const linked = parse(hash === -1 ? `#${KEY}=${t}` : t.slice(hash));
|
|
80
|
+
if (linked?.heir) return linked;
|
|
81
|
+
try {
|
|
82
|
+
const v = JSON.parse(t) as Record<string, unknown>;
|
|
83
|
+
if (v && typeof v === 'object' && typeof v.ward === 'string' && typeof v.heir === 'string' && typeof v.secret === 'string') return { ward: v.ward, heir: v.heir, secret: v.secret };
|
|
84
|
+
} catch {
|
|
85
|
+
/* not JSON either */
|
|
86
|
+
}
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// The reach note in front of an invitation: the URL a link was written on,
|
|
91
|
+
// which for a link between two devices is the rendezvous to dial to reach
|
|
92
|
+
// the one that wrote it. A hint is not a capability, so it travels beside
|
|
93
|
+
// the invitation in the open. Nothing that is not a URL is one.
|
|
94
|
+
export function reachOf(text: string): string | null {
|
|
95
|
+
const t = text.trim();
|
|
96
|
+
const hash = t.indexOf('#');
|
|
97
|
+
const before = hash === -1 ? '' : t.slice(0, hash);
|
|
98
|
+
try {
|
|
99
|
+
return /^https?:$/.test(new URL(before).protocol) ? before : null;
|
|
100
|
+
} catch {
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|