@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/dist/dock/fcm.js
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
const subtle = () => globalThis.crypto.subtle;
|
|
2
|
+
const RS256 = { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' };
|
|
3
|
+
const b64url = (bytes) => {
|
|
4
|
+
let binary = '';
|
|
5
|
+
for (const b of bytes)
|
|
6
|
+
binary += String.fromCharCode(b);
|
|
7
|
+
return btoa(binary).replaceAll('+', '-').replaceAll('/', '_').replaceAll('=', '');
|
|
8
|
+
};
|
|
9
|
+
const utf8 = (v) => new TextEncoder().encode(v);
|
|
10
|
+
const der = (pem) => {
|
|
11
|
+
const body = pem.replace(/-----(BEGIN|END)[^-]*-----/g, '').replace(/\s+/g, '');
|
|
12
|
+
if (!body)
|
|
13
|
+
throw new Error('a service account key is a PKCS#8 PEM');
|
|
14
|
+
const binary = atob(body);
|
|
15
|
+
const out = new Uint8Array(binary.length);
|
|
16
|
+
for (let at = 0; at < binary.length; at += 1)
|
|
17
|
+
out[at] = binary.charCodeAt(at);
|
|
18
|
+
return out;
|
|
19
|
+
};
|
|
20
|
+
export function isAccount(v) {
|
|
21
|
+
const a = v;
|
|
22
|
+
return a !== null && typeof a === 'object' && typeof a.project_id === 'string' && typeof a.client_email === 'string' && typeof a.private_key === 'string';
|
|
23
|
+
}
|
|
24
|
+
export const DOORS = { oauth: 'https://oauth2.googleapis.com/token', fcm: 'https://fcm.googleapis.com' };
|
|
25
|
+
const SCOPE = 'https://www.googleapis.com/auth/firebase.messaging';
|
|
26
|
+
const LIFE = 59 * 60 * 1000;
|
|
27
|
+
async function access(a, at, held) {
|
|
28
|
+
const now = Date.now();
|
|
29
|
+
if (held && now - held.at < LIFE)
|
|
30
|
+
return held;
|
|
31
|
+
const key = await subtle().importKey('pkcs8', der(a.private_key), RS256, false, ['sign']);
|
|
32
|
+
const iat = Math.floor(now / 1000);
|
|
33
|
+
const head = b64url(utf8(JSON.stringify({ alg: 'RS256', typ: 'JWT' })));
|
|
34
|
+
const claims = b64url(utf8(JSON.stringify({ iss: a.client_email, scope: SCOPE, aud: at.oauth, iat, exp: iat + 3600 })));
|
|
35
|
+
const signed = await subtle().sign(RS256, key, utf8(`${head}.${claims}`));
|
|
36
|
+
const assertion = `${head}.${claims}.${b64url(new Uint8Array(signed))}`;
|
|
37
|
+
const res = await fetch(at.oauth, {
|
|
38
|
+
method: 'POST',
|
|
39
|
+
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
|
40
|
+
body: new URLSearchParams({ grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer', assertion }).toString(),
|
|
41
|
+
});
|
|
42
|
+
if (!res.ok)
|
|
43
|
+
throw new Error(`google would not exchange the key: ${res.status} ${(await res.text()).slice(0, 200)}`);
|
|
44
|
+
const out = (await res.json());
|
|
45
|
+
if (typeof out.access_token !== 'string')
|
|
46
|
+
throw new Error('google answered no access token');
|
|
47
|
+
return { at: now, token: out.access_token };
|
|
48
|
+
}
|
|
49
|
+
// The ground. An `fcm` token is the registration token the device was
|
|
50
|
+
// handed by Google's own sdk, which is opaque and is not inspected here
|
|
51
|
+
// beyond being a string with something in it.
|
|
52
|
+
export function fcmPush(a, at = DOORS) {
|
|
53
|
+
let held = null;
|
|
54
|
+
return {
|
|
55
|
+
async ring(token, sealed) {
|
|
56
|
+
const device = token.device;
|
|
57
|
+
if (typeof device !== 'string' || device.length < 8)
|
|
58
|
+
throw new Error('an fcm token is a registration token');
|
|
59
|
+
held = await access(a, at, held);
|
|
60
|
+
// The seal rides in the data map, as it rides in the payload on the
|
|
61
|
+
// other ground, so what Google carries is ciphertext and the line is
|
|
62
|
+
// drawn on the device by the service that opens it. A ring with no
|
|
63
|
+
// seal carries an empty map and is a wake and nothing more, which is
|
|
64
|
+
// what a device that draws no banner still needs.
|
|
65
|
+
const res = await fetch(`${at.fcm}/v1/projects/${a.project_id}/messages:send`, {
|
|
66
|
+
method: 'POST',
|
|
67
|
+
headers: { authorization: `Bearer ${held.token}`, 'content-type': 'application/json' },
|
|
68
|
+
body: JSON.stringify({ message: { token: device, android: { priority: 'HIGH' }, data: sealed === undefined ? {} : { sealed } } }),
|
|
69
|
+
});
|
|
70
|
+
// 404 is Google saying this registration token is gone. Nothing here
|
|
71
|
+
// forgets it, for the reason the other two grounds do not: a device
|
|
72
|
+
// sends a fresh one whenever it comes to the foreground.
|
|
73
|
+
if (!res.ok)
|
|
74
|
+
throw new Error(`fcm answered ${res.status} ${(await res.text()).slice(0, 200)}`);
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { Faculty } from './faculty.ts';
|
|
2
|
+
export declare const GRANTS = "grants";
|
|
3
|
+
export declare const ACCESS_TTL: number;
|
|
4
|
+
export declare const REFRESH_TTL: number;
|
|
5
|
+
export type Grant = {
|
|
6
|
+
identity: string;
|
|
7
|
+
ward: string;
|
|
8
|
+
client: string;
|
|
9
|
+
exp: number;
|
|
10
|
+
};
|
|
11
|
+
type Kept = {
|
|
12
|
+
access: Record<string, Grant>;
|
|
13
|
+
refresh: Record<string, Grant>;
|
|
14
|
+
};
|
|
15
|
+
export type Tokens = {
|
|
16
|
+
access_token: string;
|
|
17
|
+
token_type: 'Bearer';
|
|
18
|
+
expires_in: number;
|
|
19
|
+
refresh_token: string;
|
|
20
|
+
scope: 'quo';
|
|
21
|
+
};
|
|
22
|
+
export declare class Grants extends Faculty {
|
|
23
|
+
static cells: {
|
|
24
|
+
access: {};
|
|
25
|
+
refresh: {};
|
|
26
|
+
};
|
|
27
|
+
static asks: {};
|
|
28
|
+
persist: () => Promise<void>;
|
|
29
|
+
now: () => number;
|
|
30
|
+
get kept(): Kept;
|
|
31
|
+
issue(identity: string, ward: string, client: string): Promise<Tokens>;
|
|
32
|
+
refresh(refresh_token: string): Promise<Tokens | null>;
|
|
33
|
+
bearer(req: Request): {
|
|
34
|
+
identity: string;
|
|
35
|
+
ward: string;
|
|
36
|
+
} | null;
|
|
37
|
+
revoke(identity: string, ward: string): Promise<void>;
|
|
38
|
+
sweep(): void;
|
|
39
|
+
}
|
|
40
|
+
export declare const offered: (req: Request) => string;
|
|
41
|
+
export {};
|
|
@@ -0,0 +1,99 @@
|
|
|
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.js';
|
|
24
|
+
// The name the dock ward holds her under.
|
|
25
|
+
export const GRANTS = 'grants';
|
|
26
|
+
export const ACCESS_TTL = 60 * 60 * 1000; // an access token, one hour
|
|
27
|
+
export const REFRESH_TTL = 30 * 24 * 60 * 60 * 1000; // a refresh token, thirty days
|
|
28
|
+
// A token is a string the far side offers, and a bare lookup would find
|
|
29
|
+
// `__proto__` and hand back an object with no identity. Own keys only.
|
|
30
|
+
const own = (rec, key) => (Object.hasOwn(rec, key) ? rec[key] : undefined);
|
|
31
|
+
// Thirty-two random bytes as base64url, from the web crypto and the base64
|
|
32
|
+
// every runtime has, so nothing of node stands in a file a tab bundles or a
|
|
33
|
+
// worker imports. `Buffer` is not among those: a worker has one only when a
|
|
34
|
+
// deploy asks for node's shims, and the box's grants are the same code on
|
|
35
|
+
// every terrain.
|
|
36
|
+
const token = () => {
|
|
37
|
+
const bytes = globalThis.crypto.getRandomValues(new Uint8Array(32));
|
|
38
|
+
let binary = '';
|
|
39
|
+
for (const b of bytes)
|
|
40
|
+
binary += String.fromCharCode(b);
|
|
41
|
+
return btoa(binary).replaceAll('+', '-').replaceAll('/', '_').replaceAll('=', '');
|
|
42
|
+
};
|
|
43
|
+
export class Grants extends Faculty {
|
|
44
|
+
static cells = { ...Faculty.cells, access: {}, refresh: {} };
|
|
45
|
+
static asks = {};
|
|
46
|
+
// The ward's save, set by whoever holds her ward: a token issued is a
|
|
47
|
+
// cell written, and the routes answer with a token the partition keeps.
|
|
48
|
+
persist = async () => { };
|
|
49
|
+
// The clock, a suite's to move.
|
|
50
|
+
now = () => Date.now();
|
|
51
|
+
get kept() {
|
|
52
|
+
return this.cells;
|
|
53
|
+
}
|
|
54
|
+
async issue(identity, ward, client) {
|
|
55
|
+
const access_token = token(), refresh_token = token();
|
|
56
|
+
this.kept.access[access_token] = { identity, ward, client, exp: this.now() + ACCESS_TTL };
|
|
57
|
+
this.kept.refresh[refresh_token] = { identity, ward, client, exp: this.now() + REFRESH_TTL };
|
|
58
|
+
this.sweep();
|
|
59
|
+
await this.persist();
|
|
60
|
+
return { access_token, token_type: 'Bearer', expires_in: ACCESS_TTL / 1000, refresh_token, scope: 'quo' };
|
|
61
|
+
}
|
|
62
|
+
// Rotated: the old one is gone with the new one's birth. Null is a token
|
|
63
|
+
// nobody issued, or one that died.
|
|
64
|
+
async refresh(refresh_token) {
|
|
65
|
+
const r = own(this.kept.refresh, refresh_token);
|
|
66
|
+
if (!r || r.exp < this.now())
|
|
67
|
+
return null;
|
|
68
|
+
delete this.kept.refresh[refresh_token];
|
|
69
|
+
return this.issue(r.identity, r.ward, r.client);
|
|
70
|
+
}
|
|
71
|
+
// The bearer on a request, to an identity in a world. Null is a stranger,
|
|
72
|
+
// or a token that died; a route tells the two apart by whether the
|
|
73
|
+
// request offered one at all.
|
|
74
|
+
bearer(req) {
|
|
75
|
+
const g = own(this.kept.access, offered(req));
|
|
76
|
+
return g && g.exp >= this.now() && typeof g.identity === 'string' ? { identity: g.identity, ward: g.ward } : null;
|
|
77
|
+
}
|
|
78
|
+
// Every grant an identity holds in a world, gone: the box's half of
|
|
79
|
+
// revocation. The other half is the user being removing the occupant.
|
|
80
|
+
async revoke(identity, ward) {
|
|
81
|
+
for (const k of ['access', 'refresh'])
|
|
82
|
+
for (const [t, g] of Object.entries(this.kept[k]))
|
|
83
|
+
if (g.identity === identity && g.ward === ward)
|
|
84
|
+
delete this.kept[k][t];
|
|
85
|
+
await this.persist();
|
|
86
|
+
}
|
|
87
|
+
sweep() {
|
|
88
|
+
const now = this.now();
|
|
89
|
+
for (const k of ['access', 'refresh'])
|
|
90
|
+
for (const [t, g] of Object.entries(this.kept[k]))
|
|
91
|
+
if (!(g.exp >= now))
|
|
92
|
+
delete this.kept[k][t];
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
// What a request offered as its bearer, or nothing.
|
|
96
|
+
export const offered = (req) => {
|
|
97
|
+
const h = req.headers.get('authorization') ?? '';
|
|
98
|
+
return h.startsWith('Bearer ') ? h.slice(7) : '';
|
|
99
|
+
};
|
|
@@ -0,0 +1,68 @@
|
|
|
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 } from 'node:http2';
|
|
19
|
+
import { HOSTS, answered, provider, request } from './apns.js';
|
|
20
|
+
// What a Node box holds if it is a bell: the team's key from the portal and
|
|
21
|
+
// which app is being rung. A box holding none is a box that rings no
|
|
22
|
+
// iPhones, which is every box but a bell's.
|
|
23
|
+
export function apnsOf(env) {
|
|
24
|
+
const { QUO_APNS_KEY: key, QUO_APNS_KEY_ID: keyId, QUO_APNS_TEAM: teamId, QUO_APNS_TOPIC: topic } = env;
|
|
25
|
+
return key && keyId && teamId && topic ? { key, keyId, teamId, topic } : null;
|
|
26
|
+
}
|
|
27
|
+
// How long a ring waits on Apple before it is a ring that did not leave. A
|
|
28
|
+
// ring that hangs is worse than one that failed: the world behind it is
|
|
29
|
+
// holding an ask open, and a push that is late is a push that is wrong.
|
|
30
|
+
const WITHIN = 10_000;
|
|
31
|
+
export function apnsHttp2(a, at = HOSTS, within = WITHIN) {
|
|
32
|
+
let made = null;
|
|
33
|
+
const held = new Map();
|
|
34
|
+
const session = (host) => {
|
|
35
|
+
const live = held.get(host);
|
|
36
|
+
if (live && !live.closed && !live.destroyed)
|
|
37
|
+
return live;
|
|
38
|
+
const s = connect(host);
|
|
39
|
+
const gone = () => {
|
|
40
|
+
if (held.get(host) === s)
|
|
41
|
+
held.delete(host);
|
|
42
|
+
};
|
|
43
|
+
s.on('error', gone);
|
|
44
|
+
s.on('close', gone);
|
|
45
|
+
s.unref();
|
|
46
|
+
held.set(host, s);
|
|
47
|
+
return s;
|
|
48
|
+
};
|
|
49
|
+
return {
|
|
50
|
+
async ring(token, sealed) {
|
|
51
|
+
made = await provider(a, made);
|
|
52
|
+
const req = request(a, made.jwt, token, sealed);
|
|
53
|
+
const stream = session(token.sandbox === true ? at.sandbox : at.production).request({ ':method': 'POST', ':path': req.path, ...req.headers });
|
|
54
|
+
stream.setEncoding('utf8');
|
|
55
|
+
stream.setTimeout(within, () => stream.destroy(new Error('apns did not answer')));
|
|
56
|
+
const { status, text } = await new Promise((ok, no) => {
|
|
57
|
+
let status = 0;
|
|
58
|
+
let text = '';
|
|
59
|
+
stream.on('response', (h) => (status = Number(h[':status'] ?? 0)));
|
|
60
|
+
stream.on('data', (chunk) => (text += chunk));
|
|
61
|
+
stream.on('error', no);
|
|
62
|
+
stream.on('end', () => ok({ status, text }));
|
|
63
|
+
stream.end(req.body);
|
|
64
|
+
});
|
|
65
|
+
answered(status, text);
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { Harbor, Lend } from '@nervur-org/nervur/harbor';
|
|
2
|
+
import { Faculty } from './faculty.ts';
|
|
3
|
+
import { Console } from './console.ts';
|
|
4
|
+
import { Local } from './local.ts';
|
|
5
|
+
import { Location } from './location.ts';
|
|
6
|
+
import { Notifications } from './notifications.ts';
|
|
7
|
+
import { Push } from './push.ts';
|
|
8
|
+
import { Timer } from './timer.ts';
|
|
9
|
+
export { Console, CONSOLE } from './console.ts';
|
|
10
|
+
export { Faculty, join, settled, type Watched, type Woke } from './faculty.ts';
|
|
11
|
+
export { Local, LOCAL } from './local.ts';
|
|
12
|
+
export { Location, type Where } from './location.ts';
|
|
13
|
+
export { Notifications } from './notifications.ts';
|
|
14
|
+
export { Push, isToken, bellOf, banner, sealBanner, SEAL, type Token, type Ring, type Line } from './push.ts';
|
|
15
|
+
export { Timer } from './timer.ts';
|
|
16
|
+
export { webPush, mintVapid, publicOf, type Vapid } from './webpush.ts';
|
|
17
|
+
export { apnsPush, pkcs8, HOSTS, type Apns } from './apns.ts';
|
|
18
|
+
export { fcmPush, isAccount, DOORS, type Account, type Doors } from './fcm.ts';
|
|
19
|
+
export { handedTo, ringing, routed } from './service.ts';
|
|
20
|
+
export declare const LOCATION = "location";
|
|
21
|
+
export declare const NOTIFICATIONS = "notifications";
|
|
22
|
+
export declare const PUSH = "push";
|
|
23
|
+
export declare const TIMER = "timer";
|
|
24
|
+
export declare const FACULTIES: Record<string, string>;
|
|
25
|
+
export declare const FACULTY_CLASSES: {
|
|
26
|
+
Console: typeof Console;
|
|
27
|
+
Local: typeof Local;
|
|
28
|
+
Location: typeof Location;
|
|
29
|
+
Notifications: typeof Notifications;
|
|
30
|
+
Push: typeof Push;
|
|
31
|
+
Timer: typeof Timer;
|
|
32
|
+
};
|
|
33
|
+
export declare const DOCK = "dock";
|
|
34
|
+
export declare function stand(harbor: Harbor, lent?: Record<string, string>): Promise<Lend>;
|
|
35
|
+
export type Rooted = {
|
|
36
|
+
ask(method?: string, args?: Record<string, unknown>): Promise<unknown>;
|
|
37
|
+
save?(): Promise<void>;
|
|
38
|
+
};
|
|
39
|
+
export declare function standOn(ward: Rooted, lent?: Record<string, string>): Promise<Lend>;
|
|
40
|
+
export declare function lending(ward: Rooted): Lend;
|
|
41
|
+
export declare function facultyOf<F extends Faculty>(harbor: Harbor, name: string): F | undefined;
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { isInvitation } from '@nervur-org/nervur';
|
|
2
|
+
import { Faculty } from './faculty.js';
|
|
3
|
+
import { Console, CONSOLE } from './console.js';
|
|
4
|
+
import { Local } from './local.js';
|
|
5
|
+
import { Location } from './location.js';
|
|
6
|
+
import { Notifications } from './notifications.js';
|
|
7
|
+
import { Push } from './push.js';
|
|
8
|
+
import { Timer } from './timer.js';
|
|
9
|
+
export { Console, CONSOLE } from './console.js';
|
|
10
|
+
export { Faculty, join, settled } from './faculty.js';
|
|
11
|
+
export { Local, LOCAL } from './local.js';
|
|
12
|
+
export { Location } from './location.js';
|
|
13
|
+
export { Notifications } from './notifications.js';
|
|
14
|
+
export { Push, isToken, bellOf, banner, sealBanner, SEAL } from './push.js';
|
|
15
|
+
export { Timer } from './timer.js';
|
|
16
|
+
// The three grounds the push faculty rings through, and the bell service
|
|
17
|
+
// that holds a publisher's keys: what the box can do, beside the faculty.
|
|
18
|
+
export { webPush, mintVapid, publicOf } from './webpush.js';
|
|
19
|
+
export { apnsPush, pkcs8, HOSTS } from './apns.js';
|
|
20
|
+
export { fcmPush, isAccount, DOORS } from './fcm.js';
|
|
21
|
+
export { handedTo, ringing, routed } from './service.js';
|
|
22
|
+
// The names this package lends under. They are conventions of the dock and
|
|
23
|
+
// nothing of Quo's: a name is the harbor's namespace, and a being holds each
|
|
24
|
+
// one under an id of her own choosing.
|
|
25
|
+
export const LOCATION = 'location';
|
|
26
|
+
export const NOTIFICATIONS = 'notifications';
|
|
27
|
+
export const PUSH = 'push';
|
|
28
|
+
export const TIMER = 'timer';
|
|
29
|
+
// The classes every dock ward may hold, by class name. A terrain hands its
|
|
30
|
+
// own subclasses under the same names and the harbor holds those instead.
|
|
31
|
+
// `local` and `grants` are not among them: the box's way in and the box's
|
|
32
|
+
// grants stand only where a terrain serves routes, and that terrain adds
|
|
33
|
+
// them to what it lends. `Grants` is not in this file at all, so that a tab
|
|
34
|
+
// bundling this index carries no route's notion: the terrain that serves
|
|
35
|
+
// routes hands her class to its harbor, as the daemon hands its own push.
|
|
36
|
+
export const FACULTIES = { [LOCATION]: 'Location', [NOTIFICATIONS]: 'Notifications', [PUSH]: 'Push', [TIMER]: 'Timer' };
|
|
37
|
+
export const FACULTY_CLASSES = { Console, Local, Location, Notifications, Push, Timer };
|
|
38
|
+
// The name a harbor keeps its own ward under. It is a ward in every other
|
|
39
|
+
// respect, and this one is never adopted or dropped as a migration: its
|
|
40
|
+
// beings are the box's and die with it.
|
|
41
|
+
export const DOCK = 'dock';
|
|
42
|
+
// Put the dock ward up and answer the `lend` this harbor hands its wards.
|
|
43
|
+
// `lent` says which names this box offers and which class stands for each;
|
|
44
|
+
// a name it does not hold is null, and so is every other kind of no.
|
|
45
|
+
//
|
|
46
|
+
// The harbor keeps this ward's ask pointer rather than handing it out, so it
|
|
47
|
+
// is the root here, and the root may invite on any being of its ward. That
|
|
48
|
+
// is the whole of what `lend` does: it never mints, it asks its own root to.
|
|
49
|
+
export async function stand(harbor, lent = FACULTIES) {
|
|
50
|
+
const kept = await harbor.store.load(DOCK);
|
|
51
|
+
const ward = harbor.wards.get(DOCK) ?? (kept ? await harbor.host(DOCK, kept) : await harbor.create(DOCK, CONSOLE));
|
|
52
|
+
return standOn(ward, lent);
|
|
53
|
+
}
|
|
54
|
+
// The faculties up in a ward already made, and the `lend` that reaches them.
|
|
55
|
+
// Apart from `stand` so a suite can put its own stubs in its own ward and
|
|
56
|
+
// get the same thing a box gets. It is run again at every boot and says the
|
|
57
|
+
// same thing twice without harm: a key already booted is refused, and an
|
|
58
|
+
// invitation on an id already held is null.
|
|
59
|
+
export async function standOn(ward, lent = FACULTIES) {
|
|
60
|
+
for (const [name, className] of Object.entries(lent))
|
|
61
|
+
await ward.ask('boot', { key: name, class: className });
|
|
62
|
+
// The box as one being, carrying what it stood. She is the page a device
|
|
63
|
+
// that has joined no world shows, and she is placed by the root because
|
|
64
|
+
// the root is the box: a standing at every faculty, minted and knocked in
|
|
65
|
+
// one move, as an owner places any relation into a being of its ward.
|
|
66
|
+
await ward.ask('boot', { key: CONSOLE, class: 'Console' });
|
|
67
|
+
for (const name of Object.keys(lent)) {
|
|
68
|
+
const inv = await ward.ask('invite', { being: name, id: `${CONSOLE}:${name}` });
|
|
69
|
+
if (!isInvitation(inv))
|
|
70
|
+
continue;
|
|
71
|
+
await ward.ask('knock', { being: CONSOLE, id: name, invitation: inv });
|
|
72
|
+
}
|
|
73
|
+
await ward.save?.();
|
|
74
|
+
return lending(ward);
|
|
75
|
+
}
|
|
76
|
+
// The root's half alone: a name and a taker in, whether the ward took it out.
|
|
77
|
+
// It never mints; it asks the root of the ward it holds to, which is what a
|
|
78
|
+
// root may do on any being of its ward. And it unmints: a taker that would
|
|
79
|
+
// not take leaves an occupant on that faculty which nobody holds, so the root
|
|
80
|
+
// removes it before answering, and a refused lend costs the box nothing.
|
|
81
|
+
//
|
|
82
|
+
// Any being of the dock ward is lent by her key, not only the ones a terrain
|
|
83
|
+
// stood: the faculties the dock ships are examples, and an estate boots its
|
|
84
|
+
// own into the dock ward, of a class in its own `classes/`, and lends it the
|
|
85
|
+
// same way. A name nobody there answers to is refused at the invite.
|
|
86
|
+
//
|
|
87
|
+
// The id is drawn and never counted. A faculty's occupants are in her cells
|
|
88
|
+
// and outlive the process, so a count that started again at every boot would
|
|
89
|
+
// name, on the first lend after a restart, an id she already holds, and the
|
|
90
|
+
// invite would be refused for the rest of the box's life.
|
|
91
|
+
export function lending(ward) {
|
|
92
|
+
return async (name, take) => {
|
|
93
|
+
const id = `lent:${fresh()}`;
|
|
94
|
+
const out = await ward.ask('invite', { being: name, id });
|
|
95
|
+
if (!isInvitation(out))
|
|
96
|
+
return false;
|
|
97
|
+
const took = await take(out);
|
|
98
|
+
if (!took)
|
|
99
|
+
await ward.ask('remove', { being: name, id });
|
|
100
|
+
await ward.save?.();
|
|
101
|
+
return took;
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
const fresh = () => Array.from(globalThis.crypto.getRandomValues(new Uint8Array(8)))
|
|
105
|
+
.map((b) => b.toString(16).padStart(2, '0'))
|
|
106
|
+
.join('');
|
|
107
|
+
// One faculty of this box, as the device's own code holds it: the object the
|
|
108
|
+
// harbor made, in this process. Not a path around a door, and nothing of it
|
|
109
|
+
// crosses an edge; it is how a device fires its own timer at a moment it
|
|
110
|
+
// names and how a suite reads what a stub recorded.
|
|
111
|
+
export function facultyOf(harbor, name) {
|
|
112
|
+
return harbor.wards.get(DOCK)?.being(name);
|
|
113
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { Faculty } from './faculty.ts';
|
|
2
|
+
import type { JsonObject } from '@nervur-org/nervur';
|
|
3
|
+
import { type Verified } from '../beings/way.ts';
|
|
4
|
+
export declare const LOCAL = "local";
|
|
5
|
+
export declare class Local extends Faculty {
|
|
6
|
+
#private;
|
|
7
|
+
static asks: {
|
|
8
|
+
verify: import("@nervur-org/nervur").AskSpec;
|
|
9
|
+
};
|
|
10
|
+
mint(nonce: string, who: Verified): void;
|
|
11
|
+
drop(nonce: string): void;
|
|
12
|
+
verify(args: JsonObject): Verified | null;
|
|
13
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
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.js';
|
|
17
|
+
import { VERIFY } from '../beings/way.js';
|
|
18
|
+
// The name the dock ward lends her under. A box with no local side lends no
|
|
19
|
+
// such name, and the desk there holds no such standing.
|
|
20
|
+
export const LOCAL = 'local';
|
|
21
|
+
export class Local extends Faculty {
|
|
22
|
+
static asks = { verify: VERIFY };
|
|
23
|
+
#nonces = new Map();
|
|
24
|
+
// A nonce for one client this box saw itself, spent by the first proof
|
|
25
|
+
// that names it. The caller drops it when the admit is over: a nonce that
|
|
26
|
+
// was never spent is not a way in that stays open.
|
|
27
|
+
mint(nonce, who) {
|
|
28
|
+
this.#nonces.set(nonce, who);
|
|
29
|
+
}
|
|
30
|
+
drop(nonce) {
|
|
31
|
+
this.#nonces.delete(nonce);
|
|
32
|
+
}
|
|
33
|
+
verify(args) {
|
|
34
|
+
const proof = args.proof;
|
|
35
|
+
if (!proof || typeof proof !== 'object' || Array.isArray(proof))
|
|
36
|
+
return null;
|
|
37
|
+
const nonce = proof.nonce;
|
|
38
|
+
const who = typeof nonce === 'string' ? this.#nonces.get(nonce) : undefined;
|
|
39
|
+
if (!who)
|
|
40
|
+
return null;
|
|
41
|
+
this.#nonces.delete(nonce);
|
|
42
|
+
return who;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { JsonObject } from '@nervur-org/nervur';
|
|
2
|
+
import { Faculty } from './faculty.ts';
|
|
3
|
+
export type Where = {
|
|
4
|
+
lat: number;
|
|
5
|
+
lng: number;
|
|
6
|
+
accuracy?: number;
|
|
7
|
+
at: number;
|
|
8
|
+
};
|
|
9
|
+
export declare class Location extends Faculty {
|
|
10
|
+
static asks: {
|
|
11
|
+
where: {
|
|
12
|
+
description: string;
|
|
13
|
+
input: {
|
|
14
|
+
type: string;
|
|
15
|
+
};
|
|
16
|
+
};
|
|
17
|
+
};
|
|
18
|
+
where(): Promise<JsonObject>;
|
|
19
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { Faculty } from './faculty.js';
|
|
2
|
+
export class Location extends Faculty {
|
|
3
|
+
static asks = {
|
|
4
|
+
where: { description: 'where this box is', input: { type: 'object' } },
|
|
5
|
+
};
|
|
6
|
+
// The platform's own permission prompt is the first ask's, and a refusal is
|
|
7
|
+
// the error object every ask after it answers.
|
|
8
|
+
where() {
|
|
9
|
+
return Promise.resolve({ error: 'this box cannot say where it is' });
|
|
10
|
+
}
|
|
11
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { JsonObject } from '@nervur-org/nervur';
|
|
2
|
+
import { Faculty } from './faculty.ts';
|
|
3
|
+
export declare class Notifications extends Faculty {
|
|
4
|
+
static cells: JsonObject;
|
|
5
|
+
static asks: {
|
|
6
|
+
notify: {
|
|
7
|
+
description: string;
|
|
8
|
+
input: {
|
|
9
|
+
type: string;
|
|
10
|
+
properties: {
|
|
11
|
+
title: {
|
|
12
|
+
type: string;
|
|
13
|
+
};
|
|
14
|
+
body: {
|
|
15
|
+
type: string;
|
|
16
|
+
};
|
|
17
|
+
};
|
|
18
|
+
required: string[];
|
|
19
|
+
};
|
|
20
|
+
};
|
|
21
|
+
banner: {
|
|
22
|
+
description: string;
|
|
23
|
+
input: {
|
|
24
|
+
type: string;
|
|
25
|
+
};
|
|
26
|
+
};
|
|
27
|
+
};
|
|
28
|
+
notify(_args: JsonObject): Promise<JsonObject>;
|
|
29
|
+
key(): Promise<{
|
|
30
|
+
secret: Uint8Array;
|
|
31
|
+
pk: Uint8Array;
|
|
32
|
+
}>;
|
|
33
|
+
banner(_args: JsonObject): Promise<JsonObject>;
|
|
34
|
+
protected plant(_pair: {
|
|
35
|
+
secret: Uint8Array;
|
|
36
|
+
pk: Uint8Array;
|
|
37
|
+
}): Promise<void>;
|
|
38
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
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 { Faculty } from './faculty.js';
|
|
26
|
+
// A key is thirty two bytes of the platform's own randomness, as every seed
|
|
27
|
+
// in this kit is.
|
|
28
|
+
const seed = () => globalThis.crypto.getRandomValues(new Uint8Array(32));
|
|
29
|
+
export class Notifications extends Faculty {
|
|
30
|
+
// The banner key, as hex: the seed it was made from, kept so the pair can
|
|
31
|
+
// be made again, and the public half a device hands out.
|
|
32
|
+
static cells = { watched: {}, minted: 0, banner: null };
|
|
33
|
+
static asks = {
|
|
34
|
+
notify: { description: 'show the human a line', input: { type: 'object', properties: { title: { type: 'string' }, body: { type: 'string' } }, required: ['title'] } },
|
|
35
|
+
banner: { description: "this device's banner key, the public half, which a world seals a notification to", input: { type: 'object' } },
|
|
36
|
+
};
|
|
37
|
+
notify(_args) {
|
|
38
|
+
return Promise.resolve({ error: 'this box shows no notifications' });
|
|
39
|
+
}
|
|
40
|
+
// The pair, minted once and kept. Every call after the first is the same
|
|
41
|
+
// key, because a device that minted a new one on every ask would be a
|
|
42
|
+
// device whose worlds all seal to a key it has forgotten.
|
|
43
|
+
async key() {
|
|
44
|
+
if (typeof this.cells.banner !== 'string') {
|
|
45
|
+
this.cells.banner = arithmetic.hex(seed());
|
|
46
|
+
await this.plant(await arithmetic.sealingPair(arithmetic.unhex(this.cells.banner)));
|
|
47
|
+
}
|
|
48
|
+
return arithmetic.sealingPair(arithmetic.unhex(this.cells.banner));
|
|
49
|
+
}
|
|
50
|
+
async banner(_args) {
|
|
51
|
+
return { banner: arithmetic.hex((await this.key()).pk) };
|
|
52
|
+
}
|
|
53
|
+
// Where this terrain's notification process reads the secret half. The
|
|
54
|
+
// base plants nothing, which is right for a terrain that draws its own
|
|
55
|
+
// banners in the same process; a phone overrides it and writes into the
|
|
56
|
+
// keychain group its extension shares with the app.
|
|
57
|
+
plant(_pair) {
|
|
58
|
+
return Promise.resolve();
|
|
59
|
+
}
|
|
60
|
+
}
|