@statewalker/httpeers-hub 0.1.3
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 +21 -0
- package/README.md +108 -0
- package/dist/admin.d.ts +35 -0
- package/dist/admin.d.ts.map +1 -0
- package/dist/admin.js +39 -0
- package/dist/browser.d.ts +11 -0
- package/dist/browser.d.ts.map +1 -0
- package/dist/browser.js +42 -0
- package/dist/create-hub.d.ts +90 -0
- package/dist/create-hub.d.ts.map +1 -0
- package/dist/create-hub.js +132 -0
- package/dist/endpoints.d.ts +137 -0
- package/dist/endpoints.d.ts.map +1 -0
- package/dist/endpoints.js +305 -0
- package/dist/hub-state.d.ts +114 -0
- package/dist/hub-state.d.ts.map +1 -0
- package/dist/hub-state.js +118 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +9 -0
- package/dist/mesh-view.d.ts +44 -0
- package/dist/mesh-view.d.ts.map +1 -0
- package/dist/mesh-view.js +23 -0
- package/dist/node.d.ts +12 -0
- package/dist/node.d.ts.map +1 -0
- package/dist/node.js +39 -0
- package/dist/registries.d.ts +45 -0
- package/dist/registries.d.ts.map +1 -0
- package/dist/registries.js +133 -0
- package/dist/storage.d.ts +53 -0
- package/dist/storage.d.ts.map +1 -0
- package/dist/storage.js +113 -0
- package/package.json +66 -0
- package/src/admin.ts +50 -0
- package/src/browser.ts +73 -0
- package/src/create-hub.ts +200 -0
- package/src/endpoints.ts +476 -0
- package/src/hub-state.ts +199 -0
- package/src/index.ts +33 -0
- package/src/mesh-view.ts +83 -0
- package/src/node.ts +40 -0
- package/src/registries.ts +150 -0
- package/src/storage.ts +136 -0
package/src/hub-state.ts
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The hub's durable state — members and spent invitation ids — over an
|
|
3
|
+
* INJECTED `SnapshotStore`, with no idea where that store puts anything.
|
|
4
|
+
*
|
|
5
|
+
* WHY THIS IS ITS OWN FILE, SEPARATE FROM `./persist.ts`. The
|
|
6
|
+
* `SnapshotStore` seam exists so the same hub logic can run in a browser
|
|
7
|
+
* page (`../pages/hub/`, Task 24) as well as in the Node process, and a
|
|
8
|
+
* seam is only as portable as the module that declares it: `persist.ts`
|
|
9
|
+
* imports `node:fs`/`node:path` at module scope for its own file store, so
|
|
10
|
+
* a browser bundle that reached `createPersistentHub` dragged both in with
|
|
11
|
+
* it. Vite does not fail that build — it substitutes a stub that throws on
|
|
12
|
+
* first property access — so the failure would have surfaced at runtime, in
|
|
13
|
+
* a tab, as an error naming `node:fs` and nothing about the hub. Everything
|
|
14
|
+
* in THIS file is storage-agnostic and free of `node:` imports; `persist.ts`
|
|
15
|
+
* keeps the Node file store and re-exports all of this unchanged, so no
|
|
16
|
+
* existing caller (or test) sees a different API.
|
|
17
|
+
*
|
|
18
|
+
* PRESENCE IS DELIBERATELY NOT HERE. It is TTL'd and regenerates within one
|
|
19
|
+
* heartbeat interval of a restart — persisting it would only let a stale
|
|
20
|
+
* "who was online" view survive past the moment it stopped being true.
|
|
21
|
+
*
|
|
22
|
+
* INVITATIONS THEMSELVES ARE ALSO NOT HERE — only which ids have already
|
|
23
|
+
* been *spent*. An unredeemed invitation forgotten across a restart is a
|
|
24
|
+
* minor inconvenience (issue a new one); a redeemed one becoming redeemable
|
|
25
|
+
* again is a membership bypass. Persisting exactly the set that guards
|
|
26
|
+
* against the second, and nothing else, is the smallest state that closes
|
|
27
|
+
* the hole. See `createInvitationStore` below: the spent-id check runs
|
|
28
|
+
* *before* the invitation is even looked up, so a code whose invitation
|
|
29
|
+
* record did not survive a restart still cannot be redeemed twice.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import type { RuleSet } from "@statewalker/httpeers-access";
|
|
33
|
+
import { assertValid, validateRoles } from "@statewalker/httpeers-access";
|
|
34
|
+
import type { MemberRecord, MemberStore } from "@statewalker/httpeers-core";
|
|
35
|
+
|
|
36
|
+
export interface HubSnapshot {
|
|
37
|
+
members: MemberRecord[];
|
|
38
|
+
spentInvitationIds: string[];
|
|
39
|
+
/**
|
|
40
|
+
* Invitations created and not yet redeemed.
|
|
41
|
+
*
|
|
42
|
+
* ABSENT FROM THE PROTOTYPE, and that was a defect rather than a decision:
|
|
43
|
+
* pending invitations lived in memory only, so a hub restart silently
|
|
44
|
+
* invalidated every invitation an operator had already handed out — and the
|
|
45
|
+
* person holding one got `not-found`, which reads like a typo rather than
|
|
46
|
+
* like "the hub restarted".
|
|
47
|
+
*
|
|
48
|
+
* Optional so a snapshot written by the older shape still loads: a hub
|
|
49
|
+
* upgrading in place finds `undefined` here and starts with no pending
|
|
50
|
+
* invitations, which is exactly the state it was already in.
|
|
51
|
+
*/
|
|
52
|
+
pendingInvitations?: InvitationRecord[];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** What a store with nothing in it yet must read as — see `SnapshotStore.read`. */
|
|
56
|
+
export const EMPTY_SNAPSHOT: HubSnapshot = {
|
|
57
|
+
members: [],
|
|
58
|
+
spentInvitationIds: [],
|
|
59
|
+
pendingInvitations: [],
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Where a hub's snapshot lives. THE ONE SEAM BETWEEN THE HUB'S STATE AND
|
|
64
|
+
* ANY PARTICULAR STORAGE -- `./persist.ts`'s `createFileSnapshotStore` is
|
|
65
|
+
* the Node one (a JSON file), `../browser/snapshot-store.ts`'s is the
|
|
66
|
+
* browser one (IndexedDB); nothing else in this file knows either exists.
|
|
67
|
+
*
|
|
68
|
+
* `read` returns `EMPTY_SNAPSHOT`-shaped state on a first run rather than
|
|
69
|
+
* throwing: "nothing stored yet" is the normal first case, not a fault.
|
|
70
|
+
*
|
|
71
|
+
* `write` is synchronous on purpose: every mutation below calls it and then
|
|
72
|
+
* returns, so an async store would open a window where the caller has been
|
|
73
|
+
* told a member was added while the snapshot still says otherwise. A browser
|
|
74
|
+
* implementation that can only write asynchronously must therefore keep an
|
|
75
|
+
* in-memory copy authoritative and flush behind it, rather than making these
|
|
76
|
+
* methods async. See `../browser/snapshot-store.ts` for the one that does.
|
|
77
|
+
*/
|
|
78
|
+
export interface SnapshotStore {
|
|
79
|
+
read(): HubSnapshot;
|
|
80
|
+
write(snapshot: HubSnapshot): void;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export interface PersistentHub {
|
|
84
|
+
memberStore: MemberStore;
|
|
85
|
+
invitations: InvitationStore;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export interface InvitationRecord {
|
|
89
|
+
id: string;
|
|
90
|
+
roles: string[];
|
|
91
|
+
expiresAt: number;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export type InvitationRedemption =
|
|
95
|
+
| { ok: true; roles: string[] }
|
|
96
|
+
| { ok: false; reason: "not-found" | "expired" | "already-redeemed" };
|
|
97
|
+
|
|
98
|
+
export interface InvitationStore {
|
|
99
|
+
/** Throws (via `assertValid`) if any role is one no rule of `rules` knows — caught where the invitation is made, not three hops later. */
|
|
100
|
+
create(id: string, roles: string[], ttlMs: number): InvitationRecord;
|
|
101
|
+
redeem(id: string): InvitationRedemption;
|
|
102
|
+
/** What became of this id. `unknown` means no invitation was ever created with it. */
|
|
103
|
+
status(id: string): "unspent" | "redeemed" | "expired" | "unknown";
|
|
104
|
+
/** Every invitation still awaiting redemption, for an operator's listing. */
|
|
105
|
+
pending(): InvitationRecord[];
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export interface CreateHubStateInit {
|
|
109
|
+
/** Where the snapshot is loaded from at construction and written back to after every mutation. */
|
|
110
|
+
store: SnapshotStore;
|
|
111
|
+
/** This mesh's rules — the role registry `validateRoles` reads (ADR-0019). */
|
|
112
|
+
rules: RuleSet;
|
|
113
|
+
now?: () => number;
|
|
114
|
+
createMemberStore: (rules: RuleSet, clock?: () => number) => MemberStore;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Wrap a `MemberStore` so every mutation is followed by a synchronous
|
|
119
|
+
* snapshot write — members and spent invitation ids share ONE snapshot, so
|
|
120
|
+
* one write keeps both consistent with each other by construction (no
|
|
121
|
+
* window where one persisted and the other did not). Presence is
|
|
122
|
+
* intentionally not part of this — see the module comment.
|
|
123
|
+
*/
|
|
124
|
+
export function createHubState(init: CreateHubStateInit): PersistentHub {
|
|
125
|
+
const now = init.now ?? Date.now;
|
|
126
|
+
const snapshot = init.store.read();
|
|
127
|
+
|
|
128
|
+
const innerMembers = init.createMemberStore(init.rules, now);
|
|
129
|
+
for (const m of snapshot.members) innerMembers.add(m.peerId, m.roles);
|
|
130
|
+
const spentIds = new Set(snapshot.spentInvitationIds);
|
|
131
|
+
|
|
132
|
+
const save = (): void => {
|
|
133
|
+
init.store.write({
|
|
134
|
+
members: innerMembers.list(),
|
|
135
|
+
spentInvitationIds: [...spentIds],
|
|
136
|
+
pendingInvitations: [...pending.values()],
|
|
137
|
+
});
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
const memberStore: MemberStore = {
|
|
141
|
+
add(peerId, roles) {
|
|
142
|
+
const record = innerMembers.add(peerId, roles);
|
|
143
|
+
save();
|
|
144
|
+
return record;
|
|
145
|
+
},
|
|
146
|
+
setRoles(peerId, roles) {
|
|
147
|
+
const record = innerMembers.setRoles(peerId, roles);
|
|
148
|
+
save();
|
|
149
|
+
return record;
|
|
150
|
+
},
|
|
151
|
+
remove(peerId) {
|
|
152
|
+
innerMembers.remove(peerId);
|
|
153
|
+
save();
|
|
154
|
+
},
|
|
155
|
+
get: innerMembers.get,
|
|
156
|
+
list: innerMembers.list,
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
// Restored from the snapshot, not merely held in memory — see
|
|
160
|
+
// `HubSnapshot.pendingInvitations` for what that fixes.
|
|
161
|
+
const pending = new Map<string, InvitationRecord>(
|
|
162
|
+
(snapshot.pendingInvitations ?? []).map((record) => [record.id, record]),
|
|
163
|
+
);
|
|
164
|
+
|
|
165
|
+
const invitations: InvitationStore = {
|
|
166
|
+
create(id, roles, ttlMs) {
|
|
167
|
+
assertValid(validateRoles(init.rules, roles, `invitation '${id}'`));
|
|
168
|
+
const record: InvitationRecord = { id, roles: [...roles], expiresAt: now() + ttlMs };
|
|
169
|
+
pending.set(id, record);
|
|
170
|
+
save();
|
|
171
|
+
return record;
|
|
172
|
+
},
|
|
173
|
+
redeem(id) {
|
|
174
|
+
// The spent check runs FIRST, and unconditionally — before the
|
|
175
|
+
// invitation record is even looked up. That is what makes double
|
|
176
|
+
// redemption impossible across a restart, and it stays first now that
|
|
177
|
+
// pending records persist too: the two facts are written together, but
|
|
178
|
+
// "already spent" must win over "here is the record" whatever order they
|
|
179
|
+
// were loaded in.
|
|
180
|
+
if (spentIds.has(id)) return { ok: false, reason: "already-redeemed" };
|
|
181
|
+
const record = pending.get(id);
|
|
182
|
+
if (record == null) return { ok: false, reason: "not-found" };
|
|
183
|
+
if (record.expiresAt <= now()) return { ok: false, reason: "expired" };
|
|
184
|
+
pending.delete(id);
|
|
185
|
+
spentIds.add(id);
|
|
186
|
+
save();
|
|
187
|
+
return { ok: true, roles: record.roles };
|
|
188
|
+
},
|
|
189
|
+
status(id) {
|
|
190
|
+
if (spentIds.has(id)) return "redeemed";
|
|
191
|
+
const record = pending.get(id);
|
|
192
|
+
if (record == null) return "unknown";
|
|
193
|
+
return record.expiresAt <= now() ? "expired" : "unspent";
|
|
194
|
+
},
|
|
195
|
+
pending: () => [...pending.values()],
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
return { memberStore, invitations };
|
|
199
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minting membership, holding the registries, and saying who is in the mesh.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export { type CreateHubInit, createHub, type Hub } from "./create-hub.js";
|
|
6
|
+
export {
|
|
7
|
+
ADMIN_CAPABILITY,
|
|
8
|
+
createHubEndpoints,
|
|
9
|
+
DEFAULT_PRESENCE_TTL_MS,
|
|
10
|
+
type HubEndpoints,
|
|
11
|
+
type HubEndpointsInit,
|
|
12
|
+
usesTransportIdentity,
|
|
13
|
+
} from "./endpoints.js";
|
|
14
|
+
export {
|
|
15
|
+
type CreateHubStateInit,
|
|
16
|
+
createHubState,
|
|
17
|
+
EMPTY_SNAPSHOT,
|
|
18
|
+
type HubSnapshot,
|
|
19
|
+
type InvitationRecord,
|
|
20
|
+
type InvitationRedemption,
|
|
21
|
+
type InvitationStore,
|
|
22
|
+
type PersistentHub,
|
|
23
|
+
type SnapshotStore,
|
|
24
|
+
} from "./hub-state.js";
|
|
25
|
+
export { type AdvertisementPayload, buildMeshView } from "./mesh-view.js";
|
|
26
|
+
export { createAdvertisementStore, createMemberStore, createPresenceStore } from "./registries.js";
|
|
27
|
+
export {
|
|
28
|
+
type AsyncSnapshotStore,
|
|
29
|
+
asyncSnapshotStore,
|
|
30
|
+
filesStorage,
|
|
31
|
+
type KeyValueStorage,
|
|
32
|
+
memoryStorage,
|
|
33
|
+
} from "./storage.js";
|
package/src/mesh-view.ts
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The mesh view: a PROJECTION over the three registries (members, presence,
|
|
3
|
+
* advertisements), not a fourth registry of its own. Nothing here is stored
|
|
4
|
+
* — it is recomputed from the hub's live state on every read, filtered by
|
|
5
|
+
* whoever is asking.
|
|
6
|
+
*
|
|
7
|
+
* THE HUB IS A BULLETIN BOARD, NEVER AN AUTHORITY. An advertisement says
|
|
8
|
+
* "worth asking"; the provider it names still decides for itself whether to
|
|
9
|
+
* answer. Nothing downstream may treat inclusion in this view as a grant —
|
|
10
|
+
* that is what the membership token and each provider's own policy
|
|
11
|
+
* are for.
|
|
12
|
+
*
|
|
13
|
+
* Two things vary by caller, both because different callers legitimately
|
|
14
|
+
* see different views:
|
|
15
|
+
* - a member with role `hidden` is omitted from the view of anyone who does
|
|
16
|
+
* not themselves hold `std:mesh.admin` — this is the one consumer of the
|
|
17
|
+
* `hidden` role `DEFAULT_RULES` already knows;
|
|
18
|
+
* - an advertisement is omitted unless the caller holds the capability that
|
|
19
|
+
* gates its `kind` (`advertisementAccess`, hub-owned config — analogous
|
|
20
|
+
* to an allow policy, but for the bulletin board rather than a path).
|
|
21
|
+
*/
|
|
22
|
+
import type {
|
|
23
|
+
Advertisement,
|
|
24
|
+
MemberRecord,
|
|
25
|
+
MeshView,
|
|
26
|
+
MeshViewAdvertisement,
|
|
27
|
+
MeshViewMember,
|
|
28
|
+
PresenceRecord,
|
|
29
|
+
} from "@statewalker/httpeers-core";
|
|
30
|
+
|
|
31
|
+
// `MeshView` and its members moved to `httpeers-core`. The hub PRODUCES one
|
|
32
|
+
// and `httpeers-member` RETURNS one, and member must not depend on hub — so a
|
|
33
|
+
// type both consume belongs in the package both already import. Re-exported
|
|
34
|
+
// here so this file's own readers still find it where they expect.
|
|
35
|
+
export type { MeshView, MeshViewAdvertisement, MeshViewMember };
|
|
36
|
+
|
|
37
|
+
/** The payload shape this hub posts advertisements with — see `hub/endpoints.ts`'s presence handler. */
|
|
38
|
+
export interface AdvertisementPayload {
|
|
39
|
+
kind: string;
|
|
40
|
+
title: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface BuildMeshViewInit {
|
|
44
|
+
version: number;
|
|
45
|
+
self: string;
|
|
46
|
+
members: MemberRecord[];
|
|
47
|
+
presence: PresenceRecord[];
|
|
48
|
+
addrsByPeer: ReadonlyMap<string, string[]>;
|
|
49
|
+
advertisements: Advertisement[];
|
|
50
|
+
/** Capabilities the caller holds (already derived from their roles by the node's rules). */
|
|
51
|
+
callerCapabilities: ReadonlySet<string>;
|
|
52
|
+
/** `kind` -> capability required to see advertisements of that kind. A kind absent here is visible to any authenticated caller. */
|
|
53
|
+
advertisementAccess: Readonly<Record<string, string>>;
|
|
54
|
+
/** The capability that grants admin visibility (sees `hidden` members). */
|
|
55
|
+
adminCapability: string;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function buildMeshView(init: BuildMeshViewInit): MeshView {
|
|
59
|
+
const isAdmin = init.callerCapabilities.has(init.adminCapability);
|
|
60
|
+
const present = new Set(init.presence.map((p) => p.peerId));
|
|
61
|
+
|
|
62
|
+
const members: MeshViewMember[] = init.members
|
|
63
|
+
.filter((m) => isAdmin || !m.roles.includes("hidden"))
|
|
64
|
+
.map((m) => ({
|
|
65
|
+
peerId: m.peerId,
|
|
66
|
+
roles: m.roles,
|
|
67
|
+
online: present.has(m.peerId),
|
|
68
|
+
addrs: init.addrsByPeer.get(m.peerId) ?? [],
|
|
69
|
+
}));
|
|
70
|
+
|
|
71
|
+
const advertisements: MeshViewAdvertisement[] = init.advertisements
|
|
72
|
+
.filter((ad) => {
|
|
73
|
+
const kind = (ad.payload as AdvertisementPayload).kind;
|
|
74
|
+
const required = init.advertisementAccess[kind];
|
|
75
|
+
return required == null || init.callerCapabilities.has(required);
|
|
76
|
+
})
|
|
77
|
+
.map((ad) => {
|
|
78
|
+
const payload = ad.payload as AdvertisementPayload;
|
|
79
|
+
return { peerId: ad.peerId, id: ad.key, kind: payload.kind, title: payload.title };
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
return { version: init.version, self: init.self, members, advertisements };
|
|
83
|
+
}
|
package/src/node.ts
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Node profile: hub state on disk.
|
|
3
|
+
*
|
|
4
|
+
* Write-then-rename, for the reason rung 04 measured: the prototype's file
|
|
5
|
+
* snapshot store wrote IN PLACE, so a crash mid-write left a truncated JSON
|
|
6
|
+
* document — and a hub that cannot parse its snapshot starts EMPTY. Every
|
|
7
|
+
* member forgotten, every spent invitation id forgotten and therefore
|
|
8
|
+
* replayable, and nothing reporting an error.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
|
12
|
+
import { dirname } from "node:path";
|
|
13
|
+
import type { KeyValueStorage } from "./storage.js";
|
|
14
|
+
|
|
15
|
+
export function fileStorage(path: string): KeyValueStorage {
|
|
16
|
+
const pathFor = (key: string) => `${path}.${key.replace(/[^A-Za-z0-9_-]/g, "_")}`;
|
|
17
|
+
return {
|
|
18
|
+
async get(key) {
|
|
19
|
+
try {
|
|
20
|
+
return await readFile(pathFor(key), "utf8");
|
|
21
|
+
} catch (error) {
|
|
22
|
+
if ((error as { code?: string }).code === "ENOENT") return undefined;
|
|
23
|
+
throw error;
|
|
24
|
+
}
|
|
25
|
+
},
|
|
26
|
+
async set(key, value) {
|
|
27
|
+
const target = pathFor(key);
|
|
28
|
+
await mkdir(dirname(target), { recursive: true });
|
|
29
|
+
// Same directory, so the rename cannot cross a filesystem boundary —
|
|
30
|
+
// the one case where it would stop being atomic.
|
|
31
|
+
const staging = `${target}.${process.pid}.${Date.now()}.tmp`;
|
|
32
|
+
await writeFile(staging, value, { mode: 0o600 });
|
|
33
|
+
await rename(staging, target);
|
|
34
|
+
},
|
|
35
|
+
async delete(key) {
|
|
36
|
+
const { rm } = await import("node:fs/promises");
|
|
37
|
+
await rm(pathFor(key), { force: true });
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The hub's three registries: members, presence, advertisements.
|
|
3
|
+
*
|
|
4
|
+
* All three are plain in-memory data structures with an injected clock
|
|
5
|
+
* (`() => number`) so TTL and sequencing behaviour is deterministic under
|
|
6
|
+
* test, with no real timers involved. Persistence is a later task's job —
|
|
7
|
+
* nothing here touches the filesystem or a database.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { RuleSet } from "@statewalker/httpeers-access";
|
|
11
|
+
import { assertValid, validateRoles } from "@statewalker/httpeers-access";
|
|
12
|
+
import type {
|
|
13
|
+
Advertisement,
|
|
14
|
+
AdvertisementStore,
|
|
15
|
+
MemberRecord,
|
|
16
|
+
MemberStore,
|
|
17
|
+
PresenceRecord,
|
|
18
|
+
PresenceStore,
|
|
19
|
+
PresenceWriteResult,
|
|
20
|
+
} from "@statewalker/httpeers-core";
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Durable membership: who belongs to the mesh, and with what roles.
|
|
24
|
+
*
|
|
25
|
+
* `setRoles` validates its `roles` against `rules` before writing — "catch a
|
|
26
|
+
* typo where it is made, not three hops later as a silent denial" (the v0.9.0
|
|
27
|
+
* delta's own rationale). Since ADR-0019 there is no separate role registry to
|
|
28
|
+
* validate against: a role exists for this node exactly when some rule fires
|
|
29
|
+
* on it, which is what `validateRoles` reads. This is deliberately at the STORE,
|
|
30
|
+
* not at whichever call site happens to reach `setRoles` today: a guard
|
|
31
|
+
* placed at a call site is only as durable as that call site, and a later
|
|
32
|
+
* one added without knowing the guard belongs here would ship an
|
|
33
|
+
* unvalidated role write. `add` is NOT validated here — every caller that
|
|
34
|
+
* feeds it caller-supplied roles (e.g. invitation redemption) validates
|
|
35
|
+
* them earlier, at the point the roles were themselves accepted
|
|
36
|
+
* (`InvitationStore.create` in `apps/httpeers-stack`), matching the
|
|
37
|
+
* archived delta's own choice to guard `createInvitation` and `setRoles`,
|
|
38
|
+
* never `addMember`.
|
|
39
|
+
*/
|
|
40
|
+
export function createMemberStore(rules: RuleSet, clock: () => number = Date.now): MemberStore {
|
|
41
|
+
const members = new Map<string, MemberRecord>();
|
|
42
|
+
|
|
43
|
+
return {
|
|
44
|
+
add(peerId, roles) {
|
|
45
|
+
const record: MemberRecord = { peerId, roles: [...roles], updatedAt: clock() };
|
|
46
|
+
members.set(peerId, record);
|
|
47
|
+
return record;
|
|
48
|
+
},
|
|
49
|
+
setRoles(peerId, roles) {
|
|
50
|
+
assertValid(validateRoles(rules, roles, `setRoles(${peerId})`));
|
|
51
|
+
const record: MemberRecord = { peerId, roles: [...roles], updatedAt: clock() };
|
|
52
|
+
members.set(peerId, record);
|
|
53
|
+
return record;
|
|
54
|
+
},
|
|
55
|
+
remove(peerId) {
|
|
56
|
+
members.delete(peerId);
|
|
57
|
+
},
|
|
58
|
+
get(peerId) {
|
|
59
|
+
return members.get(peerId);
|
|
60
|
+
},
|
|
61
|
+
list() {
|
|
62
|
+
return [...members.values()];
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* TTL'd, swept presence: who is live right now.
|
|
69
|
+
*
|
|
70
|
+
* Writes are monotonic per peer — see `PresenceStore` in types.ts for why.
|
|
71
|
+
* The comparison is always `seq` against the peer's own last-accepted
|
|
72
|
+
* `seq`, never wall-clock time and never across different peers.
|
|
73
|
+
*/
|
|
74
|
+
export function createPresenceStore(clock: () => number = Date.now): PresenceStore {
|
|
75
|
+
const presence = new Map<string, PresenceRecord>();
|
|
76
|
+
|
|
77
|
+
return {
|
|
78
|
+
heartbeat(peerId, seq, ttlMs): PresenceWriteResult {
|
|
79
|
+
const existing = presence.get(peerId);
|
|
80
|
+
if (existing !== undefined && seq <= existing.seq) {
|
|
81
|
+
return { accepted: false, reason: "stale-sequence" };
|
|
82
|
+
}
|
|
83
|
+
const record: PresenceRecord = { peerId, seq, expiresAt: clock() + ttlMs };
|
|
84
|
+
presence.set(peerId, record);
|
|
85
|
+
return { accepted: true, record };
|
|
86
|
+
},
|
|
87
|
+
isPresent(peerId) {
|
|
88
|
+
const record = presence.get(peerId);
|
|
89
|
+
return record !== undefined && record.expiresAt > clock();
|
|
90
|
+
},
|
|
91
|
+
get(peerId) {
|
|
92
|
+
return presence.get(peerId);
|
|
93
|
+
},
|
|
94
|
+
list() {
|
|
95
|
+
return [...presence.values()];
|
|
96
|
+
},
|
|
97
|
+
sweep() {
|
|
98
|
+
const now = clock();
|
|
99
|
+
const expired: string[] = [];
|
|
100
|
+
for (const record of presence.values()) {
|
|
101
|
+
if (record.expiresAt <= now) {
|
|
102
|
+
expired.push(record.peerId);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
for (const peerId of expired) {
|
|
106
|
+
presence.delete(peerId);
|
|
107
|
+
}
|
|
108
|
+
return expired;
|
|
109
|
+
},
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* The bulletin board: what peers offer. Keyed by `(peerId, key)` via a
|
|
115
|
+
* two-level `Map` — not a joined string — so there is no delimiter to pick
|
|
116
|
+
* and therefore no way for a `peerId` or `key` value to collide with one.
|
|
117
|
+
* Entries persist until withdrawn or replaced, not on a timer.
|
|
118
|
+
*/
|
|
119
|
+
export function createAdvertisementStore(clock: () => number = Date.now): AdvertisementStore {
|
|
120
|
+
const board = new Map<string, Map<string, Advertisement>>();
|
|
121
|
+
|
|
122
|
+
return {
|
|
123
|
+
post(peerId, key, payload) {
|
|
124
|
+
const ad: Advertisement = { peerId, key, payload, postedAt: clock() };
|
|
125
|
+
let byKey = board.get(peerId);
|
|
126
|
+
if (byKey === undefined) {
|
|
127
|
+
byKey = new Map();
|
|
128
|
+
board.set(peerId, byKey);
|
|
129
|
+
}
|
|
130
|
+
byKey.set(key, ad);
|
|
131
|
+
return ad;
|
|
132
|
+
},
|
|
133
|
+
withdraw(peerId, key) {
|
|
134
|
+
board.get(peerId)?.delete(key);
|
|
135
|
+
},
|
|
136
|
+
get(peerId, key) {
|
|
137
|
+
return board.get(peerId)?.get(key);
|
|
138
|
+
},
|
|
139
|
+
list(peerId) {
|
|
140
|
+
if (peerId !== undefined) {
|
|
141
|
+
return [...(board.get(peerId)?.values() ?? [])];
|
|
142
|
+
}
|
|
143
|
+
const all: Advertisement[] = [];
|
|
144
|
+
for (const byKey of board.values()) {
|
|
145
|
+
all.push(...byKey.values());
|
|
146
|
+
}
|
|
147
|
+
return all;
|
|
148
|
+
},
|
|
149
|
+
};
|
|
150
|
+
}
|
package/src/storage.ts
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The candidate `hub` package's storage seam, and two adapters for it.
|
|
3
|
+
*
|
|
4
|
+
* DERIVED, NOT DESIGNED. Everything the hub does to storage today is
|
|
5
|
+
* whole-value get / set / delete by key: `persist.ts` reads and writes one
|
|
6
|
+
* JSON file; `browser/snapshot-store.ts` reads and writes one IndexedDB
|
|
7
|
+
* value. Nothing lists, appends, or compare-and-sets. So the interface is
|
|
8
|
+
* those three methods and no more.
|
|
9
|
+
*
|
|
10
|
+
* `SnapshotStore` (`hub/hub-state.ts`) is deliberately SYNCHRONOUS, because
|
|
11
|
+
* every mutation writes and returns, and an async write would let a caller be
|
|
12
|
+
* told "member added" while the snapshot still said otherwise. Both async
|
|
13
|
+
* backends therefore need the same wrapper the browser already has: the
|
|
14
|
+
* in-memory copy is authoritative, and flushes are serialised behind it.
|
|
15
|
+
* `asyncSnapshotStore` below is that wrapper, generalised — it is
|
|
16
|
+
* `browser/snapshot-store.ts`'s logic over any `KeyValueStorage` rather than
|
|
17
|
+
* over idb-keyval specifically.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import type { FilesApi } from "@statewalker/webrun-files";
|
|
21
|
+
import { EMPTY_SNAPSHOT, type HubSnapshot, type SnapshotStore } from "./hub-state.js";
|
|
22
|
+
|
|
23
|
+
/** The whole persistence contract. Three methods, because three is what the hub uses. */
|
|
24
|
+
export interface KeyValueStorage {
|
|
25
|
+
get(key: string): Promise<string | undefined>;
|
|
26
|
+
set(key: string, value: string): Promise<void>;
|
|
27
|
+
delete(key: string): Promise<void>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** For tests and for a hub that deliberately keeps nothing. */
|
|
31
|
+
export function memoryStorage(): KeyValueStorage {
|
|
32
|
+
const map = new Map<string, string>();
|
|
33
|
+
return {
|
|
34
|
+
async get(key) {
|
|
35
|
+
return map.get(key);
|
|
36
|
+
},
|
|
37
|
+
async set(key, value) {
|
|
38
|
+
map.set(key, value);
|
|
39
|
+
},
|
|
40
|
+
async delete(key) {
|
|
41
|
+
map.delete(key);
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* A `FilesApi` as key/value storage — one file per key under `dir`.
|
|
48
|
+
*
|
|
49
|
+
* WRITE-THEN-MOVE, because a crash mid-write must not leave a half-written
|
|
50
|
+
* snapshot where the whole state lives. `persist.ts`'s Node implementation
|
|
51
|
+
* does NOT do this (it writes in place), which is a defect this adapter does
|
|
52
|
+
* not inherit.
|
|
53
|
+
*
|
|
54
|
+
* FilesApi HAS NO CONDITIONAL WRITE — no compare-and-set, no if-match. That
|
|
55
|
+
* is why the hub's single-writer precondition is a precondition rather than
|
|
56
|
+
* something the storage layer can enforce; rung 04's third claim measures
|
|
57
|
+
* exactly what that costs.
|
|
58
|
+
*/
|
|
59
|
+
export function filesStorage(files: FilesApi, dir = "hub"): KeyValueStorage {
|
|
60
|
+
const path = (key: string): string => `${dir}/${encodeURIComponent(key)}.json`;
|
|
61
|
+
|
|
62
|
+
return {
|
|
63
|
+
async get(key) {
|
|
64
|
+
const file = path(key);
|
|
65
|
+
if (!(await files.exists(file))) return undefined;
|
|
66
|
+
const chunks: Uint8Array[] = [];
|
|
67
|
+
for await (const chunk of files.read(file)) chunks.push(chunk);
|
|
68
|
+
const total = chunks.reduce((n, c) => n + c.length, 0);
|
|
69
|
+
const bytes = new Uint8Array(total);
|
|
70
|
+
let at = 0;
|
|
71
|
+
for (const chunk of chunks) {
|
|
72
|
+
bytes.set(chunk, at);
|
|
73
|
+
at += chunk.length;
|
|
74
|
+
}
|
|
75
|
+
return new TextDecoder().decode(bytes);
|
|
76
|
+
},
|
|
77
|
+
async set(key, value) {
|
|
78
|
+
const file = path(key);
|
|
79
|
+
const temp = `${file}.tmp`;
|
|
80
|
+
const bytes = new TextEncoder().encode(value);
|
|
81
|
+
await files.write(
|
|
82
|
+
temp,
|
|
83
|
+
(async function* () {
|
|
84
|
+
yield bytes;
|
|
85
|
+
})(),
|
|
86
|
+
);
|
|
87
|
+
await files.move(temp, file);
|
|
88
|
+
},
|
|
89
|
+
async delete(key) {
|
|
90
|
+
await files.remove(path(key));
|
|
91
|
+
},
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export interface AsyncSnapshotStore extends SnapshotStore {
|
|
96
|
+
/** Resolves when every write issued so far has reached the backend. */
|
|
97
|
+
flushed(): Promise<void>;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Load once, then serve reads from memory and flush writes behind them —
|
|
102
|
+
* the shape `SnapshotStore`'s synchronous contract forces on any async
|
|
103
|
+
* backend.
|
|
104
|
+
*/
|
|
105
|
+
export async function asyncSnapshotStore(
|
|
106
|
+
storage: KeyValueStorage,
|
|
107
|
+
key = "snapshot",
|
|
108
|
+
): Promise<AsyncSnapshotStore> {
|
|
109
|
+
const raw = await storage.get(key);
|
|
110
|
+
let current: HubSnapshot = EMPTY_SNAPSHOT;
|
|
111
|
+
if (raw != null) {
|
|
112
|
+
try {
|
|
113
|
+
current = JSON.parse(raw) as HubSnapshot;
|
|
114
|
+
} catch {
|
|
115
|
+
// A corrupt value reads as empty rather than throwing, matching
|
|
116
|
+
// `browser/snapshot-store.ts`. A hub that refuses to start because its
|
|
117
|
+
// state file was truncated is worse than one that starts empty and
|
|
118
|
+
// says so.
|
|
119
|
+
current = EMPTY_SNAPSHOT;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Writes are chained, never concurrent: two overlapping flushes of the same
|
|
124
|
+
// key can land out of order, and the loser would be the newer state.
|
|
125
|
+
let chain: Promise<void> = Promise.resolve();
|
|
126
|
+
|
|
127
|
+
return {
|
|
128
|
+
read: () => current,
|
|
129
|
+
write(snapshot) {
|
|
130
|
+
current = snapshot;
|
|
131
|
+
const value = JSON.stringify(snapshot);
|
|
132
|
+
chain = chain.then(() => storage.set(key, value)).catch(() => {});
|
|
133
|
+
},
|
|
134
|
+
flushed: () => chain,
|
|
135
|
+
};
|
|
136
|
+
}
|