@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/cli/quo.ts
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
// `quo`: the one command of the estate. Thin on purpose: each subcommand
|
|
4
|
+
// picks a folder, reaches the daemon or the disk, and hands off. Logic that
|
|
5
|
+
// appears here was missing from a being or a side, and moves there.
|
|
6
|
+
//
|
|
7
|
+
// quo init [--dir D] [--ward W] [--user U] [--class C] [--default] [--show] [--domain DOM] mint a seed, boot a ward over a disk store, write both; C is the class of the ward's home being, User unless said; --default marks it the default ward; --show shows it at the web route; --domain writes the four routes under DOM
|
|
8
|
+
// quo default [--dir D] <ward> mark one ward the default, the one a caller means when naming none; the one ward hosted is the default unmarked
|
|
9
|
+
// quo show|hide [--dir D] <ward> show a ward at the web route, listed at its root and its public being's page painted for a stranger; hidden at init, and a hidden ward's page is the plain door
|
|
10
|
+
// quo domain [--dir D] <domain> write routes.json by the convention: mcp., web., quo. and api. under the domain, over https
|
|
11
|
+
// quo serve [--dir D] [--http PORT] the daemon: harbor from disk, the two sockets, the HTTP door on loopback, a socket held to every route the dialers faculty keeps
|
|
12
|
+
// quo census [--dir D] [--ward W] [--via S] the empty ask: pk and every being
|
|
13
|
+
// quo boot [--dir D] [--ward W] [--via S] <json> { key, class, public? }
|
|
14
|
+
// quo invite [--dir D] [--ward W] [--via S] <json> { being, id }; with routes.json naming web., the answer carries the link
|
|
15
|
+
// quo knock [--dir D] [--ward W] [--via S] <json> { being | { boot, key }, id, invitation, method?, args?, wanted? }
|
|
16
|
+
// quo remove [--dir D] [--ward W] [--via S] <json> { being, id }: a relation out of a being; on the ward pk, an owner
|
|
17
|
+
// quo unboot [--dir D] [--ward W] [--via S] <json> { being }: a being out of the ward, with every relation she holds
|
|
18
|
+
// quo ask [--dir D] [--ward W] <being> [method] [json] the root asking a being of its own ward, as the device
|
|
19
|
+
// quo side [--dir D] [--ward W] --as NAME an avatar over stdio for a local MCP client; NAME is the client identity
|
|
20
|
+
// quo run [--dir D] [--ward W] --as NAME --url URL --model NAME [--turns N] a human at a terminal talking to a model through an avatar: a line in, the model's final text out
|
|
21
|
+
// quo pilot [--dir D] [--ward W] [--via S] the owner pilot over stdio: census, boot, invite, knock, remove
|
|
22
|
+
// quo reach [--dir D] <pk> <url> a hint for the harbor's directory: that ward pk lives at that quo. route
|
|
23
|
+
// quo estate <dir> --domain D an estate folder of the shape every estate has: a droplet's quo directory, unit and Caddyfile, a Mac's launchd agent, on this dock
|
|
24
|
+
//
|
|
25
|
+
// D defaults to $QUO_DIR, then ~/.quo. W defaults to the default ward, and
|
|
26
|
+
// init names a new ward main when none is given. init takes
|
|
27
|
+
// --user NAME for the user being, defaulting to the device's user. run
|
|
28
|
+
// takes the model's base URL as --url or $QUO_MODEL_URL, its name as
|
|
29
|
+
// --model or $QUO_MODEL, a bearer from $QUO_MODEL_KEY, read here and never
|
|
30
|
+
// kept, and --turns as the ceiling of model calls in one turn. S is a
|
|
31
|
+
// standing the user being holds at another ward, one the root there invited
|
|
32
|
+
// as an owner: with --via, the owner asks go to that ward's door, sealed,
|
|
33
|
+
// and the answers are its. Without it they are the root's, on the pointer.
|
|
34
|
+
import { homedir, userInfo } from 'node:os';
|
|
35
|
+
import { join } from 'node:path';
|
|
36
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
37
|
+
import { isInvitation } from '@nervur-org/nervur';
|
|
38
|
+
import { DiskHarbor } from '../harbor/disk.ts';
|
|
39
|
+
import { serve, readRoutes, writeRoutes } from './daemon.ts';
|
|
40
|
+
import { ask, askBeing, side, reach, type Run } from './client.ts';
|
|
41
|
+
import { link } from '../beings/link.ts';
|
|
42
|
+
import { pilotSide } from './pilot.ts';
|
|
43
|
+
import { estate } from './estate.ts';
|
|
44
|
+
|
|
45
|
+
const OWNER_ASKS = new Set(['boot', 'public', 'invite', 'knock', 'remove', 'unboot']);
|
|
46
|
+
|
|
47
|
+
function parse(argv: string[]) {
|
|
48
|
+
const flags: Record<string, string> = {};
|
|
49
|
+
const rest: string[] = [];
|
|
50
|
+
for (let i = 0; i < argv.length; i++) {
|
|
51
|
+
const a = argv[i] ?? '';
|
|
52
|
+
// a flag takes the word after it, unless that word is a flag or there is
|
|
53
|
+
// none: --default stands alone
|
|
54
|
+
if (a.startsWith('--')) flags[a.slice(2)] = argv[i + 1] === undefined || argv[i + 1]!.startsWith('--') ? '' : argv[++i]!;
|
|
55
|
+
else rest.push(a);
|
|
56
|
+
}
|
|
57
|
+
const [cmd, json, more, third] = rest;
|
|
58
|
+
return { cmd, json, more, third, flags, dir: flags.dir ?? process.env.QUO_DIR ?? join(homedir(), '.quo'), ward: flags.ward, via: flags.via };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const print = (v: unknown) => console.log(JSON.stringify(v, null, 2));
|
|
62
|
+
|
|
63
|
+
async function main(argv: string[]): Promise<number> {
|
|
64
|
+
const { cmd, json, more, third, flags, dir, via } = parse(argv);
|
|
65
|
+
if (cmd === 'init') {
|
|
66
|
+
const made = await DiskHarbor.init(dir, flags.ward, flags.user ?? userInfo().username, flags.class, 'default' in flags, 'show' in flags);
|
|
67
|
+
print(flags.domain ? { ...made, routes: await writeRoutes(dir, flags.domain) } : made);
|
|
68
|
+
return 0;
|
|
69
|
+
}
|
|
70
|
+
if (cmd === 'default') {
|
|
71
|
+
if (!json) {
|
|
72
|
+
console.error('quo default <ward>');
|
|
73
|
+
return 2;
|
|
74
|
+
}
|
|
75
|
+
await DiskHarbor.setDefault(dir, json);
|
|
76
|
+
print({ default: json });
|
|
77
|
+
return 0;
|
|
78
|
+
}
|
|
79
|
+
if (cmd === 'show' || cmd === 'hide') {
|
|
80
|
+
if (!json) {
|
|
81
|
+
console.error(`quo ${cmd} <ward>`);
|
|
82
|
+
return 2;
|
|
83
|
+
}
|
|
84
|
+
await DiskHarbor.setShown(dir, json, cmd === 'show');
|
|
85
|
+
print({ [cmd === 'show' ? 'shown' : 'hidden']: json });
|
|
86
|
+
return 0;
|
|
87
|
+
}
|
|
88
|
+
if (cmd === 'domain') {
|
|
89
|
+
if (!json) {
|
|
90
|
+
console.error('quo domain <domain>');
|
|
91
|
+
return 2;
|
|
92
|
+
}
|
|
93
|
+
print({ routes: await writeRoutes(dir, json) });
|
|
94
|
+
return 0;
|
|
95
|
+
}
|
|
96
|
+
// Every command that reaches a ward is on one: the one named, or the
|
|
97
|
+
// default, read from the folder the way the daemon reads it, and only once
|
|
98
|
+
// the command is held right.
|
|
99
|
+
const wardOf = async () => flags.ward ?? (await DiskHarbor.defaultOf(dir));
|
|
100
|
+
if (cmd === 'side' || cmd === 'run') {
|
|
101
|
+
if (!flags.as) {
|
|
102
|
+
console.error(`quo ${cmd} needs --as NAME`);
|
|
103
|
+
return 2;
|
|
104
|
+
}
|
|
105
|
+
let run: Run | undefined;
|
|
106
|
+
if (cmd === 'run') {
|
|
107
|
+
const url = flags.url ?? process.env.QUO_MODEL_URL;
|
|
108
|
+
const model = flags.model ?? process.env.QUO_MODEL;
|
|
109
|
+
if (!url || !model) {
|
|
110
|
+
console.error('quo run needs --url URL and --model NAME, or QUO_MODEL_URL and QUO_MODEL');
|
|
111
|
+
return 2;
|
|
112
|
+
}
|
|
113
|
+
const key = process.env.QUO_MODEL_KEY;
|
|
114
|
+
const turns = flags.turns !== undefined ? Number(flags.turns) : undefined;
|
|
115
|
+
if (turns !== undefined && !(Number.isInteger(turns) && turns > 0)) {
|
|
116
|
+
console.error('--turns is a positive integer');
|
|
117
|
+
return 2;
|
|
118
|
+
}
|
|
119
|
+
run = { url, model, ...(key ? { key } : {}), ...(turns !== undefined ? { turns } : {}) };
|
|
120
|
+
}
|
|
121
|
+
await side(dir, flags.as, await wardOf(), run); // stdio to the daemon, until one end closes
|
|
122
|
+
return -1;
|
|
123
|
+
}
|
|
124
|
+
if (cmd === 'pilot') {
|
|
125
|
+
const ward = await wardOf();
|
|
126
|
+
await ask(dir, undefined, {}, ward); // no daemon is a clear word now, not at the first tool call
|
|
127
|
+
const serving = await pilotSide(dir, ward, new StdioServerTransport(), via);
|
|
128
|
+
process.stdin.once('end', () => void serving.close().then(() => process.exit(0)));
|
|
129
|
+
return -1;
|
|
130
|
+
}
|
|
131
|
+
if (cmd === 'serve') {
|
|
132
|
+
const port = flags.http ?? process.env.QUO_HTTP;
|
|
133
|
+
const s = await serve(dir, port !== undefined ? { http: { port: Number(port) } } : {});
|
|
134
|
+
print({ dir: s.harbor.dir, sock: s.sock, side: s.side, http: s.http ? `http://${s.http.host}:${s.http.port}` : null, dial: s.quo.dialers.map((d) => d.url), wards: Object.fromEntries([...s.harbor.wards].map(([n, h]) => [n, h.pk])) });
|
|
135
|
+
const stop = () => void s.close().then(() => process.exit(0));
|
|
136
|
+
process.once('SIGINT', stop);
|
|
137
|
+
process.once('SIGTERM', stop);
|
|
138
|
+
return -1; // stays up
|
|
139
|
+
}
|
|
140
|
+
if (cmd === 'estate') {
|
|
141
|
+
if (!json || !flags.domain) {
|
|
142
|
+
console.error('quo estate needs <dir> --domain D');
|
|
143
|
+
return 2;
|
|
144
|
+
}
|
|
145
|
+
print(await estate(json, flags.domain));
|
|
146
|
+
return 0;
|
|
147
|
+
}
|
|
148
|
+
// The root reaching into a being of its own ward. Two positionals and the
|
|
149
|
+
// args as JSON, because a human writes this one by hand: `quo ask clock keep
|
|
150
|
+
// '{"id":"nightly","ask":"desk.hello","cron":"0 3 * * *"}'`. With no
|
|
151
|
+
// method it is that being's describe, as the device sees her.
|
|
152
|
+
if (cmd === 'ask') {
|
|
153
|
+
if (!json) {
|
|
154
|
+
console.error('quo ask needs <being> [method] [json]');
|
|
155
|
+
return 2;
|
|
156
|
+
}
|
|
157
|
+
const out = await askBeing(dir, json, more, third ? (JSON.parse(third) as Record<string, unknown>) : {}, await wardOf());
|
|
158
|
+
print('error' in out ? { error: out.error } : out.result);
|
|
159
|
+
const failed = 'error' in out || (typeof out.result === 'object' && out.result !== null && 'error' in out.result);
|
|
160
|
+
return failed ? 1 : 0;
|
|
161
|
+
}
|
|
162
|
+
if (cmd === 'reach') {
|
|
163
|
+
if (!json || !more) {
|
|
164
|
+
console.error('quo reach needs <pk> <url>');
|
|
165
|
+
return 2;
|
|
166
|
+
}
|
|
167
|
+
const out = await reach(dir, json, more);
|
|
168
|
+
print('error' in out ? { error: out.error } : out.result);
|
|
169
|
+
return 'error' in out ? 1 : 0;
|
|
170
|
+
}
|
|
171
|
+
if (cmd === 'census' || (cmd !== undefined && OWNER_ASKS.has(cmd))) {
|
|
172
|
+
const args = json ? (JSON.parse(json) as Record<string, unknown>) : {};
|
|
173
|
+
const ward = await wardOf();
|
|
174
|
+
let out = await ask(dir, cmd === 'census' ? undefined : cmd, args, ward, via);
|
|
175
|
+
// an invitation minted here is handed on as a link when the harbor's
|
|
176
|
+
// routes name a web. origin: the world's page with the invitation in
|
|
177
|
+
// the fragment, one string to send to the person it is for
|
|
178
|
+
const minted = cmd === 'invite' && !via && 'result' in out && isInvitation(out.result) ? out.result : null;
|
|
179
|
+
const routes = minted ? await readRoutes(dir) : null;
|
|
180
|
+
// the ward answers null to an invite on an id already held, standing or
|
|
181
|
+
// occupant, and null on a terminal is nothing to read: it is a refusal
|
|
182
|
+
// here, in words, and an exit code a script can tell apart
|
|
183
|
+
if (cmd === 'invite' && 'result' in out && out.result === null) out = { result: { error: 'id taken' } };
|
|
184
|
+
if (minted && routes) print({ ...minted, link: link(`${routes.web}/${encodeURIComponent(ward)}`, minted) });
|
|
185
|
+
else print('error' in out ? { error: out.error } : out.result);
|
|
186
|
+
// an error object the ward answered is an ordinary answer, and still a
|
|
187
|
+
// non-zero exit, so a script can tell a boot that happened from one that did not.
|
|
188
|
+
const failed = 'error' in out || (typeof out.result === 'object' && out.result !== null && 'error' in out.result);
|
|
189
|
+
return failed ? 1 : 0;
|
|
190
|
+
}
|
|
191
|
+
console.error('usage: quo init|default|show|hide|domain|serve|census|boot|public|invite|knock|remove|unboot|ask|side|run|pilot|reach|estate [--dir D] [--ward W] [--via S] [--as NAME] [--url URL --model NAME] [json]');
|
|
192
|
+
return 2;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
main(process.argv.slice(2)).then(
|
|
196
|
+
(code) => {
|
|
197
|
+
if (code >= 0) process.exit(code);
|
|
198
|
+
},
|
|
199
|
+
(e: unknown) => {
|
|
200
|
+
console.error(e instanceof Error ? e.message : String(e));
|
|
201
|
+
process.exit(1);
|
|
202
|
+
},
|
|
203
|
+
);
|
package/cli/stand.ts
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// Standing a harbor up: the beings an estate boots for its own working and
|
|
3
|
+
// the relations between them, placed by the root once, from the estate's
|
|
4
|
+
// own `stand.ts`, on real invitations. The desk and the user being, the
|
|
5
|
+
// bell, an estate's front, the clock with her keep and her voice: each is
|
|
6
|
+
// furniture the estate owns rather than a world anyone keeps on it, and each
|
|
7
|
+
// is placed the way every relation is placed, an invite on the far ward's
|
|
8
|
+
// owner pointer and a knock on the near one. Nothing here is read at boot:
|
|
9
|
+
// the wards are the whole truth about what stands, and a script is a
|
|
10
|
+
// recipe run once, never a second record kept in step.
|
|
11
|
+
//
|
|
12
|
+
// Five rules, and the first is the one the rest exist to protect.
|
|
13
|
+
//
|
|
14
|
+
// **It places ordinary relations.** Real keys, rotating, written into both
|
|
15
|
+
// beings' cells, removable by hand afterwards like any other. Never a channel
|
|
16
|
+
// the harbor grants itself. The moment a standing exists because the harbor
|
|
17
|
+
// said so rather than because an invitation was minted and taken, a being's
|
|
18
|
+
// occupants stop being the whole truth about who can reach her, and every
|
|
19
|
+
// gate in this tree rests on that being true.
|
|
20
|
+
//
|
|
21
|
+
// **It is idempotent.** A being already booted is left alone and a standing
|
|
22
|
+
// already held is left alone, so a script run twice stands the estate once.
|
|
23
|
+
//
|
|
24
|
+
// **Same harbor only.** Both ends name a ward this device hosts. Across
|
|
25
|
+
// harbors an invitation is not bookkeeping, it is consent, and there is no
|
|
26
|
+
// shared root to automate it with: those relations stay a human's to make.
|
|
27
|
+
//
|
|
28
|
+
// **It adds and never removes.** A line taken out of a script leaves its
|
|
29
|
+
// relation standing, because silently unplacing a relation is a wake that
|
|
30
|
+
// stops with nobody told. Removal is a deliberate `quo remove`.
|
|
31
|
+
//
|
|
32
|
+
// **It fails loudly.** A line that will not place stops the script where it
|
|
33
|
+
// is, and a harbor half stood is said, never served.
|
|
34
|
+
import type { Invitation, JsonObject } from '@nervur-org/nervur';
|
|
35
|
+
import type { Harbor } from '@nervur-org/nervur/harbor';
|
|
36
|
+
import { DiskHarbor } from '../harbor/disk.ts';
|
|
37
|
+
|
|
38
|
+
// A being the estate boots, and where; `public` marks her as the one being
|
|
39
|
+
// of her ward that answers strangers, an estate's front in place of the desk.
|
|
40
|
+
export type Boot = { ward: string; key: string; class: string; public?: boolean };
|
|
41
|
+
|
|
42
|
+
// One arrow. `from` ends up holding the standing, `to` ends up holding the
|
|
43
|
+
// occupant, which is what a relation in Quo is: one direction, and the answer
|
|
44
|
+
// rides the ask back. `as` names the standing on the near side and defaults to
|
|
45
|
+
// the far being's key, because that is the name a schedule or a caller will
|
|
46
|
+
// write. `known` names the occupant on the far side and defaults to the near
|
|
47
|
+
// being's key. `notes` are the terms the occupant is minted under, hers to
|
|
48
|
+
// read on her gate and nobody else's to write, which is how a watcher is
|
|
49
|
+
// declared here rather than in code.
|
|
50
|
+
export type Wire = { from: string; to: string; as?: string; known?: string; notes?: JsonObject };
|
|
51
|
+
|
|
52
|
+
export type Stand = { boot?: Boot[]; wire?: Wire[] };
|
|
53
|
+
|
|
54
|
+
const KEY = /^[\w.:-]+$/;
|
|
55
|
+
const RESERVED = new Set(['knock', 'take', 'remove']);
|
|
56
|
+
|
|
57
|
+
const fail = (why: string): never => {
|
|
58
|
+
throw new Error(`stand: ${why}`);
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
// `<ward>.<key>`, split at the first dot: a ward's name is a plain word and a
|
|
62
|
+
// being's key is not, so the ambiguity only ever falls one way.
|
|
63
|
+
function at(v: string, what: string): { ward: string; key: string } {
|
|
64
|
+
const dot = v.indexOf('.');
|
|
65
|
+
if (dot < 1) return fail(`${what}: "${v}" is not "<ward>.<being>"`);
|
|
66
|
+
return { ward: v.slice(0, dot), key: v.slice(dot + 1) };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
type Wards = { wards: Map<string, { being(key: string): unknown; ask(method: string, args: JsonObject): Promise<unknown> }>; partitionOf(name: string): Record<string, unknown> | undefined };
|
|
70
|
+
|
|
71
|
+
// A name a wire would use, read off the partition the harbor already holds:
|
|
72
|
+
// whether a being holds it as a standing and whether she holds it as an
|
|
73
|
+
// occupant. Standings and occupants are one namespace, so a name held under
|
|
74
|
+
// the other kind is a collision and never this wire.
|
|
75
|
+
function holds(h: Wards, ward: string, key: string, id: string): { standing: boolean; occupant: boolean } {
|
|
76
|
+
const p = h.partitionOf(ward) as { bind?: Record<string, { standings?: Record<string, unknown> }>; beings?: Record<string, { occupants?: Record<string, unknown> }> } | undefined;
|
|
77
|
+
return { standing: Object.hasOwn(p?.bind?.[key]?.standings ?? {}, id), occupant: Object.hasOwn(p?.beings?.[key]?.occupants ?? {}, id) };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const ward = (h: Wards, name: string, why: string) => h.wards.get(name) ?? fail(`${why}: this device hosts no ward ${name}`);
|
|
81
|
+
|
|
82
|
+
// Stand a booted harbor up. Everything declared and absent is placed;
|
|
83
|
+
// everything declared and present is left exactly as it is.
|
|
84
|
+
//
|
|
85
|
+
// Idempotency is read off the mint and not off anybody's cells. A being is
|
|
86
|
+
// whatever answers, `Being` or a bare object with one method, so there is no
|
|
87
|
+
// shape here to reach into: what says a wire already stands is that the far
|
|
88
|
+
// being refuses to mint that occupant a second time. `invite` answers null
|
|
89
|
+
// for exactly one reason once the reader has held the id to a word the ward
|
|
90
|
+
// does not reserve, which is that the id is taken, and an id taken is this
|
|
91
|
+
// wire, placed the last time the script ran.
|
|
92
|
+
export async function standUp(harbor: Harbor | Wards, decl: Stand): Promise<{ booted: string[]; wired: string[]; already: string[] }> {
|
|
93
|
+
const h = harbor as Wards;
|
|
94
|
+
const booted: string[] = [];
|
|
95
|
+
for (const b of decl.boot ?? []) {
|
|
96
|
+
const w = ward(h, b.ward, `boot ${b.ward}.${b.key}`);
|
|
97
|
+
if (!w.being(b.key)) {
|
|
98
|
+
const out = (await w.ask('boot', { key: b.key, class: b.class })) as { error?: string };
|
|
99
|
+
if (out.error) fail(`boot ${b.ward}.${b.key} as ${b.class}: ${out.error}`);
|
|
100
|
+
booted.push(`${b.ward}.${b.key}`);
|
|
101
|
+
}
|
|
102
|
+
// the mark is one per ward and the script is what says who holds it:
|
|
103
|
+
// taken off whoever holds it, the desk init put there, and put on her,
|
|
104
|
+
// which is the same two asks whether or not she held it already
|
|
105
|
+
if (b.public) {
|
|
106
|
+
const off = (await w.ask('public', { key: null })) as { error?: string };
|
|
107
|
+
if (off.error) fail(`public ${b.ward}.${b.key}: ${off.error}`);
|
|
108
|
+
const out = (await w.ask('public', { key: b.key })) as { error?: string };
|
|
109
|
+
if (out.error) fail(`public ${b.ward}.${b.key}: ${out.error}`);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
const wired: string[] = [];
|
|
113
|
+
const already: string[] = [];
|
|
114
|
+
for (const one of decl.wire ?? []) {
|
|
115
|
+
const from = at(one.from, 'from'),
|
|
116
|
+
to = at(one.to, 'to');
|
|
117
|
+
const as = one.as ?? to.key,
|
|
118
|
+
known = one.known ?? from.key;
|
|
119
|
+
if (!KEY.test(as) || !KEY.test(known)) fail(`a name is a word: ${one.from} -> ${one.to}`);
|
|
120
|
+
// The three the stance keeps for itself. A ward refuses them at invite
|
|
121
|
+
// and at take, and refusing them here is what leaves `invite` answering
|
|
122
|
+
// null for one reason only, which is the whole of how this stays
|
|
123
|
+
// idempotent without reading anybody's cells.
|
|
124
|
+
if (RESERVED.has(as) || RESERVED.has(known)) fail(`knock, take and remove are the ward's own words: ${one.from} -> ${one.to}`);
|
|
125
|
+
const near = ward(h, from.ward, `wire ${one.from}`),
|
|
126
|
+
far = ward(h, to.ward, `wire ${one.to}`);
|
|
127
|
+
if (!near.being(from.key)) fail(`wire ${one.from}: no such being`);
|
|
128
|
+
if (!far.being(to.key)) fail(`wire ${one.to}: no such being`);
|
|
129
|
+
// A name held under the other kind is not this wire and never will be:
|
|
130
|
+
// said now, before a heir is spent on it, rather than read as a wire
|
|
131
|
+
// already placed or refused at the knock with the far side already bound.
|
|
132
|
+
const atFar = holds(h, to.ward, to.key, known);
|
|
133
|
+
const atNear = holds(h, from.ward, from.key, as);
|
|
134
|
+
if (atFar.standing) fail(`wire ${one.from} -> ${one.to}: ${to.key} already holds a standing named ${known}, and a name is one of the two`);
|
|
135
|
+
if (atNear.occupant) fail(`wire ${one.from} -> ${one.to}: ${from.key} already holds an occupant named ${as}, and a name is one of the two`);
|
|
136
|
+
const inv = (await far.ask('invite', { being: to.key, id: known, ...(one.notes ? { notes: one.notes } : {}) })) as Invitation | { error?: string } | null;
|
|
137
|
+
if (inv === null) {
|
|
138
|
+
already.push(`${one.from} -> ${one.to}`);
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
if ('error' in inv) fail(`wire ${one.from} -> ${one.to}: ${inv.error}`);
|
|
142
|
+
const took = (await near.ask('knock', { being: from.key, id: as, invitation: inv })) as { taken?: string; error?: string };
|
|
143
|
+
if (took.taken !== as) fail(`wire ${one.from} -> ${one.to}: ${took.error ?? `${from.key} would not take ${as}`}`);
|
|
144
|
+
wired.push(`${one.from} -> ${one.to}`);
|
|
145
|
+
}
|
|
146
|
+
return { booted, wired, already };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// What an estate's `stand.ts` calls: the harbor folder booted here, with no
|
|
150
|
+
// daemon over it, stood up and written back. The lease refuses a folder a
|
|
151
|
+
// daemon holds, so the recipe is stop, stand, start, like an init.
|
|
152
|
+
export async function stand(dir: string, decl: Stand): Promise<{ booted: string[]; wired: string[]; already: string[] }> {
|
|
153
|
+
const h = new DiskHarbor(dir);
|
|
154
|
+
await h.boot();
|
|
155
|
+
try {
|
|
156
|
+
return await standUp(h, decl);
|
|
157
|
+
} finally {
|
|
158
|
+
await h.close();
|
|
159
|
+
}
|
|
160
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { Handler } from '../harbor/http.ts';
|
|
2
|
+
import type { Hosted } from '@nervur-org/nervur/harbor';
|
|
3
|
+
import type { Avatar } from '../beings/avatar.ts';
|
|
4
|
+
import type { Grants } from '../dock/grants.ts';
|
|
5
|
+
import { type Invitation } from '@nervur-org/nervur';
|
|
6
|
+
export type Held = {
|
|
7
|
+
wards: ReadonlyMap<string, Hosted>;
|
|
8
|
+
here(): string;
|
|
9
|
+
};
|
|
10
|
+
export type Options = {
|
|
11
|
+
grants: Grants;
|
|
12
|
+
resolve: (identity: string, ward: string) => Promise<{
|
|
13
|
+
avatar?: Avatar;
|
|
14
|
+
error?: string;
|
|
15
|
+
}>;
|
|
16
|
+
join: (identity: string, invitation: Invitation, ward: string) => Promise<{
|
|
17
|
+
error?: string;
|
|
18
|
+
}>;
|
|
19
|
+
web?: string;
|
|
20
|
+
};
|
|
21
|
+
export declare function apiRoute(harbor: Held, o: Options): Handler;
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { readText } from '../harbor/http.js';
|
|
2
|
+
import { invitationOf } from '../beings/link.js';
|
|
3
|
+
import { landed, POLICY } from '../human/door.js';
|
|
4
|
+
import { CSS } from '../human/style.js';
|
|
5
|
+
import { isInvitation, isSilence, isWord, wordOf } from '@nervur-org/nervur';
|
|
6
|
+
const open = { 'access-control-allow-origin': '*', 'access-control-allow-methods': 'GET, POST, OPTIONS', 'access-control-allow-headers': 'content-type, authorization' };
|
|
7
|
+
const word = (s) => (typeof s === 'string' && /^[\w.-]{1,40}$/.test(s) ? s : null);
|
|
8
|
+
export function apiRoute(harbor, o) {
|
|
9
|
+
return async (req, rest) => {
|
|
10
|
+
const json = (status, body) => Response.json(body, { status, headers: open });
|
|
11
|
+
// The one page this road serves, and it wears the one stylesheet every
|
|
12
|
+
// screen wears. No cross-origin header on it: a page is for the browser
|
|
13
|
+
// that was sent here and never for a script somewhere else. Its policy
|
|
14
|
+
// is the allow page's, and for the same reason: a page that runs no
|
|
15
|
+
// script of its own should be allowed none.
|
|
16
|
+
const page = (status, body) => new Response(`<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>quo</title>\n<style>${CSS}</style>\n</head><body>${body}</body></html>`, {
|
|
17
|
+
status,
|
|
18
|
+
headers: { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store', 'content-security-policy': POLICY, 'referrer-policy': 'no-referrer' },
|
|
19
|
+
});
|
|
20
|
+
// Where this world lives, as the daemon was told and not as the request
|
|
21
|
+
// said: a Host header is the caller's to write, and a page that printed
|
|
22
|
+
// one would print whatever a caller wanted a human to read. Falling back
|
|
23
|
+
// to it is for a box configured with no web address at all, where the
|
|
24
|
+
// request is the only thing that knows.
|
|
25
|
+
const hostOf = (r) => {
|
|
26
|
+
try {
|
|
27
|
+
return o.web === undefined ? (r.headers.get('host') ?? '') : new URL(o.web).host;
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
return r.headers.get('host') ?? '';
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
if (req.method === 'OPTIONS')
|
|
34
|
+
return new Response(null, { status: 204, headers: open });
|
|
35
|
+
if (req.method !== 'GET' && req.method !== 'POST')
|
|
36
|
+
return json(405, { error: 'GET or POST' });
|
|
37
|
+
const body = async () => {
|
|
38
|
+
const raw = await readText(req);
|
|
39
|
+
if (raw === undefined)
|
|
40
|
+
return null;
|
|
41
|
+
let parsed = {};
|
|
42
|
+
try {
|
|
43
|
+
parsed = raw.length === 0 ? {} : JSON.parse(raw);
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
return parsed === null || typeof parsed !== 'object' || Array.isArray(parsed) ? null : parsed;
|
|
49
|
+
};
|
|
50
|
+
// The trade: an invitation for a grant, or a refresh token for the next.
|
|
51
|
+
if ((rest === '' || rest === '/') && req.method === 'POST') {
|
|
52
|
+
const b = await body();
|
|
53
|
+
if (b === null)
|
|
54
|
+
return json(400, { error: 'the body is a JSON object' });
|
|
55
|
+
if (typeof b.refresh === 'string') {
|
|
56
|
+
const t = await o.grants.refresh(b.refresh);
|
|
57
|
+
return t ? json(200, t) : json(400, { error: 'invalid_grant' });
|
|
58
|
+
}
|
|
59
|
+
const inv = typeof b.invitation === 'string' ? invitationOf(b.invitation) : isInvitation(b.invitation) ? b.invitation : null;
|
|
60
|
+
if (!inv)
|
|
61
|
+
return json(400, { error: 'that is not an invitation' });
|
|
62
|
+
const world = [...harbor.wards].find(([, h]) => h.pk === inv.ward);
|
|
63
|
+
if (!world)
|
|
64
|
+
return json(400, { error: 'that invitation is for a world that does not live here' });
|
|
65
|
+
const [ward, hosted] = world;
|
|
66
|
+
const identity = word(b.identity);
|
|
67
|
+
if (identity === null || identity === hosted.record.user || identity === 'desk')
|
|
68
|
+
return json(400, { error: 'an identity is one word, and not the user or the desk' });
|
|
69
|
+
// Nobody at a page vouches for a program, so a trade is a knock that
|
|
70
|
+
// spends: an identity already in refreshes, or is removed at the user
|
|
71
|
+
// being and invited again, and never re-enters on a spent invitation.
|
|
72
|
+
if ((await o.resolve(identity, ward)).avatar)
|
|
73
|
+
return json(400, { error: 'already in' });
|
|
74
|
+
const joined = await o.join(identity, inv, ward);
|
|
75
|
+
if (joined.error)
|
|
76
|
+
return json(400, { error: joined.error });
|
|
77
|
+
return json(200, await o.grants.issue(identity, ward, 'api'));
|
|
78
|
+
}
|
|
79
|
+
// No ward named is the device's own ward, and never a default the route
|
|
80
|
+
// picks: the harbor points at one of its wards and says that one is the
|
|
81
|
+
// device's, and its door answers for it.
|
|
82
|
+
const [, named = '', ask = '', ...more] = rest.split('/');
|
|
83
|
+
const ward = named === '' ? harbor.here() : named;
|
|
84
|
+
// The delivery: a vendor's webhook to an envoy, `/api/<ward>/<being>/deliver`.
|
|
85
|
+
// Not a stranger's ask and not an occupant's: the owner asking her
|
|
86
|
+
// `deliver` with the raw bytes and the headers, and nothing else. The
|
|
87
|
+
// body is read as a string and never parsed here, since the vendor
|
|
88
|
+
// signed the bytes and a reparsed body fails her check; no bearer is
|
|
89
|
+
// read, since the rung is not the caller's to prove. She answers `{ ok }`
|
|
90
|
+
// or her error object, and the route carries it.
|
|
91
|
+
// A GET on the same path is the same ask with an empty body: where an
|
|
92
|
+
// OAuth redirect lands, the code in the query. The query crosses inside
|
|
93
|
+
// the headers under `:query`, a name no header on the wire can have, so
|
|
94
|
+
// her one ask in stays one.
|
|
95
|
+
if (more.length === 1 && more[0] === 'deliver') {
|
|
96
|
+
if (req.method !== 'POST' && req.method !== 'GET')
|
|
97
|
+
return json(405, { error: 'POST, or GET for a redirect' });
|
|
98
|
+
const hosted = harbor.wards.get(ward);
|
|
99
|
+
if (!hosted || !/^[\w-]+$/.test(ask))
|
|
100
|
+
return json(404, { error: 'no such world' });
|
|
101
|
+
const raw = req.method === 'GET' ? '' : await readText(req);
|
|
102
|
+
if (raw === undefined)
|
|
103
|
+
return json(413, { error: 'too big' });
|
|
104
|
+
const headers = {};
|
|
105
|
+
req.headers.forEach((v, k) => (headers[k] = v));
|
|
106
|
+
const query = new URL(req.url).search.slice(1);
|
|
107
|
+
if (query)
|
|
108
|
+
headers[':query'] = query;
|
|
109
|
+
const out = await hosted.ask('ask', { being: ask, method: 'deliver', args: { body: raw, headers } });
|
|
110
|
+
await hosted.save();
|
|
111
|
+
// A GET here is a browser and a browser is a human: a vendor a world
|
|
112
|
+
// sent her to has opened this URL in her tab, so what she is looking
|
|
113
|
+
// at is whatever the envoy answered a machine. She gets a page
|
|
114
|
+
// instead, and only she does; a vendor asking for JSON is answered in
|
|
115
|
+
// JSON as it always was, and so is a GET that says nothing about what
|
|
116
|
+
// it wants.
|
|
117
|
+
const wants = req.method === 'GET' && (req.headers.get('accept') ?? '').includes('text/html');
|
|
118
|
+
if (wants) {
|
|
119
|
+
const ok = out !== null && typeof out === 'object' && !Array.isArray(out) && typeof out.error !== 'string';
|
|
120
|
+
return page(ok ? 200 : 400, landed({ world: ward, host: hostOf(req), ...(o.web ? { at: `${o.web.replace(/\/$/, '')}/${encodeURIComponent(ward)}` } : {}), ok }));
|
|
121
|
+
}
|
|
122
|
+
if (isSilence(out) || out === null || typeof out !== 'object' || Array.isArray(out))
|
|
123
|
+
return json(503, { error: 'silence' });
|
|
124
|
+
const said = out.error;
|
|
125
|
+
if (said === 'no such being' || said === 'unknown ask')
|
|
126
|
+
return json(404, { error: 'no such envoy' });
|
|
127
|
+
if (said === 'silence' || said === 'threw' || said === 'late')
|
|
128
|
+
return json(503, { error: 'silence' });
|
|
129
|
+
if (typeof said === 'string')
|
|
130
|
+
return json(400, out);
|
|
131
|
+
return json(200, out);
|
|
132
|
+
}
|
|
133
|
+
if (!/^[\w.-]+$/.test(ward) || more.length > 0 || (ask !== '' && !/^[\w-]+$/.test(ask)))
|
|
134
|
+
return json(404, { error: 'GET /api for the device’s ward, GET /api/<ward> for the describe, GET or POST /api/<ward>/<ask> for one ask, POST /api/<ward>/<being>/deliver for a delivery' });
|
|
135
|
+
const hosted = harbor.wards.get(ward);
|
|
136
|
+
if (!hosted)
|
|
137
|
+
return json(404, { error: 'no such world' });
|
|
138
|
+
let args;
|
|
139
|
+
if (req.method === 'POST') {
|
|
140
|
+
const b = await body();
|
|
141
|
+
if (b === null)
|
|
142
|
+
return json(400, { error: 'the body is a JSON object' });
|
|
143
|
+
args = b;
|
|
144
|
+
}
|
|
145
|
+
else {
|
|
146
|
+
const url = new URL(req.url);
|
|
147
|
+
args = Object.fromEntries(url.searchParams.entries());
|
|
148
|
+
}
|
|
149
|
+
const carried = (req.headers.get('authorization') ?? '') !== '';
|
|
150
|
+
const grant = o.grants.bearer(req);
|
|
151
|
+
if (carried && grant === null)
|
|
152
|
+
return json(401, { error: 'unauthorized' });
|
|
153
|
+
if (grant === null) {
|
|
154
|
+
// The owner's `ask` with no being named: the public being, asked as
|
|
155
|
+
// nobody, bounded and judged by the ward, which is the in-process twin
|
|
156
|
+
// of the bytes a stranger would send. The route holds none of that. A
|
|
157
|
+
// world with nobody at the door and a being who said nothing are one
|
|
158
|
+
// answer here, as they are one answer on the wire.
|
|
159
|
+
const out = await hosted.ask('ask', ask === '' ? {} : { method: ask, args });
|
|
160
|
+
if (isSilence(out) || out === null || typeof out !== 'object' || Array.isArray(out))
|
|
161
|
+
return json(503, { error: 'silence' });
|
|
162
|
+
const said = out.error;
|
|
163
|
+
if (said === 'silence' || said === 'threw' || said === 'late' || said === 'no such being')
|
|
164
|
+
return json(503, { error: 'silence' });
|
|
165
|
+
if (typeof said === 'string')
|
|
166
|
+
return json(400, out);
|
|
167
|
+
return json(200, out);
|
|
168
|
+
}
|
|
169
|
+
// An occupant: the grant names a world, and the path must name the same
|
|
170
|
+
// one, since an avatar is one relation in one world.
|
|
171
|
+
if (grant.ward !== ward)
|
|
172
|
+
return json(403, { error: 'not your world' });
|
|
173
|
+
const found = await o.resolve(grant.identity, ward);
|
|
174
|
+
if (found.error === 'removed') {
|
|
175
|
+
await o.grants.revoke(grant.identity, ward);
|
|
176
|
+
return json(401, { error: 'removed' });
|
|
177
|
+
}
|
|
178
|
+
if (!found.avatar)
|
|
179
|
+
return json(401, { error: found.error ?? 'not joined' });
|
|
180
|
+
const out = ask === '' ? await found.avatar.tools() : await found.avatar.call(ask, args);
|
|
181
|
+
await hosted.save();
|
|
182
|
+
if (isSilence(out))
|
|
183
|
+
return json(503, { error: 'silence' });
|
|
184
|
+
if (isWord(out))
|
|
185
|
+
return json(503, { error: wordOf(out) });
|
|
186
|
+
if (out === null || typeof out !== 'object' || Array.isArray(out))
|
|
187
|
+
return json(200, out);
|
|
188
|
+
const said = out.error;
|
|
189
|
+
if (typeof said === 'string' && !('asks' in out))
|
|
190
|
+
return json(400, out);
|
|
191
|
+
return json(200, out);
|
|
192
|
+
};
|
|
193
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { Answer, Blueprint, Invitation, JsonObject, OccupantRecord, Wanted } from '@nervur-org/nervur';
|
|
2
|
+
import { Carrier } from './carry.ts';
|
|
3
|
+
export declare const USER = "user";
|
|
4
|
+
export declare const PUSHER = "user:push";
|
|
5
|
+
export declare class Avatar extends Carrier {
|
|
6
|
+
static cells: {
|
|
7
|
+
inbox: JsonObject[];
|
|
8
|
+
};
|
|
9
|
+
static asks: {
|
|
10
|
+
notify: {
|
|
11
|
+
description: string;
|
|
12
|
+
input: {
|
|
13
|
+
type: string;
|
|
14
|
+
};
|
|
15
|
+
for: (occ: OccupantRecord | undefined) => boolean;
|
|
16
|
+
};
|
|
17
|
+
};
|
|
18
|
+
readonly ears: Set<(object: JsonObject) => void>;
|
|
19
|
+
notify(args: JsonObject): {
|
|
20
|
+
notified: boolean;
|
|
21
|
+
};
|
|
22
|
+
enter(desk: Invitation, proof: JsonObject): Promise<Blueprint | {
|
|
23
|
+
error: string;
|
|
24
|
+
}>;
|
|
25
|
+
join(invitation: Invitation): Promise<Blueprint | {
|
|
26
|
+
error: string;
|
|
27
|
+
}>;
|
|
28
|
+
tools(): Promise<Blueprint | {
|
|
29
|
+
error: string;
|
|
30
|
+
}>;
|
|
31
|
+
call(name: string, args?: JsonObject, wanted?: Wanted): Promise<Answer>;
|
|
32
|
+
}
|