@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/agents.ts
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// The box's agents: `agents`, one faculty of the dock ward, standing for the
|
|
3
|
+
// one thing a box does on an event as the timer does on a moment: run a
|
|
4
|
+
// process. What she holds is the table of agents, in her cells and in no
|
|
5
|
+
// file, placed by the root with `keep` the way a schedule is placed on the
|
|
6
|
+
// clock, so a box has one place its state is and the daemon reads no recipe.
|
|
7
|
+
//
|
|
8
|
+
// A row names a client identity, the ward she is admitted into, and what
|
|
9
|
+
// runs for her, one of two: a process, a command with its args in its
|
|
10
|
+
// folder, or a model that speaks function calling, a base URL and a model
|
|
11
|
+
// name, OpenRouter, LM Studio, anything with a tools array. At her birth
|
|
12
|
+
// on a daemon she admits each identity into its ward as an avatar, through
|
|
13
|
+
// the box's own way in, and stands the side her row says over that avatar.
|
|
14
|
+
// A push from the user being is the event either way: the agent side runs
|
|
15
|
+
// the command with it and reports what it printed; the runner side turns it
|
|
16
|
+
// into a turn of the model's conversation with her describe as tools. The
|
|
17
|
+
// queue is in the avatar's cells, so it survives a restart; the row is in
|
|
18
|
+
// hers, so the agent comes back with the box.
|
|
19
|
+
//
|
|
20
|
+
// The daemon hands her how to run a row, as it hands the grants faculty how
|
|
21
|
+
// to persist: running an agent is admitting through a socket only a daemon
|
|
22
|
+
// has, and this file is not in the dock's index, since a tab has no process
|
|
23
|
+
// to spawn. A box with no runner keeps the table and runs nobody.
|
|
24
|
+
import { OWNER } from '@nervur-org/nervur';
|
|
25
|
+
import type { Asker, JsonObject, OccupantRecord } from '@nervur-org/nervur';
|
|
26
|
+
import { Faculty } from './faculty.ts';
|
|
27
|
+
|
|
28
|
+
// The name the dock ward holds her under.
|
|
29
|
+
export const AGENTS = 'agents';
|
|
30
|
+
|
|
31
|
+
// One agent. `id` is the client identity the avatar is known by in her ward.
|
|
32
|
+
// A process row and a model row, told apart by what they name.
|
|
33
|
+
export type Process = { command: string; args: string[]; dir: string };
|
|
34
|
+
export type Model = { url: string; model: string; key?: string; turns?: number };
|
|
35
|
+
export type Row = { id: string; ward: string } & (Process | Model);
|
|
36
|
+
export const isProcess = (r: Row): r is { id: string; ward: string } & Process => 'command' in r;
|
|
37
|
+
|
|
38
|
+
// What a daemon stands for a row: a side over her avatar, closed when the
|
|
39
|
+
// row is dropped or the box goes down.
|
|
40
|
+
export type Side = { close(): Promise<void> };
|
|
41
|
+
export type Runner = (row: Row) => Promise<Side>;
|
|
42
|
+
|
|
43
|
+
// The root and nobody else, as the clock gates `keep`: an ask gated this way
|
|
44
|
+
// is the device's own and cannot be reached through a door.
|
|
45
|
+
const root = (_occ: OccupantRecord | undefined, asker: Asker) => asker.id === OWNER;
|
|
46
|
+
|
|
47
|
+
const ROW = {
|
|
48
|
+
type: 'object',
|
|
49
|
+
properties: { id: { type: 'string' }, ward: { type: 'string' }, command: { type: 'string' }, args: { type: 'array', items: { type: 'string' } }, dir: { type: 'string' }, url: { type: 'string' }, model: { type: 'string' }, key: { type: 'string' }, turns: { type: 'number' } },
|
|
50
|
+
required: ['id', 'ward'],
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
export class Agents extends Faculty {
|
|
54
|
+
static override cells: JsonObject = { watched: {}, minted: 0, agents: {} };
|
|
55
|
+
static override asks = {
|
|
56
|
+
keep: { description: 'add an agent, or replace the one of that id: an identity, her ward, and what runs for her, a command with args in a folder, or a model at a url', input: ROW, for: root },
|
|
57
|
+
drop: { description: 'stop an agent and forget her row; the avatar in her ward stays until the root removes her', input: { type: 'object', properties: { id: { type: 'string' } }, required: ['id'] }, for: root },
|
|
58
|
+
list: { description: 'every agent she holds', input: { type: 'object' }, for: root },
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
runner: Runner | undefined;
|
|
62
|
+
readonly running = new Map<string, Side>();
|
|
63
|
+
|
|
64
|
+
private get rows(): Record<string, Row> {
|
|
65
|
+
return this.cells.agents as unknown as Record<string, Row>;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Every row up, once the daemon has handed the runner. Called after the
|
|
69
|
+
// wards are hosted, since an avatar is admitted into one of them.
|
|
70
|
+
async start(runner: Runner): Promise<void> {
|
|
71
|
+
this.runner = runner;
|
|
72
|
+
for (const row of Object.values(this.rows)) await this.#run(row);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async #run(row: Row): Promise<void> {
|
|
76
|
+
if (!this.runner) return;
|
|
77
|
+
await this.running.get(row.id)?.close();
|
|
78
|
+
this.running.set(row.id, await this.runner(row));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async keep(args: JsonObject): Promise<JsonObject> {
|
|
82
|
+
const id = args.id,
|
|
83
|
+
ward = args.ward;
|
|
84
|
+
if (typeof id !== 'string' || !/^[\w.-]+$/.test(id)) return { error: 'id is a word' };
|
|
85
|
+
if (typeof ward !== 'string') return { error: 'ward names the ward she is admitted into' };
|
|
86
|
+
let row: Row;
|
|
87
|
+
if (typeof args.command === 'string' && typeof args.dir === 'string') {
|
|
88
|
+
row = { id, ward, command: args.command, args: Array.isArray(args.args) ? args.args.filter((a): a is string => typeof a === 'string') : [], dir: args.dir };
|
|
89
|
+
} else if (typeof args.url === 'string' && typeof args.model === 'string') {
|
|
90
|
+
row = { id, ward, url: args.url, model: args.model, ...(typeof args.key === 'string' ? { key: args.key } : {}), ...(typeof args.turns === 'number' ? { turns: args.turns } : {}) };
|
|
91
|
+
} else {
|
|
92
|
+
return { error: 'a row runs a process, { command, args?, dir }, or a model, { url, model, key?, turns? }' };
|
|
93
|
+
}
|
|
94
|
+
try {
|
|
95
|
+
await this.#run(row);
|
|
96
|
+
} catch (e) {
|
|
97
|
+
return { error: e instanceof Error ? e.message : String(e) };
|
|
98
|
+
}
|
|
99
|
+
this.rows[id] = row;
|
|
100
|
+
return { kept: id };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async drop(args: JsonObject): Promise<JsonObject> {
|
|
104
|
+
const id = args.id;
|
|
105
|
+
if (typeof id !== 'string' || !this.rows[id]) return { error: 'no such agent' };
|
|
106
|
+
await this.running.get(id)?.close();
|
|
107
|
+
this.running.delete(id);
|
|
108
|
+
delete this.rows[id];
|
|
109
|
+
return { dropped: id };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
list(): JsonObject {
|
|
113
|
+
return { agents: Object.values(this.rows) as unknown as JsonObject[] };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// The device puts the box down: every side closes, the rows stay.
|
|
117
|
+
async close(): Promise<void> {
|
|
118
|
+
for (const a of this.running.values()) await a.close();
|
|
119
|
+
this.running.clear();
|
|
120
|
+
}
|
|
121
|
+
}
|
package/dock/apns.ts
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// APNs as a bell ground: Apple's way of waking an app, and the first of the
|
|
3
|
+
// two the stores own. What makes a ring Apple's is a token signed with a
|
|
4
|
+
// key the team minted at the portal, so unlike Web Push this ground cannot
|
|
5
|
+
// be stood up by whoever wants one: it wants an account and a key file, and
|
|
6
|
+
// that is the whole reason a bell service exists at all.
|
|
7
|
+
//
|
|
8
|
+
// A ring is one of two things, and the banner decides which. With no
|
|
9
|
+
// banner it is a background push, `content-available`, priority 5, expiring
|
|
10
|
+
// at once: the app wakes, dials, drains what it missed over Quo and
|
|
11
|
+
// composes what the human reads from that. A background push is best effort
|
|
12
|
+
// by Apple's own rules, throttled and never delivered to an app the human
|
|
13
|
+
// force quit, so that path is best effort and says so.
|
|
14
|
+
//
|
|
15
|
+
// With a banner it is an alert, `mutable-content`, priority 10, and the
|
|
16
|
+
// device's own notification service extension opens the seal and rewrites
|
|
17
|
+
// the line before anything is drawn. Apple carries ciphertext and sees a
|
|
18
|
+
// device woken, never a byte of why, on either path.
|
|
19
|
+
//
|
|
20
|
+
// One thing this asks of wherever it is put, and it is the reason a bell
|
|
21
|
+
// service lives where it does. The APNs provider API is HTTP/2 only and
|
|
22
|
+
// refuses HTTP/1.1, and not every `fetch` speaks HTTP/2: a worker's does
|
|
23
|
+
// and Node's does not. So this file is the ring for a terrain whose
|
|
24
|
+
// `fetch` speaks it, `http2.ts` beside it is the same ring over
|
|
25
|
+
// `node:http2` for a terrain whose does not, and what they send is written
|
|
26
|
+
// once here and used by both. Which terrains are which is
|
|
27
|
+
// `quo-harbor.md`'s to say and not this file's. A stand-in in a test is an
|
|
28
|
+
// ordinary HTTP/1.1 server, so what a test proves is the shape and the
|
|
29
|
+
// signing rather than the transport, as the tab's push service did.
|
|
30
|
+
import type { Ring, Token } from './push.ts';
|
|
31
|
+
|
|
32
|
+
const subtle = () => globalThis.crypto.subtle;
|
|
33
|
+
const ES256 = { name: 'ECDSA', namedCurve: 'P-256' } as const;
|
|
34
|
+
|
|
35
|
+
const b64url = (bytes: Uint8Array): string => {
|
|
36
|
+
let binary = '';
|
|
37
|
+
for (const b of bytes) binary += String.fromCharCode(b);
|
|
38
|
+
return btoa(binary).replaceAll('+', '-').replaceAll('/', '_').replaceAll('=', '');
|
|
39
|
+
};
|
|
40
|
+
const utf8 = (v: string) => new TextEncoder().encode(v);
|
|
41
|
+
|
|
42
|
+
// A `.p8` from the portal is PKCS#8 in a PEM wrapper. The wrapper is
|
|
43
|
+
// stripped rather than parsed: the base64 between the two lines is the DER.
|
|
44
|
+
export function pkcs8(pem: string): Uint8Array {
|
|
45
|
+
const body = pem.replace(/-----(BEGIN|END)[^-]*-----/g, '').replace(/\s+/g, '');
|
|
46
|
+
if (!body) throw new Error('an APNs key is a PKCS#8 PEM');
|
|
47
|
+
const binary = atob(body);
|
|
48
|
+
const out = new Uint8Array(binary.length);
|
|
49
|
+
for (let at = 0; at < binary.length; at += 1) out[at] = binary.charCodeAt(at);
|
|
50
|
+
return out;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// What a publisher holds. The key is per team and not per app, so one of
|
|
54
|
+
// these rings every app id under that team; `topic` is which app is being
|
|
55
|
+
// rung, and it is the bundle id.
|
|
56
|
+
export type Apns = { key: string; keyId: string; teamId: string; topic: string };
|
|
57
|
+
|
|
58
|
+
// Apple's two hosts. A token is minted by one of them and means nothing to
|
|
59
|
+
// the other, and only the device knows which: a build signed for
|
|
60
|
+
// development gets a sandbox token, a build from a store gets a production
|
|
61
|
+
// one, and nothing in the token itself says so. So the device says it, in
|
|
62
|
+
// the token it hands over.
|
|
63
|
+
export const HOSTS = { production: 'https://api.push.apple.com', sandbox: 'https://api.sandbox.push.apple.com' };
|
|
64
|
+
|
|
65
|
+
// Apple wants this refreshed no more often than every twenty minutes and no
|
|
66
|
+
// less than every sixty, so it is minted once and kept for forty-five.
|
|
67
|
+
const LIFE = 45 * 60 * 1000;
|
|
68
|
+
|
|
69
|
+
// The provider token, and the request one ring makes. Both are here and
|
|
70
|
+
// neither is a transport, because Apple is reached two ways in this kit and
|
|
71
|
+
// what is sent must be the same both times: a `fetch` where a `fetch`
|
|
72
|
+
// speaks HTTP/2, and `node:http2` in `http2.ts` where it does not.
|
|
73
|
+
export async function provider(a: Apns, made: { at: number; jwt: string } | null): Promise<{ at: number; jwt: string }> {
|
|
74
|
+
const now = Date.now();
|
|
75
|
+
if (made && now - made.at < LIFE) return made;
|
|
76
|
+
const key = await subtle().importKey('pkcs8', pkcs8(a.key) as BufferSource, ES256, false, ['sign']);
|
|
77
|
+
const head = b64url(utf8(JSON.stringify({ alg: 'ES256', kid: a.keyId })));
|
|
78
|
+
const claims = b64url(utf8(JSON.stringify({ iss: a.teamId, iat: Math.floor(now / 1000) })));
|
|
79
|
+
const signed = await subtle().sign({ name: 'ECDSA', hash: 'SHA-256' }, key, utf8(`${head}.${claims}`));
|
|
80
|
+
return { at: now, jwt: `${head}.${claims}.${b64url(new Uint8Array(signed))}` };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// The device a token names, or nothing a ring may send. It is checked
|
|
84
|
+
// where a human can read why rather than by Apple two hops later.
|
|
85
|
+
export function device(token: Token): string {
|
|
86
|
+
const device = token.device;
|
|
87
|
+
if (typeof device !== 'string' || !/^[0-9a-fA-F]{8,200}$/.test(device)) throw new Error('an apns token is a device token');
|
|
88
|
+
return device;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// One ring, as Apple reads it: the path, the headers and the body. A ring
|
|
92
|
+
// with a banner is an alert Apple must show, and `mutable-content` is what
|
|
93
|
+
// sends it through the device's own extension first, which is the only
|
|
94
|
+
// process that can open the seal. The title and body Apple carries are the
|
|
95
|
+
// placeholder the extension replaces, and they say nothing: they are what a
|
|
96
|
+
// person sees if her own phone cannot open its own banner. A ring with no
|
|
97
|
+
// banner stays a background wake, where the device drains what it missed
|
|
98
|
+
// over Quo and composes its own line.
|
|
99
|
+
export function request(a: Apns, jwt: string, token: Token, sealed?: string): { path: string; headers: Record<string, string>; body: string } {
|
|
100
|
+
const said =
|
|
101
|
+
sealed === undefined
|
|
102
|
+
? { aps: { 'content-available': 1 } }
|
|
103
|
+
: { aps: { alert: { 'loc-key': 'quo.ring' }, 'mutable-content': 1, sound: 'default' }, sealed };
|
|
104
|
+
return {
|
|
105
|
+
path: `/3/device/${device(token)}`,
|
|
106
|
+
headers: {
|
|
107
|
+
authorization: `bearer ${jwt}`,
|
|
108
|
+
'apns-topic': a.topic,
|
|
109
|
+
'apns-push-type': sealed === undefined ? 'background' : 'alert',
|
|
110
|
+
'apns-priority': sealed === undefined ? '5' : '10',
|
|
111
|
+
'apns-expiration': '0',
|
|
112
|
+
},
|
|
113
|
+
body: JSON.stringify(said),
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// What Apple said. 410 is Apple saying this device token is gone. Nothing
|
|
118
|
+
// here forgets it, as nothing forgets a spent Web Push endpoint: a device
|
|
119
|
+
// sends a fresh token whenever it comes to the foreground, and a world that
|
|
120
|
+
// dropped one would stop ringing a phone that is merely off.
|
|
121
|
+
export function answered(status: number, text: string): void {
|
|
122
|
+
if (status < 200 || status > 299) throw new Error(`apns answered ${status} ${text.slice(0, 200)}`);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// The ground. An `apns` token is a device token and which of Apple's two
|
|
126
|
+
// hosts minted it; `at` is what a test points somewhere else.
|
|
127
|
+
export function apnsPush(a: Apns, at: typeof HOSTS = HOSTS): Ring {
|
|
128
|
+
let made: { at: number; jwt: string } | null = null;
|
|
129
|
+
return {
|
|
130
|
+
async ring(token: Token, sealed?: string) {
|
|
131
|
+
made = await provider(a, made);
|
|
132
|
+
const host = token.sandbox === true ? at.sandbox : at.production;
|
|
133
|
+
const req = request(a, made.jwt, token, sealed);
|
|
134
|
+
const res = await fetch(`${host}${req.path}`, { method: 'POST', headers: req.headers, body: req.body });
|
|
135
|
+
answered(res.status, await res.text());
|
|
136
|
+
},
|
|
137
|
+
};
|
|
138
|
+
}
|
package/dock/console.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// The box, as one being. A device has no surface of its own, so a box that
|
|
3
|
+
// has joined no world has nothing to draw: the Console is that page and only
|
|
4
|
+
// that. She carries the faculties her harbor stood, showing each one's asks
|
|
5
|
+
// as her own, and the ordinary screen draws her exactly as it draws a
|
|
6
|
+
// world's user being, through one avatar holding one standing.
|
|
7
|
+
//
|
|
8
|
+
// She exists because a fresh install otherwise asks for location and
|
|
9
|
+
// notifications in its manifest and shows nothing that ever asks for them,
|
|
10
|
+
// which is a store reviewer's first question and a human's second.
|
|
11
|
+
//
|
|
12
|
+
// She carries for whoever the harbor's root let in, and for nobody else: the
|
|
13
|
+
// root is the box, and it invites the one avatar its own screen runs.
|
|
14
|
+
import { Carrier } from '../beings/carry.ts';
|
|
15
|
+
import type { Asker, OccupantRecord } from '@nervur-org/nervur';
|
|
16
|
+
|
|
17
|
+
export const CONSOLE = 'console';
|
|
18
|
+
|
|
19
|
+
export class Console extends Carrier {
|
|
20
|
+
static override carries(occupant: OccupantRecord | undefined, _asker: Asker): boolean {
|
|
21
|
+
return occupant !== undefined;
|
|
22
|
+
}
|
|
23
|
+
}
|
package/dock/dialers.ts
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// The box's dialers: `dialers`, one faculty of the dock ward, standing for
|
|
3
|
+
// the sockets this box holds open to other harbors' `quo.` routes. A Mac
|
|
4
|
+
// behind NAT, a phone on the mobile network, a droplet dialing home: each
|
|
5
|
+
// is a dialer, and what it dials is a row in her cells, placed once by the
|
|
6
|
+
// root with `keep` the way a schedule is placed on the clock and an agent
|
|
7
|
+
// on the agents faculty, in no file.
|
|
8
|
+
//
|
|
9
|
+
// A row is one URL, the `quo.` route of the harbor dialed. She holds the
|
|
10
|
+
// socket from her birth on, as every faculty holds what the box holds: one
|
|
11
|
+
// line per row, announcing this harbor's wards on open, binding the far
|
|
12
|
+
// side's pks, reconnecting with backoff when the line drops, and closed
|
|
13
|
+
// when the row is dropped or the box goes down. The dialer itself is the
|
|
14
|
+
// library's, written on the standard socket surface, so this faculty holds
|
|
15
|
+
// on every terrain that has a socket; the daemon hands her the harbor to
|
|
16
|
+
// dial for, as it hands the agents faculty how to run a row.
|
|
17
|
+
import { OWNER } from '@nervur-org/nervur';
|
|
18
|
+
import type { Asker, JsonObject, OccupantRecord } from '@nervur-org/nervur';
|
|
19
|
+
import { dial, type Dialer, type Harbor } from '@nervur-org/nervur/harbor';
|
|
20
|
+
import { Faculty } from './faculty.ts';
|
|
21
|
+
|
|
22
|
+
// The name the dock ward holds her under.
|
|
23
|
+
export const DIALERS = 'dialers';
|
|
24
|
+
|
|
25
|
+
export type Row = { url: string };
|
|
26
|
+
|
|
27
|
+
// The root and nobody else: a line held open is the device's own to place.
|
|
28
|
+
const root = (_occ: OccupantRecord | undefined, asker: Asker) => asker.id === OWNER;
|
|
29
|
+
|
|
30
|
+
const URL_ = { type: 'object', properties: { url: { type: 'string' } }, required: ['url'] };
|
|
31
|
+
|
|
32
|
+
export class Dialers extends Faculty {
|
|
33
|
+
static override cells: JsonObject = { watched: {}, minted: 0, dialers: {} };
|
|
34
|
+
static override asks = {
|
|
35
|
+
keep: { description: 'hold a socket to that quo. route, from now and from every boot, until it is dropped', input: URL_, for: root },
|
|
36
|
+
drop: { description: 'close the socket to that route and forget it', input: URL_, for: root },
|
|
37
|
+
list: { description: 'every route she dials, and whether the line is open now', input: { type: 'object' }, for: root },
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
harbor: Harbor | undefined;
|
|
41
|
+
readonly open = new Map<string, Dialer>();
|
|
42
|
+
|
|
43
|
+
private get rows(): Record<string, Row> {
|
|
44
|
+
return this.cells.dialers as unknown as Record<string, Row>;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Every row dialed, once the daemon has handed the harbor to dial for.
|
|
48
|
+
start(harbor: Harbor): void {
|
|
49
|
+
this.harbor = harbor;
|
|
50
|
+
for (const row of Object.values(this.rows)) this.#dial(row.url);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
#dial(url: string): void {
|
|
54
|
+
if (!this.harbor || this.open.has(url)) return;
|
|
55
|
+
this.open.set(url, dial(this.harbor, url));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
keep(args: JsonObject): JsonObject {
|
|
59
|
+
const url = args.url;
|
|
60
|
+
if (typeof url !== 'string' || !/^https?:\/\/\S+$/.test(url)) return { error: 'url is the quo. route of the harbor dialed, over http or https' };
|
|
61
|
+
const clean = url.replace(/\/$/, '');
|
|
62
|
+
this.rows[clean] = { url: clean };
|
|
63
|
+
this.#dial(clean);
|
|
64
|
+
return { kept: clean };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
drop(args: JsonObject): JsonObject {
|
|
68
|
+
const url = typeof args.url === 'string' ? args.url.replace(/\/$/, '') : '';
|
|
69
|
+
if (!this.rows[url]) return { error: 'no such route' };
|
|
70
|
+
this.open.get(url)?.close();
|
|
71
|
+
this.open.delete(url);
|
|
72
|
+
delete this.rows[url];
|
|
73
|
+
return { dropped: url };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
list(): JsonObject {
|
|
77
|
+
return { dialers: Object.keys(this.rows).map((url) => ({ url, open: this.open.get(url)?.socket !== null && this.open.has(url) })) };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// The device puts the box down: every line closes, the rows stay.
|
|
81
|
+
close(): void {
|
|
82
|
+
for (const d of this.open.values()) d.close();
|
|
83
|
+
this.open.clear();
|
|
84
|
+
}
|
|
85
|
+
}
|
package/dock/faculty.ts
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// A faculty: one being of the dock ward, standing for one thing the box can
|
|
3
|
+
// do. She is a being and nothing else, so what is here is not a second kind
|
|
4
|
+
// of object but the two moves every faculty shares.
|
|
5
|
+
//
|
|
6
|
+
// Out: whoever holds a standing at her asks her methods, and the occupant's
|
|
7
|
+
// notes say what that caller is, a prefix on a store, an account at a
|
|
8
|
+
// vendor. Her own cells say what the thing is.
|
|
9
|
+
//
|
|
10
|
+
// Back: `watch` is the one subscribe verb every faculty has. A being who
|
|
11
|
+
// wants waking mints an invitation and hands it over, this takes it as a
|
|
12
|
+
// standing, and from then on she is asked at her own door as the occupant
|
|
13
|
+
// she named. `occupants.remove` on her side is the whole of unsubscribe: the
|
|
14
|
+
// next wake hears `removed` and the row goes.
|
|
15
|
+
//
|
|
16
|
+
// She holds what the box holds and begins holding it at birth: the
|
|
17
|
+
// connection, the interval, the worker, the route are opened in her
|
|
18
|
+
// constructor, and she is born whether or not the thing is reachable,
|
|
19
|
+
// answering an error object while it is not.
|
|
20
|
+
import { Being, isInvitation, isSilence, isWord, wordOf } from '@nervur-org/nervur';
|
|
21
|
+
import type { Asker, JsonObject } from '@nervur-org/nervur';
|
|
22
|
+
|
|
23
|
+
// One subscription, as she files it: her own id for the relation, who asked
|
|
24
|
+
// for it, and what to ask them.
|
|
25
|
+
export type Watched = { by: string; method: string; args: JsonObject };
|
|
26
|
+
|
|
27
|
+
// What one round of waking did, per subscription.
|
|
28
|
+
export type Woke = { id: string; by: string; out: unknown };
|
|
29
|
+
|
|
30
|
+
export class Faculty extends Being {
|
|
31
|
+
static override cells: JsonObject = { watched: {}, minted: 0 };
|
|
32
|
+
|
|
33
|
+
// Someone wants waking. She hands the invitation she minted and says what
|
|
34
|
+
// to ask her; this knocks it, takes it under an id of this being's own,
|
|
35
|
+
// and files it. The id never crosses: it is this side's name for the
|
|
36
|
+
// relation, and the caller's name for the same relation is her own.
|
|
37
|
+
//
|
|
38
|
+
// The empty ask is the knock, because every being answers one and asking
|
|
39
|
+
// for a named ask would make every subscriber owe a method she does not
|
|
40
|
+
// otherwise need.
|
|
41
|
+
async watch(args: JsonObject, asker: Asker): Promise<JsonObject> {
|
|
42
|
+
const inv = args.invitation;
|
|
43
|
+
if (!isInvitation(inv) || typeof args.method !== 'string') return { error: 'an invitation and a method' };
|
|
44
|
+
const extra = args.args;
|
|
45
|
+
if (extra !== undefined && (extra === null || typeof extra !== 'object' || Array.isArray(extra))) return { error: 'args is one object' };
|
|
46
|
+
const out = await this.knock(inv);
|
|
47
|
+
if (isSilence(out) || isWord(out)) return { error: 'no way back' };
|
|
48
|
+
const cells = this.cells as unknown as { watched: Record<string, Watched>; minted: number };
|
|
49
|
+
const id = await this.take(`wake:${(cells.minted += 1)}`, inv);
|
|
50
|
+
if (id === null) return { error: 'not taken' };
|
|
51
|
+
cells.watched[id] = { by: asker.id ?? '', method: args.method, args: extra ?? {} };
|
|
52
|
+
await this.welcomed(id);
|
|
53
|
+
return { watching: id };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Somebody new is filed, and the answer to her `watch` waits for this. A
|
|
57
|
+
// faculty that owes a new subscriber something at once says it here: the
|
|
58
|
+
// timer wakes her, learns when she is next due and arms the box for it, so
|
|
59
|
+
// a being born long after the box was up is not waiting for a round nobody
|
|
60
|
+
// scheduled. Most faculties owe nothing, and this is why they say nothing.
|
|
61
|
+
protected async welcomed(_id: string): Promise<void> {}
|
|
62
|
+
|
|
63
|
+
// One round of waking. Each subscriber is asked what she asked to be
|
|
64
|
+
// asked, with whatever this round adds. A subscriber who removed the
|
|
65
|
+
// occupant says `removed` once, and her row goes with the standing; there
|
|
66
|
+
// is nothing to retry and nothing to remember.
|
|
67
|
+
//
|
|
68
|
+
// `only` is one subscriber of the round's own choosing, named by this
|
|
69
|
+
// being's own id for the relation, and it is the same round down to one
|
|
70
|
+
// row: a faculty that wakes somebody just filed learns exactly what a full
|
|
71
|
+
// round would have learned about her.
|
|
72
|
+
//
|
|
73
|
+
// `dropped` is this being herself: her ward took her out, so the standings
|
|
74
|
+
// she holds reach nobody. Null says so, and whoever holds the interval puts
|
|
75
|
+
// it down rather than waking an empty room for as long as the box is up.
|
|
76
|
+
protected async woke(round: (w: Watched) => JsonObject = (w) => w.args, only?: string): Promise<Woke[] | null> {
|
|
77
|
+
const cells = this.cells as unknown as { watched: Record<string, Watched> };
|
|
78
|
+
const out: Woke[] = [];
|
|
79
|
+
for (const [id, w] of Object.entries(cells.watched)) {
|
|
80
|
+
if (only !== undefined && id !== only) continue;
|
|
81
|
+
const said = await this.standings[id]?.ask(w.method, round(w));
|
|
82
|
+
if (said === undefined || (isWord(said) && wordOf(said) === 'dropped')) return null;
|
|
83
|
+
if (isWord(said) && wordOf(said) === 'removed') {
|
|
84
|
+
this.standings.remove(id);
|
|
85
|
+
delete cells.watched[id];
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
out.push({ id, by: w.by, out: said });
|
|
89
|
+
}
|
|
90
|
+
return out;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Who is waiting, by this being's own id for the relation. For a device's
|
|
94
|
+
// own code and for a suite; it crosses no door.
|
|
95
|
+
get watching(): Record<string, Watched> {
|
|
96
|
+
return this.cells.watched as unknown as Record<string, Watched>;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// The three moves a being makes at every birth to hold what her box lends.
|
|
101
|
+
// A standing points at a being and not at a box, so one she wakes up holding
|
|
102
|
+
// names a being on the box she was on when it was made; a restart and a
|
|
103
|
+
// migration are the same silent event to her, so she does these again every
|
|
104
|
+
// time she is constructed and is always pointed at the box she is on.
|
|
105
|
+
//
|
|
106
|
+
// `watch` says she wants waking too: her ward mints the occupant, the
|
|
107
|
+
// faculty takes the standing it makes, and from then on the wake arrives at
|
|
108
|
+
// her door under the id she chose.
|
|
109
|
+
// Every join in flight on this box. A being takes what her box lends at her
|
|
110
|
+
// birth, and birth is synchronous while taking a standing is not, so a
|
|
111
|
+
// harbor that answered "up" before they landed would be a box whose first
|
|
112
|
+
// tick woke nobody and whose first ring reached no platform. The terrain
|
|
113
|
+
// waits on this once, after its wards are hosted; nothing else reads it, and
|
|
114
|
+
// a join that fails leaves nothing behind but a being who asks again.
|
|
115
|
+
const flight = new Set<Promise<unknown>>();
|
|
116
|
+
export const settled = async (): Promise<void> => {
|
|
117
|
+
while (flight.size) {
|
|
118
|
+
const now = [...flight];
|
|
119
|
+
await Promise.allSettled(now);
|
|
120
|
+
for (const p of now) flight.delete(p);
|
|
121
|
+
}
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
export function join(b: Being, name: string, watch?: { method: string; args?: JsonObject }, id = name): Promise<string | null> {
|
|
125
|
+
const p = joining(b, name, watch, id);
|
|
126
|
+
flight.add(p);
|
|
127
|
+
void p.catch(() => {});
|
|
128
|
+
return p;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async function joining(b: Being, name: string, watch: { method: string; args?: JsonObject } | undefined, id: string): Promise<string | null> {
|
|
132
|
+
// One turn, before anything is asked. A being is called at her door and
|
|
133
|
+
// holds her stance from her first line, but her ward sets that door after
|
|
134
|
+
// her constructor returns, so a stance call made inside it reaches a being
|
|
135
|
+
// her ward does not hold yet and is answered as one taken out. Every
|
|
136
|
+
// faculty relation is made at birth, so the wait belongs here rather than
|
|
137
|
+
// in each of them.
|
|
138
|
+
await Promise.resolve();
|
|
139
|
+
b.standings.remove(id);
|
|
140
|
+
b.occupants.remove(`${id}:wake`);
|
|
141
|
+
const got = await b.lend(name, id);
|
|
142
|
+
if (got === null) return null;
|
|
143
|
+
if (!watch) return got;
|
|
144
|
+
const back = await b.invite(`${id}:wake`);
|
|
145
|
+
if (!back) return got;
|
|
146
|
+
await b.standings[id]?.ask('watch', { invitation: back, method: watch.method, ...(watch.args ? { args: watch.args } : {}) });
|
|
147
|
+
return got;
|
|
148
|
+
}
|
package/dock/fcm.ts
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// FCM as a bell ground: Google's way of waking an app, and the second of
|
|
3
|
+
// the two the stores own. Like APNs it wants an account, a project and a
|
|
4
|
+
// key, which is why it lives behind a bell service and not in a world.
|
|
5
|
+
//
|
|
6
|
+
// Two hops rather than one. Google does not take a signed token at the send
|
|
7
|
+
// door the way Apple does: a service account's key signs a JWT, that JWT is
|
|
8
|
+
// exchanged at Google's OAuth door for an access token, and the access
|
|
9
|
+
// token sends. The exchange is what is cached, for an hour less a minute,
|
|
10
|
+
// and one exchange serves every ring in that hour.
|
|
11
|
+
//
|
|
12
|
+
// The ring carries nothing, so the message is data only with an empty data
|
|
13
|
+
// map and no `notification` key: a message carrying a notification would be
|
|
14
|
+
// displayed by Android itself, out of the app's hands and out of the
|
|
15
|
+
// human's, saying whatever the sender put in it. Data only wakes the app,
|
|
16
|
+
// which dials, drains what it missed over Quo, sealed, and composes what
|
|
17
|
+
// the human reads. High priority is what wakes a dozing device; it is the
|
|
18
|
+
// same promise Apple's background push makes, which is best effort.
|
|
19
|
+
import type { Ring, Token } from './push.ts';
|
|
20
|
+
|
|
21
|
+
const subtle = () => globalThis.crypto.subtle;
|
|
22
|
+
const RS256 = { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' } as const;
|
|
23
|
+
|
|
24
|
+
const b64url = (bytes: Uint8Array): string => {
|
|
25
|
+
let binary = '';
|
|
26
|
+
for (const b of bytes) binary += String.fromCharCode(b);
|
|
27
|
+
return btoa(binary).replaceAll('+', '-').replaceAll('/', '_').replaceAll('=', '');
|
|
28
|
+
};
|
|
29
|
+
const utf8 = (v: string) => new TextEncoder().encode(v);
|
|
30
|
+
|
|
31
|
+
const der = (pem: string): Uint8Array => {
|
|
32
|
+
const body = pem.replace(/-----(BEGIN|END)[^-]*-----/g, '').replace(/\s+/g, '');
|
|
33
|
+
if (!body) throw new Error('a service account key is a PKCS#8 PEM');
|
|
34
|
+
const binary = atob(body);
|
|
35
|
+
const out = new Uint8Array(binary.length);
|
|
36
|
+
for (let at = 0; at < binary.length; at += 1) out[at] = binary.charCodeAt(at);
|
|
37
|
+
return out;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
// The three fields of the JSON Google hands out when a service account is
|
|
41
|
+
// given a key. The whole file has more in it and none of the rest is read
|
|
42
|
+
// here, so what a publisher keeps as a secret is the file as it came.
|
|
43
|
+
export type Account = { project_id: string; client_email: string; private_key: string };
|
|
44
|
+
|
|
45
|
+
export function isAccount(v: unknown): v is Account {
|
|
46
|
+
const a = v as Partial<Account> | null;
|
|
47
|
+
return a !== null && typeof a === 'object' && typeof a.project_id === 'string' && typeof a.client_email === 'string' && typeof a.private_key === 'string';
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Where the two doors are, so a test may point them somewhere else.
|
|
51
|
+
export type Doors = { oauth: string; fcm: string };
|
|
52
|
+
export const DOORS: Doors = { oauth: 'https://oauth2.googleapis.com/token', fcm: 'https://fcm.googleapis.com' };
|
|
53
|
+
|
|
54
|
+
const SCOPE = 'https://www.googleapis.com/auth/firebase.messaging';
|
|
55
|
+
const LIFE = 59 * 60 * 1000;
|
|
56
|
+
|
|
57
|
+
async function access(a: Account, at: Doors, held: { at: number; token: string } | null): Promise<{ at: number; token: string }> {
|
|
58
|
+
const now = Date.now();
|
|
59
|
+
if (held && now - held.at < LIFE) return held;
|
|
60
|
+
const key = await subtle().importKey('pkcs8', der(a.private_key) as BufferSource, RS256, false, ['sign']);
|
|
61
|
+
const iat = Math.floor(now / 1000);
|
|
62
|
+
const head = b64url(utf8(JSON.stringify({ alg: 'RS256', typ: 'JWT' })));
|
|
63
|
+
const claims = b64url(utf8(JSON.stringify({ iss: a.client_email, scope: SCOPE, aud: at.oauth, iat, exp: iat + 3600 })));
|
|
64
|
+
const signed = await subtle().sign(RS256, key, utf8(`${head}.${claims}`));
|
|
65
|
+
const assertion = `${head}.${claims}.${b64url(new Uint8Array(signed))}`;
|
|
66
|
+
const res = await fetch(at.oauth, {
|
|
67
|
+
method: 'POST',
|
|
68
|
+
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
|
69
|
+
body: new URLSearchParams({ grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer', assertion }).toString(),
|
|
70
|
+
});
|
|
71
|
+
if (!res.ok) throw new Error(`google would not exchange the key: ${res.status} ${(await res.text()).slice(0, 200)}`);
|
|
72
|
+
const out = (await res.json()) as { access_token?: unknown };
|
|
73
|
+
if (typeof out.access_token !== 'string') throw new Error('google answered no access token');
|
|
74
|
+
return { at: now, token: out.access_token };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// The ground. An `fcm` token is the registration token the device was
|
|
78
|
+
// handed by Google's own sdk, which is opaque and is not inspected here
|
|
79
|
+
// beyond being a string with something in it.
|
|
80
|
+
export function fcmPush(a: Account, at: Doors = DOORS): Ring {
|
|
81
|
+
let held: { at: number; token: string } | null = null;
|
|
82
|
+
return {
|
|
83
|
+
async ring(token: Token, sealed?: string) {
|
|
84
|
+
const device = token.device;
|
|
85
|
+
if (typeof device !== 'string' || device.length < 8) throw new Error('an fcm token is a registration token');
|
|
86
|
+
held = await access(a, at, held);
|
|
87
|
+
// The seal rides in the data map, as it rides in the payload on the
|
|
88
|
+
// other ground, so what Google carries is ciphertext and the line is
|
|
89
|
+
// drawn on the device by the service that opens it. A ring with no
|
|
90
|
+
// seal carries an empty map and is a wake and nothing more, which is
|
|
91
|
+
// what a device that draws no banner still needs.
|
|
92
|
+
const res = await fetch(`${at.fcm}/v1/projects/${a.project_id}/messages:send`, {
|
|
93
|
+
method: 'POST',
|
|
94
|
+
headers: { authorization: `Bearer ${held.token}`, 'content-type': 'application/json' },
|
|
95
|
+
body: JSON.stringify({ message: { token: device, android: { priority: 'HIGH' }, data: sealed === undefined ? {} : { sealed } } }),
|
|
96
|
+
});
|
|
97
|
+
// 404 is Google saying this registration token is gone. Nothing here
|
|
98
|
+
// forgets it, for the reason the other two grounds do not: a device
|
|
99
|
+
// sends a fresh one whenever it comes to the foreground.
|
|
100
|
+
if (!res.ok) throw new Error(`fcm answered ${res.status} ${(await res.text()).slice(0, 200)}`);
|
|
101
|
+
},
|
|
102
|
+
};
|
|
103
|
+
}
|