@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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Mikhail Kotelnikov
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,108 @@
1
+ # @statewalker/httpeers-hub
2
+
3
+ Minting membership, holding the registries, and saying who is in the mesh.
4
+
5
+ ```ts
6
+ import { createHub, memoryStorage } from "@statewalker/httpeers-hub";
7
+
8
+ const hub = await createHub({
9
+ selfPeerId, // this hub's peerId IS the mesh's name
10
+ policies: rules,
11
+ storage: memoryStorage(),
12
+ mintToken: async (sub, roles) => mintToken({ signer, sub, roles, ttlMs: 60_000 }),
13
+ });
14
+
15
+ const invite = await hub.invitations.create(["member"], 60_000);
16
+ ```
17
+
18
+ `hub.mounts` is a mount table. Serving it on a wire is somebody else's job.
19
+
20
+ ## A hub has no transport, and that is what lets it run in a tab
21
+
22
+ This package dials nothing and listens on nothing. `servePeer`
23
+ (`@statewalker/httpeers-libp2p`) is what puts the mount table on a wire, and
24
+ the dependency list here is `core`, `access` and `hono` — no libp2p, asserted
25
+ by `tests/boundary.test.ts`.
26
+
27
+ That is not tidiness. It is the property that makes a **hub in a browser tab**
28
+ possible at all, and it is why the hub's *lifecycle* is deliberately not in
29
+ this package: composing a node, a relay reservation, `createHub` and an edge is
30
+ application wiring, and both the Node hub and the hub page do it themselves.
31
+ `httpeers-conformance` compiles that composition as a consumer, so if the
32
+ packages ever stop being sufficient for it, a test says so.
33
+
34
+ ## Invitations are bearer credentials, and are treated as such
35
+
36
+ An invitation id is **128 bits from the platform CSPRNG**, never `Math.random`:
37
+ whoever holds one can become a member. The hub generates it **by default**, so
38
+ nobody has to invent unguessable strings — `create(roles, ttlMs)` is the whole
39
+ call. A caller may name the id (`{ id }`) for a deployment migrating existing
40
+ codes or a test wanting a constant, and that is the only way to get a weak one.
41
+
42
+ Every mutator resolves when the write is **durable**. A caller is never told
43
+ "created" before it is, because an invitation handed out over the phone and
44
+ then lost to a crash a millisecond later is an invitation somebody is standing
45
+ there holding.
46
+
47
+ Unredeemed invitations **survive a restart** — they are in the snapshot, not in
48
+ memory — and `pending()` lists them so an operator can see what is outstanding.
49
+
50
+ ## The snapshot is synchronous and the storage is not
51
+
52
+ `SnapshotStore.write` must stay synchronous: `createHubState` calls it and
53
+ returns, so an async write would open a window where a caller has been told a
54
+ member was added while the snapshot still says otherwise. There is no way to
55
+ block on a promise in a browser, so the shape is forced:
56
+
57
+ **the in-memory copy is authoritative, and the backend trails it.** `write`
58
+ replaces `current` and returns; the flush runs behind it on one promise chain.
59
+ Reads answer from `current`, so the hub always sees what it last wrote.
60
+
61
+ The chain matters: writes reach storage **in order**. An unordered
62
+ `void put(...)` per write would let a slow earlier write land on top of a later
63
+ one and **resurrect a spent invitation id**, which is a membership bypass.
64
+ `asyncSnapshotStore` is that wrapper over any `KeyValueStorage`, and
65
+ `flushed()` is how a test waits for durability.
66
+
67
+ Honestly stated: a tab closed between a write and its flush loses that write.
68
+ This is a page, not a database.
69
+
70
+ | Import | Storage |
71
+ |---|---|
72
+ | `.` | `memoryStorage()`, `filesStorage(files, dir?)` over a `FilesApi` |
73
+ | `./node` | `fileStorage(path)` |
74
+ | `./browser` | `idbStorage(name?, store?)` — IndexedDB, which is transactional |
75
+
76
+ `filesStorage` **writes-then-moves**. The prototype's Node persistence wrote in
77
+ place, so a crash mid-write left a half-written snapshot where the whole state
78
+ lives; this adapter does not inherit that.
79
+
80
+ ## Roles come off the rules, not a second list
81
+
82
+ There is no separate vocabulary document. A role exists for this mesh exactly
83
+ when some rule fires on it, so `roleNames(rules)` is the registry, and both
84
+ `members.setRoles` and `invitations.create` validate against it. A role added
85
+ to the policy appears everywhere without anyone updating a second place.
86
+
87
+ ## Changing roles takes two writes, or it is a lie
88
+
89
+ `MemberStore.setRoles` rewrites the record — but the peer is carrying a token
90
+ that already states its **old** roles and stays valid until it expires, so a
91
+ demoted admin keeps admin for the life of that token. `revocations.changeRoles`
92
+ records the change so tokens minted before it stop verifying, which is what
93
+ makes the new roles take effect on the peer's next heartbeat.
94
+
95
+ The store does not do this for you. Both halves, or the change is cosmetic.
96
+
97
+ ## Presence is a heartbeat
98
+
99
+ A member that stops beating is swept from the mesh view within one TTL, and its
100
+ advertisements go with it. `sweep()` is callable directly — which is what a
101
+ test with a controlled clock wants — or driven on a timer with
102
+ `sweepIntervalMs`.
103
+
104
+ ## Tests
105
+
106
+ **43, plus 4 skipped** (the platform-entry exemptions in the boundary test).
107
+ The hub is also exercised for real in `@statewalker/httpeers-conformance`,
108
+ where it mints an invitation that a live member redeems over a relay.
@@ -0,0 +1,35 @@
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
+ import type { RevocationRegistry } from "@statewalker/httpeers-access/issuer";
28
+ import type { FetchHandler, MemberStore } from "@statewalker/httpeers-core";
29
+ export interface AdminEndpointsInit {
30
+ memberStore: MemberStore;
31
+ revocations: RevocationRegistry;
32
+ }
33
+ /** `DELETE /admin/members/{peerId}`. Mounted at `/admin/members` — see `hub/endpoints.ts`. */
34
+ export declare function createAdminEndpoints(init: AdminEndpointsInit): FetchHandler;
35
+ //# sourceMappingURL=admin.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"admin.d.ts","sourceRoot":"","sources":["../src/admin.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AAEH,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,qCAAqC,CAAC;AAC9E,OAAO,KAAK,EAAE,YAAY,EAAE,WAAW,EAAE,MAAM,4BAA4B,CAAC;AAI5E,MAAM,WAAW,kBAAkB;IACjC,WAAW,EAAE,WAAW,CAAC;IACzB,WAAW,EAAE,kBAAkB,CAAC;CACjC;AAED,8FAA8F;AAC9F,wBAAgB,oBAAoB,CAAC,IAAI,EAAE,kBAAkB,GAAG,YAAY,CAW3E"}
package/dist/admin.js ADDED
@@ -0,0 +1,39 @@
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
+ import { json } from "@statewalker/httpeers-core";
28
+ import { Hono } from "hono";
29
+ /** `DELETE /admin/members/{peerId}`. Mounted at `/admin/members` — see `hub/endpoints.ts`. */
30
+ export function createAdminEndpoints(init) {
31
+ const app = new Hono();
32
+ app.delete("/admin/members/:peerId", (c) => {
33
+ const peerId = c.req.param("peerId");
34
+ init.memberStore.remove(peerId);
35
+ init.revocations.revoke(peerId);
36
+ return json({ ok: true, removed: peerId, policyVersion: init.revocations.policyVersion() });
37
+ });
38
+ return app.fetch;
39
+ }
@@ -0,0 +1,11 @@
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
+ import type { KeyValueStorage } from "./storage.js";
10
+ export declare function idbStorage(name?: string, store?: string): KeyValueStorage;
11
+ //# sourceMappingURL=browser.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"browser.d.ts","sourceRoot":"","sources":["../src/browser.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,cAAc,CAAC;AAyBpD,wBAAgB,UAAU,CAAC,IAAI,SAAiB,EAAE,KAAK,SAAU,GAAG,eAAe,CAsClF"}
@@ -0,0 +1,42 @@
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
+ export function idbStorage(name = "httpeers-hub", store = "state") {
10
+ const open = () => new Promise((resolve, reject) => {
11
+ const request = indexedDB.open(name, 1);
12
+ request.onupgradeneeded = () => {
13
+ if (!request.result.objectStoreNames.contains(store)) {
14
+ request.result.createObjectStore(store);
15
+ }
16
+ };
17
+ request.onsuccess = () => resolve(request.result);
18
+ request.onerror = () => reject(request.error);
19
+ });
20
+ const run = async (mode, act) => {
21
+ const db = await open();
22
+ try {
23
+ return await new Promise((resolve, reject) => {
24
+ const request = act(db.transaction(store, mode).objectStore(store));
25
+ request.onsuccess = () => resolve(request.result);
26
+ request.onerror = () => reject(request.error);
27
+ });
28
+ }
29
+ finally {
30
+ db.close();
31
+ }
32
+ };
33
+ return {
34
+ get: (key) => run("readonly", (s) => s.get(key)),
35
+ set: async (key, value) => {
36
+ await run("readwrite", (s) => s.put(value, key));
37
+ },
38
+ delete: async (key) => {
39
+ await run("readwrite", (s) => s.delete(key));
40
+ },
41
+ };
42
+ }
@@ -0,0 +1,90 @@
1
+ /**
2
+ * One constructor that returns the parts.
3
+ *
4
+ * THE API SPEC SAID THERE WOULD NOT BE A `createHub`, and the prototype proved
5
+ * it wrong by writing the assembly three times — the Node hub, the browser hub
6
+ * and the test hub. A hub is not a mount table: it is a mount table *plus* a
7
+ * snapshot store, a clock, a revocation registry, an invitation store, two
8
+ * registries with different consistency needs, and a sweep timer. Anything that
9
+ * hands back only the mounts leaves the other six to every caller.
10
+ *
11
+ * `mounts` is still separately usable, so nothing is walled off — the point is
12
+ * to stop each assembly re-deriving the wiring, not to hide it.
13
+ *
14
+ * THE ORDERING CYCLE, closed here because it cannot be closed by the caller.
15
+ * A hub's node must exist before `createHub` (the node needs a key; this needs
16
+ * `selfPeerId`), and the node's connection gater needs `hub.isMember`. So the
17
+ * documented order is:
18
+ *
19
+ * key -> node with a LATE-BOUND gater -> createHub -> servePeer
20
+ * -> point the gater's thunk at hub.isMember
21
+ *
22
+ * `membershipGater` takes a thunk for exactly this reason; see its comment in
23
+ * `httpeers-libp2p`.
24
+ */
25
+ import type { RuleSet } from "@statewalker/httpeers-access";
26
+ import { RevocationRegistry } from "@statewalker/httpeers-access/issuer";
27
+ import type { FetchHandler, MeshView, Mounts, PeerIdStr } from "@statewalker/httpeers-core";
28
+ import { type HubEndpointsInit } from "./endpoints.js";
29
+ import { type InvitationRecord, type InvitationRedemption, type PersistentHub } from "./hub-state.js";
30
+ import { type KeyValueStorage } from "./storage.js";
31
+ export interface CreateHubInit {
32
+ /** This hub's own peerId — the mesh identity every token restates. */
33
+ selfPeerId: PeerIdStr;
34
+ /** Mint a membership token that self-certifies as this mesh. */
35
+ mintToken: HubEndpointsInit["mintToken"];
36
+ /** Where members and spent invitation ids are persisted. Defaults to memory. */
37
+ storage?: KeyValueStorage;
38
+ /** This mesh's Datalog rules. Injected, never global — there is no default rule set. */
39
+ policies: RuleSet;
40
+ now?: () => number;
41
+ presenceTtlMs?: number;
42
+ /**
43
+ * Expire stale presence on a timer. Omit to drive `sweep()` yourself, which
44
+ * is what a test with a controlled clock wants.
45
+ */
46
+ sweepIntervalMs?: number;
47
+ extraMounts?: Record<string, FetchHandler>;
48
+ ownAdvertisements?: Array<{
49
+ id: string;
50
+ kind: string;
51
+ title: string;
52
+ }>;
53
+ advertisementAccess?: Record<string, string>;
54
+ /** Longest life of any token this hub mints. Sets the revocation pruning horizon. */
55
+ maxTokenTtlMs?: number;
56
+ }
57
+ export interface Hub {
58
+ /** The `.well-known` surface and `/admin`, plus whatever `extraMounts` added. */
59
+ mounts: Mounts;
60
+ /** Expire stale presence and the advertisements that rode along with it. */
61
+ sweep(): void;
62
+ /** The very view `GET /.well-known/mesh` serves, read in-process. */
63
+ meshView(caller?: PeerIdStr): MeshView;
64
+ /**
65
+ * The operator-facing shape (spec §9), which differs from the store's:
66
+ * the hub generates the id, and every mutator resolves when the write is
67
+ * DURABLE — so a caller is never told "created" before it is, and an
68
+ * invitation handed out over the phone cannot be lost to a crash a
69
+ * millisecond later.
70
+ */
71
+ invitations: {
72
+ create(roles: string[], ttlMs: number, opts?: {
73
+ id?: string;
74
+ }): Promise<{
75
+ id: string;
76
+ expiresAt: number;
77
+ }>;
78
+ redeem(id: string): Promise<InvitationRedemption>;
79
+ status(id: string): "unspent" | "redeemed" | "expired" | "unknown";
80
+ pending(): InvitationRecord[];
81
+ };
82
+ members: PersistentHub["memberStore"];
83
+ isMember(peerId: PeerIdStr): boolean;
84
+ revocations: RevocationRegistry;
85
+ rules: RuleSet;
86
+ reset(): Promise<void>;
87
+ stop(): Promise<void>;
88
+ }
89
+ export declare function createHub(init: CreateHubInit): Promise<Hub>;
90
+ //# sourceMappingURL=create-hub.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"create-hub.d.ts","sourceRoot":"","sources":["../src/create-hub.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AAEH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,8BAA8B,CAAC;AAC5D,OAAO,EAAE,kBAAkB,EAAE,MAAM,qCAAqC,CAAC;AACzE,OAAO,KAAK,EAAE,YAAY,EAAE,QAAQ,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,4BAA4B,CAAC;AAC5F,OAAO,EAAsB,KAAK,gBAAgB,EAAE,MAAM,gBAAgB,CAAC;AAC3E,OAAO,EAEL,KAAK,gBAAgB,EACrB,KAAK,oBAAoB,EACzB,KAAK,aAAa,EACnB,MAAM,gBAAgB,CAAC;AAExB,OAAO,EAAsB,KAAK,eAAe,EAAiB,MAAM,cAAc,CAAC;AAEvF,MAAM,WAAW,aAAa;IAC5B,sEAAsE;IACtE,UAAU,EAAE,SAAS,CAAC;IACtB,gEAAgE;IAChE,SAAS,EAAE,gBAAgB,CAAC,WAAW,CAAC,CAAC;IACzC,gFAAgF;IAChF,OAAO,CAAC,EAAE,eAAe,CAAC;IAC1B,wFAAwF;IACxF,QAAQ,EAAE,OAAO,CAAC;IAClB,GAAG,CAAC,EAAE,MAAM,MAAM,CAAC;IACnB,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB;;;OAGG;IACH,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,WAAW,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,YAAY,CAAC,CAAC;IAC3C,iBAAiB,CAAC,EAAE,KAAK,CAAC;QAAE,EAAE,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IACvE,mBAAmB,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAC7C,qFAAqF;IACrF,aAAa,CAAC,EAAE,MAAM,CAAC;CACxB;AAED,MAAM,WAAW,GAAG;IAClB,iFAAiF;IACjF,MAAM,EAAE,MAAM,CAAC;IACf,4EAA4E;IAC5E,KAAK,IAAI,IAAI,CAAC;IACd,qEAAqE;IACrE,QAAQ,CAAC,MAAM,CAAC,EAAE,SAAS,GAAG,QAAQ,CAAC;IACvC;;;;;;OAMG;IACH,WAAW,EAAE;QACX,MAAM,CACJ,KAAK,EAAE,MAAM,EAAE,EACf,KAAK,EAAE,MAAM,EACb,IAAI,CAAC,EAAE;YAAE,EAAE,CAAC,EAAE,MAAM,CAAA;SAAE,GACrB,OAAO,CAAC;YAAE,EAAE,EAAE,MAAM,CAAC;YAAC,SAAS,EAAE,MAAM,CAAA;SAAE,CAAC,CAAC;QAC9C,MAAM,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,oBAAoB,CAAC,CAAC;QAClD,MAAM,CAAC,EAAE,EAAE,MAAM,GAAG,SAAS,GAAG,UAAU,GAAG,SAAS,GAAG,SAAS,CAAC;QACnE,OAAO,IAAI,gBAAgB,EAAE,CAAC;KAC/B,CAAC;IACF,OAAO,EAAE,aAAa,CAAC,aAAa,CAAC,CAAC;IACtC,QAAQ,CAAC,MAAM,EAAE,SAAS,GAAG,OAAO,CAAC;IACrC,WAAW,EAAE,kBAAkB,CAAC;IAChC,KAAK,EAAE,OAAO,CAAC;IACf,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;IACvB,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;CACvB;AAeD,wBAAsB,SAAS,CAAC,IAAI,EAAE,aAAa,GAAG,OAAO,CAAC,GAAG,CAAC,CA6FjE"}
@@ -0,0 +1,132 @@
1
+ /**
2
+ * One constructor that returns the parts.
3
+ *
4
+ * THE API SPEC SAID THERE WOULD NOT BE A `createHub`, and the prototype proved
5
+ * it wrong by writing the assembly three times — the Node hub, the browser hub
6
+ * and the test hub. A hub is not a mount table: it is a mount table *plus* a
7
+ * snapshot store, a clock, a revocation registry, an invitation store, two
8
+ * registries with different consistency needs, and a sweep timer. Anything that
9
+ * hands back only the mounts leaves the other six to every caller.
10
+ *
11
+ * `mounts` is still separately usable, so nothing is walled off — the point is
12
+ * to stop each assembly re-deriving the wiring, not to hide it.
13
+ *
14
+ * THE ORDERING CYCLE, closed here because it cannot be closed by the caller.
15
+ * A hub's node must exist before `createHub` (the node needs a key; this needs
16
+ * `selfPeerId`), and the node's connection gater needs `hub.isMember`. So the
17
+ * documented order is:
18
+ *
19
+ * key -> node with a LATE-BOUND gater -> createHub -> servePeer
20
+ * -> point the gater's thunk at hub.isMember
21
+ *
22
+ * `membershipGater` takes a thunk for exactly this reason; see its comment in
23
+ * `httpeers-libp2p`.
24
+ */
25
+ import { RevocationRegistry } from "@statewalker/httpeers-access/issuer";
26
+ import { createHubEndpoints } from "./endpoints.js";
27
+ import { createHubState, } from "./hub-state.js";
28
+ import { createMemberStore } from "./registries.js";
29
+ import { asyncSnapshotStore, memoryStorage } from "./storage.js";
30
+ /**
31
+ * An invitation id.
32
+ *
33
+ * 128 bits from the platform CSPRNG, base36. An invitation is a bearer
34
+ * credential for membership, so a guessable id is a way into the mesh —
35
+ * `Math.random` is not acceptable here and the crypto global is available on
36
+ * every runtime this package targets.
37
+ */
38
+ function newInvitationId() {
39
+ const bytes = crypto.getRandomValues(new Uint8Array(16));
40
+ return [...bytes].map((b) => b.toString(16).padStart(2, "0")).join("");
41
+ }
42
+ export async function createHub(init) {
43
+ const now = init.now ?? Date.now;
44
+ const storage = init.storage ?? memoryStorage();
45
+ // Storage is async; the snapshot seam is not. An in-memory authoritative
46
+ // copy with serialised flushes behind it is what lets `SnapshotStore.write`
47
+ // stay synchronous while the durable write is still awaited by whoever
48
+ // mutated — so a caller is never told "added" before it is.
49
+ const snapshots = await asyncSnapshotStore(storage);
50
+ const state = createHubState({
51
+ store: snapshots,
52
+ rules: init.policies,
53
+ now,
54
+ createMemberStore,
55
+ });
56
+ const revocations = new RevocationRegistry({
57
+ maxTokenTtlMs: init.maxTokenTtlMs ?? 24 * 60 * 60 * 1000,
58
+ now,
59
+ });
60
+ const endpoints = createHubEndpoints({
61
+ selfPeerId: init.selfPeerId,
62
+ mintToken: init.mintToken,
63
+ memberStore: state.memberStore,
64
+ invitations: state.invitations,
65
+ rules: init.policies,
66
+ revocations,
67
+ advertisementAccess: init.advertisementAccess,
68
+ extraMounts: init.extraMounts,
69
+ ownAdvertisements: init.ownAdvertisements,
70
+ presenceTtlMs: init.presenceTtlMs,
71
+ now,
72
+ });
73
+ // The timer is optional so a test can drive `sweep()` with a controlled
74
+ // clock. A hub that also sweeps on a real timer would race that test.
75
+ let timer;
76
+ if (init.sweepIntervalMs != null) {
77
+ timer = setInterval(() => endpoints.sweep(), init.sweepIntervalMs);
78
+ // Never hold a process open for a sweep. A CLI that finished its work
79
+ // should exit, and an interval that keeps it alive is a hang nobody
80
+ // attributes to the hub.
81
+ timer.unref?.();
82
+ }
83
+ let stopped = false;
84
+ return {
85
+ mounts: endpoints.mounts,
86
+ sweep: endpoints.sweep,
87
+ meshView: endpoints.meshView,
88
+ invitations: {
89
+ async create(roles, ttlMs, opts) {
90
+ // A caller may name the id — a deployment migrating existing codes, or
91
+ // a test wanting a constant — but the hub generates one by default so
92
+ // nobody has to invent unguessable strings.
93
+ const id = opts?.id ?? newInvitationId();
94
+ const record = state.invitations.create(id, roles, ttlMs);
95
+ await snapshots.flushed();
96
+ return { id: record.id, expiresAt: record.expiresAt };
97
+ },
98
+ async redeem(id) {
99
+ const result = state.invitations.redeem(id);
100
+ // Flush even on refusal: a redemption that FAILED still may have
101
+ // consumed nothing, but one that succeeded must be durable before the
102
+ // caller is told it is a member.
103
+ await snapshots.flushed();
104
+ return result;
105
+ },
106
+ status: (id) => state.invitations.status(id),
107
+ pending: () => state.invitations.pending(),
108
+ },
109
+ members: state.memberStore,
110
+ isMember: (peerId) => state.memberStore.get(peerId) !== undefined,
111
+ revocations,
112
+ rules: init.policies,
113
+ async reset() {
114
+ // Forget every member and every spent invitation id. The next start
115
+ // founds a fresh mesh under the SAME peerId — which is the one thing
116
+ // this cannot undo, and why it is a separate call rather than something
117
+ // `stop()` does.
118
+ for (const record of state.memberStore.list())
119
+ state.memberStore.remove(record.peerId);
120
+ await snapshots.flushed();
121
+ },
122
+ async stop() {
123
+ // Idempotent: an assembly that tears down twice — a page unloading while
124
+ // a test also cleans up — must not see the second call throw.
125
+ if (stopped)
126
+ return;
127
+ stopped = true;
128
+ if (timer != null)
129
+ clearInterval(timer);
130
+ },
131
+ };
132
+ }
@@ -0,0 +1,137 @@
1
+ /**
2
+ * The hub's `.well-known` surface: registries, invitations, presence and the
3
+ * mesh view. The hub is an ordinary peer (note 03) — nothing here imports
4
+ * libp2p; it is handed a `mintToken` capability already scoped to this
5
+ * peer's own signing key (see `createPeer`'s `mounts` factory in
6
+ * `@statewalker/httpeers-core`), never the key itself.
7
+ *
8
+ * Built on Hono. `app.fetch` is already `(Request) => Promise<Response>`, so
9
+ * it drops straight into the `FetchHandler` contract with no adapter — and
10
+ * `c.req.raw` is the very `Request` the transport (or, in a test, the
11
+ * caller) registered a peer binding on, which is what makes `lookupPeer`
12
+ * and `lookupClaims` work at all from inside a handler.
13
+ *
14
+ * NO QUERY PARAMETERS. Conditional reads use `ETag` / `If-None-Match`, not
15
+ * `?since=`: some transports drop `url.search` before the wire, and ETag is
16
+ * better HTTP regardless.
17
+ */
18
+ import type { RuleSet } from "@statewalker/httpeers-access";
19
+ import type { RevocationRegistry } from "@statewalker/httpeers-access/issuer";
20
+ import type { FetchHandler, MemberStore, Mounts, PeerIdStr, UsesTransportIdentity } from "@statewalker/httpeers-core";
21
+ import type { InvitationStore } from "./hub-state.js";
22
+ import type { MeshView } from "./mesh-view.js";
23
+ /** The capability that grants admin visibility — sees `hidden` members and gates `/admin/*` (Task 8's `DELETE /admin/members/{peerId}` included). */
24
+ export declare const ADMIN_CAPABILITY = "std:mesh.admin";
25
+ /** Presence TTL: an entry not refreshed within this window is swept. */
26
+ export declare const DEFAULT_PRESENCE_TTL_MS = 15000;
27
+ /** Exactly two handlers, method-aware: a presence WRITE is bootstrap, a read is not. */
28
+ export declare function usesTransportIdentity(): UsesTransportIdentity;
29
+ /**
30
+ * The diagnostic `/test` surface: `/test/whoami` and `/test/echo`, per the
31
+ * archived `12-httpeers-prototype-validated/src/endpoints.ts` (lines 128
32
+ * and 138). NOT hub-specific — every peer in that archive served this
33
+ * unconditionally, hub or not, and Task 7b's promoted E2E suites need the
34
+ * same: their "provider" peer (an ordinary member, never a hub) must answer
35
+ * `/test/whoami` with this rich shape too.
36
+ *
37
+ * This is deliberately NOT `httpeers.core`'s own default diagnostic mount
38
+ * (`peer.ts`'s `defaultMounts`, also `/test/whoami`) — that one answers
39
+ * `{ peer, sub }` from the transport/binding alone and knows nothing about
40
+ * a minted token's `roles`/`mesh`. The archived suites assert `body.you`,
41
+ * `body.roles`, `body.mesh` — fields the core's default does not have and
42
+ * is not meant to grow, since the core must stay hub-agnostic. Any peer
43
+ * that wants this richer surface mounts this handler explicitly, the same
44
+ * way `createHubEndpoints` below does for the hub itself.
45
+ */
46
+ export declare function createTestSurfaceHandler(selfPeerId: PeerIdStr): FetchHandler;
47
+ export interface HubEndpointsInit {
48
+ selfPeerId: PeerIdStr;
49
+ /**
50
+ * Mint a membership token that self-certifies as this mesh. Structurally
51
+ * `MountsFactoryContext["mintToken"]` from `httpeers.core`, restated here
52
+ * rather than imported so this file stays a plain description of what the
53
+ * hub needs -- including the audience option ADR-0020 added, which nothing
54
+ * in this file passes yet: the invitation and presence protocols mint
55
+ * unrestricted tokens, and narrowing them is a protocol change, not a
56
+ * signature change.
57
+ */
58
+ mintToken: (sub: string, roles: string[], options?: {
59
+ ttlMs?: number;
60
+ audience?: readonly PeerIdStr[];
61
+ }) => Promise<string>;
62
+ memberStore: MemberStore;
63
+ invitations: InvitationStore;
64
+ /** This mesh's Datalog rules and policies (ADR-0019) — published read-only, and the source of the caller's capabilities in the mesh view. */
65
+ rules: RuleSet;
66
+ revocations: RevocationRegistry;
67
+ /** `kind` -> capability required to see advertisements of that kind. Defaults to `{}` (no kind gated). */
68
+ advertisementAccess?: Record<string, string>;
69
+ presenceTtlMs?: number;
70
+ now?: () => number;
71
+ /**
72
+ * Application mounts served alongside the hub's own `.well-known` surface
73
+ * and `/admin`, behind the same access checks.
74
+ *
75
+ * The prototype hard-mounted a demo `/search` endpoint here, which made the
76
+ * library import the application. A hub is membership and the bulletin
77
+ * board; what else a particular deployment serves is not its business.
78
+ */
79
+ extraMounts?: Record<string, FetchHandler>;
80
+ /**
81
+ * What this hub advertises about ITSELF on the bulletin board.
82
+ *
83
+ * Posted once at construction, because the hub is the one peer that never
84
+ * sends a heartbeat — and a heartbeat's `advertisements` array is otherwise
85
+ * the only way anything reaches that store. The prototype learned this the
86
+ * hard way: its `/search` mount was served and gated but INVISIBLE, so a
87
+ * consumer filtering `/.well-known/mesh` by `kind` found the image peer and
88
+ * nothing else.
89
+ *
90
+ * A hub that serves a route while withholding its advertisement is a
91
+ * half-wired state, so a deployment passing `extraMounts` almost always
92
+ * wants a matching entry here. These never need withdrawing: `sweep`
93
+ * withdraws only for peers whose PRESENCE expired, and a hub has no presence
94
+ * record of its own.
95
+ */
96
+ ownAdvertisements?: Array<{
97
+ id: string;
98
+ kind: string;
99
+ title: string;
100
+ }>;
101
+ }
102
+ export interface HubEndpoints {
103
+ mounts: Mounts;
104
+ /** Expire stale presence and the advertisements that rode along with it; bumps the mesh version only if something actually left. Call on a timer (production) or directly with a controlled clock (tests). */
105
+ sweep: () => void;
106
+ /**
107
+ * The very view `GET /.well-known/mesh` serves, read in-process.
108
+ *
109
+ * FOR A UI THAT IS ALREADY INSIDE THE HUB, and for nothing else. The
110
+ * browser hub page (`../pages/hub/`) renders the member list from this;
111
+ * going through the HTTP endpoint for it would mean the hub minting
112
+ * itself a token and dispatching a request to itself, purely so it could
113
+ * read three registries it is holding two references away. The HTTP
114
+ * endpoint remains the ONLY way any other peer sees this — this accessor
115
+ * adds no route, no capability, and no way in from the network.
116
+ *
117
+ * IT IS `buildMeshView`, NOT A SECOND OPINION. "Saved" (a member record,
118
+ * from the persisted snapshot) and "active" (`online`, from the TTL'd
119
+ * presence store) are two different facts, and `mesh-view.ts` already
120
+ * computes the second per member. A UI that re-derived `online` by
121
+ * joining a member list against a presence list would be inventing a
122
+ * parallel notion that could disagree with the one every remote peer
123
+ * reads — the exact kind of divergence that turns into a debugging trap.
124
+ *
125
+ * THE HUB'S OWN VIEW SEES EVERYTHING: every capability the rules can
126
+ * derive is passed as the caller's, so `hidden` members and every gated
127
+ * advertisement are included. There is nobody to hide from — this is the
128
+ * machine that holds the list, and an operator shown a filtered version
129
+ * of their own mesh would be misled about what they are administering.
130
+ *
131
+ * Live, not a snapshot: a swept peer is `online: false` on the next call,
132
+ * and a removed member is gone from it, which is what a polling UI wants.
133
+ */
134
+ meshView: () => MeshView;
135
+ }
136
+ export declare function createHubEndpoints(init: HubEndpointsInit): HubEndpoints;
137
+ //# sourceMappingURL=endpoints.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"endpoints.d.ts","sourceRoot":"","sources":["../src/endpoints.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAEH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,8BAA8B,CAAC;AAE5D,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,qCAAqC,CAAC;AAC9E,OAAO,KAAK,EAGV,YAAY,EACZ,WAAW,EACX,MAAM,EACN,SAAS,EAET,qBAAqB,EACtB,MAAM,4BAA4B,CAAC;AAepC,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,gBAAgB,CAAC;AACtD,OAAO,KAAK,EAAwB,QAAQ,EAAE,MAAM,gBAAgB,CAAC;AAIrE,qJAAqJ;AACrJ,eAAO,MAAM,gBAAgB,mBAAmB,CAAC;AAEjD,wEAAwE;AACxE,eAAO,MAAM,uBAAuB,QAAS,CAAC;AAE9C,wFAAwF;AACxF,wBAAgB,qBAAqB,IAAI,qBAAqB,CAM7D;AAED;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAgB,wBAAwB,CAAC,UAAU,EAAE,SAAS,GAAG,YAAY,CAgB5E;AAED,MAAM,WAAW,gBAAgB;IAC/B,UAAU,EAAE,SAAS,CAAC;IACtB;;;;;;;;OAQG;IACH,SAAS,EAAE,CACT,GAAG,EAAE,MAAM,EACX,KAAK,EAAE,MAAM,EAAE,EACf,OAAO,CAAC,EAAE;QAAE,KAAK,CAAC,EAAE,MAAM,CAAC;QAAC,QAAQ,CAAC,EAAE,SAAS,SAAS,EAAE,CAAA;KAAE,KAC1D,OAAO,CAAC,MAAM,CAAC,CAAC;IACrB,WAAW,EAAE,WAAW,CAAC;IACzB,WAAW,EAAE,eAAe,CAAC;IAC7B,6IAA6I;IAC7I,KAAK,EAAE,OAAO,CAAC;IACf,WAAW,EAAE,kBAAkB,CAAC;IAChC,0GAA0G;IAC1G,mBAAmB,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAC7C,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,GAAG,CAAC,EAAE,MAAM,MAAM,CAAC;IACnB;;;;;;;OAOG;IACH,WAAW,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,YAAY,CAAC,CAAC;IAC3C;;;;;;;;;;;;;;;OAeG;IACH,iBAAiB,CAAC,EAAE,KAAK,CAAC;QAAE,EAAE,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;CACxE;AAED,MAAM,WAAW,YAAY;IAC3B,MAAM,EAAE,MAAM,CAAC;IACf,8MAA8M;IAC9M,KAAK,EAAE,MAAM,IAAI,CAAC;IAClB;;;;;;;;;;;;;;;;;;;;;;;;;;;OA2BG;IACH,QAAQ,EAAE,MAAM,QAAQ,CAAC;CAC1B;AA0BD,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,gBAAgB,GAAG,YAAY,CAuQvE"}