@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.
Files changed (68) hide show
  1. package/beings/avatar.ts +8 -1
  2. package/beings/carry.ts +1 -1
  3. package/beings/quo-dock.md +53 -39
  4. package/beings/setup.ts +2 -1
  5. package/beings/user.ts +48 -1
  6. package/cli/daemon.ts +51 -25
  7. package/cli/estate/quo.service +0 -1
  8. package/dist/beings/avatar.js +9 -2
  9. package/dist/beings/carry.js +2 -2
  10. package/dist/beings/setup.js +2 -1
  11. package/dist/beings/user.d.ts +89 -0
  12. package/dist/beings/user.js +47 -1
  13. package/dist/cli/daemon.d.ts +10 -2
  14. package/dist/cli/daemon.js +51 -23
  15. package/dist/cli/estate/quo.service +0 -1
  16. package/dist/harbor/capacitor.d.ts +16 -0
  17. package/dist/harbor/capacitor.js +135 -0
  18. package/dist/harbor/disk.js +8 -3
  19. package/dist/harbor/edge/edge.js +2 -1
  20. package/dist/harbor/edge/exercise.js +2 -1
  21. package/dist/harbor/edge/storage.d.ts +0 -3
  22. package/dist/harbor/edge/storage.js +6 -25
  23. package/dist/harbor/files.d.ts +2 -1
  24. package/dist/harbor/files.js +87 -22
  25. package/dist/harbor/seal.d.ts +3 -0
  26. package/dist/harbor/seal.js +25 -0
  27. package/dist/human/door.d.ts +2 -0
  28. package/dist/human/door.js +19 -7
  29. package/dist/human/html.d.ts +2 -0
  30. package/dist/human/html.js +43 -17
  31. package/dist/human/local.d.ts +10 -0
  32. package/dist/human/local.js +10 -0
  33. package/dist/human/screen.js +26 -8
  34. package/dist/human/tab.d.ts +1 -0
  35. package/dist/human/tab.js +59 -3
  36. package/dist/human/tree.d.ts +40 -0
  37. package/dist/human/tree.js +89 -0
  38. package/dist/human/web.d.ts +1 -0
  39. package/dist/human/web.js +36 -8
  40. package/dist/mcp/oauth.js +2 -2
  41. package/dist/mcp/route.js +1 -1
  42. package/dist/mcp/runner.js +5 -2
  43. package/dist/mcp/server.js +1 -1
  44. package/dist/mcp/web/exchange.d.ts +5 -9
  45. package/dist/mcp/web/exchange.js +47 -98
  46. package/harbor/capacitor.ts +142 -0
  47. package/harbor/disk.ts +8 -3
  48. package/harbor/edge/edge.ts +2 -1
  49. package/harbor/edge/exercise.ts +2 -1
  50. package/harbor/edge/storage.ts +6 -25
  51. package/harbor/files.ts +79 -19
  52. package/harbor/quo-harbor.md +58 -15
  53. package/harbor/seal.ts +26 -0
  54. package/human/door.ts +20 -7
  55. package/human/html.ts +41 -17
  56. package/human/local.ts +26 -0
  57. package/human/quo-human.md +114 -19
  58. package/human/screen.ts +21 -7
  59. package/human/tab.ts +50 -5
  60. package/human/tree.ts +129 -0
  61. package/human/web.ts +36 -9
  62. package/mcp/oauth.ts +2 -2
  63. package/mcp/quo-mcp.md +27 -20
  64. package/mcp/route.ts +1 -1
  65. package/mcp/runner.ts +9 -6
  66. package/mcp/server.ts +1 -1
  67. package/mcp/web/exchange.ts +62 -96
  68. 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: () => '' };
@@ -6,4 +6,5 @@ export type WebOptions = {
6
6
  web: string;
7
7
  };
8
8
  };
9
+ export declare const TAB_CODE = "tab/index.ts";
9
10
  export declare function webRoute(harbor: DiskHarbor, o: WebOptions): (req: IncomingMessage, res: ServerResponse, rest: string) => Promise<boolean>;
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(['login', 'allow', 'tab.js']);
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 ??= build({ entryPoints: [tabEntry()], bundle: true, format: 'esm', platform: 'browser', target: 'es2023', write: false }).then((out) => out.outputFiles[0].text));
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 .mark{width:3.5rem;height:3.5rem;color:var(--ink);opacity:.9;margin-bottom:1.25rem}',
90
- 'main.door h1{font-size:3rem;font-weight:400;letter-spacing:-.02em;line-height:1;margin:0 0 .5rem}',
91
- 'main.door .where{font-family:system-ui,sans-serif;font-size:.85rem;letter-spacing:.08em;text-transform:uppercase;color:var(--mute);margin:0 0 2rem}main.door .where span{text-transform:none;letter-spacing:0}',
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 own login and allow pages; that returns the redirect that
18
- // carries the code back to the client. Step 8 builds those pages.
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}/login?request=${id}`, store, persist });
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;
@@ -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.map((a) => {
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;
@@ -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 declare const SESSION_TTL: number;
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
- password: () => string | undefined;
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
  }
@@ -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
- export const SESSION_TTL = 10 * 60 * 1000; // a login lives as long as a request: ten minutes
18
- const COOKIE = 'quo_exchange';
2
+ import { parse } from '../../beings/link.js';
19
3
  const esc = (s) => s.replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[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, headers = {}) => {
52
- res.writeHead(status, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store', ...headers });
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, headers = {}) => {
56
- res.writeHead(302, { location: to, 'cache-control': 'no-store', ...headers });
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>`), true;
91
- if (!this.valid(req))
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>`), true;
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>`), true;
61
+ return 'redirect' in out ? go(out.redirect) : page(400, `<h1>Gone</h1>`);
106
62
  }
107
63
  const worlds = this.o.worlds();
108
- const world = worlds.find((w) => w.ward === (f.get('ward') ?? worlds[0]?.ward));
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 page(400, allowForm(request, p.client.client_name, p.redirect_uri, suggest(p.client.client_name), worlds, 'That is not a world of this harbor.')), true;
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 page(400, allowForm(request, p.client.client_name, p.redirect_uri, suggest(p.client.client_name), worlds, 'An identity is one word, and not the user or the desk.')), true;
114
- const admitted = await this.o.admit(identity, f.get('wake') === 'on', f.get('reach') === 'on', world.ward);
115
- if (admitted.error)
116
- return page(500, `<h1>Not admitted</h1><p>${esc(admitted.error)}</p>`), true;
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, { 'set-cookie': `${COOKIE}=; Path=/; Max-Age=0` }) : page(400, `<h1>Gone</h1>`), true;
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
- const loginForm = (request, client, err = '') => `<h1>${esc(client)} wants in</h1>
127
- <p>Log in as the owner of this world to decide.</p>
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 user = worlds[0]?.user ?? '';
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 <strong>${esc(user)}</strong>${worlds.length > 1 ? ', or of another world below' : ''}.</p>
141
- <p>It will see exactly what ${esc(user)} shows the identity below, and nothing else. You can remove it any time.</p>
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="r"><input id="r" name="reach" type="checkbox" style="width:auto"> May reach what ${esc(user)} holds: her standings, acme and the rest, as asks of hers</label>
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
- // wards/<name>/ seed, partition.json, ward.json: see files.ts
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
- super(new Files(abs), async (rec) => ({ ...BUILT_IN, ...(await loadClasses(isAbsolute(rec.code) ? rec.code : join(abs, rec.code))) }));
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 (existsSync(join(h.dir, 'wards', name, 'seed'))) throw new Error(`ward ${name} already exists in ${h.dir}`);
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');