@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
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import type { Asker, JsonObject, OccupantRecord } from '@nervur-org/nervur';
|
|
2
|
+
import { Faculty } from './faculty.ts';
|
|
3
|
+
export type Token = {
|
|
4
|
+
kind: string;
|
|
5
|
+
bell?: string;
|
|
6
|
+
app?: string;
|
|
7
|
+
} & JsonObject;
|
|
8
|
+
export declare const isToken: (v: unknown) => v is Token;
|
|
9
|
+
export declare function bellOf(token: Token): string | undefined;
|
|
10
|
+
export type Line = {
|
|
11
|
+
title: string;
|
|
12
|
+
body: string;
|
|
13
|
+
};
|
|
14
|
+
export type Ring = {
|
|
15
|
+
ring(token: Token, sealed?: string, line?: Line): Promise<void>;
|
|
16
|
+
};
|
|
17
|
+
export declare const SEAL = 4096;
|
|
18
|
+
export declare function banner(o: JsonObject): Line;
|
|
19
|
+
export declare function sealBanner(line: JsonObject, key: string): Promise<string | null>;
|
|
20
|
+
declare const root: (_occ: OccupantRecord | undefined, asker: Asker) => boolean;
|
|
21
|
+
export declare class Push extends Faculty {
|
|
22
|
+
static cells: JsonObject;
|
|
23
|
+
static asks: {
|
|
24
|
+
ring: {
|
|
25
|
+
description: string;
|
|
26
|
+
input: {
|
|
27
|
+
type: string;
|
|
28
|
+
properties: {
|
|
29
|
+
token: {
|
|
30
|
+
type: string;
|
|
31
|
+
};
|
|
32
|
+
sealed: {
|
|
33
|
+
type: string;
|
|
34
|
+
};
|
|
35
|
+
line: {
|
|
36
|
+
type: string;
|
|
37
|
+
properties: {
|
|
38
|
+
title: {
|
|
39
|
+
type: string;
|
|
40
|
+
};
|
|
41
|
+
body: {
|
|
42
|
+
type: string;
|
|
43
|
+
};
|
|
44
|
+
};
|
|
45
|
+
required: string[];
|
|
46
|
+
};
|
|
47
|
+
};
|
|
48
|
+
required: string[];
|
|
49
|
+
};
|
|
50
|
+
};
|
|
51
|
+
kinds: {
|
|
52
|
+
description: string;
|
|
53
|
+
input: {
|
|
54
|
+
type: string;
|
|
55
|
+
};
|
|
56
|
+
};
|
|
57
|
+
keep: {
|
|
58
|
+
description: string;
|
|
59
|
+
input: {
|
|
60
|
+
type: string;
|
|
61
|
+
properties: {
|
|
62
|
+
service: {
|
|
63
|
+
type: string[];
|
|
64
|
+
};
|
|
65
|
+
};
|
|
66
|
+
required: string[];
|
|
67
|
+
};
|
|
68
|
+
for: typeof root;
|
|
69
|
+
};
|
|
70
|
+
};
|
|
71
|
+
mint(): Promise<JsonWebKey>;
|
|
72
|
+
get key(): JsonWebKey | null;
|
|
73
|
+
get service(): string | undefined;
|
|
74
|
+
keep(args: JsonObject): JsonObject;
|
|
75
|
+
protected get rings(): Readonly<Record<string, Ring>>;
|
|
76
|
+
kinds(): JsonObject;
|
|
77
|
+
ring(args: JsonObject): Promise<JsonObject>;
|
|
78
|
+
}
|
|
79
|
+
export {};
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// Waking a device that is not reachable, the one way its platform allows: a
|
|
3
|
+
// push that carries the device's token and the banner sealed to that
|
|
4
|
+
// device's own key, which nothing between the world and the phone can
|
|
5
|
+
// read. A token names its
|
|
6
|
+
// kind, and a kind is one ring: `web` is Web Push under a keypair a world
|
|
7
|
+
// mints itself, `apns` and `fcm` are the two stores' own, handed on to the
|
|
8
|
+
// publisher's bell service by a world that holds no platform key. A box that
|
|
9
|
+
// was given no ring for a kind says so, which is what a world that rings no
|
|
10
|
+
// iPhones says to one.
|
|
11
|
+
//
|
|
12
|
+
// The rings are live objects and not values, so they are not cells: a
|
|
13
|
+
// terrain subclasses and builds its own in `rings`, out of what its
|
|
14
|
+
// environment and this being's cells give it.
|
|
15
|
+
import { OWNER } from '@nervur-org/nervur';
|
|
16
|
+
import { arithmetic } from '@nervur-org/nervur/ward';
|
|
17
|
+
import { Faculty } from './faculty.js';
|
|
18
|
+
import { mintVapid } from './webpush.js';
|
|
19
|
+
export const isToken = (v) => v !== null && typeof v === 'object' && !Array.isArray(v) && typeof v.kind === 'string';
|
|
20
|
+
// The bell a token names, when it names one a ring can reach.
|
|
21
|
+
export function bellOf(token) {
|
|
22
|
+
if (typeof token.bell !== 'string')
|
|
23
|
+
return undefined;
|
|
24
|
+
try {
|
|
25
|
+
const u = new URL(token.bell);
|
|
26
|
+
return u.protocol === 'https:' || u.protocol === 'http:' ? token.bell : undefined;
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
return undefined;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
// What a platform will carry, so a seal too big is refused where a human
|
|
33
|
+
// can still read why rather than two hops later by a service nobody reads.
|
|
34
|
+
export const SEAL = 4096;
|
|
35
|
+
// The line a device shows when it was woken while nobody was looking,
|
|
36
|
+
// composed from one object. A pushed object is whatever the far being sent,
|
|
37
|
+
// so the two names a notification has are taken when they are there and the
|
|
38
|
+
// object is shown as it is when they are not.
|
|
39
|
+
export function banner(o) {
|
|
40
|
+
const text = (v) => (typeof v === 'string' && v.trim() ? v.trim() : '');
|
|
41
|
+
const title = text(o.title) || text(o.from) || 'quo';
|
|
42
|
+
const body = text(o.body) ||
|
|
43
|
+
text(o.said) ||
|
|
44
|
+
Object.entries(o)
|
|
45
|
+
.filter(([k]) => k !== 'title' && k !== 'from')
|
|
46
|
+
.map(([k, v]) => `${k}: ${typeof v === 'string' ? v : JSON.stringify(v)}`)
|
|
47
|
+
.join(', ');
|
|
48
|
+
return { title, body };
|
|
49
|
+
}
|
|
50
|
+
// A banner sealed to a device's banner key, as hex, or null when it will
|
|
51
|
+
// not seal or will not fit. The world seals and the process that draws the
|
|
52
|
+
// banner opens it; no doorbell, no bell service and no platform between
|
|
53
|
+
// them holds either half, which is why a ring may carry a line at all.
|
|
54
|
+
export async function sealBanner(line, key) {
|
|
55
|
+
if (!/^[0-9a-f]{64}$/.test(key))
|
|
56
|
+
return null;
|
|
57
|
+
try {
|
|
58
|
+
const boxed = await arithmetic.box(new TextEncoder().encode(JSON.stringify(line)), arithmetic.unhex(key), globalThis.crypto.getRandomValues(new Uint8Array(32)));
|
|
59
|
+
const wire = arithmetic.hex(boxed.bytes);
|
|
60
|
+
return wire.length > SEAL ? null : wire;
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
// The root and nobody else: where a box hands a phone's ring is the box's own.
|
|
67
|
+
const root = (_occ, asker) => asker.id === OWNER;
|
|
68
|
+
export class Push extends Faculty {
|
|
69
|
+
// What she keeps, beside every faculty's: the world's doorbell key, a P-256
|
|
70
|
+
// JWK she mints herself the first time a terrain asks for one, so a Web
|
|
71
|
+
// Push is signed under a key that lives with the wards and in no file; and
|
|
72
|
+
// the bell service a phone's ring is handed to when its token names none,
|
|
73
|
+
// a row the root keeps, since whose service it is belongs to an estate.
|
|
74
|
+
static cells = { watched: {}, minted: 0, key: null, service: null };
|
|
75
|
+
static asks = {
|
|
76
|
+
ring: { description: 'wake a device: its token, the banner sealed to it, or the line itself where the ground encrypts it', input: { type: 'object', properties: { token: { type: 'object' }, sealed: { type: 'string' }, line: { type: 'object', properties: { title: { type: 'string' }, body: { type: 'string' } }, required: ['title', 'body'] } }, required: ['token'] } },
|
|
77
|
+
kinds: { description: 'which kinds this box can ring', input: { type: 'object' } },
|
|
78
|
+
keep: { description: 'the bell service a phone is rung through when its token names none; null forgets it', input: { type: 'object', properties: { service: { type: ['string', 'null'] } }, required: ['service'] }, for: root },
|
|
79
|
+
};
|
|
80
|
+
// The doorbell key, minted once and kept. A terrain asks for it when it
|
|
81
|
+
// serves a web route; a box that never does holds none and rings no tabs.
|
|
82
|
+
async mint() {
|
|
83
|
+
if (this.cells.key === null)
|
|
84
|
+
this.cells.key = (await mintVapid()).key;
|
|
85
|
+
return this.cells.key;
|
|
86
|
+
}
|
|
87
|
+
get key() {
|
|
88
|
+
return this.cells.key;
|
|
89
|
+
}
|
|
90
|
+
get service() {
|
|
91
|
+
return typeof this.cells.service === 'string' ? this.cells.service : undefined;
|
|
92
|
+
}
|
|
93
|
+
keep(args) {
|
|
94
|
+
const service = args.service;
|
|
95
|
+
if (service !== null && (typeof service !== 'string' || !/^https?:\/\/\S+$/.test(service)))
|
|
96
|
+
return { error: 'a bell service is a URL over http or https, or null' };
|
|
97
|
+
this.cells.service = service === null ? null : service.replace(/\/$/, '');
|
|
98
|
+
return { service: this.cells.service };
|
|
99
|
+
}
|
|
100
|
+
// What this box can ring. A terrain overrides it and builds its rings from
|
|
101
|
+
// its environment and this being's cells; the base rings nothing.
|
|
102
|
+
get rings() {
|
|
103
|
+
return {};
|
|
104
|
+
}
|
|
105
|
+
kinds() {
|
|
106
|
+
return { kinds: Object.keys(this.rings) };
|
|
107
|
+
}
|
|
108
|
+
async ring(args) {
|
|
109
|
+
const token = args.token;
|
|
110
|
+
if (!isToken(token))
|
|
111
|
+
return { error: 'a token names its kind' };
|
|
112
|
+
const sealed = args.sealed;
|
|
113
|
+
if (sealed !== undefined && typeof sealed !== 'string')
|
|
114
|
+
return { error: 'a seal is text' };
|
|
115
|
+
if (typeof sealed === 'string' && sealed.length > SEAL)
|
|
116
|
+
return { error: `a seal is at most ${SEAL} bytes` };
|
|
117
|
+
const said = args.line;
|
|
118
|
+
if (said !== undefined && (said === null || typeof said !== 'object' || Array.isArray(said) || typeof said.title !== 'string' || typeof said.body !== 'string'))
|
|
119
|
+
return { error: 'a line is a title and a body' };
|
|
120
|
+
const line = said;
|
|
121
|
+
const r = Object.hasOwn(this.rings, token.kind) ? this.rings[token.kind] : undefined;
|
|
122
|
+
if (!r)
|
|
123
|
+
return { error: `this box cannot ring a ${token.kind}` };
|
|
124
|
+
try {
|
|
125
|
+
await r.ring(token, sealed, line);
|
|
126
|
+
return { rang: token.kind };
|
|
127
|
+
}
|
|
128
|
+
catch (e) {
|
|
129
|
+
return { error: e instanceof Error ? e.message : 'the ring threw' };
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import { type Ring } from './push.ts';
|
|
2
|
+
export declare function routed(fallback?: string): Ring;
|
|
3
|
+
export declare function handedTo(at: string): Ring;
|
|
4
|
+
export declare function ringing(rings: Record<string, Ring>, apps?: readonly string[]): (request: Request) => Promise<Response>;
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// The bell service, from both ends: the ground a world rings through when
|
|
3
|
+
// it does not hold the platform's key, and the door that holds the key and
|
|
4
|
+
// rings.
|
|
5
|
+
//
|
|
6
|
+
// Why there is one at all. Apple and Google accept a push only when it is
|
|
7
|
+
// signed with the key of the team that published the app, so a world's
|
|
8
|
+
// droplet cannot ring the Quo app however much its human wants it to: the
|
|
9
|
+
// key belongs to whoever publishes the app and to nobody else. So a world's
|
|
10
|
+
// world hands the ring on, a token and a sealed banner, to a service the
|
|
11
|
+
// publisher runs. It is the one server the app has. An estate that ships
|
|
12
|
+
// its own app under its own name holds its own keys and runs its own, or
|
|
13
|
+
// puts these two rings straight into its push faculty and runs none.
|
|
14
|
+
//
|
|
15
|
+
// What it sees is a token, a ring, and ciphertext. Not a world, not an
|
|
16
|
+
// occupant, not a pk, and not a word of what was pushed: the banner is
|
|
17
|
+
// sealed to the device's banner key by the world that rang, and this
|
|
18
|
+
// service holds no key that opens it and never will. It keeps nothing
|
|
19
|
+
// either: no log of who rang, no list of tokens, no state at all, which is
|
|
20
|
+
// the only honest way to run a server every world's traffic passes through.
|
|
21
|
+
//
|
|
22
|
+
// Who may ring it is the token itself, and that is deliberate. A device
|
|
23
|
+
// token exists only because a device installed this app and handed it to a
|
|
24
|
+
// world it joined; it is per app and per device; a ring wakes a phone and
|
|
25
|
+
// says nothing to it; and the platforms throttle a background push on their
|
|
26
|
+
// own. An account here would be a registry of worlds, which is the one
|
|
27
|
+
// thing the app is built not to have. The day this is abused it wants a
|
|
28
|
+
// rate limit at the edge, which is the terrain's and not a word of Quo.
|
|
29
|
+
import { bellOf } from './push.js';
|
|
30
|
+
// The ring a world holds for a store's kind: the token names its bell and
|
|
31
|
+
// the ring goes there, so a phone running an app this world never shipped
|
|
32
|
+
// is rung through that app's own service; a token naming none goes to the
|
|
33
|
+
// service the world was told of, and a world told of none rings nothing.
|
|
34
|
+
export function routed(fallback) {
|
|
35
|
+
return {
|
|
36
|
+
async ring(token, sealed) {
|
|
37
|
+
const at = bellOf(token) ?? fallback;
|
|
38
|
+
if (!at)
|
|
39
|
+
throw new Error(`no bell service rings a ${token.kind} from this box`);
|
|
40
|
+
return handedTo(at).ring(token, sealed);
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
// The ring a world holds: hand the token to the service and take its word
|
|
45
|
+
// for it. A ring either left or threw, as every ring does.
|
|
46
|
+
export function handedTo(at) {
|
|
47
|
+
return {
|
|
48
|
+
async ring(token, sealed) {
|
|
49
|
+
const res = await fetch(`${at.replace(/\/$/, '')}/ring`, {
|
|
50
|
+
method: 'POST',
|
|
51
|
+
headers: { 'content-type': 'application/json' },
|
|
52
|
+
body: JSON.stringify(sealed === undefined ? { token } : { token, sealed }),
|
|
53
|
+
});
|
|
54
|
+
const said = (await res.text()).slice(0, 200);
|
|
55
|
+
if (!res.ok)
|
|
56
|
+
throw new Error(`the bell service answered ${res.status} ${said}`);
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
// The other end, as a plain request in and a response out, so that the
|
|
61
|
+
// terrain it runs on is the terrain's business and this is the whole of the
|
|
62
|
+
// service. `rings` is one per kind, exactly as the push faculty's map is: a
|
|
63
|
+
// service with no key for a kind answers that it has none, which is what a
|
|
64
|
+
// publisher who rings no Android phones says to one. `apps` is which apps
|
|
65
|
+
// this service holds keys for, and a token naming another is refused,
|
|
66
|
+
// since a key signs for one app and a ring for another would be a lie the
|
|
67
|
+
// platform catches later and nobody reads.
|
|
68
|
+
export function ringing(rings, apps) {
|
|
69
|
+
const answer = (status, body) => new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' } });
|
|
70
|
+
return async (request) => {
|
|
71
|
+
if (new URL(request.url).pathname !== '/ring')
|
|
72
|
+
return answer(404, { error: 'the bell service has one door, /ring' });
|
|
73
|
+
if (request.method !== 'POST')
|
|
74
|
+
return answer(405, { error: 'a ring is a POST' });
|
|
75
|
+
let token;
|
|
76
|
+
let sealed;
|
|
77
|
+
try {
|
|
78
|
+
const body = (await request.json());
|
|
79
|
+
token = body.token;
|
|
80
|
+
sealed = body.sealed;
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
return answer(400, { error: 'a ring is one JSON object naming a token' });
|
|
84
|
+
}
|
|
85
|
+
if (sealed !== undefined && typeof sealed !== 'string')
|
|
86
|
+
return answer(400, { error: 'a seal is text' });
|
|
87
|
+
const kind = token?.kind;
|
|
88
|
+
if (typeof kind !== 'string')
|
|
89
|
+
return answer(400, { error: 'a token names its kind' });
|
|
90
|
+
const g = rings[kind];
|
|
91
|
+
if (!g)
|
|
92
|
+
return answer(400, { error: `this service holds no key to ring a ${kind}` });
|
|
93
|
+
const app = token.app;
|
|
94
|
+
if (apps && (typeof app !== 'string' || !apps.includes(app)))
|
|
95
|
+
return answer(400, { error: `this service holds no key for ${typeof app === 'string' ? app : 'an app the token does not name'}` });
|
|
96
|
+
try {
|
|
97
|
+
await g.ring(token, sealed);
|
|
98
|
+
return answer(200, { rang: kind });
|
|
99
|
+
}
|
|
100
|
+
catch (e) {
|
|
101
|
+
// The platform's own refusal, passed back as it came. A world reads
|
|
102
|
+
// it in the error object its Bell answers with, which is where a
|
|
103
|
+
// human eventually reads it too.
|
|
104
|
+
return answer(502, { error: e instanceof Error ? e.message : String(e) });
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { JsonObject } from '@nervur-org/nervur';
|
|
2
|
+
import { Faculty, type Woke } from './faculty.ts';
|
|
3
|
+
export declare class Timer extends Faculty {
|
|
4
|
+
#private;
|
|
5
|
+
static asks: {
|
|
6
|
+
watch: {
|
|
7
|
+
description: string;
|
|
8
|
+
input: {
|
|
9
|
+
type: string;
|
|
10
|
+
properties: {
|
|
11
|
+
invitation: {
|
|
12
|
+
type: string;
|
|
13
|
+
};
|
|
14
|
+
method: {
|
|
15
|
+
type: string;
|
|
16
|
+
};
|
|
17
|
+
args: {
|
|
18
|
+
type: string;
|
|
19
|
+
};
|
|
20
|
+
};
|
|
21
|
+
required: string[];
|
|
22
|
+
};
|
|
23
|
+
};
|
|
24
|
+
due: {
|
|
25
|
+
description: string;
|
|
26
|
+
input: {
|
|
27
|
+
type: string;
|
|
28
|
+
properties: {
|
|
29
|
+
at: {
|
|
30
|
+
type: string;
|
|
31
|
+
};
|
|
32
|
+
};
|
|
33
|
+
required: string[];
|
|
34
|
+
};
|
|
35
|
+
};
|
|
36
|
+
now: {
|
|
37
|
+
description: string;
|
|
38
|
+
input: {
|
|
39
|
+
type: string;
|
|
40
|
+
};
|
|
41
|
+
};
|
|
42
|
+
};
|
|
43
|
+
get armed(): number | null;
|
|
44
|
+
due(args: JsonObject): JsonObject;
|
|
45
|
+
protected welcomed(id: string): Promise<void>;
|
|
46
|
+
rang(): Promise<Woke[]>;
|
|
47
|
+
fire(now: number): Promise<Woke[]>;
|
|
48
|
+
protected round(now: number, only?: string): Promise<Woke[]>;
|
|
49
|
+
protected arm(when: number | null): void;
|
|
50
|
+
stop(): void;
|
|
51
|
+
now(): JsonObject;
|
|
52
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { Faculty } from './faculty.js';
|
|
2
|
+
const MINUTE = 60_000;
|
|
3
|
+
// A timeout further out than this is not trusted, so the arming is capped and
|
|
4
|
+
// made again when it goes off. It is the base class's own bound and no rule
|
|
5
|
+
// of the faculty: a terrain whose alarm holds a month arms for the month.
|
|
6
|
+
const DAY = 86_400_000;
|
|
7
|
+
export class Timer extends Faculty {
|
|
8
|
+
static asks = {
|
|
9
|
+
watch: { description: 'wake me when I say I am next due', input: { type: 'object', properties: { invitation: { type: 'object' }, method: { type: 'string' }, args: { type: 'object' } }, required: ['invitation', 'method'] } },
|
|
10
|
+
due: { description: 'bring my next round forward to this moment', input: { type: 'object', properties: { at: { type: 'number' } }, required: ['at'] } },
|
|
11
|
+
now: { description: 'this box’s moment', input: { type: 'object' } },
|
|
12
|
+
};
|
|
13
|
+
#timeout;
|
|
14
|
+
#stopped = false;
|
|
15
|
+
#armed = null;
|
|
16
|
+
#round = null;
|
|
17
|
+
// The moment she is armed for, or null while she is asleep. For the
|
|
18
|
+
// device's own code and for a suite; it crosses no door.
|
|
19
|
+
get armed() {
|
|
20
|
+
return this.#armed;
|
|
21
|
+
}
|
|
22
|
+
// A watcher says she is due sooner than the box knew. It only ever brings
|
|
23
|
+
// the round forward: what fires is decided at the tick, in the watcher's
|
|
24
|
+
// own cells, so an arming that moved later on somebody's word would be a
|
|
25
|
+
// schedule somebody else held silenced. The door is the whole gate here:
|
|
26
|
+
// a standing at a faculty comes from `lend` alone, and this adds no second
|
|
27
|
+
// one.
|
|
28
|
+
due(args) {
|
|
29
|
+
const at = args.at;
|
|
30
|
+
if (typeof at !== 'number' || !Number.isFinite(at))
|
|
31
|
+
return { error: 'a due carries the moment: { at }' };
|
|
32
|
+
const when = moment(at);
|
|
33
|
+
if (this.#armed === null || when < this.#armed)
|
|
34
|
+
this.#arm(when);
|
|
35
|
+
return { armed: this.#armed };
|
|
36
|
+
}
|
|
37
|
+
// Whoever was just filed is ticked at once, and what she answers is what
|
|
38
|
+
// the box arms for. One move covers a box booting, a home moving in and a
|
|
39
|
+
// watcher born long after the box was up: she is never waiting for a round
|
|
40
|
+
// nobody scheduled.
|
|
41
|
+
async welcomed(id) {
|
|
42
|
+
await this.round(Date.now(), id);
|
|
43
|
+
}
|
|
44
|
+
// The arming went off. A moment still well ahead is the day's cap this
|
|
45
|
+
// class put on its own timeout, so it is armed again rather than fired: a
|
|
46
|
+
// terrain calls this and never has to know how far out the arming was.
|
|
47
|
+
async rang() {
|
|
48
|
+
if (this.#stopped)
|
|
49
|
+
return [];
|
|
50
|
+
if (this.#armed !== null && this.#armed - Date.now() > 1_000) {
|
|
51
|
+
this.arm(this.#armed);
|
|
52
|
+
return [];
|
|
53
|
+
}
|
|
54
|
+
return this.fire(Date.now());
|
|
55
|
+
}
|
|
56
|
+
// The moment, read here and handed on. A device's own code may call it at a
|
|
57
|
+
// moment it names, which is how a suite proves a wake without waiting for
|
|
58
|
+
// one and how an operator makes a round happen now.
|
|
59
|
+
fire(now) {
|
|
60
|
+
return this.round(now);
|
|
61
|
+
}
|
|
62
|
+
// One round, and never two: a second while one is in flight is handed the
|
|
63
|
+
// one already running. What every watcher answered decides the next
|
|
64
|
+
// arming, and a `due` that landed while the round ran is not lost.
|
|
65
|
+
round(now, only) {
|
|
66
|
+
if (this.#round)
|
|
67
|
+
return this.#round;
|
|
68
|
+
const mine = this.#run(now, only);
|
|
69
|
+
this.#round = mine;
|
|
70
|
+
return mine.finally(() => {
|
|
71
|
+
if (this.#round === mine)
|
|
72
|
+
this.#round = null;
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
async #run(now, only) {
|
|
76
|
+
if (only === undefined)
|
|
77
|
+
this.#arm(null); // the arming is spent, whether it went off or the device called this by hand
|
|
78
|
+
const woke = await this.woke((w) => ({ ...w.args, now }), only);
|
|
79
|
+
if (woke === null) {
|
|
80
|
+
this.stop(); // her ward took her out; there is nobody left to wake
|
|
81
|
+
return [];
|
|
82
|
+
}
|
|
83
|
+
// A moment already gone is no arming. Every watcher works out when she is
|
|
84
|
+
// next due from the moment she was handed, so a round fired by hand at a
|
|
85
|
+
// moment that is not this box's answers one that is not this box's
|
|
86
|
+
// either, and a box that armed for it would run a round for a minute long
|
|
87
|
+
// past. A round at the box's own moment always names one still ahead.
|
|
88
|
+
const named = soonest(woke);
|
|
89
|
+
const said = named !== null && named > Date.now() ? named : null;
|
|
90
|
+
const held = this.#armed;
|
|
91
|
+
const at = said === null ? held : held === null ? moment(said) : Math.min(held, moment(said));
|
|
92
|
+
if (at !== null && at !== this.#armed)
|
|
93
|
+
this.#arm(at);
|
|
94
|
+
else if (at === null && this.#armed !== null)
|
|
95
|
+
this.#arm(null);
|
|
96
|
+
return woke;
|
|
97
|
+
}
|
|
98
|
+
#arm(when) {
|
|
99
|
+
if (this.#stopped)
|
|
100
|
+
return;
|
|
101
|
+
this.#armed = when;
|
|
102
|
+
this.arm(when);
|
|
103
|
+
}
|
|
104
|
+
// The one thing a terrain says about itself: be woken at this moment, or at
|
|
105
|
+
// no moment at all. A subclass replaces this and nothing else of her.
|
|
106
|
+
//
|
|
107
|
+
// Here it is a timeout, capped at a day and armed again when the cap goes
|
|
108
|
+
// off. The box stays up for its work and never for this, so the handle is
|
|
109
|
+
// unreferenced.
|
|
110
|
+
arm(when) {
|
|
111
|
+
clearTimeout(this.#timeout);
|
|
112
|
+
this.#timeout = undefined;
|
|
113
|
+
if (when === null)
|
|
114
|
+
return;
|
|
115
|
+
this.#timeout = setTimeout(() => void this.rang(), Math.min(Math.max(when - Date.now(), 0), DAY));
|
|
116
|
+
this.#timeout.unref?.();
|
|
117
|
+
}
|
|
118
|
+
// A being her ward took out reaches nobody, so nothing is left to wake. The
|
|
119
|
+
// device calls this when it puts the box down.
|
|
120
|
+
stop() {
|
|
121
|
+
this.#stopped = true;
|
|
122
|
+
this.#armed = null;
|
|
123
|
+
this.arm(null);
|
|
124
|
+
}
|
|
125
|
+
now() {
|
|
126
|
+
return { now: Date.now() };
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
// Half a second past the minute a moment falls in, and never before the
|
|
130
|
+
// moment itself: a watcher floors what she is handed, so a round that fired a
|
|
131
|
+
// hair early would floor to the minute before and fire nothing.
|
|
132
|
+
const moment = (at) => Math.max(at, Math.floor(at / MINUTE) * MINUTE + 500);
|
|
133
|
+
// What one round learned. Every watcher says when she is next due, and the
|
|
134
|
+
// box arms for the earliest of them. A watcher who named no moment has
|
|
135
|
+
// nothing to be woken for, and so has one who answered something that is no
|
|
136
|
+
// tally at all.
|
|
137
|
+
function soonest(woke) {
|
|
138
|
+
let at = null;
|
|
139
|
+
for (const w of woke) {
|
|
140
|
+
if (w.out === null || typeof w.out !== 'object')
|
|
141
|
+
continue;
|
|
142
|
+
const next = w.out.next;
|
|
143
|
+
if (typeof next !== 'number' || !Number.isFinite(next))
|
|
144
|
+
continue;
|
|
145
|
+
if (at === null || next < at)
|
|
146
|
+
at = next;
|
|
147
|
+
}
|
|
148
|
+
return at;
|
|
149
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { Line, Ring } from './push.ts';
|
|
2
|
+
export type Vapid = {
|
|
3
|
+
key: JsonWebKey;
|
|
4
|
+
subject: string;
|
|
5
|
+
};
|
|
6
|
+
export declare function publicOf(key: JsonWebKey): string;
|
|
7
|
+
export declare function mintVapid(): Promise<{
|
|
8
|
+
key: JsonWebKey;
|
|
9
|
+
publicKey: string;
|
|
10
|
+
}>;
|
|
11
|
+
export declare function encrypt(line: Line, ua: Uint8Array, auth: Uint8Array): Promise<Uint8Array>;
|
|
12
|
+
export declare function webPush(v: Vapid): Ring;
|