@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/dist/node.js
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
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
|
+
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
|
11
|
+
import { dirname } from "node:path";
|
|
12
|
+
export function fileStorage(path) {
|
|
13
|
+
const pathFor = (key) => `${path}.${key.replace(/[^A-Za-z0-9_-]/g, "_")}`;
|
|
14
|
+
return {
|
|
15
|
+
async get(key) {
|
|
16
|
+
try {
|
|
17
|
+
return await readFile(pathFor(key), "utf8");
|
|
18
|
+
}
|
|
19
|
+
catch (error) {
|
|
20
|
+
if (error.code === "ENOENT")
|
|
21
|
+
return undefined;
|
|
22
|
+
throw error;
|
|
23
|
+
}
|
|
24
|
+
},
|
|
25
|
+
async set(key, value) {
|
|
26
|
+
const target = pathFor(key);
|
|
27
|
+
await mkdir(dirname(target), { recursive: true });
|
|
28
|
+
// Same directory, so the rename cannot cross a filesystem boundary —
|
|
29
|
+
// the one case where it would stop being atomic.
|
|
30
|
+
const staging = `${target}.${process.pid}.${Date.now()}.tmp`;
|
|
31
|
+
await writeFile(staging, value, { mode: 0o600 });
|
|
32
|
+
await rename(staging, target);
|
|
33
|
+
},
|
|
34
|
+
async delete(key) {
|
|
35
|
+
const { rm } = await import("node:fs/promises");
|
|
36
|
+
await rm(pathFor(key), { force: true });
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
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
|
+
import type { RuleSet } from "@statewalker/httpeers-access";
|
|
10
|
+
import type { AdvertisementStore, MemberStore, PresenceStore } from "@statewalker/httpeers-core";
|
|
11
|
+
/**
|
|
12
|
+
* Durable membership: who belongs to the mesh, and with what roles.
|
|
13
|
+
*
|
|
14
|
+
* `setRoles` validates its `roles` against `rules` before writing — "catch a
|
|
15
|
+
* typo where it is made, not three hops later as a silent denial" (the v0.9.0
|
|
16
|
+
* delta's own rationale). Since ADR-0019 there is no separate role registry to
|
|
17
|
+
* validate against: a role exists for this node exactly when some rule fires
|
|
18
|
+
* on it, which is what `validateRoles` reads. This is deliberately at the STORE,
|
|
19
|
+
* not at whichever call site happens to reach `setRoles` today: a guard
|
|
20
|
+
* placed at a call site is only as durable as that call site, and a later
|
|
21
|
+
* one added without knowing the guard belongs here would ship an
|
|
22
|
+
* unvalidated role write. `add` is NOT validated here — every caller that
|
|
23
|
+
* feeds it caller-supplied roles (e.g. invitation redemption) validates
|
|
24
|
+
* them earlier, at the point the roles were themselves accepted
|
|
25
|
+
* (`InvitationStore.create` in `apps/httpeers-stack`), matching the
|
|
26
|
+
* archived delta's own choice to guard `createInvitation` and `setRoles`,
|
|
27
|
+
* never `addMember`.
|
|
28
|
+
*/
|
|
29
|
+
export declare function createMemberStore(rules: RuleSet, clock?: () => number): MemberStore;
|
|
30
|
+
/**
|
|
31
|
+
* TTL'd, swept presence: who is live right now.
|
|
32
|
+
*
|
|
33
|
+
* Writes are monotonic per peer — see `PresenceStore` in types.ts for why.
|
|
34
|
+
* The comparison is always `seq` against the peer's own last-accepted
|
|
35
|
+
* `seq`, never wall-clock time and never across different peers.
|
|
36
|
+
*/
|
|
37
|
+
export declare function createPresenceStore(clock?: () => number): PresenceStore;
|
|
38
|
+
/**
|
|
39
|
+
* The bulletin board: what peers offer. Keyed by `(peerId, key)` via a
|
|
40
|
+
* two-level `Map` — not a joined string — so there is no delimiter to pick
|
|
41
|
+
* and therefore no way for a `peerId` or `key` value to collide with one.
|
|
42
|
+
* Entries persist until withdrawn or replaced, not on a timer.
|
|
43
|
+
*/
|
|
44
|
+
export declare function createAdvertisementStore(clock?: () => number): AdvertisementStore;
|
|
45
|
+
//# sourceMappingURL=registries.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"registries.d.ts","sourceRoot":"","sources":["../src/registries.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,8BAA8B,CAAC;AAE5D,OAAO,KAAK,EAEV,kBAAkB,EAElB,WAAW,EAEX,aAAa,EAEd,MAAM,4BAA4B,CAAC;AAEpC;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAgB,iBAAiB,CAAC,KAAK,EAAE,OAAO,EAAE,KAAK,GAAE,MAAM,MAAiB,GAAG,WAAW,CAyB7F;AAED;;;;;;GAMG;AACH,wBAAgB,mBAAmB,CAAC,KAAK,GAAE,MAAM,MAAiB,GAAG,aAAa,CAqCjF;AAED;;;;;GAKG;AACH,wBAAgB,wBAAwB,CAAC,KAAK,GAAE,MAAM,MAAiB,GAAG,kBAAkB,CA+B3F"}
|
|
@@ -0,0 +1,133 @@
|
|
|
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
|
+
import { assertValid, validateRoles } from "@statewalker/httpeers-access";
|
|
10
|
+
/**
|
|
11
|
+
* Durable membership: who belongs to the mesh, and with what roles.
|
|
12
|
+
*
|
|
13
|
+
* `setRoles` validates its `roles` against `rules` before writing — "catch a
|
|
14
|
+
* typo where it is made, not three hops later as a silent denial" (the v0.9.0
|
|
15
|
+
* delta's own rationale). Since ADR-0019 there is no separate role registry to
|
|
16
|
+
* validate against: a role exists for this node exactly when some rule fires
|
|
17
|
+
* on it, which is what `validateRoles` reads. This is deliberately at the STORE,
|
|
18
|
+
* not at whichever call site happens to reach `setRoles` today: a guard
|
|
19
|
+
* placed at a call site is only as durable as that call site, and a later
|
|
20
|
+
* one added without knowing the guard belongs here would ship an
|
|
21
|
+
* unvalidated role write. `add` is NOT validated here — every caller that
|
|
22
|
+
* feeds it caller-supplied roles (e.g. invitation redemption) validates
|
|
23
|
+
* them earlier, at the point the roles were themselves accepted
|
|
24
|
+
* (`InvitationStore.create` in `apps/httpeers-stack`), matching the
|
|
25
|
+
* archived delta's own choice to guard `createInvitation` and `setRoles`,
|
|
26
|
+
* never `addMember`.
|
|
27
|
+
*/
|
|
28
|
+
export function createMemberStore(rules, clock = Date.now) {
|
|
29
|
+
const members = new Map();
|
|
30
|
+
return {
|
|
31
|
+
add(peerId, roles) {
|
|
32
|
+
const record = { peerId, roles: [...roles], updatedAt: clock() };
|
|
33
|
+
members.set(peerId, record);
|
|
34
|
+
return record;
|
|
35
|
+
},
|
|
36
|
+
setRoles(peerId, roles) {
|
|
37
|
+
assertValid(validateRoles(rules, roles, `setRoles(${peerId})`));
|
|
38
|
+
const record = { peerId, roles: [...roles], updatedAt: clock() };
|
|
39
|
+
members.set(peerId, record);
|
|
40
|
+
return record;
|
|
41
|
+
},
|
|
42
|
+
remove(peerId) {
|
|
43
|
+
members.delete(peerId);
|
|
44
|
+
},
|
|
45
|
+
get(peerId) {
|
|
46
|
+
return members.get(peerId);
|
|
47
|
+
},
|
|
48
|
+
list() {
|
|
49
|
+
return [...members.values()];
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* TTL'd, swept presence: who is live right now.
|
|
55
|
+
*
|
|
56
|
+
* Writes are monotonic per peer — see `PresenceStore` in types.ts for why.
|
|
57
|
+
* The comparison is always `seq` against the peer's own last-accepted
|
|
58
|
+
* `seq`, never wall-clock time and never across different peers.
|
|
59
|
+
*/
|
|
60
|
+
export function createPresenceStore(clock = Date.now) {
|
|
61
|
+
const presence = new Map();
|
|
62
|
+
return {
|
|
63
|
+
heartbeat(peerId, seq, ttlMs) {
|
|
64
|
+
const existing = presence.get(peerId);
|
|
65
|
+
if (existing !== undefined && seq <= existing.seq) {
|
|
66
|
+
return { accepted: false, reason: "stale-sequence" };
|
|
67
|
+
}
|
|
68
|
+
const record = { peerId, seq, expiresAt: clock() + ttlMs };
|
|
69
|
+
presence.set(peerId, record);
|
|
70
|
+
return { accepted: true, record };
|
|
71
|
+
},
|
|
72
|
+
isPresent(peerId) {
|
|
73
|
+
const record = presence.get(peerId);
|
|
74
|
+
return record !== undefined && record.expiresAt > clock();
|
|
75
|
+
},
|
|
76
|
+
get(peerId) {
|
|
77
|
+
return presence.get(peerId);
|
|
78
|
+
},
|
|
79
|
+
list() {
|
|
80
|
+
return [...presence.values()];
|
|
81
|
+
},
|
|
82
|
+
sweep() {
|
|
83
|
+
const now = clock();
|
|
84
|
+
const expired = [];
|
|
85
|
+
for (const record of presence.values()) {
|
|
86
|
+
if (record.expiresAt <= now) {
|
|
87
|
+
expired.push(record.peerId);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
for (const peerId of expired) {
|
|
91
|
+
presence.delete(peerId);
|
|
92
|
+
}
|
|
93
|
+
return expired;
|
|
94
|
+
},
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* The bulletin board: what peers offer. Keyed by `(peerId, key)` via a
|
|
99
|
+
* two-level `Map` — not a joined string — so there is no delimiter to pick
|
|
100
|
+
* and therefore no way for a `peerId` or `key` value to collide with one.
|
|
101
|
+
* Entries persist until withdrawn or replaced, not on a timer.
|
|
102
|
+
*/
|
|
103
|
+
export function createAdvertisementStore(clock = Date.now) {
|
|
104
|
+
const board = new Map();
|
|
105
|
+
return {
|
|
106
|
+
post(peerId, key, payload) {
|
|
107
|
+
const ad = { peerId, key, payload, postedAt: clock() };
|
|
108
|
+
let byKey = board.get(peerId);
|
|
109
|
+
if (byKey === undefined) {
|
|
110
|
+
byKey = new Map();
|
|
111
|
+
board.set(peerId, byKey);
|
|
112
|
+
}
|
|
113
|
+
byKey.set(key, ad);
|
|
114
|
+
return ad;
|
|
115
|
+
},
|
|
116
|
+
withdraw(peerId, key) {
|
|
117
|
+
board.get(peerId)?.delete(key);
|
|
118
|
+
},
|
|
119
|
+
get(peerId, key) {
|
|
120
|
+
return board.get(peerId)?.get(key);
|
|
121
|
+
},
|
|
122
|
+
list(peerId) {
|
|
123
|
+
if (peerId !== undefined) {
|
|
124
|
+
return [...(board.get(peerId)?.values() ?? [])];
|
|
125
|
+
}
|
|
126
|
+
const all = [];
|
|
127
|
+
for (const byKey of board.values()) {
|
|
128
|
+
all.push(...byKey.values());
|
|
129
|
+
}
|
|
130
|
+
return all;
|
|
131
|
+
},
|
|
132
|
+
};
|
|
133
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
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
|
+
import type { FilesApi } from "@statewalker/webrun-files";
|
|
20
|
+
import { type SnapshotStore } from "./hub-state.js";
|
|
21
|
+
/** The whole persistence contract. Three methods, because three is what the hub uses. */
|
|
22
|
+
export interface KeyValueStorage {
|
|
23
|
+
get(key: string): Promise<string | undefined>;
|
|
24
|
+
set(key: string, value: string): Promise<void>;
|
|
25
|
+
delete(key: string): Promise<void>;
|
|
26
|
+
}
|
|
27
|
+
/** For tests and for a hub that deliberately keeps nothing. */
|
|
28
|
+
export declare function memoryStorage(): KeyValueStorage;
|
|
29
|
+
/**
|
|
30
|
+
* A `FilesApi` as key/value storage — one file per key under `dir`.
|
|
31
|
+
*
|
|
32
|
+
* WRITE-THEN-MOVE, because a crash mid-write must not leave a half-written
|
|
33
|
+
* snapshot where the whole state lives. `persist.ts`'s Node implementation
|
|
34
|
+
* does NOT do this (it writes in place), which is a defect this adapter does
|
|
35
|
+
* not inherit.
|
|
36
|
+
*
|
|
37
|
+
* FilesApi HAS NO CONDITIONAL WRITE — no compare-and-set, no if-match. That
|
|
38
|
+
* is why the hub's single-writer precondition is a precondition rather than
|
|
39
|
+
* something the storage layer can enforce; rung 04's third claim measures
|
|
40
|
+
* exactly what that costs.
|
|
41
|
+
*/
|
|
42
|
+
export declare function filesStorage(files: FilesApi, dir?: string): KeyValueStorage;
|
|
43
|
+
export interface AsyncSnapshotStore extends SnapshotStore {
|
|
44
|
+
/** Resolves when every write issued so far has reached the backend. */
|
|
45
|
+
flushed(): Promise<void>;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Load once, then serve reads from memory and flush writes behind them —
|
|
49
|
+
* the shape `SnapshotStore`'s synchronous contract forces on any async
|
|
50
|
+
* backend.
|
|
51
|
+
*/
|
|
52
|
+
export declare function asyncSnapshotStore(storage: KeyValueStorage, key?: string): Promise<AsyncSnapshotStore>;
|
|
53
|
+
//# sourceMappingURL=storage.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"storage.d.ts","sourceRoot":"","sources":["../src/storage.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AAEH,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,2BAA2B,CAAC;AAC1D,OAAO,EAAoC,KAAK,aAAa,EAAE,MAAM,gBAAgB,CAAC;AAEtF,yFAAyF;AACzF,MAAM,WAAW,eAAe;IAC9B,GAAG,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,GAAG,SAAS,CAAC,CAAC;IAC9C,GAAG,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAC/C,MAAM,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CACpC;AAED,+DAA+D;AAC/D,wBAAgB,aAAa,IAAI,eAAe,CAa/C;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,YAAY,CAAC,KAAK,EAAE,QAAQ,EAAE,GAAG,SAAQ,GAAG,eAAe,CAkC1E;AAED,MAAM,WAAW,kBAAmB,SAAQ,aAAa;IACvD,uEAAuE;IACvE,OAAO,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;CAC1B;AAED;;;;GAIG;AACH,wBAAsB,kBAAkB,CACtC,OAAO,EAAE,eAAe,EACxB,GAAG,SAAa,GACf,OAAO,CAAC,kBAAkB,CAAC,CA4B7B"}
|
package/dist/storage.js
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
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
|
+
import { EMPTY_SNAPSHOT } from "./hub-state.js";
|
|
20
|
+
/** For tests and for a hub that deliberately keeps nothing. */
|
|
21
|
+
export function memoryStorage() {
|
|
22
|
+
const map = new Map();
|
|
23
|
+
return {
|
|
24
|
+
async get(key) {
|
|
25
|
+
return map.get(key);
|
|
26
|
+
},
|
|
27
|
+
async set(key, value) {
|
|
28
|
+
map.set(key, value);
|
|
29
|
+
},
|
|
30
|
+
async delete(key) {
|
|
31
|
+
map.delete(key);
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* A `FilesApi` as key/value storage — one file per key under `dir`.
|
|
37
|
+
*
|
|
38
|
+
* WRITE-THEN-MOVE, because a crash mid-write must not leave a half-written
|
|
39
|
+
* snapshot where the whole state lives. `persist.ts`'s Node implementation
|
|
40
|
+
* does NOT do this (it writes in place), which is a defect this adapter does
|
|
41
|
+
* not inherit.
|
|
42
|
+
*
|
|
43
|
+
* FilesApi HAS NO CONDITIONAL WRITE — no compare-and-set, no if-match. That
|
|
44
|
+
* is why the hub's single-writer precondition is a precondition rather than
|
|
45
|
+
* something the storage layer can enforce; rung 04's third claim measures
|
|
46
|
+
* exactly what that costs.
|
|
47
|
+
*/
|
|
48
|
+
export function filesStorage(files, dir = "hub") {
|
|
49
|
+
const path = (key) => `${dir}/${encodeURIComponent(key)}.json`;
|
|
50
|
+
return {
|
|
51
|
+
async get(key) {
|
|
52
|
+
const file = path(key);
|
|
53
|
+
if (!(await files.exists(file)))
|
|
54
|
+
return undefined;
|
|
55
|
+
const chunks = [];
|
|
56
|
+
for await (const chunk of files.read(file))
|
|
57
|
+
chunks.push(chunk);
|
|
58
|
+
const total = chunks.reduce((n, c) => n + c.length, 0);
|
|
59
|
+
const bytes = new Uint8Array(total);
|
|
60
|
+
let at = 0;
|
|
61
|
+
for (const chunk of chunks) {
|
|
62
|
+
bytes.set(chunk, at);
|
|
63
|
+
at += chunk.length;
|
|
64
|
+
}
|
|
65
|
+
return new TextDecoder().decode(bytes);
|
|
66
|
+
},
|
|
67
|
+
async set(key, value) {
|
|
68
|
+
const file = path(key);
|
|
69
|
+
const temp = `${file}.tmp`;
|
|
70
|
+
const bytes = new TextEncoder().encode(value);
|
|
71
|
+
await files.write(temp, (async function* () {
|
|
72
|
+
yield bytes;
|
|
73
|
+
})());
|
|
74
|
+
await files.move(temp, file);
|
|
75
|
+
},
|
|
76
|
+
async delete(key) {
|
|
77
|
+
await files.remove(path(key));
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Load once, then serve reads from memory and flush writes behind them —
|
|
83
|
+
* the shape `SnapshotStore`'s synchronous contract forces on any async
|
|
84
|
+
* backend.
|
|
85
|
+
*/
|
|
86
|
+
export async function asyncSnapshotStore(storage, key = "snapshot") {
|
|
87
|
+
const raw = await storage.get(key);
|
|
88
|
+
let current = EMPTY_SNAPSHOT;
|
|
89
|
+
if (raw != null) {
|
|
90
|
+
try {
|
|
91
|
+
current = JSON.parse(raw);
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
// A corrupt value reads as empty rather than throwing, matching
|
|
95
|
+
// `browser/snapshot-store.ts`. A hub that refuses to start because its
|
|
96
|
+
// state file was truncated is worse than one that starts empty and
|
|
97
|
+
// says so.
|
|
98
|
+
current = EMPTY_SNAPSHOT;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
// Writes are chained, never concurrent: two overlapping flushes of the same
|
|
102
|
+
// key can land out of order, and the loser would be the newer state.
|
|
103
|
+
let chain = Promise.resolve();
|
|
104
|
+
return {
|
|
105
|
+
read: () => current,
|
|
106
|
+
write(snapshot) {
|
|
107
|
+
current = snapshot;
|
|
108
|
+
const value = JSON.stringify(snapshot);
|
|
109
|
+
chain = chain.then(() => storage.set(key, value)).catch(() => { });
|
|
110
|
+
},
|
|
111
|
+
flushed: () => chain,
|
|
112
|
+
};
|
|
113
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@statewalker/httpeers-hub",
|
|
3
|
+
"version": "0.1.3",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Minting membership, holding the registries, and saying who is in the mesh.",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+ssh://git@github.com/statewalker/httpeers.git",
|
|
10
|
+
"directory": "packages/httpeers-hub"
|
|
11
|
+
},
|
|
12
|
+
"main": "./dist/index.js",
|
|
13
|
+
"types": "./dist/index.d.ts",
|
|
14
|
+
"exports": {
|
|
15
|
+
".": {
|
|
16
|
+
"source": "./src/index.ts",
|
|
17
|
+
"types": "./dist/index.d.ts",
|
|
18
|
+
"import": "./dist/index.js"
|
|
19
|
+
},
|
|
20
|
+
"./node": {
|
|
21
|
+
"source": "./src/node.ts",
|
|
22
|
+
"types": "./dist/node.d.ts",
|
|
23
|
+
"import": "./dist/node.js"
|
|
24
|
+
},
|
|
25
|
+
"./browser": {
|
|
26
|
+
"source": "./src/browser.ts",
|
|
27
|
+
"types": "./dist/browser.d.ts",
|
|
28
|
+
"import": "./dist/browser.js"
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
"files": [
|
|
32
|
+
"dist",
|
|
33
|
+
"src"
|
|
34
|
+
],
|
|
35
|
+
"sideEffects": false,
|
|
36
|
+
"dependencies": {
|
|
37
|
+
"hono": "^4.13.12",
|
|
38
|
+
"@statewalker/httpeers-access": "^0.1.3",
|
|
39
|
+
"@statewalker/httpeers-core": "^0.1.2"
|
|
40
|
+
},
|
|
41
|
+
"peerDependencies": {
|
|
42
|
+
"@statewalker/webrun-files": "^0.9.0 || ^0.10.0"
|
|
43
|
+
},
|
|
44
|
+
"peerDependenciesMeta": {
|
|
45
|
+
"@statewalker/webrun-files": {
|
|
46
|
+
"optional": true
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
"devDependencies": {
|
|
50
|
+
"@biomejs/biome": "^2.5.15",
|
|
51
|
+
"@statewalker/webrun-files": "^0.10.0",
|
|
52
|
+
"@types/node": "^26.6.4",
|
|
53
|
+
"typescript": "^7.0.2",
|
|
54
|
+
"vitest": "^5.0.3"
|
|
55
|
+
},
|
|
56
|
+
"publishConfig": {
|
|
57
|
+
"access": "public"
|
|
58
|
+
},
|
|
59
|
+
"scripts": {
|
|
60
|
+
"build": "tsc -p tsconfig.build.json",
|
|
61
|
+
"test": "pnpm run build && pnpm run typecheck && vitest run",
|
|
62
|
+
"typecheck": "tsc --noEmit",
|
|
63
|
+
"lint": "biome check src tests",
|
|
64
|
+
"lint:check": "biome check src tests"
|
|
65
|
+
}
|
|
66
|
+
}
|
package/src/admin.ts
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `DELETE /admin/members/{peerId}` — closes the gap the design record has
|
|
3
|
+
* carried since Task 1: `MemberStore.remove` has existed as a store method
|
|
4
|
+
* with no endpoint and no authorisation the whole time, "exactly the kind
|
|
5
|
+
* of thing that must not be reachable by an ordinary member" (design
|
|
6
|
+
* record §5.2). Capability gating (`std:mesh.admin`) is `policy.ts`'s
|
|
7
|
+
* `/admin` policy in `policy.ts`, not this handler's job — this file only
|
|
8
|
+
* does the removal, same split as every other handler in this app.
|
|
9
|
+
*
|
|
10
|
+
* TWO CALLS, NOT ONE, BECAUSE THIS PACKAGE SPLIT WHAT THE ARCHIVE KEPT AS
|
|
11
|
+
* ONE STORE. `hub.memberStore.remove(peerId)` drops membership;
|
|
12
|
+
* `hub.revocations.revoke(peerId)` records the change so a provider's
|
|
13
|
+
* pulled deny-list cache can see it — see `tests/support/mesh.ts`'s own
|
|
14
|
+
* header comment for the archived one-call precedent this splits in two.
|
|
15
|
+
* `RevocationRegistry.revoke` bumps `policyVersion()` internally (see
|
|
16
|
+
* `revocation.ts`): there is no separate "bump the version" step to
|
|
17
|
+
* forget — calling `revoke` at all IS the bump, which is what makes a
|
|
18
|
+
* provider's next heartbeat see the change.
|
|
19
|
+
*
|
|
20
|
+
* Removal (and revocation) is unconditional and idempotent: `MemberStore
|
|
21
|
+
* .remove` on a peerId that is not currently a member is a harmless no-op
|
|
22
|
+
* (`Map.delete` on a missing key), and revoking a peerId with no prior
|
|
23
|
+
* membership is a legitimate "make sure this identity can never redeem an
|
|
24
|
+
* old token" operation, not an error condition — so this handler does not
|
|
25
|
+
* 404 on an unknown peerId.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import type { RevocationRegistry } from "@statewalker/httpeers-access/issuer";
|
|
29
|
+
import type { FetchHandler, MemberStore } from "@statewalker/httpeers-core";
|
|
30
|
+
import { json } from "@statewalker/httpeers-core";
|
|
31
|
+
import { Hono } from "hono";
|
|
32
|
+
|
|
33
|
+
export interface AdminEndpointsInit {
|
|
34
|
+
memberStore: MemberStore;
|
|
35
|
+
revocations: RevocationRegistry;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** `DELETE /admin/members/{peerId}`. Mounted at `/admin/members` — see `hub/endpoints.ts`. */
|
|
39
|
+
export function createAdminEndpoints(init: AdminEndpointsInit): FetchHandler {
|
|
40
|
+
const app = new Hono();
|
|
41
|
+
|
|
42
|
+
app.delete("/admin/members/:peerId", (c) => {
|
|
43
|
+
const peerId = c.req.param("peerId");
|
|
44
|
+
init.memberStore.remove(peerId);
|
|
45
|
+
init.revocations.revoke(peerId);
|
|
46
|
+
return json({ ok: true, removed: peerId, policyVersion: init.revocations.policyVersion() });
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
return app.fetch as FetchHandler;
|
|
50
|
+
}
|
package/src/browser.ts
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The browser profile: hub state in IndexedDB.
|
|
3
|
+
*
|
|
4
|
+
* A hub in a tab is a real hub — it mints, it holds the registries, and its
|
|
5
|
+
* members expect to still be members after a reload. `localStorage` would do
|
|
6
|
+
* for strings, but IndexedDB is transactional, which matters when two writes
|
|
7
|
+
* race a reload.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { KeyValueStorage } from "./storage.js";
|
|
11
|
+
|
|
12
|
+
type IdbRequest<T> = {
|
|
13
|
+
result: T;
|
|
14
|
+
error: unknown;
|
|
15
|
+
onsuccess: (() => void) | null;
|
|
16
|
+
onerror: (() => void) | null;
|
|
17
|
+
onupgradeneeded?: (() => void) | null;
|
|
18
|
+
};
|
|
19
|
+
type IdbStore = {
|
|
20
|
+
get(key: string): IdbRequest<string | undefined>;
|
|
21
|
+
put(value: string, key: string): IdbRequest<unknown>;
|
|
22
|
+
delete(key: string): IdbRequest<unknown>;
|
|
23
|
+
};
|
|
24
|
+
type IdbDatabase = {
|
|
25
|
+
objectStoreNames: { contains(name: string): boolean };
|
|
26
|
+
createObjectStore(name: string): unknown;
|
|
27
|
+
transaction(
|
|
28
|
+
name: string,
|
|
29
|
+
mode: "readonly" | "readwrite",
|
|
30
|
+
): { objectStore(name: string): IdbStore };
|
|
31
|
+
close(): void;
|
|
32
|
+
};
|
|
33
|
+
declare const indexedDB: { open(name: string, version: number): IdbRequest<IdbDatabase> };
|
|
34
|
+
|
|
35
|
+
export function idbStorage(name = "httpeers-hub", store = "state"): KeyValueStorage {
|
|
36
|
+
const open = (): Promise<IdbDatabase> =>
|
|
37
|
+
new Promise((resolve, reject) => {
|
|
38
|
+
const request = indexedDB.open(name, 1);
|
|
39
|
+
request.onupgradeneeded = () => {
|
|
40
|
+
if (!request.result.objectStoreNames.contains(store)) {
|
|
41
|
+
request.result.createObjectStore(store);
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
request.onsuccess = () => resolve(request.result);
|
|
45
|
+
request.onerror = () => reject(request.error);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
const run = async <T>(
|
|
49
|
+
mode: "readonly" | "readwrite",
|
|
50
|
+
act: (s: IdbStore) => IdbRequest<unknown>,
|
|
51
|
+
): Promise<T> => {
|
|
52
|
+
const db = await open();
|
|
53
|
+
try {
|
|
54
|
+
return await new Promise<T>((resolve, reject) => {
|
|
55
|
+
const request = act(db.transaction(store, mode).objectStore(store));
|
|
56
|
+
request.onsuccess = () => resolve(request.result as T);
|
|
57
|
+
request.onerror = () => reject(request.error);
|
|
58
|
+
});
|
|
59
|
+
} finally {
|
|
60
|
+
db.close();
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
return {
|
|
65
|
+
get: (key) => run<string | undefined>("readonly", (s) => s.get(key)),
|
|
66
|
+
set: async (key, value) => {
|
|
67
|
+
await run("readwrite", (s) => s.put(value, key));
|
|
68
|
+
},
|
|
69
|
+
delete: async (key) => {
|
|
70
|
+
await run("readwrite", (s) => s.delete(key));
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
}
|