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