@quo-systems/dock 0.2.3 → 0.2.5

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 (54) hide show
  1. package/beings/carry.ts +7 -3
  2. package/beings/desk.ts +5 -1
  3. package/beings/look.ts +12 -3
  4. package/beings/quo-dock.md +22 -13
  5. package/beings/user.ts +20 -6
  6. package/dist/beings/carry.js +10 -4
  7. package/dist/beings/desk.d.ts +0 -1
  8. package/dist/beings/desk.js +5 -1
  9. package/dist/beings/look.d.ts +2 -0
  10. package/dist/beings/look.js +9 -0
  11. package/dist/beings/user.d.ts +24 -0
  12. package/dist/beings/user.js +20 -6
  13. package/dist/harbor/browser.d.ts +4 -4
  14. package/dist/harbor/browser.js +18 -9
  15. package/dist/harbor/capacitor.d.ts +19 -0
  16. package/dist/harbor/capacitor.js +151 -0
  17. package/dist/harbor/disk.js +8 -3
  18. package/dist/harbor/edge/edge.js +2 -1
  19. package/dist/harbor/edge/exercise.js +2 -1
  20. package/dist/harbor/edge/storage.d.ts +0 -3
  21. package/dist/harbor/edge/storage.js +6 -25
  22. package/dist/harbor/files.d.ts +2 -1
  23. package/dist/harbor/files.js +87 -22
  24. package/dist/harbor/seal.d.ts +3 -0
  25. package/dist/harbor/seal.js +25 -0
  26. package/dist/human/html.d.ts +1 -0
  27. package/dist/human/html.js +40 -19
  28. package/dist/human/local.d.ts +10 -0
  29. package/dist/human/local.js +10 -0
  30. package/dist/human/screen.js +32 -18
  31. package/dist/human/tab.d.ts +1 -0
  32. package/dist/human/tab.js +59 -3
  33. package/dist/human/web.d.ts +1 -0
  34. package/dist/human/web.js +34 -4
  35. package/dist/mcp/runner.js +5 -3
  36. package/dist/mcp/server.js +5 -3
  37. package/harbor/browser.ts +28 -17
  38. package/harbor/capacitor.ts +159 -0
  39. package/harbor/disk.ts +8 -3
  40. package/harbor/edge/edge.ts +2 -1
  41. package/harbor/edge/exercise.ts +2 -1
  42. package/harbor/edge/storage.ts +6 -25
  43. package/harbor/files.ts +79 -19
  44. package/harbor/quo-harbor.md +66 -14
  45. package/harbor/seal.ts +26 -0
  46. package/human/html.ts +39 -18
  47. package/human/local.ts +26 -0
  48. package/human/quo-human.md +70 -13
  49. package/human/screen.ts +27 -15
  50. package/human/tab.ts +50 -5
  51. package/human/web.ts +34 -5
  52. package/mcp/runner.ts +5 -3
  53. package/mcp/server.ts +5 -3
  54. package/package.json +14 -2
