@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,200 @@
1
+ /**
2
+ * One constructor that returns the parts.
3
+ *
4
+ * THE API SPEC SAID THERE WOULD NOT BE A `createHub`, and the prototype proved
5
+ * it wrong by writing the assembly three times — the Node hub, the browser hub
6
+ * and the test hub. A hub is not a mount table: it is a mount table *plus* a
7
+ * snapshot store, a clock, a revocation registry, an invitation store, two
8
+ * registries with different consistency needs, and a sweep timer. Anything that
9
+ * hands back only the mounts leaves the other six to every caller.
10
+ *
11
+ * `mounts` is still separately usable, so nothing is walled off — the point is
12
+ * to stop each assembly re-deriving the wiring, not to hide it.
13
+ *
14
+ * THE ORDERING CYCLE, closed here because it cannot be closed by the caller.
15
+ * A hub's node must exist before `createHub` (the node needs a key; this needs
16
+ * `selfPeerId`), and the node's connection gater needs `hub.isMember`. So the
17
+ * documented order is:
18
+ *
19
+ * key -> node with a LATE-BOUND gater -> createHub -> servePeer
20
+ * -> point the gater's thunk at hub.isMember
21
+ *
22
+ * `membershipGater` takes a thunk for exactly this reason; see its comment in
23
+ * `httpeers-libp2p`.
24
+ */
25
+
26
+ import type { RuleSet } from "@statewalker/httpeers-access";
27
+ import { RevocationRegistry } from "@statewalker/httpeers-access/issuer";
28
+ import type { FetchHandler, MeshView, Mounts, PeerIdStr } from "@statewalker/httpeers-core";
29
+ import { createHubEndpoints, type HubEndpointsInit } from "./endpoints.js";
30
+ import {
31
+ createHubState,
32
+ type InvitationRecord,
33
+ type InvitationRedemption,
34
+ type PersistentHub,
35
+ } from "./hub-state.js";
36
+ import { createMemberStore } from "./registries.js";
37
+ import { asyncSnapshotStore, type KeyValueStorage, memoryStorage } from "./storage.js";
38
+
39
+ export interface CreateHubInit {
40
+ /** This hub's own peerId — the mesh identity every token restates. */
41
+ selfPeerId: PeerIdStr;
42
+ /** Mint a membership token that self-certifies as this mesh. */
43
+ mintToken: HubEndpointsInit["mintToken"];
44
+ /** Where members and spent invitation ids are persisted. Defaults to memory. */
45
+ storage?: KeyValueStorage;
46
+ /** This mesh's Datalog rules. Injected, never global — there is no default rule set. */
47
+ policies: RuleSet;
48
+ now?: () => number;
49
+ presenceTtlMs?: number;
50
+ /**
51
+ * Expire stale presence on a timer. Omit to drive `sweep()` yourself, which
52
+ * is what a test with a controlled clock wants.
53
+ */
54
+ sweepIntervalMs?: number;
55
+ extraMounts?: Record<string, FetchHandler>;
56
+ ownAdvertisements?: Array<{ id: string; kind: string; title: string }>;
57
+ advertisementAccess?: Record<string, string>;
58
+ /** Longest life of any token this hub mints. Sets the revocation pruning horizon. */
59
+ maxTokenTtlMs?: number;
60
+ }
61
+
62
+ export interface Hub {
63
+ /** The `.well-known` surface and `/admin`, plus whatever `extraMounts` added. */
64
+ mounts: Mounts;
65
+ /** Expire stale presence and the advertisements that rode along with it. */
66
+ sweep(): void;
67
+ /** The very view `GET /.well-known/mesh` serves, read in-process. */
68
+ meshView(caller?: PeerIdStr): MeshView;
69
+ /**
70
+ * The operator-facing shape (spec §9), which differs from the store's:
71
+ * the hub generates the id, and every mutator resolves when the write is
72
+ * DURABLE — so a caller is never told "created" before it is, and an
73
+ * invitation handed out over the phone cannot be lost to a crash a
74
+ * millisecond later.
75
+ */
76
+ invitations: {
77
+ create(
78
+ roles: string[],
79
+ ttlMs: number,
80
+ opts?: { id?: string },
81
+ ): Promise<{ id: string; expiresAt: number }>;
82
+ redeem(id: string): Promise<InvitationRedemption>;
83
+ status(id: string): "unspent" | "redeemed" | "expired" | "unknown";
84
+ pending(): InvitationRecord[];
85
+ };
86
+ members: PersistentHub["memberStore"];
87
+ isMember(peerId: PeerIdStr): boolean;
88
+ revocations: RevocationRegistry;
89
+ rules: RuleSet;
90
+ reset(): Promise<void>;
91
+ stop(): Promise<void>;
92
+ }
93
+
94
+ /**
95
+ * An invitation id.
96
+ *
97
+ * 128 bits from the platform CSPRNG, base36. An invitation is a bearer
98
+ * credential for membership, so a guessable id is a way into the mesh —
99
+ * `Math.random` is not acceptable here and the crypto global is available on
100
+ * every runtime this package targets.
101
+ */
102
+ function newInvitationId(): string {
103
+ const bytes = crypto.getRandomValues(new Uint8Array(16));
104
+ return [...bytes].map((b) => b.toString(16).padStart(2, "0")).join("");
105
+ }
106
+
107
+ export async function createHub(init: CreateHubInit): Promise<Hub> {
108
+ const now = init.now ?? Date.now;
109
+ const storage = init.storage ?? memoryStorage();
110
+
111
+ // Storage is async; the snapshot seam is not. An in-memory authoritative
112
+ // copy with serialised flushes behind it is what lets `SnapshotStore.write`
113
+ // stay synchronous while the durable write is still awaited by whoever
114
+ // mutated — so a caller is never told "added" before it is.
115
+ const snapshots = await asyncSnapshotStore(storage);
116
+
117
+ const state = createHubState({
118
+ store: snapshots,
119
+ rules: init.policies,
120
+ now,
121
+ createMemberStore,
122
+ });
123
+
124
+ const revocations = new RevocationRegistry({
125
+ maxTokenTtlMs: init.maxTokenTtlMs ?? 24 * 60 * 60 * 1000,
126
+ now,
127
+ });
128
+
129
+ const endpoints = createHubEndpoints({
130
+ selfPeerId: init.selfPeerId,
131
+ mintToken: init.mintToken,
132
+ memberStore: state.memberStore,
133
+ invitations: state.invitations,
134
+ rules: init.policies,
135
+ revocations,
136
+ advertisementAccess: init.advertisementAccess,
137
+ extraMounts: init.extraMounts,
138
+ ownAdvertisements: init.ownAdvertisements,
139
+ presenceTtlMs: init.presenceTtlMs,
140
+ now,
141
+ });
142
+
143
+ // The timer is optional so a test can drive `sweep()` with a controlled
144
+ // clock. A hub that also sweeps on a real timer would race that test.
145
+ let timer: ReturnType<typeof setInterval> | undefined;
146
+ if (init.sweepIntervalMs != null) {
147
+ timer = setInterval(() => endpoints.sweep(), init.sweepIntervalMs);
148
+ // Never hold a process open for a sweep. A CLI that finished its work
149
+ // should exit, and an interval that keeps it alive is a hang nobody
150
+ // attributes to the hub.
151
+ (timer as unknown as { unref?: () => void }).unref?.();
152
+ }
153
+
154
+ let stopped = false;
155
+ return {
156
+ mounts: endpoints.mounts,
157
+ sweep: endpoints.sweep,
158
+ meshView: endpoints.meshView,
159
+ invitations: {
160
+ async create(roles, ttlMs, opts) {
161
+ // A caller may name the id — a deployment migrating existing codes, or
162
+ // a test wanting a constant — but the hub generates one by default so
163
+ // nobody has to invent unguessable strings.
164
+ const id = opts?.id ?? newInvitationId();
165
+ const record = state.invitations.create(id, roles, ttlMs);
166
+ await snapshots.flushed();
167
+ return { id: record.id, expiresAt: record.expiresAt };
168
+ },
169
+ async redeem(id) {
170
+ const result = state.invitations.redeem(id);
171
+ // Flush even on refusal: a redemption that FAILED still may have
172
+ // consumed nothing, but one that succeeded must be durable before the
173
+ // caller is told it is a member.
174
+ await snapshots.flushed();
175
+ return result;
176
+ },
177
+ status: (id) => state.invitations.status(id),
178
+ pending: () => state.invitations.pending(),
179
+ },
180
+ members: state.memberStore,
181
+ isMember: (peerId) => state.memberStore.get(peerId) !== undefined,
182
+ revocations,
183
+ rules: init.policies,
184
+ async reset() {
185
+ // Forget every member and every spent invitation id. The next start
186
+ // founds a fresh mesh under the SAME peerId — which is the one thing
187
+ // this cannot undo, and why it is a separate call rather than something
188
+ // `stop()` does.
189
+ for (const record of state.memberStore.list()) state.memberStore.remove(record.peerId);
190
+ await snapshots.flushed();
191
+ },
192
+ async stop() {
193
+ // Idempotent: an assembly that tears down twice — a page unloading while
194
+ // a test also cleans up — must not see the second call throw.
195
+ if (stopped) return;
196
+ stopped = true;
197
+ if (timer != null) clearInterval(timer);
198
+ },
199
+ };
200
+ }
@@ -0,0 +1,476 @@
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
+
19
+ import type { RuleSet } from "@statewalker/httpeers-access";
20
+ import { capabilityNames, deriveCapabilities } from "@statewalker/httpeers-access";
21
+ import type { RevocationRegistry } from "@statewalker/httpeers-access/issuer";
22
+ import type {
23
+ Advertisement,
24
+ AdvertisementStore,
25
+ FetchHandler,
26
+ MemberStore,
27
+ Mounts,
28
+ PeerIdStr,
29
+ PresenceStore,
30
+ UsesTransportIdentity,
31
+ } from "@statewalker/httpeers-core";
32
+ import {
33
+ ANONYMOUS,
34
+ createMounts,
35
+ json,
36
+ lookupClaims,
37
+ lookupPeer,
38
+ } from "@statewalker/httpeers-core";
39
+ import { Hono } from "hono";
40
+ import { createAdminEndpoints } from "./admin.js";
41
+ // `./hub-state.js`, not `./persist.js`: the latter is the NODE facade (it
42
+ // imports `node:fs`), and this module is bundled into the browser hub page
43
+ // (`../pages/hub/`) as well as into the Node hub. The import is type-only
44
+ // and therefore erased either way, but pointing it at the portable half is
45
+ // what stops a future value import here from quietly breaking that page.
46
+ import type { InvitationStore } from "./hub-state.js";
47
+ import type { AdvertisementPayload, MeshView } from "./mesh-view.js";
48
+ import { buildMeshView } from "./mesh-view.js";
49
+ import { createAdvertisementStore, createPresenceStore } from "./registries.js";
50
+
51
+ /** The capability that grants admin visibility — sees `hidden` members and gates `/admin/*` (Task 8's `DELETE /admin/members/{peerId}` included). */
52
+ export const ADMIN_CAPABILITY = "std:mesh.admin";
53
+
54
+ /** Presence TTL: an entry not refreshed within this window is swept. */
55
+ export const DEFAULT_PRESENCE_TTL_MS = 15_000;
56
+
57
+ /** Exactly two handlers, method-aware: a presence WRITE is bootstrap, a read is not. */
58
+ export function usesTransportIdentity(): UsesTransportIdentity {
59
+ return async (req: Request) => {
60
+ if (req.method !== "POST") return false;
61
+ const { pathname } = new URL(req.url);
62
+ return pathname === "/.well-known/invite" || pathname === "/.well-known/presence";
63
+ };
64
+ }
65
+
66
+ /**
67
+ * The diagnostic `/test` surface: `/test/whoami` and `/test/echo`, per the
68
+ * archived `12-httpeers-prototype-validated/src/endpoints.ts` (lines 128
69
+ * and 138). NOT hub-specific — every peer in that archive served this
70
+ * unconditionally, hub or not, and Task 7b's promoted E2E suites need the
71
+ * same: their "provider" peer (an ordinary member, never a hub) must answer
72
+ * `/test/whoami` with this rich shape too.
73
+ *
74
+ * This is deliberately NOT `httpeers.core`'s own default diagnostic mount
75
+ * (`peer.ts`'s `defaultMounts`, also `/test/whoami`) — that one answers
76
+ * `{ peer, sub }` from the transport/binding alone and knows nothing about
77
+ * a minted token's `roles`/`mesh`. The archived suites assert `body.you`,
78
+ * `body.roles`, `body.mesh` — fields the core's default does not have and
79
+ * is not meant to grow, since the core must stay hub-agnostic. Any peer
80
+ * that wants this richer surface mounts this handler explicitly, the same
81
+ * way `createHubEndpoints` below does for the hub itself.
82
+ */
83
+ export function createTestSurfaceHandler(selfPeerId: PeerIdStr): FetchHandler {
84
+ const app = new Hono();
85
+
86
+ app.get("/test/whoami", (c) => {
87
+ const claims = lookupClaims(c.req.raw) ?? null;
88
+ return json({
89
+ servedBy: selfPeerId,
90
+ you: claims?.sub ?? null,
91
+ roles: claims?.roles ?? [],
92
+ mesh: claims?.mesh ?? null,
93
+ });
94
+ });
95
+
96
+ app.post("/test/echo", async (c) => json({ method: c.req.method, body: await c.req.text() }));
97
+
98
+ return app.fetch as FetchHandler;
99
+ }
100
+
101
+ export interface HubEndpointsInit {
102
+ selfPeerId: PeerIdStr;
103
+ /**
104
+ * Mint a membership token that self-certifies as this mesh. Structurally
105
+ * `MountsFactoryContext["mintToken"]` from `httpeers.core`, restated here
106
+ * rather than imported so this file stays a plain description of what the
107
+ * hub needs -- including the audience option ADR-0020 added, which nothing
108
+ * in this file passes yet: the invitation and presence protocols mint
109
+ * unrestricted tokens, and narrowing them is a protocol change, not a
110
+ * signature change.
111
+ */
112
+ mintToken: (
113
+ sub: string,
114
+ roles: string[],
115
+ options?: { ttlMs?: number; audience?: readonly PeerIdStr[] },
116
+ ) => Promise<string>;
117
+ memberStore: MemberStore;
118
+ invitations: InvitationStore;
119
+ /** This mesh's Datalog rules and policies (ADR-0019) — published read-only, and the source of the caller's capabilities in the mesh view. */
120
+ rules: RuleSet;
121
+ revocations: RevocationRegistry;
122
+ /** `kind` -> capability required to see advertisements of that kind. Defaults to `{}` (no kind gated). */
123
+ advertisementAccess?: Record<string, string>;
124
+ presenceTtlMs?: number;
125
+ now?: () => number;
126
+ /**
127
+ * Application mounts served alongside the hub's own `.well-known` surface
128
+ * and `/admin`, behind the same access checks.
129
+ *
130
+ * The prototype hard-mounted a demo `/search` endpoint here, which made the
131
+ * library import the application. A hub is membership and the bulletin
132
+ * board; what else a particular deployment serves is not its business.
133
+ */
134
+ extraMounts?: Record<string, FetchHandler>;
135
+ /**
136
+ * What this hub advertises about ITSELF on the bulletin board.
137
+ *
138
+ * Posted once at construction, because the hub is the one peer that never
139
+ * sends a heartbeat — and a heartbeat's `advertisements` array is otherwise
140
+ * the only way anything reaches that store. The prototype learned this the
141
+ * hard way: its `/search` mount was served and gated but INVISIBLE, so a
142
+ * consumer filtering `/.well-known/mesh` by `kind` found the image peer and
143
+ * nothing else.
144
+ *
145
+ * A hub that serves a route while withholding its advertisement is a
146
+ * half-wired state, so a deployment passing `extraMounts` almost always
147
+ * wants a matching entry here. These never need withdrawing: `sweep`
148
+ * withdraws only for peers whose PRESENCE expired, and a hub has no presence
149
+ * record of its own.
150
+ */
151
+ ownAdvertisements?: Array<{ id: string; kind: string; title: string }>;
152
+ }
153
+
154
+ export interface HubEndpoints {
155
+ mounts: Mounts;
156
+ /** Expire stale presence and the advertisements that rode along with it; bumps the mesh version only if something actually left. Call on a timer (production) or directly with a controlled clock (tests). */
157
+ sweep: () => void;
158
+ /**
159
+ * The very view `GET /.well-known/mesh` serves, read in-process.
160
+ *
161
+ * FOR A UI THAT IS ALREADY INSIDE THE HUB, and for nothing else. The
162
+ * browser hub page (`../pages/hub/`) renders the member list from this;
163
+ * going through the HTTP endpoint for it would mean the hub minting
164
+ * itself a token and dispatching a request to itself, purely so it could
165
+ * read three registries it is holding two references away. The HTTP
166
+ * endpoint remains the ONLY way any other peer sees this — this accessor
167
+ * adds no route, no capability, and no way in from the network.
168
+ *
169
+ * IT IS `buildMeshView`, NOT A SECOND OPINION. "Saved" (a member record,
170
+ * from the persisted snapshot) and "active" (`online`, from the TTL'd
171
+ * presence store) are two different facts, and `mesh-view.ts` already
172
+ * computes the second per member. A UI that re-derived `online` by
173
+ * joining a member list against a presence list would be inventing a
174
+ * parallel notion that could disagree with the one every remote peer
175
+ * reads — the exact kind of divergence that turns into a debugging trap.
176
+ *
177
+ * THE HUB'S OWN VIEW SEES EVERYTHING: every capability the rules can
178
+ * derive is passed as the caller's, so `hidden` members and every gated
179
+ * advertisement are included. There is nobody to hide from — this is the
180
+ * machine that holds the list, and an operator shown a filtered version
181
+ * of their own mesh would be misled about what they are administering.
182
+ *
183
+ * Live, not a snapshot: a swept peer is `online: false` on the next call,
184
+ * and a removed member is gone from it, which is what a polling UI wants.
185
+ */
186
+ meshView: () => MeshView;
187
+ }
188
+
189
+ interface PresenceBody {
190
+ seq: number;
191
+ addrs: string[];
192
+ advertisements?: Array<{ id: string; kind: string; title: string }>;
193
+ }
194
+
195
+ function sameAddrs(a: readonly string[] | undefined, b: readonly string[]): boolean {
196
+ if (a == null) return false;
197
+ if (a.length !== b.length) return false;
198
+ return a.every((v, i) => v === b[i]);
199
+ }
200
+
201
+ function sameAdvertisements(
202
+ prev: Advertisement[],
203
+ next: ReadonlyArray<{ id: string; kind: string; title: string }>,
204
+ ): boolean {
205
+ if (prev.length !== next.length) return false;
206
+ const prevById = new Map(prev.map((a) => [a.key, a.payload as AdvertisementPayload]));
207
+ return next.every((ad) => {
208
+ const p = prevById.get(ad.id);
209
+ return p != null && p.kind === ad.kind && p.title === ad.title;
210
+ });
211
+ }
212
+
213
+ export function createHubEndpoints(init: HubEndpointsInit): HubEndpoints {
214
+ const now = init.now ?? Date.now;
215
+ const presenceTtlMs = init.presenceTtlMs ?? DEFAULT_PRESENCE_TTL_MS;
216
+ const advertisementAccess = init.advertisementAccess ?? {};
217
+
218
+ const presenceStore: PresenceStore = createPresenceStore(now);
219
+ const advertisementStore: AdvertisementStore = createAdvertisementStore(now);
220
+ const addrsByPeer = new Map<PeerIdStr, string[]>();
221
+
222
+ // Whatever this deployment says about itself, posted once — see
223
+ // `ownAdvertisements` for why this is the only path onto the board for a
224
+ // peer that never heartbeats.
225
+ for (const ad of init.ownAdvertisements ?? []) {
226
+ advertisementStore.post(init.selfPeerId, ad.id, {
227
+ kind: ad.kind,
228
+ title: ad.title,
229
+ } satisfies AdvertisementPayload);
230
+ }
231
+
232
+ // Tracks the highest `seq` EVER accepted for a peer, independent of
233
+ // `presenceStore` — which the TTL sweep clears. Without this, a delayed
234
+ // duplicate of an old heartbeat arriving AFTER the peer has already been
235
+ // swept would look like a brand-new write (no record exists to compare
236
+ // against) and resurrect a peer that has genuinely departed. This map is
237
+ // never cleared by sweep, only ever advanced.
238
+ const lastSeqByPeer = new Map<PeerIdStr, number>();
239
+
240
+ // The one counter this whole module exists to bump correctly: it moves
241
+ // only when the OBSERVABLE mesh view could have changed (a member added,
242
+ // a peer's online/addrs state changed, an advertisement changed, or the
243
+ // sweep removed someone) — never on a heartbeat that repeats what the hub
244
+ // already knew, which is what keeps `/.well-known/mesh`'s ETag stable
245
+ // between identical heartbeats instead of churning every 5 s.
246
+ let meshVersion = 1;
247
+ const bumpMesh = (): void => {
248
+ meshVersion++;
249
+ };
250
+
251
+ const claimsOf = (req: Request) => lookupClaims(req) ?? null;
252
+ const provenPeer = (req: Request): PeerIdStr => {
253
+ const peer = lookupPeer(req);
254
+ if (peer === undefined || peer === ANONYMOUS) {
255
+ throw new Error("hub bootstrap handler reached without a transport-proven peer");
256
+ }
257
+ return peer;
258
+ };
259
+
260
+ const withdrawAllFor = (peerId: PeerIdStr): void => {
261
+ for (const ad of advertisementStore.list(peerId)) advertisementStore.withdraw(peerId, ad.key);
262
+ };
263
+
264
+ const app = new Hono();
265
+
266
+ // --- BOOTSTRAP: no token exists yet ---------------------------------------
267
+
268
+ app.post("/.well-known/invite", async (c) => {
269
+ const peerId = provenPeer(c.req.raw);
270
+ const { id } = await c.req.json<{ id: string }>();
271
+
272
+ const redemption = init.invitations.redeem(id);
273
+ if (!redemption.ok) {
274
+ return json({ error: `invitation ${redemption.reason}` }, 403);
275
+ }
276
+
277
+ init.memberStore.add(peerId, redemption.roles);
278
+ bumpMesh();
279
+
280
+ return json({
281
+ token: await init.mintToken(peerId, redemption.roles),
282
+ mesh: init.selfPeerId,
283
+ roles: redemption.roles,
284
+ });
285
+ });
286
+
287
+ // --- BOOTSTRAP: mints the token, so cannot itself require one -------------
288
+
289
+ app.post("/.well-known/presence", async (c) => {
290
+ const peerId = provenPeer(c.req.raw);
291
+ const member = init.memberStore.get(peerId);
292
+ if (member == null) return json({ error: "not a member" }, 403);
293
+
294
+ const body = await c.req.json<PresenceBody>();
295
+
296
+ const lastSeq = lastSeqByPeer.get(peerId);
297
+ if (lastSeq !== undefined && body.seq <= lastSeq) {
298
+ return json({ error: "stale-sequence" }, 409);
299
+ }
300
+ lastSeqByPeer.set(peerId, body.seq);
301
+
302
+ const wasPresent = presenceStore.isPresent(peerId);
303
+ const prevAddrs = addrsByPeer.get(peerId);
304
+ // Result intentionally ignored: `lastSeqByPeer` above already decided
305
+ // whether this write is stale, using a guard that survives the TTL
306
+ // sweep clearing `presenceStore`'s own record (see that map's doc
307
+ // comment) — so `presenceStore`'s own monotonic check can only agree.
308
+ presenceStore.heartbeat(peerId, body.seq, presenceTtlMs);
309
+ addrsByPeer.set(peerId, body.addrs);
310
+
311
+ let changed = !wasPresent || !sameAddrs(prevAddrs, body.addrs);
312
+
313
+ if (body.advertisements !== undefined) {
314
+ const prevAds = advertisementStore.list(peerId);
315
+ if (!sameAdvertisements(prevAds, body.advertisements)) {
316
+ withdrawAllFor(peerId);
317
+ for (const ad of body.advertisements) {
318
+ const payload: AdvertisementPayload = { kind: ad.kind, title: ad.title };
319
+ advertisementStore.post(peerId, ad.id, payload);
320
+ }
321
+ changed = true;
322
+ }
323
+ }
324
+
325
+ if (changed) bumpMesh();
326
+
327
+ return json({
328
+ token: await init.mintToken(peerId, member.roles),
329
+ versions: {
330
+ mesh: meshVersion,
331
+ policy: init.revocations.policyVersion(),
332
+ rules: init.rules.version,
333
+ },
334
+ // `ttl` is unchanged context from the archive (`CHANGES-v0.8.0.txt`'s
335
+ // heartbeat shape `{ token, versions, meshVersion, ttl }`), not part
336
+ // of that delta -- unlike `meshVersion`, which WAS superseded by
337
+ // `versions.mesh` and correctly dropped (Ruling R31). It lets a
338
+ // client take its heartbeat interval from the server rather than
339
+ // hardcoding it.
340
+ ttl: presenceTtlMs,
341
+ });
342
+ });
343
+
344
+ // --- ORDINARY: identity comes from the verified token ----------------------
345
+
346
+ app.get("/.well-known/members", (_c) => json({ members: init.memberStore.list() }));
347
+
348
+ app.get("/.well-known/presence", (_c) =>
349
+ json({
350
+ presence: presenceStore.list().map((p) => ({ ...p, addrs: addrsByPeer.get(p.peerId) ?? [] })),
351
+ }),
352
+ );
353
+
354
+ app.get("/.well-known/advertisements", (_c) =>
355
+ json({
356
+ advertisements: advertisementStore.list().map((ad) => {
357
+ const payload = ad.payload as AdvertisementPayload;
358
+ return { peerId: ad.peerId, id: ad.key, kind: payload.kind, title: payload.title };
359
+ }),
360
+ }),
361
+ );
362
+
363
+ // ONE expression, two readers: this endpoint and `HubEndpoints.meshView`
364
+ // below. They must not drift -- an in-process UI showing a different
365
+ // membership or a different `online` from the one every remote peer reads
366
+ // would be a debugging trap rather than a diagnostic.
367
+ const viewFor = (self: PeerIdStr, callerCapabilities: ReadonlySet<string>): MeshView =>
368
+ buildMeshView({
369
+ version: meshVersion,
370
+ self,
371
+ members: init.memberStore.list(),
372
+ presence: presenceStore.list(),
373
+ addrsByPeer,
374
+ advertisements: advertisementStore.list(),
375
+ callerCapabilities,
376
+ advertisementAccess,
377
+ adminCapability: ADMIN_CAPABILITY,
378
+ });
379
+
380
+ app.get("/.well-known/mesh", (c) => {
381
+ const claims = claimsOf(c.req.raw);
382
+ if (claims == null) return json({ error: "unauthenticated" }, 401);
383
+
384
+ const etag = `"v${meshVersion}"`;
385
+ if (c.req.header("if-none-match") === etag) {
386
+ return new Response(null, { status: 304, headers: { etag } });
387
+ }
388
+
389
+ const view = viewFor(claims.sub, deriveCapabilities(init.rules, claims.roles));
390
+
391
+ return new Response(JSON.stringify(view), {
392
+ headers: { "content-type": "application/json", etag },
393
+ });
394
+ });
395
+
396
+ // ADR-0016's amendment: the rules are published, still read-only in the
397
+ // strict sense -- what this returns influences no decision, here or on any
398
+ // other peer, which is why no signing scheme is needed for it. It is served
399
+ // for discovery, debugging and the explainability of a denial.
400
+ app.get("/.well-known/rules", (c) => {
401
+ const etag = `"rules${init.rules.version}"`;
402
+ if (c.req.header("if-none-match") === etag) {
403
+ return new Response(null, { status: 304, headers: { etag } });
404
+ }
405
+ return new Response(JSON.stringify(init.rules), {
406
+ headers: { "content-type": "application/json", etag },
407
+ });
408
+ });
409
+
410
+ app.get("/.well-known/revocations", (c) => {
411
+ const version = init.revocations.policyVersion();
412
+ const etag = `"policy${version}"`;
413
+ if (c.req.header("if-none-match") === etag) {
414
+ return new Response(null, { status: 304, headers: { etag } });
415
+ }
416
+ return new Response(JSON.stringify({ version, entries: init.revocations.list() }), {
417
+ headers: { "content-type": "application/json", etag },
418
+ });
419
+ });
420
+
421
+ // --- admin-only, to exercise the policy middleware -----------------------
422
+ // Archive: `12/src/endpoints.ts` line 122. Listing only — invitations are
423
+ // created programmatically (`InvitationStore.create`, `persist.ts`), never
424
+ // over HTTP; see this app's `PROVENANCE.md`.
425
+
426
+ app.get("/admin/invitations", (c) =>
427
+ json({ ok: true, issuedBy: init.selfPeerId, caller: claimsOf(c.req.raw)?.sub }),
428
+ );
429
+
430
+ app.all("*", (c) => json({ error: "not found", path: new URL(c.req.raw.url).pathname }, 404));
431
+
432
+ const mounts = createMounts();
433
+ mounts.provide("/.well-known", app.fetch as FetchHandler);
434
+ mounts.provide("/test", createTestSurfaceHandler(init.selfPeerId));
435
+ mounts.provide("/admin", app.fetch as FetchHandler);
436
+ // Longer prefix wins (R-1): "/admin/members" is more specific than the
437
+ // "/admin" mount above, so DELETE /admin/members/{peerId} reaches
438
+ // admin.ts's handler while GET /admin/invitations keeps hitting `app`.
439
+ mounts.provide(
440
+ "/admin/members",
441
+ createAdminEndpoints({ memberStore: init.memberStore, revocations: init.revocations }),
442
+ );
443
+ // Revocation for `/search` (and every other route on this hub) is NOT
444
+ // handled here. Policy (`policy.ts`) only ever checks the CAPABILITY a
445
+ // token's roles expand to, which cannot see a membership change made
446
+ // after the token was minted -- but the fix for that is a single seam on
447
+ // this peer's own `createPeer` (`hub/main.ts`'s `revocationCache:
448
+ // revocations`, using `RevocationRegistry`'s own `check`, added in
449
+ // `httpeers.core` alongside this task -- see that package's
450
+ // `revocation.ts`), applied uniformly to EVERY mount by the binding
451
+ // middleware before any handler runs. An earlier version of this file put
452
+ // a bespoke membership check on this one mount instead; that covered only
453
+ // the route being built and silently left every other hub endpoint
454
+ // (including `/admin/*`) still honouring a revoked token until it
455
+ // expired -- removed on review. See `hub/main.ts` and this app's
456
+ // `PROVENANCE.md`.
457
+ for (const [prefix, handler] of Object.entries(init.extraMounts ?? {})) {
458
+ mounts.provide(prefix, handler);
459
+ }
460
+
461
+ return {
462
+ mounts,
463
+ // Every capability the rules can derive, so the hub's own view is
464
+ // unfiltered -- see this field's doc comment on `HubEndpoints`.
465
+ meshView: () => viewFor(init.selfPeerId, new Set(capabilityNames(init.rules))),
466
+ sweep() {
467
+ const expired = presenceStore.sweep();
468
+ if (expired.length === 0) return;
469
+ for (const peerId of expired) {
470
+ addrsByPeer.delete(peerId);
471
+ withdrawAllFor(peerId);
472
+ }
473
+ bumpMesh();
474
+ },
475
+ };
476
+ }