@statewalker/httpeers-hub 0.1.3 → 0.1.4
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/README.md +121 -72
- package/package.json +8 -4
package/README.md
CHANGED
|
@@ -1,108 +1,157 @@
|
|
|
1
1
|
# @statewalker/httpeers-hub
|
|
2
2
|
|
|
3
|
-
|
|
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,
|
|
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
|
|
51
|
+
const { id, expiresAt } = await hub.invitations.create(["member"], 60 * 60_000);
|
|
52
|
+
hub.invitations.pending(); // unredeemed invitations, persisted
|
|
16
53
|
```
|
|
17
54
|
|
|
18
|
-
|
|
55
|
+
Serve it on a libp2p node:
|
|
19
56
|
|
|
20
|
-
|
|
57
|
+
```ts
|
|
58
|
+
import { servePeer } from "@statewalker/httpeers-libp2p";
|
|
21
59
|
|
|
22
|
-
|
|
23
|
-
|
|
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
|
-
|
|
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
|
-
|
|
65
|
+
```ts
|
|
66
|
+
import { fileStorage } from "@statewalker/httpeers-hub/node";
|
|
67
|
+
import { idbStorage } from "@statewalker/httpeers-hub/browser";
|
|
35
68
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
-
|
|
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
|
-
|
|
48
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
68
|
-
|
|
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
|
-
|
|
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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
|
|
130
|
+
### Changing roles takes two writes
|
|
81
131
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
|
|
138
|
+
### Presence is a heartbeat
|
|
88
139
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
|
|
145
|
+
### Dependencies
|
|
96
146
|
|
|
97
|
-
|
|
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
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
-
##
|
|
155
|
+
## License
|
|
105
156
|
|
|
106
|
-
|
|
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
|
+
"version": "0.1.4",
|
|
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+
|
|
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.
|
|
39
|
-
"@statewalker/httpeers-core": "^0.1.
|
|
42
|
+
"@statewalker/httpeers-access": "^0.1.4",
|
|
43
|
+
"@statewalker/httpeers-core": "^0.1.3"
|
|
40
44
|
},
|
|
41
45
|
"peerDependencies": {
|
|
42
46
|
"@statewalker/webrun-files": "^0.9.0 || ^0.10.0"
|