@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
|
@@ -0,0 +1,305 @@
|
|
|
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 { capabilityNames, deriveCapabilities } from "@statewalker/httpeers-access";
|
|
19
|
+
import { ANONYMOUS, createMounts, json, lookupClaims, lookupPeer, } from "@statewalker/httpeers-core";
|
|
20
|
+
import { Hono } from "hono";
|
|
21
|
+
import { createAdminEndpoints } from "./admin.js";
|
|
22
|
+
import { buildMeshView } from "./mesh-view.js";
|
|
23
|
+
import { createAdvertisementStore, createPresenceStore } from "./registries.js";
|
|
24
|
+
/** The capability that grants admin visibility — sees `hidden` members and gates `/admin/*` (Task 8's `DELETE /admin/members/{peerId}` included). */
|
|
25
|
+
export const ADMIN_CAPABILITY = "std:mesh.admin";
|
|
26
|
+
/** Presence TTL: an entry not refreshed within this window is swept. */
|
|
27
|
+
export const DEFAULT_PRESENCE_TTL_MS = 15_000;
|
|
28
|
+
/** Exactly two handlers, method-aware: a presence WRITE is bootstrap, a read is not. */
|
|
29
|
+
export function usesTransportIdentity() {
|
|
30
|
+
return async (req) => {
|
|
31
|
+
if (req.method !== "POST")
|
|
32
|
+
return false;
|
|
33
|
+
const { pathname } = new URL(req.url);
|
|
34
|
+
return pathname === "/.well-known/invite" || pathname === "/.well-known/presence";
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* The diagnostic `/test` surface: `/test/whoami` and `/test/echo`, per the
|
|
39
|
+
* archived `12-httpeers-prototype-validated/src/endpoints.ts` (lines 128
|
|
40
|
+
* and 138). NOT hub-specific — every peer in that archive served this
|
|
41
|
+
* unconditionally, hub or not, and Task 7b's promoted E2E suites need the
|
|
42
|
+
* same: their "provider" peer (an ordinary member, never a hub) must answer
|
|
43
|
+
* `/test/whoami` with this rich shape too.
|
|
44
|
+
*
|
|
45
|
+
* This is deliberately NOT `httpeers.core`'s own default diagnostic mount
|
|
46
|
+
* (`peer.ts`'s `defaultMounts`, also `/test/whoami`) — that one answers
|
|
47
|
+
* `{ peer, sub }` from the transport/binding alone and knows nothing about
|
|
48
|
+
* a minted token's `roles`/`mesh`. The archived suites assert `body.you`,
|
|
49
|
+
* `body.roles`, `body.mesh` — fields the core's default does not have and
|
|
50
|
+
* is not meant to grow, since the core must stay hub-agnostic. Any peer
|
|
51
|
+
* that wants this richer surface mounts this handler explicitly, the same
|
|
52
|
+
* way `createHubEndpoints` below does for the hub itself.
|
|
53
|
+
*/
|
|
54
|
+
export function createTestSurfaceHandler(selfPeerId) {
|
|
55
|
+
const app = new Hono();
|
|
56
|
+
app.get("/test/whoami", (c) => {
|
|
57
|
+
const claims = lookupClaims(c.req.raw) ?? null;
|
|
58
|
+
return json({
|
|
59
|
+
servedBy: selfPeerId,
|
|
60
|
+
you: claims?.sub ?? null,
|
|
61
|
+
roles: claims?.roles ?? [],
|
|
62
|
+
mesh: claims?.mesh ?? null,
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
app.post("/test/echo", async (c) => json({ method: c.req.method, body: await c.req.text() }));
|
|
66
|
+
return app.fetch;
|
|
67
|
+
}
|
|
68
|
+
function sameAddrs(a, b) {
|
|
69
|
+
if (a == null)
|
|
70
|
+
return false;
|
|
71
|
+
if (a.length !== b.length)
|
|
72
|
+
return false;
|
|
73
|
+
return a.every((v, i) => v === b[i]);
|
|
74
|
+
}
|
|
75
|
+
function sameAdvertisements(prev, next) {
|
|
76
|
+
if (prev.length !== next.length)
|
|
77
|
+
return false;
|
|
78
|
+
const prevById = new Map(prev.map((a) => [a.key, a.payload]));
|
|
79
|
+
return next.every((ad) => {
|
|
80
|
+
const p = prevById.get(ad.id);
|
|
81
|
+
return p != null && p.kind === ad.kind && p.title === ad.title;
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
export function createHubEndpoints(init) {
|
|
85
|
+
const now = init.now ?? Date.now;
|
|
86
|
+
const presenceTtlMs = init.presenceTtlMs ?? DEFAULT_PRESENCE_TTL_MS;
|
|
87
|
+
const advertisementAccess = init.advertisementAccess ?? {};
|
|
88
|
+
const presenceStore = createPresenceStore(now);
|
|
89
|
+
const advertisementStore = createAdvertisementStore(now);
|
|
90
|
+
const addrsByPeer = new Map();
|
|
91
|
+
// Whatever this deployment says about itself, posted once — see
|
|
92
|
+
// `ownAdvertisements` for why this is the only path onto the board for a
|
|
93
|
+
// peer that never heartbeats.
|
|
94
|
+
for (const ad of init.ownAdvertisements ?? []) {
|
|
95
|
+
advertisementStore.post(init.selfPeerId, ad.id, {
|
|
96
|
+
kind: ad.kind,
|
|
97
|
+
title: ad.title,
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
// Tracks the highest `seq` EVER accepted for a peer, independent of
|
|
101
|
+
// `presenceStore` — which the TTL sweep clears. Without this, a delayed
|
|
102
|
+
// duplicate of an old heartbeat arriving AFTER the peer has already been
|
|
103
|
+
// swept would look like a brand-new write (no record exists to compare
|
|
104
|
+
// against) and resurrect a peer that has genuinely departed. This map is
|
|
105
|
+
// never cleared by sweep, only ever advanced.
|
|
106
|
+
const lastSeqByPeer = new Map();
|
|
107
|
+
// The one counter this whole module exists to bump correctly: it moves
|
|
108
|
+
// only when the OBSERVABLE mesh view could have changed (a member added,
|
|
109
|
+
// a peer's online/addrs state changed, an advertisement changed, or the
|
|
110
|
+
// sweep removed someone) — never on a heartbeat that repeats what the hub
|
|
111
|
+
// already knew, which is what keeps `/.well-known/mesh`'s ETag stable
|
|
112
|
+
// between identical heartbeats instead of churning every 5 s.
|
|
113
|
+
let meshVersion = 1;
|
|
114
|
+
const bumpMesh = () => {
|
|
115
|
+
meshVersion++;
|
|
116
|
+
};
|
|
117
|
+
const claimsOf = (req) => lookupClaims(req) ?? null;
|
|
118
|
+
const provenPeer = (req) => {
|
|
119
|
+
const peer = lookupPeer(req);
|
|
120
|
+
if (peer === undefined || peer === ANONYMOUS) {
|
|
121
|
+
throw new Error("hub bootstrap handler reached without a transport-proven peer");
|
|
122
|
+
}
|
|
123
|
+
return peer;
|
|
124
|
+
};
|
|
125
|
+
const withdrawAllFor = (peerId) => {
|
|
126
|
+
for (const ad of advertisementStore.list(peerId))
|
|
127
|
+
advertisementStore.withdraw(peerId, ad.key);
|
|
128
|
+
};
|
|
129
|
+
const app = new Hono();
|
|
130
|
+
// --- BOOTSTRAP: no token exists yet ---------------------------------------
|
|
131
|
+
app.post("/.well-known/invite", async (c) => {
|
|
132
|
+
const peerId = provenPeer(c.req.raw);
|
|
133
|
+
const { id } = await c.req.json();
|
|
134
|
+
const redemption = init.invitations.redeem(id);
|
|
135
|
+
if (!redemption.ok) {
|
|
136
|
+
return json({ error: `invitation ${redemption.reason}` }, 403);
|
|
137
|
+
}
|
|
138
|
+
init.memberStore.add(peerId, redemption.roles);
|
|
139
|
+
bumpMesh();
|
|
140
|
+
return json({
|
|
141
|
+
token: await init.mintToken(peerId, redemption.roles),
|
|
142
|
+
mesh: init.selfPeerId,
|
|
143
|
+
roles: redemption.roles,
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
// --- BOOTSTRAP: mints the token, so cannot itself require one -------------
|
|
147
|
+
app.post("/.well-known/presence", async (c) => {
|
|
148
|
+
const peerId = provenPeer(c.req.raw);
|
|
149
|
+
const member = init.memberStore.get(peerId);
|
|
150
|
+
if (member == null)
|
|
151
|
+
return json({ error: "not a member" }, 403);
|
|
152
|
+
const body = await c.req.json();
|
|
153
|
+
const lastSeq = lastSeqByPeer.get(peerId);
|
|
154
|
+
if (lastSeq !== undefined && body.seq <= lastSeq) {
|
|
155
|
+
return json({ error: "stale-sequence" }, 409);
|
|
156
|
+
}
|
|
157
|
+
lastSeqByPeer.set(peerId, body.seq);
|
|
158
|
+
const wasPresent = presenceStore.isPresent(peerId);
|
|
159
|
+
const prevAddrs = addrsByPeer.get(peerId);
|
|
160
|
+
// Result intentionally ignored: `lastSeqByPeer` above already decided
|
|
161
|
+
// whether this write is stale, using a guard that survives the TTL
|
|
162
|
+
// sweep clearing `presenceStore`'s own record (see that map's doc
|
|
163
|
+
// comment) — so `presenceStore`'s own monotonic check can only agree.
|
|
164
|
+
presenceStore.heartbeat(peerId, body.seq, presenceTtlMs);
|
|
165
|
+
addrsByPeer.set(peerId, body.addrs);
|
|
166
|
+
let changed = !wasPresent || !sameAddrs(prevAddrs, body.addrs);
|
|
167
|
+
if (body.advertisements !== undefined) {
|
|
168
|
+
const prevAds = advertisementStore.list(peerId);
|
|
169
|
+
if (!sameAdvertisements(prevAds, body.advertisements)) {
|
|
170
|
+
withdrawAllFor(peerId);
|
|
171
|
+
for (const ad of body.advertisements) {
|
|
172
|
+
const payload = { kind: ad.kind, title: ad.title };
|
|
173
|
+
advertisementStore.post(peerId, ad.id, payload);
|
|
174
|
+
}
|
|
175
|
+
changed = true;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
if (changed)
|
|
179
|
+
bumpMesh();
|
|
180
|
+
return json({
|
|
181
|
+
token: await init.mintToken(peerId, member.roles),
|
|
182
|
+
versions: {
|
|
183
|
+
mesh: meshVersion,
|
|
184
|
+
policy: init.revocations.policyVersion(),
|
|
185
|
+
rules: init.rules.version,
|
|
186
|
+
},
|
|
187
|
+
// `ttl` is unchanged context from the archive (`CHANGES-v0.8.0.txt`'s
|
|
188
|
+
// heartbeat shape `{ token, versions, meshVersion, ttl }`), not part
|
|
189
|
+
// of that delta -- unlike `meshVersion`, which WAS superseded by
|
|
190
|
+
// `versions.mesh` and correctly dropped (Ruling R31). It lets a
|
|
191
|
+
// client take its heartbeat interval from the server rather than
|
|
192
|
+
// hardcoding it.
|
|
193
|
+
ttl: presenceTtlMs,
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
// --- ORDINARY: identity comes from the verified token ----------------------
|
|
197
|
+
app.get("/.well-known/members", (_c) => json({ members: init.memberStore.list() }));
|
|
198
|
+
app.get("/.well-known/presence", (_c) => json({
|
|
199
|
+
presence: presenceStore.list().map((p) => ({ ...p, addrs: addrsByPeer.get(p.peerId) ?? [] })),
|
|
200
|
+
}));
|
|
201
|
+
app.get("/.well-known/advertisements", (_c) => json({
|
|
202
|
+
advertisements: advertisementStore.list().map((ad) => {
|
|
203
|
+
const payload = ad.payload;
|
|
204
|
+
return { peerId: ad.peerId, id: ad.key, kind: payload.kind, title: payload.title };
|
|
205
|
+
}),
|
|
206
|
+
}));
|
|
207
|
+
// ONE expression, two readers: this endpoint and `HubEndpoints.meshView`
|
|
208
|
+
// below. They must not drift -- an in-process UI showing a different
|
|
209
|
+
// membership or a different `online` from the one every remote peer reads
|
|
210
|
+
// would be a debugging trap rather than a diagnostic.
|
|
211
|
+
const viewFor = (self, callerCapabilities) => buildMeshView({
|
|
212
|
+
version: meshVersion,
|
|
213
|
+
self,
|
|
214
|
+
members: init.memberStore.list(),
|
|
215
|
+
presence: presenceStore.list(),
|
|
216
|
+
addrsByPeer,
|
|
217
|
+
advertisements: advertisementStore.list(),
|
|
218
|
+
callerCapabilities,
|
|
219
|
+
advertisementAccess,
|
|
220
|
+
adminCapability: ADMIN_CAPABILITY,
|
|
221
|
+
});
|
|
222
|
+
app.get("/.well-known/mesh", (c) => {
|
|
223
|
+
const claims = claimsOf(c.req.raw);
|
|
224
|
+
if (claims == null)
|
|
225
|
+
return json({ error: "unauthenticated" }, 401);
|
|
226
|
+
const etag = `"v${meshVersion}"`;
|
|
227
|
+
if (c.req.header("if-none-match") === etag) {
|
|
228
|
+
return new Response(null, { status: 304, headers: { etag } });
|
|
229
|
+
}
|
|
230
|
+
const view = viewFor(claims.sub, deriveCapabilities(init.rules, claims.roles));
|
|
231
|
+
return new Response(JSON.stringify(view), {
|
|
232
|
+
headers: { "content-type": "application/json", etag },
|
|
233
|
+
});
|
|
234
|
+
});
|
|
235
|
+
// ADR-0016's amendment: the rules are published, still read-only in the
|
|
236
|
+
// strict sense -- what this returns influences no decision, here or on any
|
|
237
|
+
// other peer, which is why no signing scheme is needed for it. It is served
|
|
238
|
+
// for discovery, debugging and the explainability of a denial.
|
|
239
|
+
app.get("/.well-known/rules", (c) => {
|
|
240
|
+
const etag = `"rules${init.rules.version}"`;
|
|
241
|
+
if (c.req.header("if-none-match") === etag) {
|
|
242
|
+
return new Response(null, { status: 304, headers: { etag } });
|
|
243
|
+
}
|
|
244
|
+
return new Response(JSON.stringify(init.rules), {
|
|
245
|
+
headers: { "content-type": "application/json", etag },
|
|
246
|
+
});
|
|
247
|
+
});
|
|
248
|
+
app.get("/.well-known/revocations", (c) => {
|
|
249
|
+
const version = init.revocations.policyVersion();
|
|
250
|
+
const etag = `"policy${version}"`;
|
|
251
|
+
if (c.req.header("if-none-match") === etag) {
|
|
252
|
+
return new Response(null, { status: 304, headers: { etag } });
|
|
253
|
+
}
|
|
254
|
+
return new Response(JSON.stringify({ version, entries: init.revocations.list() }), {
|
|
255
|
+
headers: { "content-type": "application/json", etag },
|
|
256
|
+
});
|
|
257
|
+
});
|
|
258
|
+
// --- admin-only, to exercise the policy middleware -----------------------
|
|
259
|
+
// Archive: `12/src/endpoints.ts` line 122. Listing only — invitations are
|
|
260
|
+
// created programmatically (`InvitationStore.create`, `persist.ts`), never
|
|
261
|
+
// over HTTP; see this app's `PROVENANCE.md`.
|
|
262
|
+
app.get("/admin/invitations", (c) => json({ ok: true, issuedBy: init.selfPeerId, caller: claimsOf(c.req.raw)?.sub }));
|
|
263
|
+
app.all("*", (c) => json({ error: "not found", path: new URL(c.req.raw.url).pathname }, 404));
|
|
264
|
+
const mounts = createMounts();
|
|
265
|
+
mounts.provide("/.well-known", app.fetch);
|
|
266
|
+
mounts.provide("/test", createTestSurfaceHandler(init.selfPeerId));
|
|
267
|
+
mounts.provide("/admin", app.fetch);
|
|
268
|
+
// Longer prefix wins (R-1): "/admin/members" is more specific than the
|
|
269
|
+
// "/admin" mount above, so DELETE /admin/members/{peerId} reaches
|
|
270
|
+
// admin.ts's handler while GET /admin/invitations keeps hitting `app`.
|
|
271
|
+
mounts.provide("/admin/members", createAdminEndpoints({ memberStore: init.memberStore, revocations: init.revocations }));
|
|
272
|
+
// Revocation for `/search` (and every other route on this hub) is NOT
|
|
273
|
+
// handled here. Policy (`policy.ts`) only ever checks the CAPABILITY a
|
|
274
|
+
// token's roles expand to, which cannot see a membership change made
|
|
275
|
+
// after the token was minted -- but the fix for that is a single seam on
|
|
276
|
+
// this peer's own `createPeer` (`hub/main.ts`'s `revocationCache:
|
|
277
|
+
// revocations`, using `RevocationRegistry`'s own `check`, added in
|
|
278
|
+
// `httpeers.core` alongside this task -- see that package's
|
|
279
|
+
// `revocation.ts`), applied uniformly to EVERY mount by the binding
|
|
280
|
+
// middleware before any handler runs. An earlier version of this file put
|
|
281
|
+
// a bespoke membership check on this one mount instead; that covered only
|
|
282
|
+
// the route being built and silently left every other hub endpoint
|
|
283
|
+
// (including `/admin/*`) still honouring a revoked token until it
|
|
284
|
+
// expired -- removed on review. See `hub/main.ts` and this app's
|
|
285
|
+
// `PROVENANCE.md`.
|
|
286
|
+
for (const [prefix, handler] of Object.entries(init.extraMounts ?? {})) {
|
|
287
|
+
mounts.provide(prefix, handler);
|
|
288
|
+
}
|
|
289
|
+
return {
|
|
290
|
+
mounts,
|
|
291
|
+
// Every capability the rules can derive, so the hub's own view is
|
|
292
|
+
// unfiltered -- see this field's doc comment on `HubEndpoints`.
|
|
293
|
+
meshView: () => viewFor(init.selfPeerId, new Set(capabilityNames(init.rules))),
|
|
294
|
+
sweep() {
|
|
295
|
+
const expired = presenceStore.sweep();
|
|
296
|
+
if (expired.length === 0)
|
|
297
|
+
return;
|
|
298
|
+
for (const peerId of expired) {
|
|
299
|
+
addrsByPeer.delete(peerId);
|
|
300
|
+
withdrawAllFor(peerId);
|
|
301
|
+
}
|
|
302
|
+
bumpMesh();
|
|
303
|
+
},
|
|
304
|
+
};
|
|
305
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The hub's durable state — members and spent invitation ids — over an
|
|
3
|
+
* INJECTED `SnapshotStore`, with no idea where that store puts anything.
|
|
4
|
+
*
|
|
5
|
+
* WHY THIS IS ITS OWN FILE, SEPARATE FROM `./persist.ts`. The
|
|
6
|
+
* `SnapshotStore` seam exists so the same hub logic can run in a browser
|
|
7
|
+
* page (`../pages/hub/`, Task 24) as well as in the Node process, and a
|
|
8
|
+
* seam is only as portable as the module that declares it: `persist.ts`
|
|
9
|
+
* imports `node:fs`/`node:path` at module scope for its own file store, so
|
|
10
|
+
* a browser bundle that reached `createPersistentHub` dragged both in with
|
|
11
|
+
* it. Vite does not fail that build — it substitutes a stub that throws on
|
|
12
|
+
* first property access — so the failure would have surfaced at runtime, in
|
|
13
|
+
* a tab, as an error naming `node:fs` and nothing about the hub. Everything
|
|
14
|
+
* in THIS file is storage-agnostic and free of `node:` imports; `persist.ts`
|
|
15
|
+
* keeps the Node file store and re-exports all of this unchanged, so no
|
|
16
|
+
* existing caller (or test) sees a different API.
|
|
17
|
+
*
|
|
18
|
+
* PRESENCE IS DELIBERATELY NOT HERE. It is TTL'd and regenerates within one
|
|
19
|
+
* heartbeat interval of a restart — persisting it would only let a stale
|
|
20
|
+
* "who was online" view survive past the moment it stopped being true.
|
|
21
|
+
*
|
|
22
|
+
* INVITATIONS THEMSELVES ARE ALSO NOT HERE — only which ids have already
|
|
23
|
+
* been *spent*. An unredeemed invitation forgotten across a restart is a
|
|
24
|
+
* minor inconvenience (issue a new one); a redeemed one becoming redeemable
|
|
25
|
+
* again is a membership bypass. Persisting exactly the set that guards
|
|
26
|
+
* against the second, and nothing else, is the smallest state that closes
|
|
27
|
+
* the hole. See `createInvitationStore` below: the spent-id check runs
|
|
28
|
+
* *before* the invitation is even looked up, so a code whose invitation
|
|
29
|
+
* record did not survive a restart still cannot be redeemed twice.
|
|
30
|
+
*/
|
|
31
|
+
import type { RuleSet } from "@statewalker/httpeers-access";
|
|
32
|
+
import type { MemberRecord, MemberStore } from "@statewalker/httpeers-core";
|
|
33
|
+
export interface HubSnapshot {
|
|
34
|
+
members: MemberRecord[];
|
|
35
|
+
spentInvitationIds: string[];
|
|
36
|
+
/**
|
|
37
|
+
* Invitations created and not yet redeemed.
|
|
38
|
+
*
|
|
39
|
+
* ABSENT FROM THE PROTOTYPE, and that was a defect rather than a decision:
|
|
40
|
+
* pending invitations lived in memory only, so a hub restart silently
|
|
41
|
+
* invalidated every invitation an operator had already handed out — and the
|
|
42
|
+
* person holding one got `not-found`, which reads like a typo rather than
|
|
43
|
+
* like "the hub restarted".
|
|
44
|
+
*
|
|
45
|
+
* Optional so a snapshot written by the older shape still loads: a hub
|
|
46
|
+
* upgrading in place finds `undefined` here and starts with no pending
|
|
47
|
+
* invitations, which is exactly the state it was already in.
|
|
48
|
+
*/
|
|
49
|
+
pendingInvitations?: InvitationRecord[];
|
|
50
|
+
}
|
|
51
|
+
/** What a store with nothing in it yet must read as — see `SnapshotStore.read`. */
|
|
52
|
+
export declare const EMPTY_SNAPSHOT: HubSnapshot;
|
|
53
|
+
/**
|
|
54
|
+
* Where a hub's snapshot lives. THE ONE SEAM BETWEEN THE HUB'S STATE AND
|
|
55
|
+
* ANY PARTICULAR STORAGE -- `./persist.ts`'s `createFileSnapshotStore` is
|
|
56
|
+
* the Node one (a JSON file), `../browser/snapshot-store.ts`'s is the
|
|
57
|
+
* browser one (IndexedDB); nothing else in this file knows either exists.
|
|
58
|
+
*
|
|
59
|
+
* `read` returns `EMPTY_SNAPSHOT`-shaped state on a first run rather than
|
|
60
|
+
* throwing: "nothing stored yet" is the normal first case, not a fault.
|
|
61
|
+
*
|
|
62
|
+
* `write` is synchronous on purpose: every mutation below calls it and then
|
|
63
|
+
* returns, so an async store would open a window where the caller has been
|
|
64
|
+
* told a member was added while the snapshot still says otherwise. A browser
|
|
65
|
+
* implementation that can only write asynchronously must therefore keep an
|
|
66
|
+
* in-memory copy authoritative and flush behind it, rather than making these
|
|
67
|
+
* methods async. See `../browser/snapshot-store.ts` for the one that does.
|
|
68
|
+
*/
|
|
69
|
+
export interface SnapshotStore {
|
|
70
|
+
read(): HubSnapshot;
|
|
71
|
+
write(snapshot: HubSnapshot): void;
|
|
72
|
+
}
|
|
73
|
+
export interface PersistentHub {
|
|
74
|
+
memberStore: MemberStore;
|
|
75
|
+
invitations: InvitationStore;
|
|
76
|
+
}
|
|
77
|
+
export interface InvitationRecord {
|
|
78
|
+
id: string;
|
|
79
|
+
roles: string[];
|
|
80
|
+
expiresAt: number;
|
|
81
|
+
}
|
|
82
|
+
export type InvitationRedemption = {
|
|
83
|
+
ok: true;
|
|
84
|
+
roles: string[];
|
|
85
|
+
} | {
|
|
86
|
+
ok: false;
|
|
87
|
+
reason: "not-found" | "expired" | "already-redeemed";
|
|
88
|
+
};
|
|
89
|
+
export interface InvitationStore {
|
|
90
|
+
/** Throws (via `assertValid`) if any role is one no rule of `rules` knows — caught where the invitation is made, not three hops later. */
|
|
91
|
+
create(id: string, roles: string[], ttlMs: number): InvitationRecord;
|
|
92
|
+
redeem(id: string): InvitationRedemption;
|
|
93
|
+
/** What became of this id. `unknown` means no invitation was ever created with it. */
|
|
94
|
+
status(id: string): "unspent" | "redeemed" | "expired" | "unknown";
|
|
95
|
+
/** Every invitation still awaiting redemption, for an operator's listing. */
|
|
96
|
+
pending(): InvitationRecord[];
|
|
97
|
+
}
|
|
98
|
+
export interface CreateHubStateInit {
|
|
99
|
+
/** Where the snapshot is loaded from at construction and written back to after every mutation. */
|
|
100
|
+
store: SnapshotStore;
|
|
101
|
+
/** This mesh's rules — the role registry `validateRoles` reads (ADR-0019). */
|
|
102
|
+
rules: RuleSet;
|
|
103
|
+
now?: () => number;
|
|
104
|
+
createMemberStore: (rules: RuleSet, clock?: () => number) => MemberStore;
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Wrap a `MemberStore` so every mutation is followed by a synchronous
|
|
108
|
+
* snapshot write — members and spent invitation ids share ONE snapshot, so
|
|
109
|
+
* one write keeps both consistent with each other by construction (no
|
|
110
|
+
* window where one persisted and the other did not). Presence is
|
|
111
|
+
* intentionally not part of this — see the module comment.
|
|
112
|
+
*/
|
|
113
|
+
export declare function createHubState(init: CreateHubStateInit): PersistentHub;
|
|
114
|
+
//# sourceMappingURL=hub-state.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"hub-state.d.ts","sourceRoot":"","sources":["../src/hub-state.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6BG;AAEH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,8BAA8B,CAAC;AAE5D,OAAO,KAAK,EAAE,YAAY,EAAE,WAAW,EAAE,MAAM,4BAA4B,CAAC;AAE5E,MAAM,WAAW,WAAW;IAC1B,OAAO,EAAE,YAAY,EAAE,CAAC;IACxB,kBAAkB,EAAE,MAAM,EAAE,CAAC;IAC7B;;;;;;;;;;;;OAYG;IACH,kBAAkB,CAAC,EAAE,gBAAgB,EAAE,CAAC;CACzC;AAED,mFAAmF;AACnF,eAAO,MAAM,cAAc,EAAE,WAI5B,CAAC;AAEF;;;;;;;;;;;;;;;GAeG;AACH,MAAM,WAAW,aAAa;IAC5B,IAAI,IAAI,WAAW,CAAC;IACpB,KAAK,CAAC,QAAQ,EAAE,WAAW,GAAG,IAAI,CAAC;CACpC;AAED,MAAM,WAAW,aAAa;IAC5B,WAAW,EAAE,WAAW,CAAC;IACzB,WAAW,EAAE,eAAe,CAAC;CAC9B;AAED,MAAM,WAAW,gBAAgB;IAC/B,EAAE,EAAE,MAAM,CAAC;IACX,KAAK,EAAE,MAAM,EAAE,CAAC;IAChB,SAAS,EAAE,MAAM,CAAC;CACnB;AAED,MAAM,MAAM,oBAAoB,GAC5B;IAAE,EAAE,EAAE,IAAI,CAAC;IAAC,KAAK,EAAE,MAAM,EAAE,CAAA;CAAE,GAC7B;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,MAAM,EAAE,WAAW,GAAG,SAAS,GAAG,kBAAkB,CAAA;CAAE,CAAC;AAExE,MAAM,WAAW,eAAe;IAC9B,0IAA0I;IAC1I,MAAM,CAAC,EAAE,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,EAAE,KAAK,EAAE,MAAM,GAAG,gBAAgB,CAAC;IACrE,MAAM,CAAC,EAAE,EAAE,MAAM,GAAG,oBAAoB,CAAC;IACzC,sFAAsF;IACtF,MAAM,CAAC,EAAE,EAAE,MAAM,GAAG,SAAS,GAAG,UAAU,GAAG,SAAS,GAAG,SAAS,CAAC;IACnE,6EAA6E;IAC7E,OAAO,IAAI,gBAAgB,EAAE,CAAC;CAC/B;AAED,MAAM,WAAW,kBAAkB;IACjC,kGAAkG;IAClG,KAAK,EAAE,aAAa,CAAC;IACrB,8EAA8E;IAC9E,KAAK,EAAE,OAAO,CAAC;IACf,GAAG,CAAC,EAAE,MAAM,MAAM,CAAC;IACnB,iBAAiB,EAAE,CAAC,KAAK,EAAE,OAAO,EAAE,KAAK,CAAC,EAAE,MAAM,MAAM,KAAK,WAAW,CAAC;CAC1E;AAED;;;;;;GAMG;AACH,wBAAgB,cAAc,CAAC,IAAI,EAAE,kBAAkB,GAAG,aAAa,CA2EtE"}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The hub's durable state — members and spent invitation ids — over an
|
|
3
|
+
* INJECTED `SnapshotStore`, with no idea where that store puts anything.
|
|
4
|
+
*
|
|
5
|
+
* WHY THIS IS ITS OWN FILE, SEPARATE FROM `./persist.ts`. The
|
|
6
|
+
* `SnapshotStore` seam exists so the same hub logic can run in a browser
|
|
7
|
+
* page (`../pages/hub/`, Task 24) as well as in the Node process, and a
|
|
8
|
+
* seam is only as portable as the module that declares it: `persist.ts`
|
|
9
|
+
* imports `node:fs`/`node:path` at module scope for its own file store, so
|
|
10
|
+
* a browser bundle that reached `createPersistentHub` dragged both in with
|
|
11
|
+
* it. Vite does not fail that build — it substitutes a stub that throws on
|
|
12
|
+
* first property access — so the failure would have surfaced at runtime, in
|
|
13
|
+
* a tab, as an error naming `node:fs` and nothing about the hub. Everything
|
|
14
|
+
* in THIS file is storage-agnostic and free of `node:` imports; `persist.ts`
|
|
15
|
+
* keeps the Node file store and re-exports all of this unchanged, so no
|
|
16
|
+
* existing caller (or test) sees a different API.
|
|
17
|
+
*
|
|
18
|
+
* PRESENCE IS DELIBERATELY NOT HERE. It is TTL'd and regenerates within one
|
|
19
|
+
* heartbeat interval of a restart — persisting it would only let a stale
|
|
20
|
+
* "who was online" view survive past the moment it stopped being true.
|
|
21
|
+
*
|
|
22
|
+
* INVITATIONS THEMSELVES ARE ALSO NOT HERE — only which ids have already
|
|
23
|
+
* been *spent*. An unredeemed invitation forgotten across a restart is a
|
|
24
|
+
* minor inconvenience (issue a new one); a redeemed one becoming redeemable
|
|
25
|
+
* again is a membership bypass. Persisting exactly the set that guards
|
|
26
|
+
* against the second, and nothing else, is the smallest state that closes
|
|
27
|
+
* the hole. See `createInvitationStore` below: the spent-id check runs
|
|
28
|
+
* *before* the invitation is even looked up, so a code whose invitation
|
|
29
|
+
* record did not survive a restart still cannot be redeemed twice.
|
|
30
|
+
*/
|
|
31
|
+
import { assertValid, validateRoles } from "@statewalker/httpeers-access";
|
|
32
|
+
/** What a store with nothing in it yet must read as — see `SnapshotStore.read`. */
|
|
33
|
+
export const EMPTY_SNAPSHOT = {
|
|
34
|
+
members: [],
|
|
35
|
+
spentInvitationIds: [],
|
|
36
|
+
pendingInvitations: [],
|
|
37
|
+
};
|
|
38
|
+
/**
|
|
39
|
+
* Wrap a `MemberStore` so every mutation is followed by a synchronous
|
|
40
|
+
* snapshot write — members and spent invitation ids share ONE snapshot, so
|
|
41
|
+
* one write keeps both consistent with each other by construction (no
|
|
42
|
+
* window where one persisted and the other did not). Presence is
|
|
43
|
+
* intentionally not part of this — see the module comment.
|
|
44
|
+
*/
|
|
45
|
+
export function createHubState(init) {
|
|
46
|
+
const now = init.now ?? Date.now;
|
|
47
|
+
const snapshot = init.store.read();
|
|
48
|
+
const innerMembers = init.createMemberStore(init.rules, now);
|
|
49
|
+
for (const m of snapshot.members)
|
|
50
|
+
innerMembers.add(m.peerId, m.roles);
|
|
51
|
+
const spentIds = new Set(snapshot.spentInvitationIds);
|
|
52
|
+
const save = () => {
|
|
53
|
+
init.store.write({
|
|
54
|
+
members: innerMembers.list(),
|
|
55
|
+
spentInvitationIds: [...spentIds],
|
|
56
|
+
pendingInvitations: [...pending.values()],
|
|
57
|
+
});
|
|
58
|
+
};
|
|
59
|
+
const memberStore = {
|
|
60
|
+
add(peerId, roles) {
|
|
61
|
+
const record = innerMembers.add(peerId, roles);
|
|
62
|
+
save();
|
|
63
|
+
return record;
|
|
64
|
+
},
|
|
65
|
+
setRoles(peerId, roles) {
|
|
66
|
+
const record = innerMembers.setRoles(peerId, roles);
|
|
67
|
+
save();
|
|
68
|
+
return record;
|
|
69
|
+
},
|
|
70
|
+
remove(peerId) {
|
|
71
|
+
innerMembers.remove(peerId);
|
|
72
|
+
save();
|
|
73
|
+
},
|
|
74
|
+
get: innerMembers.get,
|
|
75
|
+
list: innerMembers.list,
|
|
76
|
+
};
|
|
77
|
+
// Restored from the snapshot, not merely held in memory — see
|
|
78
|
+
// `HubSnapshot.pendingInvitations` for what that fixes.
|
|
79
|
+
const pending = new Map((snapshot.pendingInvitations ?? []).map((record) => [record.id, record]));
|
|
80
|
+
const invitations = {
|
|
81
|
+
create(id, roles, ttlMs) {
|
|
82
|
+
assertValid(validateRoles(init.rules, roles, `invitation '${id}'`));
|
|
83
|
+
const record = { id, roles: [...roles], expiresAt: now() + ttlMs };
|
|
84
|
+
pending.set(id, record);
|
|
85
|
+
save();
|
|
86
|
+
return record;
|
|
87
|
+
},
|
|
88
|
+
redeem(id) {
|
|
89
|
+
// The spent check runs FIRST, and unconditionally — before the
|
|
90
|
+
// invitation record is even looked up. That is what makes double
|
|
91
|
+
// redemption impossible across a restart, and it stays first now that
|
|
92
|
+
// pending records persist too: the two facts are written together, but
|
|
93
|
+
// "already spent" must win over "here is the record" whatever order they
|
|
94
|
+
// were loaded in.
|
|
95
|
+
if (spentIds.has(id))
|
|
96
|
+
return { ok: false, reason: "already-redeemed" };
|
|
97
|
+
const record = pending.get(id);
|
|
98
|
+
if (record == null)
|
|
99
|
+
return { ok: false, reason: "not-found" };
|
|
100
|
+
if (record.expiresAt <= now())
|
|
101
|
+
return { ok: false, reason: "expired" };
|
|
102
|
+
pending.delete(id);
|
|
103
|
+
spentIds.add(id);
|
|
104
|
+
save();
|
|
105
|
+
return { ok: true, roles: record.roles };
|
|
106
|
+
},
|
|
107
|
+
status(id) {
|
|
108
|
+
if (spentIds.has(id))
|
|
109
|
+
return "redeemed";
|
|
110
|
+
const record = pending.get(id);
|
|
111
|
+
if (record == null)
|
|
112
|
+
return "unknown";
|
|
113
|
+
return record.expiresAt <= now() ? "expired" : "unspent";
|
|
114
|
+
},
|
|
115
|
+
pending: () => [...pending.values()],
|
|
116
|
+
};
|
|
117
|
+
return { memberStore, invitations };
|
|
118
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minting membership, holding the registries, and saying who is in the mesh.
|
|
3
|
+
*/
|
|
4
|
+
export { type CreateHubInit, createHub, type Hub } from "./create-hub.js";
|
|
5
|
+
export { ADMIN_CAPABILITY, createHubEndpoints, DEFAULT_PRESENCE_TTL_MS, type HubEndpoints, type HubEndpointsInit, usesTransportIdentity, } from "./endpoints.js";
|
|
6
|
+
export { type CreateHubStateInit, createHubState, EMPTY_SNAPSHOT, type HubSnapshot, type InvitationRecord, type InvitationRedemption, type InvitationStore, type PersistentHub, type SnapshotStore, } from "./hub-state.js";
|
|
7
|
+
export { type AdvertisementPayload, buildMeshView } from "./mesh-view.js";
|
|
8
|
+
export { createAdvertisementStore, createMemberStore, createPresenceStore } from "./registries.js";
|
|
9
|
+
export { type AsyncSnapshotStore, asyncSnapshotStore, filesStorage, type KeyValueStorage, memoryStorage, } from "./storage.js";
|
|
10
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;GAEG;AAEH,OAAO,EAAE,KAAK,aAAa,EAAE,SAAS,EAAE,KAAK,GAAG,EAAE,MAAM,iBAAiB,CAAC;AAC1E,OAAO,EACL,gBAAgB,EAChB,kBAAkB,EAClB,uBAAuB,EACvB,KAAK,YAAY,EACjB,KAAK,gBAAgB,EACrB,qBAAqB,GACtB,MAAM,gBAAgB,CAAC;AACxB,OAAO,EACL,KAAK,kBAAkB,EACvB,cAAc,EACd,cAAc,EACd,KAAK,WAAW,EAChB,KAAK,gBAAgB,EACrB,KAAK,oBAAoB,EACzB,KAAK,eAAe,EACpB,KAAK,aAAa,EAClB,KAAK,aAAa,GACnB,MAAM,gBAAgB,CAAC;AACxB,OAAO,EAAE,KAAK,oBAAoB,EAAE,aAAa,EAAE,MAAM,gBAAgB,CAAC;AAC1E,OAAO,EAAE,wBAAwB,EAAE,iBAAiB,EAAE,mBAAmB,EAAE,MAAM,iBAAiB,CAAC;AACnG,OAAO,EACL,KAAK,kBAAkB,EACvB,kBAAkB,EAClB,YAAY,EACZ,KAAK,eAAe,EACpB,aAAa,GACd,MAAM,cAAc,CAAC"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minting membership, holding the registries, and saying who is in the mesh.
|
|
3
|
+
*/
|
|
4
|
+
export { createHub } from "./create-hub.js";
|
|
5
|
+
export { ADMIN_CAPABILITY, createHubEndpoints, DEFAULT_PRESENCE_TTL_MS, usesTransportIdentity, } from "./endpoints.js";
|
|
6
|
+
export { createHubState, EMPTY_SNAPSHOT, } from "./hub-state.js";
|
|
7
|
+
export { buildMeshView } from "./mesh-view.js";
|
|
8
|
+
export { createAdvertisementStore, createMemberStore, createPresenceStore } from "./registries.js";
|
|
9
|
+
export { asyncSnapshotStore, filesStorage, memoryStorage, } from "./storage.js";
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The mesh view: a PROJECTION over the three registries (members, presence,
|
|
3
|
+
* advertisements), not a fourth registry of its own. Nothing here is stored
|
|
4
|
+
* — it is recomputed from the hub's live state on every read, filtered by
|
|
5
|
+
* whoever is asking.
|
|
6
|
+
*
|
|
7
|
+
* THE HUB IS A BULLETIN BOARD, NEVER AN AUTHORITY. An advertisement says
|
|
8
|
+
* "worth asking"; the provider it names still decides for itself whether to
|
|
9
|
+
* answer. Nothing downstream may treat inclusion in this view as a grant —
|
|
10
|
+
* that is what the membership token and each provider's own policy
|
|
11
|
+
* are for.
|
|
12
|
+
*
|
|
13
|
+
* Two things vary by caller, both because different callers legitimately
|
|
14
|
+
* see different views:
|
|
15
|
+
* - a member with role `hidden` is omitted from the view of anyone who does
|
|
16
|
+
* not themselves hold `std:mesh.admin` — this is the one consumer of the
|
|
17
|
+
* `hidden` role `DEFAULT_RULES` already knows;
|
|
18
|
+
* - an advertisement is omitted unless the caller holds the capability that
|
|
19
|
+
* gates its `kind` (`advertisementAccess`, hub-owned config — analogous
|
|
20
|
+
* to an allow policy, but for the bulletin board rather than a path).
|
|
21
|
+
*/
|
|
22
|
+
import type { Advertisement, MemberRecord, MeshView, MeshViewAdvertisement, MeshViewMember, PresenceRecord } from "@statewalker/httpeers-core";
|
|
23
|
+
export type { MeshView, MeshViewAdvertisement, MeshViewMember };
|
|
24
|
+
/** The payload shape this hub posts advertisements with — see `hub/endpoints.ts`'s presence handler. */
|
|
25
|
+
export interface AdvertisementPayload {
|
|
26
|
+
kind: string;
|
|
27
|
+
title: string;
|
|
28
|
+
}
|
|
29
|
+
export interface BuildMeshViewInit {
|
|
30
|
+
version: number;
|
|
31
|
+
self: string;
|
|
32
|
+
members: MemberRecord[];
|
|
33
|
+
presence: PresenceRecord[];
|
|
34
|
+
addrsByPeer: ReadonlyMap<string, string[]>;
|
|
35
|
+
advertisements: Advertisement[];
|
|
36
|
+
/** Capabilities the caller holds (already derived from their roles by the node's rules). */
|
|
37
|
+
callerCapabilities: ReadonlySet<string>;
|
|
38
|
+
/** `kind` -> capability required to see advertisements of that kind. A kind absent here is visible to any authenticated caller. */
|
|
39
|
+
advertisementAccess: Readonly<Record<string, string>>;
|
|
40
|
+
/** The capability that grants admin visibility (sees `hidden` members). */
|
|
41
|
+
adminCapability: string;
|
|
42
|
+
}
|
|
43
|
+
export declare function buildMeshView(init: BuildMeshViewInit): MeshView;
|
|
44
|
+
//# sourceMappingURL=mesh-view.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"mesh-view.d.ts","sourceRoot":"","sources":["../src/mesh-view.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,OAAO,KAAK,EACV,aAAa,EACb,YAAY,EACZ,QAAQ,EACR,qBAAqB,EACrB,cAAc,EACd,cAAc,EACf,MAAM,4BAA4B,CAAC;AAMpC,YAAY,EAAE,QAAQ,EAAE,qBAAqB,EAAE,cAAc,EAAE,CAAC;AAEhE,wGAAwG;AACxG,MAAM,WAAW,oBAAoB;IACnC,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,MAAM,CAAC;CACf;AAED,MAAM,WAAW,iBAAiB;IAChC,OAAO,EAAE,MAAM,CAAC;IAChB,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,YAAY,EAAE,CAAC;IACxB,QAAQ,EAAE,cAAc,EAAE,CAAC;IAC3B,WAAW,EAAE,WAAW,CAAC,MAAM,EAAE,MAAM,EAAE,CAAC,CAAC;IAC3C,cAAc,EAAE,aAAa,EAAE,CAAC;IAChC,4FAA4F;IAC5F,kBAAkB,EAAE,WAAW,CAAC,MAAM,CAAC,CAAC;IACxC,mIAAmI;IACnI,mBAAmB,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;IACtD,2EAA2E;IAC3E,eAAe,EAAE,MAAM,CAAC;CACzB;AAED,wBAAgB,aAAa,CAAC,IAAI,EAAE,iBAAiB,GAAG,QAAQ,CAyB/D"}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export function buildMeshView(init) {
|
|
2
|
+
const isAdmin = init.callerCapabilities.has(init.adminCapability);
|
|
3
|
+
const present = new Set(init.presence.map((p) => p.peerId));
|
|
4
|
+
const members = init.members
|
|
5
|
+
.filter((m) => isAdmin || !m.roles.includes("hidden"))
|
|
6
|
+
.map((m) => ({
|
|
7
|
+
peerId: m.peerId,
|
|
8
|
+
roles: m.roles,
|
|
9
|
+
online: present.has(m.peerId),
|
|
10
|
+
addrs: init.addrsByPeer.get(m.peerId) ?? [],
|
|
11
|
+
}));
|
|
12
|
+
const advertisements = init.advertisements
|
|
13
|
+
.filter((ad) => {
|
|
14
|
+
const kind = ad.payload.kind;
|
|
15
|
+
const required = init.advertisementAccess[kind];
|
|
16
|
+
return required == null || init.callerCapabilities.has(required);
|
|
17
|
+
})
|
|
18
|
+
.map((ad) => {
|
|
19
|
+
const payload = ad.payload;
|
|
20
|
+
return { peerId: ad.peerId, id: ad.key, kind: payload.kind, title: payload.title };
|
|
21
|
+
});
|
|
22
|
+
return { version: init.version, self: init.self, members, advertisements };
|
|
23
|
+
}
|
package/dist/node.d.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
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 type { KeyValueStorage } from "./storage.js";
|
|
11
|
+
export declare function fileStorage(path: string): KeyValueStorage;
|
|
12
|
+
//# sourceMappingURL=node.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"node.d.ts","sourceRoot":"","sources":["../src/node.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAIH,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,cAAc,CAAC;AAEpD,wBAAgB,WAAW,CAAC,IAAI,EAAE,MAAM,GAAG,eAAe,CAyBzD"}
|