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