@statewalker/httpeers-hub 0.1.3 → 0.1.5

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.
Files changed (2) hide show
  1. package/README.md +121 -72
  2. package/package.json +10 -6
package/README.md CHANGED
@@ -1,108 +1,157 @@
1
1
  # @statewalker/httpeers-hub
2
2
 
3
- Minting membership, holding the registries, and saying who is in the mesh.
3
+ ## What it is
4
+
5
+ The hub of an httpeers mesh: it issues and redeems invitations, mints
6
+ membership tokens, keeps the member, presence and advertisement registries, and
7
+ answers "who is in the mesh". It is a mount table of HTTP endpoints
8
+ (`/.well-known/invite`, `/.well-known/presence`, `/.well-known/mesh`,
9
+ `/.well-known/members`, `/.well-known/rules`, `/.well-known/revocations`,
10
+ `/admin/…`) and has no transport of its own.
11
+
12
+ ## Why it exists
13
+
14
+ A mesh needs one authority that says who belongs to it. That authority is
15
+ state plus HTTP endpoints, and nothing about it requires a socket, a file
16
+ system or a particular runtime. Keeping the hub free of transport is what lets
17
+ the same code run as a Node daemon and inside a browser tab; whoever hosts it
18
+ puts `hub.mounts` on a wire with `servePeer` from
19
+ `@statewalker/httpeers-libp2p`.
20
+
21
+ ## How to use
22
+
23
+ ```sh
24
+ pnpm add @statewalker/httpeers-hub
25
+ ```
26
+
27
+ Optional peer dependency: `@statewalker/webrun-files`, only if you use
28
+ `filesStorage` (it takes a `FilesApi`).
29
+
30
+ | Import | Gives | Runs in |
31
+ |---|---|---|
32
+ | `.` | `createHub`, `memoryStorage`, `filesStorage(files, dir?)`, `asyncSnapshotStore`, the stores (`createMemberStore`, `createPresenceStore`, `createAdvertisementStore`, `createHubState`), `createHubEndpoints`, `buildMeshView` | anywhere |
33
+ | `./node` | `fileStorage(path)` | Node |
34
+ | `./browser` | `idbStorage(name?, store?)` | browsers (IndexedDB) |
35
+
36
+ ## Examples
37
+
38
+ Create a hub and an invitation:
4
39
 
5
40
  ```ts
6
41
  import { createHub, memoryStorage } from "@statewalker/httpeers-hub";
42
+ import { mintToken } from "@statewalker/httpeers-access/issuer";
7
43
 
8
44
  const hub = await createHub({
9
- selfPeerId, // this hub's peerId IS the mesh's name
10
- policies: rules,
45
+ selfPeerId, // this hub's peerId is the mesh's name
46
+ policies: rules, // a RuleSet from httpeers-access
11
47
  storage: memoryStorage(),
12
48
  mintToken: async (sub, roles) => mintToken({ signer, sub, roles, ttlMs: 60_000 }),
13
49
  });
14
50
 
15
- const invite = await hub.invitations.create(["member"], 60_000);
51
+ const { id, expiresAt } = await hub.invitations.create(["member"], 60 * 60_000);
52
+ hub.invitations.pending(); // unredeemed invitations, persisted
16
53
  ```
17
54
 
18
- `hub.mounts` is a mount table. Serving it on a wire is somebody else's job.
55
+ Serve it on a libp2p node:
19
56
 
