@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/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"}
@@ -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
+ }