package/beings/carry.ts CHANGED
@@ -58,16 +58,20 @@ export class Carrier extends Being {
58
58
  if (!st) continue;
59
59
  if (!rec.blueprint || rec.seen !== rec.digest) await st.ask(); // her ward writes the record
60
60
  if (!rec.blueprint) continue;
61
+ // her look is kept here once per digest and never carried as an ask;
62
+ // her page follows every call, so it is carried as one and named in
63
+ // the notes, where every side finds it and none takes it for a form
61
64
  const names: string[] = [];
65
+ const group: { asks: string[]; look?: Look; page?: string } = { asks: names };
62
66
  for (const a of rec.blueprint.asks) {
63
- if (a.name === 'look' || a.name === 'page') continue; // presentation is hers, never carried as an ask
67
+ if (a.name === 'look') continue;
64
68
  const name = `${id}-${a.name}`;
65
69
  if (own.has(name)) continue;
66
70
  carried[name] = { id, ask: a.name };
67
- names.push(name);
68
71
  bp.asks.push({ ...a, name });
72
+ if (a.name === 'page') group.page = name;
73
+ else names.push(name);
69
74
  }
70
- const group: { asks: string[]; look?: Look } = { asks: names };
71
75
  if (rec.blueprint.asks.some((a) => a.name === 'look')) {
72
76
  const kept = this.looks[id];
73
77
  if (!kept || kept.digest !== rec.digest) {
package/beings/desk.ts CHANGED
@@ -25,8 +25,12 @@ export class Desk extends Being {
25
25
  device: { description: 'trade a proof for a device invitation', input: { type: 'object', properties: { proof: { type: 'object' } }, required: ['proof'] } },
26
26
  };
27
27
 
28
+ // A stranger learns the proof kinds and nothing else. Who lives on this
29
+ // world is not the caller's to know: the verifier names the user, so
30
+ // nobody at the door ever has to, and a world with a thousand of them
31
+ // would otherwise hand the whole list to anyone who knocked.
28
32
  hello(_args: JsonObject, asker: Asker) {
29
- return { desk: true, asker: asker.id ?? null, kinds: Object.keys(Desk.verifiers), users: Object.keys(this.cells.standings).filter((k) => k.startsWith('user:')).map((k) => k.slice(5)) };
33
+ return { desk: true, asker: asker.id ?? null, kinds: Object.keys(Desk.verifiers) };
30
34
  }
31
35
 
32
36
  async device(args: JsonObject) {
package/beings/look.ts CHANGED
@@ -75,15 +75,24 @@ export function sanitise(v: Json | undefined): Look {
75
75
  // The one hint a side reads first: whether this ask is in the look at all.
76
76
  export const hint = (l: Look | undefined, name: string): Hint => l?.asks?.[name] ?? {};
77
77
 
78
- // A carrier's notes say which asks are a standing's, and how she looks:
79
- // `standings: { id: { asks: [name], look? } }`. Every side reads them here.
80
- export type Group = { asks?: string[]; look?: Json };
78
+ // A carrier's notes say which asks are a standing's, how she looks, and
79
+ // which carried ask is her page: `standings: { id: { asks: [name], look?,
80
+ // page? } }`. Every side reads them here.
81
+ export type Group = { asks?: string[]; look?: Json; page?: string };
81
82
  export function groups(bp: Blueprint | null): Record<string, Group> {
82
83
  const n = bp?.notes;
83
84
  const g = n !== null && n !== undefined && typeof n === 'object' && !Array.isArray(n) ? n.standings : undefined;
84
85
  return g !== null && g !== undefined && typeof g === 'object' && !Array.isArray(g) ? (g as Record<string, Group>) : {};
85
86
  }
86
87
 
88
+ // The asks that are presentation and never a form nor a tool: her look, her
89
+ // page, and the page of every standing she carries, named in her notes.
90
+ export function presentation(bp: Blueprint | null): Set<string> {
91
+ const p = new Set(['look', 'page']);
92
+ for (const g of Object.values(groups(bp))) if (typeof g.page === 'string') p.add(g.page);
93
+ return p;
94
+ }
95
+
87
96
  // The hint for one ask, hers or a standing's: a carried ask is looked up
88
97
  // under its bare name in that standing's look.
89
98
  export function hintFor(bp: Blueprint | null, mine: Look, name: string): Hint {
@@ -151,6 +151,12 @@ taken when it can be: a knock back that is unreached, a tab whose pk the
151
151
  far harbor has not bound yet or a phone in a tunnel, spends nothing, so she
152
152
  keeps the invitation and takes it at the next push. A refusal is final.
153
153
 
154
+ A device the root minted may also call her: her `name` ask, gated to
155
+ root-minted devices alone, since the root already trusts that device with
156
+ its id and its reach. A being does not know her own key, so a name is
157
+ always given, by the desk's first hello at setup or by such a device, and
158
+ she keeps the last one. A name is a word, held to the same shape as a key.
159
+
154
160
  ## Architecture
155
161
 
156
162
  One droplet, one harbor, three routes. Every other placement is a subset.
@@ -272,14 +278,15 @@ Carrying is a base, `beings/carry.ts`, and nothing in it knows what a user
272
278
  is: a house being could carry its rooms, a company its suppliers. A subclass
273
279
  says who may see what she carries, and the user being says a device with the
274
280
  `reach` note, written when the human ticked the box at the exchange. The
275
- notes of a carrier's describe say which asks are whose,
276
- `standings: { id: { asks, look? } }`, so a screen draws one section per
277
- standing and a model side keeps the flat list it needs; the mapping back
278
- from a carried name to the standing is in her cells and never parsed from
279
- the name. Her own asks come first and are never shadowed. She asks a
280
- standing's describe again when its digest moved and its `look` once per
281
- digest, so a describe of hers may cost one ask per stale standing, the price
282
- of a page that is never more than one ask behind.
281
+ notes of a carrier's describe say which asks are whose, how each standing
282
+ looks and which carried ask is her page, `standings: { id: { asks, look?,
283
+ page? } }`, so a screen draws one section per standing, paints her page in
284
+ it when she has one, and a model side keeps the flat list it needs with no
285
+ page in it; the mapping back from a carried name to the standing is in her
286
+ cells and never parsed from the name. Her own asks come first and are never
287
+ shadowed. She asks a standing's describe again when its digest moved and its
288
+ `look` once per digest, so a describe of hers may cost one ask per stale
289
+ standing, the price of a page that is never more than one ask behind.
283
290
 
284
291
  ## The look
285
292
 
@@ -307,11 +314,13 @@ A look is not capability: it is never in a blueprint or a digest, Quo never
307
314
  sees it, and a being without one renders and lists exactly as before. The
308
315
  `look` ask itself is presentation and is neither a form nor a tool, and so
309
316
  is `page`, the one other presentation ask, which a screen alone reads and
310
- `packages/dock/human/quo-human.md` defines; a carrier carries neither. A
311
- carrier keeps each standing's look beside her asks in the notes, so acme's
312
- section on the human's page is painted as acme asked, inside that section
313
- and nowhere else. No token can carry a URL, a stylesheet or code, so a far
314
- being cannot paint over the page or reach a server through it. A being who
317
+ `packages/dock/human/quo-human.md` defines. A carrier keeps each standing's
318
+ look beside her asks in the notes, since a look moves with the digest, and
319
+ carries her page as an ask named there, since a page moves with every call;
320
+ so acme's section on the human's page is painted as acme asked, her look
321
+ and her page, inside that section and nowhere else. No token can carry a
322
+ URL, a stylesheet or code, so a far being cannot paint over the page or
323
+ reach a server through it. A being who
315
324
  marks an ask read-only and then writes has lied to her own page, and nothing
316
325
  enforces it, the way nothing enforces that her empty ask is safe to repeat.
317
326
 
package/beings/user.ts CHANGED
@@ -43,6 +43,7 @@ export class User extends Carrier {
43
43
  chores: { description: 'what the agent may run', input: { type: 'object' }, for: (occ: OccupantRecord | undefined) => client(occ) === 'agent' },
44
44
  look: { description: 'how she is shown', input: { type: 'object' }, for: isDevice },
45
45
  page: { description: 'her page, as a tree of values', input: { type: 'object' }, for: isDevice },
46
+ name: { description: 'what she is called', input: { type: 'object', properties: { name: { type: 'string' } }, required: ['name'] }, for: rootMinted },
46
47
  forget: { description: 'revoke a device: drop its way in and her way back to it, in one act', input: { type: 'object', properties: { client: { type: 'string' } }, required: ['client'] }, for: isDesk },
47
48
  report: { description: 'what a run of yours found', input: { type: 'object', properties: { event: { type: 'object' }, result: {} }, required: ['event', 'result'] }, for: isDevice },
48
49
  };
@@ -134,16 +135,18 @@ export class User extends Carrier {
134
135
  look() {
135
136
  return {
136
137
  name: (this.cells.name as string) || 'you',
137
- order: ['whoami', 'push'],
138
- asks: { whoami: { title: 'who am I', readOnly: true }, push: { title: 'wake a device' } },
138
+ order: ['whoami', 'push', 'name'],
139
+ asks: { whoami: { title: 'who am I', readOnly: true }, push: { title: 'wake a device' }, name: { title: 'call her' } },
139
140
  };
140
141
  }
141
142
 
142
143
  // Her page for a device: her name, who this device is, the worlds she
143
- // holds as sections, and the one thing a human does here by hand, waking
144
- // another device. `hello` and `report` are wiring, a device's first word
145
- // and an agent's callback; they stay in her describe under the gate and
146
- // off her page, since a page is presentation and the gate is permission.
144
+ // holds as sections, and the two things a human does here by hand, waking
145
+ // another device and naming her. `hello` and `report` are wiring, a
146
+ // device's first word and an agent's callback; they stay in her describe
147
+ // under the gate and off her page, since a page is presentation and the
148
+ // gate is permission. A device that may not do a thing sees no form for
149
+ // it, however the page names it.
147
150
  page() {
148
151
  return {
149
152
  kind: 'stack',
@@ -152,10 +155,21 @@ export class User extends Carrier {
152
155
  { kind: 'row', of: [{ kind: 'text', text: 'this device', role: 'label' }, { kind: 'answer', ask: 'whoami' }] },
153
156
  { kind: 'standings' },
154
157
  { kind: 'form', ask: 'push' },
158
+ { kind: 'form', ask: 'name' },
155
159
  ],
156
160
  };
157
161
  }
158
162
 
163
+ // A device the root minted may say what she is called: the root trusts
164
+ // it with its id and its reach already, and a being does not know her own
165
+ // key. A name is a word; she keeps the last one given.
166
+ name(args: JsonObject) {
167
+ const name = typeof args.name === 'string' ? args.name.trim() : '';
168
+ if (!/^[\w.-]{1,80}$/.test(name)) return { error: 'a name is a word' };
169
+ this.cells.name = name;
170
+ return { named: name };
171
+ }
172
+
159
173
  // A device that ran something for her says what it found. Kept, so that
160
174
  // whoever renders her can show it; the ask itself is the callback.
161
175
  report(args: JsonObject, asker: Asker) {
@@ -56,18 +56,24 @@ export class Carrier extends Being {
56
56
  await st.ask(); // her ward writes the record
57
57
  if (!rec.blueprint)
58
58
  continue;
59
+ // her look is kept here once per digest and never carried as an ask;
60
+ // her page follows every call, so it is carried as one and named in
61
+ // the notes, where every side finds it and none takes it for a form
59
62
  const names = [];
63
+ const group = { asks: names };
60
64
  for (const a of rec.blueprint.asks) {
61
- if (a.name === 'look' || a.name === 'page')
62
- continue; // presentation is hers, never carried as an ask
65
+ if (a.name === 'look')
66
+ continue;
63
67
  const name = `${id}-${a.name}`;
64
68
  if (own.has(name))
65
69
  continue;
66
70
  carried[name] = { id, ask: a.name };
67
- names.push(name);
68
71
  bp.asks.push({ ...a, name });
72
+ if (a.name === 'page')
73
+ group.page = name;
74
+ else
75
+ names.push(name);
69
76
  }
70
- const group = { asks: names };
71
77
  if (rec.blueprint.asks.some((a) => a.name === 'look')) {
72
78
  const kept = this.looks[id];
73
79
  if (!kept || kept.digest !== rec.digest) {
@@ -37,7 +37,6 @@ export declare class Desk extends Being {
37
37
  desk: boolean;
38
38
  asker: string | null;
39
39
  kinds: string[];
40
- users: string[];
41
40
  };
42
41
  device(args: JsonObject): Promise<Json>;
43
42
  }
@@ -17,8 +17,12 @@ export class Desk extends Being {
17
17
  hello: { description: 'how to get in', input: { type: 'object' } },
18
18
  device: { description: 'trade a proof for a device invitation', input: { type: 'object', properties: { proof: { type: 'object' } }, required: ['proof'] } },
19
19
  };
20
+ // A stranger learns the proof kinds and nothing else. Who lives on this
21
+ // world is not the caller's to know: the verifier names the user, so
22
+ // nobody at the door ever has to, and a world with a thousand of them
23
+ // would otherwise hand the whole list to anyone who knocked.
20
24
  hello(_args, asker) {
21
- return { desk: true, asker: asker.id ?? null, kinds: Object.keys(Desk.verifiers), users: Object.keys(this.cells.standings).filter((k) => k.startsWith('user:')).map((k) => k.slice(5)) };
25
+ return { desk: true, asker: asker.id ?? null, kinds: Object.keys(Desk.verifiers) };
22
26
  }
23
27
  async device(args) {
24
28
  const proof = args.proof;
@@ -22,6 +22,8 @@ export declare const hint: (l: Look | undefined, name: string) => Hint;
22
22
  export type Group = {
23
23
  asks?: string[];
24
24
  look?: Json;
25
+ page?: string;
25
26
  };
26
27
  export declare function groups(bp: Blueprint | null): Record<string, Group>;
28
+ export declare function presentation(bp: Blueprint | null): Set<string>;
27
29
  export declare function hintFor(bp: Blueprint | null, mine: Look, name: string): Hint;
@@ -58,6 +58,15 @@ export function groups(bp) {
58
58
  const g = n !== null && n !== undefined && typeof n === 'object' && !Array.isArray(n) ? n.standings : undefined;
59
59
  return g !== null && g !== undefined && typeof g === 'object' && !Array.isArray(g) ? g : {};
60
60
  }
61
+ // The asks that are presentation and never a form nor a tool: her look, her
62
+ // page, and the page of every standing she carries, named in her notes.
63
+ export function presentation(bp) {
64
+ const p = new Set(['look', 'page']);
65
+ for (const g of Object.values(groups(bp)))
66
+ if (typeof g.page === 'string')
67
+ p.add(g.page);
68
+ return p;
69
+ }
61
70
  // The hint for one ask, hers or a standing's: a carried ask is looked up
62
71
  // under its bare name in that standing's look.
63
72
  export function hintFor(bp, mine, name) {
@@ -3,6 +3,7 @@ import { Carrier } from './carry.ts';
3
3
  export declare const DESK = "desk";
4
4
  declare const isDesk: (occ: OccupantRecord | undefined) => boolean;
5
5
  declare const isDevice: (occ: OccupantRecord | undefined) => boolean;
6
+ declare const rootMinted: (occ: OccupantRecord | undefined) => boolean;
6
7
  declare const mayWake: (occ: OccupantRecord | undefined) => boolean;
7
8
  export declare class User extends Carrier {
8
9
  static carries(occ: OccupantRecord | undefined): boolean;
@@ -86,6 +87,19 @@ export declare class User extends Carrier {
86
87
  };
87
88
  for: typeof isDevice;
88
89
  };
90
+ name: {
91
+ description: string;
92
+ input: {
93
+ type: string;
94
+ properties: {
95
+ name: {
96
+ type: string;
97
+ };
98
+ };
99
+ required: string[];
100
+ };
101
+ for: typeof rootMinted;
102
+ };
89
103
  forget: {
90
104
  description: string;
91
105
  input: {
@@ -152,6 +166,9 @@ export declare class User extends Carrier {
152
166
  push: {
153
167
  title: string;
154
168
  };
169
+ name: {
170
+ title: string;
171
+ };
155
172
  };
156
173
  };
157
174
  page(): {
@@ -192,6 +209,13 @@ export declare class User extends Carrier {
192
209
  ask: string;
193
210
  })[];
194
211
  };
212
+ name(args: JsonObject): {
213
+ error: string;
214
+ named?: undefined;
215
+ } | {
216
+ error?: undefined;
217
+ named: string;
218
+ };
195
219
  report(args: JsonObject, asker: Asker): {
196
220
  reported: boolean;
197
221
  };
@@ -40,6 +40,7 @@ export class User extends Carrier {
40
40
  chores: { description: 'what the agent may run', input: { type: 'object' }, for: (occ) => client(occ) === 'agent' },
41
41
  look: { description: 'how she is shown', input: { type: 'object' }, for: isDevice },
42
42
  page: { description: 'her page, as a tree of values', input: { type: 'object' }, for: isDevice },
43
+ name: { description: 'what she is called', input: { type: 'object', properties: { name: { type: 'string' } }, required: ['name'] }, for: rootMinted },
43
44
  forget: { description: 'revoke a device: drop its way in and her way back to it, in one act', input: { type: 'object', properties: { client: { type: 'string' } }, required: ['client'] }, for: isDesk },
44
45
  report: { description: 'what a run of yours found', input: { type: 'object', properties: { event: { type: 'object' }, result: {} }, required: ['event', 'result'] }, for: isDevice },
45
46
  };
@@ -134,15 +135,17 @@ export class User extends Carrier {
134
135
  look() {
135
136
  return {
136
137
  name: this.cells.name || 'you',
137
- order: ['whoami', 'push'],
138
- asks: { whoami: { title: 'who am I', readOnly: true }, push: { title: 'wake a device' } },
138
+ order: ['whoami', 'push', 'name'],
139
+ asks: { whoami: { title: 'who am I', readOnly: true }, push: { title: 'wake a device' }, name: { title: 'call her' } },
139
140
  };
140
141
  }
141
142
  // Her page for a device: her name, who this device is, the worlds she
142
- // holds as sections, and the one thing a human does here by hand, waking
143
- // another device. `hello` and `report` are wiring, a device's first word
144
- // and an agent's callback; they stay in her describe under the gate and
145
- // off her page, since a page is presentation and the gate is permission.
143
+ // holds as sections, and the two things a human does here by hand, waking
144
+ // another device and naming her. `hello` and `report` are wiring, a
145
+ // device's first word and an agent's callback; they stay in her describe
146
+ // under the gate and off her page, since a page is presentation and the
147
+ // gate is permission. A device that may not do a thing sees no form for
148
+ // it, however the page names it.
146
149
  page() {
147
150
  return {
148
151
  kind: 'stack',
@@ -151,9 +154,20 @@ export class User extends Carrier {
151
154
  { kind: 'row', of: [{ kind: 'text', text: 'this device', role: 'label' }, { kind: 'answer', ask: 'whoami' }] },
152
155
  { kind: 'standings' },
153
156
  { kind: 'form', ask: 'push' },
157
+ { kind: 'form', ask: 'name' },
154
158
  ],
155
159
  };
156
160
  }
161
+ // A device the root minted may say what she is called: the root trusts
162
+ // it with its id and its reach already, and a being does not know her own
163
+ // key. A name is a word; she keeps the last one given.
164
+ name(args) {
165
+ const name = typeof args.name === 'string' ? args.name.trim() : '';
166
+ if (!/^[\w.-]{1,80}$/.test(name))
167
+ return { error: 'a name is a word' };
168
+ this.cells.name = name;
169
+ return { named: name };
170
+ }
157
171
  // A device that ran something for her says what it found. Kept, so that
158
172
  // whoever renders her can show it; the ask itself is the callback.
159
173
  report(args, asker) {
@@ -1,13 +1,13 @@
1
1
  import type { BeingClass } from '@quo-systems/quo';
2
- import { Harbor, type Dialer } from '@quo-systems/quo/harbor';
3
- import { Idb } from './idb.ts';
2
+ import { Harbor, type Dialer, type Store } from '@quo-systems/quo/harbor';
4
3
  export declare const BUILT_IN: Record<string, BeingClass>;
5
4
  export declare class BrowserHarbor extends Harbor {
6
5
  #private;
7
- readonly db: Idb;
6
+ readonly name: string;
8
7
  readonly dialers: Dialer[];
9
- constructor(name?: string, classes?: Record<string, BeingClass>);
8
+ constructor(store?: string | Store, classes?: Record<string, BeingClass>);
10
9
  boot(): Promise<void>;
11
10
  dial(url: string): Dialer;
11
+ wake(): void;
12
12
  close(): Promise<void>;
13
13
  }
@@ -3,18 +3,21 @@ import { Harbor, dial } from '@quo-systems/quo/harbor';
3
3
  import { Idb } from './idb.js';
4
4
  export const BUILT_IN = { User, Desk, Avatar };
5
5
  export class BrowserHarbor extends Harbor {
6
- db;
6
+ name;
7
7
  dialers = [];
8
+ #locked;
8
9
  #release;
9
- constructor(name = 'quo', classes = {}) {
10
- const db = new Idb(name);
11
- super(db, async () => ({ ...BUILT_IN, ...classes }));
12
- this.db = db;
10
+ constructor(store = 'quo', classes = {}) {
11
+ const s = typeof store === 'string' ? new Idb(store) : store;
12
+ super(s, async () => ({ ...BUILT_IN, ...classes }));
13
+ this.name = typeof store === 'string' ? store : 'quo';
14
+ this.#locked = typeof store === 'string';
13
15
  }
14
16
  // Take the lock and boot every ward kept here. A lock already held is
15
17
  // another tab's harbor over this seed, and this one refuses to boot.
16
18
  async boot() {
17
- await this.#lease();
19
+ if (this.#locked)
20
+ await this.#lease();
18
21
  await super.boot();
19
22
  }
20
23
  // Hold one socket to a world's quo. route.
@@ -23,10 +26,16 @@ export class BrowserHarbor extends Harbor {
23
26
  this.dialers.push(d);
24
27
  return d;
25
28
  }
29
+ // The device is back: a phone out of a pocket, an app to the foreground.
30
+ // Every socket is told, and one that died silently is dialed again.
31
+ wake() {
32
+ for (const d of this.dialers)
33
+ d.wake();
34
+ }
26
35
  async close() {
27
36
  for (const d of this.dialers.splice(0))
28
37
  d.close();
29
- this.db.close();
38
+ this.store.close?.();
30
39
  this.#release?.();
31
40
  this.#release = undefined;
32
41
  }
@@ -35,7 +44,7 @@ export class BrowserHarbor extends Harbor {
35
44
  if (!locks)
36
45
  return; // no web locks on this terrain: nothing vouches, as in the memory harbor
37
46
  const granted = await new Promise((ok) => {
38
- void locks.request(`quo:${this.db.name}`, { ifAvailable: true }, (lock) => {
47
+ void locks.request(`quo:${this.name}`, { ifAvailable: true }, (lock) => {
39
48
  if (!lock) {
40
49
  ok(false);
41
50
  return;
@@ -45,6 +54,6 @@ export class BrowserHarbor extends Harbor {
45
54
  });
46
55
  });
47
56
  if (!granted)
48
- throw new Error(`harbor ${this.db.name} is held by another tab`);
57
+ throw new Error(`harbor ${this.name} is held by another tab`);
49
58
  }
50
59
  }
@@ -0,0 +1,19 @@
1
+ import type { BeingClass } from '@quo-systems/quo';
2
+ import type { Kept, Store, WardRecord } from '@quo-systems/quo/harbor';
3
+ import { BrowserHarbor } from './browser.ts';
4
+ export declare function nativeHarbor(name?: string, classes?: Record<string, BeingClass>): Promise<BrowserHarbor>;
5
+ export declare class Native implements Store {
6
+ #private;
7
+ readonly harbor: string;
8
+ private constructor();
9
+ static open(harbor: string): Promise<Native>;
10
+ static wipe(harbor: string): Promise<void>;
11
+ list(): Promise<string[]>;
12
+ load(name: string): Promise<Kept | undefined>;
13
+ put(name: string, kept: Kept): Promise<void>;
14
+ save(name: string, partition: Record<string, unknown>): Promise<void>;
15
+ record(name: string, record: WardRecord): Promise<void>;
16
+ take(name: string): Promise<Kept | undefined>;
17
+ hints(): Promise<Record<string, string>>;
18
+ hint(pk: string, url: string): Promise<void>;
19
+ }
@@ -0,0 +1,151 @@
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
+ //
16
+ // The harbor on a phone is the browser harbor over this store: the app
17
+ // process is its lease, and the App plugin's foreground is its wake.
18
+ import { Capacitor } from '@capacitor/core';
19
+ import { App } from '@capacitor/app';
20
+ import { Filesystem, Directory, Encoding } from '@capacitor/filesystem';
21
+ import { SecureStorage, KeychainAccess } from '@aparajita/capacitor-secure-storage';
22
+ import { arithmetic } from '@quo-systems/quo/ward';
23
+ import { BrowserHarbor } from './browser.js';
24
+ import { sealKey, seal, open } from './seal.js';
25
+ // The app's one harbor, booted, told `wake` every time the app comes to
26
+ // the foreground, since a phone asleep loses its sockets silently.
27
+ export async function nativeHarbor(name = 'quo', classes = {}) {
28
+ const h = new BrowserHarbor(await Native.open(name), classes);
29
+ await h.boot();
30
+ await App.addListener('appStateChange', ({ isActive }) => {
31
+ if (isActive)
32
+ h.wake();
33
+ });
34
+ return h;
35
+ }
36
+ const { hex, unhex } = arithmetic;
37
+ // Where the files live: the folder iCloud does not copy on iOS, the app's
38
+ // own files on Android, whose manifest says no backup.
39
+ const directory = Capacitor.getPlatform() === 'ios' ? Directory.LibraryNoCloud : Directory.Data;
40
+ async function exists(path) {
41
+ try {
42
+ await Filesystem.stat({ path, directory });
43
+ return true;
44
+ }
45
+ catch {
46
+ return false;
47
+ }
48
+ }
49
+ export class Native {
50
+ harbor;
51
+ #key;
52
+ #queues = new Map();
53
+ constructor(harbor, key) {
54
+ this.harbor = harbor;
55
+ this.#key = key;
56
+ }
57
+ // Open one harbor's store, applying the custody rule, minting the key and
58
+ // the folder when this is a fresh install.
59
+ static async open(harbor) {
60
+ const item = `quo-${harbor}`;
61
+ const root = `quo/${harbor}`;
62
+ // `set` keeps JSON, so `get` parses it back; `getItem` would hand back the quotes.
63
+ const got = await SecureStorage.get(item, false, false);
64
+ let secret = typeof got === 'string' ? got : undefined;
65
+ const folder = await exists(root);
66
+ if (secret && !folder) {
67
+ await SecureStorage.remove(item);
68
+ secret = undefined;
69
+ }
70
+ if (!secret && folder)
71
+ await Filesystem.rmdir({ path: root, directory, recursive: true });
72
+ if (!secret) {
73
+ secret = hex(crypto.getRandomValues(new Uint8Array(32)));
74
+ await SecureStorage.set(item, secret, false, false, KeychainAccess.afterFirstUnlockThisDeviceOnly);
75
+ }
76
+ // mkdir refuses a folder that exists, recursive or not.
77
+ if (!(await exists(`${root}/wards`)))
78
+ await Filesystem.mkdir({ path: `${root}/wards`, directory, recursive: true });
79
+ return new Native(harbor, await sealKey(secret));
80
+ }
81
+ // Everything this harbor has, key and files: what a person does by hand
82
+ // to leave a device, and what a test does between two openings.
83
+ static async wipe(harbor) {
84
+ await SecureStorage.remove(`quo-${harbor}`);
85
+ if (await exists(`quo/${harbor}`))
86
+ await Filesystem.rmdir({ path: `quo/${harbor}`, directory, recursive: true });
87
+ }
88
+ #path(name) {
89
+ return `quo/${this.harbor}/wards/${name}.sealed`;
90
+ }
91
+ async #read(name) {
92
+ const { data } = await Filesystem.readFile({ path: this.#path(name), directory, encoding: Encoding.UTF8 });
93
+ return JSON.parse(new TextDecoder().decode(await open(this.#key, data.trim())));
94
+ }
95
+ // A sealed ward is rewritten whole, so the read sits inside the queue
96
+ // with the write: two changes to one ward never lose each other's part.
97
+ #keep(name, change) {
98
+ const next = (this.#queues.get(name) ?? Promise.resolve()).then(async () => {
99
+ const blob = change((await exists(this.#path(name))) ? await this.#read(name) : undefined);
100
+ const sealed = await seal(this.#key, new TextEncoder().encode(JSON.stringify(blob)));
101
+ await Filesystem.writeFile({ path: this.#path(name), directory, data: sealed + '\n', encoding: Encoding.UTF8 });
102
+ });
103
+ this.#queues.set(name, next.catch(() => { }));
104
+ return next;
105
+ }
106
+ async list() {
107
+ const { files } = await Filesystem.readdir({ path: `quo/${this.harbor}/wards`, directory });
108
+ return files.filter((f) => f.name.endsWith('.sealed')).map((f) => f.name.slice(0, -'.sealed'.length));
109
+ }
110
+ async load(name) {
111
+ if (!(await exists(this.#path(name))))
112
+ return undefined;
113
+ const b = await this.#read(name);
114
+ return { seed: unhex(b.seed), partition: b.partition, record: b.record };
115
+ }
116
+ async put(name, kept) {
117
+ if (await exists(this.#path(name)))
118
+ throw new Error(`ward ${name} already exists on this device`);
119
+ return this.#keep(name, () => ({ seed: hex(kept.seed), partition: kept.partition, record: kept.record }));
120
+ }
121
+ async save(name, partition) {
122
+ if (!(await exists(this.#path(name))))
123
+ return; // a name not kept is nothing
124
+ return this.#keep(name, (b) => ({ ...b, partition }));
125
+ }
126
+ async record(name, record) {
127
+ if (!(await exists(this.#path(name))))
128
+ return;
129
+ return this.#keep(name, (b) => ({ ...b, record }));
130
+ }
131
+ async take(name) {
132
+ const kept = await this.load(name);
133
+ if (!kept)
134
+ return undefined;
135
+ await this.#queues.get(name);
136
+ await Filesystem.deleteFile({ path: this.#path(name), directory });
137
+ return kept;
138
+ }
139
+ async hints() {
140
+ const p = `quo/${this.harbor}/reach.json`;
141
+ if (!(await exists(p)))
142
+ return {};
143
+ const { data } = await Filesystem.readFile({ path: p, directory, encoding: Encoding.UTF8 });
144
+ return JSON.parse(data);
145
+ }
146
+ async hint(pk, url) {
147
+ const all = await this.hints();
148
+ all[pk] = url;
149
+ await Filesystem.writeFile({ path: `quo/${this.harbor}/reach.json`, directory, data: JSON.stringify(all) + '\n', encoding: Encoding.UTF8 });
150
+ }
151
+ }