@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,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
|
+
}
|
package/src/endpoints.ts
ADDED
|
@@ -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
|
+
}
|