From 7acf48d0a49d3ab8872c64371e3fbc0e068500a2 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Wed, 1 Jul 2026 21:19:25 +0200 Subject: [PATCH] feat(federation): shared epoch types + getInstanceId() --- docs/systems/admin.md | 3 + packages/server/src/routes/instance.test.ts | 7 +- packages/server/src/routes/instance.ts | 2 + .../server/src/utils/federationEpoch.test.ts | 84 +++++++++++++++++++ packages/server/src/utils/federationEpoch.ts | 24 ++++++ packages/shared/src/types.ts | 12 +++ 6 files changed, 131 insertions(+), 1 deletion(-) create mode 100644 packages/server/src/utils/federationEpoch.test.ts create mode 100644 packages/server/src/utils/federationEpoch.ts diff --git a/docs/systems/admin.md b/docs/systems/admin.md index 97e807f7..5bdf0e50 100644 --- a/docs/systems/admin.md +++ b/docs/systems/admin.md @@ -171,9 +171,12 @@ No authentication. Returns: federatedRegistrationOpen: boolean; // NOT NULL DEFAULT 1; gates federated-account creation sourceCodeUrl: string; // AGPL § 13; config.sourceCodeUrl (env BACKSPACE_SOURCE_URL) commit: string | null; // AGPL § 13; config.commit (env BACKSPACE_COMMIT, build-injected) + instanceId: string; // Persistent per-instance epoch (incarnation UUID); getInstanceId() } ``` +`instanceId` is the persistent per-instance epoch — a UUID minted once by `ensureDefaults` on first boot and stable across restarts (stored in `instance_settings.instance_id`, guaranteed non-null after boot). It changes only when the instance is wiped/re-provisioned. Peers read it to detect that a remote has been re-provisioned (federation epoch self-healing). The server reads it via the cached `getInstanceId()` in `utils/federationEpoch.ts`, which throws if the epoch is unset (invariant: `ensureDefaults` runs before any read). + Registration resolution order: `instance_settings.registrationOpen` (if not null) > `config.registrationOpen` (from `REGISTRATION_OPEN` env, default true). `federatedRegistrationOpen` is consumed by the Connections UI (client-federation.md) to decide whether to surface the "create federated account on this instance" affordance. diff --git a/packages/server/src/routes/instance.test.ts b/packages/server/src/routes/instance.test.ts index 3d0b8cc6..2eb2c808 100644 --- a/packages/server/src/routes/instance.test.ts +++ b/packages/server/src/routes/instance.test.ts @@ -53,9 +53,11 @@ beforeEach(async () => { // Seed the singleton instance_settings row mirroring ensureDefaults() — // tests don't run the boot-time helper, so we insert manually with the - // schema-default values for the new federatedRegistrationOpen column. + // schema-default values for the new federatedRegistrationOpen column plus + // the persistent epoch (instanceId) that ensureDefaults mints on boot. testDb.insert(schema.instanceSettings).values({ id: 1, + instanceId: '123e4567-e89b-12d3-a456-426614174000', updatedAt: Date.now(), }).run(); @@ -94,5 +96,8 @@ describe('GET /api/instance/info', () => { expect(typeof body.sourceCodeUrl).toBe('string'); expect(body.sourceCodeUrl).toMatch(/^https?:\/\//); expect(body.commit === null || typeof body.commit === 'string').toBe(true); + // Persistent per-instance epoch (incarnation UUID) is always advertised. + expect(typeof body.instanceId).toBe('string'); + expect(body.instanceId).toMatch(/^[0-9a-f-]{36}$/); }); }); diff --git a/packages/server/src/routes/instance.ts b/packages/server/src/routes/instance.ts index 730d6c13..5214c04e 100644 --- a/packages/server/src/routes/instance.ts +++ b/packages/server/src/routes/instance.ts @@ -2,6 +2,7 @@ import type { FastifyInstance } from 'fastify'; import { eq } from 'drizzle-orm'; import { getDb, schema } from '../db/index.js'; import { config } from '../config.js'; +import { getInstanceId } from '../utils/federationEpoch.js'; import type { InstanceInfoResponse } from '@backspace/shared'; const BACKSPACE_VERSION = '1.0.0'; @@ -23,6 +24,7 @@ export async function instanceRoutes(app: FastifyInstance): Promise { version: BACKSPACE_VERSION, registrationOpen, federatedRegistrationOpen: settings?.federatedRegistrationOpen === 1, + instanceId: getInstanceId(), // AGPL-3.0 § 13: advertise the source of the running version to every // network user (and federated peer) — public/unauthenticated by design. sourceCodeUrl: config.sourceCodeUrl, diff --git a/packages/server/src/utils/federationEpoch.test.ts b/packages/server/src/utils/federationEpoch.test.ts new file mode 100644 index 00000000..733a0187 --- /dev/null +++ b/packages/server/src/utils/federationEpoch.test.ts @@ -0,0 +1,84 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import Database from 'better-sqlite3'; +import { drizzle } from 'drizzle-orm/better-sqlite3'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import * as schema from '../db/schema.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +let sqlite: Database.Database; +let testDb: ReturnType>; + +vi.mock('../db/index.js', () => ({ + getDb: () => testDb, + getRawDb: () => sqlite, + schema, +})); + +function applyMigrations(db: Database.Database): void { + const dir = path.resolve(__dirname, '../../drizzle'); + for (const f of fs.readdirSync(dir).filter(f => f.endsWith('.sql')).sort()) { + const sqlText = fs.readFileSync(path.join(dir, f), 'utf8'); + for (const stmt of sqlText.split(/-->\s*statement-breakpoint/)) { + const clean = stmt.trim(); + if (clean) db.exec(clean); + } + } +} + +function seedEpoch(instanceId: string): void { + testDb.insert(schema.instanceSettings).values({ + id: 1, + instanceId, + updatedAt: Date.now(), + } as typeof schema.instanceSettings.$inferInsert).run(); +} + +beforeEach(async () => { + sqlite = new Database(':memory:'); + testDb = drizzle(sqlite, { schema }); + applyMigrations(sqlite); + const { __resetInstanceIdCacheForTest } = await import('./federationEpoch.js'); + __resetInstanceIdCacheForTest(); +}); + +describe('getInstanceId', () => { + it('returns the persisted epoch', async () => { + seedEpoch('123e4567-e89b-12d3-a456-426614174000'); + const { getInstanceId } = await import('./federationEpoch.js'); + const id = getInstanceId(); + expect(id).toBe('123e4567-e89b-12d3-a456-426614174000'); + expect(id).toMatch(/^[0-9a-f-]{36}$/); + }); + + it('caches the value after the first read', async () => { + seedEpoch('123e4567-e89b-12d3-a456-426614174000'); + const { getInstanceId } = await import('./federationEpoch.js'); + expect(getInstanceId()).toBe('123e4567-e89b-12d3-a456-426614174000'); + + // Mutate the underlying row; a cached reader must NOT observe the change. + testDb.update(schema.instanceSettings) + .set({ instanceId: 'ffffffff-ffff-ffff-ffff-ffffffffffff' }) + .run(); + expect(getInstanceId()).toBe('123e4567-e89b-12d3-a456-426614174000'); + }); + + it('re-reads after __resetInstanceIdCacheForTest clears the cache', async () => { + seedEpoch('123e4567-e89b-12d3-a456-426614174000'); + const { getInstanceId, __resetInstanceIdCacheForTest } = await import('./federationEpoch.js'); + expect(getInstanceId()).toBe('123e4567-e89b-12d3-a456-426614174000'); + + testDb.update(schema.instanceSettings) + .set({ instanceId: 'ffffffff-ffff-ffff-ffff-ffffffffffff' }) + .run(); + __resetInstanceIdCacheForTest(); + expect(getInstanceId()).toBe('ffffffff-ffff-ffff-ffff-ffffffffffff'); + }); + + it('throws when the epoch is unset (invariant: ensureDefaults must run first)', async () => { + // No row seeded — instance_settings is empty. + const { getInstanceId } = await import('./federationEpoch.js'); + expect(() => getInstanceId()).toThrow(/instance_id is not set/); + }); +}); diff --git a/packages/server/src/utils/federationEpoch.ts b/packages/server/src/utils/federationEpoch.ts new file mode 100644 index 00000000..daa18834 --- /dev/null +++ b/packages/server/src/utils/federationEpoch.ts @@ -0,0 +1,24 @@ +import { eq } from 'drizzle-orm'; +import { getDb, schema } from '../db/index.js'; + +let cached: string | null = null; + +/** This instance's persistent epoch (incarnation UUID). Set by ensureDefaults on boot. */ +export function getInstanceId(): string { + if (cached) return cached; + const db = getDb(); + const row = db.select({ instanceId: schema.instanceSettings.instanceId }) + .from(schema.instanceSettings) + .where(eq(schema.instanceSettings.id, 1)) + .get(); + if (!row?.instanceId) { + throw new Error('instance_id is not set — ensureDefaults must run before getInstanceId'); + } + cached = row.instanceId; + return cached; +} + +/** Test-only: clear the module cache between cases. */ +export function __resetInstanceIdCacheForTest(): void { + cached = null; +} diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index 4e69377f..aa2796a7 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -798,6 +798,10 @@ export interface InstanceInfoResponse { version: string; registrationOpen: boolean; federatedRegistrationOpen: boolean; + // Persistent per-instance epoch (incarnation UUID). Minted by ensureDefaults on + // first boot and stable across restarts; changes only on a wipe/re-provision. + // Peers use it to detect that a remote has been re-provisioned (self-healing). + instanceId: string; // AGPL-3.0 § 13 network-use source offer: URL to the Corresponding Source of // the version this instance is running (operator-configurable via // BACKSPACE_SOURCE_URL so forks point at their own source). @@ -1111,9 +1115,17 @@ export interface FederationRelayAttachment { export interface FederationRelayRequest { version: 1; sourceInstance: string; + // Sender's persistent epoch (incarnation UUID). Optional for wire compatibility + // with peers that predate epoch self-healing; when present, the receiver can + // detect that the source instance has been re-provisioned. + sourceInstanceId?: string; events: FederationRelayEvent[]; } +export interface FederationEpochResponse { + instanceId: string; +} + export interface FederationRelayResponse { accepted: string[]; rejected: Array<{ messageId: string; reason: string }>;