@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.
- package/beings/carry.ts +7 -3
- package/beings/desk.ts +5 -1
- package/beings/look.ts +12 -3
- package/beings/quo-dock.md +22 -13
- package/beings/user.ts +20 -6
- package/dist/beings/carry.js +10 -4
- package/dist/beings/desk.d.ts +0 -1
- package/dist/beings/desk.js +5 -1
- package/dist/beings/look.d.ts +2 -0
- package/dist/beings/look.js +9 -0
- package/dist/beings/user.d.ts +24 -0
- package/dist/beings/user.js +20 -6
- package/dist/harbor/browser.d.ts +4 -4
- package/dist/harbor/browser.js +18 -9
- package/dist/harbor/capacitor.d.ts +19 -0
- package/dist/harbor/capacitor.js +151 -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/html.d.ts +1 -0
- package/dist/human/html.js +40 -19
- package/dist/human/local.d.ts +10 -0
- package/dist/human/local.js +10 -0
- package/dist/human/screen.js +32 -18
- package/dist/human/tab.d.ts +1 -0
- package/dist/human/tab.js +59 -3
- package/dist/human/web.d.ts +1 -0
- package/dist/human/web.js +34 -4
- package/dist/mcp/runner.js +5 -3
- package/dist/mcp/server.js +5 -3
- package/harbor/browser.ts +28 -17
- package/harbor/capacitor.ts +159 -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 +66 -14
- package/harbor/seal.ts +26 -0
- package/human/html.ts +39 -18
- package/human/local.ts +26 -0
- package/human/quo-human.md +70 -13
- package/human/screen.ts +27 -15
- package/human/tab.ts +50 -5
- package/human/web.ts +34 -5
- package/mcp/runner.ts +5 -3
- package/mcp/server.ts +5 -3
- 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'
|
|
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)
|
|
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,
|
|
79
|
-
// `standings: { id: { asks: [name], look
|
|
80
|
-
|
|
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 {
|
package/beings/quo-dock.md
CHANGED
|
@@ -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
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
the
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
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
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
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
|
|
144
|
-
// another device. `hello` and `report` are wiring, a
|
|
145
|
-
// and an agent's callback; they stay in her describe
|
|
146
|
-
// off her page, since a page is presentation and the
|
|
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) {
|
package/dist/beings/carry.js
CHANGED
|
@@ -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'
|
|
62
|
-
continue;
|
|
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) {
|
package/dist/beings/desk.d.ts
CHANGED
package/dist/beings/desk.js
CHANGED
|
@@ -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)
|
|
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;
|
package/dist/beings/look.d.ts
CHANGED
|
@@ -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;
|
package/dist/beings/look.js
CHANGED
|
@@ -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) {
|
package/dist/beings/user.d.ts
CHANGED
|
@@ -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
|
};
|
package/dist/beings/user.js
CHANGED
|
@@ -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
|
|
143
|
-
// another device. `hello` and `report` are wiring, a
|
|
144
|
-
// and an agent's callback; they stay in her describe
|
|
145
|
-
// off her page, since a page is presentation and the
|
|
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) {
|
package/dist/harbor/browser.d.ts
CHANGED
|
@@ -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
|
|
6
|
+
readonly name: string;
|
|
8
7
|
readonly dialers: Dialer[];
|
|
9
|
-
constructor(
|
|
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
|
}
|
package/dist/harbor/browser.js
CHANGED
|
@@ -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
|
-
|
|
6
|
+
name;
|
|
7
7
|
dialers = [];
|
|
8
|
+
#locked;
|
|
8
9
|
#release;
|
|
9
|
-
constructor(
|
|
10
|
-
const
|
|
11
|
-
super(
|
|
12
|
-
this.
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
+
}
|