@quo-systems/quo 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/NOTICE +6 -0
- package/README.md +91 -0
- package/SPEC.md +1595 -0
- package/package.json +64 -0
- package/src/being/being.ts +92 -0
- package/src/being/digest.ts +31 -0
- package/src/being/index.ts +7 -0
- package/src/being/silence.ts +14 -0
- package/src/being/types.ts +88 -0
- package/src/conformance/assert.ts +67 -0
- package/src/conformance/beings.ts +129 -0
- package/src/conformance/estate.ts +339 -0
- package/src/conformance/index.ts +516 -0
- package/src/conformance/reach.ts +83 -0
- package/src/conformance/store.ts +109 -0
- package/src/harbor/core.ts +178 -0
- package/src/harbor/dial.ts +61 -0
- package/src/harbor/index.ts +11 -0
- package/src/harbor/memory.ts +74 -0
- package/src/harbor/reach.ts +189 -0
- package/src/harbor/store.ts +69 -0
- package/src/ward/allowance.ts +74 -0
- package/src/ward/arithmetic.ts +161 -0
- package/src/ward/cells.ts +90 -0
- package/src/ward/door.ts +102 -0
- package/src/ward/ground.ts +35 -0
- package/src/ward/heirs.ts +87 -0
- package/src/ward/index.ts +14 -0
- package/src/ward/owner.ts +122 -0
- package/src/ward/partition.ts +123 -0
- package/src/ward/seal.ts +133 -0
- package/src/ward/stance.ts +264 -0
- package/src/ward/ward.ts +209 -0
- package/vectors/arithmetic.json +110 -0
- package/vectors/framing.json +71 -0
- package/vectors/wire.json +48 -0
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// The owner's asks. The root owner holds the ask pointer; every other owner
|
|
3
|
+
// is an occupant of the ward itself, invited by the root. Five asks:
|
|
4
|
+
// describe, boot, invite, knock, remove. The knock is a being's knock made
|
|
5
|
+
// for her, and the standing is written into her cells under the id the
|
|
6
|
+
// owner gave; remove takes a relation out of her by id, the mirror of it.
|
|
7
|
+
import { isSilence, isUnreached } from '../being/silence.ts';
|
|
8
|
+
import { digest } from '../being/digest.ts';
|
|
9
|
+
import { OWNER } from '../being/types.ts';
|
|
10
|
+
import type { Asker, Invitation, Json, JsonObject, Wanted } from '../being/types.ts';
|
|
11
|
+
|
|
12
|
+
export type OwnerSide = {
|
|
13
|
+
pk: string;
|
|
14
|
+
doors: Map<
|
|
15
|
+
string,
|
|
16
|
+
{
|
|
17
|
+
key: string;
|
|
18
|
+
being: { answer: (...a: never[]) => unknown };
|
|
19
|
+
cells: { class?: string; occupants: Record<string, unknown>; standings: Record<string, unknown> };
|
|
20
|
+
stance: { occupants: { invite(id: string): Promise<Invitation | null>; remove(id: string): void }; standings: { knock(inv: Invitation, m?: string, a?: JsonObject, w?: Wanted): Promise<unknown>; take(id: string, inv: Invitation): Promise<string | null>; remove(id: string): void } };
|
|
21
|
+
}
|
|
22
|
+
>;
|
|
23
|
+
publicKey(): string | null;
|
|
24
|
+
instantiate(key: string, className: string, isPublic?: boolean): { key: string } | null;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export const OWNER_ASKS = ['boot', 'invite', 'knock', 'remove'] as const;
|
|
28
|
+
|
|
29
|
+
export async function ownerAnswer(w: OwnerSide, asker: Asker, method: string | undefined, args: JsonObject): Promise<Json> {
|
|
30
|
+
// Every name the owner gives is a word. Coerced instead of checked, an
|
|
31
|
+
// object passed where a name belongs becomes the string "[object Object]",
|
|
32
|
+
// and two owners who each got the shape wrong would be handed one being.
|
|
33
|
+
const word = (v: unknown): string | null => (typeof v === 'string' ? v : null);
|
|
34
|
+
|
|
35
|
+
if (method === undefined) return describe(w);
|
|
36
|
+
if (method === 'boot') {
|
|
37
|
+
// A ward may have one public being, and no more. Marking a second would
|
|
38
|
+
// leave the first holding every relation she had, reachable by nobody at
|
|
39
|
+
// the bare pk, and told by nobody that she had been replaced.
|
|
40
|
+
if (args.public === true && w.publicKey() !== null) return { error: 'a ward has one public being' };
|
|
41
|
+
const key = word(args.key),
|
|
42
|
+
className = word(args.class);
|
|
43
|
+
if (key === null || className === null) return { error: 'no such class, or key taken' };
|
|
44
|
+
const door = w.instantiate(key, className, args.public === true);
|
|
45
|
+
return door ? { booted: door.key } : { error: 'no such class, or key taken' };
|
|
46
|
+
}
|
|
47
|
+
if (method === 'invite') {
|
|
48
|
+
// On the ward itself, only the root may invite: an occupant of the ward
|
|
49
|
+
// is an owner, and ownership is minted by the ask pointer alone. An owner
|
|
50
|
+
// at the door asking to invite on the ward is refused, so that a carried
|
|
51
|
+
// key can pilot but never hand piloting on.
|
|
52
|
+
const being = word(args.being),
|
|
53
|
+
id = word(args.id);
|
|
54
|
+
if (being === null || id === null) return { error: 'no such being' };
|
|
55
|
+
if (being === w.pk && asker.id !== OWNER) return { error: 'no such being' };
|
|
56
|
+
const door = w.doors.get(being);
|
|
57
|
+
return door ? await door.stance.occupants.invite(id) : { error: 'no such being' };
|
|
58
|
+
}
|
|
59
|
+
if (method === 'knock') {
|
|
60
|
+
const b = args.being as string | { boot: unknown; key: unknown };
|
|
61
|
+
let door = typeof b === 'string' ? w.doors.get(b) : undefined;
|
|
62
|
+
if (!door && b && typeof b === 'object') {
|
|
63
|
+
// new or existing: the owner names a being of theirs, and a key already
|
|
64
|
+
// booted is a being of theirs, not a class that failed to instantiate.
|
|
65
|
+
const key = word(b.key),
|
|
66
|
+
className = word(b.boot);
|
|
67
|
+
if (key === null || className === null) return { error: 'no such being' };
|
|
68
|
+
const made = w.instantiate(key, className);
|
|
69
|
+
door = made ? w.doors.get(made.key) : w.doors.get(key);
|
|
70
|
+
}
|
|
71
|
+
const id = word(args.id);
|
|
72
|
+
// Never for the ward itself, for the reason invite refuses it: the ward
|
|
73
|
+
// is a being to nobody outside, and holds no relation of its own. A
|
|
74
|
+
// standing written into its cells would be the owner's reach dressed as
|
|
75
|
+
// a being's, and no ask of the ward's could ever use it.
|
|
76
|
+
if (!door || id === null || door.key === w.pk) return { error: 'no such being' };
|
|
77
|
+
const inv = args.invitation as Invitation;
|
|
78
|
+
// The owner is a caller like any other and may say what this knock may
|
|
79
|
+
// spend. Saying nothing is the ward's default, as it is for a being.
|
|
80
|
+
const out = await door.stance.standings.knock(inv, args.method as string | undefined, (args.args as JsonObject) ?? {}, args.wanted as Wanted | undefined);
|
|
81
|
+
if (isSilence(out) || isUnreached(out)) return { error: isUnreached(out) ? 'unreached' : 'silence' };
|
|
82
|
+
return { taken: await door.stance.standings.take(id, inv), answer: out as Json };
|
|
83
|
+
}
|
|
84
|
+
if (method === 'remove') {
|
|
85
|
+
// The mirror of knock: a relation out of a being, by id, occupant or
|
|
86
|
+
// standing, since the two share one namespace. On the ward itself the
|
|
87
|
+
// id is an owner, and only the root may unseat one, for the reason only
|
|
88
|
+
// the root may invite one: ownership moves by the root alone.
|
|
89
|
+
const being = word(args.being),
|
|
90
|
+
id = word(args.id);
|
|
91
|
+
if (being === null || id === null) return { error: 'no such being' };
|
|
92
|
+
if (being === w.pk && asker.id !== OWNER) return { error: 'no such being' };
|
|
93
|
+
const door = w.doors.get(being);
|
|
94
|
+
if (!door) return { error: 'no such being' };
|
|
95
|
+
if (Object.hasOwn(door.cells.occupants, id)) door.stance.occupants.remove(id);
|
|
96
|
+
else if (Object.hasOwn(door.cells.standings, id)) door.stance.standings.remove(id);
|
|
97
|
+
else return { error: 'nothing to remove' };
|
|
98
|
+
return { removed: id };
|
|
99
|
+
}
|
|
100
|
+
return { error: 'unknown ask' };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// The ward's describe for its owner: its beings, their classes, a digest each,
|
|
104
|
+
// as she would describe herself to the owner.
|
|
105
|
+
async function describe(w: OwnerSide): Promise<Json> {
|
|
106
|
+
const beings: Record<string, Json> = {};
|
|
107
|
+
for (const [key, door] of w.doors) {
|
|
108
|
+
if (key === w.pk) continue;
|
|
109
|
+
let d: string | null = null;
|
|
110
|
+
try {
|
|
111
|
+
const bp = await (door.being.answer as (a: unknown) => unknown)({ id: OWNER });
|
|
112
|
+
d = isSilence(bp) || isUnreached(bp) ? null : await digest(bp as Json);
|
|
113
|
+
} catch {
|
|
114
|
+
// she threw where she was asked. there is no blueprint, and d stays null.
|
|
115
|
+
}
|
|
116
|
+
beings[key] = { class: door.cells.class ?? null, public: w.publicKey() === key, digest: d };
|
|
117
|
+
}
|
|
118
|
+
return {
|
|
119
|
+
asks: OWNER_ASKS.map((name) => ({ name, input: { type: 'object' } })),
|
|
120
|
+
notes: { pk: w.pk, beings },
|
|
121
|
+
};
|
|
122
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// The partition. The ward's cells, all the way down. Values only, so the
|
|
3
|
+
// harbor may persist it as it likes. Everything durable a ward has is here,
|
|
4
|
+
// every secret included. Nothing here is ever in a being's cells.
|
|
5
|
+
import type { Cells } from '../being/types.ts';
|
|
6
|
+
|
|
7
|
+
// The shape of everything below. A ward stamps what it writes and refuses
|
|
8
|
+
// what it cannot read: the partition outlives the ward that wrote it, and a
|
|
9
|
+
// ward that guessed at a shape it did not know would not fail, it would answer
|
|
10
|
+
// wrongly on relations it had silently misread.
|
|
11
|
+
//
|
|
12
|
+
// One value, and it does not move until 1.0.0. Before a release there is
|
|
13
|
+
// nobody holding a partition of another shape to tell apart: if the types
|
|
14
|
+
// below change, the partition is thrown away, not migrated to and not counted.
|
|
15
|
+
// A number that climbed with every change would be a changelog, and the only
|
|
16
|
+
// question asked here is answered by equality — is this a partition this ward
|
|
17
|
+
// can read. At 1.0.0 this changes once, and from then on it means something.
|
|
18
|
+
export const VERSION = 'pre-1.0.0';
|
|
19
|
+
|
|
20
|
+
// Every version this ward can read, newest last. Today it is one, and the
|
|
21
|
+
// seam below is what makes it able to be more than one later: a partition
|
|
22
|
+
// written under an older readable version is carried forward by `upgrade`
|
|
23
|
+
// before the ward touches it. The seam is here before 1.0.0 because after
|
|
24
|
+
// 1.0.0 there is nowhere to put it. A partition holds every secret and every
|
|
25
|
+
// relation a world has, so the first shape change after the freeze cannot be
|
|
26
|
+
// answered by throwing it away, and a ward that meets a shape it has no seam
|
|
27
|
+
// for refuses to boot with everything still inside it.
|
|
28
|
+
export const READS: readonly string[] = [VERSION];
|
|
29
|
+
|
|
30
|
+
// Carry a partition written under an older readable version forward to this
|
|
31
|
+
// one. There is nothing to carry today, because READS has one member. Each
|
|
32
|
+
// step added here is from one member of READS to the next.
|
|
33
|
+
function upgrade(p: Partition): Partition {
|
|
34
|
+
return p;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// One occupant, as the door knows her: by the heir pk her invitation carried.
|
|
38
|
+
// `current` is the pk that may speak now. `announced` is the pk `current`
|
|
39
|
+
// vouched for on its last call, and may speak next. Both may speak until one
|
|
40
|
+
// does, so a lost reply after a rotation strands nobody. `fresh` is true
|
|
41
|
+
// until the knock: the heir is a key the inviter handed out, and it dies the
|
|
42
|
+
// moment it is used.
|
|
43
|
+
export type Heir = {
|
|
44
|
+
being: string; // the key of the being who minted the id
|
|
45
|
+
id: string; // her id for the occupant
|
|
46
|
+
current: string; // pk hex
|
|
47
|
+
announced: string | null; // pk hex
|
|
48
|
+
fresh: boolean;
|
|
49
|
+
// Once-only delivery. The highest number honoured, and which numbers below
|
|
50
|
+
// it are already spent. Durable, because a door that forgets what it
|
|
51
|
+
// honoured honours it again after a restart.
|
|
52
|
+
mark: number;
|
|
53
|
+
spent: number[];
|
|
54
|
+
// Reserved, and nothing writes it. A door refuses a number at or below the
|
|
55
|
+
// span, which is right for an accident and for an interception. A kit that
|
|
56
|
+
// grows retry may want the other answer, giving back what it already said
|
|
57
|
+
// rather than one silence, and that answer has to live somewhere durable.
|
|
58
|
+
// The slot is cut now because a Heir that grows a field after 1.0.0 is a
|
|
59
|
+
// partition shape change, and this is the cheapest thing in this file.
|
|
60
|
+
last?: { seq: number; reply: string };
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
// One standing, as the caller's ward keeps it: where to send, how to seal,
|
|
64
|
+
// which key to sign with, and the key it has announced for next time.
|
|
65
|
+
export type StandingKeys = {
|
|
66
|
+
ward: string; // the far ward's pk, 128 hex: signing pk then padlock
|
|
67
|
+
heir: string | null; // the heir pk this standing was born on. null: the far ward's public being
|
|
68
|
+
current: string; // seed hex of the signing key that speaks now
|
|
69
|
+
next: string | null; // seed hex of the key announced on the last call, if any
|
|
70
|
+
seq: number; // the last number she spoke under. the next call is this plus one
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
export type Bind = {
|
|
74
|
+
standings: Record<string, StandingKeys>; // id -> keys
|
|
75
|
+
occupants: Record<string, string>; // id -> heir pk
|
|
76
|
+
knocks: Record<string, { current: string; next: string | null; spoke: boolean; sent: boolean; seq: number }>; // <ward pk>:<heir pk>, or public:<ward pk> -> her keys for a knock, before take. spoke: bytes came back once. sent: bytes went out once, answered or not
|
|
77
|
+
answered: Record<string, true>; // <ward pk>:<heir pk>, or public:<ward pk>: knocked and answered
|
|
78
|
+
minted: string[]; // the last eight pks her side minted. inner. a test reads it
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
export type Partition = {
|
|
82
|
+
version: string;
|
|
83
|
+
beings: Record<string, Cells & { class?: string }>; // key -> her cells
|
|
84
|
+
bind: Record<string, Bind>; // key -> her bind table
|
|
85
|
+
heirs: Record<string, Heir>; // heir pk -> occupant, across every being of the ward
|
|
86
|
+
public: string | null; // the one public being's key
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
// The one place a ward reads state that another ward wrote. A fresh
|
|
90
|
+
// memory becomes a partition of this version; anything already written must
|
|
91
|
+
// say it is this version, and a ward that cannot read it does not boot. Birth is
|
|
92
|
+
// where a ward is allowed to be loud: silence is the door's word, for asks
|
|
93
|
+
// that were made, and no ask has been made yet.
|
|
94
|
+
export function open(memory: Record<string, unknown>): Partition {
|
|
95
|
+
const p = memory as Partition;
|
|
96
|
+
if (Object.keys(memory).length > 0 && p.version !== VERSION) {
|
|
97
|
+
if (!READS.includes(p.version)) throw new Error(`partition is version ${p.version}, this ward reads ${READS.join(', ')}`);
|
|
98
|
+
upgrade(p).version = VERSION;
|
|
99
|
+
}
|
|
100
|
+
p.version ??= VERSION;
|
|
101
|
+
p.beings ??= {};
|
|
102
|
+
p.bind ??= {};
|
|
103
|
+
p.heirs ??= {};
|
|
104
|
+
p.public ??= null;
|
|
105
|
+
return p;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// One record, reached by a key a being or an owner chose. A bare lookup finds
|
|
109
|
+
// every name on Object's prototype, so `__proto__` answers with the prototype
|
|
110
|
+
// itself: read, it is there where nothing was put; written, it reaches every
|
|
111
|
+
// object alive in the runtime. Own keys only, both ways, and a write that
|
|
112
|
+
// names one of those lands on the record where it belongs.
|
|
113
|
+
export const at = <T>(rec: Record<string, T>, key: string): T | undefined => (Object.hasOwn(rec, key) ? rec[key] : undefined);
|
|
114
|
+
export const put = <T>(rec: Record<string, T>, key: string, value: T): T => {
|
|
115
|
+
Object.defineProperty(rec, key, { value, writable: true, enumerable: true, configurable: true });
|
|
116
|
+
return value;
|
|
117
|
+
};
|
|
118
|
+
export const drop = (rec: Record<string, unknown>, key: string): void => {
|
|
119
|
+
if (Object.hasOwn(rec, key)) delete rec[key];
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
export const emptyCells = (): Cells => ({ standings: {}, occupants: {} });
|
|
123
|
+
export const emptyBind = (): Bind => ({ standings: {}, occupants: {}, knocks: {}, answered: {}, minted: [] });
|
package/src/ward/seal.ts
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// The seal. What a ward does to bytes on the way out and on the way in.
|
|
3
|
+
//
|
|
4
|
+
// A being's key is one 32-byte seed: an Ed25519 pair. It signs, it never
|
|
5
|
+
// seals. The ward's key is one seed too, giving both an Ed25519 pair to sign
|
|
6
|
+
// replies and an X25519 padlock every ask is sealed to. Beings never own a
|
|
7
|
+
// padlock: asks are sealed to the ward, replies to the ephemeral key of the
|
|
8
|
+
// ask they answer.
|
|
9
|
+
//
|
|
10
|
+
// ask on the wire box = eph X pk || AESGCM( payload || signature ) sealed to the ward padlock
|
|
11
|
+
// payload = JSON { to, by, next, seq, time, hops?, method?, args } signed by `by`
|
|
12
|
+
//
|
|
13
|
+
// Nothing rides outside the box. `to` names the heir this ask is for, and it
|
|
14
|
+
// is inside with everything else: a heir is the one name in a relation that
|
|
15
|
+
// never rotates, so on the outside it would be a handle an intermediary could
|
|
16
|
+
// hold forever, and the same handle anyone who ever saw the invitation
|
|
17
|
+
// already has. What a harbor needs to route is the ward pk, which it carries
|
|
18
|
+
// itself. The door opens every ask with its own padlock and reads `to` after,
|
|
19
|
+
// so nothing was ever gained by having it in the clear.
|
|
20
|
+
// reply on the wire box = eph X pk || AESGCM( reply || signature ) sealed to the ask's eph pk
|
|
21
|
+
// reply = JSON { object, seen } | { silence: true } signed by the ward key
|
|
22
|
+
import type { Json, JsonObject } from '../being/types.ts';
|
|
23
|
+
import { KEY, SIGNATURE, box, concat, hex, sha256, sign, signingPair, sealingPair, unbox, unhex, verify } from './arithmetic.ts';
|
|
24
|
+
|
|
25
|
+
const utf8 = new TextEncoder();
|
|
26
|
+
const text = new TextDecoder();
|
|
27
|
+
|
|
28
|
+
export type WardKey = { sign: Uint8Array; padlock: Uint8Array; signPk: Uint8Array; padlockPk: Uint8Array; pk: string };
|
|
29
|
+
|
|
30
|
+
// The ward's key from its seed. Its pk on the wire is the signing pk then the padlock, 128 hex.
|
|
31
|
+
export async function wardKey(seed: string | Uint8Array): Promise<WardKey> {
|
|
32
|
+
const raw = typeof seed === 'string' ? utf8.encode(seed) : seed;
|
|
33
|
+
const seed32 = raw.length === KEY ? raw : await sha256(raw);
|
|
34
|
+
const s = await signingPair(seed32);
|
|
35
|
+
const p = await sealingPair(seed32);
|
|
36
|
+
return { sign: s.secret, padlock: p.secret, signPk: s.pk, padlockPk: p.pk, pk: hex(s.pk) + hex(p.pk) };
|
|
37
|
+
}
|
|
38
|
+
export const wardSignPk = (pk: string): Uint8Array => unhex(pk.slice(0, KEY * 2));
|
|
39
|
+
export const wardPadlock = (pk: string): Uint8Array => unhex(pk.slice(KEY * 2));
|
|
40
|
+
export const isWardPk = (pk: unknown): pk is string => typeof pk === 'string' && /^[0-9a-f]{128}$/.test(pk);
|
|
41
|
+
|
|
42
|
+
// A being's relation key: a seed, and the pk it signs as.
|
|
43
|
+
export async function beingKey(seed: Uint8Array): Promise<{ seed: string; pk: string }> {
|
|
44
|
+
return { seed: hex(seed), pk: hex((await signingPair(seed)).pk) };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// time is the allowance: what this ask may still spend. It rides inside the
|
|
48
|
+
// signed body, so a budget caught on the road cannot be widened by whoever
|
|
49
|
+
// caught it.
|
|
50
|
+
// hops is reserved and nothing sets it. An ask carries no count of doors
|
|
51
|
+
// today, so a chain of relays is bounded by time alone. A relay chain that
|
|
52
|
+
// ever wants a count needs every door on it to refuse at zero, and a door
|
|
53
|
+
// written after the count was invented cannot make the doors before it
|
|
54
|
+
// enforce anything. So the field is named and refused here now, while it
|
|
55
|
+
// costs two lines: a door of this version already counts as a correct
|
|
56
|
+
// participant in a chain nobody has designed yet.
|
|
57
|
+
export type AskPayload = { by: string; next: string | null; seq: number; time: number; hops?: number; method?: string; args: JsonObject };
|
|
58
|
+
// What is actually sealed: the payload with the heir it is for, or null for
|
|
59
|
+
// the public being. `to` is signed with the rest, so it cannot be moved.
|
|
60
|
+
type Sealed = AskPayload & { to: string | null };
|
|
61
|
+
export type ReplyPayload = { object: Json; seen: string | null } | { silence: true };
|
|
62
|
+
|
|
63
|
+
// Seal an ask. Returns the bytes and the ephemeral secret the reply will be sealed to.
|
|
64
|
+
export async function sealAsk(to: string | null, payload: AskPayload, signer: Uint8Array, padlock: Uint8Array, seed: Uint8Array): Promise<{ bytes: Uint8Array; ephemeral: Uint8Array }> {
|
|
65
|
+
const body = utf8.encode(JSON.stringify({ to, ...payload } satisfies Sealed));
|
|
66
|
+
const inside = concat([body, await sign(body, signer)]);
|
|
67
|
+
const { bytes, ephemeral } = await box(inside, padlock, seed);
|
|
68
|
+
return { bytes, ephemeral: ephemeral.secret };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Open an ask at the door. Null when it does not open or does not parse.
|
|
72
|
+
// Verification is the door's own step, because only the door knows which key may speak.
|
|
73
|
+
export async function openAsk(bytes: Uint8Array, padlockSecret: Uint8Array): Promise<{ to: string | null; payload: AskPayload; body: Uint8Array; signature: Uint8Array; ephemeralPk: Uint8Array } | null> {
|
|
74
|
+
try {
|
|
75
|
+
if (!(bytes instanceof Uint8Array) || bytes.length < 1) return null;
|
|
76
|
+
const inside = await unbox(bytes, padlockSecret);
|
|
77
|
+
if (inside.length <= SIGNATURE) return null;
|
|
78
|
+
const body = inside.subarray(0, inside.length - SIGNATURE);
|
|
79
|
+
const signature = inside.subarray(inside.length - SIGNATURE);
|
|
80
|
+
const payload = JSON.parse(text.decode(body)) as Sealed;
|
|
81
|
+
// The heir, or null for the public being. Absent is not null: a payload
|
|
82
|
+
// that never named one is malformed, like any other missing field.
|
|
83
|
+
const to = payload.to;
|
|
84
|
+
if (to !== null && !(typeof to === 'string' && /^[0-9a-f]{64}$/.test(to))) return null;
|
|
85
|
+
if (typeof payload.by !== 'string' || !/^[0-9a-f]{64}$/.test(payload.by)) return null;
|
|
86
|
+
if (payload.next !== null && !(typeof payload.next === 'string' && /^[0-9a-f]{64}$/.test(payload.next))) return null;
|
|
87
|
+
if (payload.method !== undefined && typeof payload.method !== 'string') return null; // a name, or the empty ask. never a number, never an object.
|
|
88
|
+
if (payload.args !== undefined && (payload.args === null || typeof payload.args !== 'object' || Array.isArray(payload.args))) return null; // args are one object, or absent. a string or a list is not an ask.
|
|
89
|
+
if (!Number.isSafeInteger(payload.seq) || payload.seq < 1) return null; // her count for this relation. one and up, and a whole number.
|
|
90
|
+
// The allowance. A whole number, and one that has already run out is not an
|
|
91
|
+
// ask this door will open: it is refused as the bytes it is, above.
|
|
92
|
+
if (!Number.isSafeInteger(payload.time) || payload.time <= 0) return null;
|
|
93
|
+
// The hop count, if this ask carries one: a whole number, never below
|
|
94
|
+
// zero. Absent is the ordinary ask and always will be.
|
|
95
|
+
if (payload.hops !== undefined && (!Number.isSafeInteger(payload.hops) || payload.hops < 0)) return null;
|
|
96
|
+
return { to, payload, body, signature, ephemeralPk: bytes.subarray(0, KEY) };
|
|
97
|
+
} catch {
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
export const verifyAsk = (a: { body: Uint8Array; signature: Uint8Array }, pk: string): Promise<boolean> => verify(a.body, a.signature, unhex(pk));
|
|
102
|
+
|
|
103
|
+
// Seal a reply to the ephemeral pk of the ask it answers, signed by the ward.
|
|
104
|
+
export async function sealReply(reply: ReplyPayload, ephemeralPk: Uint8Array, wardSign: Uint8Array, seed: Uint8Array): Promise<Uint8Array> {
|
|
105
|
+
const body = utf8.encode(JSON.stringify(reply));
|
|
106
|
+
return (await box(concat([body, await sign(body, wardSign)]), ephemeralPk, seed)).bytes;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Open a reply with the ephemeral secret kept from the ask, and verify it
|
|
110
|
+
// came from the ward it was sent to. Null is silence: it did not open, it
|
|
111
|
+
// lied, or it is not a reply. Every ward on the wire is somebody else's code,
|
|
112
|
+
// so the shape is checked here and nowhere later: a reply is `{ silence }`
|
|
113
|
+
// or `{ object, seen }` with an object present and seen a digest or null.
|
|
114
|
+
// Anything else is bytes that said nothing, and her ward never hands her a
|
|
115
|
+
// throw or an absent value for what a stranger wrote.
|
|
116
|
+
export async function openReply(bytes: unknown, ephemeralSecret: Uint8Array, signPk: Uint8Array): Promise<ReplyPayload | null> {
|
|
117
|
+
try {
|
|
118
|
+
if (!(bytes instanceof Uint8Array)) return null;
|
|
119
|
+
const inside = await unbox(bytes, ephemeralSecret);
|
|
120
|
+
if (inside.length <= SIGNATURE) return null;
|
|
121
|
+
const body = inside.subarray(0, inside.length - SIGNATURE);
|
|
122
|
+
if (!(await verify(body, inside.subarray(inside.length - SIGNATURE), signPk))) return null;
|
|
123
|
+
const reply = JSON.parse(text.decode(body)) as unknown;
|
|
124
|
+
if (reply === null || typeof reply !== 'object' || Array.isArray(reply)) return null;
|
|
125
|
+
const r = reply as Record<string, unknown>;
|
|
126
|
+
if (r.silence === true) return { silence: true };
|
|
127
|
+
if (!Object.hasOwn(r, 'object') || r.object === undefined) return null;
|
|
128
|
+
if (r.seen !== null && !(typeof r.seen === 'string' && /^[0-9a-f]{64}$/.test(r.seen))) return null;
|
|
129
|
+
return { object: r.object as Json, seen: r.seen };
|
|
130
|
+
} catch {
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// The one stance builder. Used for every being the ward boots. Nothing
|
|
3
|
+
// outer is in what she holds: ids in, values out, keys in the bind table.
|
|
4
|
+
import { silence, isSilence, isUnreached } from '../being/silence.ts';
|
|
5
|
+
import { digest } from '../being/digest.ts';
|
|
6
|
+
import { RESERVED_IDS } from '../being/types.ts';
|
|
7
|
+
import type { Answer, Cells, Invitation, JsonObject, Silence, Stance, Standing, Standings, Unreached, Wanted } from '../being/types.ts';
|
|
8
|
+
import { at, put, drop, type Bind, type StandingKeys } from './partition.ts';
|
|
9
|
+
import { allow, within, LATE } from './allowance.ts';
|
|
10
|
+
import { isWardPk, type ReplyPayload } from './seal.ts';
|
|
11
|
+
|
|
12
|
+
// What the stance needs from its ward, and no more.
|
|
13
|
+
export type Inside = {
|
|
14
|
+
pk: string;
|
|
15
|
+
mintKey(bind: Bind): Promise<{ seed: string; pk: string }>; // a fresh signing key, recorded as minted
|
|
16
|
+
openHeir(heir: string, being: string, id: string): void;
|
|
17
|
+
closeHeir(heir: string): void;
|
|
18
|
+
// one send for every destination. the far door's reply, silence, or unreached.
|
|
19
|
+
// signs as keys.current, announces keys.next (minting one if asked), and rotates keys on an answer.
|
|
20
|
+
send(bind: Bind, keys: StandingKeys, announce: boolean, method: string | undefined, args: JsonObject, wanted: Wanted | undefined): Promise<ReplyPayload | Silence | Unreached>;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
// An invitation as it travels: the far ward's pk, the heir pk, the heir's secret seed. Or just the ward, for its public being.
|
|
24
|
+
export type SealedInvitation = { ward: string; heir?: string; secret?: string };
|
|
25
|
+
|
|
26
|
+
// The three calls on `standings` share the object with the ids she takes, so
|
|
27
|
+
// a standing named after one of them would be unreachable: `standings.knock`
|
|
28
|
+
// is the call, whatever record sits under that name. One namespace means one
|
|
29
|
+
// list: these are refused at invite and at take, beside the ward's own words.
|
|
30
|
+
const CALLS = ['knock', 'take', 'remove'];
|
|
31
|
+
const reserved = (id: string): boolean => RESERVED_IDS.includes(id) || CALLS.includes(id);
|
|
32
|
+
|
|
33
|
+
export function buildStance(inside: Inside, key: string, cells: Cells, bind: Bind): Stance {
|
|
34
|
+
// The name of one relation, as her side files it. A relation is a ward and
|
|
35
|
+
// a heir, never a heir alone: the heir pk is outer, it rides in the clear on
|
|
36
|
+
// every lid, and anyone who reads one can quote it back inside an invitation
|
|
37
|
+
// naming a ward of their own. Filed under the heir alone, that invitation
|
|
38
|
+
// would share her knock record with the relation the heir really belongs to:
|
|
39
|
+
// it would sign with that relation's key, and an answer to it would set the
|
|
40
|
+
// answered flag the other relation is taken on. Both halves, always.
|
|
41
|
+
const nameOf = (inv: SealedInvitation) => (inv.heir === undefined ? `public:${inv.ward}` : `${inv.ward}:${inv.heir}`);
|
|
42
|
+
const valid = (inv: unknown): inv is SealedInvitation => {
|
|
43
|
+
const i = inv as SealedInvitation;
|
|
44
|
+
if (!i || typeof i !== 'object' || !isWardPk(i.ward)) return false;
|
|
45
|
+
if (i.heir === undefined) return i.secret === undefined;
|
|
46
|
+
return typeof i.heir === 'string' && typeof i.secret === 'string' && /^[0-9a-f]{64}$/.test(i.heir) && /^[0-9a-f]{64}$/.test(i.secret);
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
// One relation, one ask at a time. The rotation is a conversation: a send
|
|
50
|
+
// reads the key that speaks now, announces the next, and moves to it once
|
|
51
|
+
// the far door has answered. Two sends interleaving on one relation read
|
|
52
|
+
// each other's half-written keys and the two sides diverge, which no later
|
|
53
|
+
// ask can repair. So each relation has a lane, and a send waits for the one
|
|
54
|
+
// before it. Beings may still ask concurrently: the lanes are per relation,
|
|
55
|
+
// and a slow relation never holds up another.
|
|
56
|
+
const lanes = new Map<string, Promise<unknown>>();
|
|
57
|
+
const lane = <T>(name: string, run: () => Promise<T>): Promise<T> => {
|
|
58
|
+
const mine = (lanes.get(name) ?? Promise.resolve()).then(run, run);
|
|
59
|
+
const quiet = mine.then(
|
|
60
|
+
() => {},
|
|
61
|
+
() => {},
|
|
62
|
+
);
|
|
63
|
+
lanes.set(name, quiet);
|
|
64
|
+
void quiet.then(() => {
|
|
65
|
+
if (lanes.get(name) === quiet) lanes.delete(name); // the lane is idle. nothing to remember.
|
|
66
|
+
});
|
|
67
|
+
return mine;
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
// Every ask a being makes, bounded. The bound is outside the lane on
|
|
71
|
+
// purpose: an ask that waits its turn behind a relation that comes back
|
|
72
|
+
// round never reaches a send, and a bound that only watched the wire would
|
|
73
|
+
// never see it hang. A bound that ran out is silence, never unreached:
|
|
74
|
+
// unreached promises nothing was delivered and is safe to ask again, and a
|
|
75
|
+
// wait that gave up knows no such thing.
|
|
76
|
+
//
|
|
77
|
+
// The bell rings before the work is reached, too: an ask that was still
|
|
78
|
+
// waiting its turn when the wait ran out is never sent. Sent then, it would
|
|
79
|
+
// be a fresh ask nobody is waiting on, the far being would do the work, and
|
|
80
|
+
// what came back would be read into her cells behind a silence she was
|
|
81
|
+
// already handed. So the work is given the bell, and reads it at the lane.
|
|
82
|
+
const bounded = async (wanted: Wanted | undefined, work: (live: () => boolean) => Promise<Answer>): Promise<Answer> => {
|
|
83
|
+
let late = false;
|
|
84
|
+
const out = await within(allow(wanted).time, work(() => !late), () => {
|
|
85
|
+
late = true;
|
|
86
|
+
});
|
|
87
|
+
return out === LATE ? silence : out;
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
const send = async (keys: StandingKeys, method: string | undefined, args: JsonObject, wanted: Wanted | undefined, rec?: Cells['standings'][string]): Promise<Answer> => {
|
|
91
|
+
const r = await inside.send(bind, keys, true, method, args, wanted);
|
|
92
|
+
if (isSilence(r) || isUnreached(r)) return r;
|
|
93
|
+
if ('silence' in r) return silence;
|
|
94
|
+
if (rec && method !== undefined && r.seen !== null) rec.seen = r.seen; // the digest rode along
|
|
95
|
+
return r.object;
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
const knocking = async (inv: SealedInvitation, method: string | undefined, args: JsonObject, wanted: Wanted | undefined): Promise<Answer> => {
|
|
99
|
+
const name = nameOf(inv);
|
|
100
|
+
// her own key for this relation, minted once: rotation two. reused if she knocks again.
|
|
101
|
+
const k = (bind.knocks[name] ??= { current: (await inside.mintKey(bind)).seed, next: null, spoke: false, sent: false, seq: 0 });
|
|
102
|
+
// the first knock on a heir speaks as the heir, the key the inviter handed out, and announces her own.
|
|
103
|
+
// once bytes have come back, the heir is spent whatever they said, and her own key speaks.
|
|
104
|
+
// She sent once as the heir and nothing came back. Either the door heard,
|
|
105
|
+
// spent the heir and rotated to the key she announced, or nothing arrived
|
|
106
|
+
// and the heir still stands. Only the far door knows, and it will not say.
|
|
107
|
+
//
|
|
108
|
+
// So she asks, and the asking tells her. Under her own key: if the door
|
|
109
|
+
// heard, that key is the one it admits, and she is answered. If it did
|
|
110
|
+
// not, the key means nothing there, the ask is refused, and a refusal at
|
|
111
|
+
// that door writes nothing — the heir is untouched and still speaks. Then
|
|
112
|
+
// she knocks as the heir, as she would have. One extra round trip in the
|
|
113
|
+
// one case where a reply was lost, and the relation is not stranded.
|
|
114
|
+
//
|
|
115
|
+
// No stranger gains anything by it: whoever holds the invitation could
|
|
116
|
+
// always knock as the heir, and her own key is admitted only where the
|
|
117
|
+
// door already bound it to her.
|
|
118
|
+
if (inv.heir !== undefined && !k.spoke && k.sent) {
|
|
119
|
+
const own: StandingKeys = { ward: inv.ward, heir: inv.heir, current: k.current, next: k.next, seq: k.seq };
|
|
120
|
+
const r = await inside.send(bind, own, true, method, args, wanted);
|
|
121
|
+
k.seq = own.seq;
|
|
122
|
+
k.current = own.current;
|
|
123
|
+
k.next = own.next;
|
|
124
|
+
if (isUnreached(r)) return r; // still nothing. she knows no more than before.
|
|
125
|
+
if (!isSilence(r) && !('silence' in r)) {
|
|
126
|
+
k.spoke = true; // it was heard the first time, and this is the answer
|
|
127
|
+
bind.answered[name] = true;
|
|
128
|
+
return r.object;
|
|
129
|
+
}
|
|
130
|
+
// Refused. The door never heard her, so the heir is still hers to spend.
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const first = inv.heir !== undefined && !k.spoke;
|
|
134
|
+
const keys: StandingKeys = first ? { ward: inv.ward, heir: inv.heir!, current: inv.secret!, next: k.current, seq: k.seq } : { ward: inv.ward, heir: inv.heir ?? null, current: k.current, next: k.next, seq: k.seq };
|
|
135
|
+
const r = await inside.send(bind, keys, true, method, args, wanted);
|
|
136
|
+
k.sent = true; // bytes went out. whether they arrived is the far door's to know.
|
|
137
|
+
// The number she spoke under, and the key she announced, kept whoever
|
|
138
|
+
// answered and kept when nobody did. A reply lost on the way back is a
|
|
139
|
+
// door that has already honoured the number: she must not offer it twice,
|
|
140
|
+
// or a road that drops one reply silences the relation for good.
|
|
141
|
+
k.seq = keys.seq;
|
|
142
|
+
if (!first) {
|
|
143
|
+
k.current = keys.current;
|
|
144
|
+
k.next = keys.next;
|
|
145
|
+
}
|
|
146
|
+
if (isUnreached(r)) return r; // nothing came back. she does not know whether it was heard.
|
|
147
|
+
k.spoke = true;
|
|
148
|
+
if (isSilence(r) || 'silence' in r) return silence;
|
|
149
|
+
bind.answered[name] = true;
|
|
150
|
+
return r.object;
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
// The id she took this relation under, if she took it. One relation is one
|
|
154
|
+
// ward and one heir whichever door it is reached by, so the standing and the
|
|
155
|
+
// invitation answer to the same name.
|
|
156
|
+
const keyName = (k: StandingKeys) => (k.heir === null ? `public:${k.ward}` : `${k.ward}:${k.heir}`);
|
|
157
|
+
const takenAs = (name: string): string | undefined => Object.keys(bind.standings).find((id) => keyName(bind.standings[id]) === name);
|
|
158
|
+
|
|
159
|
+
const calls = {
|
|
160
|
+
knock: async (inv: Invitation, method?: string, args: JsonObject = {}, wanted?: Wanted): Promise<Answer> => {
|
|
161
|
+
if (!valid(inv)) return silence;
|
|
162
|
+
// She may knock again, and after take that knock is an ask: the relation
|
|
163
|
+
// has one home, so it answers on the standing's lane, under the
|
|
164
|
+
// standing's keys and its count. A second lane here would speak for the
|
|
165
|
+
// same relation, and the two would refuse each other.
|
|
166
|
+
const id = takenAs(nameOf(inv));
|
|
167
|
+
if (id !== undefined) return bounded(wanted, (live) => lane(`ask:${id}`, () => (live() ? asking(id, bind.standings[id], cells.standings[id], method, args, wanted) : Promise.resolve(silence))));
|
|
168
|
+
return bounded(wanted, (live) => lane(`knock:${nameOf(inv)}`, () => (live() ? knocking(inv, method, args, wanted) : Promise.resolve(silence))));
|
|
169
|
+
},
|
|
170
|
+
// Take consumes. Until now the relation lived in the knock record, under
|
|
171
|
+
// the ward and the heir; from now it lives in the standing, under her id.
|
|
172
|
+
// It never lives in both: two records seeded from one relation are two
|
|
173
|
+
// lanes speaking for one line of keys and one count, and whichever spoke
|
|
174
|
+
// last holds it while the other is refused. She would read that refusal as
|
|
175
|
+
// the far being falling quiet, which is a thing a sovereign being may do,
|
|
176
|
+
// so the loss would look exactly like an answer she is owed nothing of.
|
|
177
|
+
// One relation, one home, and the invitation is spent: knocking it again
|
|
178
|
+
// is another consumed it, and taking it again births nothing.
|
|
179
|
+
//
|
|
180
|
+
// So it is spoken on the relation's lane, like every other word spoken
|
|
181
|
+
// for it. A take reads the count and the keys a knock writes, and copies
|
|
182
|
+
// them into the standing. Off the lane it reads them between a knock's
|
|
183
|
+
// send and its answer: the standing is born holding a number the far door
|
|
184
|
+
// is about to honour and a key it is about to rotate past, and her first
|
|
185
|
+
// ask on it is refused. The relation she took correctly would be dead
|
|
186
|
+
// because a second knock on the same invitation was careless.
|
|
187
|
+
take: async (id: string, inv: Invitation): Promise<string | null> => {
|
|
188
|
+
if (!valid(inv)) return null;
|
|
189
|
+
const name = nameOf(inv);
|
|
190
|
+
return lane(`knock:${name}`, () => {
|
|
191
|
+
if (!bind.answered[name]) return Promise.resolve(null);
|
|
192
|
+
// Standings and occupants are one namespace — invite and take each
|
|
193
|
+
// refuse a name the other holds — so a word reserved in one is
|
|
194
|
+
// reserved in both.
|
|
195
|
+
if (reserved(id) || at(cells.standings, id) || at(cells.occupants, id)) return Promise.resolve(null);
|
|
196
|
+
const k = bind.knocks[name];
|
|
197
|
+
put(bind.standings, id, { ward: inv.ward, heir: inv.heir ?? null, current: k.current, next: k.next, seq: k.seq }); // the count carries over: the standing goes on where the knock left off
|
|
198
|
+
put(cells.standings, id, { id, digest: null, blueprint: null, seen: null });
|
|
199
|
+
delete bind.knocks[name];
|
|
200
|
+
delete bind.answered[name];
|
|
201
|
+
return Promise.resolve(id);
|
|
202
|
+
});
|
|
203
|
+
},
|
|
204
|
+
remove: (id: string): void => {
|
|
205
|
+
drop(cells.standings, id);
|
|
206
|
+
drop(bind.standings, id);
|
|
207
|
+
},
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
const standingAt = (id: string): Standing => ({
|
|
211
|
+
id,
|
|
212
|
+
// The keys are read now, not when the lane reaches this ask: an ask
|
|
213
|
+
// issued while the standing stood is answered even if she drops it in
|
|
214
|
+
// the next line. Waiting for the lane is not a reason to lose it.
|
|
215
|
+
ask: (method?: string, args: JsonObject = {}, wanted?: Wanted): Promise<Answer> => {
|
|
216
|
+
const keys = at(bind.standings, id);
|
|
217
|
+
if (!keys) return Promise.resolve(silence as Answer); // she dropped it between one line and the next
|
|
218
|
+
const rec = at(cells.standings, id);
|
|
219
|
+
return bounded(wanted, (live) => lane(`ask:${id}`, () => (live() ? asking(id, keys, rec, method, args, wanted) : Promise.resolve(silence))));
|
|
220
|
+
},
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
const asking = async (id: string, keys: StandingKeys, rec: Cells['standings'][string] | undefined, method: string | undefined, args: JsonObject, wanted: Wanted | undefined): Promise<Answer> => {
|
|
224
|
+
const out = await send(keys, method, args, wanted, rec);
|
|
225
|
+
if (method === undefined && !isSilence(out) && !isUnreached(out)) {
|
|
226
|
+
const here = at(cells.standings, id);
|
|
227
|
+
if (here) {
|
|
228
|
+
here.blueprint = out as never;
|
|
229
|
+
here.digest = here.seen = await digest(out);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
return out;
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
const standings = new Proxy(calls, {
|
|
236
|
+
// Own keys only: `in` would also find every name on Object's prototype,
|
|
237
|
+
// and a standing she took as `constructor` or `toString` would answer with
|
|
238
|
+
// Object's, not hers.
|
|
239
|
+
get: (t, id) => (typeof id === 'string' && Object.hasOwn(t, id) ? t[id as keyof typeof t] : typeof id === 'string' && at(cells.standings, id) ? standingAt(id) : undefined),
|
|
240
|
+
}) as unknown as Standings;
|
|
241
|
+
|
|
242
|
+
return {
|
|
243
|
+
cells,
|
|
244
|
+
occupants: {
|
|
245
|
+
// rotation one: the ward mints the occupant's first key and gives the secret away.
|
|
246
|
+
// it keeps the pk beside the id and nothing else. the invitation IS the key.
|
|
247
|
+
invite: async (id: string): Promise<Invitation | null> => {
|
|
248
|
+
if (reserved(id) || at(cells.occupants, id) || at(cells.standings, id)) return null;
|
|
249
|
+
put(cells.occupants, id, { id, notes: {} });
|
|
250
|
+
const k = await inside.mintKey(bind);
|
|
251
|
+
inside.openHeir(k.pk, key, id);
|
|
252
|
+
put(bind.occupants, id, k.pk);
|
|
253
|
+
return { ward: inside.pk, heir: k.pk, secret: k.seed };
|
|
254
|
+
},
|
|
255
|
+
remove: (id: string): void => {
|
|
256
|
+
drop(cells.occupants, id);
|
|
257
|
+
const heir = at(bind.occupants, id);
|
|
258
|
+
drop(bind.occupants, id);
|
|
259
|
+
if (heir) inside.closeHeir(heir); // the heir dies with the id
|
|
260
|
+
},
|
|
261
|
+
},
|
|
262
|
+
standings,
|
|
263
|
+
};
|
|
264
|
+
}
|