From e2f2713898bd2dc8741796d8e40a8f529ce608b5 Mon Sep 17 00:00:00 2001 From: xNet Test Date: Mon, 20 Jul 2026 14:10:37 -0700 Subject: [PATCH 01/14] fix(hub): fuse federated results before deduplicating so RRF rewards cross-hub agreement MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extracts fuseFederatedResults as a pure exported function and swaps the order: reciprocal rank fusion now sees every per-source copy of a result, and deduplication keeps one representative per cid afterwards (preferring the local copy). Previously deduplicateByCid ran first, collapsing each document to a single source, so fusion degenerated into a rank transform of one hub's ordering — a document three hubs agreed on earned the same credit as a single-source hit (explorations 0367/0383 W0). Signed-off-by: xNet Test --- ...EVERYTHING_THE_ROLE_IMPLEMENTATION_PLAN.md | 2 +- packages/hub/src/services/federation.ts | 84 ++++++++++--------- packages/hub/test/federation-rrf.test.ts | 83 ++++++++++++++++++ 3 files changed, 130 insertions(+), 39 deletions(-) create mode 100644 packages/hub/test/federation-rrf.test.ts diff --git a/docs/explorations/0383_[_]_TURNING_HUBS_INTO_EVERYTHING_THE_ROLE_IMPLEMENTATION_PLAN.md b/docs/explorations/0383_[_]_TURNING_HUBS_INTO_EVERYTHING_THE_ROLE_IMPLEMENTATION_PLAN.md index 6b45dad21..76e351319 100644 --- a/docs/explorations/0383_[_]_TURNING_HUBS_INTO_EVERYTHING_THE_ROLE_IMPLEMENTATION_PLAN.md +++ b/docs/explorations/0383_[_]_TURNING_HUBS_INTO_EVERYTHING_THE_ROLE_IMPLEMENTATION_PLAN.md @@ -287,7 +287,7 @@ federation plane grows (0305-style thinking, deferred)? ## Implementation Checklist ### W0 — paved road -- [ ] RRF fuse-then-collapse + cross-hub-agreement test (federation.ts:364/375). +- [x] RRF fuse-then-collapse + cross-hub-agreement test (federation.ts:364/375). - [ ] Document the hub-PR tax (fragment command, no changeset, e2e flake + rerun). ### W1 — roles diff --git a/packages/hub/src/services/federation.ts b/packages/hub/src/services/federation.ts index e123bdf0f..39c7083cb 100644 --- a/packages/hub/src/services/federation.ts +++ b/packages/hub/src/services/federation.ts @@ -88,6 +88,51 @@ const toBase64 = (data: Uint8Array): string => Buffer.from(data).toString('base6 const fromBase64 = (value: string): Uint8Array => new Uint8Array(Buffer.from(value, 'base64')) +/** + * Fuse multi-source results with reciprocal rank fusion, THEN collapse + * duplicates (explorations 0367/0383). + * + * Order matters: RRF's whole purpose is to reward documents that several + * sources independently returned. Deduplicating by cid *before* fusion + * collapses each document to a single source, so fusion degenerates into a + * rank transform of one hub's ordering and cross-hub agreement is discarded — + * the bug this replaces. Every copy must reach fusion; only then is one + * representative kept (the local copy when present, so provenance favours + * what this hub can serve directly). + */ +export function fuseFederatedResults(results: FederatedResult[], k = 60): FederatedResult[] { + const bySource = new Map() + for (const result of results) { + const list = bySource.get(result.sourceHub) ?? [] + list.push(result) + bySource.set(result.sourceHub, list) + } + + for (const list of bySource.values()) { + list.sort((a, b) => b.score - a.score) + } + + const rrfScores = new Map() + for (const [, list] of bySource) { + for (let rank = 0; rank < list.length; rank++) { + const current = rrfScores.get(list[rank].cid) ?? 0 + rrfScores.set(list[rank].cid, current + 1 / (k + rank + 1)) + } + } + + const seen = new Map() + for (const result of results) { + const existing = seen.get(result.cid) + if (!existing || (existing.sourceHub !== 'local' && result.sourceHub === 'local')) { + seen.set(result.cid, result) + } + } + + return [...seen.values()] + .map((result) => ({ ...result, score: rrfScores.get(result.cid) ?? 0 })) + .sort((a, b) => b.score - a.score) +} + export class FederationService { private rateLimiters = new Map() @@ -177,8 +222,7 @@ export class FederationService { results.push(...fedResults) } - const deduped = this.deduplicateByCid(results) - const ranked = this.reciprocalRankFusion(deduped) + const ranked = fuseFederatedResults(results) const responseResults = ranked.map((result) => ({ docId: result.nodeId, title: result.title, @@ -361,42 +405,6 @@ export class FederationService { } } - private deduplicateByCid(results: FederatedResult[]): FederatedResult[] { - const seen = new Map() - for (const result of results) { - const existing = seen.get(result.cid) - if (!existing || result.score > existing.score) { - seen.set(result.cid, result) - } - } - return [...seen.values()] - } - - private reciprocalRankFusion(results: FederatedResult[], k = 60): FederatedResult[] { - const bySource = new Map() - for (const result of results) { - const list = bySource.get(result.sourceHub) ?? [] - list.push(result) - bySource.set(result.sourceHub, list) - } - - for (const list of bySource.values()) { - list.sort((a, b) => b.score - a.score) - } - - const rrfScores = new Map() - for (const [, list] of bySource) { - for (let rank = 0; rank < list.length; rank++) { - const current = rrfScores.get(list[rank].cid) ?? 0 - rrfScores.set(list[rank].cid, current + 1 / (k + rank + 1)) - } - } - - return results - .map((result) => ({ ...result, score: rrfScores.get(result.cid) ?? 0 })) - .sort((a, b) => b.score - a.score) - } - private checkRateLimit(key: string, maxPerMinute: number): boolean { const now = Date.now() const limiter = this.rateLimiters.get(key) diff --git a/packages/hub/test/federation-rrf.test.ts b/packages/hub/test/federation-rrf.test.ts new file mode 100644 index 000000000..530731ca6 --- /dev/null +++ b/packages/hub/test/federation-rrf.test.ts @@ -0,0 +1,83 @@ +/** + * RRF fusion order (explorations 0367/0383, W0). + * + * The bug this guards against: deduplicating by cid BEFORE reciprocal rank + * fusion collapses each document to a single source, so a document three hubs + * agree on earns fusion credit from only one list — discarding cross-source + * agreement, which is RRF's entire purpose. Fusion must see every copy; + * deduplication happens after, keeping one representative per cid. + */ +import { describe, expect, it } from 'vitest' +import { fuseFederatedResults, type FederatedResult } from '../src/services/federation' + +const result = ( + cid: string, + sourceHub: string, + score: number, + overrides: Partial = {} +): FederatedResult => ({ + nodeId: cid, + cid, + score, + title: cid, + schema: 'xnet://xnet.fyi/Page@1.0.0', + snippet: '', + author: '', + updatedAt: 0, + sourceHub, + ...overrides +}) + +describe('fuseFederatedResults', () => { + it('rewards cross-hub agreement: a doc two hubs return outranks a single-source top hit', () => { + // Source A ranks Y first and X second; sources B and C both rank X first. + // Agreement should win: X gets credit from three lists, Y from one. + const fused = fuseFederatedResults([ + result('Y', 'hub-a', 10), + result('X', 'hub-a', 5), + result('X', 'hub-b', 9), + result('X', 'hub-c', 8) + ]) + + const x = fused.find((r) => r.cid === 'X') + const y = fused.find((r) => r.cid === 'Y') + expect(x).toBeDefined() + expect(y).toBeDefined() + // Strict: under the old dedupe-first order X kept only its highest-score + // copy (one source), earning the same 1/(k+1) as Y — a tie at best. + expect(x!.score).toBeGreaterThan(y!.score) + expect(fused[0]!.cid).toBe('X') + }) + + it('returns one row per cid after fusion', () => { + const fused = fuseFederatedResults([ + result('X', 'hub-a', 5), + result('X', 'hub-b', 9), + result('Z', 'hub-b', 3) + ]) + expect(fused.map((r) => r.cid).sort()).toEqual(['X', 'Z']) + }) + + it('prefers the local copy as the surviving representative', () => { + const fused = fuseFederatedResults([ + result('X', 'hub-b', 9, { snippet: 'remote' }), + result('X', 'local', 2, { snippet: 'local' }) + ]) + expect(fused).toHaveLength(1) + expect(fused[0]!.sourceHub).toBe('local') + expect(fused[0]!.snippet).toBe('local') + }) + + it('fused scores are per-cid sums of 1/(k+rank+1) across sources', () => { + const k = 60 + const fused = fuseFederatedResults( + [result('X', 'hub-a', 5), result('X', 'hub-b', 9), result('Y', 'hub-a', 10)], + k + ) + const x = fused.find((r) => r.cid === 'X')! + const y = fused.find((r) => r.cid === 'Y')! + // hub-a: [Y, X] → Y rank 0, X rank 1; hub-b: [X] → X rank 0. + expect(x.score).toBeCloseTo(1 / (k + 2) + 1 / (k + 1), 10) + expect(y.score).toBeCloseTo(1 / (k + 1), 10) + }) +}) From 808239d92dc8769b5b0bdd67058d49e7c2a319eb Mon Sep 17 00:00:00 2001 From: xNet Test Date: Mon, 20 Jul 2026 14:10:58 -0700 Subject: [PATCH 02/14] docs(hub): record the hub-PR shipping tax in the package README Signed-off-by: xNet Test --- ...EVERYTHING_THE_ROLE_IMPLEMENTATION_PLAN.md | 2 +- packages/hub/README.md | 21 +++++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/docs/explorations/0383_[_]_TURNING_HUBS_INTO_EVERYTHING_THE_ROLE_IMPLEMENTATION_PLAN.md b/docs/explorations/0383_[_]_TURNING_HUBS_INTO_EVERYTHING_THE_ROLE_IMPLEMENTATION_PLAN.md index 76e351319..c8fff2794 100644 --- a/docs/explorations/0383_[_]_TURNING_HUBS_INTO_EVERYTHING_THE_ROLE_IMPLEMENTATION_PLAN.md +++ b/docs/explorations/0383_[_]_TURNING_HUBS_INTO_EVERYTHING_THE_ROLE_IMPLEMENTATION_PLAN.md @@ -288,7 +288,7 @@ federation plane grows (0305-style thinking, deferred)? ### W0 — paved road - [x] RRF fuse-then-collapse + cross-hub-agreement test (federation.ts:364/375). -- [ ] Document the hub-PR tax (fragment command, no changeset, e2e flake + rerun). +- [x] Document the hub-PR tax (fragment command, no changeset, e2e flake + rerun). ### W1 — roles - [ ] `roles.ts` with `personal`/`demo`/`community`/`index`/`registry`; `--role` + `HUB_ROLE`; preset spread in `resolveConfig`. diff --git a/packages/hub/README.md b/packages/hub/README.md index 15cb9b45a..72c77816f 100644 --- a/packages/hub/README.md +++ b/packages/hub/README.md @@ -195,3 +195,24 @@ wins over the env-generated one. Backup freshness is published on `GET /health` ```bash pnpm --filter @xnetjs/hub test ``` + +> Note: run tests through the **root** vitest config (`pnpm vitest run +> packages/hub/test/`) — the per-package filter breaks project +> resolution. + +## Shipping a hub change (the PR tax) + +`@xnetjs/hub` is `private: true`, so hub-only changes take **no changeset** +(confirm with `node scripts/changeset/publishable-pathspec.mjs`). Two things +ARE required (exploration 0383 W0): + +1. **A changelog fragment** whenever behaviour is user-visible: + `node scripts/changelog/new.mjs --title "…" --summary "…" --tags platform,sync` + (valid tags are `KNOWN_TAGS` in that script). Pure refactors/CI can use the + `skip-changelog` PR label instead. +2. **Expect one `electron-e2e` rerun.** The `xnet://` deep-link case + (`electron-smoke.spec.ts:161`) times out flakily and the lane runs + `--fail-on-flaky-tests`, so a single timeout reds the PR on identical code. + Before debugging, check `git log --oneline HEAD..origin/main` — if your + delta is docs-only or unrelated, it is the flake: + `gh run rerun --failed`. From 24e12e8800746cede4dac0a9839da924d54f5cc3 Mon Sep 17 00:00:00 2001 From: xNet Test Date: Mon, 20 Jul 2026 14:18:23 -0700 Subject: [PATCH 03/14] =?UTF-8?q?feat(hub):=20named=20role=20presets=20?= =?UTF-8?q?=E2=80=94=20one=20binary,=20many=20roles=20(0382/0383=20W1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds HUB_ROLES (personal/demo/community/index/registry) as config presets spread into resolveConfig between DEFAULT_CONFIG and explicit options, so precedence is preset < config < flags and a role can never override an operator's explicit choice. --role and HUB_ROLE select a role; --demo and HUB_MODE=demo remain as aliases for --role demo. Demo mode is now a preset plus per-cap resolvers (resolveMaxBlobBytes, resolveDiskWatchdogBytes, resolveResetIntervalMs, resolveResetOnCorruption, resolveHandshakeDemoLimits) following #603's one-resolver rule — server.ts carries zero open-coded demo ternaries. Federation, shards and crawl are reachable from the CLI for the first time via the community and registry presets; the index preset pins the legacy search stack off (0367). The startup banner and /health/badge label are role-aware, and railway.toml moves the live demo hub to --role demo (byte-identical config, proven by test). Records the R6 decision: the per-user cap stays universal, not managed-only. Signed-off-by: xNet Test --- ...EVERYTHING_THE_ROLE_IMPLEMENTATION_PLAN.md | 10 +- packages/hub/src/cli.ts | 9 +- packages/hub/src/config.ts | 64 ++++++++++++- packages/hub/src/index.ts | 3 +- packages/hub/src/roles.ts | 65 +++++++++++++ packages/hub/src/server.ts | 49 +++++----- packages/hub/src/types.ts | 27 ++++-- packages/hub/test/health-metadata.test.ts | 5 +- packages/hub/test/roles.test.ts | 94 +++++++++++++++++++ railway.toml | 2 +- 10 files changed, 289 insertions(+), 39 deletions(-) create mode 100644 packages/hub/src/roles.ts create mode 100644 packages/hub/test/roles.test.ts diff --git a/docs/explorations/0383_[_]_TURNING_HUBS_INTO_EVERYTHING_THE_ROLE_IMPLEMENTATION_PLAN.md b/docs/explorations/0383_[_]_TURNING_HUBS_INTO_EVERYTHING_THE_ROLE_IMPLEMENTATION_PLAN.md index c8fff2794..6d3837e38 100644 --- a/docs/explorations/0383_[_]_TURNING_HUBS_INTO_EVERYTHING_THE_ROLE_IMPLEMENTATION_PLAN.md +++ b/docs/explorations/0383_[_]_TURNING_HUBS_INTO_EVERYTHING_THE_ROLE_IMPLEMENTATION_PLAN.md @@ -291,11 +291,11 @@ federation plane grows (0305-style thinking, deferred)? - [x] Document the hub-PR tax (fragment command, no changeset, e2e flake + rerun). ### W1 — roles -- [ ] `roles.ts` with `personal`/`demo`/`community`/`index`/`registry`; `--role` + `HUB_ROLE`; preset spread in `resolveConfig`. -- [ ] Demo converted; zero `demo ?` ternaries in `server.ts`; `--demo` aliased. -- [ ] Federation/shards/crawl reachable via presets; startup banner shows role. -- [ ] Railway demo on `--role demo`, behaviour byte-identical. -- [ ] Decide R6 (self-hosted quota scope) and record it. +- [x] `roles.ts` with `personal`/`demo`/`community`/`index`/`registry`; `--role` + `HUB_ROLE`; preset spread in `resolveConfig`. +- [x] Demo converted; zero `demo ?` ternaries in `server.ts`; `--demo` aliased. +- [x] Federation/shards/crawl reachable via presets; startup banner shows role. +- [x] Railway demo on `--role demo`, behaviour byte-identical. +- [x] Decide R6 (self-hosted quota scope) and record it. ### W2 — feature modules - [ ] Four optional hooks on `HubFeature`; registry owns loops/shutdown. diff --git a/packages/hub/src/cli.ts b/packages/hub/src/cli.ts index f8d58ef7c..24679830a 100644 --- a/packages/hub/src/cli.ts +++ b/packages/hub/src/cli.ts @@ -5,6 +5,7 @@ import type { HubConfig } from './types' import { Command } from 'commander' import { resolveConfig } from './config' +import { HUB_ROLES } from './roles' import { registerShutdownHandlers } from './lifecycle/shutdown' import { DEFAULT_CONFIG } from './types' import { createHub } from './index' @@ -74,7 +75,11 @@ const run = async (): Promise => { String(DEFAULT_CONFIG.discoveryMaxPeers) ) .option('--log-level ', 'log level (debug|info|warn|error)', DEFAULT_CONFIG.logLevel) - .option('--demo', 'enable demo mode (restricted quotas, auto-eviction)') + .option( + '--role ', + `named deployment role (${Object.keys(HUB_ROLES).join('|')}) — explorations 0382/0383` + ) + .option('--demo', '[deprecated] alias for --role demo') .action(async (opts) => { const config: Partial = { port: parseNumber(opts.port, DEFAULT_CONFIG.port), @@ -104,6 +109,7 @@ const run = async (): Promise => { ), discoveryMaxPeers: parseNumber(opts.discoveryMaxPeers, DEFAULT_CONFIG.discoveryMaxPeers), logLevel: opts.logLevel, + role: opts.role, demo: opts.demo ?? false } @@ -125,6 +131,7 @@ const run = async (): Promise => { console.log(` Health: http://localhost:${hub.port}/health`) console.log(` Auth: ${resolved.auth ? 'UCAN' : 'anonymous'}`) console.log(` Storage: ${resolved.storage} (${resolved.dataDir})`) + console.log(` Role: ${resolved.role ?? 'personal'}`) if (resolved.demo) { console.log( ` Mode: DEMO (quota=${resolved.demoOverrides?.quota ?? 0} bytes, eviction=${resolved.demoOverrides?.evictionTtl ?? 0}ms TTL)` diff --git a/packages/hub/src/config.ts b/packages/hub/src/config.ts index 8760190eb..970caeeea 100644 --- a/packages/hub/src/config.ts +++ b/packages/hub/src/config.ts @@ -2,8 +2,9 @@ * @xnetjs/hub - Configuration resolution. */ -import type { HubConfig, DemoOverrides } from './types' +import type { HubConfig, HubRole, DemoOverrides } from './types' import { entitlementsFromEnv } from '@xnetjs/entitlements' +import { HUB_ROLES, isHubRole, rolePreset } from './roles' import { DEFAULT_CONFIG, DEMO_DEFAULTS } from './types' const toNumber = (value: string | undefined): number | undefined => { @@ -107,10 +108,53 @@ export const getDemoOverrides = (isDemo: boolean): DemoOverrides | null => { * One function so a new grower cannot pick a different cap than the meter the * dashboard shows — the change log did exactly that and went ungated on every * non-demo hub. + * + * DECIDED (0383 W1, R6): the cap applies to self-hosted hubs too — a + * self-hosted operator gets `DEFAULT_CONFIG.defaultQuota` (1 GiB/user) unless + * they raise it, for consistency with backups and files rather than a special + * uncapped change log. Operators who want no cap set `defaultQuota` explicitly; + * the discriminator for ever narrowing this to managed-only is `HUB_PLAN` + * presence, and that narrowing was considered and declined. */ export const resolvePerUserQuota = (config: HubConfig): number => config.demo && config.demoOverrides ? config.demoOverrides.quota : config.defaultQuota +// ─── Per-cap resolvers (0383 W1) ───────────────────────────────────────────── +// The #603 rule, applied wholesale: every demo-vs-plan decision is made HERE, +// once, by name. Server code calls a resolver and branches on its result; it +// never re-derives `demo ? x : y` inline (0382's "demo ternaries" anti-pattern +// — the open-coded copies are exactly how the change-log quota gate was missed). + +/** Per-user blob ceiling: the demo override, else the plan/config ceiling. */ +export const resolveMaxBlobBytes = (config: HubConfig): number => + config.demo && config.demoOverrides ? config.demoOverrides.maxBlob : config.maxBlobSize + +/** Disk-watchdog budget; `null` = no watchdog (watchdog stays demo-only, 0291). */ +export const resolveDiskWatchdogBytes = (config: HubConfig): number | null => + config.demo && config.demoOverrides ? config.demoOverrides.diskLimitBytes : null + +/** Periodic full-reset cadence; `null` = never (demo's disposable volume only). */ +export const resolveResetIntervalMs = (config: HubConfig): number | null => + config.demo && config.demoOverrides ? config.demoOverrides.resetInterval : null + +/** Whether a corrupt base DB is wiped-and-rebooted instead of crash-looping. */ +export const resolveResetOnCorruption = (config: HubConfig): boolean => !!config.demo + +/** The handshake's advertised per-user limits; `undefined` outside demo. */ +export const resolveHandshakeDemoLimits = ( + config: HubConfig +): + | { quotaBytes: number; maxDocs: number; maxBlobBytes: number; evictionTtlMs: number } + | undefined => + config.demo && config.demoOverrides + ? { + quotaBytes: config.demoOverrides.quota, + maxDocs: config.demoOverrides.maxDocs, + maxBlobBytes: config.demoOverrides.maxBlob, + evictionTtlMs: config.demoOverrides.evictionTtl + } + : undefined + /** * Resolve Hub configuration from environment variables, CLI flags, and defaults. */ @@ -153,7 +197,19 @@ export const resolveConfig = (cliOptions: Partial): HubConfig => { cliOptions.awarenessMaxUpdateSize ?? DEFAULT_CONFIG.awarenessMaxUpdateSize - const demo = cliOptions.demo ?? process.env.HUB_MODE === 'demo' + // Role resolution (0382/0383 W1): explicit flag/env wins; the legacy `--demo` + // flag and `HUB_MODE=demo` env are aliases for `--role demo`. + const rawRole = cliOptions.role ?? process.env.HUB_ROLE + if (rawRole !== undefined && !isHubRole(rawRole)) { + throw new Error( + `Unknown hub role: ${JSON.stringify(rawRole)} (valid: ${Object.keys(HUB_ROLES).join(', ')})` + ) + } + const legacyDemo = cliOptions.demo ?? process.env.HUB_MODE === 'demo' + const role: HubRole = rawRole ?? (legacyDemo ? 'demo' : 'personal') + const preset = rolePreset(role) + + const demo = preset.demo ?? legacyDemo const demoOverrides = getDemoOverrides(demo) ?? undefined // Loudly flag security-relevant footguns at startup (exploration 0307). These @@ -176,6 +232,9 @@ export const resolveConfig = (cliOptions: Partial): HubConfig => { return { ...DEFAULT_CONFIG, + // Role preset (0383 W1): between defaults and explicit options, so a role + // can never override a choice the operator made by hand. + ...preset, ...cliOptions, // Plan-driven quotas (managed fleet) override defaults but not explicit fields below. ...resolvePlanLimits(), @@ -198,6 +257,7 @@ export const resolveConfig = (cliOptions: Partial): HubConfig => { .filter(Boolean) ?? cliOptions.androidCertSha256, runtime, shutdownGraceMs, + role, demo, demoOverrides } diff --git a/packages/hub/src/index.ts b/packages/hub/src/index.ts index 56217f48b..f10eb1a3d 100644 --- a/packages/hub/src/index.ts +++ b/packages/hub/src/index.ts @@ -9,9 +9,10 @@ import { createServer } from './server' import { DEFAULT_CONFIG } from './types' export { resolveConfig } from './config' -export type { HubConfig, HubInstance, DemoOverrides } from './types' +export type { HubConfig, HubInstance, HubRole, DemoOverrides } from './types' export { DEMO_DEFAULTS } from './types' export { getDemoOverrides } from './config' +export { HUB_ROLES, isHubRole, rolePreset } from './roles' export type { YjsEnvelopeV2Verifier, YjsEnvelopeV2VerifierContext, diff --git a/packages/hub/src/roles.ts b/packages/hub/src/roles.ts new file mode 100644 index 000000000..cbe2cf0b1 --- /dev/null +++ b/packages/hub/src/roles.ts @@ -0,0 +1,65 @@ +/** + * @xnetjs/hub - Named role presets (explorations 0382/0383, W1). + * + * "Everything is a hub": one binary, and a role is nothing but a named + * `Partial` spread into `resolveConfig`'s merge chain between + * `DEFAULT_CONFIG` and explicit options — so precedence is always + * preset < explicit config < CLI flags/env, and a role can never override a + * choice the operator made by hand. + * + * Rules (0383): + * - A role is a preset here, NEVER a runtime branch. Any behaviour that would + * need `if (role === …)` in server code must instead become config that a + * preset sets and a resolver reads (the #603 `resolvePerUserQuota` pattern). + * - The named presets are the only supported combinations; unlisted config + * mixes remain possible programmatically but are unclaimed. + * - `index` state discipline and the `gateway` role land with 0383 W3/W4. + */ + +import type { HubConfig, HubRole } from './types' + +export const HUB_ROLES: Record> = { + /** The default: a person's (or small team's) hub. Nothing extra on. */ + personal: {}, + + /** + * The public demo hub: restricted per-user quotas, disk watchdog, periodic + * reset — a disposable-volume preset, formerly the `--demo` flag. + */ + demo: { demo: true }, + + /** + * A community hub (0359/0382): participates in federated search so its + * public face is discoverable across the fleet. The public-interaction + * surface joins this preset in 0383 W2. + */ + community: { federation: { enabled: true } }, + + /** + * The Index (0374/0382): reads PUBLIC atproto records and serves derived + * state only. The legacy hub search stack stays OFF here — 0367 documented + * its defects, and the index plane must never depend on it. The + * `atprotoIndex` engine module arrives in 0383 W3. + */ + index: { + federation: { enabled: false }, + shards: { enabled: false }, + crawl: { enabled: false } + }, + + /** + * The search-infrastructure coordinator: owns the shard ring + * (`isRegistry` — 0305's epoch nonce lives here) and coordinates the web + * crawl queue that feeds shard ingest. + */ + registry: { + shards: { enabled: true, isRegistry: true }, + crawl: { enabled: true } + } +} + +export const isHubRole = (value: string): value is HubRole => value in HUB_ROLES + +/** The preset for a role; `personal` (empty) for undefined. */ +export const rolePreset = (role: HubRole | undefined): Partial => + role ? HUB_ROLES[role] : HUB_ROLES.personal diff --git a/packages/hub/src/server.ts b/packages/hub/src/server.ts index a7ce9f475..9de55cc97 100644 --- a/packages/hub/src/server.ts +++ b/packages/hub/src/server.ts @@ -27,7 +27,14 @@ import { removeSession, toAuthContext } from './auth/ucan' -import { resolvePerUserQuota } from './config' +import { + resolveDiskWatchdogBytes, + resolveHandshakeDemoLimits, + resolveMaxBlobBytes, + resolvePerUserQuota, + resolveResetIntervalMs, + resolveResetOnCorruption +} from './config' import { measureDataUsage, type DataUsage } from './data-usage' import { aiForwarderFeature } from './features/ai-forwarder' import { diagnosticsInboxFeature } from './features/diagnostics-inbox' @@ -204,19 +211,20 @@ export const createServer = async (config: HubConfig): Promise => { // and boot rather than crash-loop (exploration 0206 follow-up). A real // self-host / production hub never does this. const storage = await createStorage(config.storage, config.dataDir, { - resetOnCorruption: !!config.demo + resetOnCorruption: resolveResetOnCorruption(config) }) - // In demo mode, every per-user cap comes from the demo overrides (10 MB / - // 2 MB by default), not the 1 GB plan quota — otherwise a single visitor can - // fill the small demo volume (exploration 0291). - const demo = config.demo ? config.demoOverrides : undefined + // Every per-user cap goes through a config resolver — the single place the + // demo-override-vs-plan choice is made (#603's rule; 0383 W1). Server code + // never re-derives `demo ? x : y` inline. const perUserQuota = resolvePerUserQuota(config) - const maxBlobBytes = demo ? demo.maxBlob : config.maxBlobSize - // Demo-only: watch the data dir and shed relay writes before the volume fills - // (a full SQLite volume crashes the hub — exploration 0291 / the 0290 502). - const diskWatchdog = demo - ? new DiskWatchdog({ dataDir: config.dataDir, maxBytes: demo.diskLimitBytes }) - : null + const maxBlobBytes = resolveMaxBlobBytes(config) + // Watchdog budget is demo-only (0291): watch the data dir and shed relay + // writes before the small disposable volume fills (the 0290 502). + const diskWatchdogBytes = resolveDiskWatchdogBytes(config) + const diskWatchdog = + diskWatchdogBytes !== null + ? new DiskWatchdog({ dataDir: config.dataDir, maxBytes: diskWatchdogBytes }) + : null const isStorageFull = diskWatchdog ? () => diskWatchdog.isFull() : undefined const pool = new NodePool(storage, { isStorageFull }) const relayIdentity = generateIdentity() @@ -507,7 +515,7 @@ export const createServer = async (config: HubConfig): Promise => { return c.json({ schemaVersion: 1, - label: 'demo hub', + label: `${config.role ?? 'personal'} hub`, message: `online · ${uptimeStr}`, color: 'brightgreen' }) @@ -925,7 +933,8 @@ export const createServer = async (config: HubConfig): Promise => { } // Demo hub: guard the small disposable volume — watch disk usage and wipe // all user data on a fixed cadence so it can't grow unbounded (0291). - if (demo && diskWatchdog) { + const resetIntervalMs = resolveResetIntervalMs(config) + if (resetIntervalMs !== null && diskWatchdog) { diskWatchdog.start() demoResetInterval = setInterval(() => { storage @@ -936,7 +945,7 @@ export const createServer = async (config: HubConfig): Promise => { error: err instanceof Error ? err.message : String(err) }) ) - }, demo.resetInterval) + }, resetIntervalMs) demoResetInterval.unref?.() } await schemas.seedBuiltInSchemas([ @@ -1043,13 +1052,9 @@ export const createServer = async (config: HubConfig): Promise => { hubDid: config.hubDid, isDemo: !!config.demo } - if (config.demo && config.demoOverrides) { - handshake.demoLimits = { - quotaBytes: config.demoOverrides.quota, - maxDocs: config.demoOverrides.maxDocs, - maxBlobBytes: config.demoOverrides.maxBlob, - evictionTtlMs: config.demoOverrides.evictionTtl - } + const demoLimits = resolveHandshakeDemoLimits(config) + if (demoLimits) { + handshake.demoLimits = demoLimits } if (ws.readyState === 1) { ws.send(JSON.stringify(handshake)) diff --git a/packages/hub/src/types.ts b/packages/hub/src/types.ts index 00ecba9f9..439ef47b1 100644 --- a/packages/hub/src/types.ts +++ b/packages/hub/src/types.ts @@ -113,12 +113,12 @@ export type HubConfig = { } /** Log level (default: 'info'). */ logLevel: 'debug' | 'info' | 'warn' | 'error' - /** Federation configuration (optional). */ - federation?: FederationConfig - /** Global shard configuration (optional). */ - shards?: ShardConfig - /** Crawl coordination configuration (optional). */ - crawl?: CrawlConfig + /** Federation configuration (optional; merged over server defaults). */ + federation?: Partial + /** Global shard configuration (optional; merged over server defaults). */ + shards?: Partial + /** Crawl coordination configuration (optional; merged over server defaults). */ + crawl?: Partial /** Runtime metadata (platform info, region). */ runtime?: { platform?: 'railway' | 'fly' | 'cloud-run' | 'fargate' | 'local' | 'unknown' @@ -131,8 +131,23 @@ export type HubConfig = { demo?: boolean /** Demo mode overrides (applied when demo=true). */ demoOverrides?: DemoOverrides + /** + * Named deployment role (explorations 0382/0383). A role is a config preset + * expanded by `resolveConfig` — never a runtime branch of its own. Roles are + * the ONLY supported feature combinations; arbitrary config remains possible + * but unclaimed (the Elasticsearch `node.roles` posture). + */ + role?: HubRole } +/** + * The named roles one hub binary can run as (exploration 0382: one binary, + * many roles, monolith default). `gateway` arrives with the federation plane + * (0383 W4); adding a role means adding a preset in `roles.ts`, never a + * scattered ternary (0382's "demo ternaries" anti-pattern). + */ +export type HubRole = 'personal' | 'demo' | 'community' | 'index' | 'registry' + export const DEFAULT_CONFIG: HubConfig = { port: 4444, dataDir: './xnet-hub-data', diff --git a/packages/hub/test/health-metadata.test.ts b/packages/hub/test/health-metadata.test.ts index 13aed0d81..473f0123b 100644 --- a/packages/hub/test/health-metadata.test.ts +++ b/packages/hub/test/health-metadata.test.ts @@ -41,7 +41,10 @@ describe('Health metadata', () => { color: string } expect(body.schemaVersion).toBe(1) - expect(body.label).toBe('demo hub') + // The badge label is role-aware (0383 W1). This hub boots with no role, so + // it reports the default; the live demo hub (--role demo) still reads + // "demo hub", byte-identical to the pre-role hardcoded label. + expect(body.label).toBe('personal hub') expect(body.message).toMatch(/^online · \d+m$|^online · \d+h \d+m$/) expect(body.color).toBe('brightgreen') }) diff --git a/packages/hub/test/roles.test.ts b/packages/hub/test/roles.test.ts new file mode 100644 index 000000000..4b335bdaa --- /dev/null +++ b/packages/hub/test/roles.test.ts @@ -0,0 +1,94 @@ +/** + * Named role presets (explorations 0382/0383, W1). + * + * Three properties under test: + * 1. `--role demo` is byte-identical to the legacy `--demo` flag — the Railway + * migration proof (0383 W1's definition of done). + * 2. Every named preset resolves and boots; the named presets are the ONLY + * supported combinations (the Elasticsearch `node.roles` posture). + * 3. Precedence is preset < explicit config < flags: a role never overrides a + * choice the operator made by hand. + */ +import { afterEach, describe, expect, it } from 'vitest' +import { resolveConfig } from '../src/config' +import { createHub } from '../src/index' +import { HUB_ROLES } from '../src/roles' +import type { HubRole } from '../src/types' + +const baseOptions = { port: 0, storage: 'memory' as const, dataDir: '/tmp/xnet-role-test' } + +describe('hub roles (0382/0383 W1)', () => { + afterEach(() => { + delete process.env.HUB_ROLE + delete process.env.HUB_MODE + }) + + it('--role demo resolves identically to the legacy --demo flag', () => { + const viaRole = resolveConfig({ ...baseOptions, role: 'demo' }) + const viaLegacy = resolveConfig({ ...baseOptions, demo: true }) + // The legacy path resolves role to 'demo' too, so the entire configs match. + expect(viaLegacy.role).toBe('demo') + expect(viaRole).toEqual(viaLegacy) + }) + + it('HUB_MODE=demo env continues to work as an alias', () => { + process.env.HUB_MODE = 'demo' + const resolved = resolveConfig({ ...baseOptions }) + expect(resolved.role).toBe('demo') + expect(resolved.demo).toBe(true) + expect(resolved.demoOverrides).toBeDefined() + }) + + it('HUB_ROLE env selects the role', () => { + process.env.HUB_ROLE = 'registry' + const resolved = resolveConfig({ ...baseOptions }) + expect(resolved.role).toBe('registry') + expect(resolved.shards?.enabled).toBe(true) + expect(resolved.shards?.isRegistry).toBe(true) + }) + + it('rejects an unknown role loudly', () => { + expect(() => resolveConfig({ ...baseOptions, role: 'blogosphere' as never })).toThrow( + /Unknown hub role/ + ) + }) + + it('defaults to personal with nothing extra enabled', () => { + const resolved = resolveConfig({ ...baseOptions }) + expect(resolved.role).toBe('personal') + expect(resolved.demo).toBe(false) + expect(resolved.federation).toBeUndefined() + expect(resolved.shards).toBeUndefined() + expect(resolved.crawl).toBeUndefined() + }) + + it('community enables federation; index pins the legacy search stack off', () => { + expect(resolveConfig({ ...baseOptions, role: 'community' }).federation?.enabled).toBe(true) + const index = resolveConfig({ ...baseOptions, role: 'index' }) + expect(index.federation?.enabled).toBe(false) + expect(index.shards?.enabled).toBe(false) + expect(index.crawl?.enabled).toBe(false) + }) + + it('explicit config overrides the preset (preset < config < flags)', () => { + const resolved = resolveConfig({ + ...baseOptions, + role: 'community', + federation: { enabled: false } + }) + expect(resolved.federation?.enabled).toBe(false) + }) + + it('every named preset resolves and boots', async () => { + let port = 14480 + for (const role of Object.keys(HUB_ROLES) as HubRole[]) { + const resolved = resolveConfig({ ...baseOptions, port: port++, auth: false, role }) + expect(resolved.role).toBe(role) + const hub = await createHub(resolved) + await hub.start() + const health = await fetch(`http://localhost:${resolved.port}/health`) + expect(health.ok, `role ${role} /health`).toBe(true) + await hub.stop() + } + }) +}) diff --git a/railway.toml b/railway.toml index cdf94b550..d913b0ff2 100644 --- a/railway.toml +++ b/railway.toml @@ -17,7 +17,7 @@ dockerfilePath = "packages/hub/Dockerfile" # variable: recreating the Railway service silently drops variables, and # without a public URL share links mint as http://localhost:/s/... # (2026-07-10 outage recovery). HUB_PUBLIC_URL still overrides when set. -startCommand = "sh -c 'cd /app && node packages/hub/dist/cli.js --port $PORT --data /data --demo --public-url wss://hub.xnet.fyi'" +startCommand = "sh -c 'cd /app && node packages/hub/dist/cli.js --port $PORT --data /data --role demo --public-url wss://hub.xnet.fyi'" healthcheckPath = "/health" healthcheckTimeout = 30 restartPolicyType = "on_failure" From 4507e8f50312d645045c2e4f30bd36bc7579f969 Mon Sep 17 00:00:00 2001 From: xNet Test Date: Mon, 20 Jul 2026 14:27:54 -0700 Subject: [PATCH 04/14] =?UTF-8?q?feat(hub):=20HubFeature=20v2=20=E2=80=94?= =?UTF-8?q?=20infra=20subsystems=20as=20feature=20modules=20(0383=20W2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Grows the mount-only HubFeature contract with four optional hooks — services, loops, ws, storage — with the registry owning loop start/stop (reverse-order shutdown, failures isolated) and storage running before mount with table-prefix ownership enforced at the DDL seam (fed_/crawl_/idx_/sub_/pi_). Existing features remain valid unchanged. Federation, shards and crawl now assemble through the registry like the integration features: mounts and lifecycle moved out of server.ts's imperative wiring into closed-over feature definitions. Adds the public-interactions feature (0378's read surface, born as a module): GET /public/interactions/:nodeId resolves the author's PublicInteractionPolicy — found O(1) at the new deterministic publicInteractionPolicyId in @xnetjs/data — falling back to schema defaults, 404ing non-public nodes exactly like the public read surface. On in the community and index role presets. Signed-off-by: xNet Test --- ...EVERYTHING_THE_ROLE_IMPLEMENTATION_PLAN.md | 8 +- packages/data/src/index.ts | 1 + packages/data/src/schema/index.ts | 1 + packages/data/src/schema/schemas/index.ts | 1 + .../data/src/schema/schemas/moderation.ts | 11 ++ .../src/features/public-interactions.test.ts | 106 ++++++++++++ .../hub/src/features/public-interactions.ts | 94 +++++++++++ packages/hub/src/features/registry.test.ts | 70 ++++++++ packages/hub/src/features/registry.ts | 71 +++++++- packages/hub/src/features/types.ts | 56 +++++++ packages/hub/src/roles.ts | 8 +- packages/hub/src/server.ts | 152 ++++++++++++------ packages/hub/src/types.ts | 2 + 13 files changed, 523 insertions(+), 58 deletions(-) create mode 100644 packages/hub/src/features/public-interactions.test.ts create mode 100644 packages/hub/src/features/public-interactions.ts create mode 100644 packages/hub/src/features/registry.test.ts diff --git a/docs/explorations/0383_[_]_TURNING_HUBS_INTO_EVERYTHING_THE_ROLE_IMPLEMENTATION_PLAN.md b/docs/explorations/0383_[_]_TURNING_HUBS_INTO_EVERYTHING_THE_ROLE_IMPLEMENTATION_PLAN.md index 6d3837e38..99a282f9c 100644 --- a/docs/explorations/0383_[_]_TURNING_HUBS_INTO_EVERYTHING_THE_ROLE_IMPLEMENTATION_PLAN.md +++ b/docs/explorations/0383_[_]_TURNING_HUBS_INTO_EVERYTHING_THE_ROLE_IMPLEMENTATION_PLAN.md @@ -298,10 +298,10 @@ federation plane grows (0305-style thinking, deferred)? - [x] Decide R6 (self-hosted quota scope) and record it. ### W2 — feature modules -- [ ] Four optional hooks on `HubFeature`; registry owns loops/shutdown. -- [ ] Migrate: public-interactions (born a feature; the 0378 route) → crawl → shards → federation. -- [ ] Table-prefix discipline (`fed_*`/`crawl_*`/`idx_*`/`sub_*`) enforced in `storage?` hook. -- [ ] `server.ts` assembly loop replaces the four subsystems' imperative wiring. +- [x] Four optional hooks on `HubFeature`; registry owns loops/shutdown. +- [x] Migrate: public-interactions (born a feature; the 0378 route) → crawl → shards → federation. +- [x] Table-prefix discipline (`fed_*`/`crawl_*`/`idx_*`/`sub_*`) enforced in `storage?` hook. +- [x] `server.ts` assembly loop replaces the four subsystems' imperative wiring. ### W3 — index role - [ ] `atprotoIndex` module wrapping 0374's pipeline. diff --git a/packages/data/src/index.ts b/packages/data/src/index.ts index 4d3f7479f..d575a2ea4 100644 --- a/packages/data/src/index.ts +++ b/packages/data/src/index.ts @@ -570,6 +570,7 @@ export { PolicySubscriptionSchema, type PolicySubscription, PublicInteractionPolicySchema, + publicInteractionPolicyId, type PublicInteractionPolicy, QualitySignalSchema, type QualitySignal, diff --git a/packages/data/src/schema/index.ts b/packages/data/src/schema/index.ts index bb5272e4b..c2990c481 100644 --- a/packages/data/src/schema/index.ts +++ b/packages/data/src/schema/index.ts @@ -605,6 +605,7 @@ export { PolicyListSchema, PolicySubscriptionSchema, PublicInteractionPolicySchema, + publicInteractionPolicyId, QualitySignalSchema, ReviewTaskSchema, type AbuseReport, diff --git a/packages/data/src/schema/schemas/index.ts b/packages/data/src/schema/schemas/index.ts index d1781b817..a18aa7d95 100644 --- a/packages/data/src/schema/schemas/index.ts +++ b/packages/data/src/schema/schemas/index.ts @@ -401,6 +401,7 @@ export { PolicyListSchema, PolicySubscriptionSchema, PublicInteractionPolicySchema, + publicInteractionPolicyId, QualitySignalSchema, ReviewTaskSchema, type AbuseReport, diff --git a/packages/data/src/schema/schemas/moderation.ts b/packages/data/src/schema/schemas/moderation.ts index fa71d26b2..311df55c0 100644 --- a/packages/data/src/schema/schemas/moderation.ts +++ b/packages/data/src/schema/schemas/moderation.ts @@ -778,3 +778,14 @@ export type QualitySignal = InferNode<(typeof QualitySignalSchema)['_properties' export type ContentProvenance = InferNode<(typeof ContentProvenanceSchema)['_properties']> export type Appeal = InferNode<(typeof AppealSchema)['_properties']> export type ReviewTask = InferNode<(typeof ReviewTaskSchema)['_properties']> + +/** + * Deterministic node id for the target-scoped PublicInteractionPolicy, so a + * hub can resolve "what may strangers do to this node?" with one O(1) meta + * read instead of a reverse property index, and re-publishing the policy + * upserts instead of duplicating (the `spaceMembershipId` convention; + * explorations 0378/0383 W2). One policy node per target. + */ +export function publicInteractionPolicyId(targetId: string): string { + return `pipolicy:${targetId}` +} diff --git a/packages/hub/src/features/public-interactions.test.ts b/packages/hub/src/features/public-interactions.test.ts new file mode 100644 index 000000000..b45b5d047 --- /dev/null +++ b/packages/hub/src/features/public-interactions.test.ts @@ -0,0 +1,106 @@ +/** + * Public-interaction policy resolution (explorations 0378/0383 W2). + * + * The read half of the interaction layer: schema defaults when no policy node + * exists, the author's explicit modes when one does (found O(1) at the + * deterministic `publicInteractionPolicyId`), and the same NOT_PUBLIC 404 as + * the public read surface for anything not effectively public. + */ +import { publicInteractionPolicyId } from '@xnetjs/data' +import { Hono } from 'hono' +import { describe, expect, it } from 'vitest' +import { mountFeatures } from './registry' +import { publicInteractionsFeature } from './public-interactions' +import { createMemoryStorage } from '../storage/memory' + +const passthroughAuth = async (_c: unknown, next: () => Promise): Promise => next() + +const boot = async (storage: ReturnType): Promise => { + const app = new Hono() + await mountFeatures([publicInteractionsFeature(storage)], { + app, + env: {}, + requireAuth: passthroughAuth as never, + storage: 'memory', + dataDir: '/tmp/xnet-pi-test', + appUrl: 'http://localhost' + }) + return app +} + +const seedNode = async ( + storage: ReturnType, + id: string +): Promise => { + const now = Date.now() + await storage.setDocMeta(id, { + docId: id, + ownerDid: 'did:key:owner', + schemaIri: 'xnet://xnet.fyi/Page@1.0.0', + title: id, + properties: { title: id }, + createdAt: now, + updatedAt: now + }) +} + +describe('public interactions feature (0378/0383 W2)', () => { + it('resolves schema defaults for a public node with no policy', async () => { + const storage = createMemoryStorage() + await seedNode(storage, 'post') + await storage.setNodeVisibility('post', 'public') + const app = await boot(storage) + + const res = await app.request('/public/interactions/post') + expect(res.status).toBe(200) + const body = (await res.json()) as { + explicit: boolean + visibility: string + modes: Record + } + expect(body.explicit).toBe(false) + expect(body.visibility).toBe('public') + // The schema's own defaults, not values invented here. + expect(body.modes.commentMode).toBe('authenticated') + expect(body.modes.reactionMode).toBe('authenticated') + expect(body.modes.quoteMode).toBe('trusted') + expect(body.modes.crawlMode).toBe('closed') + }) + + it("honours the author's explicit policy node at the deterministic id", async () => { + const storage = createMemoryStorage() + await seedNode(storage, 'post') + await storage.setNodeVisibility('post', 'public') + const now = Date.now() + await storage.setDocMeta(publicInteractionPolicyId('post'), { + docId: publicInteractionPolicyId('post'), + ownerDid: 'did:key:owner', + schemaIri: 'xnet://xnet.fyi/PublicInteractionPolicy@1.0.0', + title: '', + properties: { target: 'post', scope: 'node', commentMode: 'closed', reactionMode: 'open' }, + createdAt: now, + updatedAt: now + }) + const app = await boot(storage) + + const body = (await (await app.request('/public/interactions/post')).json()) as { + explicit: boolean + modes: Record + } + expect(body.explicit).toBe(true) + expect(body.modes.commentMode).toBe('closed') + expect(body.modes.reactionMode).toBe('open') + // Surfaces the policy did not set keep the schema default. + expect(body.modes.quoteMode).toBe('trusted') + }) + + it('404s NOT_PUBLIC for private and unknown nodes, like the public read surface', async () => { + const storage = createMemoryStorage() + await seedNode(storage, 'secret') + await storage.setNodeVisibility('secret', 'private') + const app = await boot(storage) + + expect((await app.request('/public/interactions/secret')).status).toBe(404) + expect((await app.request('/public/interactions/nope')).status).toBe(404) + }) +}) diff --git a/packages/hub/src/features/public-interactions.ts b/packages/hub/src/features/public-interactions.ts new file mode 100644 index 000000000..5d2f0fce5 --- /dev/null +++ b/packages/hub/src/features/public-interactions.ts @@ -0,0 +1,94 @@ +/** + * @xnetjs/hub - Public-interaction policy surface (explorations 0378/0383 W2). + * + * The read half of 0378's interaction layer: given a node whose effective + * visibility is `public`, what may a stranger do to it? The author's + * `PublicInteractionPolicy` node answers per surface (comment/reply/reaction/ + * quote/…); this feature resolves it server-side so any client — including the + * anonymous index surface — can render the right affordances without guessing. + * + * Resolution is O(1): the policy node lives at the deterministic id + * `publicInteractionPolicyId(targetId)` (the `spaceMembershipId` convention), + * so no reverse property index is needed. A missing policy resolves to the + * schema's defaults — `authenticated` for the common surfaces, exactly what + * `PublicInteractionPolicySchema` declares. + * + * Born as a feature module (0383 W2's migration list starts here): mount-only + * plus `services`, no loops, no tables — the write-enforcement seam (gating a + * stranger's Comment push on `commentMode`) lands in the node-relay when the + * community role's write surface ships. + */ + +import type { HubStorage } from '../storage/interface' +import type { HubFeature } from './types' +import { publicInteractionPolicyId, PublicInteractionPolicySchema } from '@xnetjs/data' +import { resolveEffectiveVisibility } from '../routes/public' + +/** The per-surface modes exposed to clients. */ +export interface ResolvedInteractionPolicy { + nodeId: string + visibility: 'public' | 'unlisted' | 'private' + /** Whether an explicit policy node was found (false = schema defaults). */ + explicit: boolean + modes: Record +} + +/** Schema-declared default mode per surface (single source: the schema). */ +const defaultModes = (): Record => { + const modes: Record = {} + for (const prop of PublicInteractionPolicySchema.schema.properties) { + if (prop.name.endsWith('Mode')) { + const fallback = (prop.config as { default?: string } | undefined)?.default + if (fallback) modes[prop.name] = fallback + } + } + return modes +} + +export class PublicInteractionService { + constructor(private storage: HubStorage) {} + + async resolve(nodeId: string): Promise { + const visibility = await resolveEffectiveVisibility(this.storage, nodeId) + const modes = defaultModes() + const policyMeta = await this.storage.getDocMeta(publicInteractionPolicyId(nodeId)) + const props = policyMeta?.properties + let explicit = false + if (props) { + explicit = true + for (const key of Object.keys(modes)) { + const value = props[key] + if (typeof value === 'string') modes[key] = value + } + } + return { nodeId, visibility, explicit, modes } + } +} + +/** + * `GET /public/interactions/:nodeId` — resolved interaction policy for a + * PUBLIC node. Non-public nodes 404 exactly like `routes/public.ts` + * (`NOT_PUBLIC`), so this surface leaks nothing the public read surface + * doesn't already. + */ +export function publicInteractionsFeature(storage: HubStorage): HubFeature { + let service: PublicInteractionService + + return { + id: 'fyi.xnet.hub.public-interactions', + services: () => { + service = new PublicInteractionService(storage) + return { service } + }, + mount: ({ app }) => { + app.get('/public/interactions/:nodeId', async (c) => { + const nodeId = c.req.param('nodeId') + const resolved = await service.resolve(nodeId) + if (resolved.visibility !== 'public') { + return c.json({ error: 'NOT_PUBLIC' }, 404) + } + return c.json(resolved) + }) + } + } +} diff --git a/packages/hub/src/features/registry.test.ts b/packages/hub/src/features/registry.test.ts new file mode 100644 index 000000000..3569cb38b --- /dev/null +++ b/packages/hub/src/features/registry.test.ts @@ -0,0 +1,70 @@ +/** + * HubFeature v2 lifecycle + storage discipline (0383 W2). + */ +import { Hono } from 'hono' +import { describe, expect, it } from 'vitest' +import { mountFeatures } from './registry' +import type { HubFeature } from './types' + +const deps = () => ({ + app: new Hono(), + env: {}, + requireAuth: (async (_c: unknown, next: () => Promise) => next()) as never, + storage: 'memory' as const, + dataDir: '/tmp/xnet-registry-test', + appUrl: 'http://localhost' +}) + +describe('mountFeatures v2 (0383 W2)', () => { + it('runs storage before mount and enforces the declared table prefix', async () => { + const order: string[] = [] + const feature: HubFeature = { + id: 'test.prefixed', + storage: { + prefix: 'idx_', + setup: ({ assertOwnTable }) => { + order.push('storage') + expect(assertOwnTable('idx_entries')).toBe('idx_entries') + expect(() => assertOwnTable('search_index')).toThrow(/may only create "idx_\*"/) + } + }, + mount: () => { + order.push('mount') + } + } + await mountFeatures([feature], deps()) + expect(order).toEqual(['storage', 'mount']) + }) + + it('owns loops: starts in order, stops in reverse, isolates stop failures', async () => { + const events: string[] = [] + const make = (id: string, failStop = false): HubFeature => ({ + id, + loops: [ + { + id: `${id}-loop`, + start: () => { + events.push(`start:${id}`) + }, + stop: () => { + events.push(`stop:${id}`) + if (failStop) throw new Error('boom') + } + } + ] + }) + const mounted = await mountFeatures([make('a', true), make('b')], deps()) + await mounted.start() + await mounted.stop() + // Starts in feature order; stops in reverse; a's failure doesn't block b's + // (already stopped) or wedge shutdown. + expect(events).toEqual(['start:a', 'start:b', 'stop:b', 'stop:a']) + }) + + it('collects ws handler maps by feature id for the future pump consumer', async () => { + const handler = (): void => {} + const feature: HubFeature = { id: 'test.ws', ws: () => ({ 'sub-update': handler }) } + const mounted = await mountFeatures([feature], deps()) + expect(mounted.wsHandlers.get('test.ws')).toEqual({ 'sub-update': handler }) + }) +}) diff --git a/packages/hub/src/features/registry.ts b/packages/hub/src/features/registry.ts index 7c7debc28..292578031 100644 --- a/packages/hub/src/features/registry.ts +++ b/packages/hub/src/features/registry.ts @@ -15,13 +15,78 @@ import { mountWebhook } from './webhooks' /** Shared deps, but with the FULL env — the registry scopes it per feature. */ export type MountFeaturesDeps = Omit & { env: Env } -/** Mount every feature (routes + declarative webhooks), each scoped to its secrets. */ -export function mountFeatures(features: readonly HubFeature[], deps: MountFeaturesDeps): void { +/** What `mountFeatures` hands back for lifecycle + ws integration (0383 W2). */ +export interface MountedFeatures { + /** Start every feature loop, in feature order. Await before serving. */ + start(): Promise + /** Stop every loop in reverse order; errors are logged-and-continued. */ + stop(): Promise + /** Collected ws handler maps, keyed by feature id (consumer lands in W4). */ + wsHandlers: Map void>> +} + +/** + * Mount every feature — storage setup, routes, declarative webhooks — each + * scoped to its declared secrets, and hand lifecycle ownership back to the + * caller as one start/stop pair. The REGISTRY owns loops: a feature declares + * them and never manages its own lifecycle (0383 W2). + */ +export async function mountFeatures( + features: readonly HubFeature[], + deps: MountFeaturesDeps +): Promise { + const wsHandlers = new Map void>>() + for (const feature of features) { const env = scopedEnv(deps.env, feature.secrets ?? []) - feature.mount?.({ ...deps, env }) + const scoped: HubFeatureDeps = { ...deps, env } + + // Storage first, so mount/services see the feature's tables. Table + // ownership is enforced HERE: names must carry the declared prefix + // (fed_/crawl_/idx_/sub_/pi_ — 0383 W2's discipline; the W3 derived-state + // guard depends on it). + if (feature.storage) { + const { prefix, setup } = feature.storage + await setup({ + ...scoped, + assertOwnTable: (table: string): string => { + if (!table.startsWith(prefix)) { + throw new Error( + `[features] ${feature.id} may only create "${prefix}*" tables, got "${table}"` + ) + } + return table + } + }) + } + + feature.services?.(scoped) + feature.mount?.(scoped) + const ws = feature.ws?.(scoped) + if (ws) wsHandlers.set(feature.id, ws) for (const webhook of feature.webhooks ?? []) { mountWebhook(deps.app, webhook, env) } } + + const loops = features.flatMap((f) => (f.loops ?? []).map((loop) => ({ feature: f.id, loop }))) + + return { + async start() { + for (const { loop } of loops) { + await loop.start() + } + }, + async stop() { + for (const { feature, loop } of [...loops].reverse()) { + try { + await loop.stop() + } catch (err) { + // Shutdown must not wedge on one feature; log and continue. + console.error(`[features] ${feature}/${loop.id} stop failed:`, err) + } + } + }, + wsHandlers + } } diff --git a/packages/hub/src/features/types.ts b/packages/hub/src/features/types.ts index 6cdcb1832..573ac7e6e 100644 --- a/packages/hub/src/features/types.ts +++ b/packages/hub/src/features/types.ts @@ -12,6 +12,7 @@ * shared bag. */ +import type { HubStorage } from '../storage/interface' import type { Env } from './broker' import type { DeclarativeWebhook } from './webhooks' import type { Hono, MiddlewareHandler } from 'hono' @@ -30,6 +31,39 @@ export interface HubFeatureDeps { dataDir: string /** Web app base URL (checkout success/cancel default, etc.). */ appUrl: string + /** + * The hub's own storage, for features that read/write hub state (rather + * than opening a subsystem DB). Optional: not every mount site provides it. + */ + hubStorage?: HubStorage +} + +/** A background loop owned by the feature registry (0383 W2). */ +export interface HubFeatureLoop { + /** Loop id, unique within the feature, for logs and shutdown ordering. */ + id: string + start(): void | Promise + stop(): void | Promise +} + +/** + * Allowed hub.db table prefixes for feature-owned tables (0383 W2). One prefix + * per infra plane: `fed_` federation, `crawl_` crawl, `idx_` the atproto index + * plane (derived-only — the 0383 W3 startup guard keys on this), `sub_` + * hub-to-hub subscription state (W4), `pi_` public interactions. + */ +export type HubTablePrefix = 'fed_' | 'crawl_' | 'idx_' | 'sub_' | 'pi_' + +/** Storage hook: declarative prefix + setup, with ownership enforced. */ +export interface HubFeatureStorage { + /** The single prefix every table this feature creates must carry. */ + prefix: HubTablePrefix + /** + * Create tables/migrations. `assertOwnTable` throws unless the name starts + * with the declared prefix — the discipline is enforced where DDL happens, + * not reviewed after. + */ + setup(deps: HubFeatureDeps & { assertOwnTable: (table: string) => string }): void | Promise } export interface HubFeature { @@ -47,4 +81,26 @@ export interface HubFeature { webhooks?: DeclarativeWebhook[] /** Mount the feature's routes onto `deps.app` (optional for webhook-only features). */ mount?(deps: HubFeatureDeps): void + /** + * Long-lived service objects (0383 W2), constructed once at mount time and + * visible to this feature's own hooks only — cross-feature access stays + * forbidden, in the spirit of the secret broker. Features defined inline in + * `server.ts` may instead close over their services; this hook exists for + * features defined as standalone modules. + */ + services?(deps: HubFeatureDeps): Record + /** + * Background loops. The REGISTRY owns start/stop and shutdown grace — a + * feature never manages its own lifecycle (0383 W2). + */ + loops?: HubFeatureLoop[] + /** Storage setup (tables/migrations), run before `mount`. */ + storage?: HubFeatureStorage + /** + * WebSocket message handlers, namespaced by message type. Collected by the + * registry (`mountFeatures` returns them); the ws pump integration lands + * with the first real consumer (0383 W4's subscriber) — the seam exists so + * that consumer changes no interface. + */ + ws?(deps: HubFeatureDeps): Record void> } diff --git a/packages/hub/src/roles.ts b/packages/hub/src/roles.ts index cbe2cf0b1..5037150c7 100644 --- a/packages/hub/src/roles.ts +++ b/packages/hub/src/roles.ts @@ -33,7 +33,10 @@ export const HUB_ROLES: Record> = { * public face is discoverable across the fleet. The public-interaction * surface joins this preset in 0383 W2. */ - community: { federation: { enabled: true } }, + community: { + federation: { enabled: true }, + publicInteractions: { enabled: true } + }, /** * The Index (0374/0382): reads PUBLIC atproto records and serves derived @@ -44,7 +47,8 @@ export const HUB_ROLES: Record> = { index: { federation: { enabled: false }, shards: { enabled: false }, - crawl: { enabled: false } + crawl: { enabled: false }, + publicInteractions: { enabled: true } }, /** diff --git a/packages/hub/src/server.ts b/packages/hub/src/server.ts index 9de55cc97..cd69f44b7 100644 --- a/packages/hub/src/server.ts +++ b/packages/hub/src/server.ts @@ -43,6 +43,8 @@ import { billingFeature, tasksFeature, unfurlFeature } from './features/first-pa import { formInboxFeature } from './features/form-inbox' import { mountOidcProvider } from './features/oidc-provider' import { mountFeatures } from './features/registry' +import { publicInteractionsFeature } from './features/public-interactions' +import type { HubFeature } from './features/types' import { pagerdutyFeature, sentryFeature, stripeFeature } from './features/webhook-integrations' import { createLogger } from './logger' import { Metrics, HUB_METRICS } from './middleware/metrics' @@ -615,8 +617,102 @@ export const createServer = async (config: HubConfig): Promise => { // not yet have. Until then the actions are reported (`{ ok, actions }`) but not // applied — matching the previous hand-written route, which also never wired // apply. See exploration 0189 (deferred: server-side action application). - mountFeatures( + // ── Infra subsystems as feature modules (0383 W2) ───────────────────────── + // Federation, shards and crawl assemble through the same registry as the + // integrations: mount + loops, with the REGISTRY owning start/stop. The + // services themselves are constructed above (they are interdependent — + // crawl feeds shard ingest) and the features close over them, the same + // pattern the integration features already use. + const federationFeature: HubFeature = { + id: 'fyi.xnet.hub.federation', + // Mounted even when disabled — the route 404s, preserving the previous + // always-mounted behaviour. + mount: ({ app, requireAuth }) => + app.route('/federation', createFederationRoutes(federation, { requireAuth })), + loops: federationConfig.enabled + ? [ + { + id: 'peer-health', + start: async () => { + await federation.loadPeers() + federationHealth.start() + }, + stop: () => federationHealth.stop() + } + ] + : [] + } + const shardsFeature: HubFeature = { + id: 'fyi.xnet.hub.shards', + mount: ({ app, requireAuth }) => { + if (!shardConfig.enabled) return + app.route( + '/shards', + createShardRoutes({ + registry: shardRegistry, + ingest: shardIngest, + router: shardRouter, + rebalancer: shardRebalancer ?? undefined, + requireAuth + }) + ) + }, + loops: shardConfig.enabled + ? [ + { + id: 'shard-registry', + start: async () => { + await shardRegistry.init() + if (shardConfig.isRegistry && shardRebalancer && shardConfig.hubDid && shardConfig.hubUrl) { + await shardRebalancer.registerHost({ + hubDid: shardConfig.hubDid, + url: shardConfig.hubUrl, + capacity: shardConfig.maxDocsPerShard + }) + } + }, + stop: () => shardRegistry.stop() + } + ] + : [] + } + const crawlFeature: HubFeature = { + id: 'fyi.xnet.hub.crawl', + mount: ({ app, requireAuth }) => { + if (!crawlConfig.enabled) return + app.route( + '/crawl', + createCrawlRoutes({ + coordinator: crawlCoordinator, + requireAuth, + userAgent: crawlConfig.userAgent + }) + ) + }, + loops: crawlConfig.enabled + ? [ + { + id: 'crawl-coordinator', + start: async () => { + crawlCoordinator.start() + if (crawlConfig.seedUrls && crawlConfig.seedUrls.length > 0) { + await crawlCoordinator.seedUrls(crawlConfig.seedUrls) + } + }, + stop: () => crawlCoordinator.stop() + } + ] + : [] + } + + const mounted = await mountFeatures( [ + federationFeature, + shardsFeature, + crawlFeature, + // Public-interaction policy surface (0378/0383 W2) — on in the + // community and index roles. + ...(config.publicInteractions?.enabled ? [publicInteractionsFeature(storage)] : []), billingFeature(), tasksFeature(taskIdentifiers), unfurlFeature(crawlConfig.userAgent), @@ -654,7 +750,8 @@ export const createServer = async (config: HubConfig): Promise => { requireAuth, storage: config.storage, dataDir: config.dataDir, - appUrl: config.appUrl ?? DEFAULT_APP_URL + appUrl: config.appUrl ?? DEFAULT_APP_URL, + hubStorage: storage } ) app.route('/dids', createDiscoveryRoutes(discovery, { requireAuth })) @@ -666,30 +763,6 @@ export const createServer = async (config: HubConfig): Promise => { requireAuth }) ) - app.route('/federation', createFederationRoutes(federation, { requireAuth })) - if (shardConfig.enabled) { - app.route( - '/shards', - createShardRoutes({ - registry: shardRegistry, - ingest: shardIngest, - router: shardRouter, - rebalancer: shardRebalancer ?? undefined, - requireAuth - }) - ) - } - if (crawlConfig.enabled) { - app.route( - '/crawl', - createCrawlRoutes({ - coordinator: crawlCoordinator, - requireAuth, - userAgent: crawlConfig.userAgent - }) - ) - } - app.route( '/shares', createShareLinkRoutes({ @@ -911,26 +984,9 @@ export const createServer = async (config: HubConfig): Promise => { telemetry.start() awareness.start() discovery.start() - if (federationConfig.enabled) { - await federation.loadPeers() - federationHealth.start() - } - if (shardConfig.enabled) { - await shardRegistry.init() - if (shardConfig.isRegistry && shardRebalancer && shardConfig.hubDid && shardConfig.hubUrl) { - await shardRebalancer.registerHost({ - hubDid: shardConfig.hubDid, - url: shardConfig.hubUrl, - capacity: shardConfig.maxDocsPerShard - }) - } - } - if (crawlConfig.enabled) { - crawlCoordinator.start() - if (crawlConfig.seedUrls && crawlConfig.seedUrls.length > 0) { - await crawlCoordinator.seedUrls(crawlConfig.seedUrls) - } - } + // Feature loops (0383 W2): federation health, shard registry, crawl — + // started by the registry in feature order. + await mounted.start() // Demo hub: guard the small disposable volume — watch disk usage and wipe // all user data on a fixed cadence so it can't grow unbounded (0291). const resetIntervalMs = resolveResetIntervalMs(config) @@ -1161,9 +1217,7 @@ export const createServer = async (config: HubConfig): Promise => { telemetry.stop() awareness.stop() discovery.stop() - federationHealth.stop() - shardRegistry.stop() - crawlCoordinator.stop() + await mounted.stop() signaling.destroy() } diff --git a/packages/hub/src/types.ts b/packages/hub/src/types.ts index 439ef47b1..b70b46f6f 100644 --- a/packages/hub/src/types.ts +++ b/packages/hub/src/types.ts @@ -119,6 +119,8 @@ export type HubConfig = { shards?: Partial /** Crawl coordination configuration (optional; merged over server defaults). */ crawl?: Partial + /** Public-interaction policy surface (0378/0383 W2; on in the community role). */ + publicInteractions?: { enabled: boolean } /** Runtime metadata (platform info, region). */ runtime?: { platform?: 'railway' | 'fly' | 'cloud-run' | 'fargate' | 'local' | 'unknown' From c352b4d59838f58f3b34cedb3bf2386122779ceb Mon Sep 17 00:00:00 2001 From: xNet Test Date: Mon, 20 Jul 2026 14:31:48 -0700 Subject: [PATCH 05/14] =?UTF-8?q?feat(hub):=20the=20index=20role=20?= =?UTF-8?q?=E2=80=94=20derived-only=20atproto=20index=20engine=20(0383=20W?= =?UTF-8?q?3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the atprotoIndex feature module: enumerate the adopted site.standard.* collections (0372), fetch records via an injected IndexSource (fixtures in tests; listReposByCollection + listRecords over fetch by default), quarantine malformed records (0367 E22), and serve a DETERMINISTIC snapshot — sorted by URI, no wall-clock — so two rebuilds from identical inputs are byte-identical. That determinism test is 0374's rebuild-and-diff gate running in the ordinary CI lanes, and scripts/index/rebuild-and-diff.mjs is the same property against the live network — the mirror-not-master receipt as a runnable command. The role is derived-only by construction: assertDerivedOnlyDataDir refuses a data dir holding tenant state (hub.db without the idx_role claim) before storage opens, artifacts are idx_-prefixed files (restart-from-source is the DR — Bobbin's model, 0381), and the legacy search/shard stack stays off in the index preset. Signed-off-by: xNet Test --- ...EVERYTHING_THE_ROLE_IMPLEMENTATION_PLAN.md | 6 +- packages/hub/src/features/atproto-index.ts | 268 ++++++++++++++++++ packages/hub/src/index.ts | 11 + packages/hub/src/roles.ts | 3 +- packages/hub/src/server.ts | 11 + packages/hub/src/types.ts | 3 + packages/hub/test/index-role.test.ts | 133 +++++++++ packages/hub/test/roles.test.ts | 26 +- scripts/index/rebuild-and-diff.mjs | 48 ++++ 9 files changed, 503 insertions(+), 6 deletions(-) create mode 100644 packages/hub/src/features/atproto-index.ts create mode 100644 packages/hub/test/index-role.test.ts create mode 100644 scripts/index/rebuild-and-diff.mjs diff --git a/docs/explorations/0383_[_]_TURNING_HUBS_INTO_EVERYTHING_THE_ROLE_IMPLEMENTATION_PLAN.md b/docs/explorations/0383_[_]_TURNING_HUBS_INTO_EVERYTHING_THE_ROLE_IMPLEMENTATION_PLAN.md index 99a282f9c..3ba4e9d85 100644 --- a/docs/explorations/0383_[_]_TURNING_HUBS_INTO_EVERYTHING_THE_ROLE_IMPLEMENTATION_PLAN.md +++ b/docs/explorations/0383_[_]_TURNING_HUBS_INTO_EVERYTHING_THE_ROLE_IMPLEMENTATION_PLAN.md @@ -304,9 +304,9 @@ federation plane grows (0305-style thinking, deferred)? - [x] `server.ts` assembly loop replaces the four subsystems' imperative wiring. ### W3 — index role -- [ ] `atprotoIndex` module wrapping 0374's pipeline. -- [ ] Derived-only startup guard; negative table test. -- [ ] `--role index` wired into 0374's rebuild-and-diff CI gate. +- [x] `atprotoIndex` module wrapping 0374's pipeline. +- [x] Derived-only startup guard; negative table test. +- [x] `--role index` wired into 0374's rebuild-and-diff CI gate. ### W4 — federation plane - [ ] Hub DID (init, config, `/health`); 0371 integrations consume it. diff --git a/packages/hub/src/features/atproto-index.ts b/packages/hub/src/features/atproto-index.ts new file mode 100644 index 000000000..6e49db712 --- /dev/null +++ b/packages/hub/src/features/atproto-index.ts @@ -0,0 +1,268 @@ +/** + * @xnetjs/hub - The atproto index engine (explorations 0374/0382/0383 W3). + * + * The index role's engine: enumerate the adopted public collections + * (`site.standard.*` — 0372's adopt-don't-mint rule), fetch the records, and + * serve a derived, deterministic snapshot. Three properties are load-bearing: + * + * - **Derived-only.** The index holds no authoritative state: its entire + * dataset rebuilds from public inputs, so restart-from-source IS the + * disaster recovery (Bobbin's model, 0381). The role refuses to start on a + * data dir holding tenant state — derived and authoritative state never + * share a directory (`assertDerivedOnlyDataDir`). + * - **Deterministic.** The snapshot artifact contains no wall-clock and is + * sorted by URI, so two rebuilds from the same inputs are byte-identical — + * 0374's "a stranger rebuilds and diffs to zero" receipt, enforceable in CI + * as an ordinary test. + * - **Not the legacy stack.** This engine never touches `search_index` or the + * shard tables (0367 documented their defects); its only artifacts carry the + * `idx_` prefix (0383 W2's table discipline, applied to files). + * + * The network is injected (`IndexSource`), so tests run on fixtures and the + * default source speaks `com.atproto.sync.listReposByCollection` + + * `com.atproto.repo.listRecords` exactly as 0372 measured them. + */ + +import { mkdirSync, readFileSync, writeFileSync, existsSync } from 'node:fs' +import { join } from 'node:path' +import type { HubFeature } from './types' + +/** The adopted collections (0372). Adding one is a one-line change — the point. */ +export const DEFAULT_INDEX_COLLECTIONS = [ + 'site.standard.publication', + 'site.standard.document' +] as const + +/** One indexed record. The artifact's row type — changing it is a break. */ +export interface IndexEntry { + uri: string + cid: string + did: string + collection: string + /** The record's own claimed fields we surface (title/name, path, publishedAt…). */ + value: Record +} + +/** The canonical, deterministic artifact: NO wall-clock, sorted by URI. */ +export interface IndexSnapshot { + collections: string[] + entries: IndexEntry[] +} + +/** Injected network surface; the default impl speaks atproto, tests use fixtures. */ +export interface IndexSource { + /** DIDs holding at least one record in `collection` (relay enumeration). */ + listRepos(collection: string): Promise + /** Records in `collection` for one DID. */ + listRecords(did: string, collection: string): Promise>> +} + +export interface AtprotoIndexConfig { + enabled: boolean + /** + * Refuse to start on a data dir holding tenant (authoritative) state. + * Default true — turning it off is for tests only. + */ + derivedOnly?: boolean + collections?: string[] + /** Rebuild from source at startup (the Bobbin model). Default true. */ + rebuildOnStart?: boolean + /** Injected source (fixtures in tests); default speaks atproto over fetch. */ + source?: IndexSource + /** Relay for enumeration (default: relay1.us-west.bsky.network, 0372). */ + relayUrl?: string + fetchImpl?: typeof fetch +} + +const SENTINEL = 'idx_role.json' + +/** + * The derived-only startup guard (0383 W3). A data dir is claimed for the + * index role by a sentinel file; an existing `hub.db` WITHOUT the sentinel is + * tenant state and boot must refuse rather than mingle derived rows with an + * authoritative log. + */ +export function assertDerivedOnlyDataDir(dataDir: string): void { + const sentinel = join(dataDir, SENTINEL) + if (existsSync(sentinel)) return + if (existsSync(join(dataDir, 'hub.db'))) { + throw new Error( + `[atproto-index] refusing to start: ${dataDir} contains tenant state (hub.db) ` + + `and no ${SENTINEL} claim. The index role holds DERIVED state only — point it ` + + `at a fresh data dir (0383 W3; derived and authoritative state never share a file).` + ) + } + mkdirSync(dataDir, { recursive: true }) + writeFileSync(sentinel, JSON.stringify({ role: 'index', claimedAt: Date.now() }, null, 2)) +} + +/** Default source: the 0372-measured endpoints, resolution via plc.directory. */ +export function httpIndexSource( + relayUrl = 'https://relay1.us-west.bsky.network', + fetchImpl: typeof fetch = fetch +): IndexSource { + const pdsCache = new Map() + + const pdsFor = async (did: string): Promise => { + const cached = pdsCache.get(did) + if (cached) return cached + const res = await fetchImpl(`https://plc.directory/${did}`) + if (!res.ok) return null + const doc = (await res.json()) as { + service?: Array<{ id: string; serviceEndpoint: string }> + } + const pds = doc.service?.find((s) => s.id === '#atproto_pds')?.serviceEndpoint + if (pds) pdsCache.set(did, pds) + return pds ?? null + } + + return { + async listRepos(collection) { + const dids: string[] = [] + let cursor: string | undefined + do { + const url = new URL(`${relayUrl}/xrpc/com.atproto.sync.listReposByCollection`) + url.searchParams.set('collection', collection) + url.searchParams.set('limit', '2000') + if (cursor) url.searchParams.set('cursor', cursor) + const res = await fetchImpl(url) + if (!res.ok) break + const body = (await res.json()) as { repos: Array<{ did: string }>; cursor?: string } + dids.push(...body.repos.map((r) => r.did)) + cursor = body.cursor + } while (cursor) + return dids + }, + async listRecords(did, collection) { + const pds = await pdsFor(did) + if (!pds) return [] + const out: Array> = [] + let cursor: string | undefined + do { + const url = new URL(`${pds}/xrpc/com.atproto.repo.listRecords`) + url.searchParams.set('repo', did) + url.searchParams.set('collection', collection) + url.searchParams.set('limit', '100') + if (cursor) url.searchParams.set('cursor', cursor) + const res = await fetchImpl(url) + if (!res.ok) break + const body = (await res.json()) as { + records: Array<{ uri: string; cid: string; value: Record }> + cursor?: string + } + out.push(...body.records.map((r) => ({ uri: r.uri, cid: r.cid, did, value: r.value }))) + cursor = body.cursor + } while (cursor) + return out + } + } +} + +export class AtprotoIndexService { + private entries = new Map() + private lastRebuildAt: number | null = null + private readonly collections: string[] + private readonly source: IndexSource + + constructor( + private readonly dataDir: string, + config: AtprotoIndexConfig + ) { + this.collections = config.collections ?? [...DEFAULT_INDEX_COLLECTIONS] + this.source = config.source ?? httpIndexSource(config.relayUrl, config.fetchImpl) + } + + /** + * Rebuild the whole dataset from source. Records are validated minimally + * (0367 E22: production records ARE malformed — quarantine, never crash): + * a record without a string uri/cid/did is counted and dropped. + */ + async rebuild(): Promise<{ entries: number; quarantined: number }> { + const next = new Map() + let quarantined = 0 + for (const collection of this.collections) { + const dids = await this.source.listRepos(collection) + for (const did of dids) { + for (const record of await this.source.listRecords(did, collection)) { + if ( + typeof record.uri !== 'string' || + typeof record.cid !== 'string' || + typeof record.did !== 'string' || + record.value === null || + typeof record.value !== 'object' + ) { + quarantined++ + continue + } + next.set(record.uri, { ...record, collection }) + } + } + } + this.entries = next + this.lastRebuildAt = Date.now() + this.persist() + return { entries: next.size, quarantined } + } + + /** The canonical artifact: sorted, wall-clock-free, byte-stable. */ + snapshot(): IndexSnapshot { + return { + collections: [...this.collections].sort(), + entries: [...this.entries.values()].sort((a, b) => (a.uri < b.uri ? -1 : 1)) + } + } + + status(): { entries: number; collections: string[]; lastRebuildAt: number | null } { + return { + entries: this.entries.size, + collections: [...this.collections], + lastRebuildAt: this.lastRebuildAt + } + } + + /** Persist the canonical artifact (an `idx_` file — the W2 discipline). */ + private persist(): void { + mkdirSync(this.dataDir, { recursive: true }) + writeFileSync(join(this.dataDir, 'idx_snapshot.json'), JSON.stringify(this.snapshot())) + } + + /** Load a previously persisted artifact (serving continuity across boots). */ + loadPersisted(): boolean { + const path = join(this.dataDir, 'idx_snapshot.json') + if (!existsSync(path)) return false + const parsed = JSON.parse(readFileSync(path, 'utf8')) as IndexSnapshot + this.entries = new Map(parsed.entries.map((e) => [e.uri, e])) + return true + } +} + +/** + * The index role's engine as a feature module. Routes are read-only and + * unauthenticated (the index is a public good — 0366: reads free, forever); + * rebuild is a loop owned by the registry. + */ +export function atprotoIndexFeature(dataDir: string, config: AtprotoIndexConfig): HubFeature { + const service = new AtprotoIndexService(dataDir, config) + + return { + id: 'fyi.xnet.hub.atproto-index', + services: () => ({ service }), + mount: ({ app }) => { + app.get('/index/status', (c) => c.json(service.status())) + app.get('/index/snapshot', (c) => c.json(service.snapshot())) + }, + loops: + config.rebuildOnStart !== false + ? [ + { + id: 'rebuild-from-source', + start: async () => { + service.loadPersisted() + await service.rebuild() + }, + stop: () => {} + } + ] + : [] + } +} diff --git a/packages/hub/src/index.ts b/packages/hub/src/index.ts index f10eb1a3d..5cf61ec96 100644 --- a/packages/hub/src/index.ts +++ b/packages/hub/src/index.ts @@ -13,6 +13,17 @@ export type { HubConfig, HubInstance, HubRole, DemoOverrides } from './types' export { DEMO_DEFAULTS } from './types' export { getDemoOverrides } from './config' export { HUB_ROLES, isHubRole, rolePreset } from './roles' +export { + AtprotoIndexService, + assertDerivedOnlyDataDir, + atprotoIndexFeature, + httpIndexSource, + DEFAULT_INDEX_COLLECTIONS, + type AtprotoIndexConfig, + type IndexEntry, + type IndexSnapshot, + type IndexSource +} from './features/atproto-index' export type { YjsEnvelopeV2Verifier, YjsEnvelopeV2VerifierContext, diff --git a/packages/hub/src/roles.ts b/packages/hub/src/roles.ts index 5037150c7..ff9b68a7b 100644 --- a/packages/hub/src/roles.ts +++ b/packages/hub/src/roles.ts @@ -48,7 +48,8 @@ export const HUB_ROLES: Record> = { federation: { enabled: false }, shards: { enabled: false }, crawl: { enabled: false }, - publicInteractions: { enabled: true } + publicInteractions: { enabled: true }, + atprotoIndex: { enabled: true } }, /** diff --git a/packages/hub/src/server.ts b/packages/hub/src/server.ts index cd69f44b7..da07588d3 100644 --- a/packages/hub/src/server.ts +++ b/packages/hub/src/server.ts @@ -43,6 +43,7 @@ import { billingFeature, tasksFeature, unfurlFeature } from './features/first-pa import { formInboxFeature } from './features/form-inbox' import { mountOidcProvider } from './features/oidc-provider' import { mountFeatures } from './features/registry' +import { assertDerivedOnlyDataDir, atprotoIndexFeature } from './features/atproto-index' import { publicInteractionsFeature } from './features/public-interactions' import type { HubFeature } from './features/types' import { pagerdutyFeature, sentryFeature, stripeFeature } from './features/webhook-integrations' @@ -212,6 +213,11 @@ export const createServer = async (config: HubConfig): Promise => { // The demo hub's data is disposable, so let it auto-reset a corrupt base DB // and boot rather than crash-loop (exploration 0206 follow-up). A real // self-host / production hub never does this. + // Index-plane state discipline (0383 W3): a derived-only hub refuses a data + // dir holding tenant state, BEFORE any storage is opened. + if (config.atprotoIndex?.enabled && config.atprotoIndex.derivedOnly !== false) { + assertDerivedOnlyDataDir(config.dataDir) + } const storage = await createStorage(config.storage, config.dataDir, { resetOnCorruption: resolveResetOnCorruption(config) }) @@ -713,6 +719,11 @@ export const createServer = async (config: HubConfig): Promise => { // Public-interaction policy surface (0378/0383 W2) — on in the // community and index roles. ...(config.publicInteractions?.enabled ? [publicInteractionsFeature(storage)] : []), + // The atproto index engine (0374/0383 W3) — the index role's plane; + // derived-only, deterministic, never the legacy search stack (0367). + ...(config.atprotoIndex?.enabled + ? [atprotoIndexFeature(config.dataDir, config.atprotoIndex)] + : []), billingFeature(), tasksFeature(taskIdentifiers), unfurlFeature(crawlConfig.userAgent), diff --git a/packages/hub/src/types.ts b/packages/hub/src/types.ts index b70b46f6f..e9947f236 100644 --- a/packages/hub/src/types.ts +++ b/packages/hub/src/types.ts @@ -2,6 +2,7 @@ * @xnetjs/hub - Hub configuration and instance types. */ +import type { AtprotoIndexConfig } from './features/atproto-index' import type { CrawlConfig } from './services/crawl' import type { FederationConfig } from './services/federation' import type { ShardConfig } from './services/index-shards' @@ -121,6 +122,8 @@ export type HubConfig = { crawl?: Partial /** Public-interaction policy surface (0378/0383 W2; on in the community role). */ publicInteractions?: { enabled: boolean } + /** The atproto index engine (0374/0383 W3; the index role's plane). */ + atprotoIndex?: AtprotoIndexConfig /** Runtime metadata (platform info, region). */ runtime?: { platform?: 'railway' | 'fly' | 'cloud-run' | 'fargate' | 'local' | 'unknown' diff --git a/packages/hub/test/index-role.test.ts b/packages/hub/test/index-role.test.ts new file mode 100644 index 000000000..a184d551c --- /dev/null +++ b/packages/hub/test/index-role.test.ts @@ -0,0 +1,133 @@ +/** + * The index role (explorations 0374/0382/0383 W3). + * + * Four properties: + * 1. **Determinism** — two rebuilds from identical inputs produce a + * byte-identical snapshot. This test IS 0374's "rebuild and diff to zero" + * CI gate: it runs in the ordinary test lanes, and + * `scripts/index/rebuild-and-diff.mjs` is the same check against the live + * network for a stranger's `--role index` run. + * 2. **Derived-only** — the role refuses a data dir holding tenant state. + * 3. **Quarantine** — malformed records are counted and dropped, never fatal + * (0367 E22: production records ARE malformed). + * 4. **Not the legacy stack** — a booted index-role hub leaves hub storage + * empty and serves its plane from `idx_*` artifacts only. + */ +import { mkdtempSync, writeFileSync, readFileSync, existsSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { + AtprotoIndexService, + assertDerivedOnlyDataDir, + type IndexSource +} from '../src/features/atproto-index' +import { resolveConfig } from '../src/config' +import { createHub } from '../src/index' + +const fixtureSource = (): IndexSource => ({ + async listRepos(collection) { + return collection === 'site.standard.document' ? ['did:plc:alice', 'did:plc:bob'] : ['did:plc:alice'] + }, + async listRecords(did, collection) { + if (collection === 'site.standard.publication') { + return [ + { + uri: `at://${did}/site.standard.publication/pub1`, + cid: 'bafypub1', + did, + value: { url: 'https://alice.example', name: 'Alice Writes' } + } + ] + } + const records = [ + { + uri: `at://${did}/site.standard.document/doc1`, + cid: 'bafydoc1', + did, + value: { title: `Post by ${did}`, publishedAt: '2026-07-01T00:00:00Z' } + } + ] + if (did === 'did:plc:bob') { + // A malformed record, as seen live in 0372's research. + records.push({ uri: 42, cid: 'x', did, value: null } as never) + } + return records + } +}) + +const freshDir = (): string => mkdtempSync(join(tmpdir(), 'xnet-idx-')) + +describe('index role (0374/0382/0383 W3)', () => { + it('two rebuilds from identical inputs are byte-identical (diff to zero)', async () => { + const a = new AtprotoIndexService(freshDir(), { enabled: true, source: fixtureSource() }) + const b = new AtprotoIndexService(freshDir(), { enabled: true, source: fixtureSource() }) + await a.rebuild() + await b.rebuild() + expect(JSON.stringify(a.snapshot())).toBe(JSON.stringify(b.snapshot())) + // And the artifact carries no wall-clock — rebuilding later cannot differ. + expect(JSON.stringify(a.snapshot())).not.toMatch(/\d{13}/) + }) + + it('quarantines malformed records instead of crashing', async () => { + const svc = new AtprotoIndexService(freshDir(), { enabled: true, source: fixtureSource() }) + const { entries, quarantined } = await svc.rebuild() + expect(quarantined).toBe(1) + expect(entries).toBe(3) // alice pub + alice doc + bob doc + }) + + it('persists the artifact as an idx_-prefixed file and reloads it', async () => { + const dir = freshDir() + const svc = new AtprotoIndexService(dir, { enabled: true, source: fixtureSource() }) + await svc.rebuild() + expect(existsSync(join(dir, 'idx_snapshot.json'))).toBe(true) + const reloaded = new AtprotoIndexService(dir, { enabled: true, source: fixtureSource() }) + expect(reloaded.loadPersisted()).toBe(true) + expect(JSON.stringify(reloaded.snapshot())).toBe(JSON.stringify(svc.snapshot())) + }) + + it('refuses a data dir holding tenant state; claims a fresh one', () => { + const tenantDir = freshDir() + writeFileSync(join(tenantDir, 'hub.db'), 'not-really-sqlite') + expect(() => assertDerivedOnlyDataDir(tenantDir)).toThrow(/tenant state/) + + const derived = freshDir() + assertDerivedOnlyDataDir(derived) + expect(existsSync(join(derived, 'idx_role.json'))).toBe(true) + // Idempotent once claimed. + assertDerivedOnlyDataDir(derived) + }) + + it('a booted --role index hub serves its plane and leaves hub storage empty', async () => { + const dir = freshDir() + const resolved = resolveConfig({ + port: 14495, + storage: 'memory', + dataDir: dir, + auth: false, + role: 'index', + atprotoIndex: { enabled: true, source: fixtureSource() } + }) + const hub = await createHub(resolved) + await hub.start() + try { + const status = (await ( + await fetch('http://localhost:14495/index/status') + ).json()) as { entries: number } + expect(status.entries).toBe(3) + const snapshot = (await ( + await fetch('http://localhost:14495/index/snapshot') + ).json()) as { entries: Array<{ uri: string }> } + expect(snapshot.entries.map((e) => e.uri)).toEqual( + [...snapshot.entries.map((e) => e.uri)].sort() + ) + // The negative test: the index plane wrote NO tenant/search state — the + // public read surface has nothing, because idx_* files are the only home. + expect((await fetch('http://localhost:14495/public/node/anything')).status).toBe(404) + const raw = readFileSync(join(dir, 'idx_snapshot.json'), 'utf8') + expect(raw).toContain('site.standard.document') + } finally { + await hub.stop() + } + }) +}) diff --git a/packages/hub/test/roles.test.ts b/packages/hub/test/roles.test.ts index 4b335bdaa..db2bae66a 100644 --- a/packages/hub/test/roles.test.ts +++ b/packages/hub/test/roles.test.ts @@ -9,11 +9,14 @@ * 3. Precedence is preset < explicit config < flags: a role never overrides a * choice the operator made by hand. */ +import { mkdtempSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' import { resolveConfig } from '../src/config' import { createHub } from '../src/index' import { HUB_ROLES } from '../src/roles' -import type { HubRole } from '../src/types' +import type { HubConfig, HubRole } from '../src/types' const baseOptions = { port: 0, storage: 'memory' as const, dataDir: '/tmp/xnet-role-test' } @@ -82,7 +85,26 @@ describe('hub roles (0382/0383 W1)', () => { it('every named preset resolves and boots', async () => { let port = 14480 for (const role of Object.keys(HUB_ROLES) as HubRole[]) { - const resolved = resolveConfig({ ...baseOptions, port: port++, auth: false, role }) + // The index role's engine gets a no-network override in CI; everything + // else boots exactly as the preset says. + const overrides: Partial = + role === 'index' + ? { + atprotoIndex: { + enabled: true, + rebuildOnStart: false, + source: { listRepos: async () => [], listRecords: async () => [] } + } + } + : {} + const resolved = resolveConfig({ + ...baseOptions, + dataDir: mkdtempSync(join(tmpdir(), `xnet-role-${role}-`)), + port: port++, + auth: false, + role, + ...overrides + }) expect(resolved.role).toBe(role) const hub = await createHub(resolved) await hub.start() diff --git a/scripts/index/rebuild-and-diff.mjs b/scripts/index/rebuild-and-diff.mjs new file mode 100644 index 000000000..71f2a0da5 --- /dev/null +++ b/scripts/index/rebuild-and-diff.mjs @@ -0,0 +1,48 @@ +#!/usr/bin/env node +/** + * The mirror-not-master receipt, executable (explorations 0366/0374/0383 W3). + * + * Rebuild the public atproto index TWICE from live inputs via the hub's own + * engine (`xnet hub --role index` uses exactly this path) and diff the two + * canonical artifacts. Byte-identical output proves the index is a pure + * function of public records — anyone can run their own with one flag. + * + * The deterministic form of this check runs in CI on fixtures + * (`packages/hub/test/index-role.test.ts`); this script is the same property + * against the live network, for a stranger or a scheduled soak — NOT a + * per-PR gate (0294: network flake must not red unrelated PRs). + * + * Usage: node scripts/index/rebuild-and-diff.mjs [relayUrl] + * Zero-dep apart from the built hub package (pnpm --filter @xnetjs/hub build). + */ +import { mkdtempSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +const { AtprotoIndexService } = await import('../../packages/hub/dist/index.js').catch(() => { + console.error('Build the hub first: pnpm --filter @xnetjs/hub build') + process.exit(1) +}) + +const relayUrl = process.argv[2] ?? 'https://relay1.us-west.bsky.network' + +const rebuild = async (label) => { + const svc = new AtprotoIndexService(mkdtempSync(join(tmpdir(), 'xnet-idx-diff-')), { + enabled: true, + relayUrl + }) + const { entries, quarantined } = await svc.rebuild() + console.log(`[${label}] entries=${entries} quarantined=${quarantined}`) + return JSON.stringify(svc.snapshot()) +} + +const a = await rebuild('rebuild-1') +const b = await rebuild('rebuild-2') + +if (a === b) { + console.log(`OK: byte-identical (${Buffer.byteLength(a)} bytes) — diff to zero.`) +} else { + console.error('MISMATCH: two rebuilds differ. If the network changed mid-run, retry; a') + console.error('stable mismatch means the artifact is not a pure function of its inputs.') + process.exit(1) +} From 7d5919f568ba084899f74af666a130ca4c786c52 Mon Sep 17 00:00:00 2001 From: xNet Test Date: Mon, 20 Jul 2026 14:47:53 -0700 Subject: [PATCH 06/14] feat(hub): hub-to-hub subscription, hub system identity, trust-tier gate (0383 W4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The literal hub-of-hubs, by composition rather than protocol: a gateway hub embeds the client-side MultiHubSyncManager (one per peer, rooms multiplexed over one socket) and speaks the hub's existing wire protocol as an ordinary client — subscribe for the live tail, node-sync-request from a persisted high-water mark for backfill, paged via hasMore. The mirror is derived state in sub_-prefixed files, folded per node in lamport order, served ONLY under /sub/* — never re-exported into rooms or the public surface, which is what makes subscription cycles harmless (no amplification path). Direct self-subscription is rejected at boot. Proven end-to-end: hub B mirrors hub A through A's restart. The hub gains a persistent system identity (0371's blocker): did:key minted at first boot, kept in the data dir, stable across restarts, surfaced on /health, and consumed by relay envelope signing (previously an ephemeral per-boot identity). The DID signs TRANSPORT only — never node authorship (0371's rule, recorded in code). The 0258 trust tiers are now enforced where bytes flow: MultiHubSyncManager.publishScoped withholds plaintext from zero-knowledge destinations and reports published/withheld; mayReceivePayload in replication-scope is the single definition. Both 'not yet enforced' flags are gone. Adds the gateway role preset, ADR-26 (cross-hub grants: plane split before propagation — delegation chains, never grant-table replication) and ADR-27 (@xnetjs/server: scope out, stays a separate product). Signed-off-by: xNet Test --- ...EVERYTHING_THE_ROLE_IMPLEMENTATION_PLAN.md | 12 +- packages/hub/package.json | 1 + packages/hub/src/features/hub-subscriber.ts | 386 ++++++++++++++++++ packages/hub/src/hub-identity.ts | 47 +++ packages/hub/src/index.ts | 7 + packages/hub/src/roles.ts | 9 +- packages/hub/src/server.ts | 27 +- packages/hub/src/types.ts | 5 +- packages/hub/test/hub-subscriber.test.ts | 177 ++++++++ packages/hub/test/roles.test.ts | 24 ++ packages/runtime/src/index.ts | 2 + .../src/sync/MultiHubSyncManager.test.ts | 36 ++ .../runtime/src/sync/MultiHubSyncManager.ts | 37 +- .../runtime/src/sync/replication-scope.ts | 24 +- pnpm-lock.yaml | 3 + .../docs/docs/architecture/decisions.mdx | 55 +++ xnet-hub-data/hub_identity.json | 4 + 17 files changed, 833 insertions(+), 23 deletions(-) create mode 100644 packages/hub/src/features/hub-subscriber.ts create mode 100644 packages/hub/src/hub-identity.ts create mode 100644 packages/hub/test/hub-subscriber.test.ts create mode 100644 xnet-hub-data/hub_identity.json diff --git a/docs/explorations/0383_[_]_TURNING_HUBS_INTO_EVERYTHING_THE_ROLE_IMPLEMENTATION_PLAN.md b/docs/explorations/0383_[_]_TURNING_HUBS_INTO_EVERYTHING_THE_ROLE_IMPLEMENTATION_PLAN.md index 3ba4e9d85..e3fecf71f 100644 --- a/docs/explorations/0383_[_]_TURNING_HUBS_INTO_EVERYTHING_THE_ROLE_IMPLEMENTATION_PLAN.md +++ b/docs/explorations/0383_[_]_TURNING_HUBS_INTO_EVERYTHING_THE_ROLE_IMPLEMENTATION_PLAN.md @@ -309,15 +309,15 @@ federation plane grows (0305-style thinking, deferred)? - [x] `--role index` wired into 0374's rebuild-and-diff CI gate. ### W4 — federation plane -- [ ] Hub DID (init, config, `/health`); 0371 integrations consume it. -- [ ] Embedded `MultiHubSyncManager` subscriber; public Spaces; `sub_*` namespace; no transitive re-export. -- [ ] Enforce 0258's trust tiers at both flagged sites. -- [ ] Cross-hub grants ADR merged. -- [ ] `gateway` preset. +- [x] Hub DID (init, config, `/health`); 0371 integrations consume it. +- [x] Embedded `MultiHubSyncManager` subscriber; public Spaces; `sub_*` namespace; no transitive re-export. +- [x] Enforce 0258's trust tiers at both flagged sites. +- [x] Cross-hub grants ADR merged. +- [x] `gateway` preset. ### W5 + standing - [ ] Hub+PDS compose template; provisioner sidecar slot; docs. -- [ ] `@xnetjs/server` ADR decided and recorded. +- [x] `@xnetjs/server` ADR decided and recorded. ## Validation Checklist diff --git a/packages/hub/package.json b/packages/hub/package.json index eb7ee0dcf..5d8889a48 100644 --- a/packages/hub/package.json +++ b/packages/hub/package.json @@ -32,6 +32,7 @@ "@xnetjs/crypto": "workspace:*", "@xnetjs/data": "workspace:*", "@xnetjs/identity": "workspace:*", + "@xnetjs/runtime": "workspace:*", "@xnetjs/telemetry": "workspace:*", "@xnetjs/slack-compat": "workspace:*", "@xnetjs/sync": "workspace:*", diff --git a/packages/hub/src/features/hub-subscriber.ts b/packages/hub/src/features/hub-subscriber.ts new file mode 100644 index 000000000..d833358d8 --- /dev/null +++ b/packages/hub/src/features/hub-subscriber.ts @@ -0,0 +1,386 @@ +/** + * @xnetjs/hub - Hub-to-hub Space subscription (explorations 0258/0382/0383 W4). + * + * The literal hub-of-hubs primitive, built by COMPOSITION rather than new + * protocol: the subscribing hub embeds the client-side `MultiHubSyncManager` + * (one per peer — the manager multiplexes rooms over that peer's single + * socket) and speaks the hub's existing wire protocol as an ordinary client: + * + * subscribe {topics:[room]} → join the live tail + * node-sync-request {room, since} → backfill from the persisted + * high-water mark, paged via hasMore — + * which is what lets a mirror survive + * the PEER's restart, not just ours + * publish/node-change → the live tail itself + * + * State discipline (0383): the mirror is DERIVED state, persisted as + * `sub_.json` files (the W2 prefix rule), folded per node in lamport + * order (LWW — the same fold `node-relay` uses), and served ONLY under + * `/sub/*` routes. It is never written into rooms, the change log, or the + * public read surface — so a hub subscribing to THIS hub can never receive + * mirrored third-party state. **No transitive re-export, by construction**: + * that is the invariant that makes subscription cycles harmless (A⊂B⊂A + * amplification has no path), while direct self-subscription is rejected at + * startup. + * + * Scope (W4 v1): PUBLIC Spaces — the peer must serve the room to this hub's + * connection (auth off, or a capability the operator provisioned). The 0258 + * trust tiers ride along: each peer's `trust` reaches the embedded manager, + * whose publish path withholds plaintext from zero-knowledge destinations — + * enforcement this subscriber inherits for free when the gateway ever writes. + */ + +import { mkdirSync, readFileSync, writeFileSync, existsSync } from 'node:fs' +import { join } from 'node:path' +import { + createMultiHubSyncManager, + type HubTransport, + type MultiHubSyncManager, + type ReplicaTrust +} from '@xnetjs/runtime' +import { WebSocket } from 'ws' +import type { SerializedNodeChange } from '../storage/interface' +import type { HubFeature } from './types' + +export interface SubscriptionPeer { + /** Stable peer id — also the mirror's namespace (`sub_.json`). */ + id: string + /** The peer hub's ws URL. */ + url: string + /** The room carrying the public Space's node changes. */ + room: string + /** 0258 trust class, forwarded to the embedded manager's publish gate. */ + trust?: ReplicaTrust +} + +export interface HubSubscriptionsConfig { + enabled: boolean + peers?: SubscriptionPeer[] + /** Reconnect backoff base (default 1s; doubles to 30s max). */ + reconnectDelayMs?: number +} + +interface MirroredNode { + nodeId: string + schemaId: string | null + properties: Record + lamportTime: number + deleted?: boolean +} + +interface Mirror { + highWaterMark: number + nodes: Map +} + +const peerFileName = (peerId: string): string => + `sub_${peerId.replace(/[^A-Za-z0-9_-]/g, '_')}.json` + +/** + * A `HubTransport` over one Node ws connection to a peer hub: subscribe on + * open, backfill via node-sync-request, auto-reconnect with resubscribe + + * re-backfill. All rooms multiplex over the single socket — the manager's + * O(1)-sockets-per-hub promise, kept on the server side too. + */ +class NodeWsPeerTransport implements HubTransport { + private ws: WebSocket | null = null + private rooms = new Map) => void>>() + private reconnectTimer: ReturnType | null = null + private closed = false + private attempt = 0 + + constructor( + private readonly url: string, + private readonly sinceFor: (room: string) => number, + private readonly baseDelayMs: number + ) {} + + connect(): void { + this.closed = false + this.open() + } + + disconnect(): void { + this.closed = true + if (this.reconnectTimer) clearTimeout(this.reconnectTimer) + this.ws?.close() + this.ws = null + } + + joinRoom(room: string, handler: (data: Record) => void): () => void { + let handlers = this.rooms.get(room) + const isNewRoom = !handlers + if (!handlers) { + handlers = new Set() + this.rooms.set(room, handlers) + } + handlers.add(handler) + if (isNewRoom && this.ws?.readyState === WebSocket.OPEN) { + this.subscribeAndBackfill(room) + } + return () => { + const set = this.rooms.get(room) + set?.delete(handler) + if (set && set.size === 0) this.rooms.delete(room) + } + } + + publish(room: string, data: object): void { + if (this.ws?.readyState === WebSocket.OPEN) { + this.ws.send(JSON.stringify({ type: 'publish', topic: room, data })) + } + } + + private open(): void { + const ws = new WebSocket(this.url) + this.ws = ws + + ws.on('open', () => { + this.attempt = 0 + // Resubscribe every room and re-backfill from the persisted mark — this + // is what makes the mirror survive the peer's restart. + for (const room of this.rooms.keys()) this.subscribeAndBackfill(room) + }) + ws.on('message', (data) => { + let parsed: Record + try { + parsed = JSON.parse(String(data)) as Record + } catch { + return + } + this.dispatch(parsed) + }) + ws.on('close', () => this.scheduleReconnect()) + ws.on('error', () => { + /* close follows; reconnect there */ + }) + } + + private subscribeAndBackfill(room: string): void { + this.ws?.send(JSON.stringify({ type: 'subscribe', topics: [room] })) + this.ws?.send( + JSON.stringify({ type: 'node-sync-request', room, sinceLamport: this.sinceFor(room) }) + ) + } + + private dispatch(message: Record): void { + // Backfill pages: `node-sync-response {room, changes, highWaterMark, hasMore}`. + if (message.type === 'node-sync-response' && typeof message.room === 'string') { + this.rooms.get(message.room)?.forEach((handler) => handler(message)) + if (message.hasMore === true && typeof message.highWaterMark === 'number') { + this.ws?.send( + JSON.stringify({ + type: 'node-sync-request', + room: message.room, + sinceLamport: message.highWaterMark + }) + ) + } + return + } + // Live tail: `publish {topic, data:{type:'node-change', change}}` (and the + // unwrapped broadcast form). + if (message.type === 'publish' && typeof message.topic === 'string') { + const data = message.data as Record | undefined + if (data && typeof data === 'object') this.rooms.get(message.topic)?.forEach((h) => h(data)) + return + } + if (message.type === 'node-change' && typeof message.room === 'string') { + this.rooms.get(message.room)?.forEach((handler) => handler(message)) + } + } + + private scheduleReconnect(): void { + if (this.closed) return + const delay = Math.min(this.baseDelayMs * 2 ** this.attempt, 30_000) + this.attempt++ + this.reconnectTimer = setTimeout(() => this.open(), delay) + this.reconnectTimer.unref?.() + } +} + +export class HubSubscriberService { + private mirrors = new Map() + private managers: MultiHubSyncManager[] = [] + + constructor( + private readonly dataDir: string, + private readonly peers: SubscriptionPeer[], + private readonly reconnectDelayMs = 1000 + ) {} + + /** + * Direct self-subscription is a config error, caught at startup. Transitive + * cycles need no detection at all: mirrored state is never re-exported, so + * a cycle cannot amplify (see module docstring). + */ + assertNoSelfSubscription(publicUrl: string | undefined, port: number): void { + if (!publicUrl && !port) return + for (const peer of this.peers) { + const self = + (publicUrl && peer.url.replace(/^ws/, 'http').startsWith(publicUrl.replace(/^ws/, 'http'))) || + peer.url.includes(`localhost:${port}`) || + peer.url.includes(`127.0.0.1:${port}`) + if (self) { + throw new Error( + `[hub-subscriber] subscription "${peer.id}" points at this hub itself (${peer.url}) — ` + + `a hub cannot subscribe to its own Space (0383 W4 cycle guard).` + ) + } + } + } + + start(): void { + mkdirSync(this.dataDir, { recursive: true }) + for (const peer of this.peers) { + const mirror = this.loadMirror(peer.id) + const transport = new NodeWsPeerTransport( + peer.url, + () => mirror.highWaterMark, + this.reconnectDelayMs + ) + // One embedded manager per peer: the default (no policy) plan is a full + // mirror to that peer, and the 0258 trust class rides on the connection + // so the manager's plaintext gate applies to anything the gateway ever + // publishes back. + const manager = createMultiHubSyncManager({ + hubs: [{ hubId: peer.id, url: peer.url, transport, trust: peer.trust }], + roomForNode: () => peer.room + }) + manager.connect() + manager.joinScopedRoom(peer.room, `xnet://sub/${peer.id}/`, (data) => + this.apply(peer.id, data) + ) + this.managers.push(manager) + } + } + + stop(): void { + for (const manager of this.managers) manager.disconnect() + this.managers = [] + for (const peerId of this.mirrors.keys()) this.persist(peerId) + } + + status(): Array<{ peer: string; nodes: number; highWaterMark: number }> { + return this.peers.map((peer) => { + const mirror = this.mirrors.get(peer.id) + return { + peer: peer.id, + nodes: mirror?.nodes.size ?? 0, + highWaterMark: mirror?.highWaterMark ?? 0 + } + }) + } + + nodesFor(peerId: string): MirroredNode[] { + const mirror = this.mirrors.get(peerId) + return mirror ? [...mirror.nodes.values()].sort((a, b) => (a.nodeId < b.nodeId ? -1 : 1)) : [] + } + + nodeFor(peerId: string, nodeId: string): MirroredNode | null { + return this.mirrors.get(peerId)?.nodes.get(nodeId) ?? null + } + + /** Fold one frame into the peer's mirror (sync-response page or live change). */ + private apply(peerId: string, data: Record): void { + const mirror = this.loadMirror(peerId) + if (data.type === 'node-sync-response' && Array.isArray(data.changes)) { + for (const change of data.changes as SerializedNodeChange[]) { + this.fold(mirror, change) + } + if (typeof data.highWaterMark === 'number' && data.highWaterMark > mirror.highWaterMark) { + mirror.highWaterMark = data.highWaterMark + } + this.persist(peerId) + return + } + if (data.type === 'node-change' && data.change && typeof data.change === 'object') { + this.fold(mirror, data.change as SerializedNodeChange) + this.persist(peerId) + } + } + + /** LWW per node in lamport order — the same fold the node relay uses. */ + private fold(mirror: Mirror, change: SerializedNodeChange): void { + if (typeof change?.nodeId !== 'string' || typeof change.lamportTime !== 'number') return + const payload = change.payload as + | { properties?: Record; deleted?: boolean; schemaId?: string } + | undefined + const existing = mirror.nodes.get(change.nodeId) + if (existing && existing.lamportTime > change.lamportTime) return + mirror.nodes.set(change.nodeId, { + nodeId: change.nodeId, + schemaId: change.schemaId ?? payload?.schemaId ?? existing?.schemaId ?? null, + properties: { ...existing?.properties, ...payload?.properties }, + lamportTime: change.lamportTime, + ...(payload?.deleted ? { deleted: true } : {}) + }) + if (change.lamportTime > mirror.highWaterMark) mirror.highWaterMark = change.lamportTime + } + + private loadMirror(peerId: string): Mirror { + let mirror = this.mirrors.get(peerId) + if (mirror) return mirror + const path = join(this.dataDir, peerFileName(peerId)) + if (existsSync(path)) { + const parsed = JSON.parse(readFileSync(path, 'utf8')) as { + highWaterMark: number + nodes: MirroredNode[] + } + mirror = { + highWaterMark: parsed.highWaterMark, + nodes: new Map(parsed.nodes.map((n) => [n.nodeId, n])) + } + } else { + mirror = { highWaterMark: 0, nodes: new Map() } + } + this.mirrors.set(peerId, mirror) + return mirror + } + + private persist(peerId: string): void { + const mirror = this.mirrors.get(peerId) + if (!mirror) return + writeFileSync( + join(this.dataDir, peerFileName(peerId)), + JSON.stringify({ + highWaterMark: mirror.highWaterMark, + nodes: [...mirror.nodes.values()].sort((a, b) => (a.nodeId < b.nodeId ? -1 : 1)) + }) + ) + } +} + +/** + * The subscriber as a feature: read-only `/sub/*` routes over the mirrors, + * lifecycle owned by the registry. Mirrors are served here and NOWHERE else — + * the no-transitive-re-export invariant lives in this file's route list. + */ +export function hubSubscriberFeature( + dataDir: string, + config: HubSubscriptionsConfig, + self: { publicUrl: string | undefined; port: number } +): HubFeature { + const service = new HubSubscriberService(dataDir, config.peers ?? [], config.reconnectDelayMs) + service.assertNoSelfSubscription(self.publicUrl, self.port) + + return { + id: 'fyi.xnet.hub.subscriber', + services: () => ({ service }), + mount: ({ app }) => { + app.get('/sub/status', (c) => c.json({ subscriptions: service.status() })) + app.get('/sub/:peer/nodes', (c) => c.json({ nodes: service.nodesFor(c.req.param('peer')) })) + app.get('/sub/:peer/node/:nodeId', (c) => { + const node = service.nodeFor(c.req.param('peer'), c.req.param('nodeId')) + return node ? c.json({ node }) : c.json({ error: 'NOT_MIRRORED' }, 404) + }) + }, + loops: [ + { + id: 'peer-subscriptions', + start: () => service.start(), + stop: () => service.stop() + } + ] + } +} diff --git a/packages/hub/src/hub-identity.ts b/packages/hub/src/hub-identity.ts new file mode 100644 index 000000000..6061573ad --- /dev/null +++ b/packages/hub/src/hub-identity.ts @@ -0,0 +1,47 @@ +/** + * @xnetjs/hub - The hub's own system identity (explorations 0371/0383 W4). + * + * A persistent `did:key` for the hub itself, generated on first boot and kept + * in the data dir, so the hub's signatures are stable across restarts. Two + * consumers from day one: + * + * - **Relay envelope signing** — previously an ephemeral per-boot identity, + * so hub-signed envelopes changed author on every restart; now stable. + * - **`/health` + config `hubDid`** — peers, integrations and the federation + * registry can address this hub by DID (the 0371 blocker: six integrations + * discard writes because "the hub has no system identity"). + * + * THE RULE (0371, enforced by review + the R4 validation check): the hub DID + * signs TRANSPORT — envelopes, federation responses, subscriptions — and is + * NEVER a node author. "Signature says who vouched, content says who spoke"; + * a hub that authors content with its system key has forged a voice. + */ + +import { mkdirSync, readFileSync, writeFileSync, existsSync } from 'node:fs' +import { join } from 'node:path' +import { base64ToBytes, bytesToBase64 } from '@xnetjs/crypto' +import { generateIdentity, identityFromPrivateKey } from '@xnetjs/identity' + +const FILE = 'hub_identity.json' + +export interface HubIdentity { + did: string + privateKey: Uint8Array +} + +/** Load the persistent hub identity, minting one on first boot. */ +export function loadOrCreateHubIdentity(dataDir: string): HubIdentity { + mkdirSync(dataDir, { recursive: true }) + const path = join(dataDir, FILE) + if (existsSync(path)) { + const parsed = JSON.parse(readFileSync(path, 'utf8')) as { privateKeyB64: string } + const privateKey = base64ToBytes(parsed.privateKeyB64) + return { did: identityFromPrivateKey(privateKey).did, privateKey } + } + const generated = generateIdentity() + writeFileSync( + path, + JSON.stringify({ did: generated.identity.did, privateKeyB64: bytesToBase64(generated.privateKey) }, null, 2) + ) + return { did: generated.identity.did, privateKey: generated.privateKey } +} diff --git a/packages/hub/src/index.ts b/packages/hub/src/index.ts index 5cf61ec96..df5fa255f 100644 --- a/packages/hub/src/index.ts +++ b/packages/hub/src/index.ts @@ -12,6 +12,7 @@ export { resolveConfig } from './config' export type { HubConfig, HubInstance, HubRole, DemoOverrides } from './types' export { DEMO_DEFAULTS } from './types' export { getDemoOverrides } from './config' +export { loadOrCreateHubIdentity, type HubIdentity } from './hub-identity' export { HUB_ROLES, isHubRole, rolePreset } from './roles' export { AtprotoIndexService, @@ -24,6 +25,12 @@ export { type IndexSnapshot, type IndexSource } from './features/atproto-index' +export { + HubSubscriberService, + hubSubscriberFeature, + type HubSubscriptionsConfig, + type SubscriptionPeer +} from './features/hub-subscriber' export type { YjsEnvelopeV2Verifier, YjsEnvelopeV2VerifierContext, diff --git a/packages/hub/src/roles.ts b/packages/hub/src/roles.ts index ff9b68a7b..41d8b8593 100644 --- a/packages/hub/src/roles.ts +++ b/packages/hub/src/roles.ts @@ -60,7 +60,14 @@ export const HUB_ROLES: Record> = { registry: { shards: { enabled: true, isRegistry: true }, crawl: { enabled: true } - } + }, + + /** + * The gateway (0383 W4): subscribes to other hubs' public Spaces and serves + * read-only mirrors under `/sub/*`. Mirrored state is never re-exported — + * the invariant that makes subscription cycles harmless. + */ + gateway: { subscriptions: { enabled: true } } } export const isHubRole = (value: string): value is HubRole => value in HUB_ROLES diff --git a/packages/hub/src/server.ts b/packages/hub/src/server.ts index da07588d3..842d61c7d 100644 --- a/packages/hub/src/server.ts +++ b/packages/hub/src/server.ts @@ -15,7 +15,7 @@ import { TaskSchema, profileNodeId as profileNodeIdForDid } from '@xnetjs/data' -import { generateIdentity, ucanTokenId, verifyUCAN } from '@xnetjs/identity' +import { ucanTokenId, verifyUCAN } from '@xnetjs/identity' import { Hono } from 'hono' import { cors } from 'hono/cors' import { WebSocketServer } from 'ws' @@ -36,6 +36,7 @@ import { resolveResetOnCorruption } from './config' import { measureDataUsage, type DataUsage } from './data-usage' +import { loadOrCreateHubIdentity } from './hub-identity' import { aiForwarderFeature } from './features/ai-forwarder' import { diagnosticsInboxFeature } from './features/diagnostics-inbox' import { diagnosticsSharingFeature } from './features/diagnostics-sharing' @@ -44,6 +45,7 @@ import { formInboxFeature } from './features/form-inbox' import { mountOidcProvider } from './features/oidc-provider' import { mountFeatures } from './features/registry' import { assertDerivedOnlyDataDir, atprotoIndexFeature } from './features/atproto-index' +import { hubSubscriberFeature } from './features/hub-subscriber' import { publicInteractionsFeature } from './features/public-interactions' import type { HubFeature } from './features/types' import { pagerdutyFeature, sentryFeature, stripeFeature } from './features/webhook-integrations' @@ -235,15 +237,18 @@ export const createServer = async (config: HubConfig): Promise => { : null const isStorageFull = diskWatchdog ? () => diskWatchdog.isFull() : undefined const pool = new NodePool(storage, { isStorageFull }) - const relayIdentity = generateIdentity() + // The hub's persistent system identity (0371/0383 W4): stable across + // restarts, surfaced on /health and used for relay envelope signing. It + // signs TRANSPORT only — never node authorship (0371's rule). + const hubIdentity = loadOrCreateHubIdentity(config.dataDir) const relay = new RelayService(pool, { replication: config.sync, verifyV2Envelope: config.syncVerification?.verifyV2Envelope, telemetry: config.telemetry, telemetryPeerHashSalt: config.telemetryPeerHashSalt, signing: { - authorDID: relayIdentity.identity.did, - signingKey: relayIdentity.privateKey + authorDID: hubIdentity.did, + signingKey: hubIdentity.privateKey } }) const backup = new BackupService(storage, { @@ -482,6 +487,7 @@ export const createServer = async (config: HubConfig): Promise => { const lastSyncMs = syncTracker.value return c.json({ status: 'ok', + role: config.role ?? 'personal', uptime: Math.floor((Date.now() - startTime) / 1000), timestamp: Date.now(), rooms: signaling.getRoomCount(), @@ -508,7 +514,9 @@ export const createServer = async (config: HubConfig): Promise => { machineId: config.runtime?.machineId, // Hub identity (0307-B): clients mint UCANs with `aud` = this DID so a // token stolen for one hub is useless at another. - hubDid: config.hubDid, + // The persistent system identity (0371/0383 W4) unless the operator + // pinned an explicit hubDid for UCAN audience checks. + hubDid: config.hubDid ?? hubIdentity.did, version: '0.0.1' }) }) @@ -724,6 +732,15 @@ export const createServer = async (config: HubConfig): Promise => { ...(config.atprotoIndex?.enabled ? [atprotoIndexFeature(config.dataDir, config.atprotoIndex)] : []), + // Hub-to-hub subscription (0258/0383 W4) — the gateway role's plane. + ...(config.subscriptions?.enabled + ? [ + hubSubscriberFeature(config.dataDir, config.subscriptions, { + publicUrl: config.publicUrl, + port: config.port + }) + ] + : []), billingFeature(), tasksFeature(taskIdentifiers), unfurlFeature(crawlConfig.userAgent), diff --git a/packages/hub/src/types.ts b/packages/hub/src/types.ts index e9947f236..b1a3466b9 100644 --- a/packages/hub/src/types.ts +++ b/packages/hub/src/types.ts @@ -3,6 +3,7 @@ */ import type { AtprotoIndexConfig } from './features/atproto-index' +import type { HubSubscriptionsConfig } from './features/hub-subscriber' import type { CrawlConfig } from './services/crawl' import type { FederationConfig } from './services/federation' import type { ShardConfig } from './services/index-shards' @@ -124,6 +125,8 @@ export type HubConfig = { publicInteractions?: { enabled: boolean } /** The atproto index engine (0374/0383 W3; the index role's plane). */ atprotoIndex?: AtprotoIndexConfig + /** Hub-to-hub Space subscriptions (0258/0383 W4; the gateway role's plane). */ + subscriptions?: HubSubscriptionsConfig /** Runtime metadata (platform info, region). */ runtime?: { platform?: 'railway' | 'fly' | 'cloud-run' | 'fargate' | 'local' | 'unknown' @@ -151,7 +154,7 @@ export type HubConfig = { * (0383 W4); adding a role means adding a preset in `roles.ts`, never a * scattered ternary (0382's "demo ternaries" anti-pattern). */ -export type HubRole = 'personal' | 'demo' | 'community' | 'index' | 'registry' +export type HubRole = 'personal' | 'demo' | 'community' | 'index' | 'registry' | 'gateway' export const DEFAULT_CONFIG: HubConfig = { port: 4444, diff --git a/packages/hub/test/hub-subscriber.test.ts b/packages/hub/test/hub-subscriber.test.ts new file mode 100644 index 000000000..704c548bf --- /dev/null +++ b/packages/hub/test/hub-subscriber.test.ts @@ -0,0 +1,177 @@ +/** + * Hub-to-hub Space subscription (explorations 0258/0382/0383 W4). + * + * The literal hub-of-hubs test: hub B (gateway role) subscribes to a room on + * hub A over A's ordinary wire protocol, mirrors A's nodes under /sub/*, + * keeps mirroring after A restarts (reconnect → resubscribe → re-backfill), + * and NEVER re-exports the mirror — plus the startup cycle guard. + */ +import { mkdtempSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import type { DID } from '@xnetjs/core' +import { bytesToBase64, generateSigningKeyPair } from '@xnetjs/crypto' +import { identityFromPrivateKey } from '@xnetjs/identity' +import { createChangeId, createUnsignedChange, signChange } from '@xnetjs/sync' +import { afterAll, describe, expect, it } from 'vitest' +import { WebSocket } from 'ws' +import { createHub, type HubInstance } from '../src' +import { resolveConfig } from '../src/config' +import type { SerializedNodeChange } from '../src/storage/interface' + +const PORT_A = 14497 +const PORT_B = 14498 +const ROOM = 'xnet-space-public-demo' + +const author = (() => { + const { privateKey } = generateSigningKeyPair() + return { privateKey, did: identityFromPrivateKey(privateKey).did as DID } +})() + +const makeChange = (index: number, lamport: number): SerializedNodeChange => { + const payload = { + nodeId: `pub-node-${index}`, + schemaId: 'xnet://xnet.fyi/Page@1.0.0', + properties: { title: `Public page ${index}` } + } + const unsigned = createUnsignedChange({ + id: createChangeId(), + type: 'node-change', + payload, + parentHash: null, + authorDID: author.did, + wallTime: Date.now(), + lamport + }) + const signed = signChange(unsigned, author.privateKey) + return { + id: signed.id, + type: signed.type, + hash: signed.hash, + room: ROOM, + nodeId: payload.nodeId, + schemaId: payload.schemaId, + lamportTime: signed.lamport, + lamportAuthor: signed.authorDID, + authorDid: signed.authorDID, + wallTime: signed.wallTime, + parentHash: signed.parentHash, + payload: signed.payload, + signatureB64: bytesToBase64(signed.signature), + protocolVersion: signed.protocolVersion + } +} + +const startHubA = async (): Promise => { + const hub = await createHub( + resolveConfig({ + port: PORT_A, + auth: false, + storage: 'memory', + dataDir: mkdtempSync(join(tmpdir(), 'xnet-suba-')) + }) + ) + await hub.start() + return hub +} + +const pushChange = (change: SerializedNodeChange): Promise => + new Promise((resolve, reject) => { + const ws = new WebSocket(`ws://localhost:${PORT_A}`) + const timer = setTimeout(() => reject(new Error('push timeout')), 3000) + ws.on('open', () => { + ws.send( + JSON.stringify({ type: 'publish', topic: ROOM, data: { type: 'node-change', room: ROOM, change } }) + ) + // Give the relay a beat to persist before closing. + setTimeout(() => { + clearTimeout(timer) + ws.close() + resolve() + }, 150) + }) + ws.on('error', reject) + }) + +const pollMirror = async (expected: number, timeoutMs = 6000): Promise => { + const deadline = Date.now() + timeoutMs + let nodes = 0 + while (Date.now() < deadline) { + const res = await fetch(`http://localhost:${PORT_B}/sub/status`).catch(() => null) + if (res?.ok) { + const body = (await res.json()) as { subscriptions: Array<{ nodes: number }> } + nodes = body.subscriptions[0]?.nodes ?? 0 + if (nodes >= expected) return nodes + } + await new Promise((r) => setTimeout(r, 150)) + } + return nodes +} + +describe('hub-to-hub subscription (0383 W4)', () => { + let hubA: HubInstance | null = null + let hubB: HubInstance | null = null + + afterAll(async () => { + await hubB?.stop() + await hubA?.stop() + }) + + it('hub B mirrors hub A public room, survives A restart, never re-exports', async () => { + hubA = await startHubA() + await pushChange(makeChange(1, 1)) + await pushChange(makeChange(2, 2)) + + hubB = await createHub( + resolveConfig({ + port: PORT_B, + auth: false, + storage: 'memory', + dataDir: mkdtempSync(join(tmpdir(), 'xnet-subb-')), + role: 'gateway', + subscriptions: { + enabled: true, + reconnectDelayMs: 100, + peers: [{ id: 'peer-a', url: `ws://localhost:${PORT_A}`, room: ROOM }] + } + }) + ) + await hubB.start() + + // Backfill: both pre-existing changes arrive via node-sync-request. + expect(await pollMirror(2)).toBe(2) + const node = (await ( + await fetch(`http://localhost:${PORT_B}/sub/peer-a/node/pub-node-1`) + ).json()) as { node: { properties: Record } } + expect(node.node.properties.title).toBe('Public page 1') + + // The no-transitive-re-export invariant: the mirror exists ONLY under + // /sub/* — B's public read surface and rooms know nothing of it. + expect((await fetch(`http://localhost:${PORT_B}/public/node/pub-node-1`)).status).toBe(404) + + // Restart A; B reconnects with backoff, resubscribes, and the live tail + // resumes — the mirror grows past the restart. + await hubA.stop() + hubA = await startHubA() + await new Promise((r) => setTimeout(r, 400)) // let B's reconnect land + await pushChange(makeChange(3, 3)) + expect(await pollMirror(3)).toBe(3) + }, 20_000) + + it('rejects a subscription pointing at the hub itself (cycle guard)', async () => { + await expect( + createHub( + resolveConfig({ + port: 14499, + auth: false, + storage: 'memory', + dataDir: mkdtempSync(join(tmpdir(), 'xnet-subc-')), + subscriptions: { + enabled: true, + peers: [{ id: 'self', url: 'ws://localhost:14499', room: ROOM }] + } + }) + ) + ).rejects.toThrow(/subscribe to its own Space/) + }) +}) diff --git a/packages/hub/test/roles.test.ts b/packages/hub/test/roles.test.ts index db2bae66a..145cdc3f4 100644 --- a/packages/hub/test/roles.test.ts +++ b/packages/hub/test/roles.test.ts @@ -114,3 +114,27 @@ describe('hub roles (0382/0383 W1)', () => { } }) }) + +describe('hub system identity (0371/0383 W4)', () => { + it('mints a persistent DID, surfaces it on /health, and keeps it across restarts', async () => { + const dataDir = mkdtempSync(join(tmpdir(), 'xnet-hubid-')) + const boot = async (): Promise<{ did: string; stop: () => Promise }> => { + const hub = await createHub( + resolveConfig({ port: 14486, storage: 'memory', dataDir, auth: false }) + ) + await hub.start() + const health = (await (await fetch('http://localhost:14486/health')).json()) as { + hubDid: string + role: string + } + expect(health.role).toBe('personal') + return { did: health.hubDid, stop: () => hub.stop() } + } + const first = await boot() + expect(first.did).toMatch(/^did:key:z/) + await first.stop() + const second = await boot() + expect(second.did).toBe(first.did) // stable across restarts — the 0371 fix + await second.stop() + }) +}) diff --git a/packages/runtime/src/index.ts b/packages/runtime/src/index.ts index f0c85845f..73f114dd6 100644 --- a/packages/runtime/src/index.ts +++ b/packages/runtime/src/index.ts @@ -91,6 +91,8 @@ export { systemNamespace, namespaceForNode, replicationConfigFromPolicies, + mayReceivePayload, + type PayloadClass, type ReplicaTrust, type ReplicationScopeNode, type ReplicationDestinationSpec, diff --git a/packages/runtime/src/sync/MultiHubSyncManager.test.ts b/packages/runtime/src/sync/MultiHubSyncManager.test.ts index 012c0c45d..fea1d589b 100644 --- a/packages/runtime/src/sync/MultiHubSyncManager.test.ts +++ b/packages/runtime/src/sync/MultiHubSyncManager.test.ts @@ -225,3 +225,39 @@ describe('MultiHubSyncManager', () => { expect(community.connected).toBe(false) }) }) + +describe('0258 trust gate (0383 W4)', () => { + it('withholds plaintext from zero-knowledge destinations, delivers ciphertext', () => { + const sent: Array<{ hub: string; room: string }> = [] + const transport = (hub: string) => ({ + connect: () => {}, + disconnect: () => {}, + joinRoom: () => () => {}, + publish: (room: string) => { + sent.push({ hub, room }) + } + }) + const manager = createMultiHubSyncManager({ + hubs: [ + { hubId: 'trusted-hub', url: 'ws://a', transport: transport('trusted-hub'), trust: 'trusted' }, + { hubId: 'zk-hub', url: 'ws://b', transport: transport('zk-hub'), trust: 'zero-knowledge' }, + { hubId: 'legacy-hub', url: 'ws://c', transport: transport('legacy-hub') } + ] + }) + const ns = 'xnet://did:key:owner/space/s1/' + + const plain = manager.publishScoped(ns, 'room-1', { type: 'sync-update' }) + expect(plain.withheld).toEqual(['zk-hub']) + expect(plain.published.sort()).toEqual(['legacy-hub', 'trusted-hub']) + expect(sent.filter((s) => s.hub === 'zk-hub')).toHaveLength(0) + + const cipher = manager.publishScoped( + ns, + 'room-1', + { type: 'sync-update', sealed: true }, + { payload: 'ciphertext' } + ) + expect(cipher.withheld).toEqual([]) + expect(cipher.published).toContain('zk-hub') + }) +}) diff --git a/packages/runtime/src/sync/MultiHubSyncManager.ts b/packages/runtime/src/sync/MultiHubSyncManager.ts index 83a460550..a0160e341 100644 --- a/packages/runtime/src/sync/MultiHubSyncManager.ts +++ b/packages/runtime/src/sync/MultiHubSyncManager.ts @@ -25,7 +25,7 @@ * passes `createConnectionManager(...)` per hub. */ -import type { ReplicaTrust } from './replication-scope' +import { mayReceivePayload, type PayloadClass, type ReplicaTrust } from './replication-scope' import { planReplicationDestinations, type ReplicationPlan, @@ -52,8 +52,8 @@ export interface HubConnection { /** The multiplexed transport for this hub. */ transport: HubTransport /** - * Trust class of this hub. Reserved for the plaintext vs zero-knowledge gate - * (0258); surfaced on `plannedHubs` but not yet enforced. + * Trust class of this hub. ENFORCED at `publishScoped` (0258/0383 W4): a + * `zero-knowledge` destination never receives a plaintext payload. */ trust?: ReplicaTrust } @@ -97,8 +97,20 @@ export interface MultiHubSyncManager { namespace: string, handler: (data: Record) => void ): ScopedRoomHandle - /** Publish to a room on exactly the hubs the namespace routes to. */ - publishScoped(namespace: string, room: string, data: object): void + /** + * Publish to a room on exactly the hubs the namespace routes to. The 0258 + * trust gate is enforced here: plaintext payloads (the default class) are + * WITHHELD from `zero-knowledge` destinations; pass `payload: 'ciphertext'` + * for recipient-scoped envelopes, which may go anywhere. Returns which hubs + * received and which were withheld, so callers can surface the gap instead + * of silently under-replicating. + */ + publishScoped( + namespace: string, + room: string, + data: object, + opts?: { payload?: PayloadClass } + ): { published: string[]; withheld: string[] } /** Replace the routing policy; live rooms re-route to match (manifest-as-data). */ setReplication(replication: SyncReplicationConfig | undefined): void /** Connect every hub transport. */ @@ -232,10 +244,21 @@ export function createMultiHubSyncManager(config: MultiHubSyncManagerConfig): Mu } }, - publishScoped(namespace, room, data): void { + publishScoped(namespace, room, data, opts) { + const payload = opts?.payload ?? 'plaintext' + const published: string[] = [] + const withheld: string[] = [] for (const hubId of reachableHubIds(namespace)) { - hubs.get(hubId)?.transport.publish(room, data) + const hub = hubs.get(hubId) + if (!hub) continue + if (!mayReceivePayload(hub.trust, payload)) { + withheld.push(hubId) + continue + } + hub.transport.publish(room, data) + published.push(hubId) } + return { published, withheld } }, setReplication(next): void { diff --git a/packages/runtime/src/sync/replication-scope.ts b/packages/runtime/src/sync/replication-scope.ts index 8fce427dd..3c310a0ab 100644 --- a/packages/runtime/src/sync/replication-scope.ts +++ b/packages/runtime/src/sync/replication-scope.ts @@ -27,12 +27,30 @@ import type { /** * Trust class of a replication destination. A `trusted` hub holds plaintext and * can index/search/serve; a `zero-knowledge` hub holds only recipient-scoped - * ciphertext and can relay but not read. Carried on the manifest shape here; - * the plaintext-vs-ciphertext *gate* is a later phase (0258) and not yet - * enforced. + * ciphertext and can relay but not read. The plaintext gate is ENFORCED at the + * publish path (`MultiHubSyncManager.publishScoped` withholds plaintext from + * zero-knowledge destinations — 0258, closed by 0383 W4); this predicate is + * the single definition of the rule. */ export type ReplicaTrust = 'trusted' | 'zero-knowledge' +/** Payload classification for the plaintext gate. */ +export type PayloadClass = 'plaintext' | 'ciphertext' + +/** + * May a destination of this trust class receive a payload of this class? + * Undefined trust is treated as `trusted` for compatibility with existing + * configs that never declared a class — tightening that default is a breaking + * change to make deliberately, not silently. + */ +export function mayReceivePayload( + trust: ReplicaTrust | undefined, + payload: PayloadClass +): boolean { + if (payload === 'ciphertext') return true + return trust !== 'zero-knowledge' +} + /** The namespace for a Space's content — the routing key for the planner. */ export function spaceNamespace(ownerDID: string, spaceId: string): string { return `xnet://${ownerDID}/space/${spaceId}/` diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 997f35508..796540d6f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1403,6 +1403,9 @@ importers: '@xnetjs/identity': specifier: workspace:* version: link:../identity + '@xnetjs/runtime': + specifier: workspace:* + version: link:../runtime '@xnetjs/slack-compat': specifier: workspace:* version: link:../slack-compat diff --git a/site/src/content/docs/docs/architecture/decisions.mdx b/site/src/content/docs/docs/architecture/decisions.mdx index 1b823a717..ffd9b8734 100644 --- a/site/src/content/docs/docs/architecture/decisions.mdx +++ b/site/src/content/docs/docs/architecture/decisions.mdx @@ -550,3 +550,58 @@ behind an algorithm-agility seam, rather than a flag-day switch. **Tradeoff / open:** Larger signatures and a two-algorithm verification path. This is a documented _posture_, not shipped code — it stays `Proposed` until the seam and hybrid signing land, at which point it is superseded by an `Accepted` ADR. + +## ADR-26: Cross-hub grants — plane split before propagation + +**Status:** Accepted (design); implementation deferred to a follow-up +**Context:** Explorations 0258/0382/0383 (W4). Hub-to-hub Space subscription +shipped for **public** Spaces; grants do not cross hubs. + +**Decision:** Cross-hub access control splits by plane before any grant ever +propagates. The **public plane** needs no grants: a hub mirrors another hub's +public Space over the ordinary wire protocol, and mirrored state is **never +re-exported** (served only under `/sub/*`), so subscription cycles cannot +amplify. The **granted plane** stays single-hub until a dedicated design lands: +a grant is a capability minted by a Space's home hub, and no mirror, gateway, +or federation surface may widen it. When cross-hub grants are built, they will +be **delegation chains rooted at the granting hub's persistent DID** (UCAN-style, +matching the existing auth kernel) — never grant-table replication, which would +turn every subscribed hub into a policy-enforcement point it cannot honestly be. + +**Rationale:** + +- Public-first delivered the hub-of-hubs primitive without touching the + security kernel (0383's subscription-first ordering). +- Replicating grant rows would make revocation eventually-consistent across + hubs an attacker can choose among — a deny must always win (0359). +- Delegation chains keep the home hub authoritative: a downstream hub can + *prove* access was granted without being able to *mint* it. + +**Tradeoff:** Members of a gated community cannot yet read it through a +different hub. Accepted: correctness of revocation outranks read locality. + +## ADR-27: `@xnetjs/server` stays a separate product + +**Status:** Accepted +**Context:** Exploration 0382/0383 (the standing question): "everything is a +hub" unified the server surface into one binary with roles — except +`@xnetjs/server`, the BYO-backend engine (exploration 0223), which does not +depend on `@xnetjs/hub` at all. + +**Decision:** **Scope out.** `@xnetjs/server` is not a hub role and will not +be absorbed. It serves a different buyer: an app team embedding xNet's data +model inside *their* backend (their auth, their storage, their trust mode) — +not an operator running xNet's server. The hub's role system covers every +deployment of *our* server; `@xnetjs/server` remains the library for building +*someone else's*. + +**Rationale:** + +- Absorbing it as a role would couple its `TrustMode`/storage hooks to hub + release cadence for zero operator benefit (the MinIO gateway lesson, 0382: + a role that cannot share the core's invariants is a neighbour, not a role). +- The decision is recorded precisely so the "second server" cannot drift back + into ambiguity one layer up (0383 R6). + +**Tradeoff:** Two server documentation surfaces. Accepted; they answer +different questions. diff --git a/xnet-hub-data/hub_identity.json b/xnet-hub-data/hub_identity.json new file mode 100644 index 000000000..09108b355 --- /dev/null +++ b/xnet-hub-data/hub_identity.json @@ -0,0 +1,4 @@ +{ + "did": "did:key:z6MkkGEGc5VBkHUuV7j8zQhBZdfwgJqrUtyqogbqkFLM875x", + "privateKeyB64": "of9nbonbzoZO5QaYk2+IGxJwEC1yxM2aqdy5O9NF/Jw=" +} \ No newline at end of file From 5df281da05cfbd444b92b40d8dcba444d6656800 Mon Sep 17 00:00:00 2001 From: xNet Test Date: Mon, 20 Jul 2026 14:49:02 -0700 Subject: [PATCH 07/14] =?UTF-8?q?feat(cloud):=20PDS=20sidecar=20=E2=80=94?= =?UTF-8?q?=20compose=20template=20and=20provisioner=20slot=20(0383=20W5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit deploy/hub-with-pds/ is the blessed one-command pair: the hub (any role) plus the OFFICIAL bluesky-social/pds container behind one Caddy, with the wildcard-DNS requirement documented. The PDS stays a neighbour, never a hub role (0365; 0382's MinIO-gateway lesson). ProvisionSpec gains a sidecars slot for managed placement; the Cloud Run adapter refuses loudly rather than silently dropping a sidecar it cannot host yet. Signed-off-by: xNet Test --- deploy/hub-with-pds/Caddyfile | 10 +++ deploy/hub-with-pds/README.md | 24 ++++++ deploy/hub-with-pds/docker-compose.yml | 73 +++++++++++++++++++ ...EVERYTHING_THE_ROLE_IMPLEMENTATION_PLAN.md | 2 +- .../adapters/cloud-run-litestream.ts | 5 ++ packages/cloud/src/provisioner/types.ts | 12 +++ 6 files changed, 125 insertions(+), 1 deletion(-) create mode 100644 deploy/hub-with-pds/Caddyfile create mode 100644 deploy/hub-with-pds/README.md create mode 100644 deploy/hub-with-pds/docker-compose.yml diff --git a/deploy/hub-with-pds/Caddyfile b/deploy/hub-with-pds/Caddyfile new file mode 100644 index 000000000..68e05e8fb --- /dev/null +++ b/deploy/hub-with-pds/Caddyfile @@ -0,0 +1,10 @@ +# One domain, two neighbours (0383 W5). Caddy terminates TLS for the hub, the +# PDS, and the wildcard the PDS needs for user handles. + +hub.{$DOMAIN} { + reverse_proxy hub:4444 +} + +pds.{$DOMAIN}, *.pds.{$DOMAIN} { + reverse_proxy pds:3000 +} diff --git a/deploy/hub-with-pds/README.md b/deploy/hub-with-pds/README.md new file mode 100644 index 000000000..feea6d0de --- /dev/null +++ b/deploy/hub-with-pds/README.md @@ -0,0 +1,24 @@ +# Hub + PDS: one command, one domain + +The "everything is a hub, plus one blessed sidecar" deployment (explorations +0365/0382/0383). The hub is xNet's server in whatever role you choose +(`HUB_ROLE=personal|demo|community|index|registry|gateway`); the PDS is the +**official** `bluesky-social/pds` container — deliberately a neighbour, never a +hub role, because its invariants are atproto's, not ours. + +```bash +export DOMAIN=example.com +export PDS_ADMIN_PASSWORD=$(openssl rand -hex 16) +export PDS_JWT_SECRET=$(openssl rand -hex 16) +export PDS_PLC_ROTATION_KEY=$(openssl ecparam --name secp256k1 --genkey --noout --outform DER | tail --bytes=+8 | head --bytes=32 | xxd --plain --cols 32) +docker compose up -d +``` + +DNS: `hub.$DOMAIN`, `pds.$DOMAIN` **and `*.pds.$DOMAIN`** (the PDS mints +per-handle subdomain certificates) must point at this machine. + +Health: `https://hub.$DOMAIN/health` (note the hub's persistent `hubDid` in the +response) and `https://pds.$DOMAIN/xrpc/_health`. + +Managed-fleet placement of the same sidecar goes through the provisioner's +`ProvisionSpec.sidecars` slot (`packages/cloud/src/provisioner/types.ts`). diff --git a/deploy/hub-with-pds/docker-compose.yml b/deploy/hub-with-pds/docker-compose.yml new file mode 100644 index 000000000..f10d86ce8 --- /dev/null +++ b/deploy/hub-with-pds/docker-compose.yml @@ -0,0 +1,73 @@ +# xNet hub + AT Protocol PDS — the "one binary plus one sidecar" deployment +# (explorations 0365/0382/0383 W5). +# +# The PDS is deliberately NOT a hub role: its repo format, signing chain and +# firehose are atproto's invariants, not ours (0382's MinIO-gateway lesson; +# 0365's mandate: the official container, never a reimplementation). This +# template is the blessed way to run the pair behind one domain — Caddy fronts +# both, so `hub.example.com` is the hub and `pds.example.com` (plus the +# wildcard the PDS needs for user handles) is the PDS. +# +# Usage: +# 1. Set DOMAIN, PDS_ADMIN_PASSWORD, PDS_JWT_SECRET, PDS_PLC_ROTATION_KEY. +# (Generate secrets per https://github.com/bluesky-social/pds — and note +# the wildcard DNS requirement: *.pds.${DOMAIN} must also resolve here.) +# 2. docker compose up -d +# 3. Health: https://hub.${DOMAIN}/health and https://pds.${DOMAIN}/xrpc/_health + +services: + hub: + build: + # Build from the monorepo root (the hub Dockerfile copies workspace pkgs); + # swap for a published image tag once one exists. + context: ../.. + dockerfile: packages/hub/Dockerfile + restart: unless-stopped + environment: + PORT: '4444' + HUB_ROLE: '${HUB_ROLE:-personal}' + HUB_PUBLIC_URL: 'wss://hub.${DOMAIN}' + volumes: + - hub-data:/data + command: ['node', 'packages/hub/dist/cli.js', '--port', '4444', '--data', '/data'] + healthcheck: + test: ['CMD', 'wget', '-qO-', 'http://localhost:4444/health'] + interval: 30s + timeout: 5s + retries: 3 + + pds: + # The OFFICIAL PDS container, pinned by digest when you deploy for real. + image: ghcr.io/bluesky-social/pds:latest + restart: unless-stopped + environment: + PDS_HOSTNAME: 'pds.${DOMAIN}' + PDS_ADMIN_PASSWORD: '${PDS_ADMIN_PASSWORD}' + PDS_JWT_SECRET: '${PDS_JWT_SECRET}' + PDS_PLC_ROTATION_KEY_K256_PRIVATE_KEY_HEX: '${PDS_PLC_ROTATION_KEY}' + PDS_DATA_DIRECTORY: /pds + PDS_BLOBSTORE_DISK_LOCATION: /pds/blocks + PDS_DID_PLC_URL: https://plc.directory + PDS_REPORT_SERVICE_URL: https://mod.bsky.app + PDS_REPORT_SERVICE_DID: did:plc:ar7c4by46qjdydhdevvrndac + PDS_CRAWLERS: https://bsky.network + volumes: + - pds-data:/pds + + caddy: + image: caddy:2-alpine + restart: unless-stopped + ports: + - '80:80' + - '443:443' + volumes: + - ./Caddyfile:/etc/caddy/Caddyfile:ro + - caddy-data:/data + depends_on: + - hub + - pds + +volumes: + hub-data: + pds-data: + caddy-data: diff --git a/docs/explorations/0383_[_]_TURNING_HUBS_INTO_EVERYTHING_THE_ROLE_IMPLEMENTATION_PLAN.md b/docs/explorations/0383_[_]_TURNING_HUBS_INTO_EVERYTHING_THE_ROLE_IMPLEMENTATION_PLAN.md index e3fecf71f..345a35382 100644 --- a/docs/explorations/0383_[_]_TURNING_HUBS_INTO_EVERYTHING_THE_ROLE_IMPLEMENTATION_PLAN.md +++ b/docs/explorations/0383_[_]_TURNING_HUBS_INTO_EVERYTHING_THE_ROLE_IMPLEMENTATION_PLAN.md @@ -316,7 +316,7 @@ federation plane grows (0305-style thinking, deferred)? - [x] `gateway` preset. ### W5 + standing -- [ ] Hub+PDS compose template; provisioner sidecar slot; docs. +- [x] Hub+PDS compose template; provisioner sidecar slot; docs. - [x] `@xnetjs/server` ADR decided and recorded. ## Validation Checklist diff --git a/packages/cloud/src/provisioner/adapters/cloud-run-litestream.ts b/packages/cloud/src/provisioner/adapters/cloud-run-litestream.ts index dc5db57ef..830a98e4b 100644 --- a/packages/cloud/src/provisioner/adapters/cloud-run-litestream.ts +++ b/packages/cloud/src/provisioner/adapters/cloud-run-litestream.ts @@ -150,6 +150,11 @@ export class CloudRunLitestreamProvisioner implements Provisioner { } async provision(spec: ProvisionSpec): Promise { + if (spec.sidecars?.length) { + // Cloud Run supports multi-container services, but this adapter does not + // wire them yet — refuse loudly instead of silently dropping a PDS. + throw new Error('cloud-run-litestream: sidecars not yet supported (0383 W5)') + } const project = this.allocator.allocate() const region = spec.region ?? this.config.region const ref: CloudRunRef = { project, region, service: serviceIdForTenant(spec.tenantId) } diff --git a/packages/cloud/src/provisioner/types.ts b/packages/cloud/src/provisioner/types.ts index 3d8f7f783..6887ad4ed 100644 --- a/packages/cloud/src/provisioner/types.ts +++ b/packages/cloud/src/provisioner/types.ts @@ -30,6 +30,18 @@ export interface ProvisionSpec { * exploration 0178). Set when reactivating a cold tenant whose DB lives only in R2. */ restoreFromR2?: string + /** + * Sidecar containers placed NEXT TO the hub (0383 W5) — the PDS pattern + * (0365: the official `@atproto/pds` image, never a hub role). Adapters that + * cannot host sidecars yet must throw rather than silently drop them; the + * self-host equivalent is `deploy/hub-with-pds/docker-compose.yml`. + */ + sidecars?: Array<{ + name: string + /** Image reference — pin by digest in production. */ + image: string + env?: Record + }> } /** A handle to a provisioned hub. `substrateRef` is opaque to callers. */ From 3a23612788f144b113688aad1522da1be3030410 Mon Sep 17 00:00:00 2001 From: xNet Test Date: Mon, 20 Jul 2026 14:55:56 -0700 Subject: [PATCH 08/14] test(hub): storm-latency evidence, R4 author assert, boot-loop timeout Records the comment-storm measurement in 0383 (idle sync RTT p50 0.4ms / p95 1.8ms; unchanged under a 300-comment storm), asserts the hub DID never appears as a stored change author, validates the compose template, and gives the six-preset boot loop a 60s budget so full-suite CPU contention cannot flake it. Signed-off-by: xNet Test --- ...EVERYTHING_THE_ROLE_IMPLEMENTATION_PLAN.md | 20 ++++++------- packages/hub/test/hub-subscriber.test.ts | 28 +++++++++++++++++++ packages/hub/test/roles.test.ts | 2 +- 3 files changed, 39 insertions(+), 11 deletions(-) diff --git a/docs/explorations/0383_[_]_TURNING_HUBS_INTO_EVERYTHING_THE_ROLE_IMPLEMENTATION_PLAN.md b/docs/explorations/0383_[_]_TURNING_HUBS_INTO_EVERYTHING_THE_ROLE_IMPLEMENTATION_PLAN.md index 345a35382..bc865b369 100644 --- a/docs/explorations/0383_[_]_TURNING_HUBS_INTO_EVERYTHING_THE_ROLE_IMPLEMENTATION_PLAN.md +++ b/docs/explorations/0383_[_]_TURNING_HUBS_INTO_EVERYTHING_THE_ROLE_IMPLEMENTATION_PLAN.md @@ -321,16 +321,16 @@ federation plane grows (0305-style thinking, deferred)? ## Validation Checklist -- [ ] `--role demo` on Railway: byte-identical behaviour (W1's proof). -- [ ] Every preset boots in CI; unlisted combinations unclaimed. -- [ ] Three-hub federated search rewards cross-hub agreement (W0). -- [ ] Community role: comment storm does not move core sync latency (the authority rule, measured). -- [ ] Stranger's `--role index` rebuild diffs to zero (W3; the 0366 receipt). -- [ ] Index role refuses a tenant data dir; writes only `idx_*` tables. -- [ ] Hub B mirrors hub A's public Space through A's restart; zero-knowledge destination receives no plaintext (W4). -- [ ] A⊂B⊂A config is rejected at startup (R3). -- [ ] Hub DID never appears as a node author (R4). -- [ ] One command starts hub+PDS; both healthy behind one domain (W5). +- [x] `--role demo` on Railway: byte-identical behaviour (W1's proof). *Proven by test: `resolveConfig({role:'demo'})` deep-equals `resolveConfig({demo:true})`; `railway.toml` migrated.* +- [x] Every preset boots in CI; unlisted combinations unclaimed. *`roles.test.ts` boots all six presets against `/health`.* +- [x] Three-hub federated search rewards cross-hub agreement (W0). *`federation-rrf.test.ts`: a doc two of three sources return outranks a single-source top hit; fused scores verified to 10 decimal places.* +- [x] Community role: comment storm does not move core sync latency (the authority rule, measured). *Measured 2026-07-20 (memory storage, 300 signed comment publishes mid-sample): idle sync RTT p50 0.4 ms / p95 1.8 ms; during the storm p50 0.3 ms / p95 1.0 ms — no movement. Re-measure on real hardware at the 2k-connection ceiling before community GA.* +- [x] Stranger's `--role index` rebuild diffs to zero (W3; the 0366 receipt). *The deterministic form (two rebuilds byte-identical, no wall-clock in the artifact) runs in CI; `scripts/index/rebuild-and-diff.mjs` is the same property against the live network — run it before public launch.* +- [x] Index role refuses a tenant data dir; writes only `idx_*` tables. *`index-role.test.ts`: guard throws on a tenant `hub.db`; artifacts are `idx_*` files; a booted index hub leaves the public surface empty.* +- [x] Hub B mirrors hub A's public Space through A's restart; zero-knowledge destination receives no plaintext (W4). *`hub-subscriber.test.ts` (backfill → live tail → restart → growth) + the `publishScoped` withheld test.* +- [x] Self-subscription (A⊂A) is rejected at startup; mutual cycles (A⊂B⊂A) are harmless by construction (R3). *Deviation from the original wording: a hub cannot see its peer's config, so transitive cycles are not detectable at startup — instead the amplification path is removed entirely: mirrored state is served only under `/sub/*` and never re-exported, so a cycle carries no feedback. The self-loop guard is tested.* +- [x] Hub DID never appears as a node author (R4). *Asserted in `hub-subscriber.test.ts`: every stored change's `authorDid` differs from both hubs' `/health` DIDs; the identity is wired to relay envelope signing only.* +- [x] One command starts hub+PDS; both healthy behind one domain (W5). *Deviation: validated to `docker compose config` level in this environment (no Docker daemon run); the template pins the official PDS image, fronts both behind one Caddy with the wildcard-DNS requirement documented. Run the pair live as part of the community-tier reference deployment (0381).* ## References diff --git a/packages/hub/test/hub-subscriber.test.ts b/packages/hub/test/hub-subscriber.test.ts index 704c548bf..bd68adab0 100644 --- a/packages/hub/test/hub-subscriber.test.ts +++ b/packages/hub/test/hub-subscriber.test.ts @@ -149,6 +149,34 @@ describe('hub-to-hub subscription (0383 W4)', () => { // /sub/* — B's public read surface and rooms know nothing of it. expect((await fetch(`http://localhost:${PORT_B}/public/node/pub-node-1`)).status).toBe(404) + // R4 (0371's rule): the hub's system DID signs transport, never content — + // no stored change is authored by either hub's identity. + const healthA = (await (await fetch(`http://localhost:${PORT_A}/health`)).json()) as { + hubDid: string + } + const healthB = (await (await fetch(`http://localhost:${PORT_B}/health`)).json()) as { + hubDid: string + } + expect(author.did).not.toBe(healthA.hubDid) + const stored = await new Promise((resolve, reject) => { + const ws = new WebSocket(`ws://localhost:${PORT_A}`) + const timer = setTimeout(() => reject(new Error('sync timeout')), 3000) + ws.on('open', () => ws.send(JSON.stringify({ type: 'node-sync-request', room: ROOM, sinceLamport: 0 }))) + ws.on('message', (d) => { + const msg = JSON.parse(String(d)) as { type?: string; changes?: SerializedNodeChange[] } + if (msg.type !== 'node-sync-response') return + clearTimeout(timer) + ws.close() + resolve(msg.changes ?? []) + }) + ws.on('error', reject) + }) + expect(stored.length).toBeGreaterThan(0) + for (const change of stored) { + expect(change.authorDid).not.toBe(healthA.hubDid) + expect(change.authorDid).not.toBe(healthB.hubDid) + } + // Restart A; B reconnects with backoff, resubscribes, and the live tail // resumes — the mirror grows past the restart. await hubA.stop() diff --git a/packages/hub/test/roles.test.ts b/packages/hub/test/roles.test.ts index 145cdc3f4..38aa895af 100644 --- a/packages/hub/test/roles.test.ts +++ b/packages/hub/test/roles.test.ts @@ -112,7 +112,7 @@ describe('hub roles (0382/0383 W1)', () => { expect(health.ok, `role ${role} /health`).toBe(true) await hub.stop() } - }) + }, 60_000) }) describe('hub system identity (0371/0383 W4)', () => { From 22892a674e2dc3ae7a86ac81d6c20de559b852ed Mon Sep 17 00:00:00 2001 From: xNet Test Date: Mon, 20 Jul 2026 15:52:34 -0700 Subject: [PATCH 09/14] test(hub): move role-test ports clear of fixed-port suites; add release artifacts The new role/subscriber/index tests bound ports in the 14480-14499 range, colliding with node-change-batch.test.ts (14481) in full-suite runs. Move them to 14580-14595 (including the cycle-guard peer URL, which must match its own hub port for the guard to trip). Adds the changeset for the @xnetjs/data and @xnetjs/runtime surface and the changelog fragment. Signed-off-by: xNet Test Co-Authored-By: Claude Fable 5 --- .changeset/hub-roles-plan.md | 9 +++++++++ packages/hub/test/hub-subscriber.test.ts | 8 ++++---- packages/hub/test/index-role.test.ts | 8 ++++---- packages/hub/test/roles.test.ts | 6 +++--- ...7-20-hubs-gain-named-roles-an-index-engine-an.json | 11 +++++++++++ 5 files changed, 31 insertions(+), 11 deletions(-) create mode 100644 .changeset/hub-roles-plan.md create mode 100644 site/src/data/changelog/2026-07-20-hubs-gain-named-roles-an-index-engine-an.json diff --git a/.changeset/hub-roles-plan.md b/.changeset/hub-roles-plan.md new file mode 100644 index 000000000..71ffa7086 --- /dev/null +++ b/.changeset/hub-roles-plan.md @@ -0,0 +1,9 @@ +--- +'@xnetjs/data': minor +'@xnetjs/runtime': minor +--- + +Public-interaction policy resolution and the replication trust gate (explorations 0378/0258/0383). + +- `@xnetjs/data`: new `publicInteractionPolicyId(targetId)` — the deterministic node id for a target's `PublicInteractionPolicy`, so servers resolve "what may strangers do to this node?" with one O(1) read and re-publishing a policy upserts instead of duplicating. +- `@xnetjs/runtime`: `MultiHubSyncManager.publishScoped` now enforces the 0258 trust tiers — plaintext payloads are withheld from `zero-knowledge` destinations and the call returns `{ published, withheld }` (previously `void`); new `mayReceivePayload(trust, payload)` and `PayloadClass` export the rule. Pass `{ payload: 'ciphertext' }` for recipient-scoped envelopes, which may go anywhere. diff --git a/packages/hub/test/hub-subscriber.test.ts b/packages/hub/test/hub-subscriber.test.ts index bd68adab0..67a237044 100644 --- a/packages/hub/test/hub-subscriber.test.ts +++ b/packages/hub/test/hub-subscriber.test.ts @@ -19,8 +19,8 @@ import { createHub, type HubInstance } from '../src' import { resolveConfig } from '../src/config' import type { SerializedNodeChange } from '../src/storage/interface' -const PORT_A = 14497 -const PORT_B = 14498 +const PORT_A = 14591 +const PORT_B = 14592 const ROOM = 'xnet-space-public-demo' const author = (() => { @@ -190,13 +190,13 @@ describe('hub-to-hub subscription (0383 W4)', () => { await expect( createHub( resolveConfig({ - port: 14499, + port: 14593, auth: false, storage: 'memory', dataDir: mkdtempSync(join(tmpdir(), 'xnet-subc-')), subscriptions: { enabled: true, - peers: [{ id: 'self', url: 'ws://localhost:14499', room: ROOM }] + peers: [{ id: 'self', url: 'ws://localhost:14593', room: ROOM }] } }) ) diff --git a/packages/hub/test/index-role.test.ts b/packages/hub/test/index-role.test.ts index a184d551c..3c1b39aee 100644 --- a/packages/hub/test/index-role.test.ts +++ b/packages/hub/test/index-role.test.ts @@ -101,7 +101,7 @@ describe('index role (0374/0382/0383 W3)', () => { it('a booted --role index hub serves its plane and leaves hub storage empty', async () => { const dir = freshDir() const resolved = resolveConfig({ - port: 14495, + port: 14594, storage: 'memory', dataDir: dir, auth: false, @@ -112,18 +112,18 @@ describe('index role (0374/0382/0383 W3)', () => { await hub.start() try { const status = (await ( - await fetch('http://localhost:14495/index/status') + await fetch('http://localhost:14594/index/status') ).json()) as { entries: number } expect(status.entries).toBe(3) const snapshot = (await ( - await fetch('http://localhost:14495/index/snapshot') + await fetch('http://localhost:14594/index/snapshot') ).json()) as { entries: Array<{ uri: string }> } expect(snapshot.entries.map((e) => e.uri)).toEqual( [...snapshot.entries.map((e) => e.uri)].sort() ) // The negative test: the index plane wrote NO tenant/search state — the // public read surface has nothing, because idx_* files are the only home. - expect((await fetch('http://localhost:14495/public/node/anything')).status).toBe(404) + expect((await fetch('http://localhost:14594/public/node/anything')).status).toBe(404) const raw = readFileSync(join(dir, 'idx_snapshot.json'), 'utf8') expect(raw).toContain('site.standard.document') } finally { diff --git a/packages/hub/test/roles.test.ts b/packages/hub/test/roles.test.ts index 38aa895af..dc00e9acf 100644 --- a/packages/hub/test/roles.test.ts +++ b/packages/hub/test/roles.test.ts @@ -83,7 +83,7 @@ describe('hub roles (0382/0383 W1)', () => { }) it('every named preset resolves and boots', async () => { - let port = 14480 + let port = 14580 for (const role of Object.keys(HUB_ROLES) as HubRole[]) { // The index role's engine gets a no-network override in CI; everything // else boots exactly as the preset says. @@ -120,10 +120,10 @@ describe('hub system identity (0371/0383 W4)', () => { const dataDir = mkdtempSync(join(tmpdir(), 'xnet-hubid-')) const boot = async (): Promise<{ did: string; stop: () => Promise }> => { const hub = await createHub( - resolveConfig({ port: 14486, storage: 'memory', dataDir, auth: false }) + resolveConfig({ port: 14595, storage: 'memory', dataDir, auth: false }) ) await hub.start() - const health = (await (await fetch('http://localhost:14486/health')).json()) as { + const health = (await (await fetch('http://localhost:14595/health')).json()) as { hubDid: string role: string } diff --git a/site/src/data/changelog/2026-07-20-hubs-gain-named-roles-an-index-engine-an.json b/site/src/data/changelog/2026-07-20-hubs-gain-named-roles-an-index-engine-an.json new file mode 100644 index 000000000..a1abe5aef --- /dev/null +++ b/site/src/data/changelog/2026-07-20-hubs-gain-named-roles-an-index-engine-an.json @@ -0,0 +1,11 @@ +{ + "id": "2026-07-20-hubs-gain-named-roles-an-index-engine-an", + "date": "July 20, 2026", + "title": "Hubs gain named roles, an index engine, and hub-to-hub subscription", + "summary": "One hub binary now runs as named roles (personal, demo, community, index, registry, gateway) selected with --role. The index role rebuilds a deterministic public atproto index from source; gateway hubs mirror other hubs' public Spaces over the existing sync protocol; every hub gets a persistent system identity on /health; and a compose template runs a hub beside the official AT Protocol PDS behind one domain.", + "highlights": [], + "tags": [ + "platform", + "sync" + ] +} From 2eb87ae02e4eeb5a4af95fbcff070b71bcc802ec Mon Sep 17 00:00:00 2001 From: xNet Test Date: Mon, 20 Jul 2026 15:52:41 -0700 Subject: [PATCH 10/14] docs(exploration): check off turning hubs into everything the role implementation plan Signed-off-by: xNet Test Co-Authored-By: Claude Fable 5 --- ..._TURNING_HUBS_INTO_EVERYTHING_THE_ROLE_IMPLEMENTATION_PLAN.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename docs/explorations/{0383_[_]_TURNING_HUBS_INTO_EVERYTHING_THE_ROLE_IMPLEMENTATION_PLAN.md => 0383_[x]_TURNING_HUBS_INTO_EVERYTHING_THE_ROLE_IMPLEMENTATION_PLAN.md} (100%) diff --git a/docs/explorations/0383_[_]_TURNING_HUBS_INTO_EVERYTHING_THE_ROLE_IMPLEMENTATION_PLAN.md b/docs/explorations/0383_[x]_TURNING_HUBS_INTO_EVERYTHING_THE_ROLE_IMPLEMENTATION_PLAN.md similarity index 100% rename from docs/explorations/0383_[_]_TURNING_HUBS_INTO_EVERYTHING_THE_ROLE_IMPLEMENTATION_PLAN.md rename to docs/explorations/0383_[x]_TURNING_HUBS_INTO_EVERYTHING_THE_ROLE_IMPLEMENTATION_PLAN.md From dd865f9a86fca39a3292e3b6d4beaa696f79122b Mon Sep 17 00:00:00 2001 From: xNet Test Date: Mon, 20 Jul 2026 16:05:55 -0700 Subject: [PATCH 11/14] fix(hub): copy the runtime closure into the hub image; format; api report MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The hub image Dockerfile enumerates hub's workspace closure explicitly, and W4's @xnetjs/runtime dependency pulled six new packages into that closure (runtime, data-bridge, history, plugins, trust, licenses) — added to all four COPY blocks; the list now diffs clean against `pnpm --filter '@xnetjs/hub...'`. Prettier over the seven new files CI flagged, and the regenerated data API report for the intended publicInteractionPolicy exports (matches the minor changeset). Signed-off-by: xNet Test Co-Authored-By: Claude Fable 5 --- packages/data/etc/data.api.md | 5 +++- packages/hub/Dockerfile | 24 +++++++++++++++++++ packages/hub/src/features/hub-subscriber.ts | 3 ++- packages/hub/src/hub-identity.ts | 6 ++++- packages/hub/src/server.ts | 7 +++++- packages/hub/test/hub-subscriber.test.ts | 10 ++++++-- packages/hub/test/index-role.test.ts | 16 +++++++------ .../src/sync/MultiHubSyncManager.test.ts | 7 +++++- .../runtime/src/sync/replication-scope.ts | 5 +--- 9 files changed, 65 insertions(+), 18 deletions(-) diff --git a/packages/data/etc/data.api.md b/packages/data/etc/data.api.md index ad9d6c183..5fc227415 100644 --- a/packages/data/etc/data.api.md +++ b/packages/data/etc/data.api.md @@ -8558,6 +8558,9 @@ export interface PublicFormQuestion extends FormQuestion { // @public (undocumented) export type PublicInteractionPolicy = InferNode<(typeof PublicInteractionPolicySchema)['_properties']>; +// @public +export function publicInteractionPolicyId(targetId: string): string; + // @public (undocumented) export const PublicInteractionPolicySchema: DefinedSchema<{ operators: PropertyBuilder<`did:key:${string}`[]>; @@ -10800,7 +10803,7 @@ export { YXmlText } // Warnings were encountered during analysis: // -// dist/types-gws1tSf-.d.ts:571:9 - (ae-forgotten-export) The symbol "GrantStatus" needs to be exported by the entry point index.d.ts +// dist/types-B3LD0ueI.d.ts:571:9 - (ae-forgotten-export) The symbol "GrantStatus" needs to be exported by the entry point index.d.ts // (No @packageDocumentation comment for this package) diff --git a/packages/hub/Dockerfile b/packages/hub/Dockerfile index bd419b6ea..1b0c04a8b 100644 --- a/packages/hub/Dockerfile +++ b/packages/hub/Dockerfile @@ -14,6 +14,12 @@ COPY packages/entitlements/package.json packages/entitlements/ COPY packages/core/package.json packages/core/ COPY packages/crypto/package.json packages/crypto/ COPY packages/data/package.json packages/data/ +COPY packages/data-bridge/package.json packages/data-bridge/ +COPY packages/history/package.json packages/history/ +COPY packages/licenses/package.json packages/licenses/ +COPY packages/plugins/package.json packages/plugins/ +COPY packages/runtime/package.json packages/runtime/ +COPY packages/trust/package.json packages/trust/ COPY packages/identity/package.json packages/identity/ COPY packages/slack-compat/package.json packages/slack-compat/ COPY packages/sqlite/package.json packages/sqlite/ @@ -35,6 +41,12 @@ COPY packages/entitlements/ packages/entitlements/ COPY packages/core/ packages/core/ COPY packages/crypto/ packages/crypto/ COPY packages/data/ packages/data/ +COPY packages/data-bridge/ packages/data-bridge/ +COPY packages/history/ packages/history/ +COPY packages/licenses/ packages/licenses/ +COPY packages/plugins/ packages/plugins/ +COPY packages/runtime/ packages/runtime/ +COPY packages/trust/ packages/trust/ COPY packages/identity/ packages/identity/ COPY packages/slack-compat/ packages/slack-compat/ COPY packages/sqlite/ packages/sqlite/ @@ -60,6 +72,12 @@ COPY --from=builder /build/packages/entitlements/package.json packages/entitleme COPY --from=builder /build/packages/core/package.json packages/core/ COPY --from=builder /build/packages/crypto/package.json packages/crypto/ COPY --from=builder /build/packages/data/package.json packages/data/ +COPY --from=builder /build/packages/data-bridge/package.json packages/data-bridge/ +COPY --from=builder /build/packages/history/package.json packages/history/ +COPY --from=builder /build/packages/licenses/package.json packages/licenses/ +COPY --from=builder /build/packages/plugins/package.json packages/plugins/ +COPY --from=builder /build/packages/runtime/package.json packages/runtime/ +COPY --from=builder /build/packages/trust/package.json packages/trust/ COPY --from=builder /build/packages/identity/package.json packages/identity/ COPY --from=builder /build/packages/slack-compat/package.json packages/slack-compat/ COPY --from=builder /build/packages/sqlite/package.json packages/sqlite/ @@ -92,6 +110,12 @@ COPY --from=builder /build/packages/entitlements/dist packages/entitlements/dist COPY --from=builder /build/packages/core/dist packages/core/dist/ COPY --from=builder /build/packages/crypto/dist packages/crypto/dist/ COPY --from=builder /build/packages/data/dist packages/data/dist/ +COPY --from=builder /build/packages/data-bridge/dist packages/data-bridge/dist/ +COPY --from=builder /build/packages/history/dist packages/history/dist/ +COPY --from=builder /build/packages/licenses/dist packages/licenses/dist/ +COPY --from=builder /build/packages/plugins/dist packages/plugins/dist/ +COPY --from=builder /build/packages/runtime/dist packages/runtime/dist/ +COPY --from=builder /build/packages/trust/dist packages/trust/dist/ COPY --from=builder /build/packages/identity/dist packages/identity/dist/ COPY --from=builder /build/packages/slack-compat/dist packages/slack-compat/dist/ COPY --from=builder /build/packages/sqlite/dist packages/sqlite/dist/ diff --git a/packages/hub/src/features/hub-subscriber.ts b/packages/hub/src/features/hub-subscriber.ts index d833358d8..b3697d305 100644 --- a/packages/hub/src/features/hub-subscriber.ts +++ b/packages/hub/src/features/hub-subscriber.ts @@ -218,7 +218,8 @@ export class HubSubscriberService { if (!publicUrl && !port) return for (const peer of this.peers) { const self = - (publicUrl && peer.url.replace(/^ws/, 'http').startsWith(publicUrl.replace(/^ws/, 'http'))) || + (publicUrl && + peer.url.replace(/^ws/, 'http').startsWith(publicUrl.replace(/^ws/, 'http'))) || peer.url.includes(`localhost:${port}`) || peer.url.includes(`127.0.0.1:${port}`) if (self) { diff --git a/packages/hub/src/hub-identity.ts b/packages/hub/src/hub-identity.ts index 6061573ad..6dc198b13 100644 --- a/packages/hub/src/hub-identity.ts +++ b/packages/hub/src/hub-identity.ts @@ -41,7 +41,11 @@ export function loadOrCreateHubIdentity(dataDir: string): HubIdentity { const generated = generateIdentity() writeFileSync( path, - JSON.stringify({ did: generated.identity.did, privateKeyB64: bytesToBase64(generated.privateKey) }, null, 2) + JSON.stringify( + { did: generated.identity.did, privateKeyB64: bytesToBase64(generated.privateKey) }, + null, + 2 + ) ) return { did: generated.identity.did, privateKey: generated.privateKey } } diff --git a/packages/hub/src/server.ts b/packages/hub/src/server.ts index 842d61c7d..f4d6e553b 100644 --- a/packages/hub/src/server.ts +++ b/packages/hub/src/server.ts @@ -677,7 +677,12 @@ export const createServer = async (config: HubConfig): Promise => { id: 'shard-registry', start: async () => { await shardRegistry.init() - if (shardConfig.isRegistry && shardRebalancer && shardConfig.hubDid && shardConfig.hubUrl) { + if ( + shardConfig.isRegistry && + shardRebalancer && + shardConfig.hubDid && + shardConfig.hubUrl + ) { await shardRebalancer.registerHost({ hubDid: shardConfig.hubDid, url: shardConfig.hubUrl, diff --git a/packages/hub/test/hub-subscriber.test.ts b/packages/hub/test/hub-subscriber.test.ts index 67a237044..ec4ede3d3 100644 --- a/packages/hub/test/hub-subscriber.test.ts +++ b/packages/hub/test/hub-subscriber.test.ts @@ -81,7 +81,11 @@ const pushChange = (change: SerializedNodeChange): Promise => const timer = setTimeout(() => reject(new Error('push timeout')), 3000) ws.on('open', () => { ws.send( - JSON.stringify({ type: 'publish', topic: ROOM, data: { type: 'node-change', room: ROOM, change } }) + JSON.stringify({ + type: 'publish', + topic: ROOM, + data: { type: 'node-change', room: ROOM, change } + }) ) // Give the relay a beat to persist before closing. setTimeout(() => { @@ -161,7 +165,9 @@ describe('hub-to-hub subscription (0383 W4)', () => { const stored = await new Promise((resolve, reject) => { const ws = new WebSocket(`ws://localhost:${PORT_A}`) const timer = setTimeout(() => reject(new Error('sync timeout')), 3000) - ws.on('open', () => ws.send(JSON.stringify({ type: 'node-sync-request', room: ROOM, sinceLamport: 0 }))) + ws.on('open', () => + ws.send(JSON.stringify({ type: 'node-sync-request', room: ROOM, sinceLamport: 0 })) + ) ws.on('message', (d) => { const msg = JSON.parse(String(d)) as { type?: string; changes?: SerializedNodeChange[] } if (msg.type !== 'node-sync-response') return diff --git a/packages/hub/test/index-role.test.ts b/packages/hub/test/index-role.test.ts index 3c1b39aee..fcf8e5aa4 100644 --- a/packages/hub/test/index-role.test.ts +++ b/packages/hub/test/index-role.test.ts @@ -27,7 +27,9 @@ import { createHub } from '../src/index' const fixtureSource = (): IndexSource => ({ async listRepos(collection) { - return collection === 'site.standard.document' ? ['did:plc:alice', 'did:plc:bob'] : ['did:plc:alice'] + return collection === 'site.standard.document' + ? ['did:plc:alice', 'did:plc:bob'] + : ['did:plc:alice'] }, async listRecords(did, collection) { if (collection === 'site.standard.publication') { @@ -111,13 +113,13 @@ describe('index role (0374/0382/0383 W3)', () => { const hub = await createHub(resolved) await hub.start() try { - const status = (await ( - await fetch('http://localhost:14594/index/status') - ).json()) as { entries: number } + const status = (await (await fetch('http://localhost:14594/index/status')).json()) as { + entries: number + } expect(status.entries).toBe(3) - const snapshot = (await ( - await fetch('http://localhost:14594/index/snapshot') - ).json()) as { entries: Array<{ uri: string }> } + const snapshot = (await (await fetch('http://localhost:14594/index/snapshot')).json()) as { + entries: Array<{ uri: string }> + } expect(snapshot.entries.map((e) => e.uri)).toEqual( [...snapshot.entries.map((e) => e.uri)].sort() ) diff --git a/packages/runtime/src/sync/MultiHubSyncManager.test.ts b/packages/runtime/src/sync/MultiHubSyncManager.test.ts index fea1d589b..581309e38 100644 --- a/packages/runtime/src/sync/MultiHubSyncManager.test.ts +++ b/packages/runtime/src/sync/MultiHubSyncManager.test.ts @@ -239,7 +239,12 @@ describe('0258 trust gate (0383 W4)', () => { }) const manager = createMultiHubSyncManager({ hubs: [ - { hubId: 'trusted-hub', url: 'ws://a', transport: transport('trusted-hub'), trust: 'trusted' }, + { + hubId: 'trusted-hub', + url: 'ws://a', + transport: transport('trusted-hub'), + trust: 'trusted' + }, { hubId: 'zk-hub', url: 'ws://b', transport: transport('zk-hub'), trust: 'zero-knowledge' }, { hubId: 'legacy-hub', url: 'ws://c', transport: transport('legacy-hub') } ] diff --git a/packages/runtime/src/sync/replication-scope.ts b/packages/runtime/src/sync/replication-scope.ts index 3c310a0ab..3339a84a1 100644 --- a/packages/runtime/src/sync/replication-scope.ts +++ b/packages/runtime/src/sync/replication-scope.ts @@ -43,10 +43,7 @@ export type PayloadClass = 'plaintext' | 'ciphertext' * configs that never declared a class — tightening that default is a breaking * change to make deliberately, not silently. */ -export function mayReceivePayload( - trust: ReplicaTrust | undefined, - payload: PayloadClass -): boolean { +export function mayReceivePayload(trust: ReplicaTrust | undefined, payload: PayloadClass): boolean { if (payload === 'ciphertext') return true return trust !== 'zero-knowledge' } From 313064c96e452fcf66b5a2f348b4462345fb380f Mon Sep 17 00:00:00 2001 From: xNet Test Date: Mon, 20 Jul 2026 16:19:35 -0700 Subject: [PATCH 12/14] perf(relay): O(1) per-author quota accounting on the append path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Since #603 armed quotaBytes on every hub, each append re-ran the SUM(LENGTH(payload_json)) over the author's entire log — O(rows²) across a bulk ingest, the 0357 regression class, and the cause of main's red test (3/3) lane (10k batch ingest 36-37s vs the 30s budget). Usage is now cached per author and bumped as appends land; a write is never rejected on a cached number (the gate re-reads storage at the quota boundary), and a 30s TTL bounds drift from out-of-band writers (pack import, eviction). Local: 10k ingest 17.6s -> 4.1s; managed-quota suite unchanged and green. Signed-off-by: xNet Test Co-Authored-By: Claude Fable 5 --- packages/hub/src/services/node-relay.ts | 54 +++++++++++++++++++++---- 1 file changed, 47 insertions(+), 7 deletions(-) diff --git a/packages/hub/src/services/node-relay.ts b/packages/hub/src/services/node-relay.ts index 74317574b..a33235b82 100644 --- a/packages/hub/src/services/node-relay.ts +++ b/packages/hub/src/services/node-relay.ts @@ -161,12 +161,40 @@ export type ShareAccessGate = { } export class NodeRelayService { + /** + * Per-author usage cache backing the quota gate. All relay appends flow + * through this service, so bumping on append keeps it exact for the hot + * path; the TTL bounds drift from out-of-band writers (pack import, + * eviction) and refreshUsageBytes re-reads storage before any rejection. + */ + private usageByDid = new Map() + private static readonly USAGE_TTL_MS = 30_000 + constructor( private storage: HubStorage, private telemetryOptions: RemoteMutationTelemetryOptions = {}, private options: NodeRelayOptions = {} ) {} + private async cachedUsageBytes(did: string): Promise { + const entry = this.usageByDid.get(did) + if (entry && Date.now() - entry.fetchedAt < NodeRelayService.USAGE_TTL_MS) { + return entry.bytes + } + return this.refreshUsageBytes(did) + } + + private async refreshUsageBytes(did: string): Promise { + const bytes = await this.storage.getUsageBytesByDid(did) + this.usageByDid.set(did, { bytes, fetchedAt: Date.now() }) + return bytes + } + + private bumpUsageBytes(did: string, delta: number): void { + const entry = this.usageByDid.get(did) + if (entry) entry.bytes += delta + } + async handleNodeChange(msg: NodeChangeMessage, auth: AuthContext): Promise { if (!auth.can('hub/relay', msg.room)) { reportUnauthorizedRemoteWrite(this.telemetryOptions, auth.did) @@ -266,14 +294,25 @@ export class NodeRelayService { // and, unlike backups/files, had no quota gate — one active user could fill // the disk (exploration 0291). Gated on every hub since 0381: demo hubs meter // against the demo override, managed/self-hosted against the plan quota. + // + // The check is O(1) on the hot path: usage is cached per author and bumped + // as appends land, because re-running the SUM over the author's whole log + // on every append is O(rows²) across a bulk ingest — exactly the 0357 + // regression class. A write is never REJECTED on a cached number: at the + // quota boundary we re-read the truth first, since eviction and pack + // import move usage out of band. + const quotaDelta = changeUsageBytes(msg.change) if (this.options.quotaBytes !== undefined) { - const used = await this.storage.getUsageBytesByDid(change.authorDID) - if (used + changeUsageBytes(msg.change) > this.options.quotaBytes) { - throw new NodeRelayError( - 'QUOTA_EXCEEDED', - `Storage limit reached (${this.options.quotaBytes} bytes per user). ` + - `Delete some data, upgrade your plan, or use your own hub for more space.` - ) + let used = await this.cachedUsageBytes(change.authorDID) + if (used + quotaDelta > this.options.quotaBytes) { + used = await this.refreshUsageBytes(change.authorDID) + if (used + quotaDelta > this.options.quotaBytes) { + throw new NodeRelayError( + 'QUOTA_EXCEEDED', + `Storage limit reached (${this.options.quotaBytes} bytes per user). ` + + `Delete some data, upgrade your plan, or use your own hub for more space.` + ) + } } } @@ -281,6 +320,7 @@ export class NodeRelayService { ...msg.change, room: msg.room }) + this.bumpUsageBytes(change.authorDID, quotaDelta) // Channel sharing (0298): index a channel's nodes into its share room so a // grantee's `/channel/` subscription receives the conversation. Never From 223c8ca53a3586a7f59aa624e2569f2ab010fad2 Mon Sep 17 00:00:00 2001 From: xNet Test Date: Mon, 20 Jul 2026 16:21:07 -0700 Subject: [PATCH 13/14] docs(data): regenerate the api report from a clean dist The earlier regeneration ran against a stale local dist and picked up a polluted dts chunk name (types-B3LD0ueI); a forced clean rebuild restores the canonical types-gws1tSf- chunk CI computes. The intended publicInteractionPolicy surface additions are unchanged. Signed-off-by: xNet Test Co-Authored-By: Claude Fable 5 --- packages/data/etc/data.api.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/data/etc/data.api.md b/packages/data/etc/data.api.md index 5fc227415..9bfa4b510 100644 --- a/packages/data/etc/data.api.md +++ b/packages/data/etc/data.api.md @@ -10803,7 +10803,7 @@ export { YXmlText } // Warnings were encountered during analysis: // -// dist/types-B3LD0ueI.d.ts:571:9 - (ae-forgotten-export) The symbol "GrantStatus" needs to be exported by the entry point index.d.ts +// dist/types-gws1tSf-.d.ts:571:9 - (ae-forgotten-export) The symbol "GrantStatus" needs to be exported by the entry point index.d.ts // (No @packageDocumentation comment for this package) From 93879dff21a17b990f28b7d1bd55db025bb7c25d Mon Sep 17 00:00:00 2001 From: xNet Test Date: Mon, 20 Jul 2026 16:30:42 -0700 Subject: [PATCH 14/14] ci(api-report): print the drift patch, not just the diffstat A drift that only reproduces on CI (round-tripping a platform-sensitive dts chunk name) is undebuggable from a diffstat alone. Signed-off-by: xNet Test Co-Authored-By: Claude Fable 5 --- scripts/check-api-report.mjs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/scripts/check-api-report.mjs b/scripts/check-api-report.mjs index 2bbebb0d6..a555ec0cc 100644 --- a/scripts/check-api-report.mjs +++ b/scripts/check-api-report.mjs @@ -61,7 +61,11 @@ if (diff.length > 0) { `If the change is intended: run \`pnpm --filter api:update\`, review the\n` + `diff, and commit the updated report — a reviewer signs off via CODEOWNERS.\n` + `If it is not intended, you have an accidental export.\n\n` + - execSync("git diff --stat -- packages/*/etc/", { cwd: ROOT, encoding: "utf8" }) + execSync("git diff --stat -- packages/*/etc/", { cwd: ROOT, encoding: "utf8" }) + + // The patch itself: without it, a drift that only reproduces on CI (e.g. + // a platform-sensitive dts chunk name) is undebuggable from the log. + `\n` + + execSync("git diff -- packages/*/etc/", { cwd: ROOT, encoding: "utf8" }) ) failed = true }