20
- ## A hub has no transport, and that is what lets it run in a tab
57
+ ```ts
58
+ import { servePeer } from "@statewalker/httpeers-libp2p";
21
59
 
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`.
60
+ const peer = await servePeer({ node, mounts: hub.mounts, access: guard });
61
+ ```
26
62
 
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.
63
+ Persist to disk in Node, or to IndexedDB in a page:
33
64
 
34
- ## Invitations are bearer credentials, and are treated as such
65
+ ```ts
66
+ import { fileStorage } from "@statewalker/httpeers-hub/node";
67
+ import { idbStorage } from "@statewalker/httpeers-hub/browser";
35
68
 
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.
69
+ const storage = fileStorage("./.httpeers/hub"); // Node: one file per key, ./.httpeers/hub.<key>
70
+ const storage2 = idbStorage(); // page: database "httpeers-hub", store "state"
71
+ ```
72
+
73
+ Demote a member so it takes effect before its token expires:
74
+
75
+ ```ts
76
+ hub.members.setRoles(peerId, ["member"]);
77
+ hub.revocations.changeRoles(peerId, ["member"]);
78
+ ```
79
+
80
+ ## Internals
41
81
 
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.
82
+ ### An invitation is a bearer credential
46
83
 
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.
84
+ Whoever holds an invitation id can become a member. So the id is 128 bits from
85
+ the platform CSPRNG (`crypto.getRandomValues`), generated by default:
86
+ `create(roles, ttlMs)` is the whole call. A caller may pass `{ id }` — for a
87
+ test that wants a constant — and that is the only way to get a weak one.
49
88
 
50
- ## The snapshot is synchronous and the storage is not
89
+ Every mutator resolves only once the write is durable. A caller is never told
90
+ "created" before it is, because an invitation handed out and then lost to a
91
+ crash is one somebody is still holding. Unredeemed invitations survive a
92
+ restart: they are in the snapshot.
51
93
 
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:
94
+ ### The snapshot is synchronous; the storage behind it is not
56
95
 
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.
96
+ `createHubState` writes its snapshot synchronously and returns, so the hub never
97
+ reports a member added while the snapshot says otherwise. Storage in a browser
98
+ is asynchronous and cannot be blocked on. `asyncSnapshotStore` reconciles the
99
+ two:
60
100
 
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.
101
+ ```
102
+ mutation ──> snapshot.write (sync, replaces the in-memory copy)
103
+ │
104
+ └──> one promise chain ──> KeyValueStorage.put (in order)
105
+ reads ────> the in-memory copy
106
+ ```
107
+
108
+ The in-memory copy is authoritative and the backend trails it. Writes reach
109
+ storage **in order**: an unordered `put` per write would let a slow earlier
110
+ write land on top of a later one and resurrect a spent invitation id — a
111
+ membership bypass. `flushed()` waits for durability. A tab closed between a
112
+ write and its flush loses that write.
66
113
 
67
- Honestly stated: a tab closed between a write and its flush loses that write.
68
- This is a page, not a database.
114
+ `filesStorage` and `fileStorage` write to a temporary name and then move, so a
115
+ crash mid-write never leaves a half-written snapshot.
69
116
 
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 |
117
+ ### Roles come from the rules
75
118
 
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.
119
+ A role exists in this mesh exactly when some rule fires on it, so
120
+ `roleNames(rules)` is the registry. `members.setRoles` and `invitations.create`
121
+ validate against it, and an unknown role throws:
122
+
123
+ ```
124
+ RuleSetError: invalid rule set:
125
+ - invitation '334fe529…': unknown role 'boss'
126
+ ```
127
+ A role added
128
+ to the policy is accepted everywhere with no second list to update.
79
129
 
80
- ## Roles come off the rules, not a second list
130
+ ### Changing roles takes two writes
81
131
 
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.
132
+ `members.setRoles` rewrites the record, but the peer still carries a token that
133
+ states its **old** roles until it expires. `revocations.changeRoles` makes
134
+ tokens minted before the change stop verifying, so the new roles apply on the
135
+ peer's next heartbeat. The store does not do this for you; without both writes
136
+ the change is cosmetic.
86
137
 
87
- ## Changing roles takes two writes, or it is a lie
138
+ ### Presence is a heartbeat
88
139
 
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.
140
+ A member that stops posting to `/.well-known/presence` is swept from the mesh
141
+ view within one TTL (`presenceTtlMs`), and its advertisements go with it.
142
+ `hub.sweep()` runs a sweep directly — useful with a controlled clock — and
143
+ `sweepIntervalMs` runs it on a timer.
94
144
 
95
- The store does not do this for you. Both halves, or the change is cosmetic.
145
+ ### Dependencies
96
146
 
97
- ## Presence is a heartbeat
147
+ `@statewalker/httpeers-core`, `@statewalker/httpeers-access` (tokens, rules,
148
+ revocation) and `hono` (routing inside the endpoints). No libp2p;
149
+ `tests/boundary.test.ts` asserts it.
98
150
 
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`.
151
+ Tests: `pnpm --filter @statewalker/httpeers-hub test`. The hub also runs for real
152
+ in `httpeers-conformance`, where a live member redeems one of its invitations
153
+ over a relay.
103
154
 
104
- ## Tests
155
+ ## License
105
156
 
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.
157
+ MIT
package/package.json CHANGED
@@ -1,12 +1,16 @@
1
1
  {
2
2
  "name": "@statewalker/httpeers-hub",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "type": "module",
5
5
  "description": "Minting membership, holding the registries, and saying who is in the mesh.",
6
+ "bugs": {
7
+ "url": "https://github.com/statewalker/httpeers/issues"
8
+ },
9
+ "homepage": "https://github.com/statewalker/httpeers/tree/main/packages/httpeers-hub#readme",
6
10
  "license": "MIT",
7
11
  "repository": {
8
12
  "type": "git",
9
- "url": "git+ssh://git@github.com/statewalker/httpeers.git",
13
+ "url": "git+https://github.com/statewalker/httpeers.git",
10
14
  "directory": "packages/httpeers-hub"
11
15
  },
12
16
  "main": "./dist/index.js",
@@ -35,8 +39,8 @@
35
39
  "sideEffects": false,
36
40
  "dependencies": {
37
41
  "hono": "^4.13.12",
38
- "@statewalker/httpeers-access": "^0.1.3",
39
- "@statewalker/httpeers-core": "^0.1.2"
42
+ "@statewalker/httpeers-access": "^0.1.5",
43
+ "@statewalker/httpeers-core": "^0.1.4"
40
44
  },
41
45
  "peerDependencies": {
42
46
  "@statewalker/webrun-files": "^0.9.0 || ^0.10.0"
@@ -48,10 +52,10 @@
48
52
  },
49
53
  "devDependencies": {
50
54
  "@biomejs/biome": "^2.5.15",
51
- "@statewalker/webrun-files": "^0.10.0",
55
+ "@statewalker/webrun-files": "^0.9.0 || ^0.10.0",
52
56
  "@types/node": "^26.6.4",
53
57
  "typescript": "^7.0.2",
54
- "vitest": "^5.0.3"
58
+ "vitest": "^5.0.5"
55
59
  },
56
60
  "publishConfig": {
57
61
  "access": "public"