@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/dock/grants.ts
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// The box's grants: `grants`, one faculty of the dock ward. A bearer to an
|
|
3
|
+
// identity in a world, honoured on every route alike. A route says the
|
|
4
|
+
// grammar a caller speaks, forms, tools or JSON, and a grant says who the
|
|
5
|
+
// caller is; no route holds a table of its own. She spans every world the
|
|
6
|
+
// box hosts, and a grant maps a token to an identity and nothing else: the
|
|
7
|
+
// credential is the invitation the avatar knocked with, spent once, and a
|
|
8
|
+
// grant only remembers which avatar a client speaks through. No secret of
|
|
9
|
+
// Quo's is ever here.
|
|
10
|
+
//
|
|
11
|
+
// Her tokens are her cells, kept by the dock ward's partition like every
|
|
12
|
+
// faculty's and by no file of her own, so a box has one place its state is
|
|
13
|
+
// and the harbor's one secret wraps them. Issuing, refreshing and revoking
|
|
14
|
+
// are the routes' to call on her in this process, as the daemon mints a
|
|
15
|
+
// nonce on `local`: none is an ask, none crosses a door, and she answers
|
|
16
|
+
// nothing at hers.
|
|
17
|
+
//
|
|
18
|
+
// A grant is issued by whoever finished a way in: the OAuth exchange on the
|
|
19
|
+
// model side, or the api route trading an invitation straight for tokens.
|
|
20
|
+
// An access token lives an hour and a refresh token thirty days, and
|
|
21
|
+
// refresh rotates. Revocation is the user being removing the occupant,
|
|
22
|
+
// and the route that hears `removed` asks for the grants to go with it.
|
|
23
|
+
import { Faculty } from './faculty.ts';
|
|
24
|
+
|
|
25
|
+
// The name the dock ward holds her under.
|
|
26
|
+
export const GRANTS = 'grants';
|
|
27
|
+
|
|
28
|
+
export const ACCESS_TTL = 60 * 60 * 1000; // an access token, one hour
|
|
29
|
+
export const REFRESH_TTL = 30 * 24 * 60 * 60 * 1000; // a refresh token, thirty days
|
|
30
|
+
|
|
31
|
+
// A token is a string the far side offers, and a bare lookup would find
|
|
32
|
+
// `__proto__` and hand back an object with no identity. Own keys only.
|
|
33
|
+
const own = <T>(rec: Record<string, T>, key: string): T | undefined => (Object.hasOwn(rec, key) ? rec[key] : undefined);
|
|
34
|
+
// Thirty-two random bytes as base64url, from the web crypto and the base64
|
|
35
|
+
// every runtime has, so nothing of node stands in a file a tab bundles or a
|
|
36
|
+
// worker imports. `Buffer` is not among those: a worker has one only when a
|
|
37
|
+
// deploy asks for node's shims, and the box's grants are the same code on
|
|
38
|
+
// every terrain.
|
|
39
|
+
const token = () => {
|
|
40
|
+
const bytes = globalThis.crypto.getRandomValues(new Uint8Array(32));
|
|
41
|
+
let binary = '';
|
|
42
|
+
for (const b of bytes) binary += String.fromCharCode(b);
|
|
43
|
+
return btoa(binary).replaceAll('+', '-').replaceAll('/', '_').replaceAll('=', '');
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
// A grant names the world the identity was let into, since one harbor holds
|
|
47
|
+
// many, and the client it was issued to: an OAuth client id, or the route
|
|
48
|
+
// that traded for it.
|
|
49
|
+
export type Grant = { identity: string; ward: string; client: string; exp: number };
|
|
50
|
+
type Kept = { access: Record<string, Grant>; refresh: Record<string, Grant> };
|
|
51
|
+
export type Tokens = { access_token: string; token_type: 'Bearer'; expires_in: number; refresh_token: string; scope: 'quo' };
|
|
52
|
+
|
|
53
|
+
export class Grants extends Faculty {
|
|
54
|
+
static override cells = { ...Faculty.cells, access: {}, refresh: {} };
|
|
55
|
+
static override asks = {};
|
|
56
|
+
|
|
57
|
+
// The ward's save, set by whoever holds her ward: a token issued is a
|
|
58
|
+
// cell written, and the routes answer with a token the partition keeps.
|
|
59
|
+
persist: () => Promise<void> = async () => {};
|
|
60
|
+
// The clock, a suite's to move.
|
|
61
|
+
now: () => number = () => Date.now();
|
|
62
|
+
|
|
63
|
+
get kept(): Kept {
|
|
64
|
+
return this.cells as unknown as Kept;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async issue(identity: string, ward: string, client: string): Promise<Tokens> {
|
|
68
|
+
const access_token = token(),
|
|
69
|
+
refresh_token = token();
|
|
70
|
+
this.kept.access[access_token] = { identity, ward, client, exp: this.now() + ACCESS_TTL };
|
|
71
|
+
this.kept.refresh[refresh_token] = { identity, ward, client, exp: this.now() + REFRESH_TTL };
|
|
72
|
+
this.sweep();
|
|
73
|
+
await this.persist();
|
|
74
|
+
return { access_token, token_type: 'Bearer', expires_in: ACCESS_TTL / 1000, refresh_token, scope: 'quo' };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Rotated: the old one is gone with the new one's birth. Null is a token
|
|
78
|
+
// nobody issued, or one that died.
|
|
79
|
+
async refresh(refresh_token: string): Promise<Tokens | null> {
|
|
80
|
+
const r = own(this.kept.refresh, refresh_token);
|
|
81
|
+
if (!r || r.exp < this.now()) return null;
|
|
82
|
+
delete this.kept.refresh[refresh_token];
|
|
83
|
+
return this.issue(r.identity, r.ward, r.client);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// The bearer on a request, to an identity in a world. Null is a stranger,
|
|
87
|
+
// or a token that died; a route tells the two apart by whether the
|
|
88
|
+
// request offered one at all.
|
|
89
|
+
bearer(req: Request): { identity: string; ward: string } | null {
|
|
90
|
+
const g = own(this.kept.access, offered(req));
|
|
91
|
+
return g && g.exp >= this.now() && typeof g.identity === 'string' ? { identity: g.identity, ward: g.ward } : null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Every grant an identity holds in a world, gone: the box's half of
|
|
95
|
+
// revocation. The other half is the user being removing the occupant.
|
|
96
|
+
async revoke(identity: string, ward: string): Promise<void> {
|
|
97
|
+
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];
|
|
98
|
+
await this.persist();
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
sweep(): void {
|
|
102
|
+
const now = this.now();
|
|
103
|
+
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];
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// What a request offered as its bearer, or nothing.
|
|
108
|
+
export const offered = (req: Request): string => {
|
|
109
|
+
const h = req.headers.get('authorization') ?? '';
|
|
110
|
+
return h.startsWith('Bearer ') ? h.slice(7) : '';
|
|
111
|
+
};
|
package/dock/http2.ts
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// Apple, from a Node box. The APNs provider API is HTTP/2 only and refuses
|
|
3
|
+
// HTTP/1.1, and Node's `fetch` speaks HTTP/1.1 alone, so a droplet cannot
|
|
4
|
+
// ring Apple through `apns.ts` at all. This is the same ring over
|
|
5
|
+
// `node:http2`: the same provider token, the same path, the same headers
|
|
6
|
+
// and the same body, which all live in `apns.ts` and are written once,
|
|
7
|
+
// because a phone that is rung two ways must be rung the same way twice.
|
|
8
|
+
//
|
|
9
|
+
// It is its own file for one reason, and the reason is the import. A worker
|
|
10
|
+
// bundle that pulled `node:http2` in would not build, so nothing terrain
|
|
11
|
+
// neutral may name this file: it is imported by the Node terrain and by
|
|
12
|
+
// nothing else, and it is not exported from `dock/index.ts`.
|
|
13
|
+
//
|
|
14
|
+
// A session is one TCP connection carrying every ring to one of Apple's two
|
|
15
|
+
// hosts, which is what HTTP/2 is for and what Apple asks of a provider that
|
|
16
|
+
// rings more than once. It is kept while it lives, made again when it dies,
|
|
17
|
+
// and unref'd, so an idle bell is not a process that will not exit.
|
|
18
|
+
import { connect, type ClientHttp2Session } from 'node:http2';
|
|
19
|
+
import type { Ring, Token } from './push.ts';
|
|
20
|
+
import { HOSTS, answered, provider, request, type Apns } from './apns.ts';
|
|
21
|
+
|
|
22
|
+
// What a Node box holds if it is a bell: the team's key from the portal and
|
|
23
|
+
// which app is being rung. A box holding none is a box that rings no
|
|
24
|
+
// iPhones, which is every box but a bell's.
|
|
25
|
+
export function apnsOf(env: Record<string, string | undefined>): Apns | null {
|
|
26
|
+
const { QUO_APNS_KEY: key, QUO_APNS_KEY_ID: keyId, QUO_APNS_TEAM: teamId, QUO_APNS_TOPIC: topic } = env;
|
|
27
|
+
return key && keyId && teamId && topic ? { key, keyId, teamId, topic } : null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// How long a ring waits on Apple before it is a ring that did not leave. A
|
|
31
|
+
// ring that hangs is worse than one that failed: the world behind it is
|
|
32
|
+
// holding an ask open, and a push that is late is a push that is wrong.
|
|
33
|
+
const WITHIN = 10_000;
|
|
34
|
+
|
|
35
|
+
export function apnsHttp2(a: Apns, at: typeof HOSTS = HOSTS, within = WITHIN): Ring {
|
|
36
|
+
let made: { at: number; jwt: string } | null = null;
|
|
37
|
+
const held = new Map<string, ClientHttp2Session>();
|
|
38
|
+
const session = (host: string): ClientHttp2Session => {
|
|
39
|
+
const live = held.get(host);
|
|
40
|
+
if (live && !live.closed && !live.destroyed) return live;
|
|
41
|
+
const s = connect(host);
|
|
42
|
+
const gone = () => {
|
|
43
|
+
if (held.get(host) === s) held.delete(host);
|
|
44
|
+
};
|
|
45
|
+
s.on('error', gone);
|
|
46
|
+
s.on('close', gone);
|
|
47
|
+
s.unref();
|
|
48
|
+
held.set(host, s);
|
|
49
|
+
return s;
|
|
50
|
+
};
|
|
51
|
+
return {
|
|
52
|
+
async ring(token: Token, sealed?: string) {
|
|
53
|
+
made = await provider(a, made);
|
|
54
|
+
const req = request(a, made.jwt, token, sealed);
|
|
55
|
+
const stream = session(token.sandbox === true ? at.sandbox : at.production).request({ ':method': 'POST', ':path': req.path, ...req.headers });
|
|
56
|
+
stream.setEncoding('utf8');
|
|
57
|
+
stream.setTimeout(within, () => stream.destroy(new Error('apns did not answer')));
|
|
58
|
+
const { status, text } = await new Promise<{ status: number; text: string }>((ok, no) => {
|
|
59
|
+
let status = 0;
|
|
60
|
+
let text = '';
|
|
61
|
+
stream.on('response', (h) => (status = Number(h[':status'] ?? 0)));
|
|
62
|
+
stream.on('data', (chunk: string) => (text += chunk));
|
|
63
|
+
stream.on('error', no);
|
|
64
|
+
stream.on('end', () => ok({ status, text }));
|
|
65
|
+
stream.end(req.body);
|
|
66
|
+
});
|
|
67
|
+
answered(status, text);
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
}
|
package/dock/index.ts
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// The dock ward: the ward a harbor boots and roots for itself, holding what
|
|
3
|
+
// the box can do. `stand` puts it up and answers the `lend` a harbor hands
|
|
4
|
+
// its other wards, which is the whole of what a terrain says about itself.
|
|
5
|
+
//
|
|
6
|
+
// Which names a box lends is the terrain's word; which ward may ask for one
|
|
7
|
+
// is the harbor's, decided in `lendFor`, and a ward whose code came from a
|
|
8
|
+
// stranger is lent nothing.
|
|
9
|
+
import type { Harbor, Lend } from '@nervur-org/nervur/harbor';
|
|
10
|
+
import type { JsonObject } from '@nervur-org/nervur';
|
|
11
|
+
import { isInvitation } from '@nervur-org/nervur';
|
|
12
|
+
import { Faculty } from './faculty.ts';
|
|
13
|
+
import { Console, CONSOLE } from './console.ts';
|
|
14
|
+
import { Local } from './local.ts';
|
|
15
|
+
import { Location } from './location.ts';
|
|
16
|
+
import { Notifications } from './notifications.ts';
|
|
17
|
+
import { Push } from './push.ts';
|
|
18
|
+
import { Timer } from './timer.ts';
|
|
19
|
+
|
|
20
|
+
export { Console, CONSOLE } from './console.ts';
|
|
21
|
+
export { Faculty, join, settled, type Watched, type Woke } from './faculty.ts';
|
|
22
|
+
export { Local, LOCAL } from './local.ts';
|
|
23
|
+
export { Location, type Where } from './location.ts';
|
|
24
|
+
export { Notifications } from './notifications.ts';
|
|
25
|
+
export { Push, isToken, bellOf, banner, sealBanner, SEAL, type Token, type Ring, type Line } from './push.ts';
|
|
26
|
+
export { Timer } from './timer.ts';
|
|
27
|
+
// The three grounds the push faculty rings through, and the bell service
|
|
28
|
+
// that holds a publisher's keys: what the box can do, beside the faculty.
|
|
29
|
+
export { webPush, mintVapid, publicOf, type Vapid } from './webpush.ts';
|
|
30
|
+
export { apnsPush, pkcs8, HOSTS, type Apns } from './apns.ts';
|
|
31
|
+
export { fcmPush, isAccount, DOORS, type Account, type Doors } from './fcm.ts';
|
|
32
|
+
export { handedTo, ringing, routed } from './service.ts';
|
|
33
|
+
|
|
34
|
+
// The names this package lends under. They are conventions of the dock and
|
|
35
|
+
// nothing of Quo's: a name is the harbor's namespace, and a being holds each
|
|
36
|
+
// one under an id of her own choosing.
|
|
37
|
+
export const LOCATION = 'location';
|
|
38
|
+
export const NOTIFICATIONS = 'notifications';
|
|
39
|
+
export const PUSH = 'push';
|
|
40
|
+
export const TIMER = 'timer';
|
|
41
|
+
|
|
42
|
+
// The classes every dock ward may hold, by class name. A terrain hands its
|
|
43
|
+
// own subclasses under the same names and the harbor holds those instead.
|
|
44
|
+
// `local` and `grants` are not among them: the box's way in and the box's
|
|
45
|
+
// grants stand only where a terrain serves routes, and that terrain adds
|
|
46
|
+
// them to what it lends. `Grants` is not in this file at all, so that a tab
|
|
47
|
+
// bundling this index carries no route's notion: the terrain that serves
|
|
48
|
+
// routes hands her class to its harbor, as the daemon hands its own push.
|
|
49
|
+
export const FACULTIES: Record<string, string> = { [LOCATION]: 'Location', [NOTIFICATIONS]: 'Notifications', [PUSH]: 'Push', [TIMER]: 'Timer' };
|
|
50
|
+
export const FACULTY_CLASSES = { Console, Local, Location, Notifications, Push, Timer };
|
|
51
|
+
|
|
52
|
+
// The name a harbor keeps its own ward under. It is a ward in every other
|
|
53
|
+
// respect, and this one is never adopted or dropped as a migration: its
|
|
54
|
+
// beings are the box's and die with it.
|
|
55
|
+
export const DOCK = 'dock';
|
|
56
|
+
|
|
57
|
+
// Put the dock ward up and answer the `lend` this harbor hands its wards.
|
|
58
|
+
// `lent` says which names this box offers and which class stands for each;
|
|
59
|
+
// a name it does not hold is null, and so is every other kind of no.
|
|
60
|
+
//
|
|
61
|
+
// The harbor keeps this ward's ask pointer rather than handing it out, so it
|
|
62
|
+
// is the root here, and the root may invite on any being of its ward. That
|
|
63
|
+
// is the whole of what `lend` does: it never mints, it asks its own root to.
|
|
64
|
+
export async function stand(harbor: Harbor, lent: Record<string, string> = FACULTIES): Promise<Lend> {
|
|
65
|
+
const kept = await harbor.store.load(DOCK);
|
|
66
|
+
const ward = harbor.wards.get(DOCK) ?? (kept ? await harbor.host(DOCK, kept) : await harbor.create(DOCK, CONSOLE));
|
|
67
|
+
return standOn(ward, lent);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// A ward as its root holds it: the ask pointer, and a save where the terrain
|
|
71
|
+
// keeps one. Every harbor here has both; a memory harbor has the first.
|
|
72
|
+
export type Rooted = { ask(method?: string, args?: Record<string, unknown>): Promise<unknown>; save?(): Promise<void> };
|
|
73
|
+
|
|
74
|
+
// The faculties up in a ward already made, and the `lend` that reaches them.
|
|
75
|
+
// Apart from `stand` so a suite can put its own stubs in its own ward and
|
|
76
|
+
// get the same thing a box gets. It is run again at every boot and says the
|
|
77
|
+
// same thing twice without harm: a key already booted is refused, and an
|
|
78
|
+
// invitation on an id already held is null.
|
|
79
|
+
export async function standOn(ward: Rooted, lent: Record<string, string> = FACULTIES): Promise<Lend> {
|
|
80
|
+
for (const [name, className] of Object.entries(lent)) await ward.ask('boot', { key: name, class: className });
|
|
81
|
+
// The box as one being, carrying what it stood. She is the page a device
|
|
82
|
+
// that has joined no world shows, and she is placed by the root because
|
|
83
|
+
// the root is the box: a standing at every faculty, minted and knocked in
|
|
84
|
+
// one move, as an owner places any relation into a being of its ward.
|
|
85
|
+
await ward.ask('boot', { key: CONSOLE, class: 'Console' });
|
|
86
|
+
for (const name of Object.keys(lent)) {
|
|
87
|
+
const inv = await ward.ask('invite', { being: name, id: `${CONSOLE}:${name}` });
|
|
88
|
+
if (!isInvitation(inv)) continue;
|
|
89
|
+
await ward.ask('knock', { being: CONSOLE, id: name, invitation: inv as unknown as JsonObject });
|
|
90
|
+
}
|
|
91
|
+
await ward.save?.();
|
|
92
|
+
return lending(ward);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// The root's half alone: a name and a taker in, whether the ward took it out.
|
|
96
|
+
// It never mints; it asks the root of the ward it holds to, which is what a
|
|
97
|
+
// root may do on any being of its ward. And it unmints: a taker that would
|
|
98
|
+
// not take leaves an occupant on that faculty which nobody holds, so the root
|
|
99
|
+
// removes it before answering, and a refused lend costs the box nothing.
|
|
100
|
+
//
|
|
101
|
+
// Any being of the dock ward is lent by her key, not only the ones a terrain
|
|
102
|
+
// stood: the faculties the dock ships are examples, and an estate boots its
|
|
103
|
+
// own into the dock ward, of a class in its own `classes/`, and lends it the
|
|
104
|
+
// same way. A name nobody there answers to is refused at the invite.
|
|
105
|
+
//
|
|
106
|
+
// The id is drawn and never counted. A faculty's occupants are in her cells
|
|
107
|
+
// and outlive the process, so a count that started again at every boot would
|
|
108
|
+
// name, on the first lend after a restart, an id she already holds, and the
|
|
109
|
+
// invite would be refused for the rest of the box's life.
|
|
110
|
+
export function lending(ward: Rooted): Lend {
|
|
111
|
+
return async (name, take) => {
|
|
112
|
+
const id = `lent:${fresh()}`;
|
|
113
|
+
const out = await ward.ask('invite', { being: name, id });
|
|
114
|
+
if (!isInvitation(out)) return false;
|
|
115
|
+
const took = await take(out);
|
|
116
|
+
if (!took) await ward.ask('remove', { being: name, id });
|
|
117
|
+
await ward.save?.();
|
|
118
|
+
return took;
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const fresh = (): string =>
|
|
123
|
+
Array.from(globalThis.crypto.getRandomValues(new Uint8Array(8)))
|
|
124
|
+
.map((b) => b.toString(16).padStart(2, '0'))
|
|
125
|
+
.join('');
|
|
126
|
+
|
|
127
|
+
// One faculty of this box, as the device's own code holds it: the object the
|
|
128
|
+
// harbor made, in this process. Not a path around a door, and nothing of it
|
|
129
|
+
// crosses an edge; it is how a device fires its own timer at a moment it
|
|
130
|
+
// names and how a suite reads what a stub recorded.
|
|
131
|
+
export function facultyOf<F extends Faculty>(harbor: Harbor, name: string): F | undefined {
|
|
132
|
+
return harbor.wards.get(DOCK)?.being(name) as F | undefined;
|
|
133
|
+
}
|
package/dock/local.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// The box's way in: `local`, one faculty of the dock ward. The daemon saw a
|
|
3
|
+
// process reach a socket only the device's user can open, and that is the
|
|
4
|
+
// whole of the vouch; file permissions are the device's rules, which is what
|
|
5
|
+
// `quo/SPEC.md` allows a harbor to hand the root by.
|
|
6
|
+
//
|
|
7
|
+
// She holds the nonces this box minted, in this process and never in cells:
|
|
8
|
+
// a vouch that outlived a restart would be a vouch for a process that is
|
|
9
|
+
// gone. Each is honoured once, and a `local` proof arriving any other way
|
|
10
|
+
// names no nonce and is refused.
|
|
11
|
+
//
|
|
12
|
+
// The device's own code mints on her directly, as a device fires its own
|
|
13
|
+
// timer: `mint` is not an ask, it crosses no door, and nothing at a door
|
|
14
|
+
// can reach it. The desk asks `verify` at her standing, as she asks any way
|
|
15
|
+
// in.
|
|
16
|
+
import { Faculty } from './faculty.ts';
|
|
17
|
+
import type { JsonObject } from '@nervur-org/nervur';
|
|
18
|
+
import { VERIFY, type Verified } from '../beings/way.ts';
|
|
19
|
+
|
|
20
|
+
// The name the dock ward lends her under. A box with no local side lends no
|
|
21
|
+
// such name, and the desk there holds no such standing.
|
|
22
|
+
export const LOCAL = 'local';
|
|
23
|
+
|
|
24
|
+
export class Local extends Faculty {
|
|
25
|
+
static override asks = { verify: VERIFY };
|
|
26
|
+
|
|
27
|
+
readonly #nonces = new Map<string, Verified>();
|
|
28
|
+
|
|
29
|
+
// A nonce for one client this box saw itself, spent by the first proof
|
|
30
|
+
// that names it. The caller drops it when the admit is over: a nonce that
|
|
31
|
+
// was never spent is not a way in that stays open.
|
|
32
|
+
mint(nonce: string, who: Verified): void {
|
|
33
|
+
this.#nonces.set(nonce, who);
|
|
34
|
+
}
|
|
35
|
+
drop(nonce: string): void {
|
|
36
|
+
this.#nonces.delete(nonce);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
verify(args: JsonObject): Verified | null {
|
|
40
|
+
const proof = args.proof;
|
|
41
|
+
if (!proof || typeof proof !== 'object' || Array.isArray(proof)) return null;
|
|
42
|
+
const nonce = (proof as JsonObject).nonce;
|
|
43
|
+
const who = typeof nonce === 'string' ? this.#nonces.get(nonce) : undefined;
|
|
44
|
+
if (!who) return null;
|
|
45
|
+
this.#nonces.delete(nonce as string);
|
|
46
|
+
return who;
|
|
47
|
+
}
|
|
48
|
+
}
|
package/dock/location.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// Where the box is. One blueprint, and as many bodies as there are terrains:
|
|
3
|
+
// a phone's plugin, a laptop's platform call, a suite's stub. This is the
|
|
4
|
+
// blueprint and the answer a box that cannot gives, and a terrain that can
|
|
5
|
+
// subclasses it. A box with no way at all boots her not at all, and a being
|
|
6
|
+
// who asks for the name is lent nothing.
|
|
7
|
+
import type { JsonObject } from '@nervur-org/nervur';
|
|
8
|
+
import { Faculty } from './faculty.ts';
|
|
9
|
+
|
|
10
|
+
export type Where = { lat: number; lng: number; accuracy?: number; at: number };
|
|
11
|
+
|
|
12
|
+
export class Location extends Faculty {
|
|
13
|
+
static override asks = {
|
|
14
|
+
where: { description: 'where this box is', input: { type: 'object' } },
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
// The platform's own permission prompt is the first ask's, and a refusal is
|
|
18
|
+
// the error object every ask after it answers.
|
|
19
|
+
where(): Promise<JsonObject> {
|
|
20
|
+
return Promise.resolve({ error: 'this box cannot say where it is' });
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// The box's notification center: show the human a line. One blueprint, and a
|
|
3
|
+
// body per terrain. The platform's own permission prompt is the first ask's,
|
|
4
|
+
// and a refusal is the error object every ask after it answers.
|
|
5
|
+
//
|
|
6
|
+
// She also keeps the banner key, which is the one key a world seals a
|
|
7
|
+
// notification to. It is the device's, one pair, minted here the first time
|
|
8
|
+
// it is asked for and kept in her cells; the public half is what a device
|
|
9
|
+
// hands a world it admits, and the secret half never leaves the box.
|
|
10
|
+
//
|
|
11
|
+
// It sits here and not in a being of the library's for one reason. On a
|
|
12
|
+
// phone the process that draws a banner is not the app: an extension runs
|
|
13
|
+
// between the platform and the screen, and it must be able to open the seal
|
|
14
|
+
// before anything is shown. So the secret has to reach wherever that
|
|
15
|
+
// terrain's notification process can read it, and putting a key somewhere a
|
|
16
|
+
// platform can find it is the terrain's business, which is what a faculty
|
|
17
|
+
// is. A terrain overrides `plant` and does it its own way; a terrain that
|
|
18
|
+
// has no second process does nothing and the key never leaves these cells.
|
|
19
|
+
//
|
|
20
|
+
// It is not the ward's seed. That one opens everything the person sees and
|
|
21
|
+
// is kept so that it never leaves the device at all. This one opens
|
|
22
|
+
// banners, and if the process that draws them were ever turned against her
|
|
23
|
+
// it would cost her a line of text rather than her world.
|
|
24
|
+
import { arithmetic } from '@nervur-org/nervur/ward';
|
|
25
|
+
import type { JsonObject } from '@nervur-org/nervur';
|
|
26
|
+
import { Faculty } from './faculty.ts';
|
|
27
|
+
|
|
28
|
+
// A key is thirty two bytes of the platform's own randomness, as every seed
|
|
29
|
+
// in this kit is.
|
|
30
|
+
const seed = (): Uint8Array => globalThis.crypto.getRandomValues(new Uint8Array(32));
|
|
31
|
+
|
|
32
|
+
export class Notifications extends Faculty {
|
|
33
|
+
// The banner key, as hex: the seed it was made from, kept so the pair can
|
|
34
|
+
// be made again, and the public half a device hands out.
|
|
35
|
+
static override cells: JsonObject = { watched: {}, minted: 0, banner: null };
|
|
36
|
+
static override asks = {
|
|
37
|
+
notify: { description: 'show the human a line', input: { type: 'object', properties: { title: { type: 'string' }, body: { type: 'string' } }, required: ['title'] } },
|
|
38
|
+
banner: { description: "this device's banner key, the public half, which a world seals a notification to", input: { type: 'object' } },
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
notify(_args: JsonObject): Promise<JsonObject> {
|
|
42
|
+
return Promise.resolve({ error: 'this box shows no notifications' });
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// The pair, minted once and kept. Every call after the first is the same
|
|
46
|
+
// key, because a device that minted a new one on every ask would be a
|
|
47
|
+
// device whose worlds all seal to a key it has forgotten.
|
|
48
|
+
async key(): Promise<{ secret: Uint8Array; pk: Uint8Array }> {
|
|
49
|
+
if (typeof this.cells.banner !== 'string') {
|
|
50
|
+
this.cells.banner = arithmetic.hex(seed());
|
|
51
|
+
await this.plant(await arithmetic.sealingPair(arithmetic.unhex(this.cells.banner)));
|
|
52
|
+
}
|
|
53
|
+
return arithmetic.sealingPair(arithmetic.unhex(this.cells.banner as string));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async banner(_args: JsonObject): Promise<JsonObject> {
|
|
57
|
+
return { banner: arithmetic.hex((await this.key()).pk) };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Where this terrain's notification process reads the secret half. The
|
|
61
|
+
// base plants nothing, which is right for a terrain that draws its own
|
|
62
|
+
// banners in the same process; a phone overrides it and writes into the
|
|
63
|
+
// keychain group its extension shares with the app.
|
|
64
|
+
protected plant(_pair: { secret: Uint8Array; pk: Uint8Array }): Promise<void> {
|
|
65
|
+
return Promise.resolve();
|
|
66
|
+
}
|
|
67
|
+
}
|
package/dock/push.ts
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
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 type { Asker, JsonObject, OccupantRecord } from '@nervur-org/nervur';
|
|
18
|
+
import { Faculty } from './faculty.ts';
|
|
19
|
+
import { mintVapid } from './webpush.ts';
|
|
20
|
+
|
|
21
|
+
// What a device hands a world so it can be woken, kept in that device's
|
|
22
|
+
// occupant notes and re-sent whenever the device comes to the foreground,
|
|
23
|
+
// since a push service may hand a device a new one at any time. A token of
|
|
24
|
+
// a store's kind names its bell, the address of the service holding the
|
|
25
|
+
// keys of the app that minted it, and that app, so a world rings a phone
|
|
26
|
+
// running an app it never shipped and holds no key; Web Push is the
|
|
27
|
+
// world's own key and names no service.
|
|
28
|
+
export type Token = { kind: string; bell?: string; app?: string } & JsonObject;
|
|
29
|
+
export const isToken = (v: unknown): v is Token => v !== null && typeof v === 'object' && !Array.isArray(v) && typeof (v as { kind?: unknown }).kind === 'string';
|
|
30
|
+
|
|
31
|
+
// The bell a token names, when it names one a ring can reach.
|
|
32
|
+
export function bellOf(token: Token): string | undefined {
|
|
33
|
+
if (typeof token.bell !== 'string') return undefined;
|
|
34
|
+
try {
|
|
35
|
+
const u = new URL(token.bell);
|
|
36
|
+
return u.protocol === 'https:' || u.protocol === 'http:' ? token.bell : undefined;
|
|
37
|
+
} catch {
|
|
38
|
+
return undefined;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// One platform's way of ringing. It is handed the token whole, because only
|
|
43
|
+
// the platform knows what is in one, and it says nothing back: a ring either
|
|
44
|
+
// left or threw.
|
|
45
|
+
//
|
|
46
|
+
// `sealed` is the banner, sealed to the device's banner key by the world
|
|
47
|
+
// that rang. Nothing between the world and the device can read it: not this
|
|
48
|
+
// box, not the bell service, not the platform that carries it. A ring with
|
|
49
|
+
// neither a seal nor a line wakes the device and says nothing, which is
|
|
50
|
+
// what a world with nothing to show sends.
|
|
51
|
+
//
|
|
52
|
+
// `line` is the same banner, unsealed, and only a ground whose own
|
|
53
|
+
// encryption the world performs may take one: Web Push, where RFC 8291
|
|
54
|
+
// encrypts the body to keys only that browser holds and the world holds the
|
|
55
|
+
// keypair the subscription was made under. A store's ground is handed the
|
|
56
|
+
// seal and never this, because what carries it is a service the world does
|
|
57
|
+
// not own.
|
|
58
|
+
export type Line = { title: string; body: string };
|
|
59
|
+
export type Ring = { ring(token: Token, sealed?: string, line?: Line): Promise<void> };
|
|
60
|
+
|
|
61
|
+
// What a platform will carry, so a seal too big is refused where a human
|
|
62
|
+
// can still read why rather than two hops later by a service nobody reads.
|
|
63
|
+
export const SEAL = 4096;
|
|
64
|
+
|
|
65
|
+
// The line a device shows when it was woken while nobody was looking,
|
|
66
|
+
// composed from one object. A pushed object is whatever the far being sent,
|
|
67
|
+
// so the two names a notification has are taken when they are there and the
|
|
68
|
+
// object is shown as it is when they are not.
|
|
69
|
+
export function banner(o: JsonObject): Line {
|
|
70
|
+
const text = (v: unknown) => (typeof v === 'string' && v.trim() ? v.trim() : '');
|
|
71
|
+
const title = text(o.title) || text(o.from) || 'quo';
|
|
72
|
+
const body =
|
|
73
|
+
text(o.body) ||
|
|
74
|
+
text(o.said) ||
|
|
75
|
+
Object.entries(o)
|
|
76
|
+
.filter(([k]) => k !== 'title' && k !== 'from')
|
|
77
|
+
.map(([k, v]) => `${k}: ${typeof v === 'string' ? v : JSON.stringify(v)}`)
|
|
78
|
+
.join(', ');
|
|
79
|
+
return { title, body };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// A banner sealed to a device's banner key, as hex, or null when it will
|
|
83
|
+
// not seal or will not fit. The world seals and the process that draws the
|
|
84
|
+
// banner opens it; no doorbell, no bell service and no platform between
|
|
85
|
+
// them holds either half, which is why a ring may carry a line at all.
|
|
86
|
+
export async function sealBanner(line: JsonObject, key: string): Promise<string | null> {
|
|
87
|
+
if (!/^[0-9a-f]{64}$/.test(key)) return null;
|
|
88
|
+
try {
|
|
89
|
+
const boxed = await arithmetic.box(new TextEncoder().encode(JSON.stringify(line)), arithmetic.unhex(key), globalThis.crypto.getRandomValues(new Uint8Array(32)));
|
|
90
|
+
const wire = arithmetic.hex(boxed.bytes);
|
|
91
|
+
return wire.length > SEAL ? null : wire;
|
|
92
|
+
} catch {
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// The root and nobody else: where a box hands a phone's ring is the box's own.
|
|
98
|
+
const root = (_occ: OccupantRecord | undefined, asker: Asker) => asker.id === OWNER;
|
|
99
|
+
|
|
100
|
+
export class Push extends Faculty {
|
|
101
|
+
// What she keeps, beside every faculty's: the world's doorbell key, a P-256
|
|
102
|
+
// JWK she mints herself the first time a terrain asks for one, so a Web
|
|
103
|
+
// Push is signed under a key that lives with the wards and in no file; and
|
|
104
|
+
// the bell service a phone's ring is handed to when its token names none,
|
|
105
|
+
// a row the root keeps, since whose service it is belongs to an estate.
|
|
106
|
+
static override cells: JsonObject = { watched: {}, minted: 0, key: null, service: null };
|
|
107
|
+
static override asks = {
|
|
108
|
+
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'] } },
|
|
109
|
+
kinds: { description: 'which kinds this box can ring', input: { type: 'object' } },
|
|
110
|
+
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 },
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
// The doorbell key, minted once and kept. A terrain asks for it when it
|
|
114
|
+
// serves a web route; a box that never does holds none and rings no tabs.
|
|
115
|
+
async mint(): Promise<JsonWebKey> {
|
|
116
|
+
if (this.cells.key === null) this.cells.key = (await mintVapid()).key as unknown as JsonObject;
|
|
117
|
+
return this.cells.key as unknown as JsonWebKey;
|
|
118
|
+
}
|
|
119
|
+
get key(): JsonWebKey | null {
|
|
120
|
+
return this.cells.key as unknown as JsonWebKey | null;
|
|
121
|
+
}
|
|
122
|
+
get service(): string | undefined {
|
|
123
|
+
return typeof this.cells.service === 'string' ? this.cells.service : undefined;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
keep(args: JsonObject): JsonObject {
|
|
127
|
+
const service = args.service;
|
|
128
|
+
if (service !== null && (typeof service !== 'string' || !/^https?:\/\/\S+$/.test(service))) return { error: 'a bell service is a URL over http or https, or null' };
|
|
129
|
+
this.cells.service = service === null ? null : service.replace(/\/$/, '');
|
|
130
|
+
return { service: this.cells.service };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// What this box can ring. A terrain overrides it and builds its rings from
|
|
134
|
+
// its environment and this being's cells; the base rings nothing.
|
|
135
|
+
protected get rings(): Readonly<Record<string, Ring>> {
|
|
136
|
+
return {};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
kinds(): JsonObject {
|
|
140
|
+
return { kinds: Object.keys(this.rings) };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async ring(args: JsonObject): Promise<JsonObject> {
|
|
144
|
+
const token = args.token;
|
|
145
|
+
if (!isToken(token)) return { error: 'a token names its kind' };
|
|
146
|
+
const sealed = args.sealed;
|
|
147
|
+
if (sealed !== undefined && typeof sealed !== 'string') return { error: 'a seal is text' };
|
|
148
|
+
if (typeof sealed === 'string' && sealed.length > SEAL) return { error: `a seal is at most ${SEAL} bytes` };
|
|
149
|
+
const said = args.line;
|
|
150
|
+
if (said !== undefined && (said === null || typeof said !== 'object' || Array.isArray(said) || typeof said.title !== 'string' || typeof said.body !== 'string')) return { error: 'a line is a title and a body' };
|
|
151
|
+
const line = said as Line | undefined;
|
|
152
|
+
const r = Object.hasOwn(this.rings, token.kind) ? this.rings[token.kind] : undefined;
|
|
153
|
+
if (!r) return { error: `this box cannot ring a ${token.kind}` };
|
|
154
|
+
try {
|
|
155
|
+
await r.ring(token, sealed, line);
|
|
156
|
+
return { rang: token.kind };
|
|
157
|
+
} catch (e) {
|
|
158
|
+
return { error: e instanceof Error ? e.message : 'the ring threw' };
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|