@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/human/wake.ts
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// The device's half of the doorbell, worn by every shell: the tab's, the
|
|
3
|
+
// worker's behind a closed tab, and the app's. A ring carries nothing but a
|
|
4
|
+
// sealed line, so waking is three moves and the first two are the harbor's
|
|
5
|
+
// own. The device tells its dialers it is back, since a socket that died
|
|
6
|
+
// while it slept died silently; it asks each relation in each world what it
|
|
7
|
+
// missed, which is an ordinary ask on an ordinary standing, sealed like
|
|
8
|
+
// every other; and it composes what the human sees from the objects that
|
|
9
|
+
// came back, here, on the device, from bytes no push service ever held.
|
|
10
|
+
//
|
|
11
|
+
// Each object lands on the avatar exactly where a push would have landed,
|
|
12
|
+
// so a page that is open shows what it missed in its own notices as well.
|
|
13
|
+
//
|
|
14
|
+
// A phone and a desk hold a doorbell of their own at a bell, and hand the
|
|
15
|
+
// platform's token to that doorbell and to nobody else; what a world is
|
|
16
|
+
// given is a standing at that doorbell and the banner key it seals to. A
|
|
17
|
+
// tab hands its browser endpoint instead and keeps no doorbell, because a
|
|
18
|
+
// browser endpoint is rung only by whoever holds the VAPID secret the
|
|
19
|
+
// subscription was made under, and that secret is the world's own.
|
|
20
|
+
import { isInvitation, isSilence, isWord } from '@nervur-org/nervur';
|
|
21
|
+
import type { Invitation, JsonObject, Standing } from '@nervur-org/nervur';
|
|
22
|
+
import type { Harbor } from '@nervur-org/nervur/harbor';
|
|
23
|
+
import { DOCK, CONSOLE, NOTIFICATIONS, facultyOf, type Token } from '../dock/index.ts';
|
|
24
|
+
import type { Notifications } from '../dock/notifications.ts';
|
|
25
|
+
import { banner } from '../dock/push.ts';
|
|
26
|
+
import { localName, relations } from './worlds.ts';
|
|
27
|
+
|
|
28
|
+
// The token a browser's push service hands a page: the endpoint, and the
|
|
29
|
+
// two keys of the subscription made at it. RFC 8291 encrypts a body to
|
|
30
|
+
// those keys, which is how a ring carries the world's own line, so a token
|
|
31
|
+
// without them is a device that is woken and told nothing.
|
|
32
|
+
export const webToken = (endpoint: string, p256dh?: string, auth?: string) => ({ kind: 'web', endpoint, ...(p256dh && auth ? { p256dh, auth } : {}) });
|
|
33
|
+
|
|
34
|
+
// Where this device's bell stands: the pk of its ward and the rendezvous it
|
|
35
|
+
// is reached over. Both are the build's and never a being's, exactly as the
|
|
36
|
+
// ear is and for the same reason: where a bell stands is a build's
|
|
37
|
+
// business. A shell handed none holds no doorbell and tells a world nothing.
|
|
38
|
+
export type Bell = { pk: string; at: string };
|
|
39
|
+
|
|
40
|
+
// The id this device holds its doorbell under, on the box's own being.
|
|
41
|
+
export const DOORBELL = 'doorbell';
|
|
42
|
+
|
|
43
|
+
// A doorbell this device holds. `keep` is the platform's token, said again
|
|
44
|
+
// whenever the platform hands out a new one, and no world is told: that is
|
|
45
|
+
// the whole of why a rotation costs one write. `admit` is one world allowed,
|
|
46
|
+
// the way in it will ring on and the banner key it seals to.
|
|
47
|
+
export type Held = {
|
|
48
|
+
keep(token: Token): Promise<boolean>;
|
|
49
|
+
admit(): Promise<{ invitation: Invitation; banner: string } | null>;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const said = (out: unknown): JsonObject | null => (out === undefined || isSilence(out) || isWord(out) || out === null || typeof out !== 'object' || Array.isArray(out) ? null : (out as JsonObject));
|
|
53
|
+
|
|
54
|
+
// The box's own being, as the device's own code reaches its own object in
|
|
55
|
+
// its own process. Nothing of this crosses an edge.
|
|
56
|
+
const box = (harbor: Harbor): { standings: { knock(i: Invitation, m?: string, a?: JsonObject): Promise<unknown> } & Record<string, Standing | undefined> } | undefined =>
|
|
57
|
+
harbor.wards.get(DOCK)?.being(CONSOLE) as never;
|
|
58
|
+
|
|
59
|
+
// The doorbell this device holds, taken the first time and kept after that.
|
|
60
|
+
// A stranger phone knocks the bell's public being, is booted a doorbell of
|
|
61
|
+
// its own and comes away with the one way in; the standing lives on the
|
|
62
|
+
// box's own being, because a doorbell is the device's and not any world's.
|
|
63
|
+
//
|
|
64
|
+
// Null is a device with no bell behind it, which is what a build that names
|
|
65
|
+
// none looks like and what a bell that will not answer looks like. Each of
|
|
66
|
+
// them works in every other way.
|
|
67
|
+
export async function doorbell(harbor: Harbor, bell?: Bell): Promise<Held | null> {
|
|
68
|
+
if (!bell) return null;
|
|
69
|
+
const ward = harbor.wards.get(DOCK);
|
|
70
|
+
const me = box(harbor);
|
|
71
|
+
if (!ward || !me) return null;
|
|
72
|
+
if (!me.standings[DOORBELL]) {
|
|
73
|
+
const hello = said(await me.standings.knock({ ward: bell.pk } as Invitation, 'hello', {}));
|
|
74
|
+
const inv = hello?.invitation;
|
|
75
|
+
if (!isInvitation(inv)) return null;
|
|
76
|
+
// The ward's own knock, which knocks and takes in one act: an
|
|
77
|
+
// invitation is taken only where it was answered, so the two halves are
|
|
78
|
+
// never split.
|
|
79
|
+
const took = said(await ward.ask('knock', { being: CONSOLE, id: DOORBELL, invitation: inv }));
|
|
80
|
+
await ward.save();
|
|
81
|
+
if (took?.taken !== DOORBELL) return null;
|
|
82
|
+
}
|
|
83
|
+
const mine = me.standings[DOORBELL];
|
|
84
|
+
if (!mine) return null;
|
|
85
|
+
return {
|
|
86
|
+
keep: async (token: Token) => {
|
|
87
|
+
const out = said(await mine.ask('keep', { token }));
|
|
88
|
+
await ward.save();
|
|
89
|
+
return typeof out?.kept === 'string';
|
|
90
|
+
},
|
|
91
|
+
admit: async () => {
|
|
92
|
+
// The banner key is minted on the first ask and kept, and a terrain
|
|
93
|
+
// whose banners are drawn by a second process plants the secret half
|
|
94
|
+
// where that process reads it while this is happening.
|
|
95
|
+
const keys = facultyOf<Notifications>(harbor, NOTIFICATIONS);
|
|
96
|
+
const key = keys ? said(await keys.banner({}))?.banner : undefined;
|
|
97
|
+
if (typeof key !== 'string') return null;
|
|
98
|
+
const out = said(await mine.ask('admit', {}));
|
|
99
|
+
await ward.save();
|
|
100
|
+
const inv = out?.invitation;
|
|
101
|
+
return isInvitation(inv) ? { invitation: inv, banner: key } : null;
|
|
102
|
+
},
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// One world told how to wake this device. What a world is given is a
|
|
107
|
+
// standing at this device's doorbell and the banner key it seals to, never
|
|
108
|
+
// the platform's token: the same token at every world is a bearer
|
|
109
|
+
// credential, and a standing is not one.
|
|
110
|
+
//
|
|
111
|
+
// A standing does not rotate, so it is handed over once. The world is asked
|
|
112
|
+
// what it already holds and told nothing when it holds a doorbell, which is
|
|
113
|
+
// what keeps the doorbell's list of the worlds it allows a list a human can
|
|
114
|
+
// read and cut, rather than one row per time a page was opened. A browser
|
|
115
|
+
// endpoint is said every time, because a push service may hand out a new
|
|
116
|
+
// one at any moment and a stale one rings an empty house.
|
|
117
|
+
//
|
|
118
|
+
// A device with no bell behind it and no browser endpoint says nothing, and
|
|
119
|
+
// works in every other way.
|
|
120
|
+
export async function told(rel: { call(name: string, args?: JsonObject): Promise<unknown> }, at: Held | null, token?: Token | null): Promise<string | null> {
|
|
121
|
+
const has = said(await rel.call('bell', {}));
|
|
122
|
+
const kept = typeof has?.kept === 'string' ? has.kept : null;
|
|
123
|
+
if (at) {
|
|
124
|
+
if (kept === 'doorbell') return kept;
|
|
125
|
+
const got = await at.admit();
|
|
126
|
+
if (!got) return null;
|
|
127
|
+
const out = said(await rel.call('bell', { invitation: got.invitation, banner: got.banner }));
|
|
128
|
+
return typeof out?.kept === 'string' ? out.kept : null;
|
|
129
|
+
}
|
|
130
|
+
if (token?.kind !== 'web') return null;
|
|
131
|
+
const out = said(await rel.call('bell', { token }));
|
|
132
|
+
return typeof out?.kept === 'string' ? out.kept : null;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// The tab's doorbell: the worker registered on this world's origin, whose
|
|
136
|
+
// scope is the folder it is served from and so covers every world page
|
|
137
|
+
// there, and a subscription at whatever push service this browser keeps.
|
|
138
|
+
// The world's key goes over as the base64url string it already is. Null is
|
|
139
|
+
// a device with no doorbell, which is a browser with no push service, a
|
|
140
|
+
// human who was asked and said no, or a human who has not been asked; each
|
|
141
|
+
// of them works in every other way, and the last is why `ask` exists.
|
|
142
|
+
export async function subscribe(web: string, key: string, ask = false): Promise<{ kind: string; endpoint: string } | null> {
|
|
143
|
+
const workers = globalThis.navigator?.serviceWorker;
|
|
144
|
+
if (!workers || !('PushManager' in globalThis) || !('Notification' in globalThis)) return null;
|
|
145
|
+
try {
|
|
146
|
+
const registration = await workers.register(`${web}/sw.js`, { type: 'module' });
|
|
147
|
+
await workers.ready;
|
|
148
|
+
if (Notification.permission === 'default' && ask) await Notification.requestPermission();
|
|
149
|
+
if (Notification.permission !== 'granted') return null;
|
|
150
|
+
const held = await registration.pushManager.getSubscription();
|
|
151
|
+
const sub = held ?? (await registration.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: key }));
|
|
152
|
+
// The keys are read off the subscription as it writes them itself,
|
|
153
|
+
// base64url, which is the form a world encrypts to.
|
|
154
|
+
const keys = (sub.toJSON() as { keys?: { p256dh?: string; auth?: string } }).keys;
|
|
155
|
+
return webToken(sub.endpoint, keys?.p256dh, keys?.auth);
|
|
156
|
+
} catch {
|
|
157
|
+
return null; // a browser with no push service behind it: no doorbell, and everything else stands
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// What every relation in every world here missed, in the order it was
|
|
162
|
+
// pushed, drained and gone. A world this device is not in has no relation
|
|
163
|
+
// and answers nothing.
|
|
164
|
+
export async function drained(harbor: Harbor, pks: string[]): Promise<JsonObject[]> {
|
|
165
|
+
const out: JsonObject[] = [];
|
|
166
|
+
for (const pk of pks) {
|
|
167
|
+
const ward = harbor.wards.get(localName(pk));
|
|
168
|
+
if (!ward) continue;
|
|
169
|
+
for (const rel of relations(ward)) {
|
|
170
|
+
const got = await rel.avatar.call('missed');
|
|
171
|
+
const objects = (got as { missed?: unknown }).missed;
|
|
172
|
+
if (!Array.isArray(objects)) continue;
|
|
173
|
+
for (const o of objects) {
|
|
174
|
+
if (o === null || typeof o !== 'object' || Array.isArray(o)) continue;
|
|
175
|
+
rel.avatar.notify(o as JsonObject); // where the push would have arrived
|
|
176
|
+
out.push(o as JsonObject);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
await ward.save(); // her keys rotated on that ask, whatever it answered
|
|
180
|
+
}
|
|
181
|
+
return out;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// What the human reads on the lock screen, composed from the objects and
|
|
185
|
+
// from nothing else. It is the same line a world seals into a ring, made
|
|
186
|
+
// here from what actually crossed, and the human is told how many are
|
|
187
|
+
// behind the last one.
|
|
188
|
+
export function notice(objects: JsonObject[]): { title: string; body: string } {
|
|
189
|
+
const line = banner(objects[objects.length - 1] ?? {});
|
|
190
|
+
return { ...line, body: objects.length > 1 ? `${line.body} (and ${objects.length - 1} more)` : line.body };
|
|
191
|
+
}
|
package/human/web.ts
ADDED
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// The web route: the worlds' pages, served by the daemon under `/web` and
|
|
3
|
+
// mapped by the proxy from the web. hostname. A world is a ward with a
|
|
4
|
+
// public being, and its address is `/web/<ward>`: the tab page, `tab.ts`,
|
|
5
|
+
// told which ward this is, and every ward on the harbor by name, pk and
|
|
6
|
+
// whether a public being is at its door, so that its guest is that ward's
|
|
7
|
+
// public being rendered by the screen, whatever class she is. Every ward
|
|
8
|
+
// has a page, since a link may point into one with nobody at the door;
|
|
9
|
+
// `/web/` lists the worlds and carries the tab too, so a link at the root
|
|
10
|
+
// lands in the ward its invitation names, and with no world to list it is
|
|
11
|
+
// the door page, `door.ts`. Nothing here authenticates and nothing is
|
|
12
|
+
// admitted on this side: the way into a world is an invitation, carried by
|
|
13
|
+
// a link, and the knock is the tab's. Paths this route does not take fall
|
|
14
|
+
// through to the exchange pages, which share the prefix.
|
|
15
|
+
//
|
|
16
|
+
// The tab's code and the service worker's arrive here as strings, built
|
|
17
|
+
// once by whoever built this package and never by this route. A harbor's
|
|
18
|
+
// own tab code is the estate's to build the same way: the folder holds a
|
|
19
|
+
// built `tab/index.js`, and a folder holding source and no built file says
|
|
20
|
+
// so at the path that would have served it.
|
|
21
|
+
//
|
|
22
|
+
// Nothing here reads a file. What a world keeps beside its wards, the
|
|
23
|
+
// design it wears and its own code for the tab, is asked of the terrain
|
|
24
|
+
// through `Folder`, the way a ward's classes are asked of a loader. So this
|
|
25
|
+
// route runs wherever a harbor does, and a folder is one terrain's answer
|
|
26
|
+
// to it.
|
|
27
|
+
//
|
|
28
|
+
// The page carries a content security policy: scripts from this origin
|
|
29
|
+
// only and never inline, connections to this origin and the quo. route the
|
|
30
|
+
// tab dials, images from data URIs and this origin, and nothing else. So
|
|
31
|
+
// even a bug in a renderer cannot become a script, and no look can reach a
|
|
32
|
+
// server. The config crosses in a JSON script, which the policy allows.
|
|
33
|
+
import type { Handler } from '../harbor/http.ts';
|
|
34
|
+
import type { Hosted } from '@nervur-org/nervur/harbor';
|
|
35
|
+
import { DOCK } from '../dock/index.ts';
|
|
36
|
+
import { publicKey } from '../harbor/ward.ts';
|
|
37
|
+
import { ANDROID, APPLE, association, type Apps } from './apps.ts';
|
|
38
|
+
import { door } from './door.ts';
|
|
39
|
+
import { BARE, CSS } from './style.ts';
|
|
40
|
+
import type { Config } from './tab.ts';
|
|
41
|
+
|
|
42
|
+
// The apps a world's links may open: an estate names them and this route
|
|
43
|
+
// says so where the two platforms look, in the words `apps.ts` holds for
|
|
44
|
+
// every hostname that says it. A join link is a link to a world's page, so
|
|
45
|
+
// the domain a phone verifies is the world's own web. hostname, and the
|
|
46
|
+
// app that claims it names that hostname in its entitlements. An estate
|
|
47
|
+
// with no apps of its own names none, the files are not served, and every
|
|
48
|
+
// link opens the tab, which is the rented room and says so.
|
|
49
|
+
export type { Apps };
|
|
50
|
+
|
|
51
|
+
// What a world keeps beside its wards, asked of the terrain that holds it.
|
|
52
|
+
// It is asked at every request and never taken once at boot, because a
|
|
53
|
+
// folder is worn, built and changed while the daemon runs.
|
|
54
|
+
export type Folder = {
|
|
55
|
+
// which of a design's own stylesheets this world holds. `tokens.css` is
|
|
56
|
+
// the design itself and nothing is worn without it; `fonts.css` is there
|
|
57
|
+
// only when the design speaks in its own faces.
|
|
58
|
+
wearing(): Promise<{ tokens: boolean; fonts: boolean }>;
|
|
59
|
+
// one file of the design this world wears, by the name the route asked
|
|
60
|
+
// for, or nothing when the world holds no such file
|
|
61
|
+
design(file: string): Promise<Uint8Array<ArrayBuffer> | string | null>;
|
|
62
|
+
// whether this world means to serve its own code for the tab at all. A
|
|
63
|
+
// page is told, so a tab knows to ask for it.
|
|
64
|
+
serving(): Promise<boolean>;
|
|
65
|
+
// that code, built. Nothing when the world means to serve code and its
|
|
66
|
+
// build did not run, which is said at the path and never passed over.
|
|
67
|
+
beings(): Promise<string | null>;
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
// A world with nothing beside its wards: no design, no code of its own. It
|
|
71
|
+
// is what a terrain hands the route when it holds no folder at all.
|
|
72
|
+
export const BARE_FOLDER: Folder = {
|
|
73
|
+
wearing: () => Promise.resolve({ tokens: false, fonts: false }),
|
|
74
|
+
design: () => Promise.resolve(null),
|
|
75
|
+
serving: () => Promise.resolve(false),
|
|
76
|
+
beings: () => Promise.resolve(null),
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
export type WebOptions = {
|
|
80
|
+
// where the routes are, as a tab sees them: what it dials and where it is
|
|
81
|
+
at: { quo: string; web: string };
|
|
82
|
+
// the tab's bundle and the service worker's, built once by whoever built
|
|
83
|
+
// this package and handed here as code
|
|
84
|
+
code: { tab: string; worker: string };
|
|
85
|
+
// what the world keeps beside its wards; a world that keeps nothing is bare
|
|
86
|
+
folder?: Folder;
|
|
87
|
+
// the apps this world's links open, `app.json` in the harbor folder
|
|
88
|
+
apps?: Apps | null;
|
|
89
|
+
// the public half of this world's doorbell key, which a page subscribes
|
|
90
|
+
// to a push service with. A world whose device holds no bell key sends
|
|
91
|
+
// none and its pages register no worker.
|
|
92
|
+
bell?: string | null;
|
|
93
|
+
// whether a ward is shown at this route, by name; every ward when unsaid
|
|
94
|
+
shown?: (ward: string) => boolean;
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
// The paths under `/web` that are the exchange's, never a ward's name.
|
|
98
|
+
const RESERVED_PATHS = new Set(['allow', 'tab.js', 'beings.js', 'sw.js', 'design', '.well-known']);
|
|
99
|
+
|
|
100
|
+
// The design a world wears: `design/` in the harbor folder, the compiled
|
|
101
|
+
// face of one design and the fonts it speaks in, served under this route
|
|
102
|
+
// and linked by every page after the kit. A harbor with none wears the
|
|
103
|
+
// kit's own face. Only these files, by these names, and nothing else in
|
|
104
|
+
// the folder is reachable.
|
|
105
|
+
const DESIGN = 'design';
|
|
106
|
+
const DESIGN_FILES: Record<string, string> = { 'tokens.css': 'text/css; charset=utf-8', 'fonts.css': 'text/css; charset=utf-8' };
|
|
107
|
+
const FONT = /^fonts\/[\w.-]+\.woff2$/;
|
|
108
|
+
|
|
109
|
+
// The origins a Quo app's webview speaks from: Tauri's on the desk,
|
|
110
|
+
// Capacitor's on iOS, and the plain loopback Android's webview uses. They
|
|
111
|
+
// are the only origins a world tells its config to, and the fetch is a
|
|
112
|
+
// plain GET asking for JSON, which no browser preflights.
|
|
113
|
+
const APP_ORIGINS = new Set(['tauri://localhost', 'capacitor://localhost', 'http://localhost', 'https://localhost']);
|
|
114
|
+
|
|
115
|
+
const appAsking = (req: Request): string | null => {
|
|
116
|
+
const origin = req.headers.get('origin');
|
|
117
|
+
if (origin === null || !APP_ORIGINS.has(origin)) return null;
|
|
118
|
+
return (req.headers.get('accept') ?? '').includes('application/json') ? origin : null;
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
// The world's code for the tab: a module in the harbor folder, the twin of
|
|
122
|
+
// `classes/index.ts`, exporting by name the classes the tab boots into its
|
|
123
|
+
// local ward for a world of this harbor. Served bundled as `beings.js` on
|
|
124
|
+
// this origin, and one origin is one world's code. It is a built file: the
|
|
125
|
+
// estate that deploys this harbor bundles its own tab source and puts the
|
|
126
|
+
// result in the folder, because nothing here runs a compiler.
|
|
127
|
+
export const TAB_CODE = 'tab/index.js';
|
|
128
|
+
|
|
129
|
+
// The source that built file comes from. It is named here for one reason:
|
|
130
|
+
// a folder holding it and no built file has a build that did not run, and
|
|
131
|
+
// that is said at the path rather than passed over in silence.
|
|
132
|
+
export const TAB_SOURCE = 'tab/index.ts';
|
|
133
|
+
|
|
134
|
+
export function webRoute(harbor: { wards: ReadonlyMap<string, Hosted> }, o: WebOptions): Handler {
|
|
135
|
+
const folder = o.folder ?? BARE_FOLDER;
|
|
136
|
+
const quoOrigin = (() => {
|
|
137
|
+
try {
|
|
138
|
+
const u = new URL(o.at.quo);
|
|
139
|
+
return `${u.origin} ${u.origin.replace(/^http/, 'ws')}`;
|
|
140
|
+
} catch {
|
|
141
|
+
return '';
|
|
142
|
+
}
|
|
143
|
+
})();
|
|
144
|
+
const host = (() => {
|
|
145
|
+
try {
|
|
146
|
+
return new URL(o.at.web).host;
|
|
147
|
+
} catch {
|
|
148
|
+
return o.at.web;
|
|
149
|
+
}
|
|
150
|
+
})();
|
|
151
|
+
// `worker-src` is named because it falls back to `default-src`, which is
|
|
152
|
+
// none: without it the page's own service worker, the ear that hears the
|
|
153
|
+
// doorbell while the tab is closed, is refused by the policy.
|
|
154
|
+
const policy = `default-src 'none'; script-src 'self'; worker-src 'self'; style-src 'self' 'unsafe-inline'; font-src 'self'; img-src 'self' data:; connect-src 'self' ${quoOrigin}; form-action 'self'; base-uri 'none'; frame-ancestors 'none'`;
|
|
155
|
+
const design = async () => {
|
|
156
|
+
const worn = await folder.wearing();
|
|
157
|
+
if (!worn.tokens) return `<style>${CSS}</style>`;
|
|
158
|
+
const fonts = worn.fonts ? `<link rel="stylesheet" href="${o.at.web}/${DESIGN}/fonts.css">` : '';
|
|
159
|
+
return `<style>${BARE}</style>${fonts}<link rel="stylesheet" href="${o.at.web}/${DESIGN}/tokens.css">`;
|
|
160
|
+
};
|
|
161
|
+
const shell = async (body: string) => `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>quo</title>
|
|
162
|
+
${await design()}
|
|
163
|
+
</head><body>${body}</body></html>`;
|
|
164
|
+
const esc = (v: string) => v.replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c] ?? c);
|
|
165
|
+
const html = async (status: number, body: string) =>
|
|
166
|
+
new Response(await shell(body), { status, headers: { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store', 'content-security-policy': policy, 'referrer-policy': 'no-referrer' } });
|
|
167
|
+
// Each ward is asked which being it marks public: the harbor reads no
|
|
168
|
+
// partition and the mark is the ward's, so the config a page is handed is
|
|
169
|
+
// built from describes, one per world on this harbor.
|
|
170
|
+
// The box's own ward is not a world and is never offered as one: its
|
|
171
|
+
// beings are the harbor's, reached by `lend` from this box alone, and a
|
|
172
|
+
// page that listed it would be offering a stranger a device's own things.
|
|
173
|
+
// A ward is at its door for a stranger's page only when it is shown: the
|
|
174
|
+
// public being still answers a proof and a link still knocks, but a hidden
|
|
175
|
+
// ward's page is the plain door page and its root line is not written.
|
|
176
|
+
const shown = o.shown ?? (() => true);
|
|
177
|
+
const wards = async () => Object.fromEntries(await Promise.all([...harbor.wards].filter(([n]) => n !== DOCK).map(async ([n, h]) => [n, { pk: h.pk, public: shown(n) && (await publicKey(h)) !== null }] as const)));
|
|
178
|
+
const config = async (ward?: string, being?: string): Promise<Config> => ({ quo: o.at.quo, web: o.at.web, wards: await wards(), beings: await folder.serving(), ...(o.bell ? { bell: o.bell } : {}), ...(ward ? { ward } : {}), ...(being ? { being } : {}) });
|
|
179
|
+
const tab = async (ward?: string, being?: string) => {
|
|
180
|
+
return `<script id="quo" type="application/json">${JSON.stringify(await config(ward, being)).replace(/</g, '\\u003c')}</script><script type="module" src="${o.at.web}/tab.js"></script>`;
|
|
181
|
+
};
|
|
182
|
+
// The same config the tab is handed in its page, for an app that is not a
|
|
183
|
+
// tab: it learns a world by this one fetch and never loads the bundle,
|
|
184
|
+
// because its code is its own bundle and a world's code never runs on a
|
|
185
|
+
// phone. Only the app origins are answered, and only this; a browser on
|
|
186
|
+
// any other origin is told nothing, and gets the page instead.
|
|
187
|
+
const asJson = async (origin: string, ward?: string) =>
|
|
188
|
+
new Response(JSON.stringify(await config(ward)), { headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store', 'access-control-allow-origin': origin, vary: 'origin' } });
|
|
189
|
+
return async (req, rest) => {
|
|
190
|
+
const parts = rest.split('/').filter(Boolean);
|
|
191
|
+
if (req.method === 'GET' && (rest === '' || rest === '/')) {
|
|
192
|
+
// the harbor's own config, every ward on it: an app that was sent a
|
|
193
|
+
// link into a ward it does not know learns the whole harbor at once
|
|
194
|
+
const app = appAsking(req);
|
|
195
|
+
if (app !== null) return asJson(app);
|
|
196
|
+
const open: string[] = [];
|
|
197
|
+
for (const [n, h] of harbor.wards) {
|
|
198
|
+
const key = shown(n) ? await publicKey(h) : null;
|
|
199
|
+
if (key !== null) open.push(`<li class="nv-item nv-person"><span class="nv-avatar">${esc(n.charAt(0).toUpperCase())}</span><div class="nv-stack"><p class="nv-t-body"><a href="${o.at.web}/${encodeURIComponent(n)}">${esc(n)}</a></p><p class="nv-t-quiet">${esc(key)} at the door · <span class="nv-key">${h.pk.slice(0, 16)}…</span></p></div></li>`);
|
|
200
|
+
}
|
|
201
|
+
const list = open.join('');
|
|
202
|
+
return html(200, `${list ? `<main data-door><div class="nv-hero"><h1 class="nv-t-title">worlds</h1><p class="nv-t-lead">The worlds on this box that show a face. Each is entered by a link someone sends you.</p><ul class="nv-list" role="list">${list}</ul></div></main>` : door({ host })}${await tab()}`);
|
|
203
|
+
}
|
|
204
|
+
// Where the two platforms look before they let an app claim this
|
|
205
|
+
// hostname's links. The estate names its apps and nothing else is
|
|
206
|
+
// decided here: the file is the claim, the platform verifies it over
|
|
207
|
+
// TLS, and a hostname that names no app is a hostname whose links open
|
|
208
|
+
// the tab. The exchange page is not the app's on purpose: a credential
|
|
209
|
+
// exchange belongs in the browser that started it.
|
|
210
|
+
if (req.method === 'GET' && (rest === APPLE || rest === ANDROID)) {
|
|
211
|
+
const body = association(o.apps, rest, ['/allow*']);
|
|
212
|
+
if (body === null) return undefined;
|
|
213
|
+
return new Response(body, { headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' } });
|
|
214
|
+
}
|
|
215
|
+
if (rest === '/tab.js' && req.method === 'GET') return new Response(o.code.tab, { headers: { 'content-type': 'text/javascript; charset=utf-8', 'cache-control': 'no-store' } });
|
|
216
|
+
// The service worker: the page's ear while the page is closed. It is
|
|
217
|
+
// served from the root of this route so that its scope covers every
|
|
218
|
+
// world's page, and its config is written above the bundle, since a
|
|
219
|
+
// worker has no page to be handed a JSON script in. A world with no
|
|
220
|
+
// doorbell serves no worker, and its pages register none.
|
|
221
|
+
if (rest === '/sw.js' && req.method === 'GET') {
|
|
222
|
+
if (!o.bell) return undefined;
|
|
223
|
+
const said = `self.quo=${JSON.stringify(await config()).replace(/</g, '\\u003c')};\n${o.code.worker}`;
|
|
224
|
+
return new Response(said, { headers: { 'content-type': 'text/javascript; charset=utf-8', 'cache-control': 'no-store', 'service-worker-allowed': '/' } });
|
|
225
|
+
}
|
|
226
|
+
// the design this world wears, when the harbor folder holds one: its
|
|
227
|
+
// stylesheet, its font faces, and the font files those name
|
|
228
|
+
if (rest.startsWith(`/${DESIGN}/`) && req.method === 'GET') {
|
|
229
|
+
const file = rest.slice(DESIGN.length + 2);
|
|
230
|
+
const type = DESIGN_FILES[file] ?? (FONT.test(file) ? 'font/woff2' : null);
|
|
231
|
+
if (type === null) return undefined;
|
|
232
|
+
const bytes = await folder.design(file);
|
|
233
|
+
if (bytes === null) return undefined;
|
|
234
|
+
return new Response(bytes, { headers: { 'content-type': type, 'cache-control': 'public, max-age=86400' } });
|
|
235
|
+
}
|
|
236
|
+
// the world's code for the tab, when the harbor folder holds any; a
|
|
237
|
+
// harbor with none has no such path. A folder holding the source and no
|
|
238
|
+
// built file is a build that did not run, and the page is told so here
|
|
239
|
+
// rather than quietly booting a world with no classes in it.
|
|
240
|
+
if (rest === '/beings.js' && req.method === 'GET') {
|
|
241
|
+
if (!(await folder.serving())) return undefined;
|
|
242
|
+
const own = await folder.beings();
|
|
243
|
+
if (own === null) return new Response(`this world holds ${TAB_SOURCE} and no built ${TAB_CODE}; whoever deploys it emits that file`, { status: 500, headers: { 'content-type': 'text/plain; charset=utf-8', 'cache-control': 'no-store' } });
|
|
244
|
+
return new Response(own, { headers: { 'content-type': 'text/javascript; charset=utf-8', 'cache-control': 'no-store' } });
|
|
245
|
+
}
|
|
246
|
+
const wardName = parts[0] ?? '';
|
|
247
|
+
if (!wardName || RESERVED_PATHS.has(wardName)) return undefined;
|
|
248
|
+
const hosted = harbor.wards.get(wardName);
|
|
249
|
+
if (!hosted) return html(404, `<h1>no such world</h1><p>no ward named ${esc(wardName)} on this harbor.</p>`);
|
|
250
|
+
if (parts.length === 1 && req.method === 'GET') {
|
|
251
|
+
const app = appAsking(req);
|
|
252
|
+
return app === null ? html(200, await tab(wardName)) : asJson(app, wardName);
|
|
253
|
+
}
|
|
254
|
+
// a being's page: one standing the user being carries, by its id, as the whole page. No router:
|
|
255
|
+
// the path is a key, and the tab paints nothing for a key her describe does not carry
|
|
256
|
+
if (parts.length === 2 && req.method === 'GET' && /^[\w.-]{1,80}$/.test(parts[1])) return html(200, await tab(wardName, parts[1]));
|
|
257
|
+
return undefined;
|
|
258
|
+
};
|
|
259
|
+
}
|