@quo-systems/dock 0.2.2 → 0.2.4
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/beings/avatar.ts +8 -1
- package/beings/carry.ts +1 -1
- package/beings/quo-dock.md +53 -39
- package/beings/setup.ts +2 -1
- package/beings/user.ts +48 -1
- package/cli/daemon.ts +51 -25
- package/cli/estate/quo.service +0 -1
- package/dist/beings/avatar.js +9 -2
- package/dist/beings/carry.js +2 -2
- package/dist/beings/setup.js +2 -1
- package/dist/beings/user.d.ts +89 -0
- package/dist/beings/user.js +47 -1
- package/dist/cli/daemon.d.ts +10 -2
- package/dist/cli/daemon.js +51 -23
- package/dist/cli/estate/quo.service +0 -1
- package/dist/harbor/capacitor.d.ts +16 -0
- package/dist/harbor/capacitor.js +135 -0
- package/dist/harbor/disk.js +8 -3
- package/dist/harbor/edge/edge.js +2 -1
- package/dist/harbor/edge/exercise.js +2 -1
- package/dist/harbor/edge/storage.d.ts +0 -3
- package/dist/harbor/edge/storage.js +6 -25
- package/dist/harbor/files.d.ts +2 -1
- package/dist/harbor/files.js +87 -22
- package/dist/harbor/seal.d.ts +3 -0
- package/dist/harbor/seal.js +25 -0
- package/dist/human/door.d.ts +2 -0
- package/dist/human/door.js +19 -7
- package/dist/human/html.d.ts +2 -0
- package/dist/human/html.js +43 -17
- package/dist/human/local.d.ts +10 -0
- package/dist/human/local.js +10 -0
- package/dist/human/screen.js +26 -8
- package/dist/human/tab.d.ts +1 -0
- package/dist/human/tab.js +59 -3
- package/dist/human/tree.d.ts +40 -0
- package/dist/human/tree.js +89 -0
- package/dist/human/web.d.ts +1 -0
- package/dist/human/web.js +36 -8
- package/dist/mcp/oauth.js +2 -2
- package/dist/mcp/route.js +1 -1
- package/dist/mcp/runner.js +5 -2
- package/dist/mcp/server.js +1 -1
- package/dist/mcp/web/exchange.d.ts +5 -9
- package/dist/mcp/web/exchange.js +47 -98
- package/harbor/capacitor.ts +142 -0
- package/harbor/disk.ts +8 -3
- package/harbor/edge/edge.ts +2 -1
- package/harbor/edge/exercise.ts +2 -1
- package/harbor/edge/storage.ts +6 -25
- package/harbor/files.ts +79 -19
- package/harbor/quo-harbor.md +58 -15
- package/harbor/seal.ts +26 -0
- package/human/door.ts +20 -7
- package/human/html.ts +41 -17
- package/human/local.ts +26 -0
- package/human/quo-human.md +114 -19
- package/human/screen.ts +21 -7
- package/human/tab.ts +50 -5
- package/human/tree.ts +129 -0
- package/human/web.ts +36 -9
- package/mcp/oauth.ts +2 -2
- package/mcp/quo-mcp.md +27 -20
- package/mcp/route.ts +1 -1
- package/mcp/runner.ts +9 -6
- package/mcp/server.ts +1 -1
- package/mcp/web/exchange.ts +62 -96
- package/package.json +13 -2
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { escape } from './html.js';
|
|
2
|
+
const ROLES = new Set(['title', 'lead', 'body', 'quiet', 'label']);
|
|
3
|
+
const AS = new Set(['auto', 'text', 'table', 'cards', 'list']);
|
|
4
|
+
const TEXT = 4000; // characters a text node may carry
|
|
5
|
+
const DEPTH = 12; // how deep a tree may nest
|
|
6
|
+
const WIDE = 200; // children a container may have
|
|
7
|
+
const NAME = /^[\w.-]{1,80}$/;
|
|
8
|
+
// A value held to the grammar, or null. Children that fail are dropped and
|
|
9
|
+
// the container kept; a container with nothing left is kept empty, since an
|
|
10
|
+
// empty region is a design and not a fault.
|
|
11
|
+
export function sanitiseTree(v, depth = 0) {
|
|
12
|
+
if (depth > DEPTH || v === null || typeof v !== 'object' || Array.isArray(v))
|
|
13
|
+
return null;
|
|
14
|
+
const o = v;
|
|
15
|
+
switch (o.kind) {
|
|
16
|
+
case 'stack':
|
|
17
|
+
case 'row': {
|
|
18
|
+
const of = Array.isArray(o.of) ? o.of.slice(0, WIDE).map((c) => sanitiseTree(c, depth + 1)).filter((c) => c !== null) : [];
|
|
19
|
+
return { kind: o.kind, of };
|
|
20
|
+
}
|
|
21
|
+
case 'text': {
|
|
22
|
+
if (typeof o.text !== 'string')
|
|
23
|
+
return null;
|
|
24
|
+
const n = { kind: 'text', text: o.text.slice(0, TEXT) };
|
|
25
|
+
if (typeof o.role === 'string' && ROLES.has(o.role))
|
|
26
|
+
n.role = o.role;
|
|
27
|
+
return n;
|
|
28
|
+
}
|
|
29
|
+
case 'image': {
|
|
30
|
+
if (typeof o.src !== 'string' || !/^data:image\/[a-z0-9.+-]+;base64,[A-Za-z0-9+/=]+$/i.test(o.src))
|
|
31
|
+
return null;
|
|
32
|
+
const n = { kind: 'image', src: o.src };
|
|
33
|
+
if (typeof o.alt === 'string')
|
|
34
|
+
n.alt = o.alt.slice(0, 200);
|
|
35
|
+
return n;
|
|
36
|
+
}
|
|
37
|
+
case 'form':
|
|
38
|
+
return typeof o.ask === 'string' && NAME.test(o.ask) ? { kind: 'form', ask: o.ask } : null;
|
|
39
|
+
case 'answer': {
|
|
40
|
+
if (typeof o.ask !== 'string' || !NAME.test(o.ask))
|
|
41
|
+
return null;
|
|
42
|
+
const n = { kind: 'answer', ask: o.ask };
|
|
43
|
+
if (typeof o.as === 'string' && AS.has(o.as))
|
|
44
|
+
n.as = o.as;
|
|
45
|
+
return n;
|
|
46
|
+
}
|
|
47
|
+
case 'standing':
|
|
48
|
+
return typeof o.id === 'string' && NAME.test(o.id) ? { kind: 'standing', id: o.id } : null;
|
|
49
|
+
case 'standings':
|
|
50
|
+
return { kind: 'standings' };
|
|
51
|
+
default:
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
// The names a tree asks the answers of: what a side runs on her behalf so
|
|
56
|
+
// the page opens with what it shows, when the ask needs nothing typed.
|
|
57
|
+
export function answersIn(n, out = []) {
|
|
58
|
+
if (n.kind === 'answer')
|
|
59
|
+
out.push(n.ask);
|
|
60
|
+
if (n.kind === 'stack' || n.kind === 'row')
|
|
61
|
+
for (const c of n.of)
|
|
62
|
+
answersIn(c, out);
|
|
63
|
+
return out;
|
|
64
|
+
}
|
|
65
|
+
const ROLE_TAG = { title: 'h1', lead: 'p', body: 'p', quiet: 'p', label: 'p' };
|
|
66
|
+
export function paint(n, p) {
|
|
67
|
+
switch (n.kind) {
|
|
68
|
+
case 'stack':
|
|
69
|
+
case 'row':
|
|
70
|
+
return `<div class="${n.kind}">${n.of.map((c) => paint(c, p)).join('')}</div>`;
|
|
71
|
+
case 'text': {
|
|
72
|
+
const role = n.role ?? 'body';
|
|
73
|
+
return `<${ROLE_TAG[role]} class="t-${role}">${escape(n.text)}</${ROLE_TAG[role]}>`;
|
|
74
|
+
}
|
|
75
|
+
case 'image':
|
|
76
|
+
return `<img class="picture" alt="${escape(n.alt ?? '')}" src="${n.src}">`;
|
|
77
|
+
case 'form':
|
|
78
|
+
return p.form(n.ask);
|
|
79
|
+
case 'answer':
|
|
80
|
+
return p.answer(n.ask, n.as ?? 'auto');
|
|
81
|
+
case 'standing':
|
|
82
|
+
return p.standing(n.id);
|
|
83
|
+
case 'standings':
|
|
84
|
+
return p.standings();
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
// A painter with nothing behind it: for a page that names no ask, the door
|
|
88
|
+
// page and any page painted before anyone is in.
|
|
89
|
+
export const nobody = { form: () => '', answer: () => '', standing: () => '', standings: () => '' };
|
package/dist/human/web.d.ts
CHANGED
package/dist/human/web.js
CHANGED
|
@@ -1,16 +1,30 @@
|
|
|
1
1
|
import { existsSync } from 'node:fs';
|
|
2
2
|
import { fileURLToPath } from 'node:url';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import process from 'node:process';
|
|
3
5
|
import { build } from 'esbuild';
|
|
4
6
|
import { door } from './door.js';
|
|
5
7
|
// The paths under `/web` that are the exchange's, never a ward's name.
|
|
6
|
-
const RESERVED_PATHS = new Set(['
|
|
8
|
+
const RESERVED_PATHS = new Set(['allow', 'tab.js', 'beings.js']);
|
|
9
|
+
// The world's code for the tab: a module in the harbor folder, the twin of
|
|
10
|
+
// `classes/index.ts`, exporting by name the classes the tab boots into its
|
|
11
|
+
// local ward for a world of this harbor. Served bundled as `beings.js` on
|
|
12
|
+
// this origin, and one origin is one world's code.
|
|
13
|
+
export const TAB_CODE = 'tab/index.ts';
|
|
7
14
|
export function webRoute(harbor, o) {
|
|
8
15
|
const tabEntry = () => {
|
|
9
16
|
const js = fileURLToPath(new URL('./tab.js', import.meta.url));
|
|
10
17
|
return existsSync(js) ? js : fileURLToPath(new URL('./tab.ts', import.meta.url));
|
|
11
18
|
};
|
|
19
|
+
const beingsEntry = join(harbor.dir, TAB_CODE);
|
|
20
|
+
const bundled = (entry) =>
|
|
21
|
+
// the entry may sit in a harbor folder with no node_modules of its own, as on a droplet whose
|
|
22
|
+
// estate folder is the working directory: what it imports is resolved from there too
|
|
23
|
+
build({ entryPoints: [entry], bundle: true, format: 'esm', platform: 'browser', target: 'es2023', write: false, nodePaths: [join(process.cwd(), 'node_modules')] }).then((out) => out.outputFiles[0].text);
|
|
12
24
|
let bundle;
|
|
13
|
-
const built = () => (bundle ??=
|
|
25
|
+
const built = () => (bundle ??= bundled(tabEntry()));
|
|
26
|
+
let beings;
|
|
27
|
+
const builtBeings = () => (beings ??= bundled(beingsEntry));
|
|
14
28
|
const quoOrigin = (() => {
|
|
15
29
|
try {
|
|
16
30
|
const u = new URL(o.at.quo);
|
|
@@ -41,7 +55,7 @@ export function webRoute(harbor, o) {
|
|
|
41
55
|
};
|
|
42
56
|
const wards = () => Object.fromEntries([...harbor.wards].map(([n, h]) => [n, { pk: h.pk, public: publicOf(h) !== null }]));
|
|
43
57
|
const tab = (ward) => {
|
|
44
|
-
const cfg = { quo: o.at.quo, web: o.at.web, wards: wards(), ...(ward ? { ward } : {}) };
|
|
58
|
+
const cfg = { quo: o.at.quo, web: o.at.web, wards: wards(), beings: existsSync(beingsEntry), ...(ward ? { ward } : {}) };
|
|
45
59
|
return `<script id="quo" type="application/json">${JSON.stringify(cfg).replace(/</g, '\\u003c')}</script><script type="module" src="${o.at.web}/tab.js"></script>`;
|
|
46
60
|
};
|
|
47
61
|
return async (req, res, rest) => {
|
|
@@ -58,6 +72,14 @@ export function webRoute(harbor, o) {
|
|
|
58
72
|
res.end(await built());
|
|
59
73
|
return true;
|
|
60
74
|
}
|
|
75
|
+
// the world's code for the tab, when the harbor folder holds any; a harbor with none has no such path
|
|
76
|
+
if (rest === '/beings.js' && req.method === 'GET') {
|
|
77
|
+
if (!existsSync(beingsEntry))
|
|
78
|
+
return false;
|
|
79
|
+
res.writeHead(200, { 'content-type': 'text/javascript; charset=utf-8', 'cache-control': 'no-store' });
|
|
80
|
+
res.end(await builtBeings());
|
|
81
|
+
return true;
|
|
82
|
+
}
|
|
61
83
|
const wardName = parts[0] ?? '';
|
|
62
84
|
if (!wardName || RESERVED_PATHS.has(wardName))
|
|
63
85
|
return false;
|
|
@@ -82,14 +104,20 @@ const CSS = [
|
|
|
82
104
|
'header,main{background:var(--bg);color:var(--fg);font-family:var(--font)}header{display:flex;flex-wrap:wrap;align-items:center;gap:.5rem 1rem}header .notice{width:100%;margin:0}',
|
|
83
105
|
'input,select,textarea,button{font:inherit;padding:.4rem;margin:.2rem;border-radius:var(--radius)}button{background:var(--accent);color:#fff;border:0;padding:.4rem .9rem}',
|
|
84
106
|
'fieldset{border:1px solid color-mix(in srgb,currentColor 20%,transparent);border-radius:var(--radius);margin:.5rem 0}',
|
|
107
|
+
// a being of the world running in the tab: her own section under the far page
|
|
108
|
+
'section.local{border-top:1px solid color-mix(in srgb,currentColor 15%,transparent);margin:1.5rem 0;padding-top:.5rem}section.local header:empty{display:none}',
|
|
85
109
|
'section.standing{background:var(--bg);color:var(--fg);font-family:var(--font);border-left:4px solid var(--accent);border-radius:var(--radius);padding:.25rem 1rem;margin:1.5rem 0}section.standing h2{display:flex;align-items:center;gap:.5rem;font-size:1.1rem}img.logo{height:1.6rem}',
|
|
86
110
|
'table{border-collapse:collapse}td,th{padding:.15rem .5rem;text-align:left}.answer{margin:.5rem 0 1rem;padding:.5rem;border-left:3px solid var(--accent)}.answer.error{border-color:#c33}.answer.silence,.answer.word,.answer.unreached{border-color:#c93}pre{padding:.75rem;overflow:auto}',
|
|
111
|
+
// a page in the grammar: regions, roles, cards
|
|
112
|
+
'.page .stack{display:flex;flex-direction:column;gap:.5rem}.page .row{display:flex;flex-wrap:wrap;gap:.5rem 1rem;align-items:baseline}',
|
|
113
|
+
'.page .t-title{font-size:2rem;font-weight:600;letter-spacing:-.02em;margin:.5rem 0}.page .t-lead{font-size:1.25rem;margin:.25rem 0}.page .t-label{font-size:.8rem;letter-spacing:.06em;text-transform:uppercase;color:var(--mute);margin:0}.page .t-quiet{color:var(--mute);font-size:.9rem}.page .t-body{margin:.25rem 0}',
|
|
114
|
+
'.page .picture{max-height:6rem}.cards{display:flex;flex-wrap:wrap;gap:.75rem}.card{border:1px solid color-mix(in srgb,currentColor 20%,transparent);border-radius:var(--radius);padding:.5rem .75rem}',
|
|
87
115
|
// the door page: one column, generous air, the mark, a word, a sentence
|
|
88
116
|
'main.door{min-height:70vh;display:flex;flex-direction:column;justify-content:center;align-items:flex-start;max-width:32rem;margin:0 auto;padding:3rem 0;font-family:ui-serif,Georgia,"Times New Roman",serif}',
|
|
89
|
-
'main.door .
|
|
90
|
-
'main.door
|
|
91
|
-
'main.door .
|
|
92
|
-
'main.door .lead{font-size:1.5rem;line-height:1.3;margin:0 0 1rem}',
|
|
117
|
+
'main.door .picture{width:3.5rem;height:3.5rem;color:var(--ink);opacity:.9;margin-bottom:1.25rem}',
|
|
118
|
+
'main.door .t-title{font-size:3rem;font-weight:400;letter-spacing:-.02em;line-height:1;margin:0 0 .5rem}',
|
|
119
|
+
'main.door .t-label{font-family:system-ui,sans-serif;font-size:.85rem;letter-spacing:.08em;text-transform:uppercase;color:var(--mute);margin:0 0 2rem}main.door .t-label span{text-transform:none;letter-spacing:0}',
|
|
120
|
+
'main.door .t-lead{font-size:1.5rem;line-height:1.3;margin:0 0 1rem}',
|
|
93
121
|
'main.door p{font-size:1.05rem;line-height:1.6;margin:0 0 1rem;max-width:30rem}',
|
|
94
|
-
'main.door .quiet{color:var(--mute);font-size:.95rem;border-top:1px solid color-mix(in srgb,currentColor 15%,transparent);padding-top:1rem;margin-top:1rem}',
|
|
122
|
+
'main.door .t-quiet{color:var(--mute);font-size:.95rem;border-top:1px solid color-mix(in srgb,currentColor 15%,transparent);padding-top:1rem;margin-top:1rem}',
|
|
95
123
|
].join('');
|
package/dist/mcp/oauth.js
CHANGED
|
@@ -14,8 +14,8 @@
|
|
|
14
14
|
// POST /token code or refresh for an access token
|
|
15
15
|
//
|
|
16
16
|
// The web route finishes a request by calling `complete(request, identity)`
|
|
17
|
-
// after its
|
|
18
|
-
// carries the code back to the client.
|
|
17
|
+
// after its allow page, where the human hands the invitation the root minted;
|
|
18
|
+
// that returns the redirect that carries the code back to the client.
|
|
19
19
|
import { randomBytes, createHash } from 'node:crypto';
|
|
20
20
|
// A token, a code, a client id: every key here is a string the far side chose,
|
|
21
21
|
// and a bare lookup would find `__proto__` and hand back an object with no
|
package/dist/mcp/route.js
CHANGED
|
@@ -17,7 +17,7 @@ export async function mcpRoute(dir, routes, mcp) {
|
|
|
17
17
|
await writeFile(file + '.tmp', JSON.stringify(s), { mode: 0o600 });
|
|
18
18
|
await rename(file + '.tmp', file);
|
|
19
19
|
}));
|
|
20
|
-
const oauth = new OAuth({ issuer: routes.mcp, resource: `${routes.mcp}/mcp`, finish: (id) => `${routes.web}/
|
|
20
|
+
const oauth = new OAuth({ issuer: routes.mcp, resource: `${routes.mcp}/mcp`, finish: (id) => `${routes.web}/allow?request=${id}`, store, persist });
|
|
21
21
|
const handler = async (req, res, rest) => {
|
|
22
22
|
if (await oauth.handle(req, res, rest))
|
|
23
23
|
return;
|
package/dist/mcp/runner.js
CHANGED
|
@@ -3,9 +3,12 @@ export const TURNS = 10;
|
|
|
3
3
|
// Her describe, spoken as a tools array. Name, description and input are
|
|
4
4
|
// verbatim, with the one narrowing an endpoint has asked for: an ask that
|
|
5
5
|
// declares no properties is sent with an empty `properties`, because
|
|
6
|
-
// LM Studio refuses a parameters schema without one.
|
|
6
|
+
// LM Studio refuses a parameters schema without one. `look` and `page` are
|
|
7
|
+
// presentation, for a screen, and are not functions.
|
|
7
8
|
export function tools(bp) {
|
|
8
|
-
return bp.asks
|
|
9
|
+
return bp.asks
|
|
10
|
+
.filter((a) => a.name !== 'look' && a.name !== 'page')
|
|
11
|
+
.map((a) => {
|
|
9
12
|
const t = { type: 'function', function: { name: a.name, parameters: { properties: {}, ...a.input, type: 'object' } } };
|
|
10
13
|
if (a.description !== undefined)
|
|
11
14
|
t.function.description = a.description;
|
package/dist/mcp/server.js
CHANGED
|
@@ -23,7 +23,7 @@ export const DESCRIBE = { name: 'describe', description: 'the empty ask: her des
|
|
|
23
23
|
// reads. The `look` ask itself is presentation, and is not a tool.
|
|
24
24
|
export function tools(bp, look = {}) {
|
|
25
25
|
const asks = bp.asks
|
|
26
|
-
.filter((a) => a.name !== 'look')
|
|
26
|
+
.filter((a) => a.name !== 'look' && a.name !== 'page') // presentation, for a screen, and not a tool
|
|
27
27
|
.map((a) => {
|
|
28
28
|
const t = { name: a.name, inputSchema: { ...a.input, type: 'object' } };
|
|
29
29
|
if (a.description !== undefined)
|
|
@@ -1,26 +1,22 @@
|
|
|
1
1
|
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
2
|
+
import type { Invitation } from '@quo-systems/quo';
|
|
2
3
|
import type { OAuth } from '../oauth.ts';
|
|
3
|
-
export
|
|
4
|
-
export type Admit = (identity: string, wake: boolean, reach: boolean, ward: string) => Promise<{
|
|
4
|
+
export type Join = (identity: string, invitation: Invitation, ward: string) => Promise<{
|
|
5
5
|
error?: string;
|
|
6
6
|
}>;
|
|
7
7
|
export type Options = {
|
|
8
8
|
oauth: OAuth;
|
|
9
|
-
|
|
10
|
-
admit: Admit;
|
|
9
|
+
join: Join;
|
|
11
10
|
worlds: () => {
|
|
12
11
|
ward: string;
|
|
12
|
+
pk: string;
|
|
13
13
|
user: string;
|
|
14
14
|
}[];
|
|
15
|
-
now?: () => number;
|
|
16
15
|
};
|
|
17
16
|
export declare const suggest: (name: string) => string;
|
|
17
|
+
export declare function invitationOf(text: string): Invitation | null;
|
|
18
18
|
export declare class Exchange {
|
|
19
19
|
readonly o: Options;
|
|
20
|
-
readonly secret: NonSharedBuffer;
|
|
21
20
|
constructor(o: Options);
|
|
22
|
-
now(): number;
|
|
23
|
-
mint(): string;
|
|
24
|
-
valid(req: IncomingMessage): boolean;
|
|
25
21
|
handle(req: IncomingMessage, res: ServerResponse, rest: string): Promise<boolean>;
|
|
26
22
|
}
|
package/dist/mcp/web/exchange.js
CHANGED
|
@@ -1,21 +1,5 @@
|
|
|
1
|
-
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
-
// The exchange pages on the web route. Two static pages, hand-written, and
|
|
3
|
-
// explicitly not the human side: they render no blueprint. They end the
|
|
4
|
-
// credential exchange the way the trunk says every route does: a proof is
|
|
5
|
-
// made, the front desk trades it for an invitation, the avatar knocks, and
|
|
6
|
-
// the OAuth request completes with the client identity the human chose.
|
|
7
|
-
//
|
|
8
|
-
// GET /login?request=ID the owner password, from QUO_OWNER_PASSWORD
|
|
9
|
-
// POST /login sets a short session cookie, goes to /allow
|
|
10
|
-
// GET /allow?request=ID who is asking, and the identity they will be
|
|
11
|
-
// POST /allow allow as that identity, or deny
|
|
12
|
-
//
|
|
13
|
-
// The password is the device's, read from the environment the way an envoy
|
|
14
|
-
// reads a secret, never through cells. With none set, the exchange is closed.
|
|
15
|
-
import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
|
|
16
1
|
import { readForm } from '../oauth.js';
|
|
17
|
-
|
|
18
|
-
const COOKIE = 'quo_exchange';
|
|
2
|
+
import { parse } from '../../beings/link.js';
|
|
19
3
|
const esc = (s) => s.replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c] ?? c);
|
|
20
4
|
const word = (s) => (typeof s === 'string' && /^[\w.-]{1,40}$/.test(s) ? s : null);
|
|
21
5
|
// A client's name as an identity: lowercase words joined by dashes.
|
|
@@ -24,127 +8,92 @@ export const suggest = (name) => name
|
|
|
24
8
|
.replace(/[^a-z0-9]+/g, '-')
|
|
25
9
|
.replace(/^-|-$/g, '')
|
|
26
10
|
.slice(0, 40) || 'client';
|
|
11
|
+
// The invitation as typed: the link's compact form, `ward.heir.secret`, or
|
|
12
|
+
// the whole link it came in, or the JSON value `quo invite` printed.
|
|
13
|
+
export function invitationOf(text) {
|
|
14
|
+
const t = text.trim();
|
|
15
|
+
const hash = t.indexOf('#');
|
|
16
|
+
const linked = parse(hash === -1 ? `#quo=${t}` : t.slice(hash));
|
|
17
|
+
if (linked?.heir)
|
|
18
|
+
return linked;
|
|
19
|
+
try {
|
|
20
|
+
const v = JSON.parse(t);
|
|
21
|
+
if (v && typeof v === 'object' && typeof v.ward === 'string' && typeof v.heir === 'string' && typeof v.secret === 'string')
|
|
22
|
+
return { ward: v.ward, heir: v.heir, secret: v.secret };
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
/* not JSON either */
|
|
26
|
+
}
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
27
29
|
export class Exchange {
|
|
28
30
|
o;
|
|
29
|
-
secret = randomBytes(32); // sessions die with the daemon, and that is fine: they live ten minutes
|
|
30
31
|
constructor(o) {
|
|
31
32
|
this.o = o;
|
|
32
33
|
}
|
|
33
|
-
now() {
|
|
34
|
-
return this.o.now?.() ?? Date.now();
|
|
35
|
-
}
|
|
36
|
-
// ---- the session cookie: an expiry, signed
|
|
37
|
-
mint() {
|
|
38
|
-
const exp = String(this.now() + SESSION_TTL);
|
|
39
|
-
return `${exp}.${createHmac('sha256', this.secret).update(exp).digest('base64url')}`;
|
|
40
|
-
}
|
|
41
|
-
valid(req) {
|
|
42
|
-
const m = /(?:^|;\s*)quo_exchange=([^;]+)/.exec(req.headers.cookie ?? '');
|
|
43
|
-
const [exp, sig] = (m?.[1] ?? '').split('.');
|
|
44
|
-
if (!exp || !sig)
|
|
45
|
-
return false;
|
|
46
|
-
const want = createHmac('sha256', this.secret).update(exp).digest('base64url');
|
|
47
|
-
return sig.length === want.length && timingSafeEqual(Buffer.from(sig), Buffer.from(want)) && Number(exp) > this.now();
|
|
48
|
-
}
|
|
49
34
|
async handle(req, res, rest) {
|
|
50
35
|
const url = new URL(req.url ?? '/', 'http://localhost');
|
|
51
|
-
const page = (status, body
|
|
52
|
-
res.writeHead(status, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store'
|
|
36
|
+
const page = (status, body) => {
|
|
37
|
+
res.writeHead(status, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' });
|
|
53
38
|
res.end(shell(body));
|
|
39
|
+
return true;
|
|
54
40
|
};
|
|
55
|
-
const go = (to
|
|
56
|
-
res.writeHead(302, { location: to, 'cache-control': 'no-store'
|
|
41
|
+
const go = (to) => {
|
|
42
|
+
res.writeHead(302, { location: to, 'cache-control': 'no-store' });
|
|
57
43
|
res.end();
|
|
44
|
+
return true;
|
|
58
45
|
};
|
|
59
|
-
if (rest === '/login' && req.method === 'GET') {
|
|
60
|
-
const request = url.searchParams.get('request') ?? '';
|
|
61
|
-
const p = this.o.oauth.pending(request);
|
|
62
|
-
if (!p)
|
|
63
|
-
return page(400, `<h1>Nothing to allow</h1><p>This request is gone. Start again from the app that sent you here.</p>`), true;
|
|
64
|
-
if (this.o.password() === undefined)
|
|
65
|
-
return page(503, `<h1>Closed</h1><p>This world takes no logins: no owner password is set.</p>`), true;
|
|
66
|
-
if (this.valid(req))
|
|
67
|
-
return go(`/allow?request=${encodeURIComponent(request)}`), true;
|
|
68
|
-
return page(200, loginForm(request, p.client.client_name)), true;
|
|
69
|
-
}
|
|
70
|
-
if (rest === '/login' && req.method === 'POST') {
|
|
71
|
-
const f = await readForm(req);
|
|
72
|
-
const request = f.get('request') ?? '';
|
|
73
|
-
const p = this.o.oauth.pending(request);
|
|
74
|
-
if (!p)
|
|
75
|
-
return page(400, `<h1>Nothing to allow</h1><p>This request is gone.</p>`), true;
|
|
76
|
-
const want = this.o.password();
|
|
77
|
-
const got = f.get('password') ?? '';
|
|
78
|
-
if (want === undefined)
|
|
79
|
-
return page(503, `<h1>Closed</h1>`), true;
|
|
80
|
-
if (got.length !== want.length || !timingSafeEqual(Buffer.from(got), Buffer.from(want))) {
|
|
81
|
-
await new Promise((ok) => setTimeout(ok, 300));
|
|
82
|
-
return page(401, loginForm(request, p.client.client_name, 'That is not the password.')), true;
|
|
83
|
-
}
|
|
84
|
-
return go(`/allow?request=${encodeURIComponent(request)}`, { 'set-cookie': `${COOKIE}=${this.mint()}; Path=/; HttpOnly; Secure; SameSite=Strict; Max-Age=${SESSION_TTL / 1000}` }), true;
|
|
85
|
-
}
|
|
86
46
|
if (rest === '/allow' && req.method === 'GET') {
|
|
87
47
|
const request = url.searchParams.get('request') ?? '';
|
|
88
48
|
const p = this.o.oauth.pending(request);
|
|
89
49
|
if (!p)
|
|
90
|
-
return page(400, `<h1>Nothing to allow</h1><p>This request is gone.</p>`)
|
|
91
|
-
|
|
92
|
-
return go(`/login?request=${encodeURIComponent(request)}`), true;
|
|
93
|
-
return page(200, allowForm(request, p.client.client_name, p.redirect_uri, suggest(p.client.client_name), this.o.worlds())), true;
|
|
50
|
+
return page(400, `<h1>Nothing to allow</h1><p>This request is gone. Start again from the app that sent you here.</p>`);
|
|
51
|
+
return page(200, allowForm(request, p.client.client_name, p.redirect_uri, suggest(p.client.client_name), this.o.worlds()));
|
|
94
52
|
}
|
|
95
53
|
if (rest === '/allow' && req.method === 'POST') {
|
|
96
54
|
const f = await readForm(req);
|
|
97
55
|
const request = f.get('request') ?? '';
|
|
98
56
|
const p = this.o.oauth.pending(request);
|
|
99
57
|
if (!p)
|
|
100
|
-
return page(400, `<h1>Nothing to allow</h1><p>This request is gone.</p>`)
|
|
101
|
-
if (!this.valid(req))
|
|
102
|
-
return go(`/login?request=${encodeURIComponent(request)}`), true;
|
|
58
|
+
return page(400, `<h1>Nothing to allow</h1><p>This request is gone.</p>`);
|
|
103
59
|
if (f.get('decision') !== 'allow') {
|
|
104
60
|
const out = await this.o.oauth.deny(request);
|
|
105
|
-
return 'redirect' in out ? go(out.redirect) : page(400, `<h1>Gone</h1>`)
|
|
61
|
+
return 'redirect' in out ? go(out.redirect) : page(400, `<h1>Gone</h1>`);
|
|
106
62
|
}
|
|
107
63
|
const worlds = this.o.worlds();
|
|
108
|
-
const
|
|
64
|
+
const again = (err) => page(400, allowForm(request, p.client.client_name, p.redirect_uri, word(f.get('identity')) ?? suggest(p.client.client_name), worlds, err));
|
|
65
|
+
const inv = invitationOf(f.get('invitation') ?? '');
|
|
66
|
+
if (!inv)
|
|
67
|
+
return again('That is not an invitation.');
|
|
68
|
+
const world = worlds.find((w) => w.pk === inv.ward);
|
|
109
69
|
if (!world)
|
|
110
|
-
return
|
|
70
|
+
return again('That invitation is for a world that does not live here.');
|
|
111
71
|
const identity = word(f.get('identity'));
|
|
112
72
|
if (identity === null || identity === world.user || identity === 'desk')
|
|
113
|
-
return
|
|
114
|
-
const
|
|
115
|
-
if (
|
|
116
|
-
return
|
|
73
|
+
return again('An identity is one word, and not the user or the desk.');
|
|
74
|
+
const joined = await this.o.join(identity, inv, world.ward);
|
|
75
|
+
if (joined.error)
|
|
76
|
+
return again(`Not in: ${joined.error}.`);
|
|
117
77
|
const out = await this.o.oauth.complete(request, identity, world.ward);
|
|
118
|
-
return 'redirect' in out ? go(out.redirect
|
|
78
|
+
return 'redirect' in out ? go(out.redirect) : page(400, `<h1>Gone</h1>`);
|
|
119
79
|
}
|
|
120
80
|
return false;
|
|
121
81
|
}
|
|
122
82
|
}
|
|
123
83
|
const shell = (body) => `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>quo</title>
|
|
124
|
-
<style>body{font:16px/1.5 system-ui,sans-serif;max-width:32rem;margin:4rem auto;padding:0 1rem;color:#222}h1{font-size:1.4rem}label{display:block;margin:1rem 0 .25rem}input{font:inherit;padding:.5rem;width:100%;box-sizing:border-box}button{font:inherit;padding:.5rem 1rem;margin:1rem .5rem 0 0}.err{color:#b00}.who{background:#f4f4f4;padding:.75rem 1rem;border-radius:.5rem}code{word-break:break-all}</style>
|
|
84
|
+
<style>body{font:16px/1.5 system-ui,sans-serif;max-width:32rem;margin:4rem auto;padding:0 1rem;color:#222}h1{font-size:1.4rem}label{display:block;margin:1rem 0 .25rem}input,textarea{font:inherit;padding:.5rem;width:100%;box-sizing:border-box}textarea{font-family:ui-monospace,monospace;min-height:5rem}button{font:inherit;padding:.5rem 1rem;margin:1rem .5rem 0 0}.err{color:#b00}.who{background:#f4f4f4;padding:.75rem 1rem;border-radius:.5rem}code{word-break:break-all}</style>
|
|
125
85
|
</head><body>${body}</body></html>`;
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
${err ? `<p class="err">${esc(err)}</p>` : ''}
|
|
129
|
-
<form method="post" action="/login"><input type="hidden" name="request" value="${esc(request)}">
|
|
130
|
-
<label for="p">Owner password</label><input id="p" name="password" type="password" autocomplete="current-password" autofocus required>
|
|
131
|
-
<button type="submit">Log in</button></form>`;
|
|
132
|
-
// The world is a choice when the harbor has more than one; the user named
|
|
133
|
-
// is the first world's, and the page says which world each identity lands in.
|
|
86
|
+
// The one page. The world is the invitation's, so the page only says which
|
|
87
|
+
// worlds live here and whose they are.
|
|
134
88
|
const allowForm = (request, client, redirect, identity, worlds, err = '') => {
|
|
135
|
-
const
|
|
136
|
-
const pick = worlds.length > 1
|
|
137
|
-
? `<label for="wd">World</label><select id="wd" name="ward">${worlds.map((w) => `<option value="${esc(w.ward)}">${esc(w.ward)}, ${esc(w.user)}'s</option>`).join('')}</select>`
|
|
138
|
-
: `<input type="hidden" name="ward" value="${esc(worlds[0]?.ward ?? 'main')}">`;
|
|
89
|
+
const whose = worlds.map((w) => `<strong>${esc(w.user)}</strong> in ${esc(w.ward)}`).join(', ');
|
|
139
90
|
return `<h1>Allow ${esc(client)}?</h1>
|
|
140
|
-
<div class="who"><p><strong>${esc(client)}</strong> asks to be an occupant of
|
|
141
|
-
<p>It will see exactly what
|
|
91
|
+
<div class="who"><p><strong>${esc(client)}</strong> asks to be an occupant of ${whose || 'a world here'}.</p>
|
|
92
|
+
<p>It will see exactly what the user being shows the identity below, and nothing else. You can remove it any time.</p>
|
|
142
93
|
<p>It returns to <code>${esc(redirect)}</code>.</p></div>
|
|
143
94
|
${err ? `<p class="err">${esc(err)}</p>` : ''}
|
|
144
95
|
<form method="post" action="/allow"><input type="hidden" name="request" value="${esc(request)}">
|
|
145
|
-
${pick}
|
|
146
96
|
<label for="i">Identity</label><input id="i" name="identity" value="${esc(identity)}" pattern="[\\w.-]{1,40}" required>
|
|
147
|
-
<label for="
|
|
148
|
-
<label for="w"><input id="w" name="wake" type="checkbox" style="width:auto"> May wake your other devices: hand an agent an event through ${esc(user)}</label>
|
|
97
|
+
<label for="v">Invitation</label><textarea id="v" name="invitation" autofocus required placeholder="the invitation the owner minted for this client, as the link or as printed"></textarea>
|
|
149
98
|
<button type="submit" name="decision" value="allow">Allow</button><button type="submit" name="decision" value="deny">Deny</button></form>`;
|
|
150
99
|
};
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// The store on a phone: the wrapped form over Capacitor's Filesystem and
|
|
3
|
+
// a secure-storage plugin. One key per harbor in the Keychain, this device
|
|
4
|
+
// only, so it never travels in a backup; one sealed file per ward, seed,
|
|
5
|
+
// partition and record as one blob, in the folder iCloud does not copy.
|
|
6
|
+
//
|
|
7
|
+
// <Library/NoCloud>/quo/<harbor>/wards/<name>.sealed
|
|
8
|
+
// <Library/NoCloud>/quo/<harbor>/reach.json
|
|
9
|
+
// Keychain: quo-<harbor> 32 bytes as hex
|
|
10
|
+
//
|
|
11
|
+
// Custody is the backup question, and opening the store answers it: a key
|
|
12
|
+
// with no folder is a reinstall, and the stale key is deleted; a folder
|
|
13
|
+
// with no key is a restore to another device, and the unreadable files are
|
|
14
|
+
// deleted. Either way the harbor starts fresh and there is never a twin.
|
|
15
|
+
import { Capacitor } from '@capacitor/core';
|
|
16
|
+
import { Filesystem, Directory, Encoding } from '@capacitor/filesystem';
|
|
17
|
+
import { SecureStorage, KeychainAccess } from '@aparajita/capacitor-secure-storage';
|
|
18
|
+
import { arithmetic } from '@quo-systems/quo/ward';
|
|
19
|
+
import type { Kept, Store, WardRecord } from '@quo-systems/quo/harbor';
|
|
20
|
+
import { sealKey, seal, open } from './seal.ts';
|
|
21
|
+
|
|
22
|
+
const { hex, unhex } = arithmetic;
|
|
23
|
+
type Blob = { seed: string; partition: Record<string, unknown>; record: WardRecord };
|
|
24
|
+
|
|
25
|
+
// Where the files live: the folder iCloud does not copy on iOS, the app's
|
|
26
|
+
// own files on Android, whose manifest says no backup.
|
|
27
|
+
const directory = Capacitor.getPlatform() === 'ios' ? Directory.LibraryNoCloud : Directory.Data;
|
|
28
|
+
|
|
29
|
+
async function exists(path: string): Promise<boolean> {
|
|
30
|
+
try {
|
|
31
|
+
await Filesystem.stat({ path, directory });
|
|
32
|
+
return true;
|
|
33
|
+
} catch {
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export class Native implements Store {
|
|
39
|
+
readonly harbor: string;
|
|
40
|
+
readonly #key: CryptoKey;
|
|
41
|
+
readonly #queues = new Map<string, Promise<void>>();
|
|
42
|
+
private constructor(harbor: string, key: CryptoKey) {
|
|
43
|
+
this.harbor = harbor;
|
|
44
|
+
this.#key = key;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Open one harbor's store, applying the custody rule, minting the key and
|
|
48
|
+
// the folder when this is a fresh install.
|
|
49
|
+
static async open(harbor: string): Promise<Native> {
|
|
50
|
+
const item = `quo-${harbor}`;
|
|
51
|
+
const root = `quo/${harbor}`;
|
|
52
|
+
// `set` keeps JSON, so `get` parses it back; `getItem` would hand back the quotes.
|
|
53
|
+
const got = await SecureStorage.get(item, false, false);
|
|
54
|
+
let secret = typeof got === 'string' ? got : undefined;
|
|
55
|
+
const folder = await exists(root);
|
|
56
|
+
if (secret && !folder) {
|
|
57
|
+
await SecureStorage.remove(item);
|
|
58
|
+
secret = undefined;
|
|
59
|
+
}
|
|
60
|
+
if (!secret && folder) await Filesystem.rmdir({ path: root, directory, recursive: true });
|
|
61
|
+
if (!secret) {
|
|
62
|
+
secret = hex(crypto.getRandomValues(new Uint8Array(32)));
|
|
63
|
+
await SecureStorage.set(item, secret, false, false, KeychainAccess.afterFirstUnlockThisDeviceOnly);
|
|
64
|
+
}
|
|
65
|
+
// mkdir refuses a folder that exists, recursive or not.
|
|
66
|
+
if (!(await exists(`${root}/wards`))) await Filesystem.mkdir({ path: `${root}/wards`, directory, recursive: true });
|
|
67
|
+
return new Native(harbor, await sealKey(secret));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Everything this harbor has, key and files: what a person does by hand
|
|
71
|
+
// to leave a device, and what a test does between two openings.
|
|
72
|
+
static async wipe(harbor: string): Promise<void> {
|
|
73
|
+
await SecureStorage.remove(`quo-${harbor}`);
|
|
74
|
+
if (await exists(`quo/${harbor}`)) await Filesystem.rmdir({ path: `quo/${harbor}`, directory, recursive: true });
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
#path(name: string) {
|
|
78
|
+
return `quo/${this.harbor}/wards/${name}.sealed`;
|
|
79
|
+
}
|
|
80
|
+
async #read(name: string): Promise<Blob> {
|
|
81
|
+
const { data } = await Filesystem.readFile({ path: this.#path(name), directory, encoding: Encoding.UTF8 });
|
|
82
|
+
return JSON.parse(new TextDecoder().decode(await open(this.#key, (data as string).trim()))) as Blob;
|
|
83
|
+
}
|
|
84
|
+
// A sealed ward is rewritten whole, so the read sits inside the queue
|
|
85
|
+
// with the write: two changes to one ward never lose each other's part.
|
|
86
|
+
#keep(name: string, change: (b: Blob | undefined) => Blob): Promise<void> {
|
|
87
|
+
const next = (this.#queues.get(name) ?? Promise.resolve()).then(async () => {
|
|
88
|
+
const blob = change((await exists(this.#path(name))) ? await this.#read(name) : undefined);
|
|
89
|
+
const sealed = await seal(this.#key, new TextEncoder().encode(JSON.stringify(blob)));
|
|
90
|
+
await Filesystem.writeFile({ path: this.#path(name), directory, data: sealed + '\n', encoding: Encoding.UTF8 });
|
|
91
|
+
});
|
|
92
|
+
this.#queues.set(name, next.catch(() => {}));
|
|
93
|
+
return next;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async list(): Promise<string[]> {
|
|
97
|
+
const { files } = await Filesystem.readdir({ path: `quo/${this.harbor}/wards`, directory });
|
|
98
|
+
return files.filter((f) => f.name.endsWith('.sealed')).map((f) => f.name.slice(0, -'.sealed'.length));
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async load(name: string): Promise<Kept | undefined> {
|
|
102
|
+
if (!(await exists(this.#path(name)))) return undefined;
|
|
103
|
+
const b = await this.#read(name);
|
|
104
|
+
return { seed: unhex(b.seed), partition: b.partition, record: b.record };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async put(name: string, kept: Kept): Promise<void> {
|
|
108
|
+
if (await exists(this.#path(name))) throw new Error(`ward ${name} already exists on this device`);
|
|
109
|
+
return this.#keep(name, () => ({ seed: hex(kept.seed), partition: kept.partition, record: kept.record }));
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async save(name: string, partition: Record<string, unknown>): Promise<void> {
|
|
113
|
+
if (!(await exists(this.#path(name)))) return; // a name not kept is nothing
|
|
114
|
+
return this.#keep(name, (b) => ({ ...b!, partition }));
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async record(name: string, record: WardRecord): Promise<void> {
|
|
118
|
+
if (!(await exists(this.#path(name)))) return;
|
|
119
|
+
return this.#keep(name, (b) => ({ ...b!, record }));
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async take(name: string): Promise<Kept | undefined> {
|
|
123
|
+
const kept = await this.load(name);
|
|
124
|
+
if (!kept) return undefined;
|
|
125
|
+
await this.#queues.get(name);
|
|
126
|
+
await Filesystem.deleteFile({ path: this.#path(name), directory });
|
|
127
|
+
return kept;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async hints(): Promise<Record<string, string>> {
|
|
131
|
+
const p = `quo/${this.harbor}/reach.json`;
|
|
132
|
+
if (!(await exists(p))) return {};
|
|
133
|
+
const { data } = await Filesystem.readFile({ path: p, directory, encoding: Encoding.UTF8 });
|
|
134
|
+
return JSON.parse(data as string) as Record<string, string>;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async hint(pk: string, url: string): Promise<void> {
|
|
138
|
+
const all = await this.hints();
|
|
139
|
+
all[pk] = url;
|
|
140
|
+
await Filesystem.writeFile({ path: `quo/${this.harbor}/reach.json`, directory, data: JSON.stringify(all) + '\n', encoding: Encoding.UTF8 });
|
|
141
|
+
}
|
|
142
|
+
}
|
package/harbor/disk.ts
CHANGED
|
@@ -9,7 +9,9 @@
|
|
|
9
9
|
// <dir>/
|
|
10
10
|
// lease pid of the one process that holds this harbor
|
|
11
11
|
// classes/index.ts the default class source, a module exporting classes
|
|
12
|
-
//
|
|
12
|
+
// tab/index.ts the classes for the tab, served bundled by the web route: human/web.ts
|
|
13
|
+
// wards/<name>/ seed, partition.json, ward.json, or one sealed
|
|
14
|
+
// blob when the daemon holds QUO_SEED_KEY: see files.ts
|
|
13
15
|
// reach.json the directory's hints
|
|
14
16
|
import { mkdir, readFile, writeFile, unlink, stat } from 'node:fs/promises';
|
|
15
17
|
import { existsSync } from 'node:fs';
|
|
@@ -51,7 +53,10 @@ export class DiskHarbor extends Harbor {
|
|
|
51
53
|
|
|
52
54
|
constructor(dir: string) {
|
|
53
55
|
const abs = resolve(dir);
|
|
54
|
-
|
|
56
|
+
// The key the environment gives, the edge's name for it: from the
|
|
57
|
+
// Keychain through the app that spawned this daemon, or nothing on a
|
|
58
|
+
// droplet, whose folder stays plain under its file modes.
|
|
59
|
+
super(new Files(abs, process.env.QUO_SEED_KEY), async (rec) => ({ ...BUILT_IN, ...(await loadClasses(isAbsolute(rec.code) ? rec.code : join(abs, rec.code))) }));
|
|
55
60
|
this.dir = abs;
|
|
56
61
|
}
|
|
57
62
|
|
|
@@ -62,7 +67,7 @@ export class DiskHarbor extends Harbor {
|
|
|
62
67
|
// has one.
|
|
63
68
|
static async init(dir: string, name = 'main', user = 'me'): Promise<{ dir: string; name: string; pk: string; user: string }> {
|
|
64
69
|
const h = new DiskHarbor(dir);
|
|
65
|
-
if (
|
|
70
|
+
if ((await h.store.list()).includes(name)) throw new Error(`ward ${name} already exists in ${h.dir}`);
|
|
66
71
|
await mkdir(join(h.dir, 'classes'), { recursive: true });
|
|
67
72
|
const classes = join(h.dir, DEFAULT_CODE);
|
|
68
73
|
if (!existsSync(classes)) await writeFile(classes, '// The classes this harbor holds beside the built-in ones. Export each one by name.\nexport {};\n');
|