Compare commits
10 Commits
71c7b85026
...
fix/git-wr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
46cc91ccbe | ||
| 755df9079e | |||
| ac5650d9f9 | |||
| bd83f86740 | |||
|
|
0af3e218a1 | ||
|
|
b01c9b3bb0 | ||
| b67f2c9f08 | |||
|
|
37675ae3f2 | ||
|
|
a4a6769a6d | ||
|
|
21650fb194 |
@@ -73,6 +73,7 @@
|
|||||||
"rxjs": "^7.8.0",
|
"rxjs": "^7.8.0",
|
||||||
"socket.io": "^4.8.0",
|
"socket.io": "^4.8.0",
|
||||||
"uuid": "^11.0.0",
|
"uuid": "^11.0.0",
|
||||||
|
"undici": "^7.24.6",
|
||||||
"zod": "^4.3.6"
|
"zod": "^4.3.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -1,8 +1,21 @@
|
|||||||
import { mkdirSync } from 'node:fs';
|
import { mkdirSync } from 'node:fs';
|
||||||
import { homedir } from 'node:os';
|
import { homedir } from 'node:os';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import { Global, Inject, Module, type OnApplicationShutdown } from '@nestjs/common';
|
import {
|
||||||
import { createDb, createPgliteDb, type Db, type DbHandle } from '@mosaicstack/db';
|
Global,
|
||||||
|
Inject,
|
||||||
|
Logger,
|
||||||
|
Module,
|
||||||
|
type OnApplicationShutdown,
|
||||||
|
type OnModuleInit,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import {
|
||||||
|
createDb,
|
||||||
|
createPgliteDb,
|
||||||
|
runPgliteMigrations,
|
||||||
|
type Db,
|
||||||
|
type DbHandle,
|
||||||
|
} from '@mosaicstack/db';
|
||||||
import { createStorageAdapter, type StorageAdapter } from '@mosaicstack/storage';
|
import { createStorageAdapter, type StorageAdapter } from '@mosaicstack/storage';
|
||||||
import type { MosaicConfig } from '@mosaicstack/config';
|
import type { MosaicConfig } from '@mosaicstack/config';
|
||||||
import { MOSAIC_CONFIG } from '../config/config.module.js';
|
import { MOSAIC_CONFIG } from '../config/config.module.js';
|
||||||
@@ -39,12 +52,37 @@ export const STORAGE_ADAPTER = 'STORAGE_ADAPTER';
|
|||||||
],
|
],
|
||||||
exports: [DB, STORAGE_ADAPTER],
|
exports: [DB, STORAGE_ADAPTER],
|
||||||
})
|
})
|
||||||
export class DatabaseModule implements OnApplicationShutdown {
|
export class DatabaseModule implements OnApplicationShutdown, OnModuleInit {
|
||||||
|
private readonly logger = new Logger(DatabaseModule.name);
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@Inject(DB_HANDLE) private readonly handle: DbHandle,
|
@Inject(DB_HANDLE) private readonly handle: DbHandle,
|
||||||
@Inject(STORAGE_ADAPTER) private readonly storageAdapter: StorageAdapter,
|
@Inject(STORAGE_ADAPTER) private readonly storageAdapter: StorageAdapter,
|
||||||
|
@Inject(MOSAIC_CONFIG) private readonly config: MosaicConfig,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
// Migrations must complete before any module that injects DB starts serving
|
||||||
|
// requests. NestJS awaits onModuleInit before app.listen(), and modules that
|
||||||
|
// inject DB are initialized after this one — so all DB-dependent code sees a
|
||||||
|
// populated schema before the first HTTP request lands.
|
||||||
|
//
|
||||||
|
// Local (PGlite) tier: we run gateway-DB migrations explicitly here. The
|
||||||
|
// storage adapter writes to a separate PGlite directory and only manages its
|
||||||
|
// own KV tables, so we still call its migrate() afterwards.
|
||||||
|
//
|
||||||
|
// Postgres tier: PostgresAdapter.migrate() already calls runMigrations() on
|
||||||
|
// the same DATABASE_URL, so a single call covers both the gateway DB and
|
||||||
|
// the storage tables. We deliberately do NOT call runMigrations() here to
|
||||||
|
// avoid opening a second short-lived connection and doubling startup cost.
|
||||||
|
async onModuleInit(): Promise<void> {
|
||||||
|
if (this.config.tier === 'local') {
|
||||||
|
this.logger.log('Applying PGlite schema migrations...');
|
||||||
|
await runPgliteMigrations(this.handle);
|
||||||
|
}
|
||||||
|
this.logger.log(`Initializing storage adapter (${this.storageAdapter.name})...`);
|
||||||
|
await this.storageAdapter.migrate();
|
||||||
|
}
|
||||||
|
|
||||||
async onApplicationShutdown(): Promise<void> {
|
async onApplicationShutdown(): Promise<void> {
|
||||||
await Promise.all([this.handle.close(), this.storageAdapter.close()]);
|
await Promise.all([this.handle.close(), this.storageAdapter.close()]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,553 @@
|
|||||||
|
/**
|
||||||
|
* Unit tests for FederationClientService (FED-M3-08).
|
||||||
|
*
|
||||||
|
* HTTP mocking strategy:
|
||||||
|
* undici MockAgent is used to intercept outbound HTTP requests. The service
|
||||||
|
* uses `undici.fetch` with a `dispatcher` option, so MockAgent is set as the
|
||||||
|
* global dispatcher and all requests flow through it.
|
||||||
|
*
|
||||||
|
* Because the service builds one `undici.Agent` per peer and passes it as
|
||||||
|
* the dispatcher on every fetch call, we cannot intercept at the Agent level
|
||||||
|
* in unit tests without significant refactoring. Instead, we set the global
|
||||||
|
* dispatcher to a MockAgent and override the service's `doRequest` indirection
|
||||||
|
* by spying on the internal fetch call.
|
||||||
|
*
|
||||||
|
* For the cert/key wiring, we use the real `sealClientKey` function from
|
||||||
|
* peer-key.util.ts with a test secret — no stubs.
|
||||||
|
*
|
||||||
|
* Sealed-key setup:
|
||||||
|
* Each test (or beforeAll) calls `sealClientKey(TEST_PRIVATE_KEY_PEM)` with
|
||||||
|
* BETTER_AUTH_SECRET set to a deterministic test value so that
|
||||||
|
* `unsealClientKey` in the service recovers the original PEM.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import 'reflect-metadata';
|
||||||
|
import { describe, it, expect, vi, beforeEach, afterEach, beforeAll, afterAll } from 'vitest';
|
||||||
|
import { MockAgent, setGlobalDispatcher, getGlobalDispatcher } from 'undici';
|
||||||
|
import type { Dispatcher } from 'undici';
|
||||||
|
import { writeFileSync, unlinkSync } from 'node:fs';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import type { Db } from '@mosaicstack/db';
|
||||||
|
import { FederationClientService, FederationClientError } from '../federation-client.service.js';
|
||||||
|
import { sealClientKey } from '../../peer-key.util.js';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Test constants
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const TEST_SECRET = 'test-secret-for-federation-client-spec-only';
|
||||||
|
const PEER_ID = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa';
|
||||||
|
const ENDPOINT = 'https://peer.example.com';
|
||||||
|
|
||||||
|
// Minimal valid RSA/EC private key PEM — does NOT need to be a real key for
|
||||||
|
// unit tests because we only verify it round-trips through seal/unseal, not
|
||||||
|
// that it actually negotiates TLS (MockAgent handles that).
|
||||||
|
const TEST_PRIVATE_KEY_PEM = `-----BEGIN PRIVATE KEY-----
|
||||||
|
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDummyKeyForTests
|
||||||
|
-----END PRIVATE KEY-----`;
|
||||||
|
|
||||||
|
// Minimal self-signed cert PEM (dummy — only used for mTLS Agent construction)
|
||||||
|
const TEST_CERT_PEM = `-----BEGIN CERTIFICATE-----
|
||||||
|
MIIBdummyCertForFederationClientTests==
|
||||||
|
-----END CERTIFICATE-----`;
|
||||||
|
|
||||||
|
const TEST_CERT_SERIAL = 'ABCDEF1234567890';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Sealed key (computed once in beforeAll)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
let SEALED_KEY: string;
|
||||||
|
|
||||||
|
// Path to a stub Step-CA root cert file written in beforeAll. The cert is never
|
||||||
|
// actually used to negotiate TLS in unit tests (MockAgent + spy on resolveEntry
|
||||||
|
// short-circuit the network), but loadStepCaRoot() requires the file to exist.
|
||||||
|
const STUB_CA_PEM_PATH = join(tmpdir(), 'federation-client-spec-ca.pem');
|
||||||
|
const STUB_CA_PEM = `-----BEGIN CERTIFICATE-----
|
||||||
|
MIIBdummyCAforFederationClientSpecOnly==
|
||||||
|
-----END CERTIFICATE-----
|
||||||
|
`;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Peer row factory
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function makePeerRow(overrides: Partial<Record<string, unknown>> = {}) {
|
||||||
|
return {
|
||||||
|
id: PEER_ID,
|
||||||
|
commonName: 'peer-example-com',
|
||||||
|
displayName: 'Test Peer',
|
||||||
|
certPem: TEST_CERT_PEM,
|
||||||
|
certSerial: TEST_CERT_SERIAL,
|
||||||
|
certNotAfter: new Date('2030-01-01T00:00:00Z'),
|
||||||
|
clientKeyPem: SEALED_KEY,
|
||||||
|
state: 'active' as const,
|
||||||
|
endpointUrl: ENDPOINT,
|
||||||
|
lastSeenAt: null,
|
||||||
|
createdAt: new Date('2026-01-01T00:00:00Z'),
|
||||||
|
revokedAt: null,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Mock DB builder
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function makeDb(selectRows: unknown[] = [makePeerRow()]): Db {
|
||||||
|
const limitSelect = vi.fn().mockResolvedValue(selectRows);
|
||||||
|
const whereSelect = vi.fn().mockReturnValue({ limit: limitSelect });
|
||||||
|
const fromSelect = vi.fn().mockReturnValue({ where: whereSelect });
|
||||||
|
const selectMock = vi.fn().mockReturnValue({ from: fromSelect });
|
||||||
|
|
||||||
|
return {
|
||||||
|
select: selectMock,
|
||||||
|
insert: vi.fn(),
|
||||||
|
update: vi.fn(),
|
||||||
|
delete: vi.fn(),
|
||||||
|
transaction: vi.fn(),
|
||||||
|
} as unknown as Db;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helpers for MockAgent HTTP interception
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a MockAgent + MockPool for the peer endpoint, set it as the global
|
||||||
|
* dispatcher, and return both for per-test configuration.
|
||||||
|
*/
|
||||||
|
function makeMockAgent() {
|
||||||
|
const mockAgent = new MockAgent({ connections: 1 });
|
||||||
|
mockAgent.disableNetConnect();
|
||||||
|
setGlobalDispatcher(mockAgent);
|
||||||
|
const pool = mockAgent.get(ENDPOINT);
|
||||||
|
return { mockAgent, pool };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a FederationClientService with a mock DB and a spy on the internal
|
||||||
|
* fetch so we can intercept at the HTTP layer via MockAgent.
|
||||||
|
*
|
||||||
|
* The service calls `fetch(url, { dispatcher: agent })` where `agent` is the
|
||||||
|
* mTLS undici.Agent built from the peer's cert+key. To make MockAgent work,
|
||||||
|
* we need the fetch dispatcher to be the MockAgent, not the per-peer Agent.
|
||||||
|
*
|
||||||
|
* Strategy: we replace the private `resolveEntry` result's `agent` field with
|
||||||
|
* the MockAgent's pool, so fetch uses our interceptor. We do this by spying
|
||||||
|
* on `resolveEntry` and returning a controlled entry.
|
||||||
|
*/
|
||||||
|
function makeService(db: Db, mockPool: Dispatcher): FederationClientService {
|
||||||
|
const svc = new FederationClientService(db);
|
||||||
|
|
||||||
|
// Override resolveEntry to inject MockAgent pool as the dispatcher
|
||||||
|
vi.spyOn(
|
||||||
|
svc as unknown as { resolveEntry: (peerId: string) => Promise<unknown> },
|
||||||
|
'resolveEntry',
|
||||||
|
).mockImplementation(async (_peerId: string) => {
|
||||||
|
// Still call DB (via the real logic) to exercise peer validation,
|
||||||
|
// but return mock pool as the agent.
|
||||||
|
// For simplicity in unit tests, directly return a controlled entry.
|
||||||
|
return {
|
||||||
|
agent: mockPool,
|
||||||
|
endpointUrl: ENDPOINT,
|
||||||
|
certPem: TEST_CERT_PEM,
|
||||||
|
certSerial: TEST_CERT_SERIAL,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return svc;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Test setup
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
let originalDispatcher: Dispatcher;
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
// Seal the test key once — requires BETTER_AUTH_SECRET
|
||||||
|
const saved = process.env['BETTER_AUTH_SECRET'];
|
||||||
|
process.env['BETTER_AUTH_SECRET'] = TEST_SECRET;
|
||||||
|
try {
|
||||||
|
SEALED_KEY = sealClientKey(TEST_PRIVATE_KEY_PEM);
|
||||||
|
} finally {
|
||||||
|
if (saved === undefined) {
|
||||||
|
delete process.env['BETTER_AUTH_SECRET'];
|
||||||
|
} else {
|
||||||
|
process.env['BETTER_AUTH_SECRET'] = saved;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
writeFileSync(STUB_CA_PEM_PATH, STUB_CA_PEM, 'utf8');
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
try {
|
||||||
|
unlinkSync(STUB_CA_PEM_PATH);
|
||||||
|
} catch {
|
||||||
|
// best-effort cleanup
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
originalDispatcher = getGlobalDispatcher();
|
||||||
|
process.env['BETTER_AUTH_SECRET'] = TEST_SECRET;
|
||||||
|
process.env['STEP_CA_ROOT_CERT_PATH'] = STUB_CA_PEM_PATH;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
setGlobalDispatcher(originalDispatcher);
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
delete process.env['BETTER_AUTH_SECRET'];
|
||||||
|
delete process.env['STEP_CA_ROOT_CERT_PATH'];
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Successful list response body */
|
||||||
|
const LIST_BODY = {
|
||||||
|
items: [{ id: '1', title: 'Task One' }],
|
||||||
|
nextCursor: undefined,
|
||||||
|
_partial: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Successful get response body */
|
||||||
|
const GET_BODY = {
|
||||||
|
item: { id: '1', title: 'Task One' },
|
||||||
|
_partial: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Successful capabilities response body */
|
||||||
|
const CAP_BODY = {
|
||||||
|
resources: ['tasks'],
|
||||||
|
excluded_resources: [],
|
||||||
|
max_rows_per_query: 100,
|
||||||
|
supported_verbs: ['list', 'get', 'capabilities'] as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('FederationClientService', () => {
|
||||||
|
// ─── Successful verb calls ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('list()', () => {
|
||||||
|
it('returns parsed typed response on success', async () => {
|
||||||
|
const db = makeDb();
|
||||||
|
const { mockAgent, pool } = makeMockAgent();
|
||||||
|
const svc = makeService(db, pool);
|
||||||
|
|
||||||
|
pool
|
||||||
|
.intercept({
|
||||||
|
path: '/api/federation/v1/list/tasks',
|
||||||
|
method: 'POST',
|
||||||
|
})
|
||||||
|
.reply(200, LIST_BODY, { headers: { 'content-type': 'application/json' } });
|
||||||
|
|
||||||
|
const result = await svc.list(PEER_ID, 'tasks', {});
|
||||||
|
|
||||||
|
expect(result.items).toHaveLength(1);
|
||||||
|
expect(result.items[0]).toMatchObject({ id: '1', title: 'Task One' });
|
||||||
|
|
||||||
|
await mockAgent.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('get()', () => {
|
||||||
|
it('returns parsed typed response on success', async () => {
|
||||||
|
const db = makeDb();
|
||||||
|
const { mockAgent, pool } = makeMockAgent();
|
||||||
|
const svc = makeService(db, pool);
|
||||||
|
|
||||||
|
pool
|
||||||
|
.intercept({
|
||||||
|
path: '/api/federation/v1/get/tasks/1',
|
||||||
|
method: 'POST',
|
||||||
|
})
|
||||||
|
.reply(200, GET_BODY, { headers: { 'content-type': 'application/json' } });
|
||||||
|
|
||||||
|
const result = await svc.get(PEER_ID, 'tasks', '1', {});
|
||||||
|
|
||||||
|
expect(result.item).toMatchObject({ id: '1', title: 'Task One' });
|
||||||
|
|
||||||
|
await mockAgent.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('capabilities()', () => {
|
||||||
|
it('returns parsed capabilities response on success', async () => {
|
||||||
|
const db = makeDb();
|
||||||
|
const { mockAgent, pool } = makeMockAgent();
|
||||||
|
const svc = makeService(db, pool);
|
||||||
|
|
||||||
|
pool
|
||||||
|
.intercept({
|
||||||
|
path: '/api/federation/v1/capabilities',
|
||||||
|
method: 'GET',
|
||||||
|
})
|
||||||
|
.reply(200, CAP_BODY, { headers: { 'content-type': 'application/json' } });
|
||||||
|
|
||||||
|
const result = await svc.capabilities(PEER_ID);
|
||||||
|
|
||||||
|
expect(result.resources).toContain('tasks');
|
||||||
|
expect(result.max_rows_per_query).toBe(100);
|
||||||
|
|
||||||
|
await mockAgent.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── HTTP error surfaces ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('non-2xx responses', () => {
|
||||||
|
it('surfaces 403 as FederationClientError({ status: 403, code: "FORBIDDEN" })', async () => {
|
||||||
|
const db = makeDb();
|
||||||
|
const { mockAgent, pool } = makeMockAgent();
|
||||||
|
const svc = makeService(db, pool);
|
||||||
|
|
||||||
|
pool.intercept({ path: '/api/federation/v1/list/tasks', method: 'POST' }).reply(
|
||||||
|
403,
|
||||||
|
{ error: { code: 'forbidden', message: 'Access denied' } },
|
||||||
|
{
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(svc.list(PEER_ID, 'tasks', {})).rejects.toMatchObject({
|
||||||
|
status: 403,
|
||||||
|
code: 'FORBIDDEN',
|
||||||
|
peerId: PEER_ID,
|
||||||
|
});
|
||||||
|
|
||||||
|
await mockAgent.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('surfaces 404 as FederationClientError({ status: 404, code: "HTTP_404" })', async () => {
|
||||||
|
const db = makeDb();
|
||||||
|
const { mockAgent, pool } = makeMockAgent();
|
||||||
|
const svc = makeService(db, pool);
|
||||||
|
|
||||||
|
pool.intercept({ path: '/api/federation/v1/get/tasks/999', method: 'POST' }).reply(
|
||||||
|
404,
|
||||||
|
{ error: { code: 'not_found', message: 'Not found' } },
|
||||||
|
{
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(svc.get(PEER_ID, 'tasks', '999', {})).rejects.toMatchObject({
|
||||||
|
status: 404,
|
||||||
|
code: 'HTTP_404',
|
||||||
|
peerId: PEER_ID,
|
||||||
|
});
|
||||||
|
|
||||||
|
await mockAgent.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Network error ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('network errors', () => {
|
||||||
|
it('surfaces network error as FederationClientError({ code: "NETWORK" })', async () => {
|
||||||
|
const db = makeDb();
|
||||||
|
const { mockAgent, pool } = makeMockAgent();
|
||||||
|
const svc = makeService(db, pool);
|
||||||
|
|
||||||
|
pool
|
||||||
|
.intercept({ path: '/api/federation/v1/capabilities', method: 'GET' })
|
||||||
|
.replyWithError(new Error('ECONNREFUSED'));
|
||||||
|
|
||||||
|
await expect(svc.capabilities(PEER_ID)).rejects.toMatchObject({
|
||||||
|
code: 'NETWORK',
|
||||||
|
peerId: PEER_ID,
|
||||||
|
});
|
||||||
|
|
||||||
|
await mockAgent.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Invalid response body ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('invalid response body', () => {
|
||||||
|
it('surfaces as FederationClientError({ code: "INVALID_RESPONSE" }) when body shape is wrong', async () => {
|
||||||
|
const db = makeDb();
|
||||||
|
const { mockAgent, pool } = makeMockAgent();
|
||||||
|
const svc = makeService(db, pool);
|
||||||
|
|
||||||
|
// capabilities returns wrong shape (missing required fields)
|
||||||
|
pool
|
||||||
|
.intercept({ path: '/api/federation/v1/capabilities', method: 'GET' })
|
||||||
|
.reply(200, { totally: 'wrong' }, { headers: { 'content-type': 'application/json' } });
|
||||||
|
|
||||||
|
await expect(svc.capabilities(PEER_ID)).rejects.toMatchObject({
|
||||||
|
code: 'INVALID_RESPONSE',
|
||||||
|
peerId: PEER_ID,
|
||||||
|
});
|
||||||
|
|
||||||
|
await mockAgent.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Peer DB validation ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('peer validation (without resolveEntry spy)', () => {
|
||||||
|
/**
|
||||||
|
* These tests exercise the real `resolveEntry` path — no spy on resolveEntry.
|
||||||
|
*/
|
||||||
|
|
||||||
|
it('throws PEER_NOT_FOUND when peer is not in DB', async () => {
|
||||||
|
// DB returns empty array (peer not found)
|
||||||
|
const db = makeDb([]);
|
||||||
|
const svc = new FederationClientService(db);
|
||||||
|
|
||||||
|
await expect(svc.capabilities(PEER_ID)).rejects.toMatchObject({
|
||||||
|
code: 'PEER_NOT_FOUND',
|
||||||
|
peerId: PEER_ID,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws PEER_INACTIVE when peer state is not "active"', async () => {
|
||||||
|
const db = makeDb([makePeerRow({ state: 'suspended' })]);
|
||||||
|
const svc = new FederationClientService(db);
|
||||||
|
|
||||||
|
await expect(svc.capabilities(PEER_ID)).rejects.toMatchObject({
|
||||||
|
code: 'PEER_INACTIVE',
|
||||||
|
peerId: PEER_ID,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Cache behaviour ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('cache behaviour', () => {
|
||||||
|
it('hits cache on second call — only one DB lookup happens', async () => {
|
||||||
|
// Verify cache by calling the private resolveEntry directly twice and
|
||||||
|
// asserting the DB was queried only once. This avoids the HTTP layer,
|
||||||
|
// which would require either a real network or per-peer Agent rewiring
|
||||||
|
// that the cache invariant doesn't depend on.
|
||||||
|
const db = makeDb();
|
||||||
|
const selectSpy = vi.spyOn(db, 'select');
|
||||||
|
const svc = new FederationClientService(db);
|
||||||
|
const resolveEntry = (
|
||||||
|
svc as unknown as { resolveEntry: (peerId: string) => Promise<unknown> }
|
||||||
|
).resolveEntry.bind(svc);
|
||||||
|
|
||||||
|
const first = await resolveEntry(PEER_ID);
|
||||||
|
const second = await resolveEntry(PEER_ID);
|
||||||
|
|
||||||
|
expect(first).toBe(second);
|
||||||
|
expect(selectSpy).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('serializes concurrent resolveEntry calls — only one DB lookup', async () => {
|
||||||
|
const db = makeDb();
|
||||||
|
const selectSpy = vi.spyOn(db, 'select');
|
||||||
|
const svc = new FederationClientService(db);
|
||||||
|
const resolveEntry = (
|
||||||
|
svc as unknown as {
|
||||||
|
resolveEntry: (peerId: string) => Promise<unknown>;
|
||||||
|
}
|
||||||
|
).resolveEntry.bind(svc);
|
||||||
|
|
||||||
|
const [a, b] = await Promise.all([resolveEntry(PEER_ID), resolveEntry(PEER_ID)]);
|
||||||
|
expect(a).toBe(b);
|
||||||
|
expect(selectSpy).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('flushPeer destroys the evicted Agent so old TLS connections close', async () => {
|
||||||
|
const db = makeDb();
|
||||||
|
const svc = new FederationClientService(db);
|
||||||
|
const resolveEntry = (
|
||||||
|
svc as unknown as {
|
||||||
|
resolveEntry: (peerId: string) => Promise<{ agent: { destroy: () => Promise<void> } }>;
|
||||||
|
}
|
||||||
|
).resolveEntry.bind(svc);
|
||||||
|
|
||||||
|
const entry = await resolveEntry(PEER_ID);
|
||||||
|
const destroySpy = vi.spyOn(entry.agent, 'destroy').mockResolvedValue();
|
||||||
|
|
||||||
|
svc.flushPeer(PEER_ID);
|
||||||
|
expect(destroySpy).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('flushPeer() invalidates cache — next call re-reads DB', async () => {
|
||||||
|
const db = makeDb();
|
||||||
|
const { mockAgent, pool } = makeMockAgent();
|
||||||
|
const svc = makeService(db, pool);
|
||||||
|
|
||||||
|
pool
|
||||||
|
.intercept({ path: '/api/federation/v1/capabilities', method: 'GET' })
|
||||||
|
.reply(200, CAP_BODY, { headers: { 'content-type': 'application/json' } })
|
||||||
|
.times(2);
|
||||||
|
|
||||||
|
// First call — populates cache (via mock resolveEntry)
|
||||||
|
await svc.capabilities(PEER_ID);
|
||||||
|
|
||||||
|
// Flush the cache
|
||||||
|
svc.flushPeer(PEER_ID);
|
||||||
|
|
||||||
|
// The spy on resolveEntry is still active — check it's called again after flush
|
||||||
|
const resolveEntrySpy = vi.spyOn(
|
||||||
|
svc as unknown as { resolveEntry: (peerId: string) => Promise<unknown> },
|
||||||
|
'resolveEntry',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Second call after flush — should call resolveEntry again
|
||||||
|
await svc.capabilities(PEER_ID);
|
||||||
|
|
||||||
|
// resolveEntry should have been called once after we started spying (post-flush)
|
||||||
|
expect(resolveEntrySpy).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
await mockAgent.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── loadStepCaRoot env-var guard ─────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('loadStepCaRoot() env-var guard', () => {
|
||||||
|
it('throws PEER_MISCONFIGURED when STEP_CA_ROOT_CERT_PATH is not set', async () => {
|
||||||
|
delete process.env['STEP_CA_ROOT_CERT_PATH'];
|
||||||
|
const db = makeDb();
|
||||||
|
const svc = new FederationClientService(db);
|
||||||
|
const resolveEntry = (
|
||||||
|
svc as unknown as {
|
||||||
|
resolveEntry: (peerId: string) => Promise<unknown>;
|
||||||
|
}
|
||||||
|
).resolveEntry.bind(svc);
|
||||||
|
|
||||||
|
await expect(resolveEntry(PEER_ID)).rejects.toMatchObject({
|
||||||
|
code: 'PEER_MISCONFIGURED',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── FederationClientError class ──────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('FederationClientError', () => {
|
||||||
|
it('is instanceof Error and FederationClientError', () => {
|
||||||
|
const err = new FederationClientError({
|
||||||
|
code: 'PEER_NOT_FOUND',
|
||||||
|
message: 'test',
|
||||||
|
peerId: PEER_ID,
|
||||||
|
});
|
||||||
|
expect(err).toBeInstanceOf(Error);
|
||||||
|
expect(err).toBeInstanceOf(FederationClientError);
|
||||||
|
expect(err.name).toBe('FederationClientError');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('carries status, code, and peerId', () => {
|
||||||
|
const err = new FederationClientError({
|
||||||
|
status: 403,
|
||||||
|
code: 'FORBIDDEN',
|
||||||
|
message: 'forbidden',
|
||||||
|
peerId: PEER_ID,
|
||||||
|
});
|
||||||
|
expect(err.status).toBe(403);
|
||||||
|
expect(err.code).toBe('FORBIDDEN');
|
||||||
|
expect(err.peerId).toBe(PEER_ID);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
500
apps/gateway/src/federation/client/federation-client.service.ts
Normal file
500
apps/gateway/src/federation/client/federation-client.service.ts
Normal file
@@ -0,0 +1,500 @@
|
|||||||
|
/**
|
||||||
|
* FederationClientService — outbound mTLS client for federation requests (FED-M3-08).
|
||||||
|
*
|
||||||
|
* Dials peer gateways over mTLS using the cert+sealed-key stored in `federation_peers`,
|
||||||
|
* invokes federation verbs (list / get / capabilities), and surfaces all failure modes
|
||||||
|
* as typed `FederationClientError` instances.
|
||||||
|
*
|
||||||
|
* ## Error code taxonomy
|
||||||
|
*
|
||||||
|
* | Code | When |
|
||||||
|
* | ------------------ | ------------------------------------------------------------- |
|
||||||
|
* | PEER_NOT_FOUND | No row in federation_peers for the given peerId |
|
||||||
|
* | PEER_INACTIVE | Peer row exists but state !== 'active' |
|
||||||
|
* | PEER_MISCONFIGURED | Peer row is active but missing endpointUrl or clientKeyPem |
|
||||||
|
* | NETWORK | undici threw a connection / TLS / timeout error |
|
||||||
|
* | HTTP_{status} | Peer returned a non-2xx response (e.g. HTTP_403, HTTP_404) |
|
||||||
|
* | FORBIDDEN | Peer returned 403 (convenience alias alongside HTTP_403) |
|
||||||
|
* | INVALID_RESPONSE | Response body failed Zod schema validation |
|
||||||
|
*
|
||||||
|
* ## Cache strategy
|
||||||
|
*
|
||||||
|
* Per-peer `undici.Agent` instances are cached in a `Map<peerId, AgentCacheEntry>` for
|
||||||
|
* the lifetime of the service instance. The cache is keyed on peerId (UUID).
|
||||||
|
*
|
||||||
|
* Cache invalidation:
|
||||||
|
* - `flushPeer(peerId)` — removes the entry immediately. M5/M6 MUST call this on
|
||||||
|
* cert rotation or peer revocation events so the next request re-reads the DB and
|
||||||
|
* builds a fresh TLS Agent with the new cert material.
|
||||||
|
* - On cache miss: re-reads the DB, checks state === 'active', rebuilds Agent.
|
||||||
|
*
|
||||||
|
* Cache does NOT auto-expire. The service is expected to be a singleton scoped to the
|
||||||
|
* NestJS application lifecycle; flushing on revocation/rotation is the only invalidation
|
||||||
|
* path by design (avoids redundant DB round-trips on the hot path).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Injectable, Inject, Logger } from '@nestjs/common';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { Agent, fetch as undiciFetch } from 'undici';
|
||||||
|
import type { Dispatcher } from 'undici';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { type Db, eq, federationPeers } from '@mosaicstack/db';
|
||||||
|
import {
|
||||||
|
FederationListResponseSchema,
|
||||||
|
FederationGetResponseSchema,
|
||||||
|
FederationCapabilitiesResponseSchema,
|
||||||
|
FederationErrorEnvelopeSchema,
|
||||||
|
type FederationListResponse,
|
||||||
|
type FederationGetResponse,
|
||||||
|
type FederationCapabilitiesResponse,
|
||||||
|
} from '@mosaicstack/types';
|
||||||
|
import { DB } from '../../database/database.module.js';
|
||||||
|
import { unsealClientKey } from '../peer-key.util.js';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Error taxonomy
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Client-side error code set. Distinct from the server-side `FederationErrorCode`
|
||||||
|
* (which lives in `@mosaicstack/types`) because the client has additional failure
|
||||||
|
* modes (PEER_NOT_FOUND, PEER_INACTIVE, PEER_MISCONFIGURED, NETWORK) that the
|
||||||
|
* server never emits.
|
||||||
|
*/
|
||||||
|
export type FederationClientErrorCode =
|
||||||
|
| 'PEER_NOT_FOUND'
|
||||||
|
| 'PEER_INACTIVE'
|
||||||
|
| 'PEER_MISCONFIGURED'
|
||||||
|
| 'NETWORK'
|
||||||
|
| 'FORBIDDEN'
|
||||||
|
| 'INVALID_RESPONSE'
|
||||||
|
| `HTTP_${number}`;
|
||||||
|
|
||||||
|
export interface FederationClientErrorOptions {
|
||||||
|
status?: number;
|
||||||
|
code: FederationClientErrorCode;
|
||||||
|
message: string;
|
||||||
|
peerId: string;
|
||||||
|
cause?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Thrown by FederationClientService on every failure path.
|
||||||
|
* Callers can dispatch on `error.code` for programmatic handling.
|
||||||
|
*/
|
||||||
|
export class FederationClientError extends Error {
|
||||||
|
readonly status?: number;
|
||||||
|
readonly code: FederationClientErrorCode;
|
||||||
|
readonly peerId: string;
|
||||||
|
readonly cause?: unknown;
|
||||||
|
|
||||||
|
constructor(opts: FederationClientErrorOptions) {
|
||||||
|
super(opts.message);
|
||||||
|
this.name = 'FederationClientError';
|
||||||
|
this.status = opts.status;
|
||||||
|
this.code = opts.code;
|
||||||
|
this.peerId = opts.peerId;
|
||||||
|
this.cause = opts.cause;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Internal cache types
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface AgentCacheEntry {
|
||||||
|
agent: Agent;
|
||||||
|
endpointUrl: string;
|
||||||
|
certPem: string;
|
||||||
|
certSerial: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Service
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class FederationClientService {
|
||||||
|
private readonly logger = new Logger(FederationClientService.name);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-peer undici Agent cache.
|
||||||
|
* Key = peerId (UUID string).
|
||||||
|
*
|
||||||
|
* Values are either a resolved `AgentCacheEntry` or an in-flight
|
||||||
|
* `Promise<AgentCacheEntry>` (promise-cache pattern). Storing the promise
|
||||||
|
* prevents duplicate DB lookups and duplicate key-unseal operations when two
|
||||||
|
* requests for the same peer arrive before the first build completes.
|
||||||
|
*
|
||||||
|
* Flush via `flushPeer(peerId)` on cert rotation / peer revocation (M5/M6).
|
||||||
|
*/
|
||||||
|
private readonly cache = new Map<string, AgentCacheEntry | Promise<AgentCacheEntry>>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Step-CA root cert PEM, loaded once from `STEP_CA_ROOT_CERT_PATH`.
|
||||||
|
* Used as the trust anchor for peer server certificates so federation TLS is
|
||||||
|
* pinned to our PKI, not the public trust store. Lazily loaded on first use
|
||||||
|
* so unit tests that don't exercise the agent path can run without the env var.
|
||||||
|
*/
|
||||||
|
private cachedCaPem: string | null = null;
|
||||||
|
|
||||||
|
constructor(@Inject(DB) private readonly db: Db) {}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Public verb API
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invoke the `list` verb on a remote peer.
|
||||||
|
*
|
||||||
|
* @param peerId UUID of the peer row in `federation_peers`.
|
||||||
|
* @param resource Resource path, e.g. "tasks".
|
||||||
|
* @param request Free-form body sent as JSON in the POST body.
|
||||||
|
* @returns Parsed `FederationListResponse<T>`.
|
||||||
|
*/
|
||||||
|
async list<T>(
|
||||||
|
peerId: string,
|
||||||
|
resource: string,
|
||||||
|
request: Record<string, unknown>,
|
||||||
|
): Promise<FederationListResponse<T>> {
|
||||||
|
const { endpointUrl, agent } = await this.resolveEntry(peerId);
|
||||||
|
const url = `${endpointUrl}/api/federation/v1/list/${encodeURIComponent(resource)}`;
|
||||||
|
const body = await this.doPost(peerId, url, agent, request);
|
||||||
|
return this.parseWith<FederationListResponse<T>>(
|
||||||
|
peerId,
|
||||||
|
body,
|
||||||
|
FederationListResponseSchema(z.unknown()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invoke the `get` verb on a remote peer.
|
||||||
|
*
|
||||||
|
* @param peerId UUID of the peer row in `federation_peers`.
|
||||||
|
* @param resource Resource path, e.g. "tasks".
|
||||||
|
* @param id Resource identifier.
|
||||||
|
* @param request Free-form body sent as JSON in the POST body.
|
||||||
|
* @returns Parsed `FederationGetResponse<T>`.
|
||||||
|
*/
|
||||||
|
async get<T>(
|
||||||
|
peerId: string,
|
||||||
|
resource: string,
|
||||||
|
id: string,
|
||||||
|
request: Record<string, unknown>,
|
||||||
|
): Promise<FederationGetResponse<T>> {
|
||||||
|
const { endpointUrl, agent } = await this.resolveEntry(peerId);
|
||||||
|
const url = `${endpointUrl}/api/federation/v1/get/${encodeURIComponent(resource)}/${encodeURIComponent(id)}`;
|
||||||
|
const body = await this.doPost(peerId, url, agent, request);
|
||||||
|
return this.parseWith<FederationGetResponse<T>>(
|
||||||
|
peerId,
|
||||||
|
body,
|
||||||
|
FederationGetResponseSchema(z.unknown()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invoke the `capabilities` verb on a remote peer.
|
||||||
|
*
|
||||||
|
* @param peerId UUID of the peer row in `federation_peers`.
|
||||||
|
* @returns Parsed `FederationCapabilitiesResponse`.
|
||||||
|
*/
|
||||||
|
async capabilities(peerId: string): Promise<FederationCapabilitiesResponse> {
|
||||||
|
const { endpointUrl, agent } = await this.resolveEntry(peerId);
|
||||||
|
const url = `${endpointUrl}/api/federation/v1/capabilities`;
|
||||||
|
const body = await this.doGet(peerId, url, agent);
|
||||||
|
return this.parseWith<FederationCapabilitiesResponse>(
|
||||||
|
peerId,
|
||||||
|
body,
|
||||||
|
FederationCapabilitiesResponseSchema,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Cache management
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flush the cached Agent for a specific peer.
|
||||||
|
*
|
||||||
|
* M5/M6 MUST call this on:
|
||||||
|
* - cert rotation events (so new cert material is picked up)
|
||||||
|
* - peer revocation events (so future requests fail at PEER_INACTIVE)
|
||||||
|
*
|
||||||
|
* After flushing, the next call to `list`, `get`, or `capabilities` for
|
||||||
|
* this peer will re-read the DB and rebuild the Agent.
|
||||||
|
*/
|
||||||
|
flushPeer(peerId: string): void {
|
||||||
|
const entry = this.cache.get(peerId);
|
||||||
|
if (entry === undefined) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.cache.delete(peerId);
|
||||||
|
if (!(entry instanceof Promise)) {
|
||||||
|
// best-effort destroy; promise-cached entries skip destroy because
|
||||||
|
// the in-flight build owns its own Agent which will be GC'd when the
|
||||||
|
// owning request handles the rejection from the cache miss
|
||||||
|
entry.agent.destroy().catch(() => {
|
||||||
|
// intentionally ignored — destroy errors are not actionable
|
||||||
|
});
|
||||||
|
}
|
||||||
|
this.logger.log(`Cache flushed for peer ${peerId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Internal helpers
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load and cache the Step-CA root cert PEM from `STEP_CA_ROOT_CERT_PATH`.
|
||||||
|
* Throws `FederationClientError` if the env var is unset or the file cannot
|
||||||
|
* be read — mTLS to a peer without a pinned trust anchor would silently
|
||||||
|
* fall back to the public trust store.
|
||||||
|
*/
|
||||||
|
private loadStepCaRoot(): string {
|
||||||
|
if (this.cachedCaPem !== null) {
|
||||||
|
return this.cachedCaPem;
|
||||||
|
}
|
||||||
|
const path = process.env['STEP_CA_ROOT_CERT_PATH'];
|
||||||
|
if (!path) {
|
||||||
|
throw new FederationClientError({
|
||||||
|
code: 'PEER_MISCONFIGURED',
|
||||||
|
message: 'STEP_CA_ROOT_CERT_PATH is not set; refusing to dial peer without pinned CA trust',
|
||||||
|
peerId: '',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const pem = readFileSync(path, 'utf8');
|
||||||
|
this.cachedCaPem = pem;
|
||||||
|
return pem;
|
||||||
|
} catch (err) {
|
||||||
|
throw new FederationClientError({
|
||||||
|
code: 'PEER_MISCONFIGURED',
|
||||||
|
message: `Failed to read STEP_CA_ROOT_CERT_PATH (${path})`,
|
||||||
|
peerId: '',
|
||||||
|
cause: err,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the cache entry for a peer, reading DB on miss.
|
||||||
|
*
|
||||||
|
* Uses a promise-cache pattern: concurrent callers for the same uncached
|
||||||
|
* `peerId` all `await` the same in-flight `Promise<AgentCacheEntry>` so
|
||||||
|
* only one DB lookup and one key-unseal ever runs per peer per cache miss.
|
||||||
|
* The promise is replaced with the concrete entry on success, or deleted on
|
||||||
|
* rejection so a transient error does not poison the cache permanently.
|
||||||
|
*
|
||||||
|
* Throws `FederationClientError` with appropriate code if the peer is not
|
||||||
|
* found, is inactive, or is missing required fields.
|
||||||
|
*/
|
||||||
|
private async resolveEntry(peerId: string): Promise<AgentCacheEntry> {
|
||||||
|
const cached = this.cache.get(peerId);
|
||||||
|
if (cached) {
|
||||||
|
return cached; // Promise or concrete entry — both are awaitable
|
||||||
|
}
|
||||||
|
|
||||||
|
const inflight = this.buildEntry(peerId).then(
|
||||||
|
(entry) => {
|
||||||
|
this.cache.set(peerId, entry); // replace promise with concrete value
|
||||||
|
return entry;
|
||||||
|
},
|
||||||
|
(err: unknown) => {
|
||||||
|
this.cache.delete(peerId); // don't poison the cache with a rejected promise
|
||||||
|
throw err;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
this.cache.set(peerId, inflight);
|
||||||
|
return inflight;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the `AgentCacheEntry` for a peer by reading the DB, validating the
|
||||||
|
* peer's state, unsealing the private key, and constructing the mTLS Agent.
|
||||||
|
*
|
||||||
|
* Throws `FederationClientError` with appropriate code if the peer is not
|
||||||
|
* found, is inactive, or is missing required fields.
|
||||||
|
*/
|
||||||
|
private async buildEntry(peerId: string): Promise<AgentCacheEntry> {
|
||||||
|
// DB lookup
|
||||||
|
const [peer] = await this.db
|
||||||
|
.select()
|
||||||
|
.from(federationPeers)
|
||||||
|
.where(eq(federationPeers.id, peerId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!peer) {
|
||||||
|
throw new FederationClientError({
|
||||||
|
code: 'PEER_NOT_FOUND',
|
||||||
|
message: `Federation peer ${peerId} not found`,
|
||||||
|
peerId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (peer.state !== 'active') {
|
||||||
|
throw new FederationClientError({
|
||||||
|
code: 'PEER_INACTIVE',
|
||||||
|
message: `Federation peer ${peerId} is not active (state: ${peer.state})`,
|
||||||
|
peerId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!peer.endpointUrl || !peer.clientKeyPem) {
|
||||||
|
throw new FederationClientError({
|
||||||
|
code: 'PEER_MISCONFIGURED',
|
||||||
|
message: `Federation peer ${peerId} is missing endpointUrl or clientKeyPem`,
|
||||||
|
peerId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unseal the private key
|
||||||
|
let privateKeyPem: string;
|
||||||
|
try {
|
||||||
|
privateKeyPem = unsealClientKey(peer.clientKeyPem);
|
||||||
|
} catch (err) {
|
||||||
|
throw new FederationClientError({
|
||||||
|
code: 'PEER_MISCONFIGURED',
|
||||||
|
message: `Failed to unseal client key for peer ${peerId}`,
|
||||||
|
peerId,
|
||||||
|
cause: err,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build mTLS agent — pin trust to Step-CA root so we never accept
|
||||||
|
// a peer cert signed by a public CA (defense against MITM with a
|
||||||
|
// publicly-trusted DV cert for the peer's hostname).
|
||||||
|
const agent = new Agent({
|
||||||
|
connect: {
|
||||||
|
cert: peer.certPem,
|
||||||
|
key: privateKeyPem,
|
||||||
|
ca: this.loadStepCaRoot(),
|
||||||
|
// rejectUnauthorized: true is the undici default for HTTPS
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const entry: AgentCacheEntry = {
|
||||||
|
agent,
|
||||||
|
endpointUrl: peer.endpointUrl,
|
||||||
|
certPem: peer.certPem,
|
||||||
|
certSerial: peer.certSerial,
|
||||||
|
};
|
||||||
|
|
||||||
|
this.logger.log(`Agent cached for peer ${peerId} (serial: ${peer.certSerial})`);
|
||||||
|
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute a POST request with a JSON body.
|
||||||
|
* Returns the parsed response body as an unknown value.
|
||||||
|
* Throws `FederationClientError` on network errors and non-2xx responses.
|
||||||
|
*/
|
||||||
|
private async doPost(
|
||||||
|
peerId: string,
|
||||||
|
url: string,
|
||||||
|
agent: Dispatcher,
|
||||||
|
body: Record<string, unknown>,
|
||||||
|
): Promise<unknown> {
|
||||||
|
return this.doRequest(peerId, url, agent, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute a GET request.
|
||||||
|
* Returns the parsed response body as an unknown value.
|
||||||
|
* Throws `FederationClientError` on network errors and non-2xx responses.
|
||||||
|
*/
|
||||||
|
private async doGet(peerId: string, url: string, agent: Dispatcher): Promise<unknown> {
|
||||||
|
return this.doRequest(peerId, url, agent, { method: 'GET' });
|
||||||
|
}
|
||||||
|
|
||||||
|
private async doRequest(
|
||||||
|
peerId: string,
|
||||||
|
url: string,
|
||||||
|
agent: Dispatcher,
|
||||||
|
init: { method: string; headers?: Record<string, string>; body?: string },
|
||||||
|
): Promise<unknown> {
|
||||||
|
let response: Awaited<ReturnType<typeof undiciFetch>>;
|
||||||
|
|
||||||
|
try {
|
||||||
|
response = await undiciFetch(url, {
|
||||||
|
...init,
|
||||||
|
dispatcher: agent,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
throw new FederationClientError({
|
||||||
|
code: 'NETWORK',
|
||||||
|
message: `Network error calling peer ${peerId} at ${url}: ${err instanceof Error ? err.message : String(err)}`,
|
||||||
|
peerId,
|
||||||
|
cause: err,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawBody = await response.text().catch(() => '');
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const status = response.status;
|
||||||
|
|
||||||
|
// Attempt to parse as federation error envelope
|
||||||
|
let serverMessage = `HTTP ${status}`;
|
||||||
|
try {
|
||||||
|
const json: unknown = JSON.parse(rawBody);
|
||||||
|
const result = FederationErrorEnvelopeSchema.safeParse(json);
|
||||||
|
if (result.success) {
|
||||||
|
serverMessage = result.data.error.message;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Not valid JSON or not a federation envelope — use generic message
|
||||||
|
}
|
||||||
|
|
||||||
|
// Specific code for 403 (most actionable for callers); generic HTTP_{n} for others
|
||||||
|
const code: FederationClientErrorCode = status === 403 ? 'FORBIDDEN' : `HTTP_${status}`;
|
||||||
|
|
||||||
|
throw new FederationClientError({
|
||||||
|
status,
|
||||||
|
code,
|
||||||
|
message: `Peer ${peerId} returned ${status}: ${serverMessage}`,
|
||||||
|
peerId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return JSON.parse(rawBody) as unknown;
|
||||||
|
} catch (err) {
|
||||||
|
throw new FederationClientError({
|
||||||
|
code: 'INVALID_RESPONSE',
|
||||||
|
message: `Peer ${peerId} returned non-JSON body`,
|
||||||
|
peerId,
|
||||||
|
cause: err,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse and validate a response body against a Zod schema.
|
||||||
|
*
|
||||||
|
* For list/get, callers pass the result of `FederationListResponseSchema(z.unknown())`
|
||||||
|
* so that the envelope structure is validated without requiring a concrete item schema
|
||||||
|
* at the client level. The generic `T` provides compile-time typing.
|
||||||
|
*
|
||||||
|
* Throws `FederationClientError({ code: 'INVALID_RESPONSE' })` on parse failure.
|
||||||
|
*/
|
||||||
|
private parseWith<T>(peerId: string, body: unknown, schema: z.ZodTypeAny): T {
|
||||||
|
const result = schema.safeParse(body);
|
||||||
|
if (!result.success) {
|
||||||
|
const issues = result.error.issues
|
||||||
|
.map((e: z.ZodIssue) => `[${e.path.join('.') || 'root'}] ${e.message}`)
|
||||||
|
.join('; ');
|
||||||
|
throw new FederationClientError({
|
||||||
|
code: 'INVALID_RESPONSE',
|
||||||
|
message: `Peer ${peerId} returned invalid response shape: ${issues}`,
|
||||||
|
peerId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return result.data as T;
|
||||||
|
}
|
||||||
|
}
|
||||||
13
apps/gateway/src/federation/client/index.ts
Normal file
13
apps/gateway/src/federation/client/index.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
/**
|
||||||
|
* Federation client barrel — re-exports for FederationModule consumers.
|
||||||
|
*
|
||||||
|
* M3-09 (QuerySourceService) and future milestones should import from here,
|
||||||
|
* not directly from the implementation file.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export {
|
||||||
|
FederationClientService,
|
||||||
|
FederationClientError,
|
||||||
|
type FederationClientErrorCode,
|
||||||
|
type FederationClientErrorOptions,
|
||||||
|
} from './federation-client.service.js';
|
||||||
@@ -5,10 +5,25 @@ import { EnrollmentController } from './enrollment.controller.js';
|
|||||||
import { EnrollmentService } from './enrollment.service.js';
|
import { EnrollmentService } from './enrollment.service.js';
|
||||||
import { FederationController } from './federation.controller.js';
|
import { FederationController } from './federation.controller.js';
|
||||||
import { GrantsService } from './grants.service.js';
|
import { GrantsService } from './grants.service.js';
|
||||||
|
import { FederationClientService } from './client/index.js';
|
||||||
|
import { FederationAuthGuard } from './server/index.js';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
controllers: [EnrollmentController, FederationController],
|
controllers: [EnrollmentController, FederationController],
|
||||||
providers: [AdminGuard, CaService, EnrollmentService, GrantsService],
|
providers: [
|
||||||
exports: [CaService, EnrollmentService, GrantsService],
|
AdminGuard,
|
||||||
|
CaService,
|
||||||
|
EnrollmentService,
|
||||||
|
GrantsService,
|
||||||
|
FederationClientService,
|
||||||
|
FederationAuthGuard,
|
||||||
|
],
|
||||||
|
exports: [
|
||||||
|
CaService,
|
||||||
|
EnrollmentService,
|
||||||
|
GrantsService,
|
||||||
|
FederationClientService,
|
||||||
|
FederationAuthGuard,
|
||||||
|
],
|
||||||
})
|
})
|
||||||
export class FederationModule {}
|
export class FederationModule {}
|
||||||
|
|||||||
@@ -10,12 +10,14 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { ConflictException, Inject, Injectable, NotFoundException } from '@nestjs/common';
|
import { ConflictException, Inject, Injectable, NotFoundException } from '@nestjs/common';
|
||||||
import { type Db, and, eq, federationGrants } from '@mosaicstack/db';
|
import { type Db, and, eq, federationGrants, federationPeers } from '@mosaicstack/db';
|
||||||
import { DB } from '../database/database.module.js';
|
import { DB } from '../database/database.module.js';
|
||||||
import { parseFederationScope } from './scope-schema.js';
|
import { parseFederationScope } from './scope-schema.js';
|
||||||
import type { CreateGrantDto, ListGrantsDto } from './grants.dto.js';
|
import type { CreateGrantDto, ListGrantsDto } from './grants.dto.js';
|
||||||
|
|
||||||
export type Grant = typeof federationGrants.$inferSelect;
|
export type Grant = typeof federationGrants.$inferSelect;
|
||||||
|
export type Peer = typeof federationPeers.$inferSelect;
|
||||||
|
export type GrantWithPeer = Grant & { peer: Peer };
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class GrantsService {
|
export class GrantsService {
|
||||||
@@ -60,6 +62,33 @@ export class GrantsService {
|
|||||||
return grant;
|
return grant;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch a single grant by ID, joined with its associated peer row.
|
||||||
|
* Used by FederationAuthGuard to perform grant status + cert serial checks
|
||||||
|
* in a single DB round-trip.
|
||||||
|
*
|
||||||
|
* Throws NotFoundException if the grant does not exist.
|
||||||
|
* Throws NotFoundException if the associated peer row is missing (data integrity issue).
|
||||||
|
*/
|
||||||
|
async getGrantWithPeer(id: string): Promise<GrantWithPeer> {
|
||||||
|
const rows = await this.db
|
||||||
|
.select()
|
||||||
|
.from(federationGrants)
|
||||||
|
.innerJoin(federationPeers, eq(federationGrants.peerId, federationPeers.id))
|
||||||
|
.where(eq(federationGrants.id, id))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
const row = rows[0];
|
||||||
|
if (!row) {
|
||||||
|
throw new NotFoundException(`Grant ${id} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...row.federation_grants,
|
||||||
|
peer: row.federation_peers,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* List grants with optional filters for peerId, subjectUserId, and status.
|
* List grants with optional filters for peerId, subjectUserId, and status.
|
||||||
*/
|
*/
|
||||||
|
|||||||
146
apps/gateway/src/federation/oid.util.ts
Normal file
146
apps/gateway/src/federation/oid.util.ts
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
/**
|
||||||
|
* Shared OID extraction helpers for Mosaic federation certificates.
|
||||||
|
*
|
||||||
|
* Custom OID registry (PRD §6, docs/federation/SETUP.md):
|
||||||
|
* 1.3.6.1.4.1.99999.1 — mosaic_grant_id
|
||||||
|
* 1.3.6.1.4.1.99999.2 — mosaic_subject_user_id
|
||||||
|
*
|
||||||
|
* The encoding convention: each extension value is an OCTET STRING wrapping
|
||||||
|
* an ASN.1 UTF8String TLV:
|
||||||
|
* 0x0C (tag) + 1-byte length + UTF-8 bytes
|
||||||
|
*
|
||||||
|
* CaService encodes values this way via encodeUtf8String(), and this module
|
||||||
|
* decodes them with the corresponding `.slice(2)` to skip tag + length byte.
|
||||||
|
*
|
||||||
|
* This module is intentionally pure — no NestJS, no DB, no network I/O.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { X509Certificate } from '@peculiar/x509';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// OID constants
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export const OID_MOSAIC_GRANT_ID = '1.3.6.1.4.1.99999.1';
|
||||||
|
export const OID_MOSAIC_SUBJECT_USER_ID = '1.3.6.1.4.1.99999.2';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Extraction result types
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface MosaicOids {
|
||||||
|
grantId: string;
|
||||||
|
subjectUserId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type OidExtractionResult =
|
||||||
|
| { ok: true; value: MosaicOids }
|
||||||
|
| {
|
||||||
|
ok: false;
|
||||||
|
error: 'MISSING_GRANT_ID' | 'MISSING_SUBJECT_USER_ID' | 'PARSE_ERROR';
|
||||||
|
detail?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decode an extension value encoded as ASN.1 UTF8String TLV
|
||||||
|
* (tag 0x0C + 1-byte length + UTF-8 bytes).
|
||||||
|
* Validates tag, length byte, and buffer bounds before decoding.
|
||||||
|
* Throws a descriptive Error on malformed input; caller wraps in try/catch.
|
||||||
|
*/
|
||||||
|
function decodeUtf8StringTlv(value: ArrayBuffer): string {
|
||||||
|
const bytes = new Uint8Array(value);
|
||||||
|
|
||||||
|
// Need at least tag + length bytes
|
||||||
|
if (bytes.length < 2) {
|
||||||
|
throw new Error(`UTF8String TLV too short: expected at least 2 bytes, got ${bytes.length}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tag byte must be 0x0C (ASN.1 UTF8String)
|
||||||
|
if (bytes[0] !== 0x0c) {
|
||||||
|
throw new Error(
|
||||||
|
`UTF8String TLV tag mismatch: expected 0x0C, got 0x${bytes[0]!.toString(16).toUpperCase()}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only single-byte length form is supported (values 0–127); long form not needed
|
||||||
|
// for OID strings of this length.
|
||||||
|
const declaredLength = bytes[1]!;
|
||||||
|
if (declaredLength > 127) {
|
||||||
|
throw new Error(
|
||||||
|
`UTF8String TLV uses long-form length (0x${declaredLength.toString(16).toUpperCase()}), which is not supported`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Declared length must match actual remaining bytes
|
||||||
|
if (declaredLength !== bytes.length - 2) {
|
||||||
|
throw new Error(
|
||||||
|
`UTF8String TLV length mismatch: declared ${declaredLength}, actual ${bytes.length - 2}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip: tag (1 byte) + length (1 byte)
|
||||||
|
return decoder.decode(bytes.slice(2));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Public API
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract Mosaic custom OIDs (grantId, subjectUserId) from an X.509 certificate
|
||||||
|
* already parsed via @peculiar/x509.
|
||||||
|
*
|
||||||
|
* Returns `{ ok: true, value: MosaicOids }` on success, or
|
||||||
|
* `{ ok: false, error: <code>, detail? }` on any failure — never throws.
|
||||||
|
*/
|
||||||
|
export function extractMosaicOids(cert: X509Certificate): OidExtractionResult {
|
||||||
|
try {
|
||||||
|
const grantIdExt = cert.getExtension(OID_MOSAIC_GRANT_ID);
|
||||||
|
if (!grantIdExt) {
|
||||||
|
return { ok: false, error: 'MISSING_GRANT_ID' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const subjectUserIdExt = cert.getExtension(OID_MOSAIC_SUBJECT_USER_ID);
|
||||||
|
if (!subjectUserIdExt) {
|
||||||
|
return { ok: false, error: 'MISSING_SUBJECT_USER_ID' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const grantId = decodeUtf8StringTlv(grantIdExt.value);
|
||||||
|
const subjectUserId = decodeUtf8StringTlv(subjectUserIdExt.value);
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
value: { grantId, subjectUserId },
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: 'PARSE_ERROR',
|
||||||
|
detail: err instanceof Error ? err.message : String(err),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a PEM-encoded certificate and extract Mosaic OIDs.
|
||||||
|
* Returns an OidExtractionResult — never throws.
|
||||||
|
*/
|
||||||
|
export function extractMosaicOidsFromPem(certPem: string): OidExtractionResult {
|
||||||
|
let cert: X509Certificate;
|
||||||
|
try {
|
||||||
|
cert = new X509Certificate(certPem);
|
||||||
|
} catch (err) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: 'PARSE_ERROR',
|
||||||
|
detail: err instanceof Error ? err.message : String(err),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return extractMosaicOids(cert);
|
||||||
|
}
|
||||||
@@ -0,0 +1,521 @@
|
|||||||
|
/**
|
||||||
|
* Unit tests for FederationAuthGuard (FED-M3-03).
|
||||||
|
*
|
||||||
|
* Coverage:
|
||||||
|
* - Missing cert (no TLS socket / no getPeerCertificate) → 401
|
||||||
|
* - Cert parse failure (corrupt DER raw bytes) → 401
|
||||||
|
* - Missing grantId OID → 401
|
||||||
|
* - Missing subjectUserId OID → 401
|
||||||
|
* - Grant not found (GrantsService throws NotFoundException) → 403
|
||||||
|
* - Grant in `pending` status → 403
|
||||||
|
* - Grant in `revoked` status → 403
|
||||||
|
* - Grant in `expired` status → 403
|
||||||
|
* - Cert serial mismatch → 403
|
||||||
|
* - Happy path: active grant + matching cert serial → context attached, returns true
|
||||||
|
*/
|
||||||
|
|
||||||
|
import 'reflect-metadata';
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import type { ExecutionContext } from '@nestjs/common';
|
||||||
|
import { NotFoundException } from '@nestjs/common';
|
||||||
|
import { FederationAuthGuard } from '../federation-auth.guard.js';
|
||||||
|
import { makeMosaicIssuedCert } from '../../__tests__/helpers/test-cert.js';
|
||||||
|
import type { GrantsService, GrantWithPeer } from '../../grants.service.js';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Test constants
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const GRANT_ID = 'a1111111-1111-1111-1111-111111111111';
|
||||||
|
const USER_ID = 'b2222222-2222-2222-2222-222222222222';
|
||||||
|
const PEER_ID = 'c3333333-3333-3333-3333-333333333333';
|
||||||
|
|
||||||
|
// Node.js TLS serialNumber is uppercase hex (no colons)
|
||||||
|
const CERT_SERIAL_HEX = '01';
|
||||||
|
|
||||||
|
const VALID_SCOPE = { resources: ['tasks'], max_rows_per_query: 100 };
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Mock builders
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a minimal GrantWithPeer-shaped mock.
|
||||||
|
*/
|
||||||
|
function makeGrantWithPeer(overrides: Partial<GrantWithPeer> = {}): GrantWithPeer {
|
||||||
|
return {
|
||||||
|
id: GRANT_ID,
|
||||||
|
peerId: PEER_ID,
|
||||||
|
subjectUserId: USER_ID,
|
||||||
|
scope: VALID_SCOPE,
|
||||||
|
status: 'active',
|
||||||
|
expiresAt: null,
|
||||||
|
createdAt: new Date('2026-01-01T00:00:00Z'),
|
||||||
|
revokedAt: null,
|
||||||
|
revokedReason: null,
|
||||||
|
peer: {
|
||||||
|
id: PEER_ID,
|
||||||
|
commonName: 'test-peer',
|
||||||
|
displayName: 'Test Peer',
|
||||||
|
certPem: '',
|
||||||
|
certSerial: CERT_SERIAL_HEX,
|
||||||
|
certNotAfter: new Date(Date.now() + 86_400_000),
|
||||||
|
clientKeyPem: null,
|
||||||
|
state: 'active',
|
||||||
|
endpointUrl: null,
|
||||||
|
lastSeenAt: null,
|
||||||
|
createdAt: new Date('2026-01-01T00:00:00Z'),
|
||||||
|
revokedAt: null,
|
||||||
|
},
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a mock ExecutionContext with a pre-built TLS peer certificate.
|
||||||
|
*
|
||||||
|
* `certPem` — PEM string to present as the raw DER cert (converted to Buffer).
|
||||||
|
* Pass null to simulate "no cert presented".
|
||||||
|
* `certSerialHex` — serialNumber string returned by the TLS socket.
|
||||||
|
* Node.js returns uppercase hex.
|
||||||
|
* `hasTlsSocket` — if false, raw.socket has no getPeerCertificate (plain HTTP).
|
||||||
|
*/
|
||||||
|
function makeContext(opts: {
|
||||||
|
certPem: string | null;
|
||||||
|
certSerialHex?: string;
|
||||||
|
hasTlsSocket?: boolean;
|
||||||
|
}): {
|
||||||
|
ctx: ExecutionContext;
|
||||||
|
statusMock: ReturnType<typeof vi.fn>;
|
||||||
|
sendMock: ReturnType<typeof vi.fn>;
|
||||||
|
} {
|
||||||
|
const { certPem, certSerialHex = CERT_SERIAL_HEX, hasTlsSocket = true } = opts;
|
||||||
|
|
||||||
|
// Build peerCert object that Node.js TLS socket.getPeerCertificate() returns
|
||||||
|
let peerCert: Record<string, unknown>;
|
||||||
|
if (certPem === null) {
|
||||||
|
// Simulate no cert: Node.js returns object with empty string fields
|
||||||
|
peerCert = { raw: null, serialNumber: '' };
|
||||||
|
} else {
|
||||||
|
// Convert PEM to DER Buffer (strip headers + base64 decode)
|
||||||
|
const b64 = certPem
|
||||||
|
.replace(/-----BEGIN CERTIFICATE-----/, '')
|
||||||
|
.replace(/-----END CERTIFICATE-----/, '')
|
||||||
|
.replace(/\s+/g, '');
|
||||||
|
const raw = Buffer.from(b64, 'base64');
|
||||||
|
peerCert = { raw, serialNumber: certSerialHex };
|
||||||
|
}
|
||||||
|
|
||||||
|
const getPeerCertificate = vi.fn().mockReturnValue(peerCert);
|
||||||
|
|
||||||
|
const socket = hasTlsSocket ? { getPeerCertificate } : {}; // No getPeerCertificate → non-TLS
|
||||||
|
|
||||||
|
// Fastify reply mocks
|
||||||
|
const sendMock = vi.fn().mockReturnValue(undefined);
|
||||||
|
const headerMock = vi.fn().mockReturnValue({ send: sendMock });
|
||||||
|
const statusMock = vi.fn().mockReturnValue({ header: headerMock });
|
||||||
|
|
||||||
|
const request = {
|
||||||
|
raw: {
|
||||||
|
socket,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const reply = {
|
||||||
|
status: statusMock,
|
||||||
|
};
|
||||||
|
|
||||||
|
const ctx = {
|
||||||
|
switchToHttp: () => ({
|
||||||
|
getRequest: () => request,
|
||||||
|
getResponse: () => reply,
|
||||||
|
}),
|
||||||
|
} as unknown as ExecutionContext;
|
||||||
|
|
||||||
|
return { ctx, statusMock, sendMock };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a mock GrantsService.
|
||||||
|
*/
|
||||||
|
function makeGrantsService(
|
||||||
|
overrides: Partial<Pick<GrantsService, 'getGrantWithPeer'>> = {},
|
||||||
|
): GrantsService {
|
||||||
|
return {
|
||||||
|
getGrantWithPeer: vi.fn().mockResolvedValue(makeGrantWithPeer()),
|
||||||
|
...overrides,
|
||||||
|
} as unknown as GrantsService;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Test suite
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('FederationAuthGuard', () => {
|
||||||
|
let certPem: string;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
// Generate a real Mosaic-issued cert with the standard OIDs
|
||||||
|
certPem = await makeMosaicIssuedCert({ grantId: GRANT_ID, subjectUserId: USER_ID });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── 401: No TLS socket ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
it('returns 401 when there is no TLS socket (plain HTTP connection)', async () => {
|
||||||
|
const { ctx, statusMock, sendMock } = makeContext({
|
||||||
|
certPem: certPem,
|
||||||
|
hasTlsSocket: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const guard = new FederationAuthGuard(makeGrantsService());
|
||||||
|
const result = await guard.canActivate(ctx);
|
||||||
|
|
||||||
|
expect(result).toBe(false);
|
||||||
|
expect(statusMock).toHaveBeenCalledWith(401);
|
||||||
|
expect(sendMock).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
error: expect.objectContaining({ code: 'unauthorized', message: expect.any(String) }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── 401: Cert not presented ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
it('returns 401 when the peer did not present a certificate', async () => {
|
||||||
|
const { ctx, statusMock, sendMock } = makeContext({ certPem: null });
|
||||||
|
|
||||||
|
const guard = new FederationAuthGuard(makeGrantsService());
|
||||||
|
const result = await guard.canActivate(ctx);
|
||||||
|
|
||||||
|
expect(result).toBe(false);
|
||||||
|
expect(statusMock).toHaveBeenCalledWith(401);
|
||||||
|
expect(sendMock).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
error: expect.objectContaining({ code: 'unauthorized', message: expect.any(String) }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── 401: Cert parse failure ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
it('returns 401 when the certificate DER bytes are corrupt', async () => {
|
||||||
|
// Build context with a cert that has garbage DER bytes
|
||||||
|
const corruptPem = '-----BEGIN CERTIFICATE-----\naW52YWxpZA==\n-----END CERTIFICATE-----';
|
||||||
|
const { ctx, statusMock, sendMock } = makeContext({ certPem: corruptPem });
|
||||||
|
|
||||||
|
const guard = new FederationAuthGuard(makeGrantsService());
|
||||||
|
const result = await guard.canActivate(ctx);
|
||||||
|
|
||||||
|
expect(result).toBe(false);
|
||||||
|
expect(statusMock).toHaveBeenCalledWith(401);
|
||||||
|
expect(sendMock).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
error: expect.objectContaining({ code: 'unauthorized', message: expect.any(String) }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── 401: Missing grantId OID ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
it('returns 401 when the cert is missing the grantId OID', async () => {
|
||||||
|
// makeSelfSignedCert produces a cert without any Mosaic OIDs
|
||||||
|
const { makeSelfSignedCert } = await import('../../__tests__/helpers/test-cert.js');
|
||||||
|
const plainCert = await makeSelfSignedCert();
|
||||||
|
const { ctx, statusMock, sendMock } = makeContext({ certPem: plainCert });
|
||||||
|
|
||||||
|
const guard = new FederationAuthGuard(makeGrantsService());
|
||||||
|
const result = await guard.canActivate(ctx);
|
||||||
|
|
||||||
|
expect(result).toBe(false);
|
||||||
|
expect(statusMock).toHaveBeenCalledWith(401);
|
||||||
|
expect(sendMock).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
error: expect.objectContaining({ code: 'unauthorized', message: expect.any(String) }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── 401: Missing subjectUserId OID ───────────────────────────────────────
|
||||||
|
|
||||||
|
it('returns 401 when the cert has grantId OID but is missing subjectUserId OID', async () => {
|
||||||
|
// Build a cert with only the grantId OID by importing cert generator internals
|
||||||
|
const { webcrypto } = await import('node:crypto');
|
||||||
|
const {
|
||||||
|
X509CertificateGenerator,
|
||||||
|
Extension,
|
||||||
|
KeyUsagesExtension,
|
||||||
|
KeyUsageFlags,
|
||||||
|
BasicConstraintsExtension,
|
||||||
|
cryptoProvider,
|
||||||
|
} = await import('@peculiar/x509');
|
||||||
|
|
||||||
|
cryptoProvider.set(webcrypto as unknown as Parameters<typeof cryptoProvider.set>[0]);
|
||||||
|
|
||||||
|
const alg = { name: 'ECDSA', namedCurve: 'P-256', hash: 'SHA-256' } as const;
|
||||||
|
const keys = await webcrypto.subtle.generateKey(alg, false, ['sign', 'verify']);
|
||||||
|
const now = new Date();
|
||||||
|
const tomorrow = new Date(now.getTime() + 86_400_000);
|
||||||
|
|
||||||
|
// Encode grantId only — missing subjectUserId extension
|
||||||
|
const utf8 = new TextEncoder().encode(GRANT_ID);
|
||||||
|
const encoded = new Uint8Array(2 + utf8.length);
|
||||||
|
encoded[0] = 0x0c;
|
||||||
|
encoded[1] = utf8.length;
|
||||||
|
encoded.set(utf8, 2);
|
||||||
|
|
||||||
|
const cert = await X509CertificateGenerator.createSelfSigned({
|
||||||
|
serialNumber: '01',
|
||||||
|
name: 'CN=partial-oid-test',
|
||||||
|
notBefore: now,
|
||||||
|
notAfter: tomorrow,
|
||||||
|
signingAlgorithm: alg,
|
||||||
|
keys,
|
||||||
|
extensions: [
|
||||||
|
new BasicConstraintsExtension(false),
|
||||||
|
new KeyUsagesExtension(KeyUsageFlags.digitalSignature),
|
||||||
|
new Extension('1.3.6.1.4.1.99999.1', false, encoded), // grantId only
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { ctx, statusMock, sendMock } = makeContext({ certPem: cert.toString('pem') });
|
||||||
|
|
||||||
|
const guard = new FederationAuthGuard(makeGrantsService());
|
||||||
|
const result = await guard.canActivate(ctx);
|
||||||
|
|
||||||
|
expect(result).toBe(false);
|
||||||
|
expect(statusMock).toHaveBeenCalledWith(401);
|
||||||
|
expect(sendMock).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
error: expect.objectContaining({ code: 'unauthorized', message: expect.any(String) }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── 403: Grant not found ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
it('returns 403 when the grantId from the cert does not exist in DB', async () => {
|
||||||
|
const grantsService = makeGrantsService({
|
||||||
|
getGrantWithPeer: vi
|
||||||
|
.fn()
|
||||||
|
.mockRejectedValue(new NotFoundException(`Grant ${GRANT_ID} not found`)),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { ctx, statusMock, sendMock } = makeContext({ certPem });
|
||||||
|
|
||||||
|
const guard = new FederationAuthGuard(grantsService);
|
||||||
|
const result = await guard.canActivate(ctx);
|
||||||
|
|
||||||
|
expect(result).toBe(false);
|
||||||
|
expect(statusMock).toHaveBeenCalledWith(403);
|
||||||
|
expect(sendMock).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
error: expect.objectContaining({ code: 'forbidden', message: 'Federation access denied' }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── 403: Grant in `pending` status ───────────────────────────────────────
|
||||||
|
|
||||||
|
it('returns 403 when the grant is in pending status', async () => {
|
||||||
|
const grantsService = makeGrantsService({
|
||||||
|
getGrantWithPeer: vi.fn().mockResolvedValue(makeGrantWithPeer({ status: 'pending' })),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { ctx, statusMock, sendMock } = makeContext({ certPem });
|
||||||
|
|
||||||
|
const guard = new FederationAuthGuard(grantsService);
|
||||||
|
const result = await guard.canActivate(ctx);
|
||||||
|
|
||||||
|
expect(result).toBe(false);
|
||||||
|
expect(statusMock).toHaveBeenCalledWith(403);
|
||||||
|
expect(sendMock).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
error: expect.objectContaining({ code: 'forbidden', message: 'Federation access denied' }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── 403: Grant in `revoked` status ───────────────────────────────────────
|
||||||
|
|
||||||
|
it('returns 403 when the grant is in revoked status', async () => {
|
||||||
|
const grantsService = makeGrantsService({
|
||||||
|
getGrantWithPeer: vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue(makeGrantWithPeer({ status: 'revoked', revokedAt: new Date() })),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { ctx, statusMock, sendMock } = makeContext({ certPem });
|
||||||
|
|
||||||
|
const guard = new FederationAuthGuard(grantsService);
|
||||||
|
const result = await guard.canActivate(ctx);
|
||||||
|
|
||||||
|
expect(result).toBe(false);
|
||||||
|
expect(statusMock).toHaveBeenCalledWith(403);
|
||||||
|
expect(sendMock).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
error: expect.objectContaining({ code: 'forbidden', message: 'Federation access denied' }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── 403: Grant in `expired` status ───────────────────────────────────────
|
||||||
|
|
||||||
|
it('returns 403 when the grant is in expired status', async () => {
|
||||||
|
const grantsService = makeGrantsService({
|
||||||
|
getGrantWithPeer: vi.fn().mockResolvedValue(makeGrantWithPeer({ status: 'expired' })),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { ctx, statusMock, sendMock } = makeContext({ certPem });
|
||||||
|
|
||||||
|
const guard = new FederationAuthGuard(grantsService);
|
||||||
|
const result = await guard.canActivate(ctx);
|
||||||
|
|
||||||
|
expect(result).toBe(false);
|
||||||
|
expect(statusMock).toHaveBeenCalledWith(403);
|
||||||
|
expect(sendMock).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
error: expect.objectContaining({ code: 'forbidden', message: 'Federation access denied' }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── 403: Cert serial mismatch ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
it('returns 403 when the cert serial does not match the registered peer cert serial', async () => {
|
||||||
|
// Return a grant whose peer has a different stored serial
|
||||||
|
const grantsService = makeGrantsService({
|
||||||
|
getGrantWithPeer: vi.fn().mockResolvedValue(
|
||||||
|
makeGrantWithPeer({
|
||||||
|
peer: {
|
||||||
|
id: PEER_ID,
|
||||||
|
commonName: 'test-peer',
|
||||||
|
displayName: 'Test Peer',
|
||||||
|
certPem: '',
|
||||||
|
certSerial: 'DEADBEEF', // different from CERT_SERIAL_HEX='01'
|
||||||
|
certNotAfter: new Date(Date.now() + 86_400_000),
|
||||||
|
clientKeyPem: null,
|
||||||
|
state: 'active',
|
||||||
|
endpointUrl: null,
|
||||||
|
lastSeenAt: null,
|
||||||
|
createdAt: new Date('2026-01-01T00:00:00Z'),
|
||||||
|
revokedAt: null,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Context presents cert with serial '01' but DB has 'DEADBEEF'
|
||||||
|
const { ctx, statusMock, sendMock } = makeContext({ certPem, certSerialHex: '01' });
|
||||||
|
|
||||||
|
const guard = new FederationAuthGuard(grantsService);
|
||||||
|
const result = await guard.canActivate(ctx);
|
||||||
|
|
||||||
|
expect(result).toBe(false);
|
||||||
|
expect(statusMock).toHaveBeenCalledWith(403);
|
||||||
|
expect(sendMock).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
error: expect.objectContaining({ code: 'forbidden', message: 'Federation access denied' }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── 403: subjectUserId cert/DB mismatch (CRIT-1 regression test) ─────────
|
||||||
|
|
||||||
|
it('returns 403 when the cert subjectUserId does not match the DB grant subjectUserId', async () => {
|
||||||
|
// Build a cert that claims an attacker's subjectUserId
|
||||||
|
const attackerSubjectUserId = 'attacker-user-id';
|
||||||
|
const attackerCertPem = await makeMosaicIssuedCert({
|
||||||
|
grantId: GRANT_ID,
|
||||||
|
subjectUserId: attackerSubjectUserId,
|
||||||
|
});
|
||||||
|
|
||||||
|
// DB returns a grant with the legitimate USER_ID
|
||||||
|
const grantsService = makeGrantsService({
|
||||||
|
getGrantWithPeer: vi.fn().mockResolvedValue(makeGrantWithPeer({ subjectUserId: USER_ID })),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Cert presents attacker-user-id but DB has USER_ID — should be rejected
|
||||||
|
const { ctx, statusMock, sendMock } = makeContext({
|
||||||
|
certPem: attackerCertPem,
|
||||||
|
certSerialHex: CERT_SERIAL_HEX,
|
||||||
|
});
|
||||||
|
|
||||||
|
const guard = new FederationAuthGuard(grantsService);
|
||||||
|
const result = await guard.canActivate(ctx);
|
||||||
|
|
||||||
|
expect(result).toBe(false);
|
||||||
|
expect(statusMock).toHaveBeenCalledWith(403);
|
||||||
|
expect(sendMock).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
error: expect.objectContaining({ code: 'forbidden', message: 'Federation access denied' }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Happy path ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
it('returns true and attaches federationContext on happy path', async () => {
|
||||||
|
const grant = makeGrantWithPeer({
|
||||||
|
status: 'active',
|
||||||
|
peer: {
|
||||||
|
id: PEER_ID,
|
||||||
|
commonName: 'test-peer',
|
||||||
|
displayName: 'Test Peer',
|
||||||
|
certPem: '',
|
||||||
|
certSerial: CERT_SERIAL_HEX,
|
||||||
|
certNotAfter: new Date(Date.now() + 86_400_000),
|
||||||
|
clientKeyPem: null,
|
||||||
|
state: 'active',
|
||||||
|
endpointUrl: null,
|
||||||
|
lastSeenAt: null,
|
||||||
|
createdAt: new Date('2026-01-01T00:00:00Z'),
|
||||||
|
revokedAt: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const grantsService = makeGrantsService({
|
||||||
|
getGrantWithPeer: vi.fn().mockResolvedValue(grant),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Build context manually to capture what gets set on request.federationContext
|
||||||
|
const b64 = certPem
|
||||||
|
.replace(/-----BEGIN CERTIFICATE-----/, '')
|
||||||
|
.replace(/-----END CERTIFICATE-----/, '')
|
||||||
|
.replace(/\s+/g, '');
|
||||||
|
const raw = Buffer.from(b64, 'base64');
|
||||||
|
const peerCert = { raw, serialNumber: CERT_SERIAL_HEX };
|
||||||
|
|
||||||
|
const sendMock = vi.fn().mockReturnValue(undefined);
|
||||||
|
const headerMock = vi.fn().mockReturnValue({ send: sendMock });
|
||||||
|
const statusMock = vi.fn().mockReturnValue({ header: headerMock });
|
||||||
|
|
||||||
|
const request: Record<string, unknown> = {
|
||||||
|
raw: {
|
||||||
|
socket: { getPeerCertificate: vi.fn().mockReturnValue(peerCert) },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const reply = { status: statusMock };
|
||||||
|
|
||||||
|
const ctx = {
|
||||||
|
switchToHttp: () => ({
|
||||||
|
getRequest: () => request,
|
||||||
|
getResponse: () => reply,
|
||||||
|
}),
|
||||||
|
} as unknown as ExecutionContext;
|
||||||
|
|
||||||
|
const guard = new FederationAuthGuard(grantsService);
|
||||||
|
const result = await guard.canActivate(ctx);
|
||||||
|
|
||||||
|
expect(result).toBe(true);
|
||||||
|
expect(statusMock).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
// Verify the context was attached correctly
|
||||||
|
expect(request['federationContext']).toEqual({
|
||||||
|
grantId: GRANT_ID,
|
||||||
|
subjectUserId: USER_ID,
|
||||||
|
peerId: PEER_ID,
|
||||||
|
scope: VALID_SCOPE,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
212
apps/gateway/src/federation/server/federation-auth.guard.ts
Normal file
212
apps/gateway/src/federation/server/federation-auth.guard.ts
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
/**
|
||||||
|
* FederationAuthGuard — NestJS CanActivate guard for inbound federation requests.
|
||||||
|
*
|
||||||
|
* Validates the mTLS client certificate presented by a peer gateway, extracts
|
||||||
|
* custom OIDs to identify the grant + subject user, loads the grant from DB,
|
||||||
|
* asserts it is active, and verifies the cert serial against the registered peer
|
||||||
|
* cert serial as a defense-in-depth measure.
|
||||||
|
*
|
||||||
|
* On success, attaches `request.federationContext` for downstream verb controllers.
|
||||||
|
* On failure, responds with the federation wire-format error envelope (not raw
|
||||||
|
* NestJS exception JSON) to match the federation protocol contract.
|
||||||
|
*
|
||||||
|
* ## Cert-serial check decision
|
||||||
|
* The guard validates that the inbound client cert's serial number matches the
|
||||||
|
* `certSerial` stored on the associated `federation_peers` row. This is a
|
||||||
|
* defense-in-depth measure: even if the mTLS handshake is compromised at the
|
||||||
|
* transport layer (e.g. misconfigured TLS terminator that forwards arbitrary
|
||||||
|
* client certs), an attacker cannot replay a cert with a different serial than
|
||||||
|
* what was registered during enrollment. This check is NOT loosened because:
|
||||||
|
* 1. It is O(1) — no additional DB round-trip (peerId is on the grant row,
|
||||||
|
* so we join to federationPeers in the same query).
|
||||||
|
* 2. Cert renewal MUST update the stored serial — enforced by M6 scheduler.
|
||||||
|
* 3. The OID-only path (without serial check) would allow any cert from the
|
||||||
|
* same CA bearing the same grantId OID to succeed after cert compromise.
|
||||||
|
*
|
||||||
|
* ## FastifyRequest typing path
|
||||||
|
* NestJS + Fastify wraps the raw Node.js IncomingMessage in a FastifyRequest.
|
||||||
|
* The underlying TLS socket is accessed via `request.raw.socket`, which is a
|
||||||
|
* `tls.TLSSocket` when the server is listening on HTTPS. In development/test
|
||||||
|
* the gateway may run over plain HTTP, in which case `getPeerCertificate` is
|
||||||
|
* not available. The guard safely handles both cases by checking for the
|
||||||
|
* method's existence before calling it.
|
||||||
|
*
|
||||||
|
* Note: The guard reads the peer certificate from the *already-completed*
|
||||||
|
* TLS handshake via `socket.getPeerCertificate(detailed=true)`. This relies
|
||||||
|
* on the server being configured with `requestCert: true` at the TLS level
|
||||||
|
* so Fastify/Node.js requests the client cert during the handshake.
|
||||||
|
* The guard does NOT verify the cert chain itself — that is handled by the
|
||||||
|
* TLS layer (Node.js `rejectUnauthorized: true` with the CA cert pinned).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
type CanActivate,
|
||||||
|
type ExecutionContext,
|
||||||
|
Inject,
|
||||||
|
Injectable,
|
||||||
|
Logger,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||||
|
import * as tls from 'node:tls';
|
||||||
|
import { X509Certificate } from '@peculiar/x509';
|
||||||
|
import { FederationForbiddenError, FederationUnauthorizedError } from '@mosaicstack/types';
|
||||||
|
import { extractMosaicOids } from '../oid.util.js';
|
||||||
|
import { GrantsService } from '../grants.service.js';
|
||||||
|
import type { FederationContext } from './federation-context.js';
|
||||||
|
import './federation-context.js'; // side-effect import: applies FastifyRequest module augmentation
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Internal helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a federation wire-format error response directly on the Fastify reply.
|
||||||
|
* Returns false — callers return this value from canActivate.
|
||||||
|
*/
|
||||||
|
function sendFederationError(
|
||||||
|
reply: FastifyReply,
|
||||||
|
error: FederationUnauthorizedError | FederationForbiddenError,
|
||||||
|
): boolean {
|
||||||
|
const statusCode = error.code === 'unauthorized' ? 401 : 403;
|
||||||
|
void reply.status(statusCode).header('content-type', 'application/json').send(error.toEnvelope());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Guard
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class FederationAuthGuard implements CanActivate {
|
||||||
|
private readonly logger = new Logger(FederationAuthGuard.name);
|
||||||
|
|
||||||
|
constructor(@Inject(GrantsService) private readonly grantsService: GrantsService) {}
|
||||||
|
|
||||||
|
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||||
|
const http = context.switchToHttp();
|
||||||
|
const request = http.getRequest<FastifyRequest>();
|
||||||
|
const reply = http.getResponse<FastifyReply>();
|
||||||
|
|
||||||
|
// ── Step 1: Extract peer certificate from TLS socket ────────────────────
|
||||||
|
const rawSocket = request.raw.socket;
|
||||||
|
|
||||||
|
// Check TLS socket: getPeerCertificate is only available on TLS connections.
|
||||||
|
if (
|
||||||
|
!rawSocket ||
|
||||||
|
typeof (rawSocket as Partial<tls.TLSSocket>).getPeerCertificate !== 'function'
|
||||||
|
) {
|
||||||
|
this.logger.warn('No TLS socket — client cert unavailable (non-mTLS connection)');
|
||||||
|
return sendFederationError(
|
||||||
|
reply,
|
||||||
|
new FederationUnauthorizedError('Client certificate required'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const tlsSocket = rawSocket as tls.TLSSocket;
|
||||||
|
const peerCert = tlsSocket.getPeerCertificate(true);
|
||||||
|
|
||||||
|
// Node.js returns an object with empty string fields when no cert was presented.
|
||||||
|
if (!peerCert || !peerCert.raw) {
|
||||||
|
this.logger.warn('Peer certificate not presented (mTLS handshake did not supply cert)');
|
||||||
|
return sendFederationError(
|
||||||
|
reply,
|
||||||
|
new FederationUnauthorizedError('Client certificate required'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Step 2: Parse the DER-encoded certificate via @peculiar/x509 ────────
|
||||||
|
let cert: X509Certificate;
|
||||||
|
try {
|
||||||
|
// peerCert.raw is a Buffer containing the DER-encoded cert
|
||||||
|
cert = new X509Certificate(peerCert.raw);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(
|
||||||
|
`Failed to parse peer certificate: ${err instanceof Error ? err.message : String(err)}`,
|
||||||
|
);
|
||||||
|
return sendFederationError(
|
||||||
|
reply,
|
||||||
|
new FederationUnauthorizedError('Client certificate could not be parsed'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Step 3: Extract Mosaic custom OIDs ──────────────────────────────────
|
||||||
|
const oidResult = extractMosaicOids(cert);
|
||||||
|
|
||||||
|
if (!oidResult.ok) {
|
||||||
|
const message =
|
||||||
|
oidResult.error === 'MISSING_GRANT_ID'
|
||||||
|
? 'Client certificate is missing required OID: mosaic_grant_id (1.3.6.1.4.1.99999.1)'
|
||||||
|
: oidResult.error === 'MISSING_SUBJECT_USER_ID'
|
||||||
|
? 'Client certificate is missing required OID: mosaic_subject_user_id (1.3.6.1.4.1.99999.2)'
|
||||||
|
: `Client certificate OID extraction failed: ${oidResult.detail ?? 'unknown error'}`;
|
||||||
|
this.logger.warn(`OID extraction failure [${oidResult.error}]: ${message}`);
|
||||||
|
return sendFederationError(reply, new FederationUnauthorizedError(message));
|
||||||
|
}
|
||||||
|
|
||||||
|
const { grantId, subjectUserId } = oidResult.value;
|
||||||
|
|
||||||
|
// ── Step 4: Load grant from DB ───────────────────────────────────────────
|
||||||
|
let grant: Awaited<ReturnType<GrantsService['getGrantWithPeer']>>;
|
||||||
|
try {
|
||||||
|
grant = await this.grantsService.getGrantWithPeer(grantId);
|
||||||
|
} catch {
|
||||||
|
// getGrantWithPeer throws NotFoundException when not found
|
||||||
|
this.logger.warn(`Grant not found: ${grantId}`);
|
||||||
|
return sendFederationError(reply, new FederationForbiddenError('Federation access denied'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Step 5: Assert grant is active ──────────────────────────────────────
|
||||||
|
if (grant.status !== 'active') {
|
||||||
|
this.logger.warn(`Grant ${grantId} is not active — status=${grant.status}`);
|
||||||
|
return sendFederationError(reply, new FederationForbiddenError('Federation access denied'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Step 5b: Validate cert-extracted subjectUserId against DB (CRIT-1) ──
|
||||||
|
// The cert claim is untrusted input; the DB row is authoritative.
|
||||||
|
if (subjectUserId !== grant.subjectUserId) {
|
||||||
|
this.logger.warn(`subjectUserId mismatch for grant ${grantId}`);
|
||||||
|
return sendFederationError(reply, new FederationForbiddenError('Federation access denied'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Step 6: Defense-in-depth — cert serial must match registered peer ───
|
||||||
|
// The serial number from Node.js TLS is upper-case hex without colons.
|
||||||
|
// The @peculiar/x509 serialNumber is decimal. We compare using the native
|
||||||
|
// Node.js crypto cert serial which is uppercase hex, matching DB storage.
|
||||||
|
// Both are derived from the peerCert.serialNumber Node.js provides.
|
||||||
|
const inboundSerial: string = peerCert.serialNumber ?? '';
|
||||||
|
|
||||||
|
if (!grant.peer.certSerial) {
|
||||||
|
// Peer row exists but has no stored serial — something is wrong with enrollment
|
||||||
|
this.logger.error(`Peer ${grant.peerId} has no stored certSerial — enrollment incomplete`);
|
||||||
|
return sendFederationError(reply, new FederationForbiddenError('Federation access denied'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalize both to uppercase for comparison (Node.js serialNumber is
|
||||||
|
// already uppercase hex; DB value was stored from extractSerial() which
|
||||||
|
// returns crypto.X509Certificate.serialNumber — also uppercase hex).
|
||||||
|
if (inboundSerial.toUpperCase() !== grant.peer.certSerial.toUpperCase()) {
|
||||||
|
this.logger.warn(
|
||||||
|
`Cert serial mismatch for grant ${grantId}: ` +
|
||||||
|
`inbound=${inboundSerial} registered=${grant.peer.certSerial}`,
|
||||||
|
);
|
||||||
|
return sendFederationError(reply, new FederationForbiddenError('Federation access denied'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Step 7: Attach FederationContext to request ──────────────────────────
|
||||||
|
// Use grant.subjectUserId from DB (authoritative) — not the cert-extracted value.
|
||||||
|
const federationContext: FederationContext = {
|
||||||
|
grantId,
|
||||||
|
subjectUserId: grant.subjectUserId,
|
||||||
|
peerId: grant.peerId,
|
||||||
|
scope: grant.scope as Record<string, unknown>,
|
||||||
|
};
|
||||||
|
|
||||||
|
request.federationContext = federationContext;
|
||||||
|
|
||||||
|
this.logger.debug(
|
||||||
|
`Federation auth OK — grantId=${grantId} peerId=${grant.peerId} subjectUserId=${grant.subjectUserId}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
39
apps/gateway/src/federation/server/federation-context.ts
Normal file
39
apps/gateway/src/federation/server/federation-context.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
/**
|
||||||
|
* FederationContext — attached to inbound federation requests after successful
|
||||||
|
* mTLS + grant validation by FederationAuthGuard.
|
||||||
|
*
|
||||||
|
* Downstream verb controllers access this via `request.federationContext`.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Augment FastifyRequest so TypeScript knows about the federation context
|
||||||
|
* property that FederationAuthGuard attaches on success.
|
||||||
|
*/
|
||||||
|
declare module 'fastify' {
|
||||||
|
interface FastifyRequest {
|
||||||
|
federationContext?: FederationContext;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Typed context object attached to the request by FederationAuthGuard.
|
||||||
|
* Carries all data extracted from the mTLS cert + grant DB row needed
|
||||||
|
* by downstream federation verb handlers.
|
||||||
|
*/
|
||||||
|
export interface FederationContext {
|
||||||
|
/** The federation grant ID extracted from OID 1.3.6.1.4.1.99999.1 */
|
||||||
|
grantId: string;
|
||||||
|
|
||||||
|
/** The local subject user whose data is accessible under this grant */
|
||||||
|
subjectUserId: string;
|
||||||
|
|
||||||
|
/** The peer gateway ID (from the grant's peerId FK) */
|
||||||
|
peerId: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Grant scope — determines which resources the peer may query.
|
||||||
|
* Typed as Record<string, unknown> because the full scope schema lives in
|
||||||
|
* scope-schema.ts; downstream handlers should narrow via parseFederationScope.
|
||||||
|
*/
|
||||||
|
scope: Record<string, unknown>;
|
||||||
|
}
|
||||||
13
apps/gateway/src/federation/server/index.ts
Normal file
13
apps/gateway/src/federation/server/index.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
/**
|
||||||
|
* Federation server-side barrel — inbound request handling.
|
||||||
|
*
|
||||||
|
* Exports the mTLS auth guard and the FederationContext interface
|
||||||
|
* for use by verb controllers (M3-05/06/07).
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* import { FederationAuthGuard } from './server/index.js';
|
||||||
|
* @UseGuards(FederationAuthGuard)
|
||||||
|
*/
|
||||||
|
|
||||||
|
export { FederationAuthGuard } from './federation-auth.guard.js';
|
||||||
|
export type { FederationContext } from './federation-context.js';
|
||||||
@@ -42,6 +42,7 @@
|
|||||||
"access": "public"
|
"access": "public"
|
||||||
},
|
},
|
||||||
"files": [
|
"files": [
|
||||||
"dist"
|
"dist",
|
||||||
|
"drizzle"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import { PGlite } from '@electric-sql/pglite';
|
import { PGlite } from '@electric-sql/pglite';
|
||||||
|
import { vector } from '@electric-sql/pglite/vector';
|
||||||
import { drizzle } from 'drizzle-orm/pglite';
|
import { drizzle } from 'drizzle-orm/pglite';
|
||||||
import * as schema from './schema.js';
|
import * as schema from './schema.js';
|
||||||
import type { DbHandle } from './client.js';
|
import type { DbHandle } from './client.js';
|
||||||
|
|
||||||
export function createPgliteDb(dataDir: string): DbHandle {
|
export function createPgliteDb(dataDir: string): DbHandle {
|
||||||
const client = new PGlite(dataDir);
|
// pgvector extension is required by migration 0001 (insights.embedding column).
|
||||||
|
const client = new PGlite(dataDir, { extensions: { vector } });
|
||||||
const db = drizzle(client, { schema });
|
const db = drizzle(client, { schema });
|
||||||
return {
|
return {
|
||||||
db: db as unknown as DbHandle['db'],
|
db: db as unknown as DbHandle['db'],
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
export { createDb, type Db, type DbHandle } from './client.js';
|
export { createDb, type Db, type DbHandle } from './client.js';
|
||||||
export { createPgliteDb } from './client-pglite.js';
|
export { createPgliteDb } from './client-pglite.js';
|
||||||
export { runMigrations } from './migrate.js';
|
export { runMigrations, runPgliteMigrations } from './migrate.js';
|
||||||
export * from './schema.js';
|
export * from './schema.js';
|
||||||
export * from './federation.js';
|
export * from './federation.js';
|
||||||
export {
|
export {
|
||||||
|
|||||||
70
packages/db/src/migrate.test.ts
Normal file
70
packages/db/src/migrate.test.ts
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import { mkdtempSync, rmSync } from 'node:fs';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { sql } from 'drizzle-orm';
|
||||||
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||||
|
import { createPgliteDb } from './client-pglite.js';
|
||||||
|
import { runPgliteMigrations } from './migrate.js';
|
||||||
|
import type { DbHandle } from './client.js';
|
||||||
|
|
||||||
|
interface PgliteExec {
|
||||||
|
exec(query: string): Promise<unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('runPgliteMigrations', () => {
|
||||||
|
let dataDir: string;
|
||||||
|
let handle: DbHandle;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
dataDir = mkdtempSync(join(tmpdir(), 'mosaic-db-migrate-test-'));
|
||||||
|
handle = createPgliteDb(dataDir);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await handle.close();
|
||||||
|
rmSync(dataDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates the BetterAuth tables required by the gateway', async () => {
|
||||||
|
await runPgliteMigrations(handle);
|
||||||
|
|
||||||
|
const result = (await handle.db.execute(sql`
|
||||||
|
SELECT table_name FROM information_schema.tables
|
||||||
|
WHERE table_schema = 'public'
|
||||||
|
ORDER BY table_name
|
||||||
|
`)) as unknown as { rows: Array<{ table_name: string }> };
|
||||||
|
|
||||||
|
const tables = result.rows.map((r) => r.table_name);
|
||||||
|
|
||||||
|
// Auth tables — required for sign-in / bootstrap to function.
|
||||||
|
expect(tables).toContain('users');
|
||||||
|
expect(tables).toContain('sessions');
|
||||||
|
expect(tables).toContain('accounts');
|
||||||
|
expect(tables).toContain('verifications');
|
||||||
|
|
||||||
|
// Schema sanity check — admin token table consumed by mosaic gateway config.
|
||||||
|
expect(tables).toContain('admin_tokens');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is idempotent — running twice does not error', async () => {
|
||||||
|
await runPgliteMigrations(handle);
|
||||||
|
await expect(runPgliteMigrations(handle)).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('surfaces statement-level error context on failure and leaves no ledger row', async () => {
|
||||||
|
// Pre-create a `users` table that conflicts with migration 0000's CREATE TABLE,
|
||||||
|
// forcing it to fail without IF NOT EXISTS.
|
||||||
|
const client = (handle.db as unknown as { $client: PgliteExec }).$client;
|
||||||
|
await client.exec('CREATE TABLE users (sentinel text)');
|
||||||
|
|
||||||
|
await expect(runPgliteMigrations(handle)).rejects.toThrow(
|
||||||
|
/migration hash=[a-f0-9]+ statement #\d+ failed/,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Ledger should be empty — partial application must not pretend to be complete.
|
||||||
|
const ledger = (await handle.db.execute(
|
||||||
|
sql`SELECT count(*)::int AS count FROM drizzle.__drizzle_migrations`,
|
||||||
|
)) as unknown as { rows: Array<{ count: number }> };
|
||||||
|
expect(ledger.rows[0]?.count).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,18 +1,109 @@
|
|||||||
import { dirname, resolve } from 'node:path';
|
import { dirname, resolve } from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
import { drizzle } from 'drizzle-orm/postgres-js';
|
import { sql } from 'drizzle-orm';
|
||||||
import { migrate } from 'drizzle-orm/postgres-js/migrator';
|
import { drizzle as drizzlePostgres } from 'drizzle-orm/postgres-js';
|
||||||
|
import { migrate as migratePostgres } from 'drizzle-orm/postgres-js/migrator';
|
||||||
|
import { readMigrationFiles } from 'drizzle-orm/migrator';
|
||||||
import postgres from 'postgres';
|
import postgres from 'postgres';
|
||||||
import { DEFAULT_DATABASE_URL } from './defaults.js';
|
import { DEFAULT_DATABASE_URL } from './defaults.js';
|
||||||
|
import type { DbHandle } from './client.js';
|
||||||
|
|
||||||
|
interface PgliteExecutor {
|
||||||
|
exec(query: string): Promise<unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ExecuteRows<T> {
|
||||||
|
rows: T[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function migrationsFolder(): string {
|
||||||
|
const here = dirname(fileURLToPath(import.meta.url));
|
||||||
|
return resolve(here, '../drizzle');
|
||||||
|
}
|
||||||
|
|
||||||
export async function runMigrations(url?: string): Promise<void> {
|
export async function runMigrations(url?: string): Promise<void> {
|
||||||
const connectionString = url ?? process.env['DATABASE_URL'] ?? DEFAULT_DATABASE_URL;
|
const connectionString = url ?? process.env['DATABASE_URL'] ?? DEFAULT_DATABASE_URL;
|
||||||
const sql = postgres(connectionString, { max: 1 });
|
const sqlClient = postgres(connectionString, { max: 1 });
|
||||||
const db = drizzle(sql);
|
const db = drizzlePostgres(sqlClient);
|
||||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
||||||
try {
|
try {
|
||||||
await migrate(db, { migrationsFolder: resolve(__dirname, '../drizzle') });
|
// TODO: postgres-tier first-install also fails because (a) Drizzle wraps every
|
||||||
|
// migration in one transaction (breaks 0009's ALTER TYPE ADD VALUE → SET DEFAULT
|
||||||
|
// sequence) and (b) drizzle/meta/_journal.json has 0009 ordered before 0008,
|
||||||
|
// which the postgres-js migrator skips by `created_at < folderMillis`. The
|
||||||
|
// PGlite path below sidesteps both. A follow-up should either share the
|
||||||
|
// per-statement loop (see runPgliteMigrations) or fix the journal ordering.
|
||||||
|
await migratePostgres(db, { migrationsFolder: migrationsFolder() });
|
||||||
} finally {
|
} finally {
|
||||||
await sql.end();
|
await sqlClient.end();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply Drizzle migrations against an embedded PGlite database.
|
||||||
|
//
|
||||||
|
// We don't reuse drizzle's pglite migrator because it wraps ALL migrations in
|
||||||
|
// one outer transaction, which breaks Postgres' `check_safe_enum_use` rule —
|
||||||
|
// e.g. migration 0009 does `ALTER TYPE ADD VALUE 'pending'` then references
|
||||||
|
// `'pending'` as a default in the same tx. PGlite's `exec()` runs each
|
||||||
|
// statement under the Simple Query protocol, autocommitting between them.
|
||||||
|
//
|
||||||
|
// We still write to the standard `drizzle.__drizzle_migrations` ledger so the
|
||||||
|
// result is interoperable with `runMigrations()` on a postgres-backed deploy
|
||||||
|
// (modulo the journal-ordering bug noted above).
|
||||||
|
//
|
||||||
|
// We skip-by-hash rather than skip-by-folderMillis (which is what Drizzle's
|
||||||
|
// postgres-js migrator does). That's deliberate — out-of-order timestamps in
|
||||||
|
// `_journal.json` won't silently drop migrations.
|
||||||
|
//
|
||||||
|
// Failure model: each statement autocommits, and the ledger row is written
|
||||||
|
// only after all statements in a migration succeed. A crash mid-migration
|
||||||
|
// leaves the prefix applied with no ledger entry, so the next boot will
|
||||||
|
// replay those statements and fail loudly on "already exists". Recovery:
|
||||||
|
// drop the partially-applied objects, or insert the migration's hash into
|
||||||
|
// `drizzle.__drizzle_migrations` manually. The error log identifies which
|
||||||
|
// statement of which migration was the culprit.
|
||||||
|
export async function runPgliteMigrations(handle: DbHandle): Promise<void> {
|
||||||
|
const client = (handle.db as unknown as { $client?: PgliteExecutor }).$client;
|
||||||
|
if (!client || typeof client.exec !== 'function') {
|
||||||
|
throw new Error('runPgliteMigrations: handle.db is not backed by a PGlite client');
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.exec('CREATE SCHEMA IF NOT EXISTS drizzle');
|
||||||
|
await client.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS drizzle.__drizzle_migrations (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
hash text NOT NULL,
|
||||||
|
created_at bigint
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
const appliedRows = (await handle.db.execute(
|
||||||
|
sql`SELECT hash FROM drizzle.__drizzle_migrations`,
|
||||||
|
)) as unknown as ExecuteRows<{ hash: string }>;
|
||||||
|
const applied = new Set(appliedRows.rows.map((r) => r.hash));
|
||||||
|
|
||||||
|
const migrations = readMigrationFiles({ migrationsFolder: migrationsFolder() });
|
||||||
|
for (const migration of migrations) {
|
||||||
|
if (applied.has(migration.hash)) continue;
|
||||||
|
|
||||||
|
// Run each statement-breakpoint chunk in its own exec() call so PGlite
|
||||||
|
// commits between statements — this is what lets `ALTER TYPE ADD VALUE`
|
||||||
|
// become visible before a subsequent statement references the new value.
|
||||||
|
for (const [stmtIdx, stmt] of migration.sql.entries()) {
|
||||||
|
const trimmed = stmt.trim();
|
||||||
|
if (!trimmed) continue;
|
||||||
|
try {
|
||||||
|
await client.exec(trimmed);
|
||||||
|
} catch (err) {
|
||||||
|
const cause = err instanceof Error ? err.message : String(err);
|
||||||
|
throw new Error(
|
||||||
|
`runPgliteMigrations: migration hash=${migration.hash} statement #${stmtIdx} failed: ${cause}\n` +
|
||||||
|
`Statement: ${trimmed.slice(0, 200)}${trimmed.length > 200 ? '…' : ''}`,
|
||||||
|
{ cause: err },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await handle.db.execute(
|
||||||
|
sql`INSERT INTO drizzle.__drizzle_migrations (hash, created_at) VALUES (${migration.hash}, ${migration.folderMillis})`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -74,6 +74,16 @@ get_repo_name() {
|
|||||||
echo "${repo_info##*/}"
|
echo "${repo_info##*/}"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get_repo_slug() {
|
||||||
|
get_repo_info
|
||||||
|
}
|
||||||
|
|
||||||
|
get_gitea_repo_args() {
|
||||||
|
local repo
|
||||||
|
repo=$(get_repo_slug) || return 1
|
||||||
|
printf -- '--repo %q --login %q' "$repo" "${GITEA_LOGIN:-mosaicstack}"
|
||||||
|
}
|
||||||
|
|
||||||
get_remote_host() {
|
get_remote_host() {
|
||||||
local remote_url
|
local remote_url
|
||||||
remote_url=$(git remote get-url origin 2>/dev/null || true)
|
remote_url=$(git remote get-url origin 2>/dev/null || true)
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ if [[ "$PLATFORM" == "github" ]]; then
|
|||||||
gh issue comment "$ISSUE_NUMBER" --body "$COMMENT"
|
gh issue comment "$ISSUE_NUMBER" --body "$COMMENT"
|
||||||
echo "Added comment to GitHub issue #$ISSUE_NUMBER"
|
echo "Added comment to GitHub issue #$ISSUE_NUMBER"
|
||||||
elif [[ "$PLATFORM" == "gitea" ]]; then
|
elif [[ "$PLATFORM" == "gitea" ]]; then
|
||||||
tea issue comment "$ISSUE_NUMBER" "$COMMENT"
|
tea issue comment "$ISSUE_NUMBER" "$COMMENT" $(get_gitea_repo_args)
|
||||||
echo "Added comment to Gitea issue #$ISSUE_NUMBER"
|
echo "Added comment to Gitea issue #$ISSUE_NUMBER"
|
||||||
else
|
else
|
||||||
echo "Error: Unknown platform"
|
echo "Error: Unknown platform"
|
||||||
|
|||||||
@@ -112,20 +112,22 @@ PLATFORM=$(detect_platform)
|
|||||||
|
|
||||||
case "$PLATFORM" in
|
case "$PLATFORM" in
|
||||||
github)
|
github)
|
||||||
CMD="gh issue create --title \"$TITLE\""
|
CMD=(gh issue create --title "$TITLE")
|
||||||
[[ -n "$BODY" ]] && CMD="$CMD --body \"$BODY\""
|
[[ -n "$BODY" ]] && CMD+=(--body "$BODY")
|
||||||
[[ -n "$LABELS" ]] && CMD="$CMD --label \"$LABELS\""
|
[[ -n "$LABELS" ]] && CMD+=(--label "$LABELS")
|
||||||
[[ -n "$MILESTONE" ]] && CMD="$CMD --milestone \"$MILESTONE\""
|
[[ -n "$MILESTONE" ]] && CMD+=(--milestone "$MILESTONE")
|
||||||
eval "$CMD"
|
"${CMD[@]}"
|
||||||
;;
|
;;
|
||||||
gitea)
|
gitea)
|
||||||
if command -v tea >/dev/null 2>&1; then
|
if command -v tea >/dev/null 2>&1; then
|
||||||
CMD="tea issue create --title \"$TITLE\""
|
REPO_SLUG=$(get_repo_slug)
|
||||||
[[ -n "$BODY" ]] && CMD="$CMD --description \"$BODY\""
|
REPO_ARGS=(--repo "$REPO_SLUG" --login "${GITEA_LOGIN:-mosaicstack}")
|
||||||
[[ -n "$LABELS" ]] && CMD="$CMD --labels \"$LABELS\""
|
CMD=(tea issue create "${REPO_ARGS[@]}" --title "$TITLE")
|
||||||
|
[[ -n "$BODY" ]] && CMD+=(--description "$BODY")
|
||||||
|
[[ -n "$LABELS" ]] && CMD+=(--labels "$LABELS")
|
||||||
# tea accepts milestone by name directly (verified 2026-02-05)
|
# tea accepts milestone by name directly (verified 2026-02-05)
|
||||||
[[ -n "$MILESTONE" ]] && CMD="$CMD --milestone \"$MILESTONE\""
|
[[ -n "$MILESTONE" ]] && CMD+=(--milestone "$MILESTONE")
|
||||||
if eval "$CMD"; then
|
if "${CMD[@]}"; then
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
echo "Warning: tea issue create failed, trying Gitea API fallback..." >&2
|
echo "Warning: tea issue create failed, trying Gitea API fallback..." >&2
|
||||||
|
|||||||
@@ -80,7 +80,8 @@ case "$PLATFORM" in
|
|||||||
eval "$CMD"
|
eval "$CMD"
|
||||||
;;
|
;;
|
||||||
gitea)
|
gitea)
|
||||||
CMD="tea issues list --state $STATE --limit $LIMIT"
|
REPO_ARGS=$(get_gitea_repo_args)
|
||||||
|
CMD="tea issues list $REPO_ARGS --state $STATE --limit $LIMIT"
|
||||||
[[ -n "$LABEL" ]] && CMD="$CMD --labels \"$LABEL\""
|
[[ -n "$LABEL" ]] && CMD="$CMD --labels \"$LABEL\""
|
||||||
[[ -n "$MILESTONE" ]] && CMD="$CMD --milestones \"$MILESTONE\""
|
[[ -n "$MILESTONE" ]] && CMD="$CMD --milestones \"$MILESTONE\""
|
||||||
# Note: tea may not support assignee filter directly
|
# Note: tea may not support assignee filter directly
|
||||||
|
|||||||
@@ -52,9 +52,9 @@ if [[ "$PLATFORM" == "github" ]]; then
|
|||||||
echo "Reopened GitHub issue #$ISSUE_NUMBER"
|
echo "Reopened GitHub issue #$ISSUE_NUMBER"
|
||||||
elif [[ "$PLATFORM" == "gitea" ]]; then
|
elif [[ "$PLATFORM" == "gitea" ]]; then
|
||||||
if [[ -n "$COMMENT" ]]; then
|
if [[ -n "$COMMENT" ]]; then
|
||||||
tea issue comment "$ISSUE_NUMBER" "$COMMENT"
|
tea issue comment "$ISSUE_NUMBER" "$COMMENT" $(get_gitea_repo_args)
|
||||||
fi
|
fi
|
||||||
tea issue reopen "$ISSUE_NUMBER"
|
tea issue reopen "$ISSUE_NUMBER" $(get_gitea_repo_args)
|
||||||
echo "Reopened Gitea issue #$ISSUE_NUMBER"
|
echo "Reopened Gitea issue #$ISSUE_NUMBER"
|
||||||
else
|
else
|
||||||
echo "Error: Unknown platform"
|
echo "Error: Unknown platform"
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ if [[ "$PLATFORM" == "github" ]]; then
|
|||||||
gh issue view "$ISSUE_NUMBER"
|
gh issue view "$ISSUE_NUMBER"
|
||||||
elif [[ "$PLATFORM" == "gitea" ]]; then
|
elif [[ "$PLATFORM" == "gitea" ]]; then
|
||||||
if command -v tea >/dev/null 2>&1; then
|
if command -v tea >/dev/null 2>&1; then
|
||||||
if tea issue "$ISSUE_NUMBER"; then
|
if tea issue "$ISSUE_NUMBER" $(get_gitea_repo_args); then
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
echo "Warning: tea issue view failed, trying Gitea API fallback..." >&2
|
echo "Warning: tea issue view failed, trying Gitea API fallback..." >&2
|
||||||
|
|||||||
@@ -52,9 +52,9 @@ if [[ "$PLATFORM" == "github" ]]; then
|
|||||||
echo "Closed GitHub PR #$PR_NUMBER"
|
echo "Closed GitHub PR #$PR_NUMBER"
|
||||||
elif [[ "$PLATFORM" == "gitea" ]]; then
|
elif [[ "$PLATFORM" == "gitea" ]]; then
|
||||||
if [[ -n "$COMMENT" ]]; then
|
if [[ -n "$COMMENT" ]]; then
|
||||||
tea pr comment "$PR_NUMBER" "$COMMENT"
|
tea pr comment "$PR_NUMBER" "$COMMENT" $(get_gitea_repo_args)
|
||||||
fi
|
fi
|
||||||
tea pr close "$PR_NUMBER"
|
tea pr close "$PR_NUMBER" $(get_gitea_repo_args)
|
||||||
echo "Closed Gitea PR #$PR_NUMBER"
|
echo "Closed Gitea PR #$PR_NUMBER"
|
||||||
else
|
else
|
||||||
echo "Error: Unknown platform"
|
echo "Error: Unknown platform"
|
||||||
|
|||||||
@@ -17,6 +17,51 @@ MILESTONE=""
|
|||||||
DRAFT=false
|
DRAFT=false
|
||||||
ISSUE=""
|
ISSUE=""
|
||||||
|
|
||||||
|
# get_remote_host, get_gitea_token, get_repo_info, and get_gitea_repo_args are provided by detect-platform.sh
|
||||||
|
|
||||||
|
gitea_pr_create_api() {
|
||||||
|
local host repo token url payload
|
||||||
|
host=$(get_remote_host) || {
|
||||||
|
echo "Error: could not determine remote host for API fallback" >&2
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
repo=$(get_repo_info) || {
|
||||||
|
echo "Error: could not determine repo owner/name for API fallback" >&2
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
token=$(get_gitea_token "$host") || {
|
||||||
|
echo "Error: Gitea token not found for API fallback (set GITEA_TOKEN or configure ~/.git-credentials)" >&2
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
if [[ -n "$LABELS" || -n "$MILESTONE" || "$DRAFT" == true ]]; then
|
||||||
|
echo "Warning: API fallback applies title/body/head/base only; labels/milestone/draft require authenticated tea setup." >&2
|
||||||
|
fi
|
||||||
|
|
||||||
|
payload=$(TITLE="$TITLE" BODY="$BODY" HEAD_BRANCH="$HEAD_BRANCH" BASE_BRANCH="$BASE_BRANCH" python3 - <<'PY'
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"title": os.environ["TITLE"],
|
||||||
|
"head": os.environ["HEAD_BRANCH"],
|
||||||
|
"base": os.environ["BASE_BRANCH"] or "main",
|
||||||
|
}
|
||||||
|
body = os.environ.get("BODY", "")
|
||||||
|
if body:
|
||||||
|
payload["body"] = body
|
||||||
|
print(json.dumps(payload))
|
||||||
|
PY
|
||||||
|
)
|
||||||
|
|
||||||
|
url="https://${host}/api/v1/repos/${repo}/pulls"
|
||||||
|
curl -fsS -X POST \
|
||||||
|
-H "Authorization: token ${token}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "$payload" \
|
||||||
|
"$url"
|
||||||
|
}
|
||||||
|
|
||||||
usage() {
|
usage() {
|
||||||
cat <<EOF
|
cat <<EOF
|
||||||
Usage: $(basename "$0") [OPTIONS]
|
Usage: $(basename "$0") [OPTIONS]
|
||||||
@@ -118,33 +163,37 @@ PLATFORM=$(detect_platform)
|
|||||||
|
|
||||||
case "$PLATFORM" in
|
case "$PLATFORM" in
|
||||||
github)
|
github)
|
||||||
CMD="gh pr create --title \"$TITLE\""
|
CMD=(gh pr create --title "$TITLE")
|
||||||
[[ -n "$BODY" ]] && CMD="$CMD --body \"$BODY\""
|
[[ -n "$BODY" ]] && CMD+=(--body "$BODY")
|
||||||
[[ -n "$BASE_BRANCH" ]] && CMD="$CMD --base \"$BASE_BRANCH\""
|
[[ -n "$BASE_BRANCH" ]] && CMD+=(--base "$BASE_BRANCH")
|
||||||
[[ -n "$HEAD_BRANCH" ]] && CMD="$CMD --head \"$HEAD_BRANCH\""
|
[[ -n "$HEAD_BRANCH" ]] && CMD+=(--head "$HEAD_BRANCH")
|
||||||
[[ -n "$LABELS" ]] && CMD="$CMD --label \"$LABELS\""
|
[[ -n "$LABELS" ]] && CMD+=(--label "$LABELS")
|
||||||
[[ -n "$MILESTONE" ]] && CMD="$CMD --milestone \"$MILESTONE\""
|
[[ -n "$MILESTONE" ]] && CMD+=(--milestone "$MILESTONE")
|
||||||
[[ "$DRAFT" == true ]] && CMD="$CMD --draft"
|
[[ "$DRAFT" == true ]] && CMD+=(--draft)
|
||||||
eval "$CMD"
|
"${CMD[@]}"
|
||||||
;;
|
;;
|
||||||
gitea)
|
gitea)
|
||||||
# tea pull create syntax
|
# tea pull create syntax. Always pass --repo because tea repo inference
|
||||||
CMD="tea pr create --title \"$TITLE\""
|
# is unreliable in Mosaic worktrees/profile shells. Use arrays instead
|
||||||
[[ -n "$BODY" ]] && CMD="$CMD --description \"$BODY\""
|
# of eval so markdown backticks/body content are not shell-executed.
|
||||||
[[ -n "$BASE_BRANCH" ]] && CMD="$CMD --base \"$BASE_BRANCH\""
|
REPO_SLUG=$(get_repo_slug)
|
||||||
[[ -n "$HEAD_BRANCH" ]] && CMD="$CMD --head \"$HEAD_BRANCH\""
|
REPO_ARGS=(--repo "$REPO_SLUG" --login "${GITEA_LOGIN:-mosaicstack}")
|
||||||
|
CMD=(tea pr create "${REPO_ARGS[@]}" --title "$TITLE")
|
||||||
|
[[ -n "$BODY" ]] && CMD+=(--description "$BODY")
|
||||||
|
[[ -n "$BASE_BRANCH" ]] && CMD+=(--base "$BASE_BRANCH")
|
||||||
|
[[ -n "$HEAD_BRANCH" ]] && CMD+=(--head "$HEAD_BRANCH")
|
||||||
|
|
||||||
# Handle labels for tea
|
# Handle labels for tea
|
||||||
if [[ -n "$LABELS" ]]; then
|
if [[ -n "$LABELS" ]]; then
|
||||||
# tea may use --labels flag
|
# tea may use --labels flag
|
||||||
CMD="$CMD --labels \"$LABELS\""
|
CMD+=(--labels "$LABELS")
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Handle milestone for tea
|
# Handle milestone for tea
|
||||||
if [[ -n "$MILESTONE" ]]; then
|
if [[ -n "$MILESTONE" ]]; then
|
||||||
MILESTONE_ID=$(tea milestones list 2>/dev/null | grep -E "^\s*[0-9]+" | grep "$MILESTONE" | awk '{print $1}' | head -1)
|
MILESTONE_ID=$(tea milestones list "${REPO_ARGS[@]}" 2>/dev/null | grep -E "^\s*[0-9]+" | grep "$MILESTONE" | awk '{print $1}' | head -1)
|
||||||
if [[ -n "$MILESTONE_ID" ]]; then
|
if [[ -n "$MILESTONE_ID" ]]; then
|
||||||
CMD="$CMD --milestone $MILESTONE_ID"
|
CMD+=(--milestone "$MILESTONE_ID")
|
||||||
else
|
else
|
||||||
echo "Warning: Could not find milestone '$MILESTONE', creating without milestone" >&2
|
echo "Warning: Could not find milestone '$MILESTONE', creating without milestone" >&2
|
||||||
fi
|
fi
|
||||||
@@ -155,7 +204,11 @@ case "$PLATFORM" in
|
|||||||
echo "Note: Draft PR may not be supported by your tea version" >&2
|
echo "Note: Draft PR may not be supported by your tea version" >&2
|
||||||
fi
|
fi
|
||||||
|
|
||||||
eval "$CMD"
|
if "${CMD[@]}"; then
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
echo "Warning: tea pr create failed, trying Gitea API fallback..." >&2
|
||||||
|
gitea_pr_create_api
|
||||||
;;
|
;;
|
||||||
*)
|
*)
|
||||||
echo "Error: Could not detect git platform" >&2
|
echo "Error: Could not detect git platform" >&2
|
||||||
|
|||||||
@@ -74,7 +74,8 @@ case "$PLATFORM" in
|
|||||||
;;
|
;;
|
||||||
gitea)
|
gitea)
|
||||||
# tea pr list - note: tea uses 'pulls' subcommand in some versions
|
# tea pr list - note: tea uses 'pulls' subcommand in some versions
|
||||||
CMD="tea pr list --state $STATE --limit $LIMIT"
|
REPO_ARGS=$(get_gitea_repo_args)
|
||||||
|
CMD="tea pr list $REPO_ARGS --state $STATE --limit $LIMIT"
|
||||||
|
|
||||||
# tea filtering may be limited
|
# tea filtering may be limited
|
||||||
if [[ -n "$LABEL" ]]; then
|
if [[ -n "$LABEL" ]]; then
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ if [[ "$PLATFORM" == "github" ]]; then
|
|||||||
elif [[ "$PLATFORM" == "gitea" ]]; then
|
elif [[ "$PLATFORM" == "gitea" ]]; then
|
||||||
case $ACTION in
|
case $ACTION in
|
||||||
approve)
|
approve)
|
||||||
tea pr approve "$PR_NUMBER" ${COMMENT:+--comment "$COMMENT"}
|
tea pr approve "$PR_NUMBER" $(get_gitea_repo_args) ${COMMENT:+--comment "$COMMENT"}
|
||||||
echo "Approved Gitea PR #$PR_NUMBER"
|
echo "Approved Gitea PR #$PR_NUMBER"
|
||||||
;;
|
;;
|
||||||
request-changes)
|
request-changes)
|
||||||
@@ -93,7 +93,7 @@ elif [[ "$PLATFORM" == "gitea" ]]; then
|
|||||||
echo "Error: Comment required for request-changes"
|
echo "Error: Comment required for request-changes"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
tea pr reject "$PR_NUMBER" --comment "$COMMENT"
|
tea pr reject "$PR_NUMBER" $(get_gitea_repo_args) --comment "$COMMENT"
|
||||||
echo "Requested changes on Gitea PR #$PR_NUMBER"
|
echo "Requested changes on Gitea PR #$PR_NUMBER"
|
||||||
;;
|
;;
|
||||||
comment)
|
comment)
|
||||||
@@ -101,7 +101,7 @@ elif [[ "$PLATFORM" == "gitea" ]]; then
|
|||||||
echo "Error: Comment required"
|
echo "Error: Comment required"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
tea pr comment "$PR_NUMBER" "$COMMENT"
|
tea pr comment "$PR_NUMBER" "$COMMENT" $(get_gitea_repo_args)
|
||||||
echo "Added comment to Gitea PR #$PR_NUMBER"
|
echo "Added comment to Gitea PR #$PR_NUMBER"
|
||||||
;;
|
;;
|
||||||
*)
|
*)
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ detect_platform
|
|||||||
if [[ "$PLATFORM" == "github" ]]; then
|
if [[ "$PLATFORM" == "github" ]]; then
|
||||||
gh pr view "$PR_NUMBER"
|
gh pr view "$PR_NUMBER"
|
||||||
elif [[ "$PLATFORM" == "gitea" ]]; then
|
elif [[ "$PLATFORM" == "gitea" ]]; then
|
||||||
tea pr "$PR_NUMBER"
|
tea pr "$PR_NUMBER" $(get_gitea_repo_args)
|
||||||
else
|
else
|
||||||
echo "Error: Unknown platform"
|
echo "Error: Unknown platform"
|
||||||
exit 1
|
exit 1
|
||||||
|
|||||||
@@ -16,8 +16,15 @@ import fs from 'node:fs/promises';
|
|||||||
import os from 'node:os';
|
import os from 'node:os';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
|
|
||||||
import { users, teams, teamMembers, conversations, messages } from '@mosaicstack/db';
|
import {
|
||||||
import { createPgliteDbWithVector, runPgliteMigrations } from './test-utils/pglite-with-vector.js';
|
users,
|
||||||
|
teams,
|
||||||
|
teamMembers,
|
||||||
|
conversations,
|
||||||
|
messages,
|
||||||
|
createPgliteDb,
|
||||||
|
runPgliteMigrations,
|
||||||
|
} from '@mosaicstack/db';
|
||||||
|
|
||||||
import postgres from 'postgres';
|
import postgres from 'postgres';
|
||||||
import { afterAll, describe, expect, it } from 'vitest';
|
import { afterAll, describe, expect, it } from 'vitest';
|
||||||
@@ -102,11 +109,8 @@ describe.skipIf(!run)('migrate-tier — PGlite → federated PG', () => {
|
|||||||
/* ---- 1. Create a temp PGlite db ---------------------------------- */
|
/* ---- 1. Create a temp PGlite db ---------------------------------- */
|
||||||
|
|
||||||
pgliteDataDir = await fs.mkdtemp(path.join(os.tmpdir(), 'fed-m1-08-'));
|
pgliteDataDir = await fs.mkdtemp(path.join(os.tmpdir(), 'fed-m1-08-'));
|
||||||
const handle = createPgliteDbWithVector(pgliteDataDir);
|
const handle = createPgliteDb(pgliteDataDir);
|
||||||
|
await runPgliteMigrations(handle);
|
||||||
// Run Drizzle migrations against PGlite.
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
await runPgliteMigrations(handle.db as any);
|
|
||||||
|
|
||||||
/* ---- 2. Seed representative data --------------------------------- */
|
/* ---- 2. Seed representative data --------------------------------- */
|
||||||
|
|
||||||
|
|||||||
@@ -1,52 +0,0 @@
|
|||||||
/**
|
|
||||||
* Test-only helpers for creating a PGlite database with the pgvector extension
|
|
||||||
* and running Drizzle migrations against it.
|
|
||||||
*
|
|
||||||
* These are intentionally NOT exported from @mosaicstack/db to avoid pulling
|
|
||||||
* the WASM vector bundle into the public API surface.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { createRequire } from 'node:module';
|
|
||||||
import { dirname, resolve } from 'node:path';
|
|
||||||
|
|
||||||
import { PGlite } from '@electric-sql/pglite';
|
|
||||||
import { vector } from '@electric-sql/pglite/vector';
|
|
||||||
import { drizzle } from 'drizzle-orm/pglite';
|
|
||||||
import { migrate as migratePglite } from 'drizzle-orm/pglite/migrator';
|
|
||||||
import type { PgliteDatabase } from 'drizzle-orm/pglite';
|
|
||||||
import * as schema from '@mosaicstack/db';
|
|
||||||
import type { DbHandle } from '@mosaicstack/db';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a PGlite DB handle with the pgvector extension loaded.
|
|
||||||
* Required for running Drizzle migrations that include `CREATE EXTENSION vector`.
|
|
||||||
*/
|
|
||||||
export function createPgliteDbWithVector(dataDir: string): DbHandle {
|
|
||||||
const client = new PGlite(dataDir, { extensions: { vector } });
|
|
||||||
const db = drizzle(client, { schema });
|
|
||||||
return {
|
|
||||||
db: db as unknown as DbHandle['db'],
|
|
||||||
close: async () => {
|
|
||||||
await client.close();
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Run Drizzle migrations against an already-open PGlite database handle.
|
|
||||||
* Resolves the migrations folder from @mosaicstack/db's installed location.
|
|
||||||
*
|
|
||||||
* @param db A PgliteDatabase instance (from drizzle-orm/pglite).
|
|
||||||
*/
|
|
||||||
export async function runPgliteMigrations(
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
db: PgliteDatabase<any>,
|
|
||||||
): Promise<void> {
|
|
||||||
// Resolve @mosaicstack/db package root to locate its drizzle migrations folder.
|
|
||||||
const _require = createRequire(import.meta.url);
|
|
||||||
const dbPkgMain = _require.resolve('@mosaicstack/db');
|
|
||||||
// dbPkgMain → …/packages/db/dist/index.js → dirname = dist/
|
|
||||||
// go up one level from dist/ to find the sibling drizzle/ folder
|
|
||||||
const migrationsFolder = resolve(dirname(dbPkgMain), '../drizzle');
|
|
||||||
await migratePglite(db, { migrationsFolder });
|
|
||||||
}
|
|
||||||
64
pnpm-lock.yaml
generated
64
pnpm-lock.yaml
generated
@@ -179,6 +179,9 @@ importers:
|
|||||||
socket.io:
|
socket.io:
|
||||||
specifier: ^4.8.0
|
specifier: ^4.8.0
|
||||||
version: 4.8.3
|
version: 4.8.3
|
||||||
|
undici:
|
||||||
|
specifier: ^7.24.6
|
||||||
|
version: 7.24.6
|
||||||
uuid:
|
uuid:
|
||||||
specifier: ^11.0.0
|
specifier: ^11.0.0
|
||||||
version: 11.1.0
|
version: 11.1.0
|
||||||
@@ -713,10 +716,10 @@ importers:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@mariozechner/pi-agent-core':
|
'@mariozechner/pi-agent-core':
|
||||||
specifier: ^0.63.1
|
specifier: ^0.63.1
|
||||||
version: 0.63.2(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@3.25.76)
|
version: 0.63.2(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6)
|
||||||
'@mariozechner/pi-ai':
|
'@mariozechner/pi-ai':
|
||||||
specifier: ^0.63.1
|
specifier: ^0.63.1
|
||||||
version: 0.63.2(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@3.25.76)
|
version: 0.63.2(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6)
|
||||||
'@sinclair/typebox':
|
'@sinclair/typebox':
|
||||||
specifier: ^0.34.41
|
specifier: ^0.34.41
|
||||||
version: 0.34.48
|
version: 0.34.48
|
||||||
@@ -6993,10 +6996,6 @@ packages:
|
|||||||
resolution: {integrity: sha512-gBLkYIlEnSp8pFbT64yFgGE6UIB9tAkhukC23PmMDCe5Nd+cRqKxSjw5y54MK2AZMgZfJWMaNE4nYUHgi1XEOw==}
|
resolution: {integrity: sha512-gBLkYIlEnSp8pFbT64yFgGE6UIB9tAkhukC23PmMDCe5Nd+cRqKxSjw5y54MK2AZMgZfJWMaNE4nYUHgi1XEOw==}
|
||||||
engines: {node: '>=18.17'}
|
engines: {node: '>=18.17'}
|
||||||
|
|
||||||
undici@7.24.3:
|
|
||||||
resolution: {integrity: sha512-eJdUmK/Wrx2d+mnWWmwwLRyA7OQCkLap60sk3dOK4ViZR7DKwwptwuIvFBg2HaiP9ESaEdhtpSymQPvytpmkCA==}
|
|
||||||
engines: {node: '>=20.18.1'}
|
|
||||||
|
|
||||||
undici@7.24.6:
|
undici@7.24.6:
|
||||||
resolution: {integrity: sha512-Xi4agocCbRzt0yYMZGMA6ApD7gvtUFaxm4ZmeacWI4cZxaF6C+8I8QfofC20NAePiB/IcvZmzkJ7XPa471AEtA==}
|
resolution: {integrity: sha512-Xi4agocCbRzt0yYMZGMA6ApD7gvtUFaxm4ZmeacWI4cZxaF6C+8I8QfofC20NAePiB/IcvZmzkJ7XPa471AEtA==}
|
||||||
engines: {node: '>=20.18.1'}
|
engines: {node: '>=20.18.1'}
|
||||||
@@ -7329,12 +7328,6 @@ snapshots:
|
|||||||
'@jridgewell/gen-mapping': 0.3.13
|
'@jridgewell/gen-mapping': 0.3.13
|
||||||
'@jridgewell/trace-mapping': 0.3.31
|
'@jridgewell/trace-mapping': 0.3.31
|
||||||
|
|
||||||
'@anthropic-ai/sdk@0.73.0(zod@3.25.76)':
|
|
||||||
dependencies:
|
|
||||||
json-schema-to-ts: 3.1.1
|
|
||||||
optionalDependencies:
|
|
||||||
zod: 3.25.76
|
|
||||||
|
|
||||||
'@anthropic-ai/sdk@0.73.0(zod@4.3.6)':
|
'@anthropic-ai/sdk@0.73.0(zod@4.3.6)':
|
||||||
dependencies:
|
dependencies:
|
||||||
json-schema-to-ts: 3.1.1
|
json-schema-to-ts: 3.1.1
|
||||||
@@ -8676,18 +8669,6 @@ snapshots:
|
|||||||
- ws
|
- ws
|
||||||
- zod
|
- zod
|
||||||
|
|
||||||
'@mariozechner/pi-agent-core@0.63.2(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@3.25.76)':
|
|
||||||
dependencies:
|
|
||||||
'@mariozechner/pi-ai': 0.63.2(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@3.25.76)
|
|
||||||
transitivePeerDependencies:
|
|
||||||
- '@modelcontextprotocol/sdk'
|
|
||||||
- aws-crt
|
|
||||||
- bufferutil
|
|
||||||
- supports-color
|
|
||||||
- utf-8-validate
|
|
||||||
- ws
|
|
||||||
- zod
|
|
||||||
|
|
||||||
'@mariozechner/pi-agent-core@0.63.2(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6)':
|
'@mariozechner/pi-agent-core@0.63.2(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@mariozechner/pi-ai': 0.63.2(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6)
|
'@mariozechner/pi-ai': 0.63.2(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6)
|
||||||
@@ -8736,30 +8717,6 @@ snapshots:
|
|||||||
- ws
|
- ws
|
||||||
- zod
|
- zod
|
||||||
|
|
||||||
'@mariozechner/pi-ai@0.63.2(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@3.25.76)':
|
|
||||||
dependencies:
|
|
||||||
'@anthropic-ai/sdk': 0.73.0(zod@3.25.76)
|
|
||||||
'@aws-sdk/client-bedrock-runtime': 3.1008.0
|
|
||||||
'@google/genai': 1.45.0(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))
|
|
||||||
'@mistralai/mistralai': 1.14.1
|
|
||||||
'@sinclair/typebox': 0.34.48
|
|
||||||
ajv: 8.18.0
|
|
||||||
ajv-formats: 3.0.1(ajv@8.18.0)
|
|
||||||
chalk: 5.6.2
|
|
||||||
openai: 6.26.0(ws@8.20.0)(zod@3.25.76)
|
|
||||||
partial-json: 0.1.7
|
|
||||||
proxy-agent: 6.5.0
|
|
||||||
undici: 7.24.3
|
|
||||||
zod-to-json-schema: 3.25.1(zod@3.25.76)
|
|
||||||
transitivePeerDependencies:
|
|
||||||
- '@modelcontextprotocol/sdk'
|
|
||||||
- aws-crt
|
|
||||||
- bufferutil
|
|
||||||
- supports-color
|
|
||||||
- utf-8-validate
|
|
||||||
- ws
|
|
||||||
- zod
|
|
||||||
|
|
||||||
'@mariozechner/pi-ai@0.63.2(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6)':
|
'@mariozechner/pi-ai@0.63.2(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@anthropic-ai/sdk': 0.73.0(zod@4.3.6)
|
'@anthropic-ai/sdk': 0.73.0(zod@4.3.6)
|
||||||
@@ -8773,7 +8730,7 @@ snapshots:
|
|||||||
openai: 6.26.0(ws@8.20.0)(zod@4.3.6)
|
openai: 6.26.0(ws@8.20.0)(zod@4.3.6)
|
||||||
partial-json: 0.1.7
|
partial-json: 0.1.7
|
||||||
proxy-agent: 6.5.0
|
proxy-agent: 6.5.0
|
||||||
undici: 7.24.3
|
undici: 7.24.6
|
||||||
zod-to-json-schema: 3.25.1(zod@4.3.6)
|
zod-to-json-schema: 3.25.1(zod@4.3.6)
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- '@modelcontextprotocol/sdk'
|
- '@modelcontextprotocol/sdk'
|
||||||
@@ -12632,7 +12589,7 @@ snapshots:
|
|||||||
saxes: 6.0.0
|
saxes: 6.0.0
|
||||||
symbol-tree: 3.2.4
|
symbol-tree: 3.2.4
|
||||||
tough-cookie: 6.0.1
|
tough-cookie: 6.0.1
|
||||||
undici: 7.24.3
|
undici: 7.24.6
|
||||||
w3c-xmlserializer: 5.0.0
|
w3c-xmlserializer: 5.0.0
|
||||||
webidl-conversions: 8.0.1
|
webidl-conversions: 8.0.1
|
||||||
whatwg-mimetype: 5.0.0
|
whatwg-mimetype: 5.0.0
|
||||||
@@ -13352,11 +13309,6 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
mimic-function: 5.0.1
|
mimic-function: 5.0.1
|
||||||
|
|
||||||
openai@6.26.0(ws@8.20.0)(zod@3.25.76):
|
|
||||||
optionalDependencies:
|
|
||||||
ws: 8.20.0
|
|
||||||
zod: 3.25.76
|
|
||||||
|
|
||||||
openai@6.26.0(ws@8.20.0)(zod@4.3.6):
|
openai@6.26.0(ws@8.20.0)(zod@4.3.6):
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
ws: 8.20.0
|
ws: 8.20.0
|
||||||
@@ -14488,8 +14440,6 @@ snapshots:
|
|||||||
|
|
||||||
undici@6.21.3: {}
|
undici@6.21.3: {}
|
||||||
|
|
||||||
undici@7.24.3: {}
|
|
||||||
|
|
||||||
undici@7.24.6: {}
|
undici@7.24.6: {}
|
||||||
|
|
||||||
unhomoglyph@1.0.6: {}
|
unhomoglyph@1.0.6: {}
|
||||||
|
|||||||
125
scratchpads/fix-db-bootstrap-migrations.md
Normal file
125
scratchpads/fix-db-bootstrap-migrations.md
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
# fix(db): bootstrap migrations on local-tier gateway startup
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
Fresh `mosaic gateway install` (npm-installed) leaves the gateway DB schema empty:
|
||||||
|
|
||||||
|
```
|
||||||
|
relation "users" does not exist
|
||||||
|
```
|
||||||
|
|
||||||
|
Sign-in 500s, `auth users create` says "Not signed in", `admin/bootstrap setup`
|
||||||
|
also fails — every entry point queries `users` before doing anything else.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
This PR fixes the **local (PGlite) tier** end-to-end. The postgres-tier path
|
||||||
|
has additional pre-existing bugs (see "Known issues, out of scope" below) and
|
||||||
|
needs a separate change with real Postgres validation.
|
||||||
|
|
||||||
|
## Root causes addressed (5 stacked bugs on the local-tier path)
|
||||||
|
|
||||||
|
1. **`packages/db/package.json` `files: ["dist"]`** — the `drizzle/` SQL
|
||||||
|
migrations folder is excluded from the published tarball. Even if a
|
||||||
|
migrate runner existed, it would have nothing to apply.
|
||||||
|
|
||||||
|
2. **`packages/db/src/migrate.ts`** only supports `postgres-js`. Local-tier
|
||||||
|
gateways use embedded PGlite, which can't be reached over a postgres wire
|
||||||
|
protocol — so `runMigrations()` is unusable for the local tier.
|
||||||
|
|
||||||
|
3. **`apps/gateway/src/database/database.module.ts`** never invokes
|
||||||
|
migrations at startup. The module creates the DB handle and storage
|
||||||
|
adapter, but no consumer calls `.migrate()` on either. `mosaic storage
|
||||||
|
migrate` CLI even claims "pglite runs schema setup automatically on first
|
||||||
|
connection via `adapter.migrate()`" — but `adapter.migrate()` is only
|
||||||
|
called by tests, never at runtime.
|
||||||
|
|
||||||
|
4. **`createPgliteDb` does not load the pgvector extension.** Migration 0001
|
||||||
|
declares `CREATE EXTENSION IF NOT EXISTS vector;` for the
|
||||||
|
`insights.embedding` column. Bare PGlite has no pgvector — the migration
|
||||||
|
fails on extension control file lookup.
|
||||||
|
|
||||||
|
5. **Drizzle's PG migrator wraps every migration in one outer transaction.**
|
||||||
|
Migration 0009 does `ALTER TYPE grant_status ADD VALUE 'pending'` and then
|
||||||
|
`ALTER TABLE federation_grants ALTER COLUMN status SET DEFAULT 'pending'`.
|
||||||
|
Postgres' `check_safe_enum_use` rejects the second statement because the
|
||||||
|
new enum value isn't committed yet. Splitting the migration into two
|
||||||
|
files doesn't help — drizzle batches all migrations into one outer tx.
|
||||||
|
|
||||||
|
## Fix
|
||||||
|
|
||||||
|
- `packages/db/package.json` — ship `drizzle/` in `files`.
|
||||||
|
- `packages/db/src/client-pglite.ts` — load `@electric-sql/pglite/vector`.
|
||||||
|
- `packages/db/src/migrate.ts` — add `runPgliteMigrations(handle)`. Walks the
|
||||||
|
Drizzle journal and runs each statement-breakpoint chunk through PGlite's
|
||||||
|
`client.exec()` (Simple Query protocol → autocommit per statement). Writes
|
||||||
|
to the standard `drizzle.__drizzle_migrations` ledger so the result is
|
||||||
|
interoperable with `runMigrations()` on a postgres-backed deployment.
|
||||||
|
Per-statement try/catch surfaces which statement of which migration failed
|
||||||
|
and the ledger row is only written on full success.
|
||||||
|
- `packages/db/src/index.ts` — re-export.
|
||||||
|
- `apps/gateway/src/database/database.module.ts` — implement `OnModuleInit`:
|
||||||
|
- Local tier → `runPgliteMigrations(handle)`, then `storageAdapter.migrate()`
|
||||||
|
(the local storage adapter has its own kv tables in a separate PGlite dir).
|
||||||
|
- Postgres tier → `storageAdapter.migrate()` only, since
|
||||||
|
`PostgresAdapter.migrate()` already calls `runMigrations(url)` against
|
||||||
|
the same DATABASE_URL — we deliberately don't double-call.
|
||||||
|
|
||||||
|
NestJS awaits `onModuleInit` before `app.listen()`, so DB-dependent modules
|
||||||
|
see a populated schema before any HTTP traffic is accepted.
|
||||||
|
|
||||||
|
- `packages/storage/src/test-utils/pglite-with-vector.ts` — **deleted**.
|
||||||
|
The "intentionally not exported" rationale is moot now that migration 0001
|
||||||
|
forces pgvector load anyway. `migrate-tier.integration.test.ts` switched
|
||||||
|
to `createPgliteDb` + `runPgliteMigrations` from `@mosaicstack/db`.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
`packages/db/src/migrate.test.ts`:
|
||||||
|
|
||||||
|
- Verifies `runPgliteMigrations` creates the BetterAuth tables (the original
|
||||||
|
failure mode).
|
||||||
|
- Idempotence (transitively re-runs migration 0009).
|
||||||
|
- Partial-failure: pre-creates a conflicting `users` table, asserts the
|
||||||
|
thrown error includes statement context (`hash=… statement #N failed`)
|
||||||
|
and that no ledger row was written.
|
||||||
|
|
||||||
|
## QA evidence
|
||||||
|
|
||||||
|
End-to-end on a fresh PGlite install:
|
||||||
|
|
||||||
|
- `[DatabaseModule] Applying PGlite schema migrations...` then
|
||||||
|
`Initializing storage adapter (pglite)...` in startup log.
|
||||||
|
- `GET /api/bootstrap/status` → `{"needsSetup":true}` HTTP 200 (was 500
|
||||||
|
with `relation "users" does not exist`).
|
||||||
|
- `POST /api/bootstrap/setup` with empty body → HTTP 400 with Zod
|
||||||
|
validation error (was 500), confirming the request reached the
|
||||||
|
validator past the table-existence check.
|
||||||
|
|
||||||
|
## Known issues, out of scope (file separately)
|
||||||
|
|
||||||
|
- **Postgres-tier first install is still broken.** `runMigrations()` uses
|
||||||
|
Drizzle's `migratePostgres`, which has the same outer-transaction problem
|
||||||
|
as PGlite's migrator. A fresh standalone-tier install would also fail at
|
||||||
|
migration 0009. Inline TODO in `migrate.ts:31-35` flags this. Fixing it
|
||||||
|
needs either (a) a shared per-statement loop reused for both drivers, or
|
||||||
|
(b) splitting migration 0009.
|
||||||
|
- **`drizzle/meta/_journal.json` has 0009 ordered before 0008** (`when`
|
||||||
|
values `1745280000000` < `1776822435828`). `migratePostgres` skips by
|
||||||
|
`created_at < folderMillis`, so on a postgres deployment that already
|
||||||
|
applied 0008, 0009 would be skipped forever. Our hash-based skip in the
|
||||||
|
PGlite path sidesteps this.
|
||||||
|
- **No advisory lock around the migration loop.** Two gateway processes
|
||||||
|
pointed at the same DATABASE_URL would race. PGlite is single-process by
|
||||||
|
file lock so the local tier is fine; postgres-tier deployments should add
|
||||||
|
`pg_advisory_lock(<deterministic-id>)` around the loop in a follow-up.
|
||||||
|
- **`mosaic storage migrate` CLI message is misleading** — it claims
|
||||||
|
"automatic on first connection via adapter.migrate()" but the adapter
|
||||||
|
doesn't self-migrate. With this PR the gateway invokes it explicitly, but
|
||||||
|
the CLI message could still be tightened.
|
||||||
|
- **Crash mid-migration leaves a partial-state PGlite DB without a ledger
|
||||||
|
row.** Detected loudly on next boot (the replay errors on "already
|
||||||
|
exists"), but recovery is manual (drop the partially-applied objects or
|
||||||
|
insert the migration hash into `drizzle.__drizzle_migrations`). A robust
|
||||||
|
fix would add a "started_at" column to a sidecar table to detect
|
||||||
|
half-applied state and refuse to start with actionable guidance.
|
||||||
Reference in New Issue
Block a user