Compare commits
27 Commits
feat/feder
...
docs/feder
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
97459c355b | ||
| fc1600b738 | |||
| 0ee5b14c68 | |||
| 3eee176cc3 | |||
| 74fe60d8d6 | |||
| 0bfaa56e9e | |||
| 01dd6b9fa1 | |||
| 1038ae76e1 | |||
| bf082d95a0 | |||
| bb24292cf7 | |||
| f2cda52e1a | |||
| 7d7cf012f0 | |||
| c56dda74aa | |||
| 9f1a08185e | |||
| d2e408656b | |||
| 54c278b871 | |||
| 4dbd429203 | |||
| b985d7bfe2 | |||
| 45e8f02c91 | |||
| 54c422ab06 | |||
|
|
b9fb8aab57 | ||
| 78841f228a | |||
| dc4afee848 | |||
| 1e2b8ac8de | |||
| 15d849c166 | |||
| 78251d4af8 | |||
| 1a4b1ebbf1 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -9,3 +9,6 @@ coverage
|
|||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
.pnpm-store
|
.pnpm-store
|
||||||
docs/reports/
|
docs/reports/
|
||||||
|
|
||||||
|
# Step-CA dev password — real file is gitignored; commit only the .example
|
||||||
|
infra/step-ca/dev-password
|
||||||
|
|||||||
@@ -80,6 +80,8 @@ If you already have a gateway account but no token, use `mosaic gateway config r
|
|||||||
|
|
||||||
### Configuration
|
### Configuration
|
||||||
|
|
||||||
|
Mosaic supports three storage tiers: `local` (PGlite, single-host), `standalone` (PostgreSQL, single-host), and `federated` (PostgreSQL + pgvector + Valkey, multi-host). See [Federated Tier Setup](docs/federation/SETUP.md) for multi-user and production deployments, or [Migrating to Federated](docs/guides/migrate-tier.md) to upgrade from existing tiers.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
mosaic config show # Print full config as JSON
|
mosaic config show # Print full config as JSON
|
||||||
mosaic config get <key> # Read a specific key
|
mosaic config get <key> # Read a specific key
|
||||||
|
|||||||
@@ -56,6 +56,7 @@
|
|||||||
"@opentelemetry/sdk-metrics": "^2.6.0",
|
"@opentelemetry/sdk-metrics": "^2.6.0",
|
||||||
"@opentelemetry/sdk-node": "^0.213.0",
|
"@opentelemetry/sdk-node": "^0.213.0",
|
||||||
"@opentelemetry/semantic-conventions": "^1.40.0",
|
"@opentelemetry/semantic-conventions": "^1.40.0",
|
||||||
|
"@peculiar/x509": "^2.0.0",
|
||||||
"@sinclair/typebox": "^0.34.48",
|
"@sinclair/typebox": "^0.34.48",
|
||||||
"better-auth": "^1.5.5",
|
"better-auth": "^1.5.5",
|
||||||
"bullmq": "^5.71.0",
|
"bullmq": "^5.71.0",
|
||||||
@@ -64,6 +65,7 @@
|
|||||||
"dotenv": "^17.3.1",
|
"dotenv": "^17.3.1",
|
||||||
"fastify": "^5.0.0",
|
"fastify": "^5.0.0",
|
||||||
"ioredis": "^5.10.0",
|
"ioredis": "^5.10.0",
|
||||||
|
"jose": "^6.2.2",
|
||||||
"node-cron": "^4.2.1",
|
"node-cron": "^4.2.1",
|
||||||
"openai": "^6.32.0",
|
"openai": "^6.32.0",
|
||||||
"postgres": "^3.4.8",
|
"postgres": "^3.4.8",
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
/**
|
||||||
|
* Test B — Gateway boot refuses (fail-fast) when PG is unreachable.
|
||||||
|
*
|
||||||
|
* Prereq: docker compose -f docker-compose.federated.yml --profile federated up -d
|
||||||
|
* (Valkey must be running; only PG is intentionally misconfigured.)
|
||||||
|
* Run: FEDERATED_INTEGRATION=1 pnpm --filter @mosaicstack/gateway test src/__tests__/integration/federated-boot.pg-unreachable.integration.test.ts
|
||||||
|
*
|
||||||
|
* Skipped when FEDERATED_INTEGRATION !== '1'.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import net from 'node:net';
|
||||||
|
import { beforeAll, describe, expect, it } from 'vitest';
|
||||||
|
import { TierDetectionError, detectAndAssertTier } from '@mosaicstack/storage';
|
||||||
|
|
||||||
|
const run = process.env['FEDERATED_INTEGRATION'] === '1';
|
||||||
|
|
||||||
|
const VALKEY_URL = 'redis://localhost:6380';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reserves a guaranteed-closed port at runtime by binding to an ephemeral OS
|
||||||
|
* port (port 0) and immediately releasing it. The OS will not reassign the
|
||||||
|
* port during the TIME_WAIT window, so it remains closed for the duration of
|
||||||
|
* this test.
|
||||||
|
*/
|
||||||
|
async function reserveClosedPort(): Promise<number> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const server = net.createServer();
|
||||||
|
server.listen(0, '127.0.0.1', () => {
|
||||||
|
const addr = server.address();
|
||||||
|
if (typeof addr !== 'object' || !addr) return reject(new Error('no addr'));
|
||||||
|
const port = addr.port;
|
||||||
|
server.close(() => resolve(port));
|
||||||
|
});
|
||||||
|
server.on('error', reject);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe.skipIf(!run)('federated boot — PG unreachable', () => {
|
||||||
|
let badPgUrl: string;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
const closedPort = await reserveClosedPort();
|
||||||
|
badPgUrl = `postgresql://mosaic:mosaic@localhost:${closedPort}/mosaic`;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detectAndAssertTier throws TierDetectionError with service: postgres when PG is down', async () => {
|
||||||
|
const brokenConfig = {
|
||||||
|
tier: 'federated' as const,
|
||||||
|
storage: {
|
||||||
|
type: 'postgres' as const,
|
||||||
|
url: badPgUrl,
|
||||||
|
enableVector: true,
|
||||||
|
},
|
||||||
|
queue: {
|
||||||
|
type: 'bullmq',
|
||||||
|
url: VALKEY_URL,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
await expect(detectAndAssertTier(brokenConfig)).rejects.toSatisfy(
|
||||||
|
(err: unknown) => err instanceof TierDetectionError && err.service === 'postgres',
|
||||||
|
);
|
||||||
|
}, 10_000);
|
||||||
|
});
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
/**
|
||||||
|
* Test A — Gateway boot succeeds when federated services are up.
|
||||||
|
*
|
||||||
|
* Prereq: docker compose -f docker-compose.federated.yml --profile federated up -d
|
||||||
|
* Run: FEDERATED_INTEGRATION=1 pnpm --filter @mosaicstack/gateway test src/__tests__/integration/federated-boot.success.integration.test.ts
|
||||||
|
*
|
||||||
|
* Skipped when FEDERATED_INTEGRATION !== '1'.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import postgres from 'postgres';
|
||||||
|
import { afterAll, describe, expect, it } from 'vitest';
|
||||||
|
import { detectAndAssertTier } from '@mosaicstack/storage';
|
||||||
|
|
||||||
|
const run = process.env['FEDERATED_INTEGRATION'] === '1';
|
||||||
|
|
||||||
|
const PG_URL = 'postgresql://mosaic:mosaic@localhost:5433/mosaic';
|
||||||
|
const VALKEY_URL = 'redis://localhost:6380';
|
||||||
|
|
||||||
|
const federatedConfig = {
|
||||||
|
tier: 'federated' as const,
|
||||||
|
storage: {
|
||||||
|
type: 'postgres' as const,
|
||||||
|
url: PG_URL,
|
||||||
|
enableVector: true,
|
||||||
|
},
|
||||||
|
queue: {
|
||||||
|
type: 'bullmq',
|
||||||
|
url: VALKEY_URL,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
describe.skipIf(!run)('federated boot — success path', () => {
|
||||||
|
let sql: ReturnType<typeof postgres> | undefined;
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
if (sql) {
|
||||||
|
await sql.end({ timeout: 2 }).catch(() => {});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detectAndAssertTier resolves without throwing when federated services are up', async () => {
|
||||||
|
await expect(detectAndAssertTier(federatedConfig)).resolves.toBeUndefined();
|
||||||
|
}, 10_000);
|
||||||
|
|
||||||
|
it('pgvector extension is registered (pg_extension row exists)', async () => {
|
||||||
|
sql = postgres(PG_URL, { max: 1, connect_timeout: 5, idle_timeout: 5 });
|
||||||
|
const rows = await sql`SELECT * FROM pg_extension WHERE extname = 'vector'`;
|
||||||
|
expect(rows).toHaveLength(1);
|
||||||
|
}, 10_000);
|
||||||
|
});
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
/**
|
||||||
|
* Test C — pgvector extension is functional end-to-end.
|
||||||
|
*
|
||||||
|
* Creates a temp table with a vector(3) column, inserts a row, and queries it
|
||||||
|
* back — confirming the extension is not just registered but operational.
|
||||||
|
*
|
||||||
|
* Prereq: docker compose -f docker-compose.federated.yml --profile federated up -d
|
||||||
|
* Run: FEDERATED_INTEGRATION=1 pnpm --filter @mosaicstack/gateway test src/__tests__/integration/federated-pgvector.integration.test.ts
|
||||||
|
*
|
||||||
|
* Skipped when FEDERATED_INTEGRATION !== '1'.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import postgres from 'postgres';
|
||||||
|
import { afterAll, describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
const run = process.env['FEDERATED_INTEGRATION'] === '1';
|
||||||
|
|
||||||
|
const PG_URL = 'postgresql://mosaic:mosaic@localhost:5433/mosaic';
|
||||||
|
|
||||||
|
let sql: ReturnType<typeof postgres> | undefined;
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
if (sql) {
|
||||||
|
await sql.end({ timeout: 2 }).catch(() => {});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
describe.skipIf(!run)('federated pgvector — functional end-to-end', () => {
|
||||||
|
it('vector ops round-trip: INSERT [1,2,3] and SELECT returns [1,2,3]', async () => {
|
||||||
|
sql = postgres(PG_URL, { max: 1, connect_timeout: 5, idle_timeout: 5 });
|
||||||
|
|
||||||
|
await sql`CREATE TEMP TABLE t (id int, embedding vector(3))`;
|
||||||
|
await sql`INSERT INTO t VALUES (1, '[1,2,3]')`;
|
||||||
|
const rows = await sql`SELECT embedding FROM t`;
|
||||||
|
|
||||||
|
expect(rows).toHaveLength(1);
|
||||||
|
// The postgres driver returns vector columns as strings like '[1,2,3]'.
|
||||||
|
// Normalise by parsing the string representation.
|
||||||
|
const raw = rows[0]?.['embedding'] as string;
|
||||||
|
const parsed = JSON.parse(raw) as number[];
|
||||||
|
expect(parsed).toEqual([1, 2, 3]);
|
||||||
|
}, 10_000);
|
||||||
|
});
|
||||||
@@ -0,0 +1,243 @@
|
|||||||
|
/**
|
||||||
|
* Federation M2 E2E test — peer-add enrollment flow (FED-M2-10).
|
||||||
|
*
|
||||||
|
* Covers MILESTONES.md acceptance test #6:
|
||||||
|
* "`peer add <url>` on Server A yields an `active` peer record with a valid cert + key"
|
||||||
|
*
|
||||||
|
* This test simulates two gateways using a single bootstrapped NestJS app:
|
||||||
|
* - "Server A": the admin API that generates a keypair and stores the cert
|
||||||
|
* - "Server B": the enrollment endpoint that signs the CSR
|
||||||
|
* Both share the same DB + Step-CA in the test environment.
|
||||||
|
*
|
||||||
|
* Prerequisites:
|
||||||
|
* docker compose -f docker-compose.federated.yml --profile federated up -d
|
||||||
|
*
|
||||||
|
* Run:
|
||||||
|
* FEDERATED_INTEGRATION=1 STEP_CA_AVAILABLE=1 \
|
||||||
|
* STEP_CA_URL=https://localhost:9000 \
|
||||||
|
* STEP_CA_PROVISIONER_KEY_JSON="$(docker exec $(docker ps -qf name=step-ca) cat /home/step/secrets/mosaic-fed.json)" \
|
||||||
|
* STEP_CA_ROOT_CERT_PATH=/tmp/step-ca-root.crt \
|
||||||
|
* pnpm --filter @mosaicstack/gateway test \
|
||||||
|
* src/__tests__/integration/federation-m2-e2e.integration.test.ts
|
||||||
|
*
|
||||||
|
* Obtaining Step-CA credentials:
|
||||||
|
* # Extract provisioner key from running container:
|
||||||
|
* # docker exec $(docker ps -qf name=step-ca) cat /home/step/secrets/mosaic-fed.json
|
||||||
|
* # Copy root cert from container:
|
||||||
|
* # docker cp $(docker ps -qf name=step-ca):/home/step/certs/root_ca.crt /tmp/step-ca-root.crt
|
||||||
|
* # Then: export STEP_CA_ROOT_CERT_PATH=/tmp/step-ca-root.crt
|
||||||
|
*
|
||||||
|
* Skipped unless both FEDERATED_INTEGRATION=1 and STEP_CA_AVAILABLE=1 are set.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as crypto from 'node:crypto';
|
||||||
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||||
|
import { Test } from '@nestjs/testing';
|
||||||
|
import { ValidationPipe } from '@nestjs/common';
|
||||||
|
import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify';
|
||||||
|
import supertest from 'supertest';
|
||||||
|
import {
|
||||||
|
createDb,
|
||||||
|
type Db,
|
||||||
|
type DbHandle,
|
||||||
|
federationPeers,
|
||||||
|
federationGrants,
|
||||||
|
federationEnrollmentTokens,
|
||||||
|
inArray,
|
||||||
|
eq,
|
||||||
|
} from '@mosaicstack/db';
|
||||||
|
import * as schema from '@mosaicstack/db';
|
||||||
|
import { DB } from '../../database/database.module.js';
|
||||||
|
import { AdminGuard } from '../../admin/admin.guard.js';
|
||||||
|
import { FederationModule } from '../../federation/federation.module.js';
|
||||||
|
import { GrantsService } from '../../federation/grants.service.js';
|
||||||
|
import { EnrollmentService } from '../../federation/enrollment.service.js';
|
||||||
|
|
||||||
|
const run = process.env['FEDERATED_INTEGRATION'] === '1';
|
||||||
|
const stepCaRun =
|
||||||
|
run &&
|
||||||
|
process.env['STEP_CA_AVAILABLE'] === '1' &&
|
||||||
|
!!process.env['STEP_CA_URL'] &&
|
||||||
|
!!process.env['STEP_CA_PROVISIONER_KEY_JSON'] &&
|
||||||
|
!!process.env['STEP_CA_ROOT_CERT_PATH'];
|
||||||
|
|
||||||
|
const PG_URL = 'postgresql://mosaic:mosaic@localhost:5433/mosaic';
|
||||||
|
|
||||||
|
const RUN_ID = crypto.randomUUID();
|
||||||
|
|
||||||
|
describe.skipIf(!stepCaRun)('federation M2 E2E — peer add enrollment flow', () => {
|
||||||
|
let handle: DbHandle;
|
||||||
|
let db: Db;
|
||||||
|
let app: NestFastifyApplication;
|
||||||
|
let agent: ReturnType<typeof supertest>;
|
||||||
|
let grantsService: GrantsService;
|
||||||
|
let enrollmentService: EnrollmentService;
|
||||||
|
|
||||||
|
const createdTokenGrantIds: string[] = [];
|
||||||
|
const createdGrantIds: string[] = [];
|
||||||
|
const createdPeerIds: string[] = [];
|
||||||
|
const createdUserIds: string[] = [];
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
process.env['BETTER_AUTH_SECRET'] ??= 'test-e2e-sealing-key';
|
||||||
|
|
||||||
|
handle = createDb(PG_URL);
|
||||||
|
db = handle.db;
|
||||||
|
|
||||||
|
const moduleRef = await Test.createTestingModule({
|
||||||
|
imports: [FederationModule],
|
||||||
|
providers: [{ provide: DB, useValue: db }],
|
||||||
|
})
|
||||||
|
.overrideGuard(AdminGuard)
|
||||||
|
.useValue({ canActivate: () => true })
|
||||||
|
.compile();
|
||||||
|
|
||||||
|
app = moduleRef.createNestApplication<NestFastifyApplication>(new FastifyAdapter());
|
||||||
|
app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true }));
|
||||||
|
await app.init();
|
||||||
|
await app.getHttpAdapter().getInstance().ready();
|
||||||
|
|
||||||
|
agent = supertest(app.getHttpServer());
|
||||||
|
|
||||||
|
grantsService = moduleRef.get(GrantsService);
|
||||||
|
enrollmentService = moduleRef.get(EnrollmentService);
|
||||||
|
}, 30_000);
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
if (db && createdTokenGrantIds.length > 0) {
|
||||||
|
await db
|
||||||
|
.delete(federationEnrollmentTokens)
|
||||||
|
.where(inArray(federationEnrollmentTokens.grantId, createdTokenGrantIds))
|
||||||
|
.catch((e: unknown) => console.error('[federation-m2-e2e cleanup]', e));
|
||||||
|
}
|
||||||
|
if (db && createdGrantIds.length > 0) {
|
||||||
|
await db
|
||||||
|
.delete(federationGrants)
|
||||||
|
.where(inArray(federationGrants.id, createdGrantIds))
|
||||||
|
.catch((e: unknown) => console.error('[federation-m2-e2e cleanup]', e));
|
||||||
|
}
|
||||||
|
if (db && createdPeerIds.length > 0) {
|
||||||
|
await db
|
||||||
|
.delete(federationPeers)
|
||||||
|
.where(inArray(federationPeers.id, createdPeerIds))
|
||||||
|
.catch((e: unknown) => console.error('[federation-m2-e2e cleanup]', e));
|
||||||
|
}
|
||||||
|
if (db && createdUserIds.length > 0) {
|
||||||
|
await db
|
||||||
|
.delete(schema.users)
|
||||||
|
.where(inArray(schema.users.id, createdUserIds))
|
||||||
|
.catch((e: unknown) => console.error('[federation-m2-e2e cleanup]', e));
|
||||||
|
}
|
||||||
|
if (app)
|
||||||
|
await app.close().catch((e: unknown) => console.error('[federation-m2-e2e cleanup]', e));
|
||||||
|
if (handle)
|
||||||
|
await handle.close().catch((e: unknown) => console.error('[federation-m2-e2e cleanup]', e));
|
||||||
|
});
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// #6 — peer add: keypair → enrollment → cert storage → active peer record
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
it('#6 — peer add flow: keypair → enrollment → cert storage → active peer record', async () => {
|
||||||
|
// Create a subject user to satisfy FK on federation_grants.subject_user_id
|
||||||
|
const userId = crypto.randomUUID();
|
||||||
|
await db
|
||||||
|
.insert(schema.users)
|
||||||
|
.values({
|
||||||
|
id: userId,
|
||||||
|
name: `e2e-user-${RUN_ID}`,
|
||||||
|
email: `e2e-${RUN_ID}@federation-test.invalid`,
|
||||||
|
emailVerified: false,
|
||||||
|
})
|
||||||
|
.onConflictDoNothing();
|
||||||
|
createdUserIds.push(userId);
|
||||||
|
|
||||||
|
// ── Step A: "Server B" setup ─────────────────────────────────────────
|
||||||
|
// Server B admin creates a grant and generates an enrollment token to
|
||||||
|
// share out-of-band with Server A's operator.
|
||||||
|
|
||||||
|
// Insert a placeholder peer on "Server B" to satisfy the grant FK
|
||||||
|
const serverBPeerId = crypto.randomUUID();
|
||||||
|
await db
|
||||||
|
.insert(federationPeers)
|
||||||
|
.values({
|
||||||
|
id: serverBPeerId,
|
||||||
|
commonName: `server-b-peer-${RUN_ID}`,
|
||||||
|
displayName: 'Server B Placeholder',
|
||||||
|
certPem: '-----BEGIN CERTIFICATE-----\nMOCK\n-----END CERTIFICATE-----\n',
|
||||||
|
certSerial: `serial-b-${serverBPeerId}`,
|
||||||
|
certNotAfter: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000),
|
||||||
|
state: 'pending',
|
||||||
|
})
|
||||||
|
.onConflictDoNothing();
|
||||||
|
createdPeerIds.push(serverBPeerId);
|
||||||
|
|
||||||
|
const grant = await grantsService.createGrant({
|
||||||
|
subjectUserId: userId,
|
||||||
|
scope: { resources: ['tasks'], excluded_resources: [], max_rows_per_query: 100 },
|
||||||
|
peerId: serverBPeerId,
|
||||||
|
});
|
||||||
|
createdGrantIds.push(grant.id);
|
||||||
|
createdTokenGrantIds.push(grant.id);
|
||||||
|
|
||||||
|
const { token } = await enrollmentService.createToken({
|
||||||
|
grantId: grant.id,
|
||||||
|
peerId: serverBPeerId,
|
||||||
|
ttlSeconds: 900,
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Step B: "Server A" generates keypair ─────────────────────────────
|
||||||
|
const keypairRes = await agent
|
||||||
|
.post('/api/admin/federation/peers/keypair')
|
||||||
|
.send({
|
||||||
|
commonName: `e2e-peer-${RUN_ID.slice(0, 8)}`,
|
||||||
|
displayName: 'E2E Test Peer',
|
||||||
|
endpointUrl: 'https://test.invalid',
|
||||||
|
})
|
||||||
|
.set('Content-Type', 'application/json');
|
||||||
|
|
||||||
|
expect(keypairRes.status).toBe(201);
|
||||||
|
const { peerId, csrPem } = keypairRes.body as { peerId: string; csrPem: string };
|
||||||
|
expect(typeof peerId).toBe('string');
|
||||||
|
expect(csrPem).toContain('-----BEGIN CERTIFICATE REQUEST-----');
|
||||||
|
createdPeerIds.push(peerId);
|
||||||
|
|
||||||
|
// ── Step C: Enrollment (simulates Server A sending CSR to Server B) ──
|
||||||
|
const enrollRes = await agent
|
||||||
|
.post(`/api/federation/enrollment/${token}`)
|
||||||
|
.send({ csrPem })
|
||||||
|
.set('Content-Type', 'application/json');
|
||||||
|
|
||||||
|
expect(enrollRes.status).toBe(200);
|
||||||
|
const { certPem, certChainPem } = enrollRes.body as {
|
||||||
|
certPem: string;
|
||||||
|
certChainPem: string;
|
||||||
|
};
|
||||||
|
expect(certPem).toContain('-----BEGIN CERTIFICATE-----');
|
||||||
|
expect(certChainPem).toContain('-----BEGIN CERTIFICATE-----');
|
||||||
|
|
||||||
|
// ── Step D: "Server A" stores the cert ───────────────────────────────
|
||||||
|
const storeRes = await agent
|
||||||
|
.patch(`/api/admin/federation/peers/${peerId}/cert`)
|
||||||
|
.send({ certPem })
|
||||||
|
.set('Content-Type', 'application/json');
|
||||||
|
|
||||||
|
expect(storeRes.status).toBe(200);
|
||||||
|
|
||||||
|
// ── Step E: Verify peer record in DB ─────────────────────────────────
|
||||||
|
const [peer] = await db
|
||||||
|
.select()
|
||||||
|
.from(federationPeers)
|
||||||
|
.where(eq(federationPeers.id, peerId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
expect(peer).toBeDefined();
|
||||||
|
expect(peer?.state).toBe('active');
|
||||||
|
expect(peer?.certPem).toContain('-----BEGIN CERTIFICATE-----');
|
||||||
|
expect(typeof peer?.certSerial).toBe('string');
|
||||||
|
expect((peer?.certSerial ?? '').length).toBeGreaterThan(0);
|
||||||
|
// clientKeyPem is a sealed ciphertext — must not be a raw PEM
|
||||||
|
expect(peer?.clientKeyPem?.startsWith('-----BEGIN')).toBe(false);
|
||||||
|
// certNotAfter must be in the future
|
||||||
|
expect(peer?.certNotAfter?.getTime()).toBeGreaterThan(Date.now());
|
||||||
|
}, 60_000);
|
||||||
|
});
|
||||||
@@ -0,0 +1,483 @@
|
|||||||
|
/**
|
||||||
|
* Federation M2 integration tests (FED-M2-09).
|
||||||
|
*
|
||||||
|
* Covers MILESTONES.md acceptance tests #1, #2, #3, #5, #7, #8.
|
||||||
|
*
|
||||||
|
* Prerequisites:
|
||||||
|
* docker compose -f docker-compose.federated.yml --profile federated up -d
|
||||||
|
*
|
||||||
|
* Run DB-only tests (no Step-CA):
|
||||||
|
* FEDERATED_INTEGRATION=1 BETTER_AUTH_SECRET=test-secret pnpm --filter @mosaicstack/gateway test \
|
||||||
|
* src/__tests__/integration/federation-m2.integration.test.ts
|
||||||
|
*
|
||||||
|
* Run all tests including Step-CA-dependent ones:
|
||||||
|
* FEDERATED_INTEGRATION=1 STEP_CA_AVAILABLE=1 \
|
||||||
|
* STEP_CA_URL=https://localhost:9000 \
|
||||||
|
* STEP_CA_PROVISIONER_KEY_JSON="$(docker exec $(docker ps -qf name=step-ca) cat /home/step/secrets/mosaic-fed.json)" \
|
||||||
|
* STEP_CA_ROOT_CERT_PATH=/tmp/step-ca-root.crt \
|
||||||
|
* pnpm --filter @mosaicstack/gateway test \
|
||||||
|
* src/__tests__/integration/federation-m2.integration.test.ts
|
||||||
|
*
|
||||||
|
* Obtaining Step-CA credentials:
|
||||||
|
* # Extract provisioner key from running container:
|
||||||
|
* # docker exec $(docker ps -qf name=step-ca) cat /home/step/secrets/mosaic-fed.json
|
||||||
|
* # Copy root cert from container:
|
||||||
|
* # docker cp $(docker ps -qf name=step-ca):/home/step/certs/root_ca.crt /tmp/step-ca-root.crt
|
||||||
|
* # Then: export STEP_CA_ROOT_CERT_PATH=/tmp/step-ca-root.crt
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as crypto from 'node:crypto';
|
||||||
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||||
|
import { Test } from '@nestjs/testing';
|
||||||
|
import { GoneException } from '@nestjs/common';
|
||||||
|
import { Pkcs10CertificateRequestGenerator, X509Certificate as PeculiarX509 } from '@peculiar/x509';
|
||||||
|
import {
|
||||||
|
createDb,
|
||||||
|
type Db,
|
||||||
|
type DbHandle,
|
||||||
|
federationPeers,
|
||||||
|
federationGrants,
|
||||||
|
federationEnrollmentTokens,
|
||||||
|
inArray,
|
||||||
|
eq,
|
||||||
|
} from '@mosaicstack/db';
|
||||||
|
import * as schema from '@mosaicstack/db';
|
||||||
|
import { seal } from '@mosaicstack/auth';
|
||||||
|
import { DB } from '../../database/database.module.js';
|
||||||
|
import { GrantsService } from '../../federation/grants.service.js';
|
||||||
|
import { EnrollmentService } from '../../federation/enrollment.service.js';
|
||||||
|
import { CaService } from '../../federation/ca.service.js';
|
||||||
|
import { FederationScopeError } from '../../federation/scope-schema.js';
|
||||||
|
|
||||||
|
const run = process.env['FEDERATED_INTEGRATION'] === '1';
|
||||||
|
const stepCaRun = run && process.env['STEP_CA_AVAILABLE'] === '1';
|
||||||
|
|
||||||
|
const PG_URL = 'postgresql://mosaic:mosaic@localhost:5433/mosaic';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helpers for test data isolation
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Unique run prefix to identify rows created by this test run. */
|
||||||
|
const RUN_ID = crypto.randomUUID();
|
||||||
|
|
||||||
|
/** Insert a minimal user row to satisfy the FK on federation_grants.subject_user_id. */
|
||||||
|
async function insertTestUser(db: Db, id: string): Promise<void> {
|
||||||
|
await db
|
||||||
|
.insert(schema.users)
|
||||||
|
.values({
|
||||||
|
id,
|
||||||
|
name: `test-user-${id}`,
|
||||||
|
email: `test-${id}@federation-test.invalid`,
|
||||||
|
emailVerified: false,
|
||||||
|
})
|
||||||
|
.onConflictDoNothing();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Insert a minimal peer row to satisfy the FK on federation_grants.peer_id. */
|
||||||
|
async function insertTestPeer(db: Db, id: string, suffix: string = ''): Promise<void> {
|
||||||
|
await db
|
||||||
|
.insert(federationPeers)
|
||||||
|
.values({
|
||||||
|
id,
|
||||||
|
commonName: `test-peer-${RUN_ID}-${suffix}`,
|
||||||
|
displayName: `Test Peer ${suffix}`,
|
||||||
|
certPem: '-----BEGIN CERTIFICATE-----\nMOCK\n-----END CERTIFICATE-----\n',
|
||||||
|
certSerial: `test-serial-${id}`,
|
||||||
|
certNotAfter: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000),
|
||||||
|
state: 'pending',
|
||||||
|
})
|
||||||
|
.onConflictDoNothing();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// DB-only test module (CaService mocked so env vars not required)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function buildDbModule(db: Db) {
|
||||||
|
return Test.createTestingModule({
|
||||||
|
providers: [
|
||||||
|
{ provide: DB, useValue: db },
|
||||||
|
GrantsService,
|
||||||
|
{
|
||||||
|
provide: CaService,
|
||||||
|
useValue: {
|
||||||
|
issueCert: async () => {
|
||||||
|
throw new Error('CaService.issueCert should not be called in DB-only tests');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
EnrollmentService,
|
||||||
|
],
|
||||||
|
}).compile();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Test suite — DB-only (no Step-CA)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe.skipIf(!run)('federation M2 — DB-only tests', () => {
|
||||||
|
let handle: DbHandle;
|
||||||
|
let db: Db;
|
||||||
|
let grantsService: GrantsService;
|
||||||
|
|
||||||
|
/** IDs created during this run — cleaned up in afterAll. */
|
||||||
|
const createdGrantIds: string[] = [];
|
||||||
|
const createdPeerIds: string[] = [];
|
||||||
|
const createdUserIds: string[] = [];
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
process.env['BETTER_AUTH_SECRET'] ??= 'test-integration-sealing-key-not-for-prod';
|
||||||
|
|
||||||
|
handle = createDb(PG_URL);
|
||||||
|
db = handle.db;
|
||||||
|
|
||||||
|
const moduleRef = await buildDbModule(db);
|
||||||
|
grantsService = moduleRef.get(GrantsService);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
// Clean up in FK-safe order: tokens → grants → peers → users
|
||||||
|
if (db && createdGrantIds.length > 0) {
|
||||||
|
await db
|
||||||
|
.delete(federationEnrollmentTokens)
|
||||||
|
.where(inArray(federationEnrollmentTokens.grantId, createdGrantIds))
|
||||||
|
.catch((e: unknown) => console.error('[federation-m2-test cleanup]', e));
|
||||||
|
await db
|
||||||
|
.delete(federationGrants)
|
||||||
|
.where(inArray(federationGrants.id, createdGrantIds))
|
||||||
|
.catch((e: unknown) => console.error('[federation-m2-test cleanup]', e));
|
||||||
|
}
|
||||||
|
if (db && createdPeerIds.length > 0) {
|
||||||
|
await db
|
||||||
|
.delete(federationPeers)
|
||||||
|
.where(inArray(federationPeers.id, createdPeerIds))
|
||||||
|
.catch((e: unknown) => console.error('[federation-m2-test cleanup]', e));
|
||||||
|
}
|
||||||
|
if (db && createdUserIds.length > 0) {
|
||||||
|
await db
|
||||||
|
.delete(schema.users)
|
||||||
|
.where(inArray(schema.users.id, createdUserIds))
|
||||||
|
.catch((e: unknown) => console.error('[federation-m2-test cleanup]', e));
|
||||||
|
}
|
||||||
|
if (handle)
|
||||||
|
await handle.close().catch((e: unknown) => console.error('[federation-m2-test cleanup]', e));
|
||||||
|
});
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// #1 — grant create writes a pending row
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
it('#1 — createGrant writes a pending row to DB', async () => {
|
||||||
|
const userId = crypto.randomUUID();
|
||||||
|
const peerId = crypto.randomUUID();
|
||||||
|
const validScope = {
|
||||||
|
resources: ['tasks'],
|
||||||
|
excluded_resources: [],
|
||||||
|
max_rows_per_query: 100,
|
||||||
|
};
|
||||||
|
|
||||||
|
await insertTestUser(db, userId);
|
||||||
|
await insertTestPeer(db, peerId, 'test1');
|
||||||
|
createdUserIds.push(userId);
|
||||||
|
createdPeerIds.push(peerId);
|
||||||
|
|
||||||
|
const grant = await grantsService.createGrant({
|
||||||
|
subjectUserId: userId,
|
||||||
|
scope: validScope,
|
||||||
|
peerId,
|
||||||
|
});
|
||||||
|
|
||||||
|
createdGrantIds.push(grant.id);
|
||||||
|
|
||||||
|
// Verify the row exists in DB with correct shape
|
||||||
|
const [row] = await db
|
||||||
|
.select()
|
||||||
|
.from(federationGrants)
|
||||||
|
.where(eq(federationGrants.id, grant.id))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
expect(row).toBeDefined();
|
||||||
|
expect(row?.status).toBe('pending');
|
||||||
|
expect(row?.peerId).toBe(peerId);
|
||||||
|
expect(row?.subjectUserId).toBe(userId);
|
||||||
|
const storedScope = row?.scope as Record<string, unknown>;
|
||||||
|
expect(storedScope['resources']).toEqual(['tasks']);
|
||||||
|
expect(storedScope['max_rows_per_query']).toBe(100);
|
||||||
|
}, 15_000);
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// #7 — scope with unknown resource type rejected
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
it('#7 — createGrant rejects scope with unknown resource type', async () => {
|
||||||
|
const userId = crypto.randomUUID();
|
||||||
|
const peerId = crypto.randomUUID();
|
||||||
|
const invalidScope = {
|
||||||
|
resources: ['totally_unknown_resource'],
|
||||||
|
excluded_resources: [],
|
||||||
|
max_rows_per_query: 100,
|
||||||
|
};
|
||||||
|
|
||||||
|
await insertTestUser(db, userId);
|
||||||
|
await insertTestPeer(db, peerId, 'test7');
|
||||||
|
createdUserIds.push(userId);
|
||||||
|
createdPeerIds.push(peerId);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
grantsService.createGrant({
|
||||||
|
subjectUserId: userId,
|
||||||
|
scope: invalidScope,
|
||||||
|
peerId,
|
||||||
|
}),
|
||||||
|
).rejects.toThrow(FederationScopeError);
|
||||||
|
}, 15_000);
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// #8 — listGrants returns accurate status for grants in various states
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
it('#8 — listGrants returns accurate status for grants in various states', async () => {
|
||||||
|
const userId = crypto.randomUUID();
|
||||||
|
const peerId = crypto.randomUUID();
|
||||||
|
const validScope = {
|
||||||
|
resources: ['notes'],
|
||||||
|
excluded_resources: [],
|
||||||
|
max_rows_per_query: 50,
|
||||||
|
};
|
||||||
|
|
||||||
|
await insertTestUser(db, userId);
|
||||||
|
await insertTestPeer(db, peerId, 'test8');
|
||||||
|
createdUserIds.push(userId);
|
||||||
|
createdPeerIds.push(peerId);
|
||||||
|
|
||||||
|
// Create two pending grants via GrantsService
|
||||||
|
const grantA = await grantsService.createGrant({
|
||||||
|
subjectUserId: userId,
|
||||||
|
scope: validScope,
|
||||||
|
peerId,
|
||||||
|
});
|
||||||
|
const grantB = await grantsService.createGrant({
|
||||||
|
subjectUserId: userId,
|
||||||
|
scope: { resources: ['tasks'], excluded_resources: [], max_rows_per_query: 50 },
|
||||||
|
peerId,
|
||||||
|
});
|
||||||
|
createdGrantIds.push(grantA.id, grantB.id);
|
||||||
|
|
||||||
|
// Insert a third grant directly in 'revoked' state to test status variety
|
||||||
|
const [grantC] = await db
|
||||||
|
.insert(federationGrants)
|
||||||
|
.values({
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
subjectUserId: userId,
|
||||||
|
peerId,
|
||||||
|
scope: validScope,
|
||||||
|
status: 'revoked',
|
||||||
|
revokedAt: new Date(),
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
createdGrantIds.push(grantC!.id);
|
||||||
|
|
||||||
|
// List all grants for this peer
|
||||||
|
const allForPeer = await grantsService.listGrants({ peerId });
|
||||||
|
|
||||||
|
const ourGrantIds = new Set([grantA.id, grantB.id, grantC!.id]);
|
||||||
|
const ourGrants = allForPeer.filter((g) => ourGrantIds.has(g.id));
|
||||||
|
expect(ourGrants).toHaveLength(3);
|
||||||
|
|
||||||
|
const pendingGrants = ourGrants.filter((g) => g.status === 'pending');
|
||||||
|
const revokedGrants = ourGrants.filter((g) => g.status === 'revoked');
|
||||||
|
expect(pendingGrants).toHaveLength(2);
|
||||||
|
expect(revokedGrants).toHaveLength(1);
|
||||||
|
|
||||||
|
// Status-filtered query
|
||||||
|
const pendingOnly = await grantsService.listGrants({ peerId, status: 'pending' });
|
||||||
|
const ourPending = pendingOnly.filter((g) => ourGrantIds.has(g.id));
|
||||||
|
expect(ourPending.every((g) => g.status === 'pending')).toBe(true);
|
||||||
|
|
||||||
|
// Verify peer list from DB also shows the peer rows with correct state
|
||||||
|
const peers = await db.select().from(federationPeers).where(eq(federationPeers.id, peerId));
|
||||||
|
expect(peers).toHaveLength(1);
|
||||||
|
expect(peers[0]?.state).toBe('pending');
|
||||||
|
}, 15_000);
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// #5 — client_key_pem encrypted at rest
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
it('#5 — clientKeyPem stored in DB is a sealed ciphertext (not a valid PEM)', async () => {
|
||||||
|
const peerId = crypto.randomUUID();
|
||||||
|
const rawPem = '-----BEGIN PRIVATE KEY-----\nMOCK\n-----END PRIVATE KEY-----\n';
|
||||||
|
const sealed = seal(rawPem);
|
||||||
|
|
||||||
|
await db.insert(federationPeers).values({
|
||||||
|
id: peerId,
|
||||||
|
commonName: `test-peer-${RUN_ID}-sealed`,
|
||||||
|
displayName: 'Sealed Key Test Peer',
|
||||||
|
certPem: '-----BEGIN CERTIFICATE-----\nMOCK\n-----END CERTIFICATE-----\n',
|
||||||
|
certSerial: `test-serial-sealed-${peerId}`,
|
||||||
|
certNotAfter: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000),
|
||||||
|
state: 'pending',
|
||||||
|
clientKeyPem: sealed,
|
||||||
|
});
|
||||||
|
createdPeerIds.push(peerId);
|
||||||
|
|
||||||
|
const [row] = await db
|
||||||
|
.select()
|
||||||
|
.from(federationPeers)
|
||||||
|
.where(eq(federationPeers.id, peerId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
expect(row).toBeDefined();
|
||||||
|
// The stored value must NOT be a valid PEM — it's a sealed ciphertext blob
|
||||||
|
expect(row?.clientKeyPem).toBeDefined();
|
||||||
|
expect(row?.clientKeyPem?.startsWith('-----BEGIN')).toBe(false);
|
||||||
|
// The sealed value should be non-trivial (at least 20 chars)
|
||||||
|
expect((row?.clientKeyPem ?? '').length).toBeGreaterThan(20);
|
||||||
|
}, 15_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Test suite — Step-CA gated
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe.skipIf(!stepCaRun)('federation M2 — Step-CA tests', () => {
|
||||||
|
let handle: DbHandle;
|
||||||
|
let db: Db;
|
||||||
|
let grantsService: GrantsService;
|
||||||
|
let enrollmentService: EnrollmentService;
|
||||||
|
|
||||||
|
const createdGrantIds: string[] = [];
|
||||||
|
const createdPeerIds: string[] = [];
|
||||||
|
const createdUserIds: string[] = [];
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
handle = createDb(PG_URL);
|
||||||
|
db = handle.db;
|
||||||
|
|
||||||
|
// Use real CaService — env vars (STEP_CA_URL, STEP_CA_PROVISIONER_KEY_JSON,
|
||||||
|
// STEP_CA_ROOT_CERT_PATH) must be set when STEP_CA_AVAILABLE=1
|
||||||
|
const moduleRef = await Test.createTestingModule({
|
||||||
|
providers: [{ provide: DB, useValue: db }, CaService, GrantsService, EnrollmentService],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
grantsService = moduleRef.get(GrantsService);
|
||||||
|
enrollmentService = moduleRef.get(EnrollmentService);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
if (db && createdGrantIds.length > 0) {
|
||||||
|
await db
|
||||||
|
.delete(federationEnrollmentTokens)
|
||||||
|
.where(inArray(federationEnrollmentTokens.grantId, createdGrantIds))
|
||||||
|
.catch((e: unknown) => console.error('[federation-m2-test cleanup]', e));
|
||||||
|
await db
|
||||||
|
.delete(federationGrants)
|
||||||
|
.where(inArray(federationGrants.id, createdGrantIds))
|
||||||
|
.catch((e: unknown) => console.error('[federation-m2-test cleanup]', e));
|
||||||
|
}
|
||||||
|
if (db && createdPeerIds.length > 0) {
|
||||||
|
await db
|
||||||
|
.delete(federationPeers)
|
||||||
|
.where(inArray(federationPeers.id, createdPeerIds))
|
||||||
|
.catch((e: unknown) => console.error('[federation-m2-test cleanup]', e));
|
||||||
|
}
|
||||||
|
if (db && createdUserIds.length > 0) {
|
||||||
|
await db
|
||||||
|
.delete(schema.users)
|
||||||
|
.where(inArray(schema.users.id, createdUserIds))
|
||||||
|
.catch((e: unknown) => console.error('[federation-m2-test cleanup]', e));
|
||||||
|
}
|
||||||
|
if (handle)
|
||||||
|
await handle.close().catch((e: unknown) => console.error('[federation-m2-test cleanup]', e));
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Generate a P-256 key pair and PKCS#10 CSR, returning the CSR as PEM. */
|
||||||
|
async function generateCsrPem(cn: string): Promise<string> {
|
||||||
|
const alg = { name: 'ECDSA', namedCurve: 'P-256', hash: 'SHA-256' };
|
||||||
|
const keyPair = await crypto.subtle.generateKey(alg, true, ['sign', 'verify']);
|
||||||
|
const csr = await Pkcs10CertificateRequestGenerator.create({
|
||||||
|
name: `CN=${cn}`,
|
||||||
|
keys: keyPair,
|
||||||
|
signingAlgorithm: alg,
|
||||||
|
});
|
||||||
|
return csr.toString('pem');
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// #2 — enrollment signs CSR and returns cert
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
it('#2 — redeem returns a certPem containing a valid PEM certificate', async () => {
|
||||||
|
const userId = crypto.randomUUID();
|
||||||
|
const peerId = crypto.randomUUID();
|
||||||
|
const validScope = {
|
||||||
|
resources: ['tasks'],
|
||||||
|
excluded_resources: [],
|
||||||
|
max_rows_per_query: 100,
|
||||||
|
};
|
||||||
|
|
||||||
|
await insertTestUser(db, userId);
|
||||||
|
await insertTestPeer(db, peerId, 'ca-test2');
|
||||||
|
createdUserIds.push(userId);
|
||||||
|
createdPeerIds.push(peerId);
|
||||||
|
|
||||||
|
const grant = await grantsService.createGrant({
|
||||||
|
subjectUserId: userId,
|
||||||
|
scope: validScope,
|
||||||
|
peerId,
|
||||||
|
});
|
||||||
|
createdGrantIds.push(grant.id);
|
||||||
|
|
||||||
|
const { token } = await enrollmentService.createToken({
|
||||||
|
grantId: grant.id,
|
||||||
|
peerId,
|
||||||
|
ttlSeconds: 900,
|
||||||
|
});
|
||||||
|
|
||||||
|
const csrPem = await generateCsrPem(`gateway-test-${RUN_ID.slice(0, 8)}`);
|
||||||
|
const result = await enrollmentService.redeem(token, csrPem);
|
||||||
|
|
||||||
|
expect(result.certPem).toContain('-----BEGIN CERTIFICATE-----');
|
||||||
|
expect(result.certChainPem).toContain('-----BEGIN CERTIFICATE-----');
|
||||||
|
|
||||||
|
// Verify the issued cert parses cleanly
|
||||||
|
const cert = new PeculiarX509(result.certPem);
|
||||||
|
expect(cert.serialNumber).toBeTruthy();
|
||||||
|
}, 30_000);
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// #3 — token single-use; second attempt returns GoneException
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
it('#3 — second redeem of the same token throws GoneException', async () => {
|
||||||
|
const userId = crypto.randomUUID();
|
||||||
|
const peerId = crypto.randomUUID();
|
||||||
|
const validScope = {
|
||||||
|
resources: ['notes'],
|
||||||
|
excluded_resources: [],
|
||||||
|
max_rows_per_query: 50,
|
||||||
|
};
|
||||||
|
|
||||||
|
await insertTestUser(db, userId);
|
||||||
|
await insertTestPeer(db, peerId, 'ca-test3');
|
||||||
|
createdUserIds.push(userId);
|
||||||
|
createdPeerIds.push(peerId);
|
||||||
|
|
||||||
|
const grant = await grantsService.createGrant({
|
||||||
|
subjectUserId: userId,
|
||||||
|
scope: validScope,
|
||||||
|
peerId,
|
||||||
|
});
|
||||||
|
createdGrantIds.push(grant.id);
|
||||||
|
|
||||||
|
const { token } = await enrollmentService.createToken({
|
||||||
|
grantId: grant.id,
|
||||||
|
peerId,
|
||||||
|
ttlSeconds: 900,
|
||||||
|
});
|
||||||
|
|
||||||
|
const csrPem = await generateCsrPem(`gateway-test-replay-${RUN_ID.slice(0, 8)}`);
|
||||||
|
|
||||||
|
// First redeem must succeed
|
||||||
|
const result = await enrollmentService.redeem(token, csrPem);
|
||||||
|
expect(result.certPem).toContain('-----BEGIN CERTIFICATE-----');
|
||||||
|
|
||||||
|
// Second redeem with the same token must be rejected
|
||||||
|
await expect(enrollmentService.redeem(token, csrPem)).rejects.toThrow(GoneException);
|
||||||
|
}, 30_000);
|
||||||
|
});
|
||||||
@@ -1,62 +1,10 @@
|
|||||||
import { Inject, Injectable, Logger } from '@nestjs/common';
|
import { Inject, Injectable, Logger } from '@nestjs/common';
|
||||||
import { createCipheriv, createDecipheriv, createHash, randomBytes } from 'node:crypto';
|
import { seal, unseal } from '@mosaicstack/auth';
|
||||||
import type { Db } from '@mosaicstack/db';
|
import type { Db } from '@mosaicstack/db';
|
||||||
import { providerCredentials, eq, and } from '@mosaicstack/db';
|
import { providerCredentials, eq, and } from '@mosaicstack/db';
|
||||||
import { DB } from '../database/database.module.js';
|
import { DB } from '../database/database.module.js';
|
||||||
import type { ProviderCredentialSummaryDto } from './provider-credentials.dto.js';
|
import type { ProviderCredentialSummaryDto } from './provider-credentials.dto.js';
|
||||||
|
|
||||||
const ALGORITHM = 'aes-256-gcm';
|
|
||||||
const IV_LENGTH = 12; // 96-bit IV for GCM
|
|
||||||
const TAG_LENGTH = 16; // 128-bit auth tag
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Derive a 32-byte AES-256 key from BETTER_AUTH_SECRET using SHA-256.
|
|
||||||
* The secret is assumed to be set in the environment.
|
|
||||||
*/
|
|
||||||
function deriveEncryptionKey(): Buffer {
|
|
||||||
const secret = process.env['BETTER_AUTH_SECRET'];
|
|
||||||
if (!secret) {
|
|
||||||
throw new Error('BETTER_AUTH_SECRET is not set — cannot derive encryption key');
|
|
||||||
}
|
|
||||||
return createHash('sha256').update(secret).digest();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Encrypt a plain-text value using AES-256-GCM.
|
|
||||||
* Output format: base64(iv + authTag + ciphertext)
|
|
||||||
*/
|
|
||||||
function encrypt(plaintext: string): string {
|
|
||||||
const key = deriveEncryptionKey();
|
|
||||||
const iv = randomBytes(IV_LENGTH);
|
|
||||||
const cipher = createCipheriv(ALGORITHM, key, iv);
|
|
||||||
|
|
||||||
const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
|
|
||||||
const authTag = cipher.getAuthTag();
|
|
||||||
|
|
||||||
// Combine iv (12) + authTag (16) + ciphertext and base64-encode
|
|
||||||
const combined = Buffer.concat([iv, authTag, encrypted]);
|
|
||||||
return combined.toString('base64');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Decrypt a value encrypted by `encrypt()`.
|
|
||||||
* Throws on authentication failure (tampered data).
|
|
||||||
*/
|
|
||||||
function decrypt(encoded: string): string {
|
|
||||||
const key = deriveEncryptionKey();
|
|
||||||
const combined = Buffer.from(encoded, 'base64');
|
|
||||||
|
|
||||||
const iv = combined.subarray(0, IV_LENGTH);
|
|
||||||
const authTag = combined.subarray(IV_LENGTH, IV_LENGTH + TAG_LENGTH);
|
|
||||||
const ciphertext = combined.subarray(IV_LENGTH + TAG_LENGTH);
|
|
||||||
|
|
||||||
const decipher = createDecipheriv(ALGORITHM, key, iv);
|
|
||||||
decipher.setAuthTag(authTag);
|
|
||||||
|
|
||||||
const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
|
||||||
return decrypted.toString('utf8');
|
|
||||||
}
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ProviderCredentialsService {
|
export class ProviderCredentialsService {
|
||||||
private readonly logger = new Logger(ProviderCredentialsService.name);
|
private readonly logger = new Logger(ProviderCredentialsService.name);
|
||||||
@@ -74,7 +22,7 @@ export class ProviderCredentialsService {
|
|||||||
value: string,
|
value: string,
|
||||||
metadata?: Record<string, unknown>,
|
metadata?: Record<string, unknown>,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const encryptedValue = encrypt(value);
|
const encryptedValue = seal(value);
|
||||||
|
|
||||||
await this.db
|
await this.db
|
||||||
.insert(providerCredentials)
|
.insert(providerCredentials)
|
||||||
@@ -122,7 +70,7 @@ export class ProviderCredentialsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return decrypt(row.encryptedValue);
|
return unseal(row.encryptedValue);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.logger.error(
|
this.logger.error(
|
||||||
`Failed to decrypt credential for user=${userId} provider=${provider}`,
|
`Failed to decrypt credential for user=${userId} provider=${provider}`,
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import { GCModule } from './gc/gc.module.js';
|
|||||||
import { ReloadModule } from './reload/reload.module.js';
|
import { ReloadModule } from './reload/reload.module.js';
|
||||||
import { WorkspaceModule } from './workspace/workspace.module.js';
|
import { WorkspaceModule } from './workspace/workspace.module.js';
|
||||||
import { QueueModule } from './queue/queue.module.js';
|
import { QueueModule } from './queue/queue.module.js';
|
||||||
|
import { FederationModule } from './federation/federation.module.js';
|
||||||
import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
|
import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
@@ -52,6 +53,7 @@ import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
|
|||||||
QueueModule,
|
QueueModule,
|
||||||
ReloadModule,
|
ReloadModule,
|
||||||
WorkspaceModule,
|
WorkspaceModule,
|
||||||
|
FederationModule,
|
||||||
],
|
],
|
||||||
controllers: [HealthController],
|
controllers: [HealthController],
|
||||||
providers: [
|
providers: [
|
||||||
|
|||||||
373
apps/gateway/src/federation/__tests__/enrollment.service.spec.ts
Normal file
373
apps/gateway/src/federation/__tests__/enrollment.service.spec.ts
Normal file
@@ -0,0 +1,373 @@
|
|||||||
|
/**
|
||||||
|
* Unit tests for EnrollmentService — federation enrollment token flow (FED-M2-07).
|
||||||
|
*
|
||||||
|
* Coverage:
|
||||||
|
* createToken:
|
||||||
|
* - inserts token row with correct grantId, peerId, and future expiresAt
|
||||||
|
* - returns { token, expiresAt } with a 64-char hex token
|
||||||
|
* - clamps ttlSeconds to 900
|
||||||
|
*
|
||||||
|
* redeem — error paths:
|
||||||
|
* - NotFoundException when token row not found
|
||||||
|
* - GoneException when token already used (usedAt set)
|
||||||
|
* - GoneException when token expired (expiresAt < now)
|
||||||
|
* - GoneException when grant status is not pending
|
||||||
|
*
|
||||||
|
* redeem — success path:
|
||||||
|
* - atomically claims token BEFORE cert issuance (claim → issueCert → tx)
|
||||||
|
* - calls CaService.issueCert with correct args
|
||||||
|
* - activates grant + updates peer + writes audit log inside a transaction
|
||||||
|
* - returns { certPem, certChainPem }
|
||||||
|
*
|
||||||
|
* redeem — replay protection:
|
||||||
|
* - GoneException when claim UPDATE returns empty array (concurrent request won)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import 'reflect-metadata';
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { GoneException, NotFoundException } from '@nestjs/common';
|
||||||
|
import type { Db } from '@mosaicstack/db';
|
||||||
|
import { EnrollmentService } from '../enrollment.service.js';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Test constants
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const GRANT_ID = 'g1111111-1111-1111-1111-111111111111';
|
||||||
|
const PEER_ID = 'p2222222-2222-2222-2222-222222222222';
|
||||||
|
const USER_ID = 'u3333333-3333-3333-3333-333333333333';
|
||||||
|
const TOKEN = 'a'.repeat(64); // 64-char hex
|
||||||
|
|
||||||
|
const MOCK_CERT_PEM = '-----BEGIN CERTIFICATE-----\nMOCK\n-----END CERTIFICATE-----\n';
|
||||||
|
const MOCK_CHAIN_PEM = MOCK_CERT_PEM + MOCK_CERT_PEM;
|
||||||
|
const MOCK_SERIAL = 'ABCD1234';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Factory helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function makeTokenRow(overrides: Partial<Record<string, unknown>> = {}) {
|
||||||
|
return {
|
||||||
|
token: TOKEN,
|
||||||
|
grantId: GRANT_ID,
|
||||||
|
peerId: PEER_ID,
|
||||||
|
expiresAt: new Date(Date.now() + 60_000), // 1 min from now
|
||||||
|
usedAt: null,
|
||||||
|
createdAt: new Date(),
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeGrant(overrides: Partial<Record<string, unknown>> = {}) {
|
||||||
|
return {
|
||||||
|
id: GRANT_ID,
|
||||||
|
peerId: PEER_ID,
|
||||||
|
subjectUserId: USER_ID,
|
||||||
|
scope: { resources: ['tasks'], excluded_resources: [], max_rows_per_query: 100 },
|
||||||
|
status: 'pending',
|
||||||
|
expiresAt: null,
|
||||||
|
createdAt: new Date(),
|
||||||
|
revokedAt: null,
|
||||||
|
revokedReason: null,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Mock DB builder
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function makeDb({
|
||||||
|
tokenRows = [makeTokenRow()],
|
||||||
|
// claimedRows is returned by the .returning() on the token-claim UPDATE.
|
||||||
|
// Empty array = concurrent request won the race (GoneException).
|
||||||
|
claimedRows = [{ token: TOKEN }],
|
||||||
|
}: {
|
||||||
|
tokenRows?: unknown[];
|
||||||
|
claimedRows?: unknown[];
|
||||||
|
} = {}) {
|
||||||
|
// insert().values() — for createToken (outer db, not tx)
|
||||||
|
const insertValues = vi.fn().mockResolvedValue(undefined);
|
||||||
|
const insertMock = vi.fn().mockReturnValue({ values: insertValues });
|
||||||
|
|
||||||
|
// select().from().where().limit() — for fetching the token row
|
||||||
|
const limitSelect = vi.fn().mockResolvedValue(tokenRows);
|
||||||
|
const whereSelect = vi.fn().mockReturnValue({ limit: limitSelect });
|
||||||
|
const fromSelect = vi.fn().mockReturnValue({ where: whereSelect });
|
||||||
|
const selectMock = vi.fn().mockReturnValue({ from: fromSelect });
|
||||||
|
|
||||||
|
// update().set().where().returning() — for the atomic token claim (outer db)
|
||||||
|
const returningMock = vi.fn().mockResolvedValue(claimedRows);
|
||||||
|
const whereClaimUpdate = vi.fn().mockReturnValue({ returning: returningMock });
|
||||||
|
const setClaimMock = vi.fn().mockReturnValue({ where: whereClaimUpdate });
|
||||||
|
const claimUpdateMock = vi.fn().mockReturnValue({ set: setClaimMock });
|
||||||
|
|
||||||
|
// transaction(cb) — cb receives txMock; txMock has update + insert
|
||||||
|
const txInsertValues = vi.fn().mockResolvedValue(undefined);
|
||||||
|
const txInsertMock = vi.fn().mockReturnValue({ values: txInsertValues });
|
||||||
|
const txWhereUpdate = vi.fn().mockResolvedValue(undefined);
|
||||||
|
const txSetMock = vi.fn().mockReturnValue({ where: txWhereUpdate });
|
||||||
|
const txUpdateMock = vi.fn().mockReturnValue({ set: txSetMock });
|
||||||
|
const txMock = { update: txUpdateMock, insert: txInsertMock };
|
||||||
|
const transactionMock = vi
|
||||||
|
.fn()
|
||||||
|
.mockImplementation(async (cb: (tx: typeof txMock) => Promise<void>) => cb(txMock));
|
||||||
|
|
||||||
|
return {
|
||||||
|
insert: insertMock,
|
||||||
|
select: selectMock,
|
||||||
|
update: claimUpdateMock,
|
||||||
|
transaction: transactionMock,
|
||||||
|
_mocks: {
|
||||||
|
insertValues,
|
||||||
|
insertMock,
|
||||||
|
limitSelect,
|
||||||
|
whereSelect,
|
||||||
|
fromSelect,
|
||||||
|
selectMock,
|
||||||
|
returningMock,
|
||||||
|
whereClaimUpdate,
|
||||||
|
setClaimMock,
|
||||||
|
claimUpdateMock,
|
||||||
|
txInsertValues,
|
||||||
|
txInsertMock,
|
||||||
|
txWhereUpdate,
|
||||||
|
txSetMock,
|
||||||
|
txUpdateMock,
|
||||||
|
txMock,
|
||||||
|
transactionMock,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Mock CaService
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function makeCaService() {
|
||||||
|
return {
|
||||||
|
issueCert: vi.fn().mockResolvedValue({
|
||||||
|
certPem: MOCK_CERT_PEM,
|
||||||
|
certChainPem: MOCK_CHAIN_PEM,
|
||||||
|
serialNumber: MOCK_SERIAL,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Mock GrantsService
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function makeGrantsService(grantOverrides: Partial<Record<string, unknown>> = {}) {
|
||||||
|
return {
|
||||||
|
getGrant: vi.fn().mockResolvedValue(makeGrant(grantOverrides)),
|
||||||
|
activateGrant: vi.fn().mockResolvedValue(makeGrant({ status: 'active' })),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helper: build service under test
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function buildService({
|
||||||
|
db = makeDb(),
|
||||||
|
caService = makeCaService(),
|
||||||
|
grantsService = makeGrantsService(),
|
||||||
|
}: {
|
||||||
|
db?: ReturnType<typeof makeDb>;
|
||||||
|
caService?: ReturnType<typeof makeCaService>;
|
||||||
|
grantsService?: ReturnType<typeof makeGrantsService>;
|
||||||
|
} = {}) {
|
||||||
|
return new EnrollmentService(db as unknown as Db, caService as never, grantsService as never);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tests: createToken
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('EnrollmentService.createToken', () => {
|
||||||
|
it('inserts a token row and returns { token, expiresAt }', async () => {
|
||||||
|
const db = makeDb();
|
||||||
|
const service = buildService({ db });
|
||||||
|
|
||||||
|
const result = await service.createToken({
|
||||||
|
grantId: GRANT_ID,
|
||||||
|
peerId: PEER_ID,
|
||||||
|
ttlSeconds: 900,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.token).toHaveLength(64); // 32 bytes hex
|
||||||
|
expect(result.expiresAt).toBeDefined();
|
||||||
|
expect(new Date(result.expiresAt).getTime()).toBeGreaterThan(Date.now());
|
||||||
|
expect(db._mocks.insertValues).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ grantId: GRANT_ID, peerId: PEER_ID }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clamps ttlSeconds to 900', async () => {
|
||||||
|
const db = makeDb();
|
||||||
|
const service = buildService({ db });
|
||||||
|
|
||||||
|
const before = Date.now();
|
||||||
|
const result = await service.createToken({
|
||||||
|
grantId: GRANT_ID,
|
||||||
|
peerId: PEER_ID,
|
||||||
|
ttlSeconds: 9999,
|
||||||
|
});
|
||||||
|
const after = Date.now();
|
||||||
|
|
||||||
|
const expiresMs = new Date(result.expiresAt).getTime();
|
||||||
|
// Should be at most 900s from now
|
||||||
|
expect(expiresMs - before).toBeLessThanOrEqual(900_000 + 100);
|
||||||
|
expect(expiresMs - after).toBeGreaterThanOrEqual(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tests: redeem — error paths
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('EnrollmentService.redeem — error paths', () => {
|
||||||
|
it('throws NotFoundException when token row not found', async () => {
|
||||||
|
const db = makeDb({ tokenRows: [] });
|
||||||
|
const service = buildService({ db });
|
||||||
|
|
||||||
|
await expect(service.redeem(TOKEN, '---CSR---')).rejects.toBeInstanceOf(NotFoundException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws GoneException when usedAt is set (already redeemed)', async () => {
|
||||||
|
const db = makeDb({ tokenRows: [makeTokenRow({ usedAt: new Date(Date.now() - 1000) })] });
|
||||||
|
const service = buildService({ db });
|
||||||
|
|
||||||
|
await expect(service.redeem(TOKEN, '---CSR---')).rejects.toBeInstanceOf(GoneException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws GoneException when token has expired', async () => {
|
||||||
|
const db = makeDb({ tokenRows: [makeTokenRow({ expiresAt: new Date(Date.now() - 1000) })] });
|
||||||
|
const service = buildService({ db });
|
||||||
|
|
||||||
|
await expect(service.redeem(TOKEN, '---CSR---')).rejects.toBeInstanceOf(GoneException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws GoneException when grant status is not pending', async () => {
|
||||||
|
const db = makeDb();
|
||||||
|
const grantsService = makeGrantsService({ status: 'active' });
|
||||||
|
const service = buildService({ db, grantsService });
|
||||||
|
|
||||||
|
await expect(service.redeem(TOKEN, '---CSR---')).rejects.toBeInstanceOf(GoneException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws GoneException when token claim UPDATE returns empty array (concurrent replay)', async () => {
|
||||||
|
const db = makeDb({ claimedRows: [] });
|
||||||
|
const caService = makeCaService();
|
||||||
|
const grantsService = makeGrantsService();
|
||||||
|
const service = buildService({ db, caService, grantsService });
|
||||||
|
|
||||||
|
await expect(service.redeem(TOKEN, '---CSR---')).rejects.toBeInstanceOf(GoneException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does NOT call issueCert when token claim fails (no double minting)', async () => {
|
||||||
|
const db = makeDb({ claimedRows: [] });
|
||||||
|
const caService = makeCaService();
|
||||||
|
const service = buildService({ db, caService });
|
||||||
|
|
||||||
|
await expect(service.redeem(TOKEN, '---CSR---')).rejects.toBeInstanceOf(GoneException);
|
||||||
|
expect(caService.issueCert).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tests: redeem — success path
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('EnrollmentService.redeem — success path', () => {
|
||||||
|
let db: ReturnType<typeof makeDb>;
|
||||||
|
let caService: ReturnType<typeof makeCaService>;
|
||||||
|
let grantsService: ReturnType<typeof makeGrantsService>;
|
||||||
|
let service: EnrollmentService;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
db = makeDb();
|
||||||
|
caService = makeCaService();
|
||||||
|
grantsService = makeGrantsService();
|
||||||
|
service = buildService({ db, caService, grantsService });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('claims token BEFORE calling issueCert (prevents double minting)', async () => {
|
||||||
|
const callOrder: string[] = [];
|
||||||
|
db._mocks.returningMock.mockImplementation(async () => {
|
||||||
|
callOrder.push('claim');
|
||||||
|
return [{ token: TOKEN }];
|
||||||
|
});
|
||||||
|
caService.issueCert.mockImplementation(async () => {
|
||||||
|
callOrder.push('issueCert');
|
||||||
|
return { certPem: MOCK_CERT_PEM, certChainPem: MOCK_CHAIN_PEM, serialNumber: MOCK_SERIAL };
|
||||||
|
});
|
||||||
|
|
||||||
|
await service.redeem(TOKEN, MOCK_CERT_PEM);
|
||||||
|
|
||||||
|
expect(callOrder).toEqual(['claim', 'issueCert']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls CaService.issueCert with grantId, subjectUserId, csrPem, ttlSeconds=300', async () => {
|
||||||
|
await service.redeem(TOKEN, MOCK_CERT_PEM);
|
||||||
|
|
||||||
|
expect(caService.issueCert).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
grantId: GRANT_ID,
|
||||||
|
subjectUserId: USER_ID,
|
||||||
|
csrPem: MOCK_CERT_PEM,
|
||||||
|
ttlSeconds: 300,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('runs activate grant + peer update + audit inside a transaction', async () => {
|
||||||
|
await service.redeem(TOKEN, MOCK_CERT_PEM);
|
||||||
|
|
||||||
|
expect(db._mocks.transactionMock).toHaveBeenCalledOnce();
|
||||||
|
// tx.update called twice: activate grant + update peer
|
||||||
|
expect(db._mocks.txUpdateMock).toHaveBeenCalledTimes(2);
|
||||||
|
// tx.insert called once: audit log
|
||||||
|
expect(db._mocks.txInsertMock).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('activates grant (sets status=active) inside the transaction', async () => {
|
||||||
|
await service.redeem(TOKEN, MOCK_CERT_PEM);
|
||||||
|
|
||||||
|
expect(db._mocks.txSetMock).toHaveBeenCalledWith(expect.objectContaining({ status: 'active' }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates the federationPeers row with certPem, certSerial, state=active inside the transaction', async () => {
|
||||||
|
await service.redeem(TOKEN, MOCK_CERT_PEM);
|
||||||
|
|
||||||
|
expect(db._mocks.txSetMock).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
certPem: MOCK_CERT_PEM,
|
||||||
|
certSerial: MOCK_SERIAL,
|
||||||
|
state: 'active',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('inserts an audit log row inside the transaction', async () => {
|
||||||
|
await service.redeem(TOKEN, MOCK_CERT_PEM);
|
||||||
|
|
||||||
|
expect(db._mocks.txInsertValues).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
peerId: PEER_ID,
|
||||||
|
grantId: GRANT_ID,
|
||||||
|
verb: 'enrollment',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns { certPem, certChainPem } from CaService', async () => {
|
||||||
|
const result = await service.redeem(TOKEN, MOCK_CERT_PEM);
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
certPem: MOCK_CERT_PEM,
|
||||||
|
certChainPem: MOCK_CHAIN_PEM,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,212 @@
|
|||||||
|
/**
|
||||||
|
* Unit tests for FederationController (FED-M2-08).
|
||||||
|
*
|
||||||
|
* Coverage:
|
||||||
|
* - listGrants: delegates to GrantsService with query params
|
||||||
|
* - createGrant: delegates to GrantsService, validates body
|
||||||
|
* - generateToken: returns enrollmentUrl containing the token
|
||||||
|
* - listPeers: returns DB rows
|
||||||
|
*/
|
||||||
|
|
||||||
|
import 'reflect-metadata';
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { NotFoundException } from '@nestjs/common';
|
||||||
|
import type { Db } from '@mosaicstack/db';
|
||||||
|
import { FederationController } from '../federation.controller.js';
|
||||||
|
import type { GrantsService } from '../grants.service.js';
|
||||||
|
import type { EnrollmentService } from '../enrollment.service.js';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Constants
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const GRANT_ID = 'g1111111-1111-1111-1111-111111111111';
|
||||||
|
const PEER_ID = 'p2222222-2222-2222-2222-222222222222';
|
||||||
|
const USER_ID = 'u3333333-3333-3333-3333-333333333333';
|
||||||
|
|
||||||
|
const MOCK_GRANT = {
|
||||||
|
id: GRANT_ID,
|
||||||
|
peerId: PEER_ID,
|
||||||
|
subjectUserId: USER_ID,
|
||||||
|
scope: { resources: ['tasks'], operations: ['list'] },
|
||||||
|
status: 'pending' as const,
|
||||||
|
expiresAt: null,
|
||||||
|
createdAt: new Date('2026-01-01T00:00:00Z'),
|
||||||
|
revokedAt: null,
|
||||||
|
revokedReason: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const MOCK_PEER = {
|
||||||
|
id: PEER_ID,
|
||||||
|
commonName: 'test-peer',
|
||||||
|
displayName: 'Test Peer',
|
||||||
|
certPem: '',
|
||||||
|
certSerial: 'pending',
|
||||||
|
certNotAfter: new Date(0),
|
||||||
|
clientKeyPem: null,
|
||||||
|
state: 'pending' as const,
|
||||||
|
endpointUrl: null,
|
||||||
|
createdAt: new Date('2026-01-01T00:00:00Z'),
|
||||||
|
updatedAt: new Date('2026-01-01T00:00:00Z'),
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// DB mock builder
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function makeDbMock(rows: unknown[] = []) {
|
||||||
|
const orderBy = vi.fn().mockResolvedValue(rows);
|
||||||
|
const where = vi.fn().mockReturnValue({ orderBy });
|
||||||
|
const from = vi.fn().mockReturnValue({ where, orderBy });
|
||||||
|
const select = vi.fn().mockReturnValue({ from });
|
||||||
|
|
||||||
|
return {
|
||||||
|
select,
|
||||||
|
from,
|
||||||
|
where,
|
||||||
|
orderBy,
|
||||||
|
insert: vi.fn(),
|
||||||
|
update: vi.fn(),
|
||||||
|
delete: vi.fn(),
|
||||||
|
} as unknown as Db;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('FederationController', () => {
|
||||||
|
let db: Db;
|
||||||
|
let grantsService: GrantsService;
|
||||||
|
let enrollmentService: EnrollmentService;
|
||||||
|
let controller: FederationController;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
db = makeDbMock([MOCK_PEER]);
|
||||||
|
|
||||||
|
grantsService = {
|
||||||
|
createGrant: vi.fn().mockResolvedValue(MOCK_GRANT),
|
||||||
|
getGrant: vi.fn().mockResolvedValue(MOCK_GRANT),
|
||||||
|
listGrants: vi.fn().mockResolvedValue([MOCK_GRANT]),
|
||||||
|
revokeGrant: vi.fn().mockResolvedValue({ ...MOCK_GRANT, status: 'revoked' }),
|
||||||
|
activateGrant: vi.fn(),
|
||||||
|
expireGrant: vi.fn(),
|
||||||
|
} as unknown as GrantsService;
|
||||||
|
|
||||||
|
enrollmentService = {
|
||||||
|
createToken: vi.fn().mockResolvedValue({
|
||||||
|
token: 'abc123def456abc123def456abc123def456abc123def456abc123def456ab12',
|
||||||
|
expiresAt: '2026-01-01T00:15:00.000Z',
|
||||||
|
}),
|
||||||
|
redeem: vi.fn(),
|
||||||
|
} as unknown as EnrollmentService;
|
||||||
|
|
||||||
|
controller = new FederationController(db, grantsService, enrollmentService);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Grant management ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('listGrants', () => {
|
||||||
|
it('delegates to GrantsService with provided query params', async () => {
|
||||||
|
const query = { peerId: PEER_ID, status: 'pending' as const };
|
||||||
|
const result = await controller.listGrants(query);
|
||||||
|
|
||||||
|
expect(grantsService.listGrants).toHaveBeenCalledWith(query);
|
||||||
|
expect(result).toEqual([MOCK_GRANT]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('delegates to GrantsService with empty filters', async () => {
|
||||||
|
const result = await controller.listGrants({});
|
||||||
|
|
||||||
|
expect(grantsService.listGrants).toHaveBeenCalledWith({});
|
||||||
|
expect(result).toEqual([MOCK_GRANT]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('createGrant', () => {
|
||||||
|
it('delegates to GrantsService and returns created grant', async () => {
|
||||||
|
const body = {
|
||||||
|
peerId: PEER_ID,
|
||||||
|
subjectUserId: USER_ID,
|
||||||
|
scope: { resources: ['tasks'], operations: ['list'] },
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await controller.createGrant(body);
|
||||||
|
|
||||||
|
expect(grantsService.createGrant).toHaveBeenCalledWith(body);
|
||||||
|
expect(result).toEqual(MOCK_GRANT);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getGrant', () => {
|
||||||
|
it('delegates to GrantsService with provided ID', async () => {
|
||||||
|
const result = await controller.getGrant(GRANT_ID);
|
||||||
|
|
||||||
|
expect(grantsService.getGrant).toHaveBeenCalledWith(GRANT_ID);
|
||||||
|
expect(result).toEqual(MOCK_GRANT);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('revokeGrant', () => {
|
||||||
|
it('delegates to GrantsService with id and reason', async () => {
|
||||||
|
const result = await controller.revokeGrant(GRANT_ID, { reason: 'test reason' });
|
||||||
|
|
||||||
|
expect(grantsService.revokeGrant).toHaveBeenCalledWith(GRANT_ID, 'test reason');
|
||||||
|
expect(result).toMatchObject({ status: 'revoked' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('delegates without reason when omitted', async () => {
|
||||||
|
await controller.revokeGrant(GRANT_ID, {});
|
||||||
|
|
||||||
|
expect(grantsService.revokeGrant).toHaveBeenCalledWith(GRANT_ID, undefined);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('generateToken', () => {
|
||||||
|
it('returns enrollmentUrl containing the token', async () => {
|
||||||
|
const token = 'abc123def456abc123def456abc123def456abc123def456abc123def456ab12';
|
||||||
|
vi.mocked(enrollmentService.createToken).mockResolvedValueOnce({
|
||||||
|
token,
|
||||||
|
expiresAt: '2026-01-01T00:15:00.000Z',
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await controller.generateToken(GRANT_ID, { ttlSeconds: 900 });
|
||||||
|
|
||||||
|
expect(result.token).toBe(token);
|
||||||
|
expect(result.enrollmentUrl).toContain(token);
|
||||||
|
expect(result.enrollmentUrl).toContain('/api/federation/enrollment/');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates token via EnrollmentService with correct grantId and peerId', async () => {
|
||||||
|
await controller.generateToken(GRANT_ID, { ttlSeconds: 300 });
|
||||||
|
|
||||||
|
expect(enrollmentService.createToken).toHaveBeenCalledWith({
|
||||||
|
grantId: GRANT_ID,
|
||||||
|
peerId: PEER_ID,
|
||||||
|
ttlSeconds: 300,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws NotFoundException when grant does not exist', async () => {
|
||||||
|
vi.mocked(grantsService.getGrant).mockRejectedValueOnce(
|
||||||
|
new NotFoundException(`Grant ${GRANT_ID} not found`),
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(controller.generateToken(GRANT_ID, { ttlSeconds: 900 })).rejects.toThrow(
|
||||||
|
NotFoundException,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Peer management ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('listPeers', () => {
|
||||||
|
it('returns DB rows ordered by commonName', async () => {
|
||||||
|
const result = await controller.listPeers();
|
||||||
|
|
||||||
|
expect(db.select).toHaveBeenCalled();
|
||||||
|
// The DB mock resolves with [MOCK_PEER]
|
||||||
|
expect(result).toEqual([MOCK_PEER]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
351
apps/gateway/src/federation/__tests__/grants.service.spec.ts
Normal file
351
apps/gateway/src/federation/__tests__/grants.service.spec.ts
Normal file
@@ -0,0 +1,351 @@
|
|||||||
|
/**
|
||||||
|
* Unit tests for GrantsService — federation grants CRUD + status transitions (FED-M2-06).
|
||||||
|
*
|
||||||
|
* Coverage:
|
||||||
|
* - createGrant: validates scope via parseFederationScope
|
||||||
|
* - createGrant: inserts with status 'pending'
|
||||||
|
* - getGrant: returns grant when found
|
||||||
|
* - getGrant: throws NotFoundException when not found
|
||||||
|
* - listGrants: no filters returns all grants
|
||||||
|
* - listGrants: filters by peerId
|
||||||
|
* - listGrants: filters by subjectUserId
|
||||||
|
* - listGrants: filters by status
|
||||||
|
* - listGrants: multiple filters combined
|
||||||
|
* - activateGrant: pending → active works
|
||||||
|
* - activateGrant: non-pending throws ConflictException
|
||||||
|
* - revokeGrant: active → revoked works, sets revokedAt
|
||||||
|
* - revokeGrant: non-active throws ConflictException
|
||||||
|
* - expireGrant: active → expired works
|
||||||
|
* - expireGrant: non-active throws ConflictException
|
||||||
|
*/
|
||||||
|
|
||||||
|
import 'reflect-metadata';
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { ConflictException, NotFoundException } from '@nestjs/common';
|
||||||
|
import type { Db } from '@mosaicstack/db';
|
||||||
|
import { GrantsService } from '../grants.service.js';
|
||||||
|
import { FederationScopeError } from '../scope-schema.js';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Minimal valid federation scope for testing
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
const VALID_SCOPE = {
|
||||||
|
resources: ['tasks'] as const,
|
||||||
|
excluded_resources: [],
|
||||||
|
max_rows_per_query: 100,
|
||||||
|
};
|
||||||
|
|
||||||
|
const PEER_ID = 'a1111111-1111-1111-1111-111111111111';
|
||||||
|
const USER_ID = 'u2222222-2222-2222-2222-222222222222';
|
||||||
|
const GRANT_ID = 'g3333333-3333-3333-3333-333333333333';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Build a mock DB that mimics chained Drizzle query builder calls
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function makeMockGrant(overrides: Partial<Record<string, unknown>> = {}) {
|
||||||
|
return {
|
||||||
|
id: GRANT_ID,
|
||||||
|
peerId: PEER_ID,
|
||||||
|
subjectUserId: USER_ID,
|
||||||
|
scope: VALID_SCOPE,
|
||||||
|
status: 'pending',
|
||||||
|
expiresAt: null,
|
||||||
|
createdAt: new Date('2026-01-01T00:00:00Z'),
|
||||||
|
revokedAt: null,
|
||||||
|
revokedReason: null,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeDb(
|
||||||
|
overrides: {
|
||||||
|
insertReturning?: unknown[];
|
||||||
|
selectRows?: unknown[];
|
||||||
|
updateReturning?: unknown[];
|
||||||
|
} = {},
|
||||||
|
) {
|
||||||
|
const insertReturning = overrides.insertReturning ?? [makeMockGrant()];
|
||||||
|
const selectRows = overrides.selectRows ?? [makeMockGrant()];
|
||||||
|
const updateReturning = overrides.updateReturning ?? [makeMockGrant({ status: 'active' })];
|
||||||
|
|
||||||
|
// Drizzle returns a chainable builder; we need to mock the full chain.
|
||||||
|
const returningInsert = vi.fn().mockResolvedValue(insertReturning);
|
||||||
|
const valuesInsert = vi.fn().mockReturnValue({ returning: returningInsert });
|
||||||
|
const insertMock = vi.fn().mockReturnValue({ values: valuesInsert });
|
||||||
|
|
||||||
|
// select().from().where().limit()
|
||||||
|
const limitSelect = vi.fn().mockResolvedValue(selectRows);
|
||||||
|
const whereSelect = vi.fn().mockReturnValue({ limit: limitSelect });
|
||||||
|
// from returns something that is both thenable (for full-table select) and has .where()
|
||||||
|
const fromSelect = vi.fn().mockReturnValue({
|
||||||
|
where: whereSelect,
|
||||||
|
limit: limitSelect,
|
||||||
|
// Make it thenable for listGrants with no filters (await db.select().from(federationGrants))
|
||||||
|
then: (resolve: (v: unknown) => unknown) => resolve(selectRows),
|
||||||
|
});
|
||||||
|
const selectMock = vi.fn().mockReturnValue({ from: fromSelect });
|
||||||
|
|
||||||
|
const returningUpdate = vi.fn().mockResolvedValue(updateReturning);
|
||||||
|
const whereUpdate = vi.fn().mockReturnValue({ returning: returningUpdate });
|
||||||
|
const setMock = vi.fn().mockReturnValue({ where: whereUpdate });
|
||||||
|
const updateMock = vi.fn().mockReturnValue({ set: setMock });
|
||||||
|
|
||||||
|
return {
|
||||||
|
insert: insertMock,
|
||||||
|
select: selectMock,
|
||||||
|
update: updateMock,
|
||||||
|
// Expose internals for assertions
|
||||||
|
_mocks: {
|
||||||
|
insertReturning,
|
||||||
|
valuesInsert,
|
||||||
|
insertMock,
|
||||||
|
limitSelect,
|
||||||
|
whereSelect,
|
||||||
|
fromSelect,
|
||||||
|
selectMock,
|
||||||
|
returningUpdate,
|
||||||
|
whereUpdate,
|
||||||
|
setMock,
|
||||||
|
updateMock,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('GrantsService', () => {
|
||||||
|
let db: ReturnType<typeof makeDb>;
|
||||||
|
let service: GrantsService;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
db = makeDb();
|
||||||
|
service = new GrantsService(db as unknown as Db);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── createGrant ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('createGrant', () => {
|
||||||
|
it('calls parseFederationScope — rejects an invalid scope', async () => {
|
||||||
|
const invalidScope = { resources: [], max_rows_per_query: 0 };
|
||||||
|
await expect(
|
||||||
|
service.createGrant({ peerId: PEER_ID, subjectUserId: USER_ID, scope: invalidScope }),
|
||||||
|
).rejects.toBeInstanceOf(FederationScopeError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('inserts a grant with status pending and returns it', async () => {
|
||||||
|
const result = await service.createGrant({
|
||||||
|
peerId: PEER_ID,
|
||||||
|
subjectUserId: USER_ID,
|
||||||
|
scope: VALID_SCOPE,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(db._mocks.valuesInsert).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ status: 'pending', peerId: PEER_ID, subjectUserId: USER_ID }),
|
||||||
|
);
|
||||||
|
expect(result.status).toBe('pending');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes expiresAt as a Date when provided', async () => {
|
||||||
|
await service.createGrant({
|
||||||
|
peerId: PEER_ID,
|
||||||
|
subjectUserId: USER_ID,
|
||||||
|
scope: VALID_SCOPE,
|
||||||
|
expiresAt: '2027-01-01T00:00:00Z',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(db._mocks.valuesInsert).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ expiresAt: expect.any(Date) }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sets expiresAt to null when not provided', async () => {
|
||||||
|
await service.createGrant({ peerId: PEER_ID, subjectUserId: USER_ID, scope: VALID_SCOPE });
|
||||||
|
|
||||||
|
expect(db._mocks.valuesInsert).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ expiresAt: null }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── getGrant ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('getGrant', () => {
|
||||||
|
it('returns the grant when found', async () => {
|
||||||
|
const result = await service.getGrant(GRANT_ID);
|
||||||
|
expect(result.id).toBe(GRANT_ID);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws NotFoundException when no rows returned', async () => {
|
||||||
|
db = makeDb({ selectRows: [] });
|
||||||
|
service = new GrantsService(db as unknown as Db);
|
||||||
|
await expect(service.getGrant(GRANT_ID)).rejects.toBeInstanceOf(NotFoundException);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── listGrants ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('listGrants', () => {
|
||||||
|
it('queries without where clause when no filters provided', async () => {
|
||||||
|
const result = await service.listGrants({});
|
||||||
|
expect(Array.isArray(result)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies peerId filter', async () => {
|
||||||
|
await service.listGrants({ peerId: PEER_ID });
|
||||||
|
expect(db._mocks.whereSelect).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies subjectUserId filter', async () => {
|
||||||
|
await service.listGrants({ subjectUserId: USER_ID });
|
||||||
|
expect(db._mocks.whereSelect).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies status filter', async () => {
|
||||||
|
await service.listGrants({ status: 'active' });
|
||||||
|
expect(db._mocks.whereSelect).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies multiple filters combined', async () => {
|
||||||
|
await service.listGrants({ peerId: PEER_ID, status: 'pending' });
|
||||||
|
expect(db._mocks.whereSelect).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── activateGrant ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('activateGrant', () => {
|
||||||
|
it('transitions pending → active and returns updated grant', async () => {
|
||||||
|
db = makeDb({
|
||||||
|
selectRows: [makeMockGrant({ status: 'pending' })],
|
||||||
|
updateReturning: [makeMockGrant({ status: 'active' })],
|
||||||
|
});
|
||||||
|
service = new GrantsService(db as unknown as Db);
|
||||||
|
|
||||||
|
const result = await service.activateGrant(GRANT_ID);
|
||||||
|
|
||||||
|
expect(db._mocks.setMock).toHaveBeenCalledWith({ status: 'active' });
|
||||||
|
expect(result.status).toBe('active');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws ConflictException when grant is already active', async () => {
|
||||||
|
db = makeDb({ selectRows: [makeMockGrant({ status: 'active' })] });
|
||||||
|
service = new GrantsService(db as unknown as Db);
|
||||||
|
|
||||||
|
await expect(service.activateGrant(GRANT_ID)).rejects.toBeInstanceOf(ConflictException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws ConflictException when grant is revoked', async () => {
|
||||||
|
db = makeDb({ selectRows: [makeMockGrant({ status: 'revoked' })] });
|
||||||
|
service = new GrantsService(db as unknown as Db);
|
||||||
|
|
||||||
|
await expect(service.activateGrant(GRANT_ID)).rejects.toBeInstanceOf(ConflictException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws ConflictException when grant is expired', async () => {
|
||||||
|
db = makeDb({ selectRows: [makeMockGrant({ status: 'expired' })] });
|
||||||
|
service = new GrantsService(db as unknown as Db);
|
||||||
|
|
||||||
|
await expect(service.activateGrant(GRANT_ID)).rejects.toBeInstanceOf(ConflictException);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── revokeGrant ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('revokeGrant', () => {
|
||||||
|
it('transitions active → revoked and sets revokedAt', async () => {
|
||||||
|
const revokedAt = new Date();
|
||||||
|
db = makeDb({
|
||||||
|
selectRows: [makeMockGrant({ status: 'active' })],
|
||||||
|
updateReturning: [makeMockGrant({ status: 'revoked', revokedAt })],
|
||||||
|
});
|
||||||
|
service = new GrantsService(db as unknown as Db);
|
||||||
|
|
||||||
|
const result = await service.revokeGrant(GRANT_ID, 'test reason');
|
||||||
|
|
||||||
|
expect(db._mocks.setMock).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
status: 'revoked',
|
||||||
|
revokedAt: expect.any(Date),
|
||||||
|
revokedReason: 'test reason',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(result.status).toBe('revoked');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sets revokedReason to null when not provided', async () => {
|
||||||
|
db = makeDb({
|
||||||
|
selectRows: [makeMockGrant({ status: 'active' })],
|
||||||
|
updateReturning: [makeMockGrant({ status: 'revoked', revokedAt: new Date() })],
|
||||||
|
});
|
||||||
|
service = new GrantsService(db as unknown as Db);
|
||||||
|
|
||||||
|
await service.revokeGrant(GRANT_ID);
|
||||||
|
|
||||||
|
expect(db._mocks.setMock).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ revokedReason: null }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws ConflictException when grant is pending', async () => {
|
||||||
|
db = makeDb({ selectRows: [makeMockGrant({ status: 'pending' })] });
|
||||||
|
service = new GrantsService(db as unknown as Db);
|
||||||
|
|
||||||
|
await expect(service.revokeGrant(GRANT_ID)).rejects.toBeInstanceOf(ConflictException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws ConflictException when grant is already revoked', async () => {
|
||||||
|
db = makeDb({ selectRows: [makeMockGrant({ status: 'revoked' })] });
|
||||||
|
service = new GrantsService(db as unknown as Db);
|
||||||
|
|
||||||
|
await expect(service.revokeGrant(GRANT_ID)).rejects.toBeInstanceOf(ConflictException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws ConflictException when grant is expired', async () => {
|
||||||
|
db = makeDb({ selectRows: [makeMockGrant({ status: 'expired' })] });
|
||||||
|
service = new GrantsService(db as unknown as Db);
|
||||||
|
|
||||||
|
await expect(service.revokeGrant(GRANT_ID)).rejects.toBeInstanceOf(ConflictException);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── expireGrant ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('expireGrant', () => {
|
||||||
|
it('transitions active → expired and returns updated grant', async () => {
|
||||||
|
db = makeDb({
|
||||||
|
selectRows: [makeMockGrant({ status: 'active' })],
|
||||||
|
updateReturning: [makeMockGrant({ status: 'expired' })],
|
||||||
|
});
|
||||||
|
service = new GrantsService(db as unknown as Db);
|
||||||
|
|
||||||
|
const result = await service.expireGrant(GRANT_ID);
|
||||||
|
|
||||||
|
expect(db._mocks.setMock).toHaveBeenCalledWith({ status: 'expired' });
|
||||||
|
expect(result.status).toBe('expired');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws ConflictException when grant is pending', async () => {
|
||||||
|
db = makeDb({ selectRows: [makeMockGrant({ status: 'pending' })] });
|
||||||
|
service = new GrantsService(db as unknown as Db);
|
||||||
|
|
||||||
|
await expect(service.expireGrant(GRANT_ID)).rejects.toBeInstanceOf(ConflictException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws ConflictException when grant is already expired', async () => {
|
||||||
|
db = makeDb({ selectRows: [makeMockGrant({ status: 'expired' })] });
|
||||||
|
service = new GrantsService(db as unknown as Db);
|
||||||
|
|
||||||
|
await expect(service.expireGrant(GRANT_ID)).rejects.toBeInstanceOf(ConflictException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws ConflictException when grant is revoked', async () => {
|
||||||
|
db = makeDb({ selectRows: [makeMockGrant({ status: 'revoked' })] });
|
||||||
|
service = new GrantsService(db as unknown as Db);
|
||||||
|
|
||||||
|
await expect(service.expireGrant(GRANT_ID)).rejects.toBeInstanceOf(ConflictException);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
63
apps/gateway/src/federation/__tests__/peer-key.spec.ts
Normal file
63
apps/gateway/src/federation/__tests__/peer-key.spec.ts
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||||
|
import { sealClientKey, unsealClientKey } from '../peer-key.util.js';
|
||||||
|
|
||||||
|
const TEST_SECRET = 'test-secret-for-peer-key-unit-tests-only';
|
||||||
|
|
||||||
|
const TEST_PEM = `-----BEGIN PRIVATE KEY-----
|
||||||
|
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC7o4qne60TB3wo
|
||||||
|
pCOW8QqstpxEBpnFo37JxLYEJbpE3gUlJajsHv9UWRQ7m5B7n+MBXwTCQqMEY8Wl
|
||||||
|
kHv9tGgz1YGwzBjNKxPJXE6pPTXQ1Oa0VB9l3qHdqF5HtZoJzE0c6dO8HJ5YUVL
|
||||||
|
-----END PRIVATE KEY-----`;
|
||||||
|
|
||||||
|
let savedSecret: string | undefined;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
savedSecret = process.env['BETTER_AUTH_SECRET'];
|
||||||
|
process.env['BETTER_AUTH_SECRET'] = TEST_SECRET;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
if (savedSecret === undefined) {
|
||||||
|
delete process.env['BETTER_AUTH_SECRET'];
|
||||||
|
} else {
|
||||||
|
process.env['BETTER_AUTH_SECRET'] = savedSecret;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('peer-key seal/unseal', () => {
|
||||||
|
it('round-trip: unsealClientKey(sealClientKey(pem)) returns original pem', () => {
|
||||||
|
const sealed = sealClientKey(TEST_PEM);
|
||||||
|
const roundTripped = unsealClientKey(sealed);
|
||||||
|
expect(roundTripped).toBe(TEST_PEM);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('non-determinism: sealClientKey produces different ciphertext each call', () => {
|
||||||
|
const sealed1 = sealClientKey(TEST_PEM);
|
||||||
|
const sealed2 = sealClientKey(TEST_PEM);
|
||||||
|
expect(sealed1).not.toBe(sealed2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('at-rest: sealed output does not contain plaintext PEM content', () => {
|
||||||
|
const sealed = sealClientKey(TEST_PEM);
|
||||||
|
expect(sealed).not.toContain('PRIVATE KEY');
|
||||||
|
expect(sealed).not.toContain(
|
||||||
|
'MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC7o4qne60TB3wo',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tamper: flipping a byte in the sealed payload causes unseal to throw', () => {
|
||||||
|
const sealed = sealClientKey(TEST_PEM);
|
||||||
|
const buf = Buffer.from(sealed, 'base64');
|
||||||
|
// Flip a byte in the middle of the buffer (past IV and authTag)
|
||||||
|
const midpoint = Math.floor(buf.length / 2);
|
||||||
|
buf[midpoint] = buf[midpoint]! ^ 0xff;
|
||||||
|
const tampered = buf.toString('base64');
|
||||||
|
expect(() => unsealClientKey(tampered)).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('missing secret: unsealClientKey throws when BETTER_AUTH_SECRET is unset', () => {
|
||||||
|
const sealed = sealClientKey(TEST_PEM);
|
||||||
|
delete process.env['BETTER_AUTH_SECRET'];
|
||||||
|
expect(() => unsealClientKey(sealed)).toThrow('BETTER_AUTH_SECRET is not set');
|
||||||
|
});
|
||||||
|
});
|
||||||
57
apps/gateway/src/federation/ca.dto.ts
Normal file
57
apps/gateway/src/federation/ca.dto.ts
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
/**
|
||||||
|
* DTOs for the Step-CA client service (FED-M2-04).
|
||||||
|
*
|
||||||
|
* IssueCertRequestDto — input to CaService.issueCert()
|
||||||
|
* IssuedCertDto — output from CaService.issueCert()
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { IsInt, IsNotEmpty, IsOptional, IsString, IsUUID, Max, Min } from 'class-validator';
|
||||||
|
|
||||||
|
export class IssueCertRequestDto {
|
||||||
|
/**
|
||||||
|
* PEM-encoded PKCS#10 Certificate Signing Request.
|
||||||
|
* The CSR must already include the desired SANs.
|
||||||
|
*/
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
csrPem!: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UUID of the federation_grants row this certificate is being issued for.
|
||||||
|
* Embedded as the `mosaic_grant_id` custom OID extension.
|
||||||
|
*/
|
||||||
|
@IsUUID()
|
||||||
|
grantId!: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UUID of the local user on whose behalf the cert is being issued.
|
||||||
|
* Embedded as the `mosaic_subject_user_id` custom OID extension.
|
||||||
|
*/
|
||||||
|
@IsUUID()
|
||||||
|
subjectUserId!: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Requested certificate validity in seconds.
|
||||||
|
* Hard cap: 900 s (15 minutes). Default: 300 s (5 minutes).
|
||||||
|
* The service will always clamp to 900 s regardless of this value.
|
||||||
|
*/
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
@Min(60)
|
||||||
|
@Max(15 * 60)
|
||||||
|
ttlSeconds: number = 300;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class IssuedCertDto {
|
||||||
|
/** PEM-encoded leaf certificate returned by step-ca. */
|
||||||
|
certPem!: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PEM-encoded full certificate chain (leaf + intermediates + root).
|
||||||
|
* Falls back to `certPem` when step-ca returns no `certChain` field.
|
||||||
|
*/
|
||||||
|
certChainPem!: string;
|
||||||
|
|
||||||
|
/** Decimal serial number string of the issued certificate. */
|
||||||
|
serialNumber!: string;
|
||||||
|
}
|
||||||
577
apps/gateway/src/federation/ca.service.spec.ts
Normal file
577
apps/gateway/src/federation/ca.service.spec.ts
Normal file
@@ -0,0 +1,577 @@
|
|||||||
|
/**
|
||||||
|
* Unit tests for CaService — Step-CA client (FED-M2-04).
|
||||||
|
*
|
||||||
|
* Coverage:
|
||||||
|
* - Happy path: returns IssuedCertDto with certPem, certChainPem, serialNumber
|
||||||
|
* - certChainPem fallback: falls back to certPem when certChain absent
|
||||||
|
* - certChainPem from ca field: uses crt+ca when certChain absent but ca present
|
||||||
|
* - HTTP 401: throws CaServiceError with cause + remediation
|
||||||
|
* - HTTP non-401 error: throws CaServiceError
|
||||||
|
* - Malformed CSR: throws before HTTP call (INVALID_CSR)
|
||||||
|
* - Non-JSON response: throws CaServiceError
|
||||||
|
* - HTTPS connection error: throws CaServiceError
|
||||||
|
* - JWT custom claims: mosaic_grant_id and mosaic_subject_user_id present in OTT payload
|
||||||
|
* verified with jose.jwtVerify (real signature check)
|
||||||
|
* - CaServiceError: has cause + remediation properties
|
||||||
|
* - Missing crt in response: throws CaServiceError
|
||||||
|
* - Real CSR validation: valid P-256 CSR passes; malformed CSR fails with INVALID_CSR
|
||||||
|
* - provisionerPassword never appears in CaServiceError messages
|
||||||
|
* - HTTPS-only enforcement: http:// URL throws in constructor
|
||||||
|
*/
|
||||||
|
|
||||||
|
import 'reflect-metadata';
|
||||||
|
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
|
||||||
|
import { jwtVerify, exportJWK, generateKeyPair } from 'jose';
|
||||||
|
import { Pkcs10CertificateRequestGenerator } from '@peculiar/x509';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Mock node:https BEFORE importing CaService so the mock is in place when
|
||||||
|
// the module is loaded. Vitest/ESM require vi.mock at the top level.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
vi.mock('node:https', () => {
|
||||||
|
const mockRequest = vi.fn();
|
||||||
|
const mockAgent = vi.fn().mockImplementation(() => ({}));
|
||||||
|
return {
|
||||||
|
default: { request: mockRequest, Agent: mockAgent },
|
||||||
|
request: mockRequest,
|
||||||
|
Agent: mockAgent,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock('node:fs', () => {
|
||||||
|
const mockReadFileSync = vi
|
||||||
|
.fn()
|
||||||
|
.mockReturnValue('-----BEGIN CERTIFICATE-----\nFAKEROOT\n-----END CERTIFICATE-----\n');
|
||||||
|
return {
|
||||||
|
default: { readFileSync: mockReadFileSync },
|
||||||
|
readFileSync: mockReadFileSync,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// Real self-signed EC P-256 certificate generated with openssl for testing.
|
||||||
|
// openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:P-256 -nodes -keyout /dev/null \
|
||||||
|
// -out /dev/stdout -subj "/CN=test" -days 1
|
||||||
|
const FAKE_CERT_PEM = `-----BEGIN CERTIFICATE-----
|
||||||
|
MIIBdDCCARmgAwIBAgIUM+iUJSayN+PwXkyVN6qwSY7sr6gwCgYIKoZIzj0EAwIw
|
||||||
|
DzENMAsGA1UEAwwEdGVzdDAeFw0yNjA0MjIwMzE5MTlaFw0yNjA0MjMwMzE5MTla
|
||||||
|
MA8xDTALBgNVBAMMBHRlc3QwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAAR21kHL
|
||||||
|
n1GmFQ4TEBw3EA53pD+2McIBf5WcoHE+x0eMz5DpRKJe0ksHwOVN5Yev5d57kb+4
|
||||||
|
MvG1LhbHCB/uQo8So1MwUTAdBgNVHQ4EFgQUPq0pdIGiQ7pLBRXICS8GTliCrLsw
|
||||||
|
HwYDVR0jBBgwFoAUPq0pdIGiQ7pLBRXICS8GTliCrLswDwYDVR0TAQH/BAUwAwEB
|
||||||
|
/zAKBggqhkjOPQQDAgNJADBGAiEAypJqyC6S77aQ3eEXokM6sgAsD7Oa3tJbCbVm
|
||||||
|
zG3uJb0CIQC1w+GE+Ad0OTR5Quja46R1RjOo8ydpzZ7Fh4rouAiwEw==
|
||||||
|
-----END CERTIFICATE-----
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Use a second copy of the same cert for the CA field in tests.
|
||||||
|
const FAKE_CA_PEM = FAKE_CERT_PEM;
|
||||||
|
|
||||||
|
const GRANT_ID = 'a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11';
|
||||||
|
const SUBJECT_USER_ID = 'b1ffcd00-0d1c-5f09-cc7e-7cc0ce491b22';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Generate a real EC P-256 key pair and CSR for integration-style tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// We generate this once at module level so it's available to all tests.
|
||||||
|
// The key pair and CSR PEM are populated asynchronously in the test that needs them.
|
||||||
|
|
||||||
|
let realCsrPem: string;
|
||||||
|
|
||||||
|
async function generateRealCsr(): Promise<string> {
|
||||||
|
const { privateKey, publicKey } = await generateKeyPair('ES256');
|
||||||
|
// Export public key JWK for potential verification (not used here but confirms key is exportable)
|
||||||
|
await exportJWK(publicKey);
|
||||||
|
|
||||||
|
// Use @peculiar/x509 to build a proper CSR
|
||||||
|
const csr = await Pkcs10CertificateRequestGenerator.create({
|
||||||
|
name: 'CN=test.federation.local',
|
||||||
|
signingAlgorithm: { name: 'ECDSA', hash: 'SHA-256' },
|
||||||
|
keys: { privateKey, publicKey },
|
||||||
|
});
|
||||||
|
|
||||||
|
return csr.toString('pem');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Setup env before importing service
|
||||||
|
// We use an EC P-256 key pair here so the JWK-based signing works.
|
||||||
|
// The key pair is generated once and stored in module-level vars.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// Real EC P-256 test JWK (test-only, never used in production).
|
||||||
|
// Generated with node webcrypto for use in unit tests.
|
||||||
|
const TEST_EC_PRIVATE_JWK = {
|
||||||
|
key_ops: ['sign'],
|
||||||
|
ext: true,
|
||||||
|
kty: 'EC',
|
||||||
|
x: 'Xq2RjZctcPcUMU14qfjs3MtZTmFk8z1lFGQyypgXZOU',
|
||||||
|
y: 't8w9Cbt4RVmR47Wnb_i5cLwefEnMcvwse049zu9Rl_E',
|
||||||
|
crv: 'P-256',
|
||||||
|
d: 'TM6N79w1HE-PiML5Td4mbXfJaLHEaZrVyVrrwlJv7q8',
|
||||||
|
kid: 'test-ec-kid',
|
||||||
|
};
|
||||||
|
|
||||||
|
const TEST_EC_PUBLIC_JWK = {
|
||||||
|
key_ops: ['verify'],
|
||||||
|
ext: true,
|
||||||
|
kty: 'EC',
|
||||||
|
x: 'Xq2RjZctcPcUMU14qfjs3MtZTmFk8z1lFGQyypgXZOU',
|
||||||
|
y: 't8w9Cbt4RVmR47Wnb_i5cLwefEnMcvwse049zu9Rl_E',
|
||||||
|
crv: 'P-256',
|
||||||
|
kid: 'test-ec-kid',
|
||||||
|
};
|
||||||
|
|
||||||
|
process.env['STEP_CA_URL'] = 'https://step-ca:9000';
|
||||||
|
process.env['STEP_CA_PROVISIONER_KEY_JSON'] = JSON.stringify(TEST_EC_PRIVATE_JWK);
|
||||||
|
process.env['STEP_CA_ROOT_CERT_PATH'] = '/fake/root.pem';
|
||||||
|
|
||||||
|
// Import AFTER env is set and mocks are registered
|
||||||
|
import * as httpsModule from 'node:https';
|
||||||
|
import { CaService, CaServiceError } from './ca.service.js';
|
||||||
|
import type { IssueCertRequestDto } from './ca.dto.js';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helper to build a mock https.request that simulates step-ca
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function makeHttpsMock(statusCode: number, body: unknown, errorMsg?: string): void {
|
||||||
|
const mockReq = {
|
||||||
|
write: vi.fn(),
|
||||||
|
end: vi.fn(),
|
||||||
|
on: vi.fn(),
|
||||||
|
setTimeout: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
(httpsModule.request as unknown as Mock).mockImplementation(
|
||||||
|
(
|
||||||
|
_options: unknown,
|
||||||
|
callback: (res: {
|
||||||
|
statusCode: number;
|
||||||
|
on: (event: string, cb: (chunk?: Buffer) => void) => void;
|
||||||
|
}) => void,
|
||||||
|
) => {
|
||||||
|
const mockRes = {
|
||||||
|
statusCode,
|
||||||
|
on: (event: string, cb: (chunk?: Buffer) => void) => {
|
||||||
|
if (event === 'data') {
|
||||||
|
if (body !== undefined) {
|
||||||
|
cb(Buffer.from(typeof body === 'string' ? body : JSON.stringify(body)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (event === 'end') {
|
||||||
|
cb();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
if (errorMsg) {
|
||||||
|
// Simulate a connection error via the req.on('error') handler
|
||||||
|
mockReq.on.mockImplementation((event: string, cb: (err: Error) => void) => {
|
||||||
|
if (event === 'error') {
|
||||||
|
setImmediate(() => cb(new Error(errorMsg)));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Normal flow: call the response callback
|
||||||
|
setImmediate(() => callback(mockRes));
|
||||||
|
}
|
||||||
|
|
||||||
|
return mockReq;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('CaService', () => {
|
||||||
|
let service: CaService;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
service = new CaService();
|
||||||
|
});
|
||||||
|
|
||||||
|
function makeReq(overrides: Partial<IssueCertRequestDto> = {}): IssueCertRequestDto {
|
||||||
|
// Use a real CSR if available; fall back to a minimal placeholder
|
||||||
|
const defaultCsr = realCsrPem ?? makeFakeCsr();
|
||||||
|
return {
|
||||||
|
csrPem: defaultCsr,
|
||||||
|
grantId: GRANT_ID,
|
||||||
|
subjectUserId: SUBJECT_USER_ID,
|
||||||
|
ttlSeconds: 300,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeFakeCsr(): string {
|
||||||
|
// A structurally valid-looking CSR header/footer (body will fail crypto verify)
|
||||||
|
return `-----BEGIN CERTIFICATE REQUEST-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA0000000000000000AAAA\n-----END CERTIFICATE REQUEST-----\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Real CSR generation — runs once and populates realCsrPem
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
it('generates a real P-256 CSR that passes validateCsr', async () => {
|
||||||
|
realCsrPem = await generateRealCsr();
|
||||||
|
expect(realCsrPem).toMatch(/BEGIN CERTIFICATE REQUEST/);
|
||||||
|
|
||||||
|
// Now test that the service's validateCsr accepts it.
|
||||||
|
// We call it indirectly via issueCert with a successful mock.
|
||||||
|
makeHttpsMock(200, { crt: FAKE_CERT_PEM, certChain: [FAKE_CERT_PEM, FAKE_CA_PEM] });
|
||||||
|
const result = await service.issueCert(makeReq({ csrPem: realCsrPem }));
|
||||||
|
expect(result.certPem).toBe(FAKE_CERT_PEM);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws INVALID_CSR for a malformed PEM-shaped CSR', async () => {
|
||||||
|
const malformedCsr =
|
||||||
|
'-----BEGIN CERTIFICATE REQUEST-----\nTm90QVJlYWxDU1I=\n-----END CERTIFICATE REQUEST-----\n';
|
||||||
|
|
||||||
|
await expect(service.issueCert(makeReq({ csrPem: malformedCsr }))).rejects.toSatisfy(
|
||||||
|
(err: unknown) => {
|
||||||
|
if (!(err instanceof CaServiceError)) return false;
|
||||||
|
expect(err.code).toBe('INVALID_CSR');
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Happy path
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
it('returns IssuedCertDto on success (certChain present)', async () => {
|
||||||
|
if (!realCsrPem) realCsrPem = await generateRealCsr();
|
||||||
|
makeHttpsMock(200, {
|
||||||
|
crt: FAKE_CERT_PEM,
|
||||||
|
certChain: [FAKE_CERT_PEM, FAKE_CA_PEM],
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await service.issueCert(makeReq());
|
||||||
|
|
||||||
|
expect(result.certPem).toBe(FAKE_CERT_PEM);
|
||||||
|
expect(result.certChainPem).toContain(FAKE_CERT_PEM);
|
||||||
|
expect(result.certChainPem).toContain(FAKE_CA_PEM);
|
||||||
|
expect(typeof result.serialNumber).toBe('string');
|
||||||
|
});
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// certChainPem fallback — certChain absent, ca field present
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
it('builds certChainPem from crt+ca when certChain is absent', async () => {
|
||||||
|
if (!realCsrPem) realCsrPem = await generateRealCsr();
|
||||||
|
makeHttpsMock(200, {
|
||||||
|
crt: FAKE_CERT_PEM,
|
||||||
|
ca: FAKE_CA_PEM,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await service.issueCert(makeReq());
|
||||||
|
|
||||||
|
expect(result.certPem).toBe(FAKE_CERT_PEM);
|
||||||
|
expect(result.certChainPem).toContain(FAKE_CERT_PEM);
|
||||||
|
expect(result.certChainPem).toContain(FAKE_CA_PEM);
|
||||||
|
});
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// certChainPem fallback — no certChain, no ca field
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
it('falls back to certPem alone when certChain and ca are absent', async () => {
|
||||||
|
if (!realCsrPem) realCsrPem = await generateRealCsr();
|
||||||
|
makeHttpsMock(200, { crt: FAKE_CERT_PEM });
|
||||||
|
|
||||||
|
const result = await service.issueCert(makeReq());
|
||||||
|
|
||||||
|
expect(result.certPem).toBe(FAKE_CERT_PEM);
|
||||||
|
expect(result.certChainPem).toBe(FAKE_CERT_PEM);
|
||||||
|
});
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// HTTP 401
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
it('throws CaServiceError on HTTP 401', async () => {
|
||||||
|
if (!realCsrPem) realCsrPem = await generateRealCsr();
|
||||||
|
makeHttpsMock(401, { message: 'Unauthorized' });
|
||||||
|
|
||||||
|
await expect(service.issueCert(makeReq())).rejects.toSatisfy((err: unknown) => {
|
||||||
|
if (!(err instanceof CaServiceError)) return false;
|
||||||
|
expect(err.message).toMatch(/401/);
|
||||||
|
expect(err.remediation).toBeTruthy();
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// HTTP non-401 error (e.g. 422)
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
it('throws CaServiceError on HTTP 422', async () => {
|
||||||
|
if (!realCsrPem) realCsrPem = await generateRealCsr();
|
||||||
|
makeHttpsMock(422, { message: 'Unprocessable Entity' });
|
||||||
|
|
||||||
|
await expect(service.issueCert(makeReq())).rejects.toBeInstanceOf(CaServiceError);
|
||||||
|
});
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Malformed CSR — throws before HTTP call
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
it('throws CaServiceError for malformed CSR without making HTTP call', async () => {
|
||||||
|
const requestSpy = vi.spyOn(httpsModule, 'request');
|
||||||
|
|
||||||
|
await expect(service.issueCert(makeReq({ csrPem: 'not-a-valid-csr' }))).rejects.toBeInstanceOf(
|
||||||
|
CaServiceError,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(requestSpy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Non-JSON response
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
it('throws CaServiceError when step-ca returns non-JSON', async () => {
|
||||||
|
if (!realCsrPem) realCsrPem = await generateRealCsr();
|
||||||
|
makeHttpsMock(200, 'this is not json');
|
||||||
|
|
||||||
|
await expect(service.issueCert(makeReq())).rejects.toSatisfy((err: unknown) => {
|
||||||
|
if (!(err instanceof CaServiceError)) return false;
|
||||||
|
expect(err.message).toMatch(/non-JSON/);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// HTTPS connection error
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
it('throws CaServiceError on HTTPS connection error', async () => {
|
||||||
|
if (!realCsrPem) realCsrPem = await generateRealCsr();
|
||||||
|
makeHttpsMock(0, undefined, 'connect ECONNREFUSED 127.0.0.1:9000');
|
||||||
|
|
||||||
|
await expect(service.issueCert(makeReq())).rejects.toSatisfy((err: unknown) => {
|
||||||
|
if (!(err instanceof CaServiceError)) return false;
|
||||||
|
expect(err.message).toMatch(/HTTPS connection/);
|
||||||
|
expect(err.cause).toBeInstanceOf(Error);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// JWT custom claims: mosaic_grant_id and mosaic_subject_user_id
|
||||||
|
// Verified with jose.jwtVerify for real signature verification (M6)
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
it('OTT contains mosaic_grant_id, mosaic_subject_user_id, and jti; signature verifies with jose', async () => {
|
||||||
|
if (!realCsrPem) realCsrPem = await generateRealCsr();
|
||||||
|
|
||||||
|
let capturedBody: Record<string, unknown> | undefined;
|
||||||
|
|
||||||
|
const mockReq = {
|
||||||
|
write: vi.fn((data: string) => {
|
||||||
|
capturedBody = JSON.parse(data) as Record<string, unknown>;
|
||||||
|
}),
|
||||||
|
end: vi.fn(),
|
||||||
|
on: vi.fn(),
|
||||||
|
setTimeout: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
(httpsModule.request as unknown as Mock).mockImplementation(
|
||||||
|
(
|
||||||
|
_options: unknown,
|
||||||
|
callback: (res: {
|
||||||
|
statusCode: number;
|
||||||
|
on: (event: string, cb: (chunk?: Buffer) => void) => void;
|
||||||
|
}) => void,
|
||||||
|
) => {
|
||||||
|
const mockRes = {
|
||||||
|
statusCode: 200,
|
||||||
|
on: (event: string, cb: (chunk?: Buffer) => void) => {
|
||||||
|
if (event === 'data') {
|
||||||
|
cb(Buffer.from(JSON.stringify({ crt: FAKE_CERT_PEM })));
|
||||||
|
}
|
||||||
|
if (event === 'end') {
|
||||||
|
cb();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
setImmediate(() => callback(mockRes));
|
||||||
|
return mockReq;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
await service.issueCert(makeReq({ csrPem: realCsrPem }));
|
||||||
|
|
||||||
|
expect(capturedBody).toBeDefined();
|
||||||
|
const ott = capturedBody!['ott'] as string;
|
||||||
|
expect(typeof ott).toBe('string');
|
||||||
|
|
||||||
|
// Verify JWT structure
|
||||||
|
const parts = ott.split('.');
|
||||||
|
expect(parts).toHaveLength(3);
|
||||||
|
|
||||||
|
// Decode payload without signature check first
|
||||||
|
const payloadJson = Buffer.from(parts[1]!, 'base64url').toString('utf8');
|
||||||
|
const payload = JSON.parse(payloadJson) as Record<string, unknown>;
|
||||||
|
|
||||||
|
expect(payload['mosaic_grant_id']).toBe(GRANT_ID);
|
||||||
|
expect(payload['mosaic_subject_user_id']).toBe(SUBJECT_USER_ID);
|
||||||
|
expect(typeof payload['jti']).toBe('string'); // M2: jti present
|
||||||
|
expect(payload['jti']).toMatch(/^[0-9a-f-]{36}$/); // UUID format
|
||||||
|
|
||||||
|
// M3: top-level sha should NOT be present; step.sha should be present
|
||||||
|
expect(payload['sha']).toBeUndefined();
|
||||||
|
const step = payload['step'] as Record<string, unknown> | undefined;
|
||||||
|
expect(step?.['sha']).toBeDefined();
|
||||||
|
|
||||||
|
// M6: Verify signature with jose.jwtVerify using the public key
|
||||||
|
const { importJWK: importJose } = await import('jose');
|
||||||
|
const publicKey = await importJose(TEST_EC_PUBLIC_JWK, 'ES256');
|
||||||
|
const verified = await jwtVerify(ott, publicKey);
|
||||||
|
expect(verified.payload['mosaic_grant_id']).toBe(GRANT_ID);
|
||||||
|
});
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// CaServiceError has cause + remediation
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
it('CaServiceError carries cause and remediation', () => {
|
||||||
|
const cause = new Error('original error');
|
||||||
|
const err = new CaServiceError('something went wrong', 'fix it like this', cause);
|
||||||
|
|
||||||
|
expect(err).toBeInstanceOf(Error);
|
||||||
|
expect(err).toBeInstanceOf(CaServiceError);
|
||||||
|
expect(err.message).toBe('something went wrong');
|
||||||
|
expect(err.remediation).toBe('fix it like this');
|
||||||
|
expect(err.cause).toBe(cause);
|
||||||
|
expect(err.name).toBe('CaServiceError');
|
||||||
|
});
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Missing crt in response
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
it('throws CaServiceError when response is missing the crt field', async () => {
|
||||||
|
if (!realCsrPem) realCsrPem = await generateRealCsr();
|
||||||
|
makeHttpsMock(200, { ca: FAKE_CA_PEM });
|
||||||
|
|
||||||
|
await expect(service.issueCert(makeReq())).rejects.toSatisfy((err: unknown) => {
|
||||||
|
if (!(err instanceof CaServiceError)) return false;
|
||||||
|
expect(err.message).toMatch(/missing the "crt" field/);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// M6: provisionerPassword must never appear in CaServiceError messages
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
it('provisionerPassword does not appear in any CaServiceError message', async () => {
|
||||||
|
// Temporarily set a recognizable password to test against
|
||||||
|
const originalPassword = process.env['STEP_CA_PROVISIONER_PASSWORD'];
|
||||||
|
process.env['STEP_CA_PROVISIONER_PASSWORD'] = 'super-secret-password-12345';
|
||||||
|
|
||||||
|
// Generate a bad CSR to trigger an error path
|
||||||
|
const caughtErrors: CaServiceError[] = [];
|
||||||
|
try {
|
||||||
|
await service.issueCert(makeReq({ csrPem: 'not-a-csr' }));
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof CaServiceError) {
|
||||||
|
caughtErrors.push(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also try HTTP 401 path
|
||||||
|
if (!realCsrPem) realCsrPem = await generateRealCsr();
|
||||||
|
makeHttpsMock(401, { message: 'Unauthorized' });
|
||||||
|
try {
|
||||||
|
await service.issueCert(makeReq({ csrPem: realCsrPem }));
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof CaServiceError) {
|
||||||
|
caughtErrors.push(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const err of caughtErrors) {
|
||||||
|
expect(err.message).not.toContain('super-secret-password-12345');
|
||||||
|
if (err.remediation) {
|
||||||
|
expect(err.remediation).not.toContain('super-secret-password-12345');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
process.env['STEP_CA_PROVISIONER_PASSWORD'] = originalPassword;
|
||||||
|
});
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// M7: HTTPS-only enforcement in constructor
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
it('throws in constructor if STEP_CA_URL uses http://', () => {
|
||||||
|
const originalUrl = process.env['STEP_CA_URL'];
|
||||||
|
process.env['STEP_CA_URL'] = 'http://step-ca:9000';
|
||||||
|
|
||||||
|
expect(() => new CaService()).toThrow(CaServiceError);
|
||||||
|
|
||||||
|
process.env['STEP_CA_URL'] = originalUrl;
|
||||||
|
});
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// TTL clamp: ttlSeconds is clamped to 900 s (15 min) maximum
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
it('clamps ttlSeconds to 900 s regardless of input', async () => {
|
||||||
|
if (!realCsrPem) realCsrPem = await generateRealCsr();
|
||||||
|
|
||||||
|
let capturedBody: Record<string, unknown> | undefined;
|
||||||
|
|
||||||
|
const mockReq = {
|
||||||
|
write: vi.fn((data: string) => {
|
||||||
|
capturedBody = JSON.parse(data) as Record<string, unknown>;
|
||||||
|
}),
|
||||||
|
end: vi.fn(),
|
||||||
|
on: vi.fn(),
|
||||||
|
setTimeout: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
(httpsModule.request as unknown as Mock).mockImplementation(
|
||||||
|
(
|
||||||
|
_options: unknown,
|
||||||
|
callback: (res: {
|
||||||
|
statusCode: number;
|
||||||
|
on: (event: string, cb: (chunk?: Buffer) => void) => void;
|
||||||
|
}) => void,
|
||||||
|
) => {
|
||||||
|
const mockRes = {
|
||||||
|
statusCode: 200,
|
||||||
|
on: (event: string, cb: (chunk?: Buffer) => void) => {
|
||||||
|
if (event === 'data') {
|
||||||
|
cb(Buffer.from(JSON.stringify({ crt: FAKE_CERT_PEM })));
|
||||||
|
}
|
||||||
|
if (event === 'end') {
|
||||||
|
cb();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
setImmediate(() => callback(mockRes));
|
||||||
|
return mockReq;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Request 86400 s — should be clamped to 900
|
||||||
|
await service.issueCert(makeReq({ ttlSeconds: 86400 }));
|
||||||
|
|
||||||
|
expect(capturedBody).toBeDefined();
|
||||||
|
const validity = capturedBody!['validity'] as Record<string, unknown>;
|
||||||
|
expect(validity['duration']).toBe('900s');
|
||||||
|
});
|
||||||
|
});
|
||||||
680
apps/gateway/src/federation/ca.service.ts
Normal file
680
apps/gateway/src/federation/ca.service.ts
Normal file
@@ -0,0 +1,680 @@
|
|||||||
|
/**
|
||||||
|
* CaService — Step-CA client for federation grant certificate issuance.
|
||||||
|
*
|
||||||
|
* Responsibilities:
|
||||||
|
* 1. Build a JWK-provisioner One-Time Token (OTT) signed with the provisioner
|
||||||
|
* private key (ES256/ES384/RS256 per JWK kty/crv) carrying Mosaic-specific
|
||||||
|
* claims (`mosaic_grant_id`, `mosaic_subject_user_id`, `step.sha`) per the
|
||||||
|
* step-ca JWK provisioner protocol.
|
||||||
|
* 2. POST the CSR + OTT to the step-ca `/1.0/sign` endpoint over HTTPS,
|
||||||
|
* pinning the trust to the CA root cert supplied via env.
|
||||||
|
* 3. Return an IssuedCertDto containing the leaf cert, full chain, and
|
||||||
|
* serial number.
|
||||||
|
*
|
||||||
|
* Environment variables (all required at runtime — validated in constructor):
|
||||||
|
* STEP_CA_URL https://step-ca:9000
|
||||||
|
* STEP_CA_PROVISIONER_KEY_JSON JWK provisioner private key (JSON)
|
||||||
|
* STEP_CA_ROOT_CERT_PATH Absolute path to the CA root PEM
|
||||||
|
*
|
||||||
|
* Optional (only used for JWK PBES2 decrypt at startup if key is encrypted):
|
||||||
|
* STEP_CA_PROVISIONER_PASSWORD JWK provisioner password (raw string)
|
||||||
|
*
|
||||||
|
* 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
|
||||||
|
*
|
||||||
|
* Fail-loud contract:
|
||||||
|
* Every error path throws CaServiceError with a human-readable `remediation`
|
||||||
|
* field. Silent OID-stripping is NEVER allowed — if the sign response does
|
||||||
|
* not include the cert, we throw rather than return a cert that may be
|
||||||
|
* missing the custom extensions.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import * as crypto from 'node:crypto';
|
||||||
|
import * as fs from 'node:fs';
|
||||||
|
import * as https from 'node:https';
|
||||||
|
import { SignJWT, importJWK } from 'jose';
|
||||||
|
import { Pkcs10CertificateRequest, X509Certificate } from '@peculiar/x509';
|
||||||
|
import type { IssueCertRequestDto } from './ca.dto.js';
|
||||||
|
import { IssuedCertDto } from './ca.dto.js';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Custom error class
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export class CaServiceError extends Error {
|
||||||
|
readonly cause: unknown;
|
||||||
|
readonly remediation: string;
|
||||||
|
readonly code?: string;
|
||||||
|
|
||||||
|
constructor(message: string, remediation: string, cause?: unknown, code?: string) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'CaServiceError';
|
||||||
|
this.cause = cause;
|
||||||
|
this.remediation = remediation;
|
||||||
|
this.code = code;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Internal types
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface StepSignResponse {
|
||||||
|
crt: string;
|
||||||
|
ca?: string;
|
||||||
|
certChain?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface JwkKey {
|
||||||
|
kty: string;
|
||||||
|
kid?: string;
|
||||||
|
use?: string;
|
||||||
|
alg?: string;
|
||||||
|
k?: string; // symmetric
|
||||||
|
n?: string; // RSA
|
||||||
|
e?: string;
|
||||||
|
d?: string;
|
||||||
|
x?: string; // EC
|
||||||
|
y?: string;
|
||||||
|
crv?: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** UUID regex for validation */
|
||||||
|
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derive the JWT algorithm string from a JWK's kty/crv fields.
|
||||||
|
* EC P-256 → ES256, EC P-384 → ES384, RSA → RS256.
|
||||||
|
*/
|
||||||
|
function algFromJwk(jwk: JwkKey): string {
|
||||||
|
if (jwk.alg) return jwk.alg;
|
||||||
|
if (jwk.kty === 'EC') {
|
||||||
|
if (jwk.crv === 'P-384') return 'ES384';
|
||||||
|
return 'ES256'; // default for P-256 and Ed25519-style EC keys
|
||||||
|
}
|
||||||
|
if (jwk.kty === 'RSA') return 'RS256';
|
||||||
|
throw new CaServiceError(
|
||||||
|
`Unsupported JWK kty: ${jwk.kty}`,
|
||||||
|
'STEP_CA_PROVISIONER_KEY_JSON must be an EC (P-256/P-384) or RSA JWK private key.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute SHA-256 fingerprint of the DER-encoded CSR body.
|
||||||
|
* step-ca uses this as the `step.sha` claim to bind the OTT to a specific CSR.
|
||||||
|
*/
|
||||||
|
function csrFingerprint(csrPem: string): string {
|
||||||
|
// Strip PEM headers and decode base64 body
|
||||||
|
const b64 = csrPem
|
||||||
|
.replace(/-----BEGIN CERTIFICATE REQUEST-----/, '')
|
||||||
|
.replace(/-----END CERTIFICATE REQUEST-----/, '')
|
||||||
|
.replace(/\s+/g, '');
|
||||||
|
|
||||||
|
let derBuf: Buffer;
|
||||||
|
try {
|
||||||
|
derBuf = Buffer.from(b64, 'base64');
|
||||||
|
} catch (err) {
|
||||||
|
throw new CaServiceError(
|
||||||
|
'Failed to base64-decode the CSR PEM body',
|
||||||
|
'Verify that csrPem is a valid PKCS#10 PEM-encoded certificate request.',
|
||||||
|
err,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (derBuf.length === 0) {
|
||||||
|
throw new CaServiceError(
|
||||||
|
'CSR PEM decoded to empty buffer — malformed input',
|
||||||
|
'Provide a valid non-empty PKCS#10 PEM-encoded certificate request.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return crypto.createHash('sha256').update(derBuf).digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a JSON POST to the step-ca sign endpoint.
|
||||||
|
* Returns the parsed response body or throws CaServiceError.
|
||||||
|
*/
|
||||||
|
function httpsPost(url: string, body: unknown, agent: https.Agent): Promise<StepSignResponse> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const bodyStr = JSON.stringify(body);
|
||||||
|
const parsed = new URL(url);
|
||||||
|
|
||||||
|
const options: https.RequestOptions = {
|
||||||
|
hostname: parsed.hostname,
|
||||||
|
port: parsed.port ? parseInt(parsed.port, 10) : 443,
|
||||||
|
path: parsed.pathname,
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Content-Length': Buffer.byteLength(bodyStr),
|
||||||
|
},
|
||||||
|
agent,
|
||||||
|
timeout: 5000,
|
||||||
|
};
|
||||||
|
|
||||||
|
const req = https.request(options, (res) => {
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
res.on('data', (chunk: Buffer) => chunks.push(chunk));
|
||||||
|
res.on('end', () => {
|
||||||
|
const raw = Buffer.concat(chunks).toString('utf8');
|
||||||
|
|
||||||
|
if (res.statusCode === 401) {
|
||||||
|
reject(
|
||||||
|
new CaServiceError(
|
||||||
|
`step-ca returned HTTP 401 — invalid or expired OTT`,
|
||||||
|
'Check STEP_CA_PROVISIONER_KEY_JSON. Ensure the mosaic-fed provisioner is configured in the CA.',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (res.statusCode && res.statusCode >= 400) {
|
||||||
|
reject(
|
||||||
|
new CaServiceError(
|
||||||
|
`step-ca returned HTTP ${res.statusCode}: ${raw.slice(0, 256)}`,
|
||||||
|
`Review the step-ca logs. Status ${res.statusCode} may indicate a CSR policy violation or misconfigured provisioner.`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let parsed: unknown;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(raw) as unknown;
|
||||||
|
} catch (err) {
|
||||||
|
reject(
|
||||||
|
new CaServiceError(
|
||||||
|
'step-ca returned a non-JSON response',
|
||||||
|
'Verify STEP_CA_URL points to a running step-ca instance and that TLS is properly configured.',
|
||||||
|
err,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve(parsed as StepSignResponse);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
req.setTimeout(5000, () => {
|
||||||
|
req.destroy(new Error('Request timed out after 5000ms'));
|
||||||
|
});
|
||||||
|
|
||||||
|
req.on('error', (err: Error) => {
|
||||||
|
reject(
|
||||||
|
new CaServiceError(
|
||||||
|
`HTTPS connection to step-ca failed: ${err.message}`,
|
||||||
|
'Ensure STEP_CA_URL is reachable and STEP_CA_ROOT_CERT_PATH points to the correct CA root certificate.',
|
||||||
|
err,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
req.write(bodyStr);
|
||||||
|
req.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract a decimal serial number from a PEM certificate.
|
||||||
|
* Throws CaServiceError on failure — never silently returns 'unknown'.
|
||||||
|
*/
|
||||||
|
function extractSerial(certPem: string): string {
|
||||||
|
let cert: crypto.X509Certificate;
|
||||||
|
try {
|
||||||
|
cert = new crypto.X509Certificate(certPem);
|
||||||
|
} catch (err) {
|
||||||
|
throw new CaServiceError(
|
||||||
|
'Failed to parse the issued certificate PEM',
|
||||||
|
'The certificate returned by step-ca could not be parsed. Check that step-ca is returning a valid PEM certificate.',
|
||||||
|
err,
|
||||||
|
'CERT_PARSE',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return cert.serialNumber;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Service
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class CaService {
|
||||||
|
private readonly logger = new Logger(CaService.name);
|
||||||
|
|
||||||
|
private readonly caUrl: string;
|
||||||
|
private readonly rootCertPath: string;
|
||||||
|
private readonly httpsAgent: https.Agent;
|
||||||
|
private readonly jwk: JwkKey;
|
||||||
|
private cachedPrivateKey: crypto.KeyObject | null = null;
|
||||||
|
private readonly jwtAlg: string;
|
||||||
|
private readonly kid: string;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
const caUrl = process.env['STEP_CA_URL'];
|
||||||
|
const provisionerKeyJson = process.env['STEP_CA_PROVISIONER_KEY_JSON'];
|
||||||
|
const rootCertPath = process.env['STEP_CA_ROOT_CERT_PATH'];
|
||||||
|
|
||||||
|
if (!caUrl) {
|
||||||
|
throw new CaServiceError(
|
||||||
|
'STEP_CA_URL is not set',
|
||||||
|
'Set STEP_CA_URL to the base URL of the step-ca instance, e.g. https://step-ca:9000',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enforce HTTPS-only URL
|
||||||
|
let parsedUrl: URL;
|
||||||
|
try {
|
||||||
|
parsedUrl = new URL(caUrl);
|
||||||
|
} catch (err) {
|
||||||
|
throw new CaServiceError(
|
||||||
|
`STEP_CA_URL is not a valid URL: ${caUrl}`,
|
||||||
|
'Set STEP_CA_URL to a valid HTTPS URL, e.g. https://step-ca:9000',
|
||||||
|
err,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (parsedUrl.protocol !== 'https:') {
|
||||||
|
throw new CaServiceError(
|
||||||
|
`STEP_CA_URL must use HTTPS — got: ${parsedUrl.protocol}`,
|
||||||
|
'Set STEP_CA_URL to an https:// URL. Unencrypted connections to the CA are not permitted.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!provisionerKeyJson) {
|
||||||
|
throw new CaServiceError(
|
||||||
|
'STEP_CA_PROVISIONER_KEY_JSON is not set',
|
||||||
|
'Set STEP_CA_PROVISIONER_KEY_JSON to the JSON-encoded JWK for the mosaic-fed provisioner.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!rootCertPath) {
|
||||||
|
throw new CaServiceError(
|
||||||
|
'STEP_CA_ROOT_CERT_PATH is not set',
|
||||||
|
'Set STEP_CA_ROOT_CERT_PATH to the absolute path of the step-ca root CA certificate PEM file.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse JWK once — do NOT store the raw JSON string as a class field
|
||||||
|
let jwk: JwkKey;
|
||||||
|
try {
|
||||||
|
jwk = JSON.parse(provisionerKeyJson) as JwkKey;
|
||||||
|
} catch (err) {
|
||||||
|
throw new CaServiceError(
|
||||||
|
'STEP_CA_PROVISIONER_KEY_JSON is not valid JSON',
|
||||||
|
'Set STEP_CA_PROVISIONER_KEY_JSON to the JSON-serialised JWK object for the mosaic-fed provisioner.',
|
||||||
|
err,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Derive algorithm from JWK metadata
|
||||||
|
const jwtAlg = algFromJwk(jwk);
|
||||||
|
const kid = jwk.kid ?? 'mosaic-fed';
|
||||||
|
|
||||||
|
// Import the JWK into a native KeyObject — fail loudly if it cannot be loaded.
|
||||||
|
// We do this synchronously here by calling the async importJWK via a blocking workaround.
|
||||||
|
// Actually importJWK is async, so we store it for use during token building.
|
||||||
|
// We keep the raw jwk object for later async import inside buildOtt.
|
||||||
|
// NOTE: We do NOT store provisionerKeyJson string as a class field.
|
||||||
|
this.jwk = jwk;
|
||||||
|
this.jwtAlg = jwtAlg;
|
||||||
|
this.kid = kid;
|
||||||
|
|
||||||
|
this.caUrl = caUrl;
|
||||||
|
this.rootCertPath = rootCertPath;
|
||||||
|
|
||||||
|
// Read the root cert and pin it for all HTTPS connections.
|
||||||
|
let rootCert: string;
|
||||||
|
try {
|
||||||
|
rootCert = fs.readFileSync(this.rootCertPath, 'utf8');
|
||||||
|
} catch (err) {
|
||||||
|
throw new CaServiceError(
|
||||||
|
`Cannot read STEP_CA_ROOT_CERT_PATH: ${rootCertPath}`,
|
||||||
|
'Ensure the file exists and is readable by the gateway process.',
|
||||||
|
err,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.httpsAgent = new https.Agent({
|
||||||
|
ca: rootCert,
|
||||||
|
rejectUnauthorized: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logger.log(`CaService initialised — CA URL: ${this.caUrl}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lazily import the private key from JWK on first use.
|
||||||
|
* The key is cached in cachedPrivateKey after first import.
|
||||||
|
*/
|
||||||
|
private async getPrivateKey(): Promise<crypto.KeyObject> {
|
||||||
|
if (this.cachedPrivateKey !== null) return this.cachedPrivateKey;
|
||||||
|
try {
|
||||||
|
const key = await importJWK(this.jwk, this.jwtAlg);
|
||||||
|
// importJWK returns KeyLike (crypto.KeyObject | Uint8Array) — in Node.js it's KeyObject
|
||||||
|
this.cachedPrivateKey = key as unknown as crypto.KeyObject;
|
||||||
|
return this.cachedPrivateKey;
|
||||||
|
} catch (err) {
|
||||||
|
throw new CaServiceError(
|
||||||
|
'Failed to import STEP_CA_PROVISIONER_KEY_JSON as a cryptographic key',
|
||||||
|
'Ensure STEP_CA_PROVISIONER_KEY_JSON contains a valid JWK private key (EC P-256/P-384 or RSA).',
|
||||||
|
err,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the JWK-provisioner OTT signed with the provisioner private key.
|
||||||
|
* Algorithm is derived from the JWK kty/crv fields.
|
||||||
|
*/
|
||||||
|
private async buildOtt(params: {
|
||||||
|
csrPem: string;
|
||||||
|
grantId: string;
|
||||||
|
subjectUserId: string;
|
||||||
|
ttlSeconds: number;
|
||||||
|
csrCn: string;
|
||||||
|
}): Promise<string> {
|
||||||
|
const { csrPem, grantId, subjectUserId, ttlSeconds, csrCn } = params;
|
||||||
|
|
||||||
|
// Validate UUID shape for grant id and subject user id
|
||||||
|
if (!UUID_RE.test(grantId)) {
|
||||||
|
throw new CaServiceError(
|
||||||
|
`grantId is not a valid UUID: ${grantId}`,
|
||||||
|
'Provide a valid UUID (RFC 4122) for grantId.',
|
||||||
|
undefined,
|
||||||
|
'INVALID_GRANT_ID',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!UUID_RE.test(subjectUserId)) {
|
||||||
|
throw new CaServiceError(
|
||||||
|
`subjectUserId is not a valid UUID: ${subjectUserId}`,
|
||||||
|
'Provide a valid UUID (RFC 4122) for subjectUserId.',
|
||||||
|
undefined,
|
||||||
|
'INVALID_GRANT_ID',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sha = csrFingerprint(csrPem);
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
const privateKey = await this.getPrivateKey();
|
||||||
|
|
||||||
|
const ott = await new SignJWT({
|
||||||
|
iss: this.kid,
|
||||||
|
sub: csrCn, // M1: set sub to identity from CSR CN
|
||||||
|
aud: [`${this.caUrl}/1.0/sign`],
|
||||||
|
iat: now,
|
||||||
|
nbf: now - 30, // 30 s clock-skew tolerance
|
||||||
|
exp: now + Math.min(ttlSeconds, 3600), // OTT validity ≤ 1 h
|
||||||
|
jti: crypto.randomUUID(), // M2: unique token ID
|
||||||
|
// step.sha is the canonical field name used in the template — M3: keep only step.sha
|
||||||
|
step: { sha },
|
||||||
|
// Mosaic custom claims consumed by federation.tpl
|
||||||
|
mosaic_grant_id: grantId,
|
||||||
|
mosaic_subject_user_id: subjectUserId,
|
||||||
|
})
|
||||||
|
.setProtectedHeader({ alg: this.jwtAlg, typ: 'JWT', kid: this.kid })
|
||||||
|
.sign(privateKey);
|
||||||
|
|
||||||
|
return ott;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate a PEM-encoded CSR using @peculiar/x509.
|
||||||
|
* Verifies the self-signature, key type/size, and signature algorithm.
|
||||||
|
* Optionally verifies that the CSR's SANs match the expected set.
|
||||||
|
*
|
||||||
|
* Throws CaServiceError with code 'INVALID_CSR' on failure.
|
||||||
|
*/
|
||||||
|
private async validateCsr(pem: string, expectedSans?: string[]): Promise<string> {
|
||||||
|
let csr: Pkcs10CertificateRequest;
|
||||||
|
try {
|
||||||
|
csr = new Pkcs10CertificateRequest(pem);
|
||||||
|
} catch (err) {
|
||||||
|
throw new CaServiceError(
|
||||||
|
'Failed to parse CSR PEM as a valid PKCS#10 certificate request',
|
||||||
|
'Provide a valid PEM-encoded PKCS#10 CSR.',
|
||||||
|
err,
|
||||||
|
'INVALID_CSR',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify self-signature
|
||||||
|
let valid: boolean;
|
||||||
|
try {
|
||||||
|
valid = await csr.verify();
|
||||||
|
} catch (err) {
|
||||||
|
throw new CaServiceError(
|
||||||
|
'CSR signature verification threw an error',
|
||||||
|
'The CSR self-signature could not be verified. Ensure the CSR is properly formed.',
|
||||||
|
err,
|
||||||
|
'INVALID_CSR',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!valid) {
|
||||||
|
throw new CaServiceError(
|
||||||
|
'CSR self-signature is invalid',
|
||||||
|
'The CSR must be self-signed with the corresponding private key.',
|
||||||
|
undefined,
|
||||||
|
'INVALID_CSR',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate signature algorithm — reject MD5 and SHA-1
|
||||||
|
// signatureAlgorithm is HashedAlgorithm which extends Algorithm.
|
||||||
|
// Cast through unknown to access .name and .hash.name without DOM lib globals.
|
||||||
|
const sigAlgAny = csr.signatureAlgorithm as unknown as {
|
||||||
|
name?: string;
|
||||||
|
hash?: { name?: string };
|
||||||
|
};
|
||||||
|
const sigAlgName = (sigAlgAny.name ?? '').toLowerCase();
|
||||||
|
const hashName = (sigAlgAny.hash?.name ?? '').toLowerCase();
|
||||||
|
if (
|
||||||
|
sigAlgName.includes('md5') ||
|
||||||
|
sigAlgName.includes('sha1') ||
|
||||||
|
hashName === 'sha-1' ||
|
||||||
|
hashName === 'sha1'
|
||||||
|
) {
|
||||||
|
throw new CaServiceError(
|
||||||
|
`CSR uses a forbidden signature algorithm: ${sigAlgAny.name ?? 'unknown'}`,
|
||||||
|
'Use SHA-256 or stronger. MD5 and SHA-1 are not permitted.',
|
||||||
|
undefined,
|
||||||
|
'INVALID_CSR',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate public key algorithm and strength via the algorithm descriptor on the key.
|
||||||
|
// csr.publicKey.algorithm is type Algorithm (WebCrypto) — use name-based checks.
|
||||||
|
// We cast to an extended interface to access curve/modulus info without DOM globals.
|
||||||
|
const pubKeyAlgo = csr.publicKey.algorithm as {
|
||||||
|
name: string;
|
||||||
|
namedCurve?: string;
|
||||||
|
modulusLength?: number;
|
||||||
|
};
|
||||||
|
const keyAlgoName = pubKeyAlgo.name;
|
||||||
|
|
||||||
|
if (keyAlgoName === 'RSASSA-PKCS1-v1_5' || keyAlgoName === 'RSA-PSS') {
|
||||||
|
const modulusLength = pubKeyAlgo.modulusLength ?? 0;
|
||||||
|
if (modulusLength < 2048) {
|
||||||
|
throw new CaServiceError(
|
||||||
|
`CSR RSA key is too short: ${modulusLength} bits (minimum 2048)`,
|
||||||
|
'Use an RSA key of at least 2048 bits.',
|
||||||
|
undefined,
|
||||||
|
'INVALID_CSR',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else if (keyAlgoName === 'ECDSA') {
|
||||||
|
const namedCurve = pubKeyAlgo.namedCurve ?? '';
|
||||||
|
const allowedCurves = new Set(['P-256', 'P-384']);
|
||||||
|
if (!allowedCurves.has(namedCurve)) {
|
||||||
|
throw new CaServiceError(
|
||||||
|
`CSR EC key uses disallowed curve: ${namedCurve}`,
|
||||||
|
'Use EC P-256 or P-384. Other curves are not permitted.',
|
||||||
|
undefined,
|
||||||
|
'INVALID_CSR',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else if (keyAlgoName === 'Ed25519') {
|
||||||
|
// Ed25519 is explicitly allowed
|
||||||
|
} else {
|
||||||
|
throw new CaServiceError(
|
||||||
|
`CSR uses unsupported key algorithm: ${keyAlgoName}`,
|
||||||
|
'Use EC (P-256/P-384), Ed25519, or RSA (≥2048 bit) keys.',
|
||||||
|
undefined,
|
||||||
|
'INVALID_CSR',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract SANs if expectedSans provided
|
||||||
|
if (expectedSans && expectedSans.length > 0) {
|
||||||
|
// Get SANs from CSR extensions
|
||||||
|
const sanExtension = csr.extensions?.find(
|
||||||
|
(ext) => ext.type === '2.5.29.17', // Subject Alternative Name OID
|
||||||
|
);
|
||||||
|
const csrSans: string[] = [];
|
||||||
|
if (sanExtension) {
|
||||||
|
// Parse the raw SAN extension — store as stringified for comparison
|
||||||
|
// @peculiar/x509 exposes SANs through the parsed extension
|
||||||
|
const sanExt = sanExtension as { names?: Array<{ type: string; value: string }> };
|
||||||
|
if (sanExt.names) {
|
||||||
|
for (const name of sanExt.names) {
|
||||||
|
csrSans.push(name.value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const csrSanSet = new Set(csrSans);
|
||||||
|
const expectedSanSet = new Set(expectedSans);
|
||||||
|
const missing = expectedSans.filter((s) => !csrSanSet.has(s));
|
||||||
|
const extra = csrSans.filter((s) => !expectedSanSet.has(s));
|
||||||
|
|
||||||
|
if (missing.length > 0 || extra.length > 0) {
|
||||||
|
throw new CaServiceError(
|
||||||
|
`CSR SANs do not match expected set. Missing: [${missing.join(', ')}], Extra: [${extra.join(', ')}]`,
|
||||||
|
'The CSR must include exactly the SANs specified in the issuance request.',
|
||||||
|
undefined,
|
||||||
|
'INVALID_CSR',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return the CN from the CSR subject for use as JWT sub
|
||||||
|
const cn = csr.subjectName.getField('CN')?.[0] ?? '';
|
||||||
|
return cn;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Submit a CSR to step-ca and return the issued certificate.
|
||||||
|
*
|
||||||
|
* Throws `CaServiceError` on any failure (network, auth, malformed input).
|
||||||
|
* Never silently swallows errors — fail-loud is a hard contract per M2-02 review.
|
||||||
|
*/
|
||||||
|
async issueCert(req: IssueCertRequestDto): Promise<IssuedCertDto> {
|
||||||
|
// Clamp TTL to 15-minute maximum (H2)
|
||||||
|
const ttl = Math.min(req.ttlSeconds ?? 300, 900);
|
||||||
|
|
||||||
|
this.logger.debug(
|
||||||
|
`issueCert — grantId=${req.grantId} subjectUserId=${req.subjectUserId} ttl=${ttl}s`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Validate CSR — real cryptographic validation (H3)
|
||||||
|
const csrCn = await this.validateCsr(req.csrPem);
|
||||||
|
|
||||||
|
const ott = await this.buildOtt({
|
||||||
|
csrPem: req.csrPem,
|
||||||
|
grantId: req.grantId,
|
||||||
|
subjectUserId: req.subjectUserId,
|
||||||
|
ttlSeconds: ttl,
|
||||||
|
csrCn,
|
||||||
|
});
|
||||||
|
|
||||||
|
const signUrl = `${this.caUrl}/1.0/sign`;
|
||||||
|
const requestBody = {
|
||||||
|
csr: req.csrPem,
|
||||||
|
ott,
|
||||||
|
validity: {
|
||||||
|
duration: `${ttl}s`,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
this.logger.debug(`Posting CSR to ${signUrl}`);
|
||||||
|
const response = await httpsPost(signUrl, requestBody, this.httpsAgent);
|
||||||
|
|
||||||
|
if (!response.crt) {
|
||||||
|
throw new CaServiceError(
|
||||||
|
'step-ca sign response missing the "crt" field',
|
||||||
|
'This is unexpected — the step-ca instance may be misconfigured or running an incompatible version.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build certChainPem: prefer certChain array, fall back to ca field, fall back to crt alone.
|
||||||
|
let certChainPem: string;
|
||||||
|
if (response.certChain && response.certChain.length > 0) {
|
||||||
|
certChainPem = response.certChain.join('\n');
|
||||||
|
} else if (response.ca) {
|
||||||
|
certChainPem = response.crt + '\n' + response.ca;
|
||||||
|
} else {
|
||||||
|
certChainPem = response.crt;
|
||||||
|
}
|
||||||
|
|
||||||
|
const serialNumber = extractSerial(response.crt);
|
||||||
|
|
||||||
|
// CRIT-1: Verify the issued certificate contains both Mosaic OID extensions
|
||||||
|
// with the correct values. Step-CA's federation.tpl encodes each as an ASN.1
|
||||||
|
// UTF8String TLV: tag 0x0C + 1-byte length + UUID bytes. We skip 2 bytes
|
||||||
|
// (tag + length) to extract the raw UUID string.
|
||||||
|
const issuedCert = new X509Certificate(response.crt);
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
|
||||||
|
const grantIdExt = issuedCert.getExtension('1.3.6.1.4.1.99999.1');
|
||||||
|
if (!grantIdExt) {
|
||||||
|
throw new CaServiceError(
|
||||||
|
'Issued certificate is missing required Mosaic OID: mosaic_grant_id',
|
||||||
|
'The Step-CA federation.tpl template did not embed OID 1.3.6.1.4.1.99999.1. Check the provisioner template configuration.',
|
||||||
|
undefined,
|
||||||
|
'OID_MISSING',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const grantIdInCert = decoder.decode(grantIdExt.value.slice(2));
|
||||||
|
if (grantIdInCert !== req.grantId) {
|
||||||
|
throw new CaServiceError(
|
||||||
|
`Issued certificate mosaic_grant_id mismatch: expected ${req.grantId}, got ${grantIdInCert}`,
|
||||||
|
'The Step-CA issued a certificate with a different grant ID than requested. This may indicate a provisioner misconfiguration or a MITM.',
|
||||||
|
undefined,
|
||||||
|
'OID_MISMATCH',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const subjectUserIdExt = issuedCert.getExtension('1.3.6.1.4.1.99999.2');
|
||||||
|
if (!subjectUserIdExt) {
|
||||||
|
throw new CaServiceError(
|
||||||
|
'Issued certificate is missing required Mosaic OID: mosaic_subject_user_id',
|
||||||
|
'The Step-CA federation.tpl template did not embed OID 1.3.6.1.4.1.99999.2. Check the provisioner template configuration.',
|
||||||
|
undefined,
|
||||||
|
'OID_MISSING',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const subjectUserIdInCert = decoder.decode(subjectUserIdExt.value.slice(2));
|
||||||
|
if (subjectUserIdInCert !== req.subjectUserId) {
|
||||||
|
throw new CaServiceError(
|
||||||
|
`Issued certificate mosaic_subject_user_id mismatch: expected ${req.subjectUserId}, got ${subjectUserIdInCert}`,
|
||||||
|
'The Step-CA issued a certificate with a different subject user ID than requested. This may indicate a provisioner misconfiguration or a MITM.',
|
||||||
|
undefined,
|
||||||
|
'OID_MISMATCH',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.log(`Certificate issued — serial=${serialNumber} grantId=${req.grantId}`);
|
||||||
|
|
||||||
|
const result = new IssuedCertDto();
|
||||||
|
result.certPem = response.crt;
|
||||||
|
result.certChainPem = certChainPem;
|
||||||
|
result.serialNumber = serialNumber;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
54
apps/gateway/src/federation/enrollment.controller.ts
Normal file
54
apps/gateway/src/federation/enrollment.controller.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
/**
|
||||||
|
* EnrollmentController — federation enrollment HTTP layer (FED-M2-07).
|
||||||
|
*
|
||||||
|
* Routes:
|
||||||
|
* POST /api/federation/enrollment/tokens — admin creates a single-use token
|
||||||
|
* POST /api/federation/enrollment/:token — unauthenticated; token IS the auth
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
HttpCode,
|
||||||
|
HttpStatus,
|
||||||
|
Inject,
|
||||||
|
Param,
|
||||||
|
Post,
|
||||||
|
UseGuards,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { AdminGuard } from '../admin/admin.guard.js';
|
||||||
|
import { EnrollmentService } from './enrollment.service.js';
|
||||||
|
import { CreateEnrollmentTokenDto, RedeemEnrollmentTokenDto } from './enrollment.dto.js';
|
||||||
|
|
||||||
|
@Controller('api/federation/enrollment')
|
||||||
|
export class EnrollmentController {
|
||||||
|
constructor(@Inject(EnrollmentService) private readonly enrollmentService: EnrollmentService) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Admin-only: generate a single-use enrollment token for a pending grant.
|
||||||
|
* The token should be distributed out-of-band to the remote peer operator.
|
||||||
|
*
|
||||||
|
* POST /api/federation/enrollment/tokens
|
||||||
|
*/
|
||||||
|
@Post('tokens')
|
||||||
|
@UseGuards(AdminGuard)
|
||||||
|
@HttpCode(HttpStatus.CREATED)
|
||||||
|
async createToken(@Body() dto: CreateEnrollmentTokenDto) {
|
||||||
|
return this.enrollmentService.createToken(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unauthenticated: remote peer redeems a token by submitting its CSR.
|
||||||
|
* The token itself is the credential — no session or bearer token required.
|
||||||
|
*
|
||||||
|
* POST /api/federation/enrollment/:token
|
||||||
|
*
|
||||||
|
* Returns the signed leaf cert and full chain PEM on success.
|
||||||
|
* Returns 410 Gone if the token was already used or has expired.
|
||||||
|
*/
|
||||||
|
@Post(':token')
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
async redeem(@Param('token') token: string, @Body() dto: RedeemEnrollmentTokenDto) {
|
||||||
|
return this.enrollmentService.redeem(token, dto.csrPem);
|
||||||
|
}
|
||||||
|
}
|
||||||
35
apps/gateway/src/federation/enrollment.dto.ts
Normal file
35
apps/gateway/src/federation/enrollment.dto.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
/**
|
||||||
|
* DTOs for the federation enrollment flow (FED-M2-07).
|
||||||
|
*
|
||||||
|
* CreateEnrollmentTokenDto — admin generates a single-use enrollment token
|
||||||
|
* RedeemEnrollmentTokenDto — remote peer submits CSR to redeem the token
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { IsInt, IsNotEmpty, IsOptional, IsString, IsUUID, Max, Min } from 'class-validator';
|
||||||
|
|
||||||
|
export class CreateEnrollmentTokenDto {
|
||||||
|
/** UUID of the federation grant this token will activate on redemption. */
|
||||||
|
@IsUUID()
|
||||||
|
grantId!: string;
|
||||||
|
|
||||||
|
/** UUID of the peer record that will receive the issued cert on redemption. */
|
||||||
|
@IsUUID()
|
||||||
|
peerId!: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Token lifetime in seconds. Default 900 (15 min). Min 60. Max 900.
|
||||||
|
* After this time the token is rejected even if unused.
|
||||||
|
*/
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
@Min(60)
|
||||||
|
@Max(900)
|
||||||
|
ttlSeconds: number = 900;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RedeemEnrollmentTokenDto {
|
||||||
|
/** PEM-encoded PKCS#10 Certificate Signing Request from the remote peer. */
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
csrPem!: string;
|
||||||
|
}
|
||||||
281
apps/gateway/src/federation/enrollment.service.ts
Normal file
281
apps/gateway/src/federation/enrollment.service.ts
Normal file
@@ -0,0 +1,281 @@
|
|||||||
|
/**
|
||||||
|
* EnrollmentService — single-use enrollment token lifecycle (FED-M2-07).
|
||||||
|
*
|
||||||
|
* Responsibilities:
|
||||||
|
* 1. Generate time-limited single-use enrollment tokens (admin action).
|
||||||
|
* 2. Redeem a token: validate → atomically claim token → issue cert via
|
||||||
|
* CaService → transactionally activate grant + update peer + write audit.
|
||||||
|
*
|
||||||
|
* Replay protection: the token is claimed (UPDATE WHERE used_at IS NULL) BEFORE
|
||||||
|
* cert issuance. This prevents double cert minting on concurrent requests.
|
||||||
|
* If cert issuance fails after claim, the token is consumed and the grant
|
||||||
|
* stays pending — admin must create a new grant.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
ConflictException,
|
||||||
|
GoneException,
|
||||||
|
Inject,
|
||||||
|
Injectable,
|
||||||
|
Logger,
|
||||||
|
NotFoundException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import * as crypto from 'node:crypto';
|
||||||
|
// X509Certificate is available as a named export in Node.js ≥ 15.6
|
||||||
|
const { X509Certificate } = crypto;
|
||||||
|
import {
|
||||||
|
type Db,
|
||||||
|
and,
|
||||||
|
eq,
|
||||||
|
isNull,
|
||||||
|
sql,
|
||||||
|
federationEnrollmentTokens,
|
||||||
|
federationGrants,
|
||||||
|
federationPeers,
|
||||||
|
federationAuditLog,
|
||||||
|
} from '@mosaicstack/db';
|
||||||
|
import { DB } from '../database/database.module.js';
|
||||||
|
import { CaService } from './ca.service.js';
|
||||||
|
import { GrantsService } from './grants.service.js';
|
||||||
|
import { FederationScopeError } from './scope-schema.js';
|
||||||
|
import type { CreateEnrollmentTokenDto } from './enrollment.dto.js';
|
||||||
|
|
||||||
|
export interface EnrollmentTokenResult {
|
||||||
|
token: string;
|
||||||
|
expiresAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RedeemResult {
|
||||||
|
certPem: string;
|
||||||
|
certChainPem: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class EnrollmentService {
|
||||||
|
private readonly logger = new Logger(EnrollmentService.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
@Inject(DB) private readonly db: Db,
|
||||||
|
private readonly caService: CaService,
|
||||||
|
private readonly grantsService: GrantsService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a single-use enrollment token for an admin to distribute
|
||||||
|
* out-of-band to the remote peer operator.
|
||||||
|
*/
|
||||||
|
async createToken(dto: CreateEnrollmentTokenDto): Promise<EnrollmentTokenResult> {
|
||||||
|
const ttl = Math.min(dto.ttlSeconds, 900);
|
||||||
|
|
||||||
|
// MED-3: Verify the grantId ↔ peerId binding — prevents attacker from
|
||||||
|
// cross-wiring grants to attacker-controlled peers.
|
||||||
|
const [grant] = await this.db
|
||||||
|
.select({ peerId: federationGrants.peerId })
|
||||||
|
.from(federationGrants)
|
||||||
|
.where(eq(federationGrants.id, dto.grantId))
|
||||||
|
.limit(1);
|
||||||
|
if (!grant) {
|
||||||
|
throw new NotFoundException(`Grant ${dto.grantId} not found`);
|
||||||
|
}
|
||||||
|
if (grant.peerId !== dto.peerId) {
|
||||||
|
throw new BadRequestException(`peerId does not match the grant's registered peer`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = crypto.randomBytes(32).toString('hex');
|
||||||
|
const expiresAt = new Date(Date.now() + ttl * 1000);
|
||||||
|
|
||||||
|
await this.db.insert(federationEnrollmentTokens).values({
|
||||||
|
token,
|
||||||
|
grantId: dto.grantId,
|
||||||
|
peerId: dto.peerId,
|
||||||
|
expiresAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logger.log(
|
||||||
|
`Enrollment token created — grantId=${dto.grantId} peerId=${dto.peerId} expiresAt=${expiresAt.toISOString()}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
return { token, expiresAt: expiresAt.toISOString() };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Redeem an enrollment token.
|
||||||
|
*
|
||||||
|
* Full flow:
|
||||||
|
* 1. Fetch token row — NotFoundException if not found
|
||||||
|
* 2. usedAt set → GoneException (already used)
|
||||||
|
* 3. expiresAt < now → GoneException (expired)
|
||||||
|
* 4. Load grant — verify status is 'pending'
|
||||||
|
* 5. Atomically claim token (UPDATE WHERE used_at IS NULL RETURNING token)
|
||||||
|
* — if no rows returned, concurrent request won → GoneException
|
||||||
|
* 6. Issue cert via CaService (network call, outside transaction)
|
||||||
|
* — if this fails, token is consumed; grant stays pending; admin must recreate
|
||||||
|
* 7. Transaction: activate grant + update peer record + write audit log
|
||||||
|
* 8. Return { certPem, certChainPem }
|
||||||
|
*/
|
||||||
|
async redeem(token: string, csrPem: string): Promise<RedeemResult> {
|
||||||
|
// HIGH-5: Track outcome so we can write a failure audit row on any error.
|
||||||
|
let outcome: 'allowed' | 'denied' = 'denied';
|
||||||
|
// row may be undefined if the token is not found — used defensively in catch.
|
||||||
|
let row: typeof federationEnrollmentTokens.$inferSelect | undefined;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. Fetch token row
|
||||||
|
const [fetchedRow] = await this.db
|
||||||
|
.select()
|
||||||
|
.from(federationEnrollmentTokens)
|
||||||
|
.where(eq(federationEnrollmentTokens.token, token))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!fetchedRow) {
|
||||||
|
throw new NotFoundException('Enrollment token not found');
|
||||||
|
}
|
||||||
|
row = fetchedRow;
|
||||||
|
|
||||||
|
// 2. Already used?
|
||||||
|
if (row.usedAt !== null) {
|
||||||
|
throw new GoneException('Enrollment token has already been used');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Expired?
|
||||||
|
if (row.expiresAt < new Date()) {
|
||||||
|
throw new GoneException('Enrollment token has expired');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Load grant and verify it is still pending
|
||||||
|
let grant;
|
||||||
|
try {
|
||||||
|
grant = await this.grantsService.getGrant(row.grantId);
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof FederationScopeError) {
|
||||||
|
throw new BadRequestException(err.message);
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (grant.status !== 'pending') {
|
||||||
|
throw new GoneException(
|
||||||
|
`Grant ${row.grantId} is no longer pending (status: ${grant.status})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Atomically claim the token BEFORE cert issuance to prevent double-minting.
|
||||||
|
// WHERE used_at IS NULL ensures only one concurrent request wins.
|
||||||
|
// Using .returning() works on both node-postgres and PGlite without rowCount inspection.
|
||||||
|
const claimed = await this.db
|
||||||
|
.update(federationEnrollmentTokens)
|
||||||
|
.set({ usedAt: sql`NOW()` })
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(federationEnrollmentTokens.token, token),
|
||||||
|
isNull(federationEnrollmentTokens.usedAt),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.returning({ token: federationEnrollmentTokens.token });
|
||||||
|
|
||||||
|
if (claimed.length === 0) {
|
||||||
|
throw new GoneException('Enrollment token has already been used (concurrent request)');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Issue certificate via CaService (network call — outside any transaction).
|
||||||
|
// If this throws, the token is already consumed. The grant stays pending.
|
||||||
|
// Admin must revoke the grant and create a new one.
|
||||||
|
let issued;
|
||||||
|
try {
|
||||||
|
issued = await this.caService.issueCert({
|
||||||
|
csrPem,
|
||||||
|
grantId: row.grantId,
|
||||||
|
subjectUserId: grant.subjectUserId,
|
||||||
|
ttlSeconds: 300,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
// HIGH-4: Log only the first 8 hex chars of the token for correlation — never log the full token.
|
||||||
|
this.logger.error(
|
||||||
|
`issueCert failed after token ${token.slice(0, 8)}... was claimed — grant ${row.grantId} is stranded pending`,
|
||||||
|
err instanceof Error ? err.stack : String(err),
|
||||||
|
);
|
||||||
|
if (err instanceof FederationScopeError) {
|
||||||
|
throw new BadRequestException((err as Error).message);
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7. Atomically activate grant, update peer record, and write audit log.
|
||||||
|
const certNotAfter = this.extractCertNotAfter(issued.certPem);
|
||||||
|
await this.db.transaction(async (tx) => {
|
||||||
|
// CRIT-2: Guard activation with WHERE status='pending' to prevent double-activation.
|
||||||
|
const [activated] = await tx
|
||||||
|
.update(federationGrants)
|
||||||
|
.set({ status: 'active' })
|
||||||
|
.where(and(eq(federationGrants.id, row!.grantId), eq(federationGrants.status, 'pending')))
|
||||||
|
.returning({ id: federationGrants.id });
|
||||||
|
if (!activated) {
|
||||||
|
throw new ConflictException(
|
||||||
|
`Grant ${row!.grantId} is no longer pending — cannot activate`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// CRIT-2: Guard peer update with WHERE state='pending'.
|
||||||
|
await tx
|
||||||
|
.update(federationPeers)
|
||||||
|
.set({
|
||||||
|
certPem: issued.certPem,
|
||||||
|
certSerial: issued.serialNumber,
|
||||||
|
certNotAfter,
|
||||||
|
state: 'active',
|
||||||
|
})
|
||||||
|
.where(and(eq(federationPeers.id, row!.peerId), eq(federationPeers.state, 'pending')));
|
||||||
|
|
||||||
|
await tx.insert(federationAuditLog).values({
|
||||||
|
requestId: crypto.randomUUID(),
|
||||||
|
peerId: row!.peerId,
|
||||||
|
grantId: row!.grantId,
|
||||||
|
verb: 'enrollment',
|
||||||
|
resource: 'federation_grant',
|
||||||
|
statusCode: 200,
|
||||||
|
outcome: 'allowed',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logger.log(
|
||||||
|
`Enrollment complete — peerId=${row.peerId} grantId=${row.grantId} serial=${issued.serialNumber}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
outcome = 'allowed';
|
||||||
|
|
||||||
|
// 8. Return cert material
|
||||||
|
return {
|
||||||
|
certPem: issued.certPem,
|
||||||
|
certChainPem: issued.certChainPem,
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
// HIGH-5: Best-effort audit write on failure — do not let this throw.
|
||||||
|
if (outcome === 'denied') {
|
||||||
|
await this.db
|
||||||
|
.insert(federationAuditLog)
|
||||||
|
.values({
|
||||||
|
requestId: crypto.randomUUID(),
|
||||||
|
peerId: row?.peerId ?? null,
|
||||||
|
grantId: row?.grantId ?? null,
|
||||||
|
verb: 'enrollment',
|
||||||
|
resource: 'federation_grant',
|
||||||
|
statusCode:
|
||||||
|
err instanceof GoneException ? 410 : err instanceof NotFoundException ? 404 : 500,
|
||||||
|
outcome: 'denied',
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract the notAfter date from a PEM certificate.
|
||||||
|
* HIGH-2: No silent fallback — a cert that cannot be parsed should fail loud.
|
||||||
|
*/
|
||||||
|
private extractCertNotAfter(certPem: string): Date {
|
||||||
|
const cert = new X509Certificate(certPem);
|
||||||
|
return new Date(cert.validTo);
|
||||||
|
}
|
||||||
|
}
|
||||||
39
apps/gateway/src/federation/federation-admin.dto.ts
Normal file
39
apps/gateway/src/federation/federation-admin.dto.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
/**
|
||||||
|
* DTOs for the federation admin controller (FED-M2-08).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { IsInt, IsNotEmpty, IsOptional, IsString, IsUrl, Max, Min } from 'class-validator';
|
||||||
|
|
||||||
|
export class CreatePeerKeypairDto {
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
commonName!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
displayName!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsUrl()
|
||||||
|
endpointUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class StorePeerCertDto {
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
certPem!: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class GenerateEnrollmentTokenDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
@Min(60)
|
||||||
|
@Max(900)
|
||||||
|
ttlSeconds: number = 900;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RevokeGrantBodyDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
reason?: string;
|
||||||
|
}
|
||||||
266
apps/gateway/src/federation/federation.controller.ts
Normal file
266
apps/gateway/src/federation/federation.controller.ts
Normal file
@@ -0,0 +1,266 @@
|
|||||||
|
/**
|
||||||
|
* FederationController — admin REST API for federation management (FED-M2-08).
|
||||||
|
*
|
||||||
|
* Routes (all under /api/admin/federation, all require AdminGuard):
|
||||||
|
*
|
||||||
|
* Grant management:
|
||||||
|
* POST /api/admin/federation/grants
|
||||||
|
* GET /api/admin/federation/grants
|
||||||
|
* GET /api/admin/federation/grants/:id
|
||||||
|
* PATCH /api/admin/federation/grants/:id/revoke
|
||||||
|
* POST /api/admin/federation/grants/:id/tokens
|
||||||
|
*
|
||||||
|
* Peer management:
|
||||||
|
* GET /api/admin/federation/peers
|
||||||
|
* POST /api/admin/federation/peers/keypair
|
||||||
|
* PATCH /api/admin/federation/peers/:id/cert
|
||||||
|
*
|
||||||
|
* NOTE: The enrollment REDEMPTION endpoint (POST /api/federation/enrollment/:token)
|
||||||
|
* is handled by EnrollmentController — not duplicated here.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
HttpCode,
|
||||||
|
HttpStatus,
|
||||||
|
Inject,
|
||||||
|
NotFoundException,
|
||||||
|
Param,
|
||||||
|
Patch,
|
||||||
|
Post,
|
||||||
|
Query,
|
||||||
|
UseGuards,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { webcrypto } from 'node:crypto';
|
||||||
|
import { X509Certificate } from 'node:crypto';
|
||||||
|
import { Pkcs10CertificateRequestGenerator } from '@peculiar/x509';
|
||||||
|
import { type Db, eq, federationPeers } from '@mosaicstack/db';
|
||||||
|
import { DB } from '../database/database.module.js';
|
||||||
|
import { AdminGuard } from '../admin/admin.guard.js';
|
||||||
|
import { GrantsService } from './grants.service.js';
|
||||||
|
import { EnrollmentService } from './enrollment.service.js';
|
||||||
|
import { sealClientKey } from './peer-key.util.js';
|
||||||
|
import { CreateGrantDto, ListGrantsDto } from './grants.dto.js';
|
||||||
|
import {
|
||||||
|
CreatePeerKeypairDto,
|
||||||
|
GenerateEnrollmentTokenDto,
|
||||||
|
RevokeGrantBodyDto,
|
||||||
|
StorePeerCertDto,
|
||||||
|
} from './federation-admin.dto.js';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert an ArrayBuffer to a Base64 string (for PEM encoding).
|
||||||
|
*/
|
||||||
|
function arrayBufferToBase64(buf: ArrayBuffer): string {
|
||||||
|
const bytes = new Uint8Array(buf);
|
||||||
|
let binary = '';
|
||||||
|
for (const b of bytes) {
|
||||||
|
binary += String.fromCharCode(b);
|
||||||
|
}
|
||||||
|
return Buffer.from(binary, 'binary').toString('base64');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wrap a Base64 string in PEM armour.
|
||||||
|
*/
|
||||||
|
function toPem(label: string, b64: string): string {
|
||||||
|
const lines = b64.match(/.{1,64}/g) ?? [];
|
||||||
|
return `-----BEGIN ${label}-----\n${lines.join('\n')}\n-----END ${label}-----\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Controller
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@Controller('api/admin/federation')
|
||||||
|
@UseGuards(AdminGuard)
|
||||||
|
export class FederationController {
|
||||||
|
constructor(
|
||||||
|
@Inject(DB) private readonly db: Db,
|
||||||
|
@Inject(GrantsService) private readonly grantsService: GrantsService,
|
||||||
|
@Inject(EnrollmentService) private readonly enrollmentService: EnrollmentService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
// ─── Grant management ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/admin/federation/grants
|
||||||
|
* Create a new grant in pending state.
|
||||||
|
*/
|
||||||
|
@Post('grants')
|
||||||
|
@HttpCode(HttpStatus.CREATED)
|
||||||
|
async createGrant(@Body() body: CreateGrantDto) {
|
||||||
|
return this.grantsService.createGrant(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/admin/federation/grants
|
||||||
|
* List grants with optional filters.
|
||||||
|
*/
|
||||||
|
@Get('grants')
|
||||||
|
async listGrants(@Query() query: ListGrantsDto) {
|
||||||
|
return this.grantsService.listGrants(query);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/admin/federation/grants/:id
|
||||||
|
* Get a single grant by ID.
|
||||||
|
*/
|
||||||
|
@Get('grants/:id')
|
||||||
|
async getGrant(@Param('id') id: string) {
|
||||||
|
return this.grantsService.getGrant(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PATCH /api/admin/federation/grants/:id/revoke
|
||||||
|
* Revoke an active grant.
|
||||||
|
*/
|
||||||
|
@Patch('grants/:id/revoke')
|
||||||
|
async revokeGrant(@Param('id') id: string, @Body() body: RevokeGrantBodyDto) {
|
||||||
|
return this.grantsService.revokeGrant(id, body.reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/admin/federation/grants/:id/tokens
|
||||||
|
* Generate a single-use enrollment token for a pending grant.
|
||||||
|
* Returns the token plus an enrollmentUrl the operator shares out-of-band.
|
||||||
|
*/
|
||||||
|
@Post('grants/:id/tokens')
|
||||||
|
@HttpCode(HttpStatus.CREATED)
|
||||||
|
async generateToken(@Param('id') id: string, @Body() body: GenerateEnrollmentTokenDto) {
|
||||||
|
const grant = await this.grantsService.getGrant(id);
|
||||||
|
|
||||||
|
const result = await this.enrollmentService.createToken({
|
||||||
|
grantId: id,
|
||||||
|
peerId: grant.peerId,
|
||||||
|
ttlSeconds: body.ttlSeconds ?? 900,
|
||||||
|
});
|
||||||
|
|
||||||
|
const baseUrl = process.env['BETTER_AUTH_URL'] ?? 'http://localhost:14242';
|
||||||
|
const enrollmentUrl = `${baseUrl}/api/federation/enrollment/${result.token}`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
token: result.token,
|
||||||
|
expiresAt: result.expiresAt,
|
||||||
|
enrollmentUrl,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Peer management ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/admin/federation/peers
|
||||||
|
* List all federation peer rows.
|
||||||
|
*/
|
||||||
|
@Get('peers')
|
||||||
|
async listPeers() {
|
||||||
|
return this.db.select().from(federationPeers).orderBy(federationPeers.commonName);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/admin/federation/peers/keypair
|
||||||
|
* Generate a new peer entry with EC P-256 key pair and a PKCS#10 CSR.
|
||||||
|
*
|
||||||
|
* Flow:
|
||||||
|
* 1. Generate EC P-256 key pair via webcrypto
|
||||||
|
* 2. Generate a self-signed CSR via @peculiar/x509
|
||||||
|
* 3. Export private key as PEM
|
||||||
|
* 4. sealClientKey(privatePem) → sealed blob
|
||||||
|
* 5. Insert pending peer row
|
||||||
|
* 6. Return { peerId, csrPem }
|
||||||
|
*/
|
||||||
|
@Post('peers/keypair')
|
||||||
|
@HttpCode(HttpStatus.CREATED)
|
||||||
|
async createPeerKeypair(@Body() body: CreatePeerKeypairDto) {
|
||||||
|
// 1. Generate EC P-256 key pair via Web Crypto
|
||||||
|
const keyPair = await webcrypto.subtle.generateKey(
|
||||||
|
{ name: 'ECDSA', namedCurve: 'P-256' },
|
||||||
|
true, // extractable
|
||||||
|
['sign', 'verify'],
|
||||||
|
);
|
||||||
|
|
||||||
|
// 2. Generate PKCS#10 CSR
|
||||||
|
const csr = await Pkcs10CertificateRequestGenerator.create({
|
||||||
|
name: `CN=${body.commonName}`,
|
||||||
|
keys: keyPair,
|
||||||
|
signingAlgorithm: { name: 'ECDSA', hash: 'SHA-256' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const csrPem = csr.toString('pem');
|
||||||
|
|
||||||
|
// 3. Export private key as PKCS#8 PEM
|
||||||
|
const pkcs8Der = await webcrypto.subtle.exportKey('pkcs8', keyPair.privateKey);
|
||||||
|
const privatePem = toPem('PRIVATE KEY', arrayBufferToBase64(pkcs8Der));
|
||||||
|
|
||||||
|
// 4. Seal the private key
|
||||||
|
const sealed = sealClientKey(privatePem);
|
||||||
|
|
||||||
|
// 5. Insert pending peer row
|
||||||
|
const [peer] = await this.db
|
||||||
|
.insert(federationPeers)
|
||||||
|
.values({
|
||||||
|
commonName: body.commonName,
|
||||||
|
displayName: body.displayName,
|
||||||
|
certPem: '',
|
||||||
|
certSerial: 'pending',
|
||||||
|
certNotAfter: new Date(0),
|
||||||
|
clientKeyPem: sealed,
|
||||||
|
state: 'pending',
|
||||||
|
endpointUrl: body.endpointUrl,
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
return {
|
||||||
|
peerId: peer!.id,
|
||||||
|
csrPem,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PATCH /api/admin/federation/peers/:id/cert
|
||||||
|
* Store a signed certificate after enrollment completes.
|
||||||
|
*
|
||||||
|
* Flow:
|
||||||
|
* 1. Parse the cert to extract serial and notAfter
|
||||||
|
* 2. Update the peer row with cert data + state='active'
|
||||||
|
* 3. Return the updated peer row
|
||||||
|
*/
|
||||||
|
@Patch('peers/:id/cert')
|
||||||
|
async storePeerCert(@Param('id') id: string, @Body() body: StorePeerCertDto) {
|
||||||
|
// Ensure peer exists
|
||||||
|
const [existing] = await this.db
|
||||||
|
.select({ id: federationPeers.id })
|
||||||
|
.from(federationPeers)
|
||||||
|
.where(eq(federationPeers.id, id))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
throw new NotFoundException(`Peer ${id} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Parse cert
|
||||||
|
const x509 = new X509Certificate(body.certPem);
|
||||||
|
const certSerial = x509.serialNumber;
|
||||||
|
const certNotAfter = new Date(x509.validTo);
|
||||||
|
|
||||||
|
// 2. Update peer
|
||||||
|
const [updated] = await this.db
|
||||||
|
.update(federationPeers)
|
||||||
|
.set({
|
||||||
|
certPem: body.certPem,
|
||||||
|
certSerial,
|
||||||
|
certNotAfter,
|
||||||
|
state: 'active',
|
||||||
|
})
|
||||||
|
.where(eq(federationPeers.id, id))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
}
|
||||||
14
apps/gateway/src/federation/federation.module.ts
Normal file
14
apps/gateway/src/federation/federation.module.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { AdminGuard } from '../admin/admin.guard.js';
|
||||||
|
import { CaService } from './ca.service.js';
|
||||||
|
import { EnrollmentController } from './enrollment.controller.js';
|
||||||
|
import { EnrollmentService } from './enrollment.service.js';
|
||||||
|
import { FederationController } from './federation.controller.js';
|
||||||
|
import { GrantsService } from './grants.service.js';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [EnrollmentController, FederationController],
|
||||||
|
providers: [AdminGuard, CaService, EnrollmentService, GrantsService],
|
||||||
|
exports: [CaService, EnrollmentService, GrantsService],
|
||||||
|
})
|
||||||
|
export class FederationModule {}
|
||||||
36
apps/gateway/src/federation/grants.dto.ts
Normal file
36
apps/gateway/src/federation/grants.dto.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { IsDateString, IsIn, IsObject, IsOptional, IsString, IsUUID } from 'class-validator';
|
||||||
|
|
||||||
|
export class CreateGrantDto {
|
||||||
|
@IsUUID()
|
||||||
|
peerId!: string;
|
||||||
|
|
||||||
|
@IsUUID()
|
||||||
|
subjectUserId!: string;
|
||||||
|
|
||||||
|
@IsObject()
|
||||||
|
scope!: Record<string, unknown>;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsDateString()
|
||||||
|
expiresAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ListGrantsDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
peerId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
subjectUserId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsIn(['pending', 'active', 'revoked', 'expired'])
|
||||||
|
status?: 'pending' | 'active' | 'revoked' | 'expired';
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RevokeGrantDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
reason?: string;
|
||||||
|
}
|
||||||
161
apps/gateway/src/federation/grants.service.ts
Normal file
161
apps/gateway/src/federation/grants.service.ts
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
/**
|
||||||
|
* Federation grants service — CRUD + status transitions (FED-M2-06).
|
||||||
|
*
|
||||||
|
* Business logic only. CSR/cert work is handled by M2-07.
|
||||||
|
*
|
||||||
|
* Status lifecycle:
|
||||||
|
* pending → active (activateGrant, called by M2-07 enrollment controller after cert signed)
|
||||||
|
* active → revoked (revokeGrant)
|
||||||
|
* active → expired (expireGrant, called by M6 scheduler)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { ConflictException, Inject, Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
import { type Db, and, eq, federationGrants } from '@mosaicstack/db';
|
||||||
|
import { DB } from '../database/database.module.js';
|
||||||
|
import { parseFederationScope } from './scope-schema.js';
|
||||||
|
import type { CreateGrantDto, ListGrantsDto } from './grants.dto.js';
|
||||||
|
|
||||||
|
export type Grant = typeof federationGrants.$inferSelect;
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class GrantsService {
|
||||||
|
constructor(@Inject(DB) private readonly db: Db) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new grant in `pending` state.
|
||||||
|
* Validates the scope against the federation scope JSON schema before inserting.
|
||||||
|
*/
|
||||||
|
async createGrant(dto: CreateGrantDto): Promise<Grant> {
|
||||||
|
// Throws FederationScopeError (a plain Error subclass) on invalid scope.
|
||||||
|
parseFederationScope(dto.scope);
|
||||||
|
|
||||||
|
const [grant] = await this.db
|
||||||
|
.insert(federationGrants)
|
||||||
|
.values({
|
||||||
|
peerId: dto.peerId,
|
||||||
|
subjectUserId: dto.subjectUserId,
|
||||||
|
scope: dto.scope,
|
||||||
|
status: 'pending',
|
||||||
|
expiresAt: dto.expiresAt != null ? new Date(dto.expiresAt) : null,
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
return grant!;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch a single grant by ID. Throws NotFoundException if not found.
|
||||||
|
*/
|
||||||
|
async getGrant(id: string): Promise<Grant> {
|
||||||
|
const [grant] = await this.db
|
||||||
|
.select()
|
||||||
|
.from(federationGrants)
|
||||||
|
.where(eq(federationGrants.id, id))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!grant) {
|
||||||
|
throw new NotFoundException(`Grant ${id} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return grant;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List grants with optional filters for peerId, subjectUserId, and status.
|
||||||
|
*/
|
||||||
|
async listGrants(filters: ListGrantsDto): Promise<Grant[]> {
|
||||||
|
const conditions = [];
|
||||||
|
|
||||||
|
if (filters.peerId != null) {
|
||||||
|
conditions.push(eq(federationGrants.peerId, filters.peerId));
|
||||||
|
}
|
||||||
|
if (filters.subjectUserId != null) {
|
||||||
|
conditions.push(eq(federationGrants.subjectUserId, filters.subjectUserId));
|
||||||
|
}
|
||||||
|
if (filters.status != null) {
|
||||||
|
conditions.push(eq(federationGrants.status, filters.status));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (conditions.length === 0) {
|
||||||
|
return this.db.select().from(federationGrants);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.db
|
||||||
|
.select()
|
||||||
|
.from(federationGrants)
|
||||||
|
.where(and(...conditions));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transition a grant from `pending` → `active`.
|
||||||
|
* Called by M2-07 enrollment controller after cert is signed.
|
||||||
|
* Throws ConflictException if the grant is not in `pending` state.
|
||||||
|
*/
|
||||||
|
async activateGrant(id: string): Promise<Grant> {
|
||||||
|
const grant = await this.getGrant(id);
|
||||||
|
|
||||||
|
if (grant.status !== 'pending') {
|
||||||
|
throw new ConflictException(
|
||||||
|
`Grant ${id} cannot be activated: expected status 'pending', got '${grant.status}'`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [updated] = await this.db
|
||||||
|
.update(federationGrants)
|
||||||
|
.set({ status: 'active' })
|
||||||
|
.where(eq(federationGrants.id, id))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
return updated!;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transition a grant from `active` → `revoked`.
|
||||||
|
* Sets revokedAt and optionally revokedReason.
|
||||||
|
* Throws ConflictException if the grant is not in `active` state.
|
||||||
|
*/
|
||||||
|
async revokeGrant(id: string, reason?: string): Promise<Grant> {
|
||||||
|
const grant = await this.getGrant(id);
|
||||||
|
|
||||||
|
if (grant.status !== 'active') {
|
||||||
|
throw new ConflictException(
|
||||||
|
`Grant ${id} cannot be revoked: expected status 'active', got '${grant.status}'`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [updated] = await this.db
|
||||||
|
.update(federationGrants)
|
||||||
|
.set({
|
||||||
|
status: 'revoked',
|
||||||
|
revokedAt: new Date(),
|
||||||
|
revokedReason: reason ?? null,
|
||||||
|
})
|
||||||
|
.where(eq(federationGrants.id, id))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
return updated!;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transition a grant from `active` → `expired`.
|
||||||
|
* Intended for use by the M6 scheduler.
|
||||||
|
* Throws ConflictException if the grant is not in `active` state.
|
||||||
|
*/
|
||||||
|
async expireGrant(id: string): Promise<Grant> {
|
||||||
|
const grant = await this.getGrant(id);
|
||||||
|
|
||||||
|
if (grant.status !== 'active') {
|
||||||
|
throw new ConflictException(
|
||||||
|
`Grant ${id} cannot be expired: expected status 'active', got '${grant.status}'`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [updated] = await this.db
|
||||||
|
.update(federationGrants)
|
||||||
|
.set({ status: 'expired' })
|
||||||
|
.where(eq(federationGrants.id, id))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
return updated!;
|
||||||
|
}
|
||||||
|
}
|
||||||
9
apps/gateway/src/federation/peer-key.util.ts
Normal file
9
apps/gateway/src/federation/peer-key.util.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { seal, unseal } from '@mosaicstack/auth';
|
||||||
|
|
||||||
|
export function sealClientKey(privateKeyPem: string): string {
|
||||||
|
return seal(privateKeyPem);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function unsealClientKey(sealedKey: string): string {
|
||||||
|
return unseal(sealedKey);
|
||||||
|
}
|
||||||
187
apps/gateway/src/federation/scope-schema.spec.ts
Normal file
187
apps/gateway/src/federation/scope-schema.spec.ts
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
/**
|
||||||
|
* Unit tests for FederationScopeSchema and parseFederationScope.
|
||||||
|
*
|
||||||
|
* Coverage:
|
||||||
|
* - Valid: minimal scope
|
||||||
|
* - Valid: full PRD §8.1 example
|
||||||
|
* - Valid: resources + excluded_resources (no overlap)
|
||||||
|
* - Invalid: empty resources
|
||||||
|
* - Invalid: unknown resource value
|
||||||
|
* - Invalid: resources / excluded_resources intersection
|
||||||
|
* - Invalid: filter key not in resources
|
||||||
|
* - Invalid: max_rows_per_query = 0
|
||||||
|
* - Invalid: max_rows_per_query = 10001
|
||||||
|
* - Invalid: not an object / null
|
||||||
|
* - Defaults: include_personal defaults to true; excluded_resources defaults to []
|
||||||
|
* - Sentinel: console.warn fires for sensitive resources
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||||
|
import {
|
||||||
|
parseFederationScope,
|
||||||
|
FederationScopeError,
|
||||||
|
FederationScopeSchema,
|
||||||
|
} from './scope-schema.js';
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('parseFederationScope — valid inputs', () => {
|
||||||
|
it('accepts a minimal scope (resources + max_rows_per_query only)', () => {
|
||||||
|
const scope = parseFederationScope({
|
||||||
|
resources: ['tasks'],
|
||||||
|
max_rows_per_query: 100,
|
||||||
|
});
|
||||||
|
expect(scope.resources).toEqual(['tasks']);
|
||||||
|
expect(scope.max_rows_per_query).toBe(100);
|
||||||
|
expect(scope.excluded_resources).toEqual([]);
|
||||||
|
expect(scope.filters).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts the full PRD §8.1 example', () => {
|
||||||
|
const scope = parseFederationScope({
|
||||||
|
resources: ['tasks', 'notes', 'memory'],
|
||||||
|
filters: {
|
||||||
|
tasks: { include_teams: ['team_uuid_1', 'team_uuid_2'], include_personal: true },
|
||||||
|
notes: { include_personal: true, include_teams: [] },
|
||||||
|
memory: { include_personal: true },
|
||||||
|
},
|
||||||
|
excluded_resources: ['credentials', 'api_keys'],
|
||||||
|
max_rows_per_query: 500,
|
||||||
|
});
|
||||||
|
expect(scope.resources).toEqual(['tasks', 'notes', 'memory']);
|
||||||
|
expect(scope.excluded_resources).toEqual(['credentials', 'api_keys']);
|
||||||
|
expect(scope.filters?.tasks?.include_teams).toEqual(['team_uuid_1', 'team_uuid_2']);
|
||||||
|
expect(scope.max_rows_per_query).toBe(500);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts a scope with excluded_resources and no filter overlap', () => {
|
||||||
|
const scope = parseFederationScope({
|
||||||
|
resources: ['tasks', 'notes'],
|
||||||
|
excluded_resources: ['memory'],
|
||||||
|
max_rows_per_query: 250,
|
||||||
|
});
|
||||||
|
expect(scope.resources).toEqual(['tasks', 'notes']);
|
||||||
|
expect(scope.excluded_resources).toEqual(['memory']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('parseFederationScope — defaults', () => {
|
||||||
|
it('defaults excluded_resources to []', () => {
|
||||||
|
const scope = parseFederationScope({ resources: ['tasks'], max_rows_per_query: 1 });
|
||||||
|
expect(scope.excluded_resources).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('defaults include_personal to true when filter is provided without it', () => {
|
||||||
|
const scope = parseFederationScope({
|
||||||
|
resources: ['tasks'],
|
||||||
|
filters: { tasks: { include_teams: ['t1'] } },
|
||||||
|
max_rows_per_query: 10,
|
||||||
|
});
|
||||||
|
expect(scope.filters?.tasks?.include_personal).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('parseFederationScope — invalid inputs', () => {
|
||||||
|
it('throws FederationScopeError for empty resources array', () => {
|
||||||
|
expect(() => parseFederationScope({ resources: [], max_rows_per_query: 100 })).toThrow(
|
||||||
|
FederationScopeError,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws for unknown resource value in resources', () => {
|
||||||
|
expect(() =>
|
||||||
|
parseFederationScope({ resources: ['unknown_resource'], max_rows_per_query: 100 }),
|
||||||
|
).toThrow(FederationScopeError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws when resources and excluded_resources intersect', () => {
|
||||||
|
expect(() =>
|
||||||
|
parseFederationScope({
|
||||||
|
resources: ['tasks', 'memory'],
|
||||||
|
excluded_resources: ['memory'],
|
||||||
|
max_rows_per_query: 100,
|
||||||
|
}),
|
||||||
|
).toThrow(FederationScopeError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws when filters references a resource not in resources', () => {
|
||||||
|
expect(() =>
|
||||||
|
parseFederationScope({
|
||||||
|
resources: ['tasks'],
|
||||||
|
filters: { notes: { include_personal: true } },
|
||||||
|
max_rows_per_query: 100,
|
||||||
|
}),
|
||||||
|
).toThrow(FederationScopeError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws for max_rows_per_query = 0', () => {
|
||||||
|
expect(() => parseFederationScope({ resources: ['tasks'], max_rows_per_query: 0 })).toThrow(
|
||||||
|
FederationScopeError,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws for max_rows_per_query = 10001', () => {
|
||||||
|
expect(() => parseFederationScope({ resources: ['tasks'], max_rows_per_query: 10001 })).toThrow(
|
||||||
|
FederationScopeError,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws for null input', () => {
|
||||||
|
expect(() => parseFederationScope(null)).toThrow(FederationScopeError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws for non-object input (string)', () => {
|
||||||
|
expect(() => parseFederationScope('not-an-object')).toThrow(FederationScopeError);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('parseFederationScope — sentinel warning', () => {
|
||||||
|
it('emits console.warn when resources includes "credentials"', () => {
|
||||||
|
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||||
|
parseFederationScope({
|
||||||
|
resources: ['tasks', 'credentials'],
|
||||||
|
max_rows_per_query: 100,
|
||||||
|
});
|
||||||
|
expect(warnSpy).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining(
|
||||||
|
'[FederationScope] WARNING: scope grants sensitive resource "credentials"',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('emits console.warn when resources includes "api_keys"', () => {
|
||||||
|
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||||
|
parseFederationScope({
|
||||||
|
resources: ['tasks', 'api_keys'],
|
||||||
|
max_rows_per_query: 100,
|
||||||
|
});
|
||||||
|
expect(warnSpy).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining(
|
||||||
|
'[FederationScope] WARNING: scope grants sensitive resource "api_keys"',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does NOT emit console.warn for non-sensitive resources', () => {
|
||||||
|
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||||
|
parseFederationScope({ resources: ['tasks', 'notes', 'memory'], max_rows_per_query: 100 });
|
||||||
|
expect(warnSpy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('FederationScopeSchema — boundary values', () => {
|
||||||
|
it('accepts max_rows_per_query = 1 (lower bound)', () => {
|
||||||
|
const result = FederationScopeSchema.safeParse({ resources: ['tasks'], max_rows_per_query: 1 });
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts max_rows_per_query = 10000 (upper bound)', () => {
|
||||||
|
const result = FederationScopeSchema.safeParse({
|
||||||
|
resources: ['tasks'],
|
||||||
|
max_rows_per_query: 10000,
|
||||||
|
});
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
147
apps/gateway/src/federation/scope-schema.ts
Normal file
147
apps/gateway/src/federation/scope-schema.ts
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
/**
|
||||||
|
* Federation grant scope schema and validator.
|
||||||
|
*
|
||||||
|
* Source of truth: docs/federation/PRD.md §8.1
|
||||||
|
*
|
||||||
|
* This module is intentionally pure — no DB, no NestJS, no CA wiring.
|
||||||
|
* It is reusable from grant CRUD (M2-06) and scope enforcement (M3+).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Allowlist of federation resources (canonical — M3+ will extend this list)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
export const FEDERATION_RESOURCE_VALUES = [
|
||||||
|
'tasks',
|
||||||
|
'notes',
|
||||||
|
'memory',
|
||||||
|
'credentials',
|
||||||
|
'api_keys',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type FederationResource = (typeof FEDERATION_RESOURCE_VALUES)[number];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sensitive resources require explicit admin approval (PRD §8.4).
|
||||||
|
* The parser warns when these appear in `resources`; M2-06 grant CRUD
|
||||||
|
* will add a hard gate on top of this warning.
|
||||||
|
*/
|
||||||
|
const SENSITIVE_RESOURCES: ReadonlySet<FederationResource> = new Set(['credentials', 'api_keys']);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Sub-schemas
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const ResourceArraySchema = z
|
||||||
|
.array(z.enum(FEDERATION_RESOURCE_VALUES))
|
||||||
|
.nonempty({ message: 'resources must contain at least one value' })
|
||||||
|
.refine((arr) => new Set(arr).size === arr.length, {
|
||||||
|
message: 'resources must not contain duplicate values',
|
||||||
|
});
|
||||||
|
|
||||||
|
const ResourceFilterSchema = z.object({
|
||||||
|
include_teams: z.array(z.string()).optional(),
|
||||||
|
include_personal: z.boolean().default(true),
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Top-level schema
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export const FederationScopeSchema = z
|
||||||
|
.object({
|
||||||
|
resources: ResourceArraySchema,
|
||||||
|
|
||||||
|
excluded_resources: z
|
||||||
|
.array(z.enum(FEDERATION_RESOURCE_VALUES))
|
||||||
|
.default([])
|
||||||
|
.refine((arr) => new Set(arr).size === arr.length, {
|
||||||
|
message: 'excluded_resources must not contain duplicate values',
|
||||||
|
}),
|
||||||
|
|
||||||
|
filters: z.record(z.string(), ResourceFilterSchema).optional(),
|
||||||
|
|
||||||
|
max_rows_per_query: z
|
||||||
|
.number()
|
||||||
|
.int({ message: 'max_rows_per_query must be an integer' })
|
||||||
|
.min(1, { message: 'max_rows_per_query must be at least 1' })
|
||||||
|
.max(10000, { message: 'max_rows_per_query must be at most 10000' }),
|
||||||
|
})
|
||||||
|
.superRefine((data, ctx) => {
|
||||||
|
const resourceSet = new Set(data.resources);
|
||||||
|
|
||||||
|
// Intersection guard: a resource cannot be both granted and excluded
|
||||||
|
for (const r of data.excluded_resources) {
|
||||||
|
if (resourceSet.has(r)) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: `Resource "${r}" appears in both resources and excluded_resources`,
|
||||||
|
path: ['excluded_resources'],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter keys must be a subset of resources
|
||||||
|
if (data.filters) {
|
||||||
|
for (const key of Object.keys(data.filters)) {
|
||||||
|
if (!resourceSet.has(key as FederationResource)) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: `filters key "${key}" references a resource not present in resources`,
|
||||||
|
path: ['filters', key],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export type FederationScope = z.infer<typeof FederationScopeSchema>;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Error class
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export class FederationScopeError extends Error {
|
||||||
|
constructor(message: string) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'FederationScopeError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Typed parser
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse and validate an unknown value as a FederationScope.
|
||||||
|
*
|
||||||
|
* Throws `FederationScopeError` with aggregated Zod issues on failure.
|
||||||
|
*
|
||||||
|
* Emits `console.warn` when sensitive resources (`credentials`, `api_keys`)
|
||||||
|
* are present in `resources` — per PRD §8.4, these require explicit admin
|
||||||
|
* approval. M2-06 grant CRUD will add a hard gate on top of this warning.
|
||||||
|
*/
|
||||||
|
export function parseFederationScope(input: unknown): FederationScope {
|
||||||
|
const result = FederationScopeSchema.safeParse(input);
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
const issues = result.error.issues
|
||||||
|
.map((e) => ` - [${e.path.join('.') || 'root'}] ${e.message}`)
|
||||||
|
.join('\n');
|
||||||
|
throw new FederationScopeError(`Invalid federation scope:\n${issues}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const scope = result.data;
|
||||||
|
|
||||||
|
// Sentinel warning for sensitive resources (PRD §8.4)
|
||||||
|
for (const resource of scope.resources) {
|
||||||
|
if (SENSITIVE_RESOURCES.has(resource)) {
|
||||||
|
console.warn(
|
||||||
|
`[FederationScope] WARNING: scope grants sensitive resource "${resource}". Per PRD §8.4 this requires explicit admin approval and is logged.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return scope;
|
||||||
|
}
|
||||||
70
deploy/portainer/README.md
Normal file
70
deploy/portainer/README.md
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
# deploy/portainer/
|
||||||
|
|
||||||
|
Portainer stack templates for Mosaic Stack deployments.
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
| -------------------------- | -------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| `federated-test.stack.yml` | Docker Swarm stack for federation end-to-end test instances (`mos-test-1.woltje.com`, `mos-test-2.woltje.com`) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## federated-test.stack.yml
|
||||||
|
|
||||||
|
A self-contained Swarm stack that boots a federated-tier Mosaic gateway with co-located Postgres 17 (pgvector) and Valkey 8. This is a **test template** — production deployments will use a separate template with stricter resource limits and Docker secrets.
|
||||||
|
|
||||||
|
### Deploy via Portainer UI
|
||||||
|
|
||||||
|
1. Log into Portainer.
|
||||||
|
2. Navigate to **Stacks → Add stack**.
|
||||||
|
3. Set a stack name matching `STACK_NAME` below (e.g. `mos-test-1`).
|
||||||
|
4. Choose **Web editor** and paste the contents of `federated-test.stack.yml`.
|
||||||
|
5. Scroll to **Environment variables** and add each variable listed below.
|
||||||
|
6. Click **Deploy the stack**.
|
||||||
|
|
||||||
|
### Required environment variables
|
||||||
|
|
||||||
|
| Variable | Example | Notes |
|
||||||
|
| -------------------- | --------------------------------------- | -------------------------------------------------------- |
|
||||||
|
| `STACK_NAME` | `mos-test-1` | Unique per stack — used in Traefik router/service names. |
|
||||||
|
| `HOST_FQDN` | `mos-test-1.woltje.com` | Fully-qualified hostname served by this stack. |
|
||||||
|
| `POSTGRES_PASSWORD` | _(generate randomly)_ | Database password. Do **not** reuse between stacks. |
|
||||||
|
| `BETTER_AUTH_SECRET` | _(generate: `openssl rand -base64 32`)_ | BetterAuth session signing key. |
|
||||||
|
| `BETTER_AUTH_URL` | `https://mos-test-1.woltje.com` | Public base URL of the gateway. |
|
||||||
|
|
||||||
|
Optional variables (uncomment in the YAML or set in Portainer):
|
||||||
|
|
||||||
|
| Variable | Notes |
|
||||||
|
| ----------------------------- | ---------------------------------------------------------- |
|
||||||
|
| `ANTHROPIC_API_KEY` | Enable Claude models. |
|
||||||
|
| `OPENAI_API_KEY` | Enable OpenAI models. |
|
||||||
|
| `OTEL_EXPORTER_OTLP_ENDPOINT` | Forward traces to a collector (e.g. `http://jaeger:4318`). |
|
||||||
|
|
||||||
|
### Required external resources
|
||||||
|
|
||||||
|
Before deploying, ensure the following exist on the Swarm:
|
||||||
|
|
||||||
|
1. **`traefik-public` overlay network** — shared network Traefik uses to route traffic to stacks.
|
||||||
|
```bash
|
||||||
|
docker network create --driver overlay --attachable traefik-public
|
||||||
|
```
|
||||||
|
2. **`letsencrypt` cert resolver** — configured in the Traefik Swarm stack. The stack template references `tls.certresolver=letsencrypt`; the name must match your Traefik config.
|
||||||
|
3. **DNS A record** — `${HOST_FQDN}` must resolve to the Swarm ingress IP (or a Cloudflare-proxied address pointing there).
|
||||||
|
|
||||||
|
### Deployed instances
|
||||||
|
|
||||||
|
| Stack name | HOST_FQDN | Purpose |
|
||||||
|
| ------------ | ----------------------- | ---------------------------------- |
|
||||||
|
| `mos-test-1` | `mos-test-1.woltje.com` | DEPLOY-03 — first federation peer |
|
||||||
|
| `mos-test-2` | `mos-test-2.woltje.com` | DEPLOY-04 — second federation peer |
|
||||||
|
|
||||||
|
### Image
|
||||||
|
|
||||||
|
The gateway image is pinned by digest to `fed-v0.1.0-m1` (verified in DEPLOY-01). Update the digest in the YAML when promoting a new build — never use `:latest` or a mutable tag in Swarm.
|
||||||
|
|
||||||
|
### Notes
|
||||||
|
|
||||||
|
- This template boots a **vanilla M1-baseline gateway** in federated tier. Federation grants (Step-CA, mTLS) are M2+ scope and not included here.
|
||||||
|
- Each stack gets its own Postgres volume (`postgres-data`) and Valkey volume (`valkey-data`) scoped to the stack name by Swarm.
|
||||||
|
- `depends_on` is honoured by Compose but ignored by Swarm — healthchecks on Postgres and Valkey ensure the gateway retries until they are ready.
|
||||||
160
deploy/portainer/federated-test.stack.yml
Normal file
160
deploy/portainer/federated-test.stack.yml
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
# deploy/portainer/federated-test.stack.yml
|
||||||
|
#
|
||||||
|
# Portainer / Docker Swarm stack template — federated-tier test instance
|
||||||
|
#
|
||||||
|
# PURPOSE
|
||||||
|
# Deploys a single federated-tier Mosaic gateway with co-located Postgres
|
||||||
|
# (pgvector) and Valkey for end-to-end federation testing. Intended for
|
||||||
|
# mos-test-1.woltje.com and mos-test-2.woltje.com (DEPLOY-03/04).
|
||||||
|
#
|
||||||
|
# REQUIRED ENV VARS (set per-stack in Portainer → Stacks → Environment variables)
|
||||||
|
# STACK_NAME Unique name for Traefik router/service labels.
|
||||||
|
# Examples: mos-test-1, mos-test-2
|
||||||
|
# HOST_FQDN Fully-qualified domain name served by this stack.
|
||||||
|
# Examples: mos-test-1.woltje.com, mos-test-2.woltje.com
|
||||||
|
# POSTGRES_PASSWORD Database password — set per stack; do NOT commit a default.
|
||||||
|
# BETTER_AUTH_SECRET Random 32-char string for BetterAuth session signing.
|
||||||
|
# Generate: openssl rand -base64 32
|
||||||
|
# BETTER_AUTH_URL Public gateway base URL, e.g. https://mos-test-1.woltje.com
|
||||||
|
#
|
||||||
|
# OPTIONAL ENV VARS (uncomment and set in Portainer to enable features)
|
||||||
|
# ANTHROPIC_API_KEY sk-ant-...
|
||||||
|
# OPENAI_API_KEY sk-...
|
||||||
|
# OTEL_EXPORTER_OTLP_ENDPOINT http://<collector>:4318
|
||||||
|
# OTEL_SERVICE_NAME (default: mosaic-gateway)
|
||||||
|
#
|
||||||
|
# REQUIRED EXTERNAL RESOURCES
|
||||||
|
# traefik-public Docker overlay network — must exist before deploying.
|
||||||
|
# Create: docker network create --driver overlay --attachable traefik-public
|
||||||
|
# letsencrypt Traefik cert resolver configured on the Swarm manager.
|
||||||
|
# DNS A record ${HOST_FQDN} → Swarm ingress IP (or Cloudflare proxy).
|
||||||
|
#
|
||||||
|
# IMAGE
|
||||||
|
# Pinned to sha-9f1a081 (main HEAD post-#488 Dockerfile fix). The previous
|
||||||
|
# pin (fed-v0.1.0-m1, sha256:9b72e2...) had a broken pnpm copy and could
|
||||||
|
# not resolve @mosaicstack/storage at runtime. The new digest was smoke-
|
||||||
|
# tested locally — gateway boots, imports resolve, tier-detector runs.
|
||||||
|
# Update digest here when promoting a new build.
|
||||||
|
#
|
||||||
|
# HEALTHCHECK NOTE (2026-04-21)
|
||||||
|
# Switched from busybox wget to node http.get on 127.0.0.1 (not localhost) to
|
||||||
|
# avoid IPv6 resolution issues on Alpine. Retries increased to 5 and
|
||||||
|
# start_period to 60s to cover the NestJS/GC cold-start window (~40-50s).
|
||||||
|
# restart_policy set to `any` so SIGTERM/clean-exit also triggers restart.
|
||||||
|
#
|
||||||
|
# NOTE: This is a TEST template — production deployments use a separate
|
||||||
|
# parameterised template with stricter resource limits and secrets.
|
||||||
|
|
||||||
|
version: '3.9'
|
||||||
|
|
||||||
|
services:
|
||||||
|
gateway:
|
||||||
|
image: git.mosaicstack.dev/mosaicstack/stack/gateway@sha256:1069117740e00ccfeba357cae38c43f3729fe5ae702740ce474f6512414d7c02
|
||||||
|
# Tag for human reference: sha-9f1a081 (post-#488 Dockerfile fix; smoke-tested locally)
|
||||||
|
environment:
|
||||||
|
# ── Tier ───────────────────────────────────────────────────────────────
|
||||||
|
MOSAIC_TIER: federated
|
||||||
|
|
||||||
|
# ── Database ───────────────────────────────────────────────────────────
|
||||||
|
DATABASE_URL: postgres://gateway:${POSTGRES_PASSWORD}@postgres:5432/mosaic
|
||||||
|
|
||||||
|
# ── Queue ──────────────────────────────────────────────────────────────
|
||||||
|
VALKEY_URL: redis://valkey:6379
|
||||||
|
|
||||||
|
# ── Gateway ────────────────────────────────────────────────────────────
|
||||||
|
GATEWAY_PORT: '3000'
|
||||||
|
GATEWAY_CORS_ORIGIN: https://${HOST_FQDN}
|
||||||
|
|
||||||
|
# ── Auth ───────────────────────────────────────────────────────────────
|
||||||
|
BETTER_AUTH_SECRET: ${BETTER_AUTH_SECRET}
|
||||||
|
BETTER_AUTH_URL: https://${HOST_FQDN}
|
||||||
|
|
||||||
|
# ── Observability ──────────────────────────────────────────────────────
|
||||||
|
OTEL_SERVICE_NAME: ${STACK_NAME:-mosaic-gateway}
|
||||||
|
# OTEL_EXPORTER_OTLP_ENDPOINT: http://<collector>:4318
|
||||||
|
|
||||||
|
# ── AI Providers (uncomment to enable) ─────────────────────────────────
|
||||||
|
# ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY}
|
||||||
|
# OPENAI_API_KEY: ${OPENAI_API_KEY}
|
||||||
|
networks:
|
||||||
|
- federated-test
|
||||||
|
- traefik-public
|
||||||
|
deploy:
|
||||||
|
replicas: 1
|
||||||
|
restart_policy:
|
||||||
|
condition: any
|
||||||
|
delay: 5s
|
||||||
|
max_attempts: 3
|
||||||
|
labels:
|
||||||
|
- 'traefik.enable=true'
|
||||||
|
- 'traefik.docker.network=traefik-public'
|
||||||
|
- 'traefik.http.routers.${STACK_NAME}.rule=Host(`${HOST_FQDN}`)'
|
||||||
|
- 'traefik.http.routers.${STACK_NAME}.entrypoints=websecure'
|
||||||
|
- 'traefik.http.routers.${STACK_NAME}.tls=true'
|
||||||
|
- 'traefik.http.routers.${STACK_NAME}.tls.certresolver=letsencrypt'
|
||||||
|
- 'traefik.http.services.${STACK_NAME}.loadbalancer.server.port=3000'
|
||||||
|
healthcheck:
|
||||||
|
test:
|
||||||
|
- 'CMD'
|
||||||
|
- 'node'
|
||||||
|
- '-e'
|
||||||
|
- "require('http').get('http://127.0.0.1:3000/health',r=>process.exit(r.statusCode===200?0:1)).on('error',()=>process.exit(1))"
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
start_period: 60s
|
||||||
|
depends_on:
|
||||||
|
- postgres
|
||||||
|
- valkey
|
||||||
|
|
||||||
|
postgres:
|
||||||
|
image: pgvector/pgvector:pg17
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: gateway
|
||||||
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||||
|
POSTGRES_DB: mosaic
|
||||||
|
volumes:
|
||||||
|
- postgres-data:/var/lib/postgresql/data
|
||||||
|
networks:
|
||||||
|
- federated-test
|
||||||
|
deploy:
|
||||||
|
replicas: 1
|
||||||
|
restart_policy:
|
||||||
|
condition: on-failure
|
||||||
|
delay: 5s
|
||||||
|
max_attempts: 3
|
||||||
|
healthcheck:
|
||||||
|
test: ['CMD-SHELL', 'pg_isready -U gateway']
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
start_period: 10s
|
||||||
|
|
||||||
|
valkey:
|
||||||
|
image: valkey/valkey:8-alpine
|
||||||
|
volumes:
|
||||||
|
- valkey-data:/data
|
||||||
|
networks:
|
||||||
|
- federated-test
|
||||||
|
deploy:
|
||||||
|
replicas: 1
|
||||||
|
restart_policy:
|
||||||
|
condition: on-failure
|
||||||
|
delay: 5s
|
||||||
|
max_attempts: 3
|
||||||
|
healthcheck:
|
||||||
|
test: ['CMD', 'valkey-cli', 'ping']
|
||||||
|
interval: 10s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 5
|
||||||
|
start_period: 5s
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres-data:
|
||||||
|
valkey-data:
|
||||||
|
|
||||||
|
networks:
|
||||||
|
federated-test:
|
||||||
|
driver: overlay
|
||||||
|
traefik-public:
|
||||||
|
external: true
|
||||||
@@ -27,6 +27,7 @@ services:
|
|||||||
postgres-federated:
|
postgres-federated:
|
||||||
image: pgvector/pgvector:pg17
|
image: pgvector/pgvector:pg17
|
||||||
profiles: [federated]
|
profiles: [federated]
|
||||||
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- '${PG_FEDERATED_HOST_PORT:-5433}:5432'
|
- '${PG_FEDERATED_HOST_PORT:-5433}:5432'
|
||||||
environment:
|
environment:
|
||||||
@@ -45,6 +46,7 @@ services:
|
|||||||
valkey-federated:
|
valkey-federated:
|
||||||
image: valkey/valkey:8-alpine
|
image: valkey/valkey:8-alpine
|
||||||
profiles: [federated]
|
profiles: [federated]
|
||||||
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- '${VALKEY_FEDERATED_HOST_PORT:-6380}:6379'
|
- '${VALKEY_FEDERATED_HOST_PORT:-6380}:6379'
|
||||||
volumes:
|
volumes:
|
||||||
@@ -55,6 +57,64 @@ services:
|
|||||||
timeout: 3s
|
timeout: 3s
|
||||||
retries: 5
|
retries: 5
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Step-CA — Mosaic Federation internal certificate authority
|
||||||
|
#
|
||||||
|
# Image: pinned to 0.27.4 (latest stable as of late 2025).
|
||||||
|
# `latest` is forbidden per Mosaic image policy (immutable tag required for
|
||||||
|
# reproducible deployments and digest-first promotion in CI).
|
||||||
|
#
|
||||||
|
# Profile: `federated` — this service must not start in non-federated dev.
|
||||||
|
#
|
||||||
|
# Password:
|
||||||
|
# Dev: bind-mount ./infra/step-ca/dev-password (gitignored; copy from
|
||||||
|
# ./infra/step-ca/dev-password.example and customise locally).
|
||||||
|
# Prod: replace the bind-mount with a Docker secret:
|
||||||
|
# secrets:
|
||||||
|
# ca_password:
|
||||||
|
# external: true
|
||||||
|
# and reference it as `/run/secrets/ca_password` (same path the
|
||||||
|
# init script already uses).
|
||||||
|
#
|
||||||
|
# Provisioner: "mosaic-fed" (consumed by apps/gateway/src/federation/ca.service.ts)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
step-ca:
|
||||||
|
image: smallstep/step-ca:0.27.4
|
||||||
|
profiles: [federated]
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- '${STEP_CA_HOST_PORT:-9000}:9000'
|
||||||
|
volumes:
|
||||||
|
- step_ca_data:/home/step
|
||||||
|
# init script — executed as the container entrypoint
|
||||||
|
- ./infra/step-ca/init.sh:/usr/local/bin/mosaic-step-ca-init.sh:ro
|
||||||
|
# X.509 template skeleton (wired in M2-04)
|
||||||
|
- ./infra/step-ca/templates:/etc/step-ca-templates:ro
|
||||||
|
# Dev password file — GITIGNORED; copy from dev-password.example
|
||||||
|
# In production, replace this with a Docker secret (see comment above).
|
||||||
|
- ./infra/step-ca/dev-password:/run/secrets/ca_password:ro
|
||||||
|
entrypoint: ['/bin/sh', '/usr/local/bin/mosaic-step-ca-init.sh']
|
||||||
|
healthcheck:
|
||||||
|
# The healthcheck requires the root cert to exist, which is only true
|
||||||
|
# after init.sh has completed on first boot. start_period gives init
|
||||||
|
# time to finish before Docker starts counting retries.
|
||||||
|
test:
|
||||||
|
[
|
||||||
|
'CMD',
|
||||||
|
'step',
|
||||||
|
'ca',
|
||||||
|
'health',
|
||||||
|
'--ca-url',
|
||||||
|
'https://localhost:9000',
|
||||||
|
'--root',
|
||||||
|
'/home/step/certs/root_ca.crt',
|
||||||
|
]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
start_period: 30s
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
pg_federated_data:
|
pg_federated_data:
|
||||||
valkey_federated_data:
|
valkey_federated_data:
|
||||||
|
step_ca_data:
|
||||||
|
|||||||
@@ -5,18 +5,27 @@ RUN corepack enable
|
|||||||
|
|
||||||
FROM base AS builder
|
FROM base AS builder
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
# Copy workspace manifests first for layer-cached install
|
||||||
COPY pnpm-workspace.yaml pnpm-lock.yaml package.json ./
|
COPY pnpm-workspace.yaml pnpm-lock.yaml package.json ./
|
||||||
COPY apps/gateway/package.json ./apps/gateway/
|
COPY apps/gateway/package.json ./apps/gateway/
|
||||||
COPY packages/ ./packages/
|
COPY packages/ ./packages/
|
||||||
|
COPY plugins/ ./plugins/
|
||||||
RUN pnpm install --frozen-lockfile
|
RUN pnpm install --frozen-lockfile
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN pnpm --filter @mosaic/gateway build
|
# Build gateway and all of its workspace dependencies via turbo dependency graph
|
||||||
|
RUN pnpm turbo run build --filter @mosaicstack/gateway...
|
||||||
|
# Produce a self-contained deploy artifact: flat node_modules, no pnpm symlinks
|
||||||
|
# --legacy is required for pnpm v10 when inject-workspace-packages is not set
|
||||||
|
RUN pnpm --filter @mosaicstack/gateway --prod deploy --legacy /deploy
|
||||||
|
|
||||||
FROM base AS runner
|
FROM base AS runner
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
|
# Use the pnpm deploy output — resolves all deps into a flat, self-contained node_modules
|
||||||
|
COPY --from=builder /deploy/node_modules ./node_modules
|
||||||
|
COPY --from=builder /deploy/package.json ./package.json
|
||||||
|
# dist is declared in package.json "files" so pnpm deploy copies it into /deploy;
|
||||||
|
# copy from builder explicitly as belt-and-suspenders
|
||||||
COPY --from=builder /app/apps/gateway/dist ./dist
|
COPY --from=builder /app/apps/gateway/dist ./dist
|
||||||
COPY --from=builder /app/apps/gateway/package.json ./package.json
|
|
||||||
COPY --from=builder /app/node_modules ./node_modules
|
|
||||||
EXPOSE 4000
|
EXPOSE 4000
|
||||||
CMD ["node", "dist/main.js"]
|
CMD ["node", "dist/main.js"]
|
||||||
|
|||||||
106
docs/federation/ADMIN-CLI.md
Normal file
106
docs/federation/ADMIN-CLI.md
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
# Mosaic Federation — Admin CLI Reference
|
||||||
|
|
||||||
|
Available since: FED-M2
|
||||||
|
|
||||||
|
## Grant Management
|
||||||
|
|
||||||
|
### Create a grant
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mosaic federation grant create --user <userId> --peer <peerId> --scope <scope-file.json>
|
||||||
|
```
|
||||||
|
|
||||||
|
The scope file defines what resources and rows the peer may access:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"resources": ["tasks", "notes"],
|
||||||
|
"excluded_resources": ["credentials"],
|
||||||
|
"max_rows_per_query": 100
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Valid resource values: `tasks`, `notes`, `credentials`, `teams`, `users`
|
||||||
|
|
||||||
|
### List grants
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mosaic federation grant list [--peer <peerId>] [--status pending|active|revoked|expired]
|
||||||
|
```
|
||||||
|
|
||||||
|
Shows all federation grants, optionally filtered by peer or status.
|
||||||
|
|
||||||
|
### Show a grant
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mosaic federation grant show <grantId>
|
||||||
|
```
|
||||||
|
|
||||||
|
Display details of a single grant, including its scope, activation timestamp, and status.
|
||||||
|
|
||||||
|
### Revoke a grant
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mosaic federation grant revoke <grantId> [--reason "Reason text"]
|
||||||
|
```
|
||||||
|
|
||||||
|
Revoke an active grant immediately. Revoked grants cannot be reactivated. The optional reason is stored in the audit log.
|
||||||
|
|
||||||
|
### Generate enrollment token
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mosaic federation grant token <grantId> [--ttl <seconds>]
|
||||||
|
```
|
||||||
|
|
||||||
|
Generate a single-use enrollment token for the grant. The default TTL is 900 seconds (15 minutes); maximum 15 minutes.
|
||||||
|
|
||||||
|
Output includes the token and the full enrollment URL for the peer to use.
|
||||||
|
|
||||||
|
## Peer Management
|
||||||
|
|
||||||
|
### Add a peer (remote enrollment)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mosaic federation peer add <enrollment-url>
|
||||||
|
```
|
||||||
|
|
||||||
|
Enroll a remote peer using the enrollment URL obtained from a grant token. The command:
|
||||||
|
|
||||||
|
1. Generates a P-256 ECDSA keypair locally
|
||||||
|
2. Creates a certificate signing request (CSR)
|
||||||
|
3. Submits the CSR to the enrollment URL
|
||||||
|
4. Verifies the returned certificate includes the correct custom OIDs (grant ID and subject user ID)
|
||||||
|
5. Seals the private key at rest using `BETTER_AUTH_SECRET`
|
||||||
|
6. Stores the peer record and sealed key in the local gateway database
|
||||||
|
|
||||||
|
Once enrollment completes, the peer can authenticate using the certificate and private key.
|
||||||
|
|
||||||
|
### List peers
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mosaic federation peer list
|
||||||
|
```
|
||||||
|
|
||||||
|
Shows all enrolled peers, including their certificate fingerprints and activation status.
|
||||||
|
|
||||||
|
## REST API Reference
|
||||||
|
|
||||||
|
All CLI commands call the local gateway admin API. Equivalent REST endpoints:
|
||||||
|
|
||||||
|
| CLI Command | REST Endpoint | Method |
|
||||||
|
| ------------ | ------------------------------------------------------------------------------------------- | ----------------- |
|
||||||
|
| grant create | `/api/admin/federation/grants` | POST |
|
||||||
|
| grant list | `/api/admin/federation/grants` | GET |
|
||||||
|
| grant show | `/api/admin/federation/grants/:id` | GET |
|
||||||
|
| grant revoke | `/api/admin/federation/grants/:id/revoke` | PATCH |
|
||||||
|
| grant token | `/api/admin/federation/grants/:id/tokens` | POST |
|
||||||
|
| peer list | `/api/admin/federation/peers` | GET |
|
||||||
|
| peer add | `/api/admin/federation/peers/keypair` + enrollment + `/api/admin/federation/peers/:id/cert` | POST, POST, PATCH |
|
||||||
|
|
||||||
|
## Security Notes
|
||||||
|
|
||||||
|
- **Enrollment tokens** are single-use and expire in 15 minutes (not configurable beyond 15 minutes)
|
||||||
|
- **Peer private keys** are encrypted at rest using AES-256-GCM, keyed from `BETTER_AUTH_SECRET`
|
||||||
|
- **Custom OIDs** in issued certificates are verified post-issuance: the grant ID and subject user ID must match the certificate extensions
|
||||||
|
- **Grant activation** is atomic — concurrent enrollment attempts for the same grant are rejected
|
||||||
|
- **Revoked grants** cannot be activated; peers attempting to use a revoked grant's token will be rejected
|
||||||
@@ -7,13 +7,22 @@
|
|||||||
|
|
||||||
**ID:** federation-v1-20260419
|
**ID:** federation-v1-20260419
|
||||||
**Statement:** Jarvis operates across 3–4 workstations in two physical locations (home, USC). The user currently reaches back to a single jarvis-brain checkout from every session; a prior OpenBrain attempt caused cache, latency, and opacity pain. This mission builds asymmetric federation between Mosaic Stack gateways so that a session on a user's home gateway can query their work gateway in real time without data ever persisting across the boundary, with full multi-tenant isolation and standard-PKI (X.509 / Step-CA) trust management.
|
**Statement:** Jarvis operates across 3–4 workstations in two physical locations (home, USC). The user currently reaches back to a single jarvis-brain checkout from every session; a prior OpenBrain attempt caused cache, latency, and opacity pain. This mission builds asymmetric federation between Mosaic Stack gateways so that a session on a user's home gateway can query their work gateway in real time without data ever persisting across the boundary, with full multi-tenant isolation and standard-PKI (X.509 / Step-CA) trust management.
|
||||||
**Phase:** Planning complete — M1 implementation not started
|
**Phase:** M2 active — Step-CA + grant schema + admin CLI; parallel test-deploy workstream stood up
|
||||||
**Current Milestone:** FED-M1
|
**Current Milestone:** FED-M2
|
||||||
**Progress:** 0 / 7 milestones
|
**Progress:** 1 / 7 milestones
|
||||||
**Status:** active
|
**Status:** active
|
||||||
**Last Updated:** 2026-04-19 (PRD + MILESTONES + tracking issues filed)
|
**Last Updated:** 2026-04-21 (M2 decomposed; mos-test-1/-2 designated as federation E2E test hosts)
|
||||||
**Parent Mission:** None — new mission
|
**Parent Mission:** None — new mission
|
||||||
|
|
||||||
|
## Test Infrastructure
|
||||||
|
|
||||||
|
| Host | Role | Image | Tier |
|
||||||
|
| ----------------------- | ----------------------------------- | ------------------------------------- | --------- |
|
||||||
|
| `mos-test-1.woltje.com` | Federation Server A (querying side) | `gateway:fed-v0.1.0-m1` (M1 baseline) | federated |
|
||||||
|
| `mos-test-2.woltje.com` | Federation Server B (serving side) | `gateway:fed-v0.1.0-m1` (M1 baseline) | federated |
|
||||||
|
|
||||||
|
These are TEST hosts for federation E2E (M3+). Distinct from PRD AC-12 production targets (`woltje.com` ↔ `uscllc.com`). Deployment workstream tracked in `docs/federation/TASKS.md` under FED-M2-DEPLOY-\*.
|
||||||
|
|
||||||
## Context
|
## Context
|
||||||
|
|
||||||
Federation is the solution to what originally drove OpenBrain. The prior attempt coupled every agent session to a remote service, introduced cache/latency/opacity pain, and created a hard dependency that punished offline use. This redesign:
|
Federation is the solution to what originally drove OpenBrain. The prior attempt coupled every agent session to a remote service, introduced cache/latency/opacity pain, and created a hard dependency that punished offline use. This redesign:
|
||||||
@@ -52,9 +61,9 @@ Key design references:
|
|||||||
## Milestones
|
## Milestones
|
||||||
|
|
||||||
| # | ID | Name | Status | Branch | Issue | Started | Completed |
|
| # | ID | Name | Status | Branch | Issue | Started | Completed |
|
||||||
| --- | ------ | --------------------------------------------- | ----------- | ------ | ----- | ------- | --------- |
|
| --- | ------ | --------------------------------------------- | ----------- | ------------------ | ----- | ---------- | ---------- |
|
||||||
| 1 | FED-M1 | Federated tier infrastructure | not-started | — | #460 | — | — |
|
| 1 | FED-M1 | Federated tier infrastructure | done | (12 PRs #470-#481) | #460 | 2026-04-19 | 2026-04-19 |
|
||||||
| 2 | FED-M2 | Step-CA + grant schema + admin CLI | not-started | — | #461 | — | — |
|
| 2 | FED-M2 | Step-CA + grant schema + admin CLI | in-progress | (decomposition) | #461 | 2026-04-21 | — |
|
||||||
| 3 | FED-M3 | mTLS handshake + list/get + scope enforcement | not-started | — | #462 | — | — |
|
| 3 | FED-M3 | mTLS handshake + list/get + scope enforcement | not-started | — | #462 | — | — |
|
||||||
| 4 | FED-M4 | search verb + audit log + rate limit | not-started | — | #463 | — | — |
|
| 4 | FED-M4 | search verb + audit log + rate limit | not-started | — | #463 | — | — |
|
||||||
| 5 | FED-M5 | Cache + offline degradation + OTEL | not-started | — | #464 | — | — |
|
| 5 | FED-M5 | Cache + offline degradation + OTEL | not-started | — | #464 | — | — |
|
||||||
@@ -77,9 +86,16 @@ Key design references:
|
|||||||
## Session History
|
## Session History
|
||||||
|
|
||||||
| Session | Date | Runtime | Outcome |
|
| Session | Date | Runtime | Outcome |
|
||||||
| ------- | ---------- | ------- | --------------------------------------------------- |
|
| ------- | ---------- | ------- | --------------------------------------------------------------------- |
|
||||||
| S1 | 2026-04-19 | claude | PRD authored, MILESTONES decomposed, 7 issues filed |
|
| S1 | 2026-04-19 | claude | PRD authored, MILESTONES decomposed, 7 issues filed |
|
||||||
|
| S2-S4 | 2026-04-19 | claude | FED-M1 complete: 12 tasks (PRs #470-#481) merged; tag `fed-v0.1.0-m1` |
|
||||||
|
|
||||||
## Next Step
|
## Next Step
|
||||||
|
|
||||||
Begin FED-M1 implementation: federated tier infrastructure. Breakdown in `docs/federation/TASKS.md`.
|
FED-M2 active. Decomposition landed in `docs/federation/TASKS.md` (M2-01..M2-13 code workstream + DEPLOY-01..DEPLOY-05 parallel test-deploy workstream, ~88K total). Tracking issue #482.
|
||||||
|
|
||||||
|
Parallel execution plan:
|
||||||
|
|
||||||
|
- **CODE workstream**: M2-01 (DB migration) starts immediately — sonnet subagent on `feat/federation-m2-schema`. Then M2-02 → M2-09 sequentially with M2-04/M2-05/M2-06/M2-07 having interleaved CA/storage/grant dependencies.
|
||||||
|
- **DEPLOY workstream**: DEPLOY-01 (image verify) → DEPLOY-02 (stack template) → DEPLOY-03/04 (mos-test-1/-2 deploy) → DEPLOY-05 (TEST-INFRA.md). Gated on Portainer wrapper PR (`PORTAINER_INSECURE` flag) merging first.
|
||||||
|
- **Re-converge** at M2-10 (E2E test) once both workstreams ready.
|
||||||
|
|||||||
280
docs/federation/SETUP.md
Normal file
280
docs/federation/SETUP.md
Normal file
@@ -0,0 +1,280 @@
|
|||||||
|
# Federated Tier Setup Guide
|
||||||
|
|
||||||
|
## What is the federated tier?
|
||||||
|
|
||||||
|
The federated tier is designed for multi-user and multi-host deployments. It consists of PostgreSQL 17 with pgvector extension (for embeddings and RAG), Valkey for distributed task queueing and caching, and a shared configuration across multiple Mosaic gateway instances. Use this tier when running Mosaic in production or when scaling beyond a single-host deployment.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- Docker and Docker Compose installed
|
||||||
|
- Ports 5433 (PostgreSQL) and 6380 (Valkey) available on your host (or adjust environment variables)
|
||||||
|
- At least 2 GB free disk space for data volumes
|
||||||
|
|
||||||
|
## Start the federated stack
|
||||||
|
|
||||||
|
Run the federated overlay:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f docker-compose.federated.yml --profile federated up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
This starts PostgreSQL 17 with pgvector and Valkey 8. The pgvector extension is created automatically on first boot.
|
||||||
|
|
||||||
|
Verify the services are running:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f docker-compose.federated.yml ps
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected output shows `postgres-federated` and `valkey-federated` both healthy.
|
||||||
|
|
||||||
|
## Configure mosaic for federated tier
|
||||||
|
|
||||||
|
Create or update your `mosaic.config.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tier": "federated",
|
||||||
|
"database": "postgresql://mosaic:mosaic@localhost:5433/mosaic",
|
||||||
|
"queue": "redis://localhost:6380"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
If you're using environment variables instead:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export DATABASE_URL="postgresql://mosaic:mosaic@localhost:5433/mosaic"
|
||||||
|
export REDIS_URL="redis://localhost:6380"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Verify health
|
||||||
|
|
||||||
|
Run the health check:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mosaic gateway doctor
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected output (green):
|
||||||
|
|
||||||
|
```
|
||||||
|
Tier: federated Config: mosaic.config.json
|
||||||
|
✓ postgres localhost:5433 (42ms)
|
||||||
|
✓ valkey localhost:6380 (8ms)
|
||||||
|
✓ pgvector (embedded) (15ms)
|
||||||
|
```
|
||||||
|
|
||||||
|
For JSON output (useful in CI/automation):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mosaic gateway doctor --json
|
||||||
|
```
|
||||||
|
|
||||||
|
## Step 2: Step-CA Bootstrap
|
||||||
|
|
||||||
|
Step-CA is a certificate authority that issues X.509 certificates for federation peers. In Mosaic federation, it signs peer certificates with custom OIDs that embed grant and user identities, enforcing authorization at the certificate level.
|
||||||
|
|
||||||
|
### Prerequisites for Step-CA
|
||||||
|
|
||||||
|
Before starting the CA, you must set up the dev password:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp infra/step-ca/dev-password.example infra/step-ca/dev-password
|
||||||
|
# Edit dev-password and set your CA password (minimum 16 characters)
|
||||||
|
```
|
||||||
|
|
||||||
|
The password is required for the CA to boot and derive the provisioner key used by the gateway.
|
||||||
|
|
||||||
|
### Start the Step-CA service
|
||||||
|
|
||||||
|
Add the step-ca service to your federated stack:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f docker-compose.federated.yml --profile federated up -d step-ca
|
||||||
|
```
|
||||||
|
|
||||||
|
On first boot, the init script (`infra/step-ca/init.sh`) runs automatically. It:
|
||||||
|
|
||||||
|
- Generates the CA root key and certificate in the Docker volume
|
||||||
|
- Creates the `mosaic-fed` JWK provisioner
|
||||||
|
- Applies the X.509 template from `infra/step-ca/templates/federation.tpl`
|
||||||
|
|
||||||
|
The volume is persistent, so subsequent boots reuse the existing CA keys.
|
||||||
|
|
||||||
|
Verify the CA is healthy:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl https://localhost:9000/health --cacert /tmp/step-ca-root.crt
|
||||||
|
```
|
||||||
|
|
||||||
|
(If the root cert file doesn't exist yet, see the extraction steps below.)
|
||||||
|
|
||||||
|
### Extract credentials for the gateway
|
||||||
|
|
||||||
|
The gateway requires two credentials from the running CA:
|
||||||
|
|
||||||
|
**1. Provisioner key (for `STEP_CA_PROVISIONER_KEY_JSON`)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker exec $(docker ps -qf name=step-ca) cat /home/step/secrets/mosaic-fed.json > /tmp/step-ca-provisioner.json
|
||||||
|
```
|
||||||
|
|
||||||
|
This JSON file contains the JWK public and private keys for the `mosaic-fed` provisioner. Store it securely and pass its contents to the gateway via the `STEP_CA_PROVISIONER_KEY_JSON` environment variable.
|
||||||
|
|
||||||
|
**2. Root certificate (for `STEP_CA_ROOT_CERT_PATH`)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker cp $(docker ps -qf name=step-ca):/home/step/certs/root_ca.crt /tmp/step-ca-root.crt
|
||||||
|
```
|
||||||
|
|
||||||
|
This PEM file is the CA's root certificate, used to verify peer certificates issued by step-ca. Pass its path to the gateway via `STEP_CA_ROOT_CERT_PATH`.
|
||||||
|
|
||||||
|
### Custom OID Registry
|
||||||
|
|
||||||
|
Federation certificates include custom OIDs in the certificate extension. These encode authorization metadata:
|
||||||
|
|
||||||
|
| OID | Name | Description |
|
||||||
|
| ------------------- | ---------------------- | --------------------- |
|
||||||
|
| 1.3.6.1.4.1.99999.1 | mosaic_grant_id | Federation grant UUID |
|
||||||
|
| 1.3.6.1.4.1.99999.2 | mosaic_subject_user_id | Subject user UUID |
|
||||||
|
|
||||||
|
These OIDs are verified by the gateway after the CSR is signed, ensuring the certificate was issued with the correct grant and user context.
|
||||||
|
|
||||||
|
### Environment Variables
|
||||||
|
|
||||||
|
Configure the gateway with the following environment variables before startup:
|
||||||
|
|
||||||
|
| Variable | Required | Description |
|
||||||
|
| ------------------------------ | -------- | --------------------------------------------------------------------------------------------------------- |
|
||||||
|
| `STEP_CA_URL` | Yes | Base URL of the step-ca instance, e.g. `https://step-ca:9000` (use `https://localhost:9000` in local dev) |
|
||||||
|
| `STEP_CA_PROVISIONER_KEY_JSON` | Yes | JSON-encoded JWK from `/home/step/secrets/mosaic-fed.json` |
|
||||||
|
| `STEP_CA_ROOT_CERT_PATH` | Yes | Absolute path to the root CA certificate (e.g. `/tmp/step-ca-root.crt`) |
|
||||||
|
| `BETTER_AUTH_SECRET` | Yes | Secret used to seal peer private keys at rest; already required for M1 |
|
||||||
|
|
||||||
|
Example environment setup:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export STEP_CA_URL="https://localhost:9000"
|
||||||
|
export STEP_CA_PROVISIONER_KEY_JSON="$(cat /tmp/step-ca-provisioner.json)"
|
||||||
|
export STEP_CA_ROOT_CERT_PATH="/tmp/step-ca-root.crt"
|
||||||
|
export BETTER_AUTH_SECRET="<your-secret>"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Port conflicts
|
||||||
|
|
||||||
|
**Symptom:** `bind: address already in use`
|
||||||
|
|
||||||
|
**Fix:** Stop the base dev stack first:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose down
|
||||||
|
docker compose -f docker-compose.federated.yml --profile federated up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
Or change the host port with an environment variable:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
PG_FEDERATED_HOST_PORT=5434 VALKEY_FEDERATED_HOST_PORT=6381 \
|
||||||
|
docker compose -f docker-compose.federated.yml --profile federated up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
### pgvector extension error
|
||||||
|
|
||||||
|
**Symptom:** `ERROR: could not open extension control file`
|
||||||
|
|
||||||
|
**Fix:** pgvector is created at first boot. Check logs:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f docker-compose.federated.yml logs postgres-federated | grep -i vector
|
||||||
|
```
|
||||||
|
|
||||||
|
If missing, exec into the container and create it manually:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker exec <postgres-federated-id> psql -U mosaic -d mosaic -c "CREATE EXTENSION vector;"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Valkey connection refused
|
||||||
|
|
||||||
|
**Symptom:** `Error: connect ECONNREFUSED 127.0.0.1:6380`
|
||||||
|
|
||||||
|
**Fix:** Check service health:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f docker-compose.federated.yml logs valkey-federated
|
||||||
|
```
|
||||||
|
|
||||||
|
If Valkey is running, verify your firewall allows 6380. On macOS, Docker Desktop may require binding to `host.docker.internal` instead of `localhost`.
|
||||||
|
|
||||||
|
## Key rotation (deferred)
|
||||||
|
|
||||||
|
Federation peer private keys (`federation_peers.client_key_pem`) are sealed at rest using AES-256-GCM with a key derived from `BETTER_AUTH_SECRET` via SHA-256. If `BETTER_AUTH_SECRET` is rotated, all sealed `client_key_pem` values in the database become unreadable and must be re-sealed with the new key before rotation completes.
|
||||||
|
|
||||||
|
The full key rotation procedure (decrypt all rows with old key, re-encrypt with new key, atomically swap the secret) is out of scope for M2. Operators must not rotate `BETTER_AUTH_SECRET` without a migration plan for all sealed federation peer keys.
|
||||||
|
|
||||||
|
## OID Assignments — Mosaic Internal OID Arc
|
||||||
|
|
||||||
|
Mosaic uses the private enterprise arc `1.3.6.1.4.1.99999` for custom X.509
|
||||||
|
certificate extensions in federation grant certificates.
|
||||||
|
|
||||||
|
**IMPORTANT:** This is a development/internal OID arc. Before deploying to a
|
||||||
|
production environment accessible by external parties, register a proper IANA
|
||||||
|
Private Enterprise Number (PEN) at <https://pen.iana.org/pen/PenApplication.page>
|
||||||
|
and update these assignments accordingly.
|
||||||
|
|
||||||
|
### Assigned OIDs
|
||||||
|
|
||||||
|
| OID | Symbolic name | Description |
|
||||||
|
| --------------------- | --------------------------------- | --------------------------------------------------------- |
|
||||||
|
| `1.3.6.1.4.1.99999.1` | `mosaic.federation.grantId` | UUID of the `federation_grants` row authorising this cert |
|
||||||
|
| `1.3.6.1.4.1.99999.2` | `mosaic.federation.subjectUserId` | UUID of the local user on whose behalf the cert is issued |
|
||||||
|
|
||||||
|
### Encoding
|
||||||
|
|
||||||
|
Each extension value is DER-encoded as an ASN.1 **UTF8String**:
|
||||||
|
|
||||||
|
```
|
||||||
|
Tag 0x0C (UTF8String)
|
||||||
|
Length 0x24 (36 decimal — fixed length of a UUID string)
|
||||||
|
Value <36 ASCII bytes of the UUID>
|
||||||
|
```
|
||||||
|
|
||||||
|
The step-ca X.509 template at `infra/step-ca/templates/federation.tpl`
|
||||||
|
produces this encoding via the Go template expression:
|
||||||
|
|
||||||
|
```
|
||||||
|
{{ printf "\x0c\x24%s" .Token.mosaic_grant_id | b64enc }}
|
||||||
|
```
|
||||||
|
|
||||||
|
The resulting base64 value is passed as the `value` field of the extension
|
||||||
|
object in the template JSON.
|
||||||
|
|
||||||
|
### CA Environment Variables
|
||||||
|
|
||||||
|
The `CaService` (`apps/gateway/src/federation/ca.service.ts`) requires the
|
||||||
|
following environment variables at gateway startup:
|
||||||
|
|
||||||
|
| Variable | Required | Description |
|
||||||
|
| ------------------------------ | -------- | -------------------------------------------------------------------- |
|
||||||
|
| `STEP_CA_URL` | Yes | Base URL of the step-ca instance, e.g. `https://step-ca:9000` |
|
||||||
|
| `STEP_CA_PROVISIONER_PASSWORD` | Yes | JWK provisioner password for the `mosaic-fed` provisioner |
|
||||||
|
| `STEP_CA_PROVISIONER_KEY_JSON` | Yes | JSON-encoded JWK (public + private) for the `mosaic-fed` provisioner |
|
||||||
|
| `STEP_CA_ROOT_CERT_PATH` | Yes | Absolute path to the step-ca root CA certificate PEM file |
|
||||||
|
|
||||||
|
Set these variables in your environment or secret manager before starting
|
||||||
|
the gateway. In the federated Docker Compose stack they are expected to be
|
||||||
|
injected via Docker secrets and environment variable overrides.
|
||||||
|
|
||||||
|
### Fail-loud contract
|
||||||
|
|
||||||
|
The CA service (and the X.509 template) are designed to fail loudly if the
|
||||||
|
custom OIDs cannot be embedded:
|
||||||
|
|
||||||
|
- The template produces a malformed extension value (zero-length UTF8String
|
||||||
|
body) when the JWT claims `mosaic_grant_id` or `mosaic_subject_user_id` are
|
||||||
|
absent. step-ca rejects the CSR rather than issuing a cert without the OIDs.
|
||||||
|
- `CaService.issueCert()` throws a `CaServiceError` on every error path with
|
||||||
|
a human-readable `remediation` string. It never silently returns a cert that
|
||||||
|
may be missing the required extensions.
|
||||||
@@ -16,19 +16,19 @@
|
|||||||
Goal: Gateway runs in `federated` tier with containerized PG+pgvector+Valkey. No federation logic yet. Existing standalone behavior does not regress.
|
Goal: Gateway runs in `federated` tier with containerized PG+pgvector+Valkey. No federation logic yet. Existing standalone behavior does not regress.
|
||||||
|
|
||||||
| id | status | description | issue | agent | branch | depends_on | estimate | notes |
|
| id | status | description | issue | agent | branch | depends_on | estimate | notes |
|
||||||
| --------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----- | ------ | ------------------------------- | ---------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------- |
|
| --------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----- | ------ | ---------------------------------- | ---------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||||
| FED-M1-01 | done | Extend `mosaic.config.json` schema: add `"federated"` to `tier` enum in validator + TS types. Keep `local` and `standalone` working. Update schema docs/README where referenced. | #460 | sonnet | feat/federation-m1-tier-config | — | 4K | Shipped in PR #470. Renamed `team` → `standalone`; added `team` deprecation alias; added `DEFAULT_FEDERATED_CONFIG`. |
|
| FED-M1-01 | done | Extend `mosaic.config.json` schema: add `"federated"` to `tier` enum in validator + TS types. Keep `local` and `standalone` working. Update schema docs/README where referenced. | #460 | sonnet | feat/federation-m1-tier-config | — | 4K | Shipped in PR #470. Renamed `team` → `standalone`; added `team` deprecation alias; added `DEFAULT_FEDERATED_CONFIG`. |
|
||||||
| FED-M1-02 | done | Author `docker-compose.federated.yml` as an overlay profile: Postgres 17 + pgvector extension (port 5433), Valkey (6380), named volumes, healthchecks. Compose-up should boot cleanly on a clean machine. | #460 | sonnet | feat/federation-m1-compose | FED-M1-01 | 5K | Shipped in PR #471. Overlay defines `postgres-federated`/`valkey-federated`, profile-gated, with pg-init for pgvector extension. |
|
| FED-M1-02 | done | Author `docker-compose.federated.yml` as an overlay profile: Postgres 17 + pgvector extension (port 5433), Valkey (6380), named volumes, healthchecks. Compose-up should boot cleanly on a clean machine. | #460 | sonnet | feat/federation-m1-compose | FED-M1-01 | 5K | Shipped in PR #471. Overlay defines `postgres-federated`/`valkey-federated`, profile-gated, with pg-init for pgvector extension. |
|
||||||
| FED-M1-03 | done | Add pgvector support to `packages/storage/src/adapters/postgres.ts`: create extension on init (idempotent), expose vector column type in schema helpers. No adapter changes for non-federated tiers. | #460 | sonnet | feat/federation-m1-pgvector | FED-M1-02 | 8K | Shipped in PR #472. `enableVector` flag on postgres StorageConfig; idempotent CREATE EXTENSION before migrations. |
|
| FED-M1-03 | done | Add pgvector support to `packages/storage/src/adapters/postgres.ts`: create extension on init (idempotent), expose vector column type in schema helpers. No adapter changes for non-federated tiers. | #460 | sonnet | feat/federation-m1-pgvector | FED-M1-02 | 8K | Shipped in PR #472. `enableVector` flag on postgres StorageConfig; idempotent CREATE EXTENSION before migrations. |
|
||||||
| FED-M1-04 | done | Implement `apps/gateway/src/bootstrap/tier-detector.ts`: reads config, asserts PG/Valkey/pgvector reachable for `federated`, fail-fast with actionable error message on failure. Unit tests for each failure mode. | #460 | sonnet | feat/federation-m1-detector | FED-M1-03 | 8K | Shipped in PR #473. 12 tests; 5s timeouts on probes; pgvector library/permission discrimination; rejects non-bullmq for federated. |
|
| FED-M1-04 | done | Implement `apps/gateway/src/bootstrap/tier-detector.ts`: reads config, asserts PG/Valkey/pgvector reachable for `federated`, fail-fast with actionable error message on failure. Unit tests for each failure mode. | #460 | sonnet | feat/federation-m1-detector | FED-M1-03 | 8K | Shipped in PR #473. 12 tests; 5s timeouts on probes; pgvector library/permission discrimination; rejects non-bullmq for federated. |
|
||||||
| FED-M1-05 | done | Write `scripts/migrate-to-federated.ts`: one-way migration from `local` (PGlite) / `standalone` (PG without pgvector) → `federated`. Dumps, transforms, loads; dry-run + confirm UX. Idempotent on re-run. | #460 | sonnet | feat/federation-m1-migrate | FED-M1-04 | 10K | Shipped in PR #474. `mosaic storage migrate-tier`; DrizzleMigrationSource (corrects P0 found in review); 32 tests; idempotent. |
|
| FED-M1-05 | done | Write `scripts/migrate-to-federated.ts`: one-way migration from `local` (PGlite) / `standalone` (PG without pgvector) → `federated`. Dumps, transforms, loads; dry-run + confirm UX. Idempotent on re-run. | #460 | sonnet | feat/federation-m1-migrate | FED-M1-04 | 10K | Shipped in PR #474. `mosaic storage migrate-tier`; DrizzleMigrationSource (corrects P0 found in review); 32 tests; idempotent. |
|
||||||
| FED-M1-06 | in-progress | Update `mosaic doctor`: report current tier, required services, actual health per service, pgvector presence, overall green/yellow/red. Machine-readable JSON output flag for CI use. | #460 | sonnet | feat/federation-m1-doctor | FED-M1-04 | 6K | Existing doctor output evolves; add `--json` flag. Green/yellow/red + remediation suggestions per issue. |
|
| FED-M1-06 | done | Update `mosaic doctor`: report current tier, required services, actual health per service, pgvector presence, overall green/yellow/red. Machine-readable JSON output flag for CI use. | #460 | sonnet | feat/federation-m1-doctor | FED-M1-04 | 6K | Shipped in PR #475 as `mosaic gateway doctor`. Probes lifted to @mosaicstack/storage; structural TierConfig breaks dep cycle. |
|
||||||
| FED-M1-07 | not-started | Integration test: gateway boots in `federated` tier with docker-compose `federated` profile; refuses to boot when PG unreachable (asserts fail-fast); pgvector extension query succeeds. | #460 | sonnet | feat/federation-m1-integration | FED-M1-04 | 8K | Vitest + docker-compose test profile. One test file per assertion; real services, no mocks. |
|
| FED-M1-07 | done | Integration test: gateway boots in `federated` tier with docker-compose `federated` profile; refuses to boot when PG unreachable (asserts fail-fast); pgvector extension query succeeds. | #460 | sonnet | feat/federation-m1-integration | FED-M1-04 | 8K | Shipped in PR #476. 3 test files, 4 tests, gated by FEDERATED_INTEGRATION=1; reserved-port helper avoids host collisions. |
|
||||||
| FED-M1-08 | not-started | Integration test for migration script: seed a local PGlite with representative data (tasks, notes, users, teams), run migration, assert row counts + key samples equal on federated PG. | #460 | sonnet | feat/federation-m1-migrate-test | FED-M1-05 | 6K | Runs against docker-compose federated profile; uses temp PGlite file; deterministic seed. |
|
| FED-M1-08 | done | Integration test for migration script: seed a local PGlite with representative data (tasks, notes, users, teams), run migration, assert row counts + key samples equal on federated PG. | #460 | sonnet | feat/federation-m1-migrate-test | FED-M1-05 | 6K | Shipped in PR #477. Caught P0 in M1-05 (camelCase→snake_case) missed by mocked unit tests; fix in same PR. |
|
||||||
| FED-M1-09 | not-started | Standalone regression: full agent-session E2E on existing `standalone` tier with a gateway built from this branch. Must pass without referencing any federation module. | #460 | haiku | feat/federation-m1-regression | FED-M1-07 | 4K | Reuse existing e2e harness; just re-point at the federation branch build. Canary that we didn't break it. |
|
| FED-M1-09 | done | Standalone regression: full agent-session E2E on existing `standalone` tier with a gateway built from this branch. Must pass without referencing any federation module. | #460 | sonnet | feat/federation-m1-regression | FED-M1-07 | 4K | Clean canary. 351 gateway tests + 85 storage unit tests + full pnpm test all green; only FEDERATED_INTEGRATION-gated tests skip. |
|
||||||
| FED-M1-10 | not-started | Code review pass: security-focused on the migration script (data-at-rest during migration) + tier detector (error-message sensitivity leakage). Independent reviewer, not authors of tasks 01-09. | #460 | sonnet | — | FED-M1-09 | 8K | Use `feature-dev:code-reviewer` agent. Specifically: no secrets in error messages; no partial-migration footguns. |
|
| FED-M1-10 | done | Code review pass: security-focused on the migration script (data-at-rest during migration) + tier detector (error-message sensitivity leakage). Independent reviewer, not authors of tasks 01-09. | #460 | sonnet | feat/federation-m1-security-review | FED-M1-09 | 8K | 2 review rounds caught 7 issues: credential leak in pg/valkey/pgvector errors + redact-error util; missing advisory lock; SKIP_TABLES rationale. |
|
||||||
| FED-M1-11 | not-started | Docs update: `docs/federation/` operator notes for tier setup; README blurb on federated tier; `docs/guides/` entry for migration. Do NOT touch runbook yet (deferred to FED-M7). | #460 | haiku | feat/federation-m1-docs | FED-M1-10 | 4K | Short, actionable. Link from MISSION-MANIFEST. No decisions captured here — those belong in PRD. |
|
| FED-M1-11 | done | Docs update: `docs/federation/` operator notes for tier setup; README blurb on federated tier; `docs/guides/` entry for migration. Do NOT touch runbook yet (deferred to FED-M7). | #460 | haiku | feat/federation-m1-docs | FED-M1-10 | 4K | Shipped: `docs/federation/SETUP.md` (119 lines), `docs/guides/migrate-tier.md` (147 lines), README Configuration blurb. |
|
||||||
| FED-M1-12 | not-started | PR, CI green, merge to main, close #460. | #460 | — | (aggregate) | FED-M1-11 | 3K | Queue-guard before push; wait for green; merge squashed; tea `issue-close` #460. |
|
| FED-M1-12 | done | PR, CI green, merge to main, close #460. | #460 | sonnet | feat/federation-m1-close | FED-M1-11 | 3K | M1 closed. PRs #470-#480 merged across 11 tasks. Issue #460 closed; release tag `fed-v0.1.0-m1` published. |
|
||||||
|
|
||||||
**M1 total estimate:** ~74K tokens (over-budget vs 20K PRD estimate — explanation below)
|
**M1 total estimate:** ~74K tokens (over-budget vs 20K PRD estimate — explanation below)
|
||||||
|
|
||||||
@@ -36,9 +36,52 @@ Goal: Gateway runs in `federated` tier with containerized PG+pgvector+Valkey. No
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Pre-M2 — Test deployment infrastructure (FED-M2-DEPLOY)
|
||||||
|
|
||||||
|
Goal: Two federated-tier gateways stood up on Portainer at `mos-test-1.woltje.com` and `mos-test-2.woltje.com` running the M1 release (`gateway:fed-v0.1.0-m1`). This is the test bed for M2 enrollment work and the M3 federation E2E harness. No federation logic exercised yet — pure infrastructure validation.
|
||||||
|
|
||||||
|
> **Why now:** M2 enrollment requires a real second gateway to test peer-add flows; standing the test hosts up before M2 code lands gives both code and deployment streams a fast feedback loop.
|
||||||
|
|
||||||
|
> **Parallelizable:** This workstream runs in parallel with the M2 code workstream (M2-01 → M2-13). They re-converge at M2-10 (E2E test).
|
||||||
|
|
||||||
|
> **Tracking issue:** #482.
|
||||||
|
|
||||||
|
| id | status | description | issue | agent | branch | depends_on | estimate | notes |
|
||||||
|
| --------------------- | ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----- | ------ | ------------------------------------- | ------------ | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| FED-M2-DEPLOY-01 | done | Verify `gateway:fed-v0.1.0-m1` image was published by `.woodpecker/publish.yml` on tag push; if not, investigate and remediate. Document image URI in deployment artifact. | #482 | sonnet | (verified inline, no PR) | — | 2K | Tag exists; digest `sha256:9b72e202a9eecc27d31920b87b475b9e96e483c0323acc57856be4b1355db1ec` captured for digest-pinned deploys. |
|
||||||
|
| FED-M2-DEPLOY-02 | done | Author Portainer git-stack compose file `deploy/portainer/federated-test.stack.yml` (gateway + PG-pgvector + Valkey, env-driven). Use immutable tag, not `latest`. | #482 | sonnet | feat/federation-deploy-stack-template | DEPLOY-01 | 5K | Shipped in PR #485. Digest-pinned. Env: STACK_NAME, HOST_FQDN, POSTGRES_PASSWORD, BETTER_AUTH_SECRET, BETTER_AUTH_URL. |
|
||||||
|
| FED-M2-DEPLOY-IMG-FIX | in-progress | Gateway image runtime broken (ERR_MODULE_NOT_FOUND for `dotenv`); Dockerfile copies `.pnpm/` store but not `apps/gateway/node_modules` symlinks. Switch to `pnpm deploy` for self-contained runtime. | #482 | sonnet | (subagent in flight) | DEPLOY-02 | 4K | Subagent `a78a9ab0ddae91fbc` in flight. Triggers Kaniko rebuild on merge; capture new digest; bump stack template in follow-up PR before redeploy. |
|
||||||
|
| FED-M2-DEPLOY-03 | blocked | Deploy stack to mos-test-1.woltje.com via `~/.config/mosaic/tools/portainer/`. Verify M1 acceptance: federated-tier boot succeeds; `mosaic gateway doctor --json` returns green; pgvector `vector(3)` round-trip works. | #482 | sonnet | feat/federation-deploy-test-1 | IMG-FIX | 3K | Stack created on Portainer endpoint 3 (Swarm `local`), but blocked on image fix. Container fails on boot until IMG-FIX merges + redeploy. |
|
||||||
|
| FED-M2-DEPLOY-04 | blocked | Deploy stack to mos-test-2.woltje.com via Portainer wrapper. Same M1 acceptance probes as DEPLOY-03. | #482 | sonnet | feat/federation-deploy-test-2 | IMG-FIX | 3K | Same status as DEPLOY-03. Stack created; blocked on image fix. |
|
||||||
|
| FED-M2-DEPLOY-05 | not-started | Document deployment in `docs/federation/TEST-INFRA.md`: hosts, image tags, secrets sourcing, redeploy procedure, teardown. Update MISSION-MANIFEST with deployment status. | #482 | haiku | feat/federation-deploy-docs | DEPLOY-03,04 | 3K | Operator-facing doc; mentions but does not duplicate `tools/portainer/README.md`. |
|
||||||
|
|
||||||
|
**Deploy workstream estimate:** ~16K tokens
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Milestone 2 — Step-CA + grant schema + admin CLI (FED-M2)
|
## Milestone 2 — Step-CA + grant schema + admin CLI (FED-M2)
|
||||||
|
|
||||||
_Deferred to mission planning when M1 is complete. Issue #461 tracks scope._
|
Goal: An admin can create a federation grant; counterparty enrolls; cert is signed by Step-CA with SAN OIDs for `grantId` + `subjectUserId`. No runtime federation traffic flows yet (that's M3).
|
||||||
|
|
||||||
|
| id | status | description | issue | agent | branch | depends_on | estimate | notes |
|
||||||
|
| --------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----- | ------ | ---------------------------------- | ---------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| FED-M2-01 | needs-qa | DB migration: `federation_grants`, `federation_peers`, `federation_audit_log` tables + enum types (`grant_status`, `peer_state`). Drizzle schema + migration generation; migration tests. | #461 | sonnet | feat/federation-m2-schema | — | 5K | PR #486 open. First review NEEDS CHANGES (missing DESC indexes + reserved cols). Remediation subagent `a673dd9355dc26f82` in flight in worktree `agent-a4404ac1`. |
|
||||||
|
| FED-M2-02 | not-started | Add Step-CA sidecar to `docker-compose.federated.yml`: official `smallstep/step-ca` image, persistent CA volume, JWK provisioner config baked into init script. | #461 | sonnet | feat/federation-m2-stepca | DEPLOY-02 | 4K | Profile-gated under `federated`. CA password from secret; dev compose uses dev-only password file. |
|
||||||
|
| FED-M2-03 | not-started | Scope JSON schema + validator: `resources` allowlist, `excluded_resources`, `include_teams`, `include_personal`, `max_rows_per_query`. Vitest unit tests for valid + invalid scopes. | #461 | sonnet | feat/federation-m2-scope-schema | — | 4K | Validator independent of CA — reusable from grant CRUD + (later) M3 scope enforcement. |
|
||||||
|
| FED-M2-04 | not-started | `apps/gateway/src/federation/ca.service.ts`: Step-CA client (CSR submission, OID-bearing cert retrieval). Mocked + integration tests against real Step-CA container. | #461 | sonnet | feat/federation-m2-ca-service | M2-02 | 6K | SAN OIDs: `grantId` (custom OID 1.3.6.1.4.1.99999.1) + `subjectUserId` (1.3.6.1.4.1.99999.2). Document OID assignments in PRD/SETUP. **Acceptance**: must (a) wire `federation.tpl` template into `mosaic-fed` provisioner config and (b) include a unit/integration test asserting issued certs contain BOTH OIDs — fails-loud guard against silent OID stripping (carry-forward from M2-02 review). |
|
||||||
|
| FED-M2-05 | not-started | Sealed storage for `client_key_pem` reusing existing `provider_credentials` sealing key. Tests prove DB-at-rest is ciphertext, not PEM. Key rotation path documented (deferred impl). | #461 | sonnet | feat/federation-m2-key-sealing | M2-01 | 5K | Separate from M2-06 to keep crypto seam isolated; reviewer focus is sealing only. |
|
||||||
|
| FED-M2-06 | not-started | `grants.service.ts`: CRUD + status transitions (`pending` → `active` → `revoked`); integrates M2-03 (scope) + M2-05 (sealing). Unit tests cover all transitions including invalid ones. | #461 | sonnet | feat/federation-m2-grants-service | M2-03, M2-05 | 6K | Business logic only — CSR + cert work delegated to M2-04. Revocation handler is M6. |
|
||||||
|
| FED-M2-07 | not-started | `enrollment.controller.ts`: short-lived single-use token endpoint; CSR signing; updates grant `pending` → `active`; emits enrollment audit (table-only write, M4 tightens). | #461 | sonnet | feat/federation-m2-enrollment | M2-04, M2-06 | 6K | Tokens single-use with 410 on replay; tokens TTL'd at 15min; rate-limited at request layer (M4 introduces guard, M2 uses simple lock). |
|
||||||
|
| FED-M2-08 | not-started | Admin CLI: `mosaic federation grant create/list/show` + `peer add/list`. Integration with grants.service (no API duplication). Help output + machine-readable JSON option. | #461 | sonnet | feat/federation-m2-cli | M2-06, M2-07 | 7K | `peer add <enrollment-url>` is the client-side flow; resolves enrollment URL → CSR → store sealed key + cert. |
|
||||||
|
| FED-M2-09 | not-started | Integration tests covering MILESTONES.md M2 acceptance tests #1, #2, #3, #5, #7, #8 (single-gateway suite). Real Step-CA container; vitest profile gated by `FEDERATED_INTEGRATION=1`. | #461 | sonnet | feat/federation-m2-integration | M2-08 | 8K | Tests #4 (cert OID match) + #6 (two-gateway peer-add) handled separately by M2-10 (E2E). |
|
||||||
|
| FED-M2-10 | not-started | E2E test against deployed mos-test-1 + mos-test-2 (or local two-gateway docker-compose if Portainer not ready): MILESTONES test #6 `peer add` yields `active` peer record with valid cert + key. | #461 | sonnet | feat/federation-m2-e2e | M2-08, DEPLOY-04 | 6K | Falls back to local docker-compose-two-gateways if remote test hosts not yet available. Documents both paths. |
|
||||||
|
| FED-M2-11 | not-started | Independent security review (sonnet, not author of M2-04/05/06/07): focus on single-use token replay, sealing leak surfaces, OID match enforcement, scope schema bypass paths. | #461 | sonnet | feat/federation-m2-security-review | M2-10 | 8K | Apply M1 two-round pattern. Reviewer should explicitly attempt enrollment-token replay, OID-spoofing CSR, and key leak in error messages. |
|
||||||
|
| FED-M2-12 | not-started | Docs update: `docs/federation/SETUP.md` Step-CA section; new `docs/federation/ADMIN-CLI.md` with grant/peer commands; scope schema reference; OID registration note. Runbook still M7-deferred. | #461 | haiku | feat/federation-m2-docs | M2-11 | 4K | Adds CA bootstrap section to SETUP.md with `docker compose --profile federated up step-ca` example. |
|
||||||
|
| FED-M2-13 | not-started | PR aggregate close, CI green, merge to main, close #461. Release tag `fed-v0.2.0-m2`. Mark deploy stream complete. Update mission manifest M2 row. | #461 | sonnet | feat/federation-m2-close | M2-12 | 3K | Same close pattern as M1-12; queue-guard before merge; tea release-create with notes including deploy-stream PRs. |
|
||||||
|
|
||||||
|
**M2 code workstream estimate:** ~72K tokens (vs MILESTONES.md 30K — same over-budget pattern as M1, where per-task breakdown including tests/review/docs catches the real cost).
|
||||||
|
|
||||||
|
**Deploy + code combined:** ~88K tokens.
|
||||||
|
|
||||||
## Milestone 3 — mTLS handshake + list/get + scope enforcement (FED-M3)
|
## Milestone 3 — mTLS handshake + list/get + scope enforcement (FED-M3)
|
||||||
|
|
||||||
|
|||||||
147
docs/guides/migrate-tier.md
Normal file
147
docs/guides/migrate-tier.md
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
# Migrating to the Federated Tier
|
||||||
|
|
||||||
|
Step-by-step guide to migrate from `local` (PGlite) or `standalone` (PostgreSQL without pgvector) to `federated` (PostgreSQL 17 + pgvector + Valkey).
|
||||||
|
|
||||||
|
## When to migrate
|
||||||
|
|
||||||
|
Migrate to federated tier when:
|
||||||
|
|
||||||
|
- Scaling from single-user to multi-user deployments
|
||||||
|
- Adding vector embeddings or RAG features
|
||||||
|
- Running Mosaic across multiple hosts
|
||||||
|
- Requires distributed task queueing and caching
|
||||||
|
- Moving to production with high availability
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- Federated stack running and healthy (see [Federated Tier Setup](../federation/SETUP.md))
|
||||||
|
- Source database accessible and empty target database at the federated URL
|
||||||
|
- Backup of source database (recommended before any migration)
|
||||||
|
|
||||||
|
## Dry-run first
|
||||||
|
|
||||||
|
Always run a dry-run to validate the migration:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mosaic storage migrate-tier --to federated \
|
||||||
|
--target-url postgresql://mosaic:mosaic@localhost:5433/mosaic \
|
||||||
|
--dry-run
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected output (partial example):
|
||||||
|
|
||||||
|
```
|
||||||
|
[migrate-tier] Analyzing source tier: pglite
|
||||||
|
[migrate-tier] Analyzing target tier: federated
|
||||||
|
[migrate-tier] Precondition: target is empty ✓
|
||||||
|
users: 5 rows
|
||||||
|
teams: 2 rows
|
||||||
|
conversations: 12 rows
|
||||||
|
messages: 187 rows
|
||||||
|
... (all tables listed)
|
||||||
|
[migrate-tier] NOTE: Source tier has no pgvector support. insights.embedding will be NULL on all migrated rows.
|
||||||
|
[migrate-tier] DRY-RUN COMPLETE (no data written). 206 total rows would be migrated.
|
||||||
|
```
|
||||||
|
|
||||||
|
Review the output. If it shows an error (e.g., target not empty), address it before proceeding.
|
||||||
|
|
||||||
|
## Run the migration
|
||||||
|
|
||||||
|
When ready, run without `--dry-run`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mosaic storage migrate-tier --to federated \
|
||||||
|
--target-url postgresql://mosaic:mosaic@localhost:5433/mosaic \
|
||||||
|
--yes
|
||||||
|
```
|
||||||
|
|
||||||
|
The `--yes` flag skips the confirmation prompt (required in non-TTY environments like CI).
|
||||||
|
|
||||||
|
The command will:
|
||||||
|
|
||||||
|
1. Acquire an advisory lock (blocks concurrent invocations)
|
||||||
|
2. Copy data from source to target in dependency order
|
||||||
|
3. Report rows migrated per table
|
||||||
|
4. Display any warnings (e.g., null vector embeddings)
|
||||||
|
|
||||||
|
## What gets migrated
|
||||||
|
|
||||||
|
All persistent, user-bound data is migrated in dependency order:
|
||||||
|
|
||||||
|
- **users, teams, team_members** — user and team ownership
|
||||||
|
- **accounts** — OAuth provider tokens (durable credentials)
|
||||||
|
- **projects, agents, missions, tasks** — all project and agent definitions
|
||||||
|
- **conversations, messages** — all chat history
|
||||||
|
- **preferences, insights, agent_logs** — preferences and observability
|
||||||
|
- **provider_credentials** — stored API keys and secrets
|
||||||
|
- **tickets, events, skills, routing_rules, appreciations** — auxiliary records
|
||||||
|
|
||||||
|
Full order is defined in code (`MIGRATION_ORDER` in `packages/storage/src/migrate-tier.ts`).
|
||||||
|
|
||||||
|
## What gets skipped and why
|
||||||
|
|
||||||
|
Three tables are intentionally not migrated:
|
||||||
|
|
||||||
|
| Table | Reason |
|
||||||
|
| ----------------- | ----------------------------------------------------------------------------------------------- |
|
||||||
|
| **sessions** | TTL'd auth sessions from the old environment; they will fail JWT verification on the new target |
|
||||||
|
| **verifications** | One-time tokens (email verify, password reset) that have either expired or been consumed |
|
||||||
|
| **admin_tokens** | Hashed tokens bound to the old environment's secret keys; must be re-issued |
|
||||||
|
|
||||||
|
**Note on accounts and provider_credentials:** These durable credentials ARE migrated because they are user-bound and required for resuming agent work on the target environment. After migration to a multi-tenant federated deployment, operators may want to audit or wipe these if users are untrusted or credentials should not be shared.
|
||||||
|
|
||||||
|
## Idempotency and concurrency
|
||||||
|
|
||||||
|
The migration is **idempotent**:
|
||||||
|
|
||||||
|
- Re-running is safe (uses `ON CONFLICT DO UPDATE` internally)
|
||||||
|
- Ideal for retries on transient failures
|
||||||
|
- Concurrent invocations are blocked by a Postgres advisory lock; the second caller will wait
|
||||||
|
|
||||||
|
If a previous run is stuck, check for advisory locks:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SELECT * FROM pg_locks WHERE locktype='advisory';
|
||||||
|
```
|
||||||
|
|
||||||
|
If you need to force-unlock (dangerous):
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SELECT pg_advisory_unlock(<lock_id>);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Verify the migration
|
||||||
|
|
||||||
|
After migration completes, spot-check the target:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Count rows on a few critical tables
|
||||||
|
psql postgresql://mosaic:mosaic@localhost:5433/mosaic -c \
|
||||||
|
"SELECT 'users' as table, COUNT(*) FROM users UNION ALL
|
||||||
|
SELECT 'conversations' as table, COUNT(*) FROM conversations UNION ALL
|
||||||
|
SELECT 'messages' as table, COUNT(*) FROM messages;"
|
||||||
|
```
|
||||||
|
|
||||||
|
Verify a known user or project exists by ID:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
psql postgresql://mosaic:mosaic@localhost:5433/mosaic -c \
|
||||||
|
"SELECT id, email FROM users WHERE email='<your-email>';"
|
||||||
|
```
|
||||||
|
|
||||||
|
Ensure vector embeddings are NULL (if source was PGlite) or populated (if source was postgres + pgvector):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
psql postgresql://mosaic:mosaic@localhost:5433/mosaic -c \
|
||||||
|
"SELECT embedding IS NOT NULL as has_vector FROM insights LIMIT 5;"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Rollback
|
||||||
|
|
||||||
|
There is no in-place rollback. If the migration fails:
|
||||||
|
|
||||||
|
1. Restore the target database from a pre-migration backup
|
||||||
|
2. Investigate the failure logs
|
||||||
|
3. Rerun the migration
|
||||||
|
|
||||||
|
Always test migrations in a staging environment first.
|
||||||
@@ -379,3 +379,236 @@ Initial verifier (haiku) on the first delivery returned "OK to ship" but missed
|
|||||||
- #8: confirm `packages/config/dist` not git-tracked.
|
- #8: confirm `packages/config/dist` not git-tracked.
|
||||||
|
|
||||||
**Next:** PR for FED-M1-04 → CI wait → merge. Then FED-M1-05 (migration script, codex/sonnet, 10K).
|
**Next:** PR for FED-M1-04 → CI wait → merge. Then FED-M1-05 (migration script, codex/sonnet, 10K).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Session 18 — 2026-04-19 — FED-M1-07 + FED-M1-08
|
||||||
|
|
||||||
|
**Branches landed this run:** `feat/federation-m1-integration` (PR #476, FED-M1-07), `feat/federation-m1-migrate-test` (PR #477, FED-M1-08)
|
||||||
|
**Branch active at end:** none — both PRs merged to main, branches deleted
|
||||||
|
|
||||||
|
**M1 progress:** 8 of 12 tasks done. Remaining: M1-09 (regression e2e, haiku), M1-10 (security review, sonnet), M1-11 (docs, haiku), M1-12 (close + release, orchestrator).
|
||||||
|
|
||||||
|
### FED-M1-07 — Integration tests for federated tier gateway boot
|
||||||
|
|
||||||
|
Three test files under `apps/gateway/src/__tests__/integration/` gated by `FEDERATED_INTEGRATION=1`:
|
||||||
|
|
||||||
|
- `federated-boot.success.integration.test.ts` — `detectAndAssertTier` resolves; `pg_extension` row for `vector` exists
|
||||||
|
- `federated-boot.pg-unreachable.integration.test.ts` — throws `TierDetectionError` with `service: 'postgres'` when PG port is closed
|
||||||
|
- `federated-pgvector.integration.test.ts` — TEMP table with `vector(3)` column round-trips data
|
||||||
|
|
||||||
|
Independent code review (sonnet) returned VERDICT: B with two IMPORTANT items, both fixed in the same PR:
|
||||||
|
|
||||||
|
- Port 5499 collision risk → replaced with `net.createServer().listen(0)` reserved-port helper
|
||||||
|
- `afterAll` and `sql` scoped outside `describe` → moved both inside `describe.skipIf` block
|
||||||
|
|
||||||
|
Independent surface verifier (haiku) confirmed all claims. 4/4 tests pass live; 4/4 skip cleanly without env var.
|
||||||
|
|
||||||
|
### FED-M1-08 — Migration integration test (caught real P0 bug)
|
||||||
|
|
||||||
|
`packages/storage/src/migrate-tier.integration.test.ts` seeds temp PGlite with cross-table data (users, teams, team_members, conversations, messages), runs `runMigrateTier`, asserts row counts + spot-checks. Gated by `FEDERATED_INTEGRATION=1`.
|
||||||
|
|
||||||
|
**P0 bug surfaced and fixed in same PR:** `DrizzleMigrationSource.readTable()` returns Drizzle's camelCase keys (`emailVerified`, `userId`); `PostgresMigrationTarget.upsertBatch()` was using them verbatim as SQL identifiers, producing `column "emailVerified" does not exist` against real federated PG. The 32 unit tests in M1-05 missed this because both source and target were mocked. Fix: `normaliseSourceRow` now applies `toSnakeCase` (`/[A-Z]/g` → `_<lowercase>`), idempotent on already-snake_case keys.
|
||||||
|
|
||||||
|
Code review (sonnet) returned VERDICT: B with one IMPORTANT and one MINOR, both fixed:
|
||||||
|
|
||||||
|
- `createPgliteDbWithVector` and `runPgliteMigrations` were initially added to `@mosaicstack/db` public exports → moved to `packages/storage/src/test-utils/pglite-with-vector.ts` (avoids polluting prod consumers with WASM bundle)
|
||||||
|
- `afterAll` did not call `cleanTarget` → added before connection close, ensuring orphan rows cleaned even on test panic
|
||||||
|
|
||||||
|
Side change: `packages/storage/package.json` gained `"type": "module"` (codebase convention; required for `import.meta.url` in test-utils). All other workspace packages already declared this.
|
||||||
|
|
||||||
|
### Process notes for this session
|
||||||
|
|
||||||
|
- Review-then-verify pipeline now battle-tested: M1-08 reviewer caught the P0 bug + the public-API leak that the worker would have shipped. Without review, both would have gone to main.
|
||||||
|
- Integration tests are paying for themselves immediately: M1-08 caught a real P0 in M1-05 that 32 mocked unit tests missed. Going forward, **at least one real-services integration test per code-mutating PR** should become a soft norm where feasible.
|
||||||
|
- TASKS.md status updates continue to ride on the matching feature branch (avoids direct-to-main commits).
|
||||||
|
|
||||||
|
**Followup tasks tracked but still deferred (no change):**
|
||||||
|
|
||||||
|
- #7: `tier=local` hardcoded in gateway-config resume branches (~262, ~317)
|
||||||
|
- #8: confirm `packages/config/dist` not git-tracked
|
||||||
|
|
||||||
|
**Next:** FED-M1-09 — standalone regression e2e (haiku canary, ~4K). Verifies that the existing `standalone` tier behavior still works end-to-end on the federation-touched build, since M1 changes touched shared paths (storage, config, gateway boot).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Session 19 — 2026-04-19 — FED-M1-09 → FED-M1-12 (M1 close)
|
||||||
|
|
||||||
|
**Branches landed this run:** `feat/federation-m1-regression` (PR #478, M1-09), `feat/federation-m1-security-review` (PR #479, M1-10), `feat/federation-m1-docs` (PR #480, M1-11), `feat/federation-m1-close` (PR #481, M1-12)
|
||||||
|
**Branch active at end:** none — M1 closed, all branches deleted, issue #460 closed, release tag `fed-v0.1.0-m1` published
|
||||||
|
|
||||||
|
**M1 progress:** 12 of 12 tasks done. **Milestone complete.**
|
||||||
|
|
||||||
|
### FED-M1-09 — Standalone regression canary
|
||||||
|
|
||||||
|
Verification-only milestone. Re-ran the existing standalone/local test suites against current `main` (with M1-01 → M1-08 merged):
|
||||||
|
|
||||||
|
- 4 target gateway test files: 148/148 pass (conversation-persistence, cross-user-isolation, resource-ownership, session-hardening)
|
||||||
|
- Full gateway suite: 351 pass, 4 skipped (FEDERATED_INTEGRATION-gated only)
|
||||||
|
- Storage unit tests: 85 pass, 1 skipped (integration-gated)
|
||||||
|
- Top-level `pnpm test`: all green; only env-gated skips
|
||||||
|
|
||||||
|
No regression in standalone or local tier. Federation M1 changes are non-disruptive.
|
||||||
|
|
||||||
|
### FED-M1-10 — Security review (two rounds, 7 findings)
|
||||||
|
|
||||||
|
Independent security review surfaced three high-impact and four medium findings; all fixed in same PR.
|
||||||
|
|
||||||
|
**Round 1 (4 findings):**
|
||||||
|
|
||||||
|
- MEDIUM: Credential leak via `postgres`/`ioredis` driver error messages (DSN strings) re-thrown by `migrate-tier.ts` → caller; `cli.ts:402` outer catch
|
||||||
|
- MEDIUM: Same leak in `tier-detection.ts` `probePostgresMeasured` / `probePgvectorMeasured` → emitted as JSON by `mosaic gateway doctor --json`
|
||||||
|
- LOW-MEDIUM: No advisory lock on `migrate-tier`; two concurrent invocations could both pass `checkTargetPreconditions` (non-atomic) and race
|
||||||
|
- ADVISORY: `SKIP_TABLES` lacked rationale comment
|
||||||
|
|
||||||
|
**Fixes:**
|
||||||
|
|
||||||
|
- New internal helper `packages/storage/src/redact-error.ts` — regex `(postgres(?:ql)?|rediss?):\/\/[^@\s]*@` → `<scheme>://***@`. NOT exported from package public surface. 10 unit tests covering all schemes, multi-URL, no-creds, case-insensitive.
|
||||||
|
- `redactErrMsg` applied at all 5 leak sites
|
||||||
|
- `PostgresMigrationTarget.tryAcquireAdvisoryLock()` / `releaseAdvisoryLock()` using session-scoped `pg_try_advisory_lock(hashtext('mosaic-migrate-tier'))`. Acquired before preflight, released in `finally`. Dry-run skips. Non-blocking.
|
||||||
|
- `SKIP_TABLES` comment expanded with rationale for skipped tables (TTL'd / one-time / env-bound) AND why `accounts` (OAuth) and `provider_credentials` (AI keys) are intentionally migrated (durable user-bound, not deployment-bound).
|
||||||
|
|
||||||
|
**Round 2 (3 findings missed by first round):**
|
||||||
|
|
||||||
|
- HIGH: Round 1 regex only covered `postgres` scheme, not `redis`/`rediss` — extended to `(postgres(?:ql)?|rediss?)`
|
||||||
|
- HIGH: `probeValkeyMeasured` was missed in Round 1 → applied `redactErrMsg`
|
||||||
|
- MEDIUM: `cli.ts:402` migrate-tier outer catch was missed in Round 1 → applied `redactErrMsg`
|
||||||
|
|
||||||
|
**Process validation:** the two-round review pattern proved load-bearing for security work. A single review-then-fix cycle would have shipped the Valkey credential leak.
|
||||||
|
|
||||||
|
### FED-M1-11 — Docs (haiku)
|
||||||
|
|
||||||
|
- `docs/federation/SETUP.md` (119 lines): federated tier setup — what it is, prerequisites, docker compose start, mosaic.config.json snippet, doctor health check, troubleshooting
|
||||||
|
- `docs/guides/migrate-tier.md` (147 lines): when to migrate, dry-run first, what migrates/skips with rationale, idempotency + advisory-lock semantics, no in-place rollback
|
||||||
|
- `README.md` Configuration blurb linking to both
|
||||||
|
- Runbook deferred to FED-M7 per TASKS.md scope rule
|
||||||
|
|
||||||
|
### FED-M1-12 — Aggregate close (this PR)
|
||||||
|
|
||||||
|
- Marked M1-12 done in TASKS.md
|
||||||
|
- MISSION-MANIFEST.md: phase → "M1 complete", progress 1/7, M1 row done with PR range #470-#481, session log appended
|
||||||
|
- This Session 19 entry added
|
||||||
|
- Issue #460 closed via `~/.config/mosaic/tools/git/issue-close.sh -i 460`
|
||||||
|
- Release tag `fed-v0.1.0-m1` created and pushed to gitea
|
||||||
|
|
||||||
|
### M1 PR ledger
|
||||||
|
|
||||||
|
| PR | Task | Branch |
|
||||||
|
| ---- | ----------------------------------------- | ---------------------------------- |
|
||||||
|
| #470 | M1-01 (tier config schema) | feat/federation-m1-tier-config |
|
||||||
|
| #471 | M1-02 (compose overlay) | feat/federation-m1-compose |
|
||||||
|
| #472 | M1-03 (pgvector adapter) | feat/federation-m1-pgvector |
|
||||||
|
| #473 | M1-04 (tier-detector) | feat/federation-m1-detector |
|
||||||
|
| #474 | M1-05 (migrate-tier script) | feat/federation-m1-migrate |
|
||||||
|
| #475 | M1-06 (gateway doctor) | feat/federation-m1-doctor |
|
||||||
|
| #476 | M1-07 (boot integration tests) | feat/federation-m1-integration |
|
||||||
|
| #477 | M1-08 (migrate integration test + P0 fix) | feat/federation-m1-migrate-test |
|
||||||
|
| #478 | M1-09 (standalone regression) | feat/federation-m1-regression |
|
||||||
|
| #479 | M1-10 (security review fixes) | feat/federation-m1-security-review |
|
||||||
|
| #480 | M1-11 (docs) | feat/federation-m1-docs |
|
||||||
|
| #481 | M1-12 (aggregate close) | feat/federation-m1-close |
|
||||||
|
|
||||||
|
### Process learnings (M1 retrospective)
|
||||||
|
|
||||||
|
1. **Two-round security review is non-negotiable for security work.** First round caught postgres credential leaks; second round caught equivalent valkey leaks the worker missed when extending the regex. Single-round would have shipped HIGH severity issues.
|
||||||
|
2. **Real-services integration tests catch what mocked unit tests cannot.** M1-08 caught a P0 in M1-05 (camelCase column names) that 32 mocked unit tests missed because both source and target were mocked. Going forward: at least one real-services test per code-mutating PR where feasible.
|
||||||
|
3. **Test-utils for live services co-locate with consumer, not in shared library.** M1-08 reviewer caught `createPgliteDbWithVector` initially being added to `@mosaicstack/db` public exports — would have polluted prod consumers with WASM bundle. Moved to `packages/storage/src/test-utils/`.
|
||||||
|
4. **Per-task budgets including tests/review/docs more accurate than PRD's implementation-only estimates.** M1 PRD estimated 20K; actual ~74K. Future milestones should budget the full delivery cycle.
|
||||||
|
5. **TASKS.md status updates ride feature branches, never direct-to-main.** Caught one violation early in M1; pattern held for all 12 tasks.
|
||||||
|
6. **Subagent tier matters.** Code review needs sonnet-level reasoning (haiku missed deep issues in M1-04); claim verification (line counts, file existence) is fine on haiku.
|
||||||
|
|
||||||
|
**Followup tasks still deferred (carry forward to M2):**
|
||||||
|
|
||||||
|
- #7: `tier=local` hardcoded in gateway-config resume branches (~262, ~317)
|
||||||
|
- #8: confirm `packages/config/dist` not git-tracked
|
||||||
|
|
||||||
|
**Next mission step:** FED-M2 (Step-CA + grant schema + admin CLI). Per TASKS.md scope rule, M2 will be decomposed when it enters active planning. Issue #461 tracks scope.
|
||||||
|
|
||||||
|
## Session 20 — 2026-04-21 — FED-M2 kickoff
|
||||||
|
|
||||||
|
### Decisions
|
||||||
|
|
||||||
|
- **Workstream split**: parallel CODE (M2-01..M2-13, ~72K) + DEPLOY (DEPLOY-01..DEPLOY-05, ~16K) tracks; re-converge at M2-10 E2E.
|
||||||
|
- **Test hosts**: `mos-test-1.woltje.com` (querying side / Server A), `mos-test-2.woltje.com` (serving side / Server B). Wildcard `*.woltje.com` A→174.137.97.162 already exists; Traefik wildcard cert covers both subdomains. No DNS or cert work needed pre-deploy.
|
||||||
|
- **Portainer access**: requires `PORTAINER_INSECURE=1` flag added to mosaic wrappers (self-signed cert at `https://10.1.1.43:9443`). PR pending on `feat/mosaic-portainer-tls-flag`.
|
||||||
|
- **Image policy**: deploy by digest (immutable) per Mosaic policy. `gateway:fed-v0.1.0-m1` digest = `sha256:9b72e202a9eecc27d31920b87b475b9e96e483c0323acc57856be4b1355db1ec`.
|
||||||
|
|
||||||
|
### DEPLOY-01 — image manifest verified
|
||||||
|
|
||||||
|
- Tag `fed-v0.1.0-m1` exists at `git.mosaicstack.dev/mosaicstack/stack/gateway`
|
||||||
|
- Digest: `sha256:9b72e202a9eecc27d31920b87b475b9e96e483c0323acc57856be4b1355db1ec`
|
||||||
|
- 9 layers, ~530MB total
|
||||||
|
- Use this digest in DEPLOY-02 stack template (do NOT reference `:fed-v0.1.0-m1` tag in stack — pin to digest)
|
||||||
|
|
||||||
|
### Registry auth note
|
||||||
|
|
||||||
|
- Gitea container registry uses Bearer token flow (`/v2/token?service=container_registry&scope=repository:<repo>:pull`)
|
||||||
|
- Username: `jarvis` (NOT `mosaicstack`); password: `gitea.mosaicstack.token` from credentials.json
|
||||||
|
- Direct `Authorization: Bearer <pat>` does NOT work — must exchange PAT for registry token first
|
||||||
|
|
||||||
|
### Active PRs
|
||||||
|
|
||||||
|
- #483 — docs: M2 mission planning (TASKS decomposition + manifest update) — CI running
|
||||||
|
- (pending) `feat/mosaic-portainer-tls-flag` — wrapper PORTAINER_INSECURE flag (sonnet subagent in progress)
|
||||||
|
- (pending) `feat/federation-m2-schema` — FED-M2-01 DB schema migration (sonnet subagent in progress)
|
||||||
|
|
||||||
|
### MISSION-MANIFEST layout fix
|
||||||
|
|
||||||
|
- Initial M2 commit had Test Infrastructure block inserted by lint-staged prettier between "Last Updated" and "Parent Mission" — split mission frontmatter
|
||||||
|
- Fixed in 3d001fdb: moved Parent Mission back to frontmatter, kept Test Infrastructure as standalone H2 between Mission and Context
|
||||||
|
|
||||||
|
## Session 21 — 2026-04-21/22 — DEPLOY-02 merged, gateway image bug discovered, M2-01 in remediation
|
||||||
|
|
||||||
|
### PRs merged
|
||||||
|
|
||||||
|
- **#483** — docs(federation): M2 mission planning (TASKS decomposition + manifest update)
|
||||||
|
- **#484** — feat(mosaic-portainer): PORTAINER_INSECURE flag for self-signed TLS (wrapper sync to `~/.config/mosaic/tools/portainer/` done manually due to broken `mosaic upgrade` `set -o pipefail` on dash)
|
||||||
|
- **#485** — feat(deploy): portainer stack template `deploy/portainer/federated-test.stack.yml` for federation test instances [DEPLOY-02]
|
||||||
|
|
||||||
|
### Stack deployed (mos-test-1, mos-test-2)
|
||||||
|
|
||||||
|
- Both stacks created on Portainer endpoint 3 (`local` Swarm @ 10.1.1.43, the only endpoint with traefik-public + woltje.com wildcard cert)
|
||||||
|
- Swarm ID `l7z67tfpd4bvj4979ufpkyi50`
|
||||||
|
- Image pinned to digest `sha256:9b72e202a9eecc27d31920b87b475b9e96e483c0323acc57856be4b1355db1ec`
|
||||||
|
- Traefik labels target `${HOST_FQDN}` per env
|
||||||
|
|
||||||
|
### CRITICAL FINDING — gateway image runtime-broken
|
||||||
|
|
||||||
|
- `docker run` against `gateway:fed-v0.1.0-m1` fails immediately:
|
||||||
|
`Error [ERR_MODULE_NOT_FOUND]: Cannot find package 'dotenv' imported from /app/dist/main.js`
|
||||||
|
- Root cause: `docker/gateway.Dockerfile` copies `/app/node_modules` from builder — but pnpm puts deps in the content-addressed `.pnpm/` store with symlinks at `apps/gateway/node_modules/*`. The runner stage misses the symlinks → Node can't resolve workspace deps.
|
||||||
|
- M1 release was never runtime-tested as a stripped container; CI passed because tests run in dev tree where pnpm symlinks are intact.
|
||||||
|
- **Fix in flight** (subagent `a78a9ab0ddae91fbc`): switch builder to `pnpm --filter @mosaic/gateway --prod deploy /deploy`, then runner copies `/deploy/node_modules` + `/deploy/dist` + `/deploy/package.json`.
|
||||||
|
|
||||||
|
### M2-01 schema review verdict — NEEDS CHANGES
|
||||||
|
|
||||||
|
- PR #486 (`feat/federation-m2-schema`) — independent reviewer (sonnet) found 2 real issues:
|
||||||
|
1. `federation_audit_log` time-range indexes missing `.desc()` on `created_at` (3 places)
|
||||||
|
2. Reserved columns missing per TASKS.md M2-01 spec: `query_hash`, `outcome`, `bytes_out` (M4 will write; spec said reserve now)
|
||||||
|
- Also notes (advisory): subject_user_id correctly `text` (matches BetterAuth users.id; spec defect, not code defect); peer→grant cascade test not present (would be trivial to add)
|
||||||
|
- **Remediation in flight** (subagent `a673dd9355dc26f82` in worktree `agent-a4404ac1`): apply DESC + reserved cols, regenerate migration in place (preferred) or stack 0009 (fallback), force-push, post PR comment.
|
||||||
|
|
||||||
|
### Process notes
|
||||||
|
|
||||||
|
- Branch race incident: schema subagent + wrapper subagent both ran in main checkout → schema files appeared on wrapper branch. Recovered by TaskStop, `git checkout --` to clean, respawned schema subagent with `isolation: "worktree"`. **Rule going forward:** any subagent doing code edits gets `isolation: "worktree"` unless work is single-file and the orchestrator confirms no other branch will touch overlapping files.
|
||||||
|
- `pr-create.sh` shell-quotes backticks badly → use `tea pr create --repo mosaicstack/stack` directly (matches CLI-skill behavior). Will leave a followup to harden pr-create.sh.
|
||||||
|
- Gitea registry auth: bearer-token exchange flow (`/v2/token?service=container_registry&scope=repository:<repo>:pull`) — direct `Authorization: Bearer <pat>` returns 401.
|
||||||
|
- Portainer Swarm stack create endpoint: `POST /api/stacks/create/swarm/string?endpointId=<id>` (NOT `/api/stacks?type=1` — deprecated and rejected with 400).
|
||||||
|
|
||||||
|
### In-flight at compaction boundary
|
||||||
|
|
||||||
|
- Subagent `a78a9ab0ddae91fbc` — Dockerfile pnpm-deploy fix → PR (not yet opened at handoff)
|
||||||
|
- Subagent `a673dd9355dc26f82` — M2-01 schema remediation (DESC + reserved cols) → force-push to PR #486
|
||||||
|
- Both will trigger CI; orchestrator must independently re-review fixes (especially the security-adjacent schema work) per "always verify subagent claims" rule.
|
||||||
|
|
||||||
|
### Next after subagents return
|
||||||
|
|
||||||
|
1. Independent re-review of schema remediation (different subagent, fresh context)
|
||||||
|
2. Merge #486 if green
|
||||||
|
3. Merge Dockerfile fix PR if green → triggers Kaniko CI rebuild → capture new digest
|
||||||
|
4. Update `deploy/portainer/federated-test.stack.yml` to new digest in a small PR
|
||||||
|
5. Redeploy mos-test-1 + mos-test-2 (Portainer stack update via API)
|
||||||
|
6. Verify HTTPS reachability + `/health` endpoint at both hosts
|
||||||
|
7. DEPLOY-03/04 acceptance probes (`mosaic gateway doctor --json`, pgvector `vector(3)` round-trip)
|
||||||
|
8. DEPLOY-05: author `docs/federation/TEST-INFRA.md`
|
||||||
|
9. M2-02 (Step-CA sidecar) kicks off after image health is green
|
||||||
|
|||||||
1
infra/step-ca/dev-password.example
Normal file
1
infra/step-ca/dev-password.example
Normal file
@@ -0,0 +1 @@
|
|||||||
|
dev-only-step-ca-password-do-not-use-in-production
|
||||||
90
infra/step-ca/init.sh
Executable file
90
infra/step-ca/init.sh
Executable file
@@ -0,0 +1,90 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# infra/step-ca/init.sh
|
||||||
|
#
|
||||||
|
# Idempotent first-boot initialiser for the Mosaic Federation CA.
|
||||||
|
#
|
||||||
|
# On the first run (no /home/step/config/ca.json present) this script:
|
||||||
|
# 1. Initialises Step-CA with a JWK provisioner named "mosaic-fed".
|
||||||
|
# 2. Writes the CA configuration to the persistent volume at /home/step.
|
||||||
|
# 3. Copies the federation X.509 template into the CA config directory.
|
||||||
|
# 4. Patches the mosaic-fed provisioner entry in ca.json to reference the
|
||||||
|
# template via options.x509.templateFile (using jq — must be installed
|
||||||
|
# in the container image).
|
||||||
|
#
|
||||||
|
# On subsequent runs (config already exists) this script skips init and
|
||||||
|
# starts the CA directly.
|
||||||
|
#
|
||||||
|
# The provisioner name "mosaic-fed" is consumed by:
|
||||||
|
# apps/gateway/src/federation/ca.service.ts (added in M2-04)
|
||||||
|
#
|
||||||
|
# Password source:
|
||||||
|
# Dev: mounted from ./infra/step-ca/dev-password via bind mount.
|
||||||
|
# Prod: mounted from a Docker secret at /run/secrets/ca_password.
|
||||||
|
#
|
||||||
|
# OID template:
|
||||||
|
# infra/step-ca/templates/federation.tpl emits custom OID extensions:
|
||||||
|
# 1.3.6.1.4.1.99999.1 — mosaic_grant_id
|
||||||
|
# 1.3.6.1.4.1.99999.2 — mosaic_subject_user_id
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
CA_CONFIG="/home/step/config/ca.json"
|
||||||
|
PASSWORD_FILE="/run/secrets/ca_password"
|
||||||
|
TEMPLATE_SRC="/etc/step-ca-templates/federation.tpl"
|
||||||
|
TEMPLATE_DEST="/home/step/templates/federation.tpl"
|
||||||
|
|
||||||
|
if [ ! -f "${CA_CONFIG}" ]; then
|
||||||
|
echo "[step-ca init] First boot detected — initialising Mosaic Federation CA..."
|
||||||
|
|
||||||
|
step ca init \
|
||||||
|
--name "Mosaic Federation CA" \
|
||||||
|
--dns "localhost" \
|
||||||
|
--dns "step-ca" \
|
||||||
|
--address ":9000" \
|
||||||
|
--provisioner "mosaic-fed" \
|
||||||
|
--password-file "${PASSWORD_FILE}" \
|
||||||
|
--provisioner-password-file "${PASSWORD_FILE}" \
|
||||||
|
--no-db
|
||||||
|
|
||||||
|
echo "[step-ca init] CA initialised."
|
||||||
|
|
||||||
|
# Copy the X.509 template into the Step-CA config directory.
|
||||||
|
if [ -f "${TEMPLATE_SRC}" ]; then
|
||||||
|
mkdir -p /home/step/templates
|
||||||
|
cp "${TEMPLATE_SRC}" "${TEMPLATE_DEST}"
|
||||||
|
echo "[step-ca init] Federation X.509 template copied to ${TEMPLATE_DEST}."
|
||||||
|
else
|
||||||
|
echo "[step-ca init] WARNING: Template source ${TEMPLATE_SRC} not found — skipping copy."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Wire the template into the mosaic-fed provisioner via jq.
|
||||||
|
# This is idempotent: the block only runs once (first boot).
|
||||||
|
#
|
||||||
|
# jq filter: find the provisioner entry with name "mosaic-fed" and set
|
||||||
|
# .options.x509.templateFile to the absolute path of the template.
|
||||||
|
# All other provisioners and config keys are left unchanged.
|
||||||
|
if [ -f "${TEMPLATE_DEST}" ] && command -v jq > /dev/null 2>&1; then
|
||||||
|
echo "[step-ca init] Patching mosaic-fed provisioner with X.509 template..."
|
||||||
|
TEMP_CONFIG="${CA_CONFIG}.tmp"
|
||||||
|
jq --arg tpl "${TEMPLATE_DEST}" '
|
||||||
|
.authority.provisioners |= map(
|
||||||
|
if .name == "mosaic-fed" then
|
||||||
|
.options.x509.templateFile = $tpl
|
||||||
|
else
|
||||||
|
.
|
||||||
|
end
|
||||||
|
)
|
||||||
|
' "${CA_CONFIG}" > "${TEMP_CONFIG}" && mv "${TEMP_CONFIG}" "${CA_CONFIG}"
|
||||||
|
echo "[step-ca init] Provisioner patched."
|
||||||
|
elif ! command -v jq > /dev/null 2>&1; then
|
||||||
|
echo "[step-ca init] WARNING: jq not found — skipping provisioner template patch."
|
||||||
|
echo "[step-ca init] Install jq in the step-ca image to enable automatic template wiring."
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "[step-ca init] Startup complete."
|
||||||
|
else
|
||||||
|
echo "[step-ca init] Config already exists — skipping init."
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "[step-ca init] Starting Step-CA on :9000..."
|
||||||
|
exec step-ca /home/step/config/ca.json --password-file "${PASSWORD_FILE}"
|
||||||
56
infra/step-ca/templates/federation.tpl
Normal file
56
infra/step-ca/templates/federation.tpl
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
{
|
||||||
|
"subject": {{ toJson .Subject }},
|
||||||
|
"sans": {{ toJson .SANs }},
|
||||||
|
|
||||||
|
{{- /*
|
||||||
|
Mosaic Federation X.509 Certificate Template
|
||||||
|
============================================
|
||||||
|
Provisioner: mosaic-fed (JWK)
|
||||||
|
Implemented: FED-M2-04
|
||||||
|
|
||||||
|
This template emits two custom OID extensions carrying Mosaic federation
|
||||||
|
identifiers. The OTT token (built by CaService.buildOtt) MUST include the
|
||||||
|
claims `mosaic_grant_id` and `mosaic_subject_user_id` as top-level JWT
|
||||||
|
claims. step-ca exposes them under `.Token.<claim>` in this template.
|
||||||
|
|
||||||
|
OID Registry (Mosaic Internal Arc — 1.3.6.1.4.1.99999):
|
||||||
|
1.3.6.1.4.1.99999.1 mosaic_grant_id (UUID, 36 ASCII chars)
|
||||||
|
1.3.6.1.4.1.99999.2 mosaic_subject_user_id (UUID, 36 ASCII chars)
|
||||||
|
|
||||||
|
DER encoding for each extension value (ASN.1 UTF8String):
|
||||||
|
Tag = 0x0C (UTF8String)
|
||||||
|
Length = 0x24 (decimal 36 — the fixed length of a UUID string)
|
||||||
|
Value = 36 ASCII bytes of the UUID
|
||||||
|
|
||||||
|
The `printf` below builds the raw TLV bytes then base64-encodes them.
|
||||||
|
step-ca expects the `value` field to be base64-encoded raw DER bytes.
|
||||||
|
|
||||||
|
Fail-loud contract:
|
||||||
|
If either claim is missing from the token the printf will produce a
|
||||||
|
zero-length UUID field, making the extension malformed. step-ca will
|
||||||
|
reject the certificate rather than issuing one without the required OIDs.
|
||||||
|
Silent OID stripping is NEVER tolerated.
|
||||||
|
|
||||||
|
Step-CA template reference:
|
||||||
|
https://smallstep.com/docs/step-ca/templates
|
||||||
|
*/ -}}
|
||||||
|
|
||||||
|
"extensions": [
|
||||||
|
{
|
||||||
|
"id": "1.3.6.1.4.1.99999.1",
|
||||||
|
"critical": false,
|
||||||
|
"value": "{{ printf "\x0c%c%s" (len .Token.mosaic_grant_id) .Token.mosaic_grant_id | b64enc }}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "1.3.6.1.4.1.99999.2",
|
||||||
|
"critical": false,
|
||||||
|
"value": "{{ printf "\x0c%c%s" (len .Token.mosaic_subject_user_id) .Token.mosaic_subject_user_id | b64enc }}"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
|
||||||
|
"keyUsage": ["digitalSignature"],
|
||||||
|
"extKeyUsage": ["clientAuth"],
|
||||||
|
"basicConstraints": {
|
||||||
|
"isCA": false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,3 +10,4 @@ export {
|
|||||||
type SsoTeamSyncConfig,
|
type SsoTeamSyncConfig,
|
||||||
type SupportedSsoProviderId,
|
type SupportedSsoProviderId,
|
||||||
} from './sso.js';
|
} from './sso.js';
|
||||||
|
export { seal, unseal } from './seal.js';
|
||||||
|
|||||||
52
packages/auth/src/seal.ts
Normal file
52
packages/auth/src/seal.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import { createCipheriv, createDecipheriv, createHash, randomBytes } from 'node:crypto';
|
||||||
|
|
||||||
|
const ALGORITHM = 'aes-256-gcm';
|
||||||
|
const IV_LENGTH = 12; // 96-bit IV for GCM
|
||||||
|
const TAG_LENGTH = 16; // 128-bit auth tag
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derive a 32-byte AES-256 key from BETTER_AUTH_SECRET using SHA-256.
|
||||||
|
* Throws if BETTER_AUTH_SECRET is not set.
|
||||||
|
*/
|
||||||
|
function deriveKey(): Buffer {
|
||||||
|
const secret = process.env['BETTER_AUTH_SECRET'];
|
||||||
|
if (!secret) {
|
||||||
|
throw new Error('BETTER_AUTH_SECRET is not set — cannot derive encryption key');
|
||||||
|
}
|
||||||
|
return createHash('sha256').update(secret).digest();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Seal a plaintext string using AES-256-GCM.
|
||||||
|
* Output format: base64(IV || authTag || ciphertext)
|
||||||
|
*/
|
||||||
|
export function seal(plaintext: string): string {
|
||||||
|
const key = deriveKey();
|
||||||
|
const iv = randomBytes(IV_LENGTH);
|
||||||
|
const cipher = createCipheriv(ALGORITHM, key, iv);
|
||||||
|
|
||||||
|
const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
|
||||||
|
const authTag = cipher.getAuthTag();
|
||||||
|
|
||||||
|
const combined = Buffer.concat([iv, authTag, encrypted]);
|
||||||
|
return combined.toString('base64');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unseal a value sealed by `seal()`.
|
||||||
|
* Throws on authentication failure (tampered data) or if BETTER_AUTH_SECRET is unset.
|
||||||
|
*/
|
||||||
|
export function unseal(encoded: string): string {
|
||||||
|
const key = deriveKey();
|
||||||
|
const combined = Buffer.from(encoded, 'base64');
|
||||||
|
|
||||||
|
const iv = combined.subarray(0, IV_LENGTH);
|
||||||
|
const authTag = combined.subarray(IV_LENGTH, IV_LENGTH + TAG_LENGTH);
|
||||||
|
const ciphertext = combined.subarray(IV_LENGTH + TAG_LENGTH);
|
||||||
|
|
||||||
|
const decipher = createDecipheriv(ALGORITHM, key, iv);
|
||||||
|
decipher.setAuthTag(authTag);
|
||||||
|
|
||||||
|
const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
||||||
|
return decrypted.toString('utf8');
|
||||||
|
}
|
||||||
75
packages/db/drizzle/0008_smart_lyja.sql
Normal file
75
packages/db/drizzle/0008_smart_lyja.sql
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
CREATE TYPE "public"."grant_status" AS ENUM('active', 'revoked', 'expired');--> statement-breakpoint
|
||||||
|
CREATE TYPE "public"."peer_state" AS ENUM('pending', 'active', 'suspended', 'revoked');--> statement-breakpoint
|
||||||
|
CREATE TABLE "admin_tokens" (
|
||||||
|
"id" text PRIMARY KEY NOT NULL,
|
||||||
|
"user_id" text NOT NULL,
|
||||||
|
"token_hash" text NOT NULL,
|
||||||
|
"label" text NOT NULL,
|
||||||
|
"scope" text DEFAULT 'admin' NOT NULL,
|
||||||
|
"expires_at" timestamp with time zone,
|
||||||
|
"last_used_at" timestamp with time zone,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "federation_audit_log" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"request_id" text NOT NULL,
|
||||||
|
"peer_id" uuid,
|
||||||
|
"subject_user_id" text,
|
||||||
|
"grant_id" uuid,
|
||||||
|
"verb" text NOT NULL,
|
||||||
|
"resource" text NOT NULL,
|
||||||
|
"status_code" integer NOT NULL,
|
||||||
|
"result_count" integer,
|
||||||
|
"denied_reason" text,
|
||||||
|
"latency_ms" integer,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"query_hash" text,
|
||||||
|
"outcome" text,
|
||||||
|
"bytes_out" integer
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "federation_grants" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"subject_user_id" text NOT NULL,
|
||||||
|
"peer_id" uuid NOT NULL,
|
||||||
|
"scope" jsonb NOT NULL,
|
||||||
|
"status" "grant_status" DEFAULT 'active' NOT NULL,
|
||||||
|
"expires_at" timestamp with time zone,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"revoked_at" timestamp with time zone,
|
||||||
|
"revoked_reason" text
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "federation_peers" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"common_name" text NOT NULL,
|
||||||
|
"display_name" text NOT NULL,
|
||||||
|
"cert_pem" text NOT NULL,
|
||||||
|
"cert_serial" text NOT NULL,
|
||||||
|
"cert_not_after" timestamp with time zone NOT NULL,
|
||||||
|
"client_key_pem" text,
|
||||||
|
"state" "peer_state" DEFAULT 'pending' NOT NULL,
|
||||||
|
"endpoint_url" text,
|
||||||
|
"last_seen_at" timestamp with time zone,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"revoked_at" timestamp with time zone,
|
||||||
|
CONSTRAINT "federation_peers_common_name_unique" UNIQUE("common_name"),
|
||||||
|
CONSTRAINT "federation_peers_cert_serial_unique" UNIQUE("cert_serial")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "admin_tokens" ADD CONSTRAINT "admin_tokens_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "federation_audit_log" ADD CONSTRAINT "federation_audit_log_peer_id_federation_peers_id_fk" FOREIGN KEY ("peer_id") REFERENCES "public"."federation_peers"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "federation_audit_log" ADD CONSTRAINT "federation_audit_log_subject_user_id_users_id_fk" FOREIGN KEY ("subject_user_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "federation_audit_log" ADD CONSTRAINT "federation_audit_log_grant_id_federation_grants_id_fk" FOREIGN KEY ("grant_id") REFERENCES "public"."federation_grants"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "federation_grants" ADD CONSTRAINT "federation_grants_subject_user_id_users_id_fk" FOREIGN KEY ("subject_user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "federation_grants" ADD CONSTRAINT "federation_grants_peer_id_federation_peers_id_fk" FOREIGN KEY ("peer_id") REFERENCES "public"."federation_peers"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
CREATE INDEX "admin_tokens_user_id_idx" ON "admin_tokens" USING btree ("user_id");--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "admin_tokens_hash_idx" ON "admin_tokens" USING btree ("token_hash");--> statement-breakpoint
|
||||||
|
CREATE INDEX "federation_audit_log_peer_created_at_idx" ON "federation_audit_log" USING btree ("peer_id","created_at" DESC NULLS LAST);--> statement-breakpoint
|
||||||
|
CREATE INDEX "federation_audit_log_subject_created_at_idx" ON "federation_audit_log" USING btree ("subject_user_id","created_at" DESC NULLS LAST);--> statement-breakpoint
|
||||||
|
CREATE INDEX "federation_audit_log_created_at_idx" ON "federation_audit_log" USING btree ("created_at" DESC NULLS LAST);--> statement-breakpoint
|
||||||
|
CREATE INDEX "federation_grants_subject_status_idx" ON "federation_grants" USING btree ("subject_user_id","status");--> statement-breakpoint
|
||||||
|
CREATE INDEX "federation_grants_peer_status_idx" ON "federation_grants" USING btree ("peer_id","status");--> statement-breakpoint
|
||||||
|
CREATE INDEX "federation_peers_cert_serial_idx" ON "federation_peers" USING btree ("cert_serial");--> statement-breakpoint
|
||||||
|
CREATE INDEX "federation_peers_state_idx" ON "federation_peers" USING btree ("state");
|
||||||
2
packages/db/drizzle/0009_federation_grant_pending.sql
Normal file
2
packages/db/drizzle/0009_federation_grant_pending.sql
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TYPE "public"."grant_status" ADD VALUE 'pending' BEFORE 'active';--> statement-breakpoint
|
||||||
|
ALTER TABLE "federation_grants" ALTER COLUMN "status" SET DEFAULT 'pending';
|
||||||
11
packages/db/drizzle/0010_federation_enrollment_tokens.sql
Normal file
11
packages/db/drizzle/0010_federation_enrollment_tokens.sql
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
CREATE TABLE "federation_enrollment_tokens" (
|
||||||
|
"token" text PRIMARY KEY NOT NULL,
|
||||||
|
"grant_id" uuid NOT NULL,
|
||||||
|
"peer_id" uuid NOT NULL,
|
||||||
|
"expires_at" timestamp with time zone NOT NULL,
|
||||||
|
"used_at" timestamp with time zone,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "federation_enrollment_tokens" ADD CONSTRAINT "federation_enrollment_tokens_grant_id_federation_grants_id_fk" FOREIGN KEY ("grant_id") REFERENCES "public"."federation_grants"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "federation_enrollment_tokens" ADD CONSTRAINT "federation_enrollment_tokens_peer_id_federation_peers_id_fk" FOREIGN KEY ("peer_id") REFERENCES "public"."federation_peers"("id") ON DELETE cascade ON UPDATE no action;
|
||||||
3375
packages/db/drizzle/meta/0008_snapshot.json
Normal file
3375
packages/db/drizzle/meta/0008_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
3376
packages/db/drizzle/meta/0009_snapshot.json
Normal file
3376
packages/db/drizzle/meta/0009_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
3453
packages/db/drizzle/meta/0010_snapshot.json
Normal file
3453
packages/db/drizzle/meta/0010_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -57,6 +57,27 @@
|
|||||||
"when": 1774227064500,
|
"when": 1774227064500,
|
||||||
"tag": "0006_swift_shen",
|
"tag": "0006_swift_shen",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 8,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1776822435828,
|
||||||
|
"tag": "0008_smart_lyja",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 9,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1745280000000,
|
||||||
|
"tag": "0009_federation_grant_pending",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 10,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1745366400000,
|
||||||
|
"tag": "0010_federation_enrollment_tokens",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
424
packages/db/src/federation.integration.test.ts
Normal file
424
packages/db/src/federation.integration.test.ts
Normal file
@@ -0,0 +1,424 @@
|
|||||||
|
/**
|
||||||
|
* FED-M2-01 — Integration test: federation DB schema (peers / grants / audit_log).
|
||||||
|
*
|
||||||
|
* Prereq: docker compose -f docker-compose.federated.yml --profile federated up -d
|
||||||
|
* (or any postgres with the mosaic schema already applied)
|
||||||
|
* Run: FEDERATED_INTEGRATION=1 pnpm --filter @mosaicstack/db test src/federation.integration.test.ts
|
||||||
|
*
|
||||||
|
* Skipped when FEDERATED_INTEGRATION !== '1'.
|
||||||
|
*
|
||||||
|
* Strategy:
|
||||||
|
* - Applies the federation migration SQL directly (idempotent: CREATE TYPE/TABLE
|
||||||
|
* with IF NOT EXISTS guards applied via inline SQL before the migration DDL).
|
||||||
|
* - Assumes the base schema (users table etc.) already exists in the target DB.
|
||||||
|
* - All test rows use the `fed-m2-01-` prefix; cleanup in afterAll.
|
||||||
|
*
|
||||||
|
* Coverage:
|
||||||
|
* 1. Federation tables + enums apply cleanly against the existing schema.
|
||||||
|
* 2. Insert a sample user + peer + grant + audit row; verify round-trip.
|
||||||
|
* 3. FK cascade: deleting the user cascades to federation_grants.
|
||||||
|
* 4. FK set-null: deleting the peer sets federation_audit_log.peer_id to NULL.
|
||||||
|
* 5. Enum constraint: inserting an invalid status/state value throws a DB error.
|
||||||
|
* 6. Unique constraint: duplicate cert_serial throws a DB error.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import postgres from 'postgres';
|
||||||
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
const run = process.env['FEDERATED_INTEGRATION'] === '1';
|
||||||
|
|
||||||
|
const PG_URL = process.env['DATABASE_URL'] ?? 'postgresql://mosaic:mosaic@localhost:5433/mosaic';
|
||||||
|
|
||||||
|
/** Recognisable test-row prefix for safe cleanup without full-table truncation. */
|
||||||
|
const T = 'fed-m2-01';
|
||||||
|
|
||||||
|
// Deterministic IDs (UUID format required for uuid PK columns: 8-4-4-4-12 hex digits).
|
||||||
|
const PEER1_ID = `f2000001-0000-4000-8000-000000000001`;
|
||||||
|
const PEER2_ID = `f2000002-0000-4000-8000-000000000002`;
|
||||||
|
const USER1_ID = `${T}-user-1`;
|
||||||
|
|
||||||
|
let sql: ReturnType<typeof postgres> | undefined;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
if (!run) return;
|
||||||
|
sql = postgres(PG_URL, { max: 1, connect_timeout: 10, idle_timeout: 10 });
|
||||||
|
|
||||||
|
// Apply the federation enums and tables idempotently.
|
||||||
|
// This mirrors the migration file but uses IF NOT EXISTS guards so it can run
|
||||||
|
// against a DB that may not have had drizzle migrations tracked.
|
||||||
|
await sql`
|
||||||
|
DO $$ BEGIN
|
||||||
|
CREATE TYPE peer_state AS ENUM ('pending', 'active', 'suspended', 'revoked');
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL;
|
||||||
|
END $$
|
||||||
|
`;
|
||||||
|
await sql`
|
||||||
|
DO $$ BEGIN
|
||||||
|
CREATE TYPE grant_status AS ENUM ('active', 'revoked', 'expired');
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL;
|
||||||
|
END $$
|
||||||
|
`;
|
||||||
|
await sql`
|
||||||
|
CREATE TABLE IF NOT EXISTS federation_peers (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
common_name text NOT NULL,
|
||||||
|
display_name text NOT NULL,
|
||||||
|
cert_pem text NOT NULL,
|
||||||
|
cert_serial text NOT NULL,
|
||||||
|
cert_not_after timestamp with time zone NOT NULL,
|
||||||
|
client_key_pem text,
|
||||||
|
state peer_state NOT NULL DEFAULT 'pending',
|
||||||
|
endpoint_url text,
|
||||||
|
last_seen_at timestamp with time zone,
|
||||||
|
created_at timestamp with time zone NOT NULL DEFAULT now(),
|
||||||
|
revoked_at timestamp with time zone,
|
||||||
|
CONSTRAINT federation_peers_common_name_unique UNIQUE (common_name),
|
||||||
|
CONSTRAINT federation_peers_cert_serial_unique UNIQUE (cert_serial)
|
||||||
|
)
|
||||||
|
`;
|
||||||
|
await sql`
|
||||||
|
CREATE INDEX IF NOT EXISTS federation_peers_cert_serial_idx ON federation_peers (cert_serial)
|
||||||
|
`;
|
||||||
|
await sql`
|
||||||
|
CREATE INDEX IF NOT EXISTS federation_peers_state_idx ON federation_peers (state)
|
||||||
|
`;
|
||||||
|
await sql`
|
||||||
|
CREATE TABLE IF NOT EXISTS federation_grants (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
subject_user_id text NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
peer_id uuid NOT NULL REFERENCES federation_peers(id) ON DELETE CASCADE,
|
||||||
|
scope jsonb NOT NULL,
|
||||||
|
status grant_status NOT NULL DEFAULT 'active',
|
||||||
|
expires_at timestamp with time zone,
|
||||||
|
created_at timestamp with time zone NOT NULL DEFAULT now(),
|
||||||
|
revoked_at timestamp with time zone,
|
||||||
|
revoked_reason text
|
||||||
|
)
|
||||||
|
`;
|
||||||
|
await sql`
|
||||||
|
CREATE INDEX IF NOT EXISTS federation_grants_subject_status_idx ON federation_grants (subject_user_id, status)
|
||||||
|
`;
|
||||||
|
await sql`
|
||||||
|
CREATE INDEX IF NOT EXISTS federation_grants_peer_status_idx ON federation_grants (peer_id, status)
|
||||||
|
`;
|
||||||
|
await sql`
|
||||||
|
CREATE TABLE IF NOT EXISTS federation_audit_log (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
request_id text NOT NULL,
|
||||||
|
peer_id uuid REFERENCES federation_peers(id) ON DELETE SET NULL,
|
||||||
|
subject_user_id text REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
grant_id uuid REFERENCES federation_grants(id) ON DELETE SET NULL,
|
||||||
|
verb text NOT NULL,
|
||||||
|
resource text NOT NULL,
|
||||||
|
status_code integer NOT NULL,
|
||||||
|
result_count integer,
|
||||||
|
denied_reason text,
|
||||||
|
latency_ms integer,
|
||||||
|
created_at timestamp with time zone NOT NULL DEFAULT now(),
|
||||||
|
query_hash text,
|
||||||
|
outcome text,
|
||||||
|
bytes_out integer
|
||||||
|
)
|
||||||
|
`;
|
||||||
|
await sql`
|
||||||
|
CREATE INDEX IF NOT EXISTS federation_audit_log_peer_created_at_idx
|
||||||
|
ON federation_audit_log (peer_id, created_at DESC NULLS LAST)
|
||||||
|
`;
|
||||||
|
await sql`
|
||||||
|
CREATE INDEX IF NOT EXISTS federation_audit_log_subject_created_at_idx
|
||||||
|
ON federation_audit_log (subject_user_id, created_at DESC NULLS LAST)
|
||||||
|
`;
|
||||||
|
await sql`
|
||||||
|
CREATE INDEX IF NOT EXISTS federation_audit_log_created_at_idx
|
||||||
|
ON federation_audit_log (created_at DESC NULLS LAST)
|
||||||
|
`;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
if (!sql) return;
|
||||||
|
|
||||||
|
// Cleanup in FK-safe order (children before parents).
|
||||||
|
await sql`DELETE FROM federation_audit_log WHERE request_id LIKE ${T + '%'}`.catch(() => {});
|
||||||
|
await sql`
|
||||||
|
DELETE FROM federation_grants
|
||||||
|
WHERE subject_user_id LIKE ${T + '%'}
|
||||||
|
OR revoked_reason LIKE ${T + '%'}
|
||||||
|
`.catch(() => {});
|
||||||
|
await sql`DELETE FROM federation_peers WHERE common_name LIKE ${T + '%'}`.catch(() => {});
|
||||||
|
await sql`DELETE FROM users WHERE id LIKE ${T + '%'}`.catch(() => {});
|
||||||
|
await sql.end({ timeout: 3 }).catch(() => {});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe.skipIf(!run)('federation schema — integration', () => {
|
||||||
|
// ── 1. Insert sample rows ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
it('inserts a user, peer, grant, and audit row without constraint violation', async () => {
|
||||||
|
const certPem = '-----BEGIN CERTIFICATE-----\nMIItest\n-----END CERTIFICATE-----';
|
||||||
|
|
||||||
|
// User — BetterAuth users.id is text (any string, not uuid).
|
||||||
|
await sql!`
|
||||||
|
INSERT INTO users (id, name, email, email_verified, created_at, updated_at)
|
||||||
|
VALUES (${USER1_ID}, ${'M2-01 Test User'}, ${USER1_ID + '@example.com'}, false, now(), now())
|
||||||
|
ON CONFLICT (id) DO NOTHING
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Peer
|
||||||
|
await sql!`
|
||||||
|
INSERT INTO federation_peers
|
||||||
|
(id, common_name, display_name, cert_pem, cert_serial, cert_not_after, state, created_at)
|
||||||
|
VALUES (
|
||||||
|
${PEER1_ID},
|
||||||
|
${T + '-gateway-example-com'},
|
||||||
|
${'Test Peer'},
|
||||||
|
${certPem},
|
||||||
|
${T + '-serial-001'},
|
||||||
|
now() + interval '1 year',
|
||||||
|
${'active'},
|
||||||
|
now()
|
||||||
|
)
|
||||||
|
ON CONFLICT (id) DO NOTHING
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Grant — scope is jsonb; pass as JSON string and cast server-side.
|
||||||
|
const scopeJson = JSON.stringify({
|
||||||
|
resources: ['tasks', 'notes'],
|
||||||
|
operations: ['list', 'get'],
|
||||||
|
});
|
||||||
|
const grants = await sql!`
|
||||||
|
INSERT INTO federation_grants
|
||||||
|
(subject_user_id, peer_id, scope, status, created_at)
|
||||||
|
VALUES (
|
||||||
|
${USER1_ID},
|
||||||
|
${PEER1_ID},
|
||||||
|
${scopeJson}::jsonb,
|
||||||
|
${'active'},
|
||||||
|
now()
|
||||||
|
)
|
||||||
|
RETURNING id
|
||||||
|
`;
|
||||||
|
expect(grants).toHaveLength(1);
|
||||||
|
const grantId = grants[0]!['id'] as string;
|
||||||
|
|
||||||
|
// Audit log row
|
||||||
|
await sql!`
|
||||||
|
INSERT INTO federation_audit_log
|
||||||
|
(request_id, peer_id, subject_user_id, grant_id, verb, resource, status_code, created_at)
|
||||||
|
VALUES (
|
||||||
|
${T + '-req-001'},
|
||||||
|
${PEER1_ID},
|
||||||
|
${USER1_ID},
|
||||||
|
${grantId},
|
||||||
|
${'list'},
|
||||||
|
${'tasks'},
|
||||||
|
${200},
|
||||||
|
now()
|
||||||
|
)
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Verify the audit row is present and has correct data.
|
||||||
|
const auditRows = await sql!`
|
||||||
|
SELECT * FROM federation_audit_log WHERE request_id = ${T + '-req-001'}
|
||||||
|
`;
|
||||||
|
expect(auditRows).toHaveLength(1);
|
||||||
|
expect(auditRows[0]!['status_code']).toBe(200);
|
||||||
|
expect(auditRows[0]!['verb']).toBe('list');
|
||||||
|
expect(auditRows[0]!['resource']).toBe('tasks');
|
||||||
|
}, 30_000);
|
||||||
|
|
||||||
|
// ── 2. FK cascade: user delete cascades grants ─────────────────────────────
|
||||||
|
|
||||||
|
it('cascade-deletes federation_grants when the subject user is deleted', async () => {
|
||||||
|
const cascadeUserId = `${T}-cascade-user`;
|
||||||
|
await sql!`
|
||||||
|
INSERT INTO users (id, name, email, email_verified, created_at, updated_at)
|
||||||
|
VALUES (${cascadeUserId}, ${'Cascade User'}, ${cascadeUserId + '@example.com'}, false, now(), now())
|
||||||
|
ON CONFLICT (id) DO NOTHING
|
||||||
|
`;
|
||||||
|
const scopeJson = JSON.stringify({ resources: ['tasks'] });
|
||||||
|
await sql!`
|
||||||
|
INSERT INTO federation_grants
|
||||||
|
(subject_user_id, peer_id, scope, status, revoked_reason, created_at)
|
||||||
|
VALUES (
|
||||||
|
${cascadeUserId},
|
||||||
|
${PEER1_ID},
|
||||||
|
${scopeJson}::jsonb,
|
||||||
|
${'active'},
|
||||||
|
${T + '-cascade-test'},
|
||||||
|
now()
|
||||||
|
)
|
||||||
|
`;
|
||||||
|
|
||||||
|
const before = await sql!`
|
||||||
|
SELECT count(*)::int AS cnt FROM federation_grants WHERE subject_user_id = ${cascadeUserId}
|
||||||
|
`;
|
||||||
|
expect(before[0]!['cnt']).toBe(1);
|
||||||
|
|
||||||
|
// Delete user → grants should cascade-delete.
|
||||||
|
await sql!`DELETE FROM users WHERE id = ${cascadeUserId}`;
|
||||||
|
|
||||||
|
const after = await sql!`
|
||||||
|
SELECT count(*)::int AS cnt FROM federation_grants WHERE subject_user_id = ${cascadeUserId}
|
||||||
|
`;
|
||||||
|
expect(after[0]!['cnt']).toBe(0);
|
||||||
|
}, 15_000);
|
||||||
|
|
||||||
|
// ── 3. FK set-null: peer delete sets audit_log.peer_id to NULL ────────────
|
||||||
|
|
||||||
|
it('sets federation_audit_log.peer_id to NULL when the peer is deleted', async () => {
|
||||||
|
// Insert a throwaway peer for this specific cascade test.
|
||||||
|
await sql!`
|
||||||
|
INSERT INTO federation_peers
|
||||||
|
(id, common_name, display_name, cert_pem, cert_serial, cert_not_after, state, created_at)
|
||||||
|
VALUES (
|
||||||
|
${PEER2_ID},
|
||||||
|
${T + '-gateway-throwaway-com'},
|
||||||
|
${'Throwaway Peer'},
|
||||||
|
${'cert-pem-placeholder'},
|
||||||
|
${T + '-serial-002'},
|
||||||
|
now() + interval '1 year',
|
||||||
|
${'active'},
|
||||||
|
now()
|
||||||
|
)
|
||||||
|
ON CONFLICT (id) DO NOTHING
|
||||||
|
`;
|
||||||
|
const reqId = `${T}-req-setnull`;
|
||||||
|
await sql!`
|
||||||
|
INSERT INTO federation_audit_log
|
||||||
|
(request_id, peer_id, subject_user_id, verb, resource, status_code, created_at)
|
||||||
|
VALUES (
|
||||||
|
${reqId},
|
||||||
|
${PEER2_ID},
|
||||||
|
${USER1_ID},
|
||||||
|
${'get'},
|
||||||
|
${'tasks'},
|
||||||
|
${200},
|
||||||
|
now()
|
||||||
|
)
|
||||||
|
`;
|
||||||
|
|
||||||
|
await sql!`DELETE FROM federation_peers WHERE id = ${PEER2_ID}`;
|
||||||
|
|
||||||
|
const rows = await sql!`
|
||||||
|
SELECT peer_id FROM federation_audit_log WHERE request_id = ${reqId}
|
||||||
|
`;
|
||||||
|
expect(rows).toHaveLength(1);
|
||||||
|
expect(rows[0]!['peer_id']).toBeNull();
|
||||||
|
}, 15_000);
|
||||||
|
|
||||||
|
// ── 4. Enum constraint: invalid grant_status rejected ─────────────────────
|
||||||
|
|
||||||
|
it('rejects an invalid grant_status value with a DB error', async () => {
|
||||||
|
const scopeJson = JSON.stringify({ resources: ['tasks'] });
|
||||||
|
await expect(
|
||||||
|
sql!`
|
||||||
|
INSERT INTO federation_grants
|
||||||
|
(subject_user_id, peer_id, scope, status, created_at)
|
||||||
|
VALUES (
|
||||||
|
${USER1_ID},
|
||||||
|
${PEER1_ID},
|
||||||
|
${scopeJson}::jsonb,
|
||||||
|
${'invalid_status'},
|
||||||
|
now()
|
||||||
|
)
|
||||||
|
`,
|
||||||
|
).rejects.toThrow();
|
||||||
|
}, 10_000);
|
||||||
|
|
||||||
|
// ── 5. Enum constraint: invalid peer_state rejected ───────────────────────
|
||||||
|
|
||||||
|
it('rejects an invalid peer_state value with a DB error', async () => {
|
||||||
|
await expect(
|
||||||
|
sql!`
|
||||||
|
INSERT INTO federation_peers
|
||||||
|
(common_name, display_name, cert_pem, cert_serial, cert_not_after, state, created_at)
|
||||||
|
VALUES (
|
||||||
|
${'bad-state-peer'},
|
||||||
|
${'Bad State'},
|
||||||
|
${'pem'},
|
||||||
|
${'bad-serial-999'},
|
||||||
|
now() + interval '1 year',
|
||||||
|
${'invalid_state'},
|
||||||
|
now()
|
||||||
|
)
|
||||||
|
`,
|
||||||
|
).rejects.toThrow();
|
||||||
|
}, 10_000);
|
||||||
|
|
||||||
|
// ── 6. Unique constraint: duplicate cert_serial rejected ──────────────────
|
||||||
|
|
||||||
|
it('rejects a duplicate cert_serial with a unique constraint violation', async () => {
|
||||||
|
await expect(
|
||||||
|
sql!`
|
||||||
|
INSERT INTO federation_peers
|
||||||
|
(common_name, display_name, cert_pem, cert_serial, cert_not_after, state, created_at)
|
||||||
|
VALUES (
|
||||||
|
${T + '-dup-cn'},
|
||||||
|
${'Dup Peer'},
|
||||||
|
${'pem'},
|
||||||
|
${T + '-serial-001'},
|
||||||
|
now() + interval '1 year',
|
||||||
|
${'pending'},
|
||||||
|
now()
|
||||||
|
)
|
||||||
|
`,
|
||||||
|
).rejects.toThrow();
|
||||||
|
}, 10_000);
|
||||||
|
|
||||||
|
// ── 7. FK cascade: peer delete cascades to federation_grants ─────────────
|
||||||
|
|
||||||
|
it('cascade-deletes federation_grants when the owning peer is deleted', async () => {
|
||||||
|
const PEER3_ID = `f2000003-0000-4000-8000-000000000003`;
|
||||||
|
const cascadeGrantUserId = `${T}-cascade-grant-user`;
|
||||||
|
|
||||||
|
// Insert a dedicated user and peer for this test.
|
||||||
|
await sql!`
|
||||||
|
INSERT INTO users (id, name, email, email_verified, created_at, updated_at)
|
||||||
|
VALUES (${cascadeGrantUserId}, ${'Cascade Grant User'}, ${cascadeGrantUserId + '@example.com'}, false, now(), now())
|
||||||
|
ON CONFLICT (id) DO NOTHING
|
||||||
|
`;
|
||||||
|
await sql!`
|
||||||
|
INSERT INTO federation_peers
|
||||||
|
(id, common_name, display_name, cert_pem, cert_serial, cert_not_after, state, created_at)
|
||||||
|
VALUES (
|
||||||
|
${PEER3_ID},
|
||||||
|
${T + '-gateway-cascade-peer'},
|
||||||
|
${'Cascade Peer'},
|
||||||
|
${'cert-pem-cascade'},
|
||||||
|
${T + '-serial-003'},
|
||||||
|
now() + interval '1 year',
|
||||||
|
${'active'},
|
||||||
|
now()
|
||||||
|
)
|
||||||
|
ON CONFLICT (id) DO NOTHING
|
||||||
|
`;
|
||||||
|
|
||||||
|
const scopeJson = JSON.stringify({ resources: ['tasks'] });
|
||||||
|
await sql!`
|
||||||
|
INSERT INTO federation_grants
|
||||||
|
(subject_user_id, peer_id, scope, status, created_at)
|
||||||
|
VALUES (
|
||||||
|
${cascadeGrantUserId},
|
||||||
|
${PEER3_ID},
|
||||||
|
${scopeJson}::jsonb,
|
||||||
|
${'active'},
|
||||||
|
now()
|
||||||
|
)
|
||||||
|
`;
|
||||||
|
|
||||||
|
const before = await sql!`
|
||||||
|
SELECT count(*)::int AS cnt FROM federation_grants WHERE peer_id = ${PEER3_ID}
|
||||||
|
`;
|
||||||
|
expect(before[0]!['cnt']).toBe(1);
|
||||||
|
|
||||||
|
// Delete peer → grants should cascade-delete.
|
||||||
|
await sql!`DELETE FROM federation_peers WHERE id = ${PEER3_ID}`;
|
||||||
|
|
||||||
|
const after = await sql!`
|
||||||
|
SELECT count(*)::int AS cnt FROM federation_grants WHERE peer_id = ${PEER3_ID}
|
||||||
|
`;
|
||||||
|
expect(after[0]!['cnt']).toBe(0);
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
await sql!`DELETE FROM users WHERE id = ${cascadeGrantUserId}`.catch(() => {});
|
||||||
|
}, 15_000);
|
||||||
|
});
|
||||||
21
packages/db/src/federation.ts
Normal file
21
packages/db/src/federation.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
/**
|
||||||
|
* Federation schema re-exports.
|
||||||
|
*
|
||||||
|
* The actual table and enum definitions live in schema.ts (alongside all other
|
||||||
|
* Drizzle tables) to avoid CJS/ESM cross-import issues when drizzle-kit loads
|
||||||
|
* schema files via esbuild-register. Application code that wants named imports
|
||||||
|
* for federation symbols should import from this file.
|
||||||
|
*
|
||||||
|
* M2-01: DB tables and enums only. No business logic.
|
||||||
|
* M2-03 will add JSON schema validation for the `scope` column.
|
||||||
|
* M4 will write rows to federation_audit_log.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export {
|
||||||
|
peerStateEnum,
|
||||||
|
grantStatusEnum,
|
||||||
|
federationPeers,
|
||||||
|
federationGrants,
|
||||||
|
federationAuditLog,
|
||||||
|
federationEnrollmentTokens,
|
||||||
|
} from './schema.js';
|
||||||
@@ -2,6 +2,7 @@ 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 } from './migrate.js';
|
||||||
export * from './schema.js';
|
export * from './schema.js';
|
||||||
|
export * from './federation.js';
|
||||||
export {
|
export {
|
||||||
eq,
|
eq,
|
||||||
and,
|
and,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
pgTable,
|
pgTable,
|
||||||
|
pgEnum,
|
||||||
text,
|
text,
|
||||||
timestamp,
|
timestamp,
|
||||||
boolean,
|
boolean,
|
||||||
@@ -585,3 +586,226 @@ export const summarizationJobs = pgTable(
|
|||||||
},
|
},
|
||||||
(t) => [index('summarization_jobs_status_idx').on(t.status)],
|
(t) => [index('summarization_jobs_status_idx').on(t.status)],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// ─── Federation ──────────────────────────────────────────────────────────────
|
||||||
|
// Enums declared before tables that reference them.
|
||||||
|
// All federation definitions live in this file (avoids CJS/ESM cross-import
|
||||||
|
// issues when drizzle-kit loads schema files via esbuild-register).
|
||||||
|
// Application code imports from `federation.ts` which re-exports from here.
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lifecycle state of a federation peer.
|
||||||
|
* - pending: registered but not yet approved / TLS handshake not confirmed
|
||||||
|
* - active: fully operational; mTLS verified
|
||||||
|
* - suspended: temporarily blocked; cert still valid
|
||||||
|
* - revoked: cert revoked; no traffic allowed
|
||||||
|
*/
|
||||||
|
export const peerStateEnum = pgEnum('peer_state', ['pending', 'active', 'suspended', 'revoked']);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lifecycle state of a federation grant.
|
||||||
|
* - pending: created but not yet activated (awaiting cert enrollment, M2-07)
|
||||||
|
* - active: grant is in effect
|
||||||
|
* - revoked: manually revoked before expiry
|
||||||
|
* - expired: natural expiry (expires_at passed)
|
||||||
|
*/
|
||||||
|
export const grantStatusEnum = pgEnum('grant_status', ['pending', 'active', 'revoked', 'expired']);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A registered peer gateway identified by its Step-CA certificate CN.
|
||||||
|
* Represents both inbound peers (other gateways querying us) and outbound
|
||||||
|
* peers (gateways we query — identified by client_key_pem being set).
|
||||||
|
*/
|
||||||
|
export const federationPeers = pgTable(
|
||||||
|
'federation_peers',
|
||||||
|
{
|
||||||
|
id: uuid('id').primaryKey().defaultRandom(),
|
||||||
|
|
||||||
|
/** Certificate CN, e.g. "gateway-uscllc-com". Unique — one row per peer identity. */
|
||||||
|
commonName: text('common_name').notNull().unique(),
|
||||||
|
|
||||||
|
/** Human-friendly label shown in admin UI. */
|
||||||
|
displayName: text('display_name').notNull(),
|
||||||
|
|
||||||
|
/** Pinned PEM certificate used for mTLS verification. */
|
||||||
|
certPem: text('cert_pem').notNull(),
|
||||||
|
|
||||||
|
/** Certificate serial number — used for CRL / revocation lookup. */
|
||||||
|
certSerial: text('cert_serial').notNull().unique(),
|
||||||
|
|
||||||
|
/** Certificate expiry — used by the renewal scheduler (FED-M6). */
|
||||||
|
certNotAfter: timestamp('cert_not_after', { withTimezone: true }).notNull(),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sealed (encrypted) private key for outbound connections TO this peer.
|
||||||
|
* NULL for inbound-only peer rows (we serve them; we don't call them).
|
||||||
|
*/
|
||||||
|
clientKeyPem: text('client_key_pem'),
|
||||||
|
|
||||||
|
/** Current peer lifecycle state. */
|
||||||
|
state: peerStateEnum('state').notNull().default('pending'),
|
||||||
|
|
||||||
|
/** Base URL for outbound queries, e.g. "https://woltje.com:443". NULL for inbound-only peers. */
|
||||||
|
endpointUrl: text('endpoint_url'),
|
||||||
|
|
||||||
|
/** Timestamp of the most recent successful inbound or outbound request. */
|
||||||
|
lastSeenAt: timestamp('last_seen_at', { withTimezone: true }),
|
||||||
|
|
||||||
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
|
||||||
|
/** Populated when the cert is revoked; NULL while the peer is active. */
|
||||||
|
revokedAt: timestamp('revoked_at', { withTimezone: true }),
|
||||||
|
},
|
||||||
|
(t) => [
|
||||||
|
// CRL / revocation lookups by serial.
|
||||||
|
index('federation_peers_cert_serial_idx').on(t.certSerial),
|
||||||
|
// Filter peers by state (e.g. find all active peers for outbound routing).
|
||||||
|
index('federation_peers_state_idx').on(t.state),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A grant lets a specific peer cert query a specific local user's data within
|
||||||
|
* a defined scope. Scopes are validated by JSON Schema in M2-03; this table
|
||||||
|
* stores them as raw jsonb.
|
||||||
|
*/
|
||||||
|
export const federationGrants = pgTable(
|
||||||
|
'federation_grants',
|
||||||
|
{
|
||||||
|
id: uuid('id').primaryKey().defaultRandom(),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The local user whose data this grant exposes.
|
||||||
|
* Cascade delete: if the user account is deleted, revoke all their grants.
|
||||||
|
*/
|
||||||
|
subjectUserId: text('subject_user_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => users.id, { onDelete: 'cascade' }),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The peer gateway holding the grant.
|
||||||
|
* Cascade delete: if the peer record is removed, the grant is moot.
|
||||||
|
*/
|
||||||
|
peerId: uuid('peer_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => federationPeers.id, { onDelete: 'cascade' }),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scope object — validated by JSON Schema (M2-03).
|
||||||
|
* Example: { "resources": ["tasks", "notes"], "operations": ["list", "get"] }
|
||||||
|
*/
|
||||||
|
scope: jsonb('scope').notNull(),
|
||||||
|
|
||||||
|
/** Current grant lifecycle state. */
|
||||||
|
status: grantStatusEnum('status').notNull().default('pending'),
|
||||||
|
|
||||||
|
/** Optional hard expiry. NULL means the grant does not expire automatically. */
|
||||||
|
expiresAt: timestamp('expires_at', { withTimezone: true }),
|
||||||
|
|
||||||
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
|
||||||
|
/** Populated when the grant is explicitly revoked. */
|
||||||
|
revokedAt: timestamp('revoked_at', { withTimezone: true }),
|
||||||
|
|
||||||
|
/** Human-readable reason for revocation (audit trail). */
|
||||||
|
revokedReason: text('revoked_reason'),
|
||||||
|
},
|
||||||
|
(t) => [
|
||||||
|
// Hot path: look up active grants for a subject user (auth middleware).
|
||||||
|
index('federation_grants_subject_status_idx').on(t.subjectUserId, t.status),
|
||||||
|
// Hot path: look up active grants held by a peer (inbound request check).
|
||||||
|
index('federation_grants_peer_status_idx').on(t.peerId, t.status),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Append-only audit log of all federation requests.
|
||||||
|
* M4 writes rows here. M2 only creates the table.
|
||||||
|
*
|
||||||
|
* All FKs use SET NULL so audit rows survive peer/user/grant deletion.
|
||||||
|
*/
|
||||||
|
export const federationAuditLog = pgTable(
|
||||||
|
'federation_audit_log',
|
||||||
|
{
|
||||||
|
id: uuid('id').primaryKey().defaultRandom(),
|
||||||
|
|
||||||
|
/** UUIDv7 from the X-Request-ID header — correlates with OTEL traces. */
|
||||||
|
requestId: text('request_id').notNull(),
|
||||||
|
|
||||||
|
/** Peer that made the request. SET NULL if the peer is later deleted. */
|
||||||
|
peerId: uuid('peer_id').references(() => federationPeers.id, { onDelete: 'set null' }),
|
||||||
|
|
||||||
|
/** Subject user whose data was queried. SET NULL if the user is deleted. */
|
||||||
|
subjectUserId: text('subject_user_id').references(() => users.id, { onDelete: 'set null' }),
|
||||||
|
|
||||||
|
/** Grant under which the request was authorised. SET NULL if the grant is deleted. */
|
||||||
|
grantId: uuid('grant_id').references(() => federationGrants.id, { onDelete: 'set null' }),
|
||||||
|
|
||||||
|
/** Request verb: "list" | "get" | "search". */
|
||||||
|
verb: text('verb').notNull(),
|
||||||
|
|
||||||
|
/** Resource type: "tasks" | "notes" | "memory" | etc. */
|
||||||
|
resource: text('resource').notNull(),
|
||||||
|
|
||||||
|
/** HTTP status code returned to the peer. */
|
||||||
|
statusCode: integer('status_code').notNull(),
|
||||||
|
|
||||||
|
/** Number of items returned (NULL for non-list requests or errors). */
|
||||||
|
resultCount: integer('result_count'),
|
||||||
|
|
||||||
|
/** Why the request was denied (NULL when allowed). */
|
||||||
|
deniedReason: text('denied_reason'),
|
||||||
|
|
||||||
|
/** End-to-end latency in milliseconds. */
|
||||||
|
latencyMs: integer('latency_ms'),
|
||||||
|
|
||||||
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
|
||||||
|
// Reserved for M4 — see PRD 7.3
|
||||||
|
/** SHA-256 of the normalised GraphQL/REST query string; written by M4 search. */
|
||||||
|
queryHash: text('query_hash'),
|
||||||
|
/** Request outcome: "allowed" | "denied" | "partial"; written by M4. */
|
||||||
|
outcome: text('outcome'),
|
||||||
|
/** Response payload size in bytes; written by M4. */
|
||||||
|
bytesOut: integer('bytes_out'),
|
||||||
|
},
|
||||||
|
(t) => [
|
||||||
|
// Per-peer request history in reverse chronological order.
|
||||||
|
index('federation_audit_log_peer_created_at_idx').on(t.peerId, t.createdAt.desc()),
|
||||||
|
// Per-user access log in reverse chronological order.
|
||||||
|
index('federation_audit_log_subject_created_at_idx').on(t.subjectUserId, t.createdAt.desc()),
|
||||||
|
// Global time-range scans (dashboards, rate-limit windows).
|
||||||
|
index('federation_audit_log_created_at_idx').on(t.createdAt.desc()),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Single-use enrollment tokens — M2-07.
|
||||||
|
*
|
||||||
|
* An admin creates a token (with a TTL) and hands it out-of-band to the
|
||||||
|
* remote peer operator. The peer redeems it exactly once by posting its
|
||||||
|
* CSR to POST /api/federation/enrollment/:token. The token is atomically
|
||||||
|
* marked as used to prevent replay attacks.
|
||||||
|
*/
|
||||||
|
export const federationEnrollmentTokens = pgTable('federation_enrollment_tokens', {
|
||||||
|
/** 32-byte hex token — crypto.randomBytes(32).toString('hex') */
|
||||||
|
token: text('token').primaryKey(),
|
||||||
|
|
||||||
|
/** The federation grant this enrollment activates. */
|
||||||
|
grantId: uuid('grant_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => federationGrants.id, { onDelete: 'cascade' }),
|
||||||
|
|
||||||
|
/** The peer record that will be updated on successful enrollment. */
|
||||||
|
peerId: uuid('peer_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => federationPeers.id, { onDelete: 'cascade' }),
|
||||||
|
|
||||||
|
/** Hard expiry — token rejected after this time even if not used. */
|
||||||
|
expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
|
||||||
|
|
||||||
|
/** NULL until the token is redeemed. Set atomically to prevent replay. */
|
||||||
|
usedAt: timestamp('used_at', { withTimezone: true }),
|
||||||
|
|
||||||
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
});
|
||||||
|
|||||||
@@ -13,6 +13,14 @@ export PORTAINER_URL="https://portainer.example.com:9443"
|
|||||||
export PORTAINER_API_KEY="your-api-key-here"
|
export PORTAINER_API_KEY="your-api-key-here"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
If your Portainer instance uses a self-signed TLS certificate (e.g. internal LAN), set:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export PORTAINER_INSECURE=1
|
||||||
|
```
|
||||||
|
|
||||||
|
This passes `-k` to all curl calls, bypassing certificate verification. Do not set this against public/production instances.
|
||||||
|
|
||||||
You can add these to your shell profile (`~/.bashrc`, `~/.zshrc`) or use a `.env` file.
|
You can add these to your shell profile (`~/.bashrc`, `~/.zshrc`) or use a `.env` file.
|
||||||
|
|
||||||
### Creating an API Key
|
### Creating an API Key
|
||||||
|
|||||||
@@ -46,8 +46,14 @@ fi
|
|||||||
# Remove trailing slash from URL
|
# Remove trailing slash from URL
|
||||||
PORTAINER_URL="${PORTAINER_URL%/}"
|
PORTAINER_URL="${PORTAINER_URL%/}"
|
||||||
|
|
||||||
|
# TLS options
|
||||||
|
CURL_OPTS=()
|
||||||
|
if [ "${PORTAINER_INSECURE:-0}" = "1" ]; then
|
||||||
|
CURL_OPTS+=(-k)
|
||||||
|
fi
|
||||||
|
|
||||||
# Fetch endpoints
|
# Fetch endpoints
|
||||||
response=$(curl -s -w "\n%{http_code}" \
|
response=$(curl -s "${CURL_OPTS[@]}" -w "\n%{http_code}" \
|
||||||
-H "X-API-Key: ${PORTAINER_API_KEY}" \
|
-H "X-API-Key: ${PORTAINER_API_KEY}" \
|
||||||
"${PORTAINER_URL}/api/endpoints")
|
"${PORTAINER_URL}/api/endpoints")
|
||||||
|
|
||||||
|
|||||||
@@ -52,8 +52,14 @@ fi
|
|||||||
# Remove trailing slash from URL
|
# Remove trailing slash from URL
|
||||||
PORTAINER_URL="${PORTAINER_URL%/}"
|
PORTAINER_URL="${PORTAINER_URL%/}"
|
||||||
|
|
||||||
|
# TLS options
|
||||||
|
CURL_OPTS=()
|
||||||
|
if [ "${PORTAINER_INSECURE:-0}" = "1" ]; then
|
||||||
|
CURL_OPTS+=(-k)
|
||||||
|
fi
|
||||||
|
|
||||||
# Fetch stacks
|
# Fetch stacks
|
||||||
response=$(curl -s -w "\n%{http_code}" \
|
response=$(curl -s "${CURL_OPTS[@]}" -w "\n%{http_code}" \
|
||||||
-H "X-API-Key: ${PORTAINER_API_KEY}" \
|
-H "X-API-Key: ${PORTAINER_API_KEY}" \
|
||||||
"${PORTAINER_URL}/api/stacks")
|
"${PORTAINER_URL}/api/stacks")
|
||||||
|
|
||||||
|
|||||||
@@ -64,12 +64,18 @@ fi
|
|||||||
# Remove trailing slash from URL
|
# Remove trailing slash from URL
|
||||||
PORTAINER_URL="${PORTAINER_URL%/}"
|
PORTAINER_URL="${PORTAINER_URL%/}"
|
||||||
|
|
||||||
|
# TLS options
|
||||||
|
CURL_OPTS=()
|
||||||
|
if [ "${PORTAINER_INSECURE:-0}" = "1" ]; then
|
||||||
|
CURL_OPTS+=(-k)
|
||||||
|
fi
|
||||||
|
|
||||||
# Function to make API requests
|
# Function to make API requests
|
||||||
api_request() {
|
api_request() {
|
||||||
local method="$1"
|
local method="$1"
|
||||||
local endpoint="$2"
|
local endpoint="$2"
|
||||||
|
|
||||||
curl -s -w "\n%{http_code}" -X "$method" \
|
curl -s "${CURL_OPTS[@]}" -w "\n%{http_code}" -X "$method" \
|
||||||
-H "X-API-Key: ${PORTAINER_API_KEY}" \
|
-H "X-API-Key: ${PORTAINER_API_KEY}" \
|
||||||
"${PORTAINER_URL}${endpoint}"
|
"${PORTAINER_URL}${endpoint}"
|
||||||
}
|
}
|
||||||
@@ -165,7 +171,7 @@ fi
|
|||||||
# Note: Docker API returns raw log stream, not JSON
|
# Note: Docker API returns raw log stream, not JSON
|
||||||
if [[ "$FOLLOW" == "true" ]]; then
|
if [[ "$FOLLOW" == "true" ]]; then
|
||||||
# Stream logs
|
# Stream logs
|
||||||
curl -s -N \
|
curl -s "${CURL_OPTS[@]}" -N \
|
||||||
-H "X-API-Key: ${PORTAINER_API_KEY}" \
|
-H "X-API-Key: ${PORTAINER_API_KEY}" \
|
||||||
"${PORTAINER_URL}/api/endpoints/${ENDPOINT_ID}/docker/containers/${CONTAINER_ID}/logs?${params}" | \
|
"${PORTAINER_URL}/api/endpoints/${ENDPOINT_ID}/docker/containers/${CONTAINER_ID}/logs?${params}" | \
|
||||||
# Docker log format has 8-byte header per line, strip it
|
# Docker log format has 8-byte header per line, strip it
|
||||||
@@ -175,7 +181,7 @@ if [[ "$FOLLOW" == "true" ]]; then
|
|||||||
done
|
done
|
||||||
else
|
else
|
||||||
# Get logs (non-streaming)
|
# Get logs (non-streaming)
|
||||||
curl -s \
|
curl -s "${CURL_OPTS[@]}" \
|
||||||
-H "X-API-Key: ${PORTAINER_API_KEY}" \
|
-H "X-API-Key: ${PORTAINER_API_KEY}" \
|
||||||
"${PORTAINER_URL}/api/endpoints/${ENDPOINT_ID}/docker/containers/${CONTAINER_ID}/logs?${params}" | \
|
"${PORTAINER_URL}/api/endpoints/${ENDPOINT_ID}/docker/containers/${CONTAINER_ID}/logs?${params}" | \
|
||||||
# Docker log format has 8-byte header per line, attempt to strip it
|
# Docker log format has 8-byte header per line, attempt to strip it
|
||||||
|
|||||||
@@ -63,13 +63,19 @@ fi
|
|||||||
# Remove trailing slash from URL
|
# Remove trailing slash from URL
|
||||||
PORTAINER_URL="${PORTAINER_URL%/}"
|
PORTAINER_URL="${PORTAINER_URL%/}"
|
||||||
|
|
||||||
|
# TLS options
|
||||||
|
CURL_OPTS=()
|
||||||
|
if [ "${PORTAINER_INSECURE:-0}" = "1" ]; then
|
||||||
|
CURL_OPTS+=(-k)
|
||||||
|
fi
|
||||||
|
|
||||||
# Function to make API requests
|
# Function to make API requests
|
||||||
api_request() {
|
api_request() {
|
||||||
local method="$1"
|
local method="$1"
|
||||||
local endpoint="$2"
|
local endpoint="$2"
|
||||||
local data="${3:-}"
|
local data="${3:-}"
|
||||||
|
|
||||||
local args=(-s -w "\n%{http_code}" -X "$method" -H "X-API-Key: ${PORTAINER_API_KEY}")
|
local args=(-s "${CURL_OPTS[@]}" -w "\n%{http_code}" -X "$method" -H "X-API-Key: ${PORTAINER_API_KEY}")
|
||||||
|
|
||||||
if [[ -n "$data" ]]; then
|
if [[ -n "$data" ]]; then
|
||||||
args+=(-H "Content-Type: application/json" -d "$data")
|
args+=(-H "Content-Type: application/json" -d "$data")
|
||||||
|
|||||||
@@ -54,12 +54,18 @@ fi
|
|||||||
# Remove trailing slash from URL
|
# Remove trailing slash from URL
|
||||||
PORTAINER_URL="${PORTAINER_URL%/}"
|
PORTAINER_URL="${PORTAINER_URL%/}"
|
||||||
|
|
||||||
|
# TLS options
|
||||||
|
CURL_OPTS=()
|
||||||
|
if [ "${PORTAINER_INSECURE:-0}" = "1" ]; then
|
||||||
|
CURL_OPTS+=(-k)
|
||||||
|
fi
|
||||||
|
|
||||||
# Function to make API requests
|
# Function to make API requests
|
||||||
api_request() {
|
api_request() {
|
||||||
local method="$1"
|
local method="$1"
|
||||||
local endpoint="$2"
|
local endpoint="$2"
|
||||||
|
|
||||||
curl -s -w "\n%{http_code}" -X "$method" \
|
curl -s "${CURL_OPTS[@]}" -w "\n%{http_code}" -X "$method" \
|
||||||
-H "X-API-Key: ${PORTAINER_API_KEY}" \
|
-H "X-API-Key: ${PORTAINER_API_KEY}" \
|
||||||
"${PORTAINER_URL}${endpoint}"
|
"${PORTAINER_URL}${endpoint}"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,12 +57,18 @@ fi
|
|||||||
# Remove trailing slash from URL
|
# Remove trailing slash from URL
|
||||||
PORTAINER_URL="${PORTAINER_URL%/}"
|
PORTAINER_URL="${PORTAINER_URL%/}"
|
||||||
|
|
||||||
|
# TLS options
|
||||||
|
CURL_OPTS=()
|
||||||
|
if [ "${PORTAINER_INSECURE:-0}" = "1" ]; then
|
||||||
|
CURL_OPTS+=(-k)
|
||||||
|
fi
|
||||||
|
|
||||||
# Function to make API requests
|
# Function to make API requests
|
||||||
api_request() {
|
api_request() {
|
||||||
local method="$1"
|
local method="$1"
|
||||||
local endpoint="$2"
|
local endpoint="$2"
|
||||||
|
|
||||||
curl -s -w "\n%{http_code}" -X "$method" \
|
curl -s "${CURL_OPTS[@]}" -w "\n%{http_code}" -X "$method" \
|
||||||
-H "X-API-Key: ${PORTAINER_API_KEY}" \
|
-H "X-API-Key: ${PORTAINER_API_KEY}" \
|
||||||
"${PORTAINER_URL}${endpoint}"
|
"${PORTAINER_URL}${endpoint}"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,12 +54,18 @@ fi
|
|||||||
# Remove trailing slash from URL
|
# Remove trailing slash from URL
|
||||||
PORTAINER_URL="${PORTAINER_URL%/}"
|
PORTAINER_URL="${PORTAINER_URL%/}"
|
||||||
|
|
||||||
|
# TLS options
|
||||||
|
CURL_OPTS=()
|
||||||
|
if [ "${PORTAINER_INSECURE:-0}" = "1" ]; then
|
||||||
|
CURL_OPTS+=(-k)
|
||||||
|
fi
|
||||||
|
|
||||||
# Function to make API requests
|
# Function to make API requests
|
||||||
api_request() {
|
api_request() {
|
||||||
local method="$1"
|
local method="$1"
|
||||||
local endpoint="$2"
|
local endpoint="$2"
|
||||||
|
|
||||||
curl -s -w "\n%{http_code}" -X "$method" \
|
curl -s "${CURL_OPTS[@]}" -w "\n%{http_code}" -X "$method" \
|
||||||
-H "X-API-Key: ${PORTAINER_API_KEY}" \
|
-H "X-API-Key: ${PORTAINER_API_KEY}" \
|
||||||
"${PORTAINER_URL}${endpoint}"
|
"${PORTAINER_URL}${endpoint}"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { registerUninstallCommand } from './commands/uninstall.js';
|
|||||||
// prdy is registered via launch.ts
|
// prdy is registered via launch.ts
|
||||||
import { registerLaunchCommands } from './commands/launch.js';
|
import { registerLaunchCommands } from './commands/launch.js';
|
||||||
import { registerAuthCommand } from './commands/auth.js';
|
import { registerAuthCommand } from './commands/auth.js';
|
||||||
|
import { registerFederationCommand } from './commands/federation.js';
|
||||||
import { registerGatewayCommand } from './commands/gateway.js';
|
import { registerGatewayCommand } from './commands/gateway.js';
|
||||||
import {
|
import {
|
||||||
backgroundUpdateCheck,
|
backgroundUpdateCheck,
|
||||||
@@ -336,6 +337,10 @@ registerAuthCommand(program);
|
|||||||
|
|
||||||
registerGatewayCommand(program);
|
registerGatewayCommand(program);
|
||||||
|
|
||||||
|
// ─── federation ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
registerFederationCommand(program);
|
||||||
|
|
||||||
// ─── agent ─────────────────────────────────────────────────────────────
|
// ─── agent ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
registerAgentCommand(program);
|
registerAgentCommand(program);
|
||||||
|
|||||||
410
packages/mosaic/src/commands/federation.ts
Normal file
410
packages/mosaic/src/commands/federation.ts
Normal file
@@ -0,0 +1,410 @@
|
|||||||
|
/**
|
||||||
|
* `mosaic federation` command group — federation grant + peer management (FED-M2-08).
|
||||||
|
*
|
||||||
|
* All HTTP calls go to the local gateway admin API using an admin token
|
||||||
|
* resolved from CLI options or meta.json.
|
||||||
|
*
|
||||||
|
* Subcommands:
|
||||||
|
* grant create --peer-id <uuid> --user-id <uuid> --scope <json> [--expires-at <iso>]
|
||||||
|
* grant list [--peer-id <uuid>] [--user-id <uuid>] [--status pending|active|revoked|expired]
|
||||||
|
* grant show <id>
|
||||||
|
* grant revoke <id> [--reason <text>]
|
||||||
|
* grant token <id> [--ttl 900]
|
||||||
|
*
|
||||||
|
* peer list
|
||||||
|
* peer add <enrollment-url>
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Command } from 'commander';
|
||||||
|
import { readMeta } from './gateway/daemon.js';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Types
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface FedParentOpts {
|
||||||
|
host: string;
|
||||||
|
port: string;
|
||||||
|
token?: string;
|
||||||
|
json?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ResolvedOpts {
|
||||||
|
baseUrl: string;
|
||||||
|
token?: string;
|
||||||
|
json: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function resolveOpts(raw: FedParentOpts): ResolvedOpts {
|
||||||
|
const meta = readMeta();
|
||||||
|
const host = raw.host ?? meta?.host ?? 'localhost';
|
||||||
|
const port = parseInt(raw.port, 10) || meta?.port || 14242;
|
||||||
|
const token = raw.token ?? meta?.adminToken;
|
||||||
|
return {
|
||||||
|
baseUrl: `http://${host}:${port.toString()}`,
|
||||||
|
token,
|
||||||
|
json: raw.json ?? false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireToken(opts: ResolvedOpts): string {
|
||||||
|
if (!opts.token) {
|
||||||
|
console.error(
|
||||||
|
'Error: admin token required. Use -t/--token <token> or ensure meta.json has adminToken.',
|
||||||
|
);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
return opts.token;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function apiRequest<T>(
|
||||||
|
opts: ResolvedOpts,
|
||||||
|
method: string,
|
||||||
|
path: string,
|
||||||
|
body?: unknown,
|
||||||
|
): Promise<T> {
|
||||||
|
const token = requireToken(opts);
|
||||||
|
const url = `${opts.baseUrl}${path}`;
|
||||||
|
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
const text = await res.text();
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
let message = text;
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(text) as { message?: string };
|
||||||
|
message = parsed.message ?? text;
|
||||||
|
} catch {
|
||||||
|
// use raw text
|
||||||
|
}
|
||||||
|
throw new Error(`HTTP ${res.status.toString()}: ${message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!text) return undefined as unknown as T;
|
||||||
|
return JSON.parse(text) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
function printJson(data: unknown, useJson: boolean): void {
|
||||||
|
if (useJson) {
|
||||||
|
console.log(JSON.stringify(data, null, 2));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function printTable(rows: Record<string, unknown>[]): void {
|
||||||
|
if (rows.length === 0) {
|
||||||
|
console.log('(none)');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const row of rows) {
|
||||||
|
for (const [key, val] of Object.entries(row)) {
|
||||||
|
console.log(` ${key}: ${String(val ?? '')}`);
|
||||||
|
}
|
||||||
|
console.log('');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Command registration
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function registerFederationCommand(program: Command): void {
|
||||||
|
const fed = program
|
||||||
|
.command('federation')
|
||||||
|
.alias('fed')
|
||||||
|
.description('Manage federation grants and peers')
|
||||||
|
.option('-h, --host <host>', 'Gateway host', 'localhost')
|
||||||
|
.option('-p, --port <port>', 'Gateway port', '14242')
|
||||||
|
.option('-t, --token <token>', 'Admin token')
|
||||||
|
.option('--json', 'Machine-readable JSON output')
|
||||||
|
.action(() => fed.outputHelp());
|
||||||
|
|
||||||
|
// ─── grant subcommands ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const grant = fed
|
||||||
|
.command('grant')
|
||||||
|
.description('Manage federation grants')
|
||||||
|
.action(() => grant.outputHelp());
|
||||||
|
|
||||||
|
grant
|
||||||
|
.command('create')
|
||||||
|
.description('Create a new federation grant')
|
||||||
|
.requiredOption('--peer-id <uuid>', 'Peer UUID')
|
||||||
|
.requiredOption('--user-id <uuid>', 'Subject user UUID')
|
||||||
|
.requiredOption('--scope <json>', 'Grant scope as JSON string')
|
||||||
|
.option('--expires-at <iso>', 'Optional expiry (ISO 8601)')
|
||||||
|
.action(
|
||||||
|
async (cmdOpts: { peerId: string; userId: string; scope: string; expiresAt?: string }) => {
|
||||||
|
const opts = resolveOpts(fed.opts() as FedParentOpts);
|
||||||
|
try {
|
||||||
|
let scope: Record<string, unknown>;
|
||||||
|
try {
|
||||||
|
scope = JSON.parse(cmdOpts.scope) as Record<string, unknown>;
|
||||||
|
} catch {
|
||||||
|
console.error('Error: --scope must be valid JSON');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const body: Record<string, unknown> = {
|
||||||
|
peerId: cmdOpts.peerId,
|
||||||
|
subjectUserId: cmdOpts.userId,
|
||||||
|
scope,
|
||||||
|
};
|
||||||
|
if (cmdOpts.expiresAt) body['expiresAt'] = cmdOpts.expiresAt;
|
||||||
|
|
||||||
|
const result = await apiRequest<Record<string, unknown>>(
|
||||||
|
opts,
|
||||||
|
'POST',
|
||||||
|
'/api/admin/federation/grants',
|
||||||
|
body,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (opts.json) {
|
||||||
|
printJson(result, true);
|
||||||
|
} else {
|
||||||
|
console.log(`Grant created: ${String(result['id'])}`);
|
||||||
|
console.log(` Peer: ${String(result['peerId'])}`);
|
||||||
|
console.log(` User: ${String(result['subjectUserId'])}`);
|
||||||
|
console.log(` Status: ${String(result['status'])}`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err instanceof Error ? err.message : String(err));
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
grant
|
||||||
|
.command('list')
|
||||||
|
.description('List federation grants')
|
||||||
|
.option('--peer-id <uuid>', 'Filter by peer UUID')
|
||||||
|
.option('--user-id <uuid>', 'Filter by subject user UUID')
|
||||||
|
.option('--status <status>', 'Filter by status (pending|active|revoked|expired)')
|
||||||
|
.action(async (cmdOpts: { peerId?: string; userId?: string; status?: string }) => {
|
||||||
|
const opts = resolveOpts(fed.opts() as FedParentOpts);
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (cmdOpts.peerId) params.set('peerId', cmdOpts.peerId);
|
||||||
|
if (cmdOpts.userId) params.set('subjectUserId', cmdOpts.userId);
|
||||||
|
if (cmdOpts.status) params.set('status', cmdOpts.status);
|
||||||
|
|
||||||
|
const qs = params.toString() ? `?${params.toString()}` : '';
|
||||||
|
const result = await apiRequest<Record<string, unknown>[]>(
|
||||||
|
opts,
|
||||||
|
'GET',
|
||||||
|
`/api/admin/federation/grants${qs}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (opts.json) {
|
||||||
|
printJson(result, true);
|
||||||
|
} else {
|
||||||
|
console.log(`Grants (${result.length.toString()}):\n`);
|
||||||
|
printTable(result);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err instanceof Error ? err.message : String(err));
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
grant
|
||||||
|
.command('show <id>')
|
||||||
|
.description('Get a single grant by ID')
|
||||||
|
.action(async (id: string) => {
|
||||||
|
const opts = resolveOpts(fed.opts() as FedParentOpts);
|
||||||
|
try {
|
||||||
|
const result = await apiRequest<Record<string, unknown>>(
|
||||||
|
opts,
|
||||||
|
'GET',
|
||||||
|
`/api/admin/federation/grants/${id}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (opts.json) {
|
||||||
|
printJson(result, true);
|
||||||
|
} else {
|
||||||
|
for (const [key, val] of Object.entries(result)) {
|
||||||
|
console.log(` ${key}: ${String(val ?? '')}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err instanceof Error ? err.message : String(err));
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
grant
|
||||||
|
.command('revoke <id>')
|
||||||
|
.description('Revoke an active grant')
|
||||||
|
.option('--reason <text>', 'Revocation reason')
|
||||||
|
.action(async (id: string, cmdOpts: { reason?: string }) => {
|
||||||
|
const opts = resolveOpts(fed.opts() as FedParentOpts);
|
||||||
|
try {
|
||||||
|
const body: Record<string, unknown> = {};
|
||||||
|
if (cmdOpts.reason) body['reason'] = cmdOpts.reason;
|
||||||
|
|
||||||
|
const result = await apiRequest<Record<string, unknown>>(
|
||||||
|
opts,
|
||||||
|
'PATCH',
|
||||||
|
`/api/admin/federation/grants/${id}/revoke`,
|
||||||
|
body,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (opts.json) {
|
||||||
|
printJson(result, true);
|
||||||
|
} else {
|
||||||
|
console.log(`Grant ${id} revoked.`);
|
||||||
|
if (result['revokedReason']) console.log(` Reason: ${String(result['revokedReason'])}`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err instanceof Error ? err.message : String(err));
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
grant
|
||||||
|
.command('token <id>')
|
||||||
|
.description('Generate a single-use enrollment token for a grant')
|
||||||
|
.option('--ttl <seconds>', 'Token lifetime in seconds (60-900)', '900')
|
||||||
|
.action(async (id: string, cmdOpts: { ttl: string }) => {
|
||||||
|
const opts = resolveOpts(fed.opts() as FedParentOpts);
|
||||||
|
try {
|
||||||
|
const ttlSeconds = parseInt(cmdOpts.ttl, 10) || 900;
|
||||||
|
const result = await apiRequest<{
|
||||||
|
token: string;
|
||||||
|
expiresAt: string;
|
||||||
|
enrollmentUrl: string;
|
||||||
|
}>(opts, 'POST', `/api/admin/federation/grants/${id}/tokens`, { ttlSeconds });
|
||||||
|
|
||||||
|
if (opts.json) {
|
||||||
|
printJson(result, true);
|
||||||
|
} else {
|
||||||
|
console.log('Enrollment token generated:');
|
||||||
|
console.log(` Token: ${result.token}`);
|
||||||
|
console.log(` Expires at: ${result.expiresAt}`);
|
||||||
|
console.log(` Enrollment URL: ${result.enrollmentUrl}`);
|
||||||
|
console.log('');
|
||||||
|
console.log('Share the enrollment URL with the remote peer operator.');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err instanceof Error ? err.message : String(err));
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── peer subcommands ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const peer = fed
|
||||||
|
.command('peer')
|
||||||
|
.description('Manage federation peers')
|
||||||
|
.action(() => peer.outputHelp());
|
||||||
|
|
||||||
|
peer
|
||||||
|
.command('list')
|
||||||
|
.description('List all federation peers')
|
||||||
|
.action(async () => {
|
||||||
|
const opts = resolveOpts(fed.opts() as FedParentOpts);
|
||||||
|
try {
|
||||||
|
const result = await apiRequest<Record<string, unknown>[]>(
|
||||||
|
opts,
|
||||||
|
'GET',
|
||||||
|
'/api/admin/federation/peers',
|
||||||
|
);
|
||||||
|
|
||||||
|
if (opts.json) {
|
||||||
|
printJson(result, true);
|
||||||
|
} else {
|
||||||
|
console.log(`Peers (${result.length.toString()}):\n`);
|
||||||
|
printTable(result);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err instanceof Error ? err.message : String(err));
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
peer
|
||||||
|
.command('add <enrollment-url>')
|
||||||
|
.description('Enroll as a peer using a remote enrollment URL')
|
||||||
|
.action(async (enrollmentUrl: string) => {
|
||||||
|
const opts = resolveOpts(fed.opts() as FedParentOpts);
|
||||||
|
try {
|
||||||
|
// 1. Validate enrollment URL
|
||||||
|
let parsedUrl: URL;
|
||||||
|
try {
|
||||||
|
parsedUrl = new URL(enrollmentUrl);
|
||||||
|
} catch {
|
||||||
|
console.error(`Error: invalid enrollment URL: ${enrollmentUrl}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsedUrl.protocol !== 'https:' && parsedUrl.protocol !== 'http:') {
|
||||||
|
console.error('Error: enrollment URL must use http or https');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const hostname = parsedUrl.hostname;
|
||||||
|
const commonName = hostname.replace(/\./g, '-');
|
||||||
|
|
||||||
|
console.log(`Enrolling as peer with remote: ${enrollmentUrl}`);
|
||||||
|
console.log(` Common name: ${commonName}`);
|
||||||
|
|
||||||
|
// 2. Generate key pair and CSR via local gateway
|
||||||
|
console.log('Generating key pair and CSR...');
|
||||||
|
const keypairResult = await apiRequest<{ peerId: string; csrPem: string }>(
|
||||||
|
opts,
|
||||||
|
'POST',
|
||||||
|
'/api/admin/federation/peers/keypair',
|
||||||
|
{ commonName, displayName: hostname },
|
||||||
|
);
|
||||||
|
|
||||||
|
const { peerId, csrPem } = keypairResult;
|
||||||
|
console.log(` Peer ID: ${peerId}`);
|
||||||
|
|
||||||
|
// 3. Submit CSR to remote enrollment endpoint
|
||||||
|
console.log('Submitting CSR to remote enrollment endpoint...');
|
||||||
|
const remoteRes = await fetch(enrollmentUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ csrPem }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!remoteRes.ok) {
|
||||||
|
const errText = await remoteRes.text();
|
||||||
|
throw new Error(`Remote enrollment failed (${remoteRes.status.toString()}): ${errText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const remoteResult = (await remoteRes.json()) as { certPem: string; certChainPem: string };
|
||||||
|
|
||||||
|
if (!remoteResult.certPem) {
|
||||||
|
throw new Error('Remote enrollment response missing certPem');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Store the signed certificate in the local gateway
|
||||||
|
console.log('Storing signed certificate...');
|
||||||
|
await apiRequest<Record<string, unknown>>(
|
||||||
|
opts,
|
||||||
|
'PATCH',
|
||||||
|
`/api/admin/federation/peers/${peerId}/cert`,
|
||||||
|
{ certPem: remoteResult.certPem },
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(`\nPeer enrolled successfully.`);
|
||||||
|
console.log(` ID: ${peerId}`);
|
||||||
|
console.log(` State: active`);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err instanceof Error ? err.message : String(err));
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@
|
|||||||
"url": "https://git.mosaicstack.dev/mosaicstack/stack.git",
|
"url": "https://git.mosaicstack.dev/mosaicstack/stack.git",
|
||||||
"directory": "packages/storage"
|
"directory": "packages/storage"
|
||||||
},
|
},
|
||||||
|
"type": "module",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
"types": "dist/index.d.ts",
|
"types": "dist/index.d.ts",
|
||||||
"exports": {
|
"exports": {
|
||||||
@@ -29,6 +30,7 @@
|
|||||||
"postgres": "^3.4.8"
|
"postgres": "^3.4.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"drizzle-orm": "^0.45.1",
|
||||||
"typescript": "^5.8.0",
|
"typescript": "^5.8.0",
|
||||||
"vitest": "^2.0.0"
|
"vitest": "^2.0.0"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { Command } from 'commander';
|
import type { Command } from 'commander';
|
||||||
import type { MigrationSource } from './migrate-tier.js';
|
import type { MigrationSource } from './migrate-tier.js';
|
||||||
|
import { redactErrMsg } from './redact-error.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reads the DATABASE_URL environment variable and redacts the password portion.
|
* Reads the DATABASE_URL environment variable and redacts the password portion.
|
||||||
@@ -73,7 +74,7 @@ export function registerStorageCommand(parent: Command): void {
|
|||||||
console.log('[storage] reachable: yes');
|
console.log('[storage] reachable: yes');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.log(
|
console.log(
|
||||||
`[storage] reachable: no (${err instanceof Error ? err.message : String(err)})`,
|
`[storage] reachable: no (${redactErrMsg(err instanceof Error ? err.message : String(err))})`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -398,7 +399,7 @@ export function registerStorageCommand(parent: Command): void {
|
|||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(
|
console.error(
|
||||||
`[migrate-tier] ERROR: ${err instanceof Error ? err.message : String(err)}`,
|
`[migrate-tier] ERROR: ${redactErrMsg(err instanceof Error ? err.message : String(err))}`,
|
||||||
);
|
);
|
||||||
process.exitCode = 1;
|
process.exitCode = 1;
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
324
packages/storage/src/migrate-tier.integration.test.ts
Normal file
324
packages/storage/src/migrate-tier.integration.test.ts
Normal file
@@ -0,0 +1,324 @@
|
|||||||
|
/**
|
||||||
|
* FED-M1-08 — Integration test: PGlite → federated Postgres+pgvector migration.
|
||||||
|
*
|
||||||
|
* Prereq: docker compose -f docker-compose.federated.yml --profile federated up -d
|
||||||
|
* Run: FEDERATED_INTEGRATION=1 pnpm --filter @mosaicstack/storage test src/migrate-tier.integration.test.ts
|
||||||
|
*
|
||||||
|
* Skipped when FEDERATED_INTEGRATION !== '1'.
|
||||||
|
*
|
||||||
|
* Strategy: users.id (TEXT PK) uses the recognisable prefix `fed-m1-08-` for
|
||||||
|
* easy cleanup. UUID-PKed tables (teams, conversations, messages, team_members)
|
||||||
|
* use deterministic valid UUIDs in the `f0000xxx-…` namespace. Cleanup is
|
||||||
|
* explicit DELETE by id — no full-table truncation.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import fs from 'node:fs/promises';
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
import { users, teams, teamMembers, conversations, messages } from '@mosaicstack/db';
|
||||||
|
import { createPgliteDbWithVector, runPgliteMigrations } from './test-utils/pglite-with-vector.js';
|
||||||
|
|
||||||
|
import postgres from 'postgres';
|
||||||
|
import { afterAll, describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { DrizzleMigrationSource, PostgresMigrationTarget, runMigrateTier } from './migrate-tier.js';
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Constants */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
const run = process.env['FEDERATED_INTEGRATION'] === '1';
|
||||||
|
|
||||||
|
const FEDERATED_PG_URL = 'postgresql://mosaic:mosaic@localhost:5433/mosaic';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deterministic IDs for the test's seed data.
|
||||||
|
*
|
||||||
|
* users.id is TEXT (any string) — we use a recognisable prefix for easy cleanup.
|
||||||
|
* All other tables use UUID primary keys — must be valid UUID v4 format.
|
||||||
|
* The 4th segment starts with '4' (version 4) and 5th starts with '8' (variant).
|
||||||
|
*/
|
||||||
|
const IDS = {
|
||||||
|
// text PK — can be any string
|
||||||
|
user1: 'fed-m1-08-user-1',
|
||||||
|
user2: 'fed-m1-08-user-2',
|
||||||
|
// UUID PKs — must be valid UUID format
|
||||||
|
team1: 'f0000001-0000-4000-8000-000000000001',
|
||||||
|
teamMember1: 'f0000002-0000-4000-8000-000000000001',
|
||||||
|
teamMember2: 'f0000002-0000-4000-8000-000000000002',
|
||||||
|
conv1: 'f0000003-0000-4000-8000-000000000001',
|
||||||
|
conv2: 'f0000003-0000-4000-8000-000000000002',
|
||||||
|
msg1: 'f0000004-0000-4000-8000-000000000001',
|
||||||
|
msg2: 'f0000004-0000-4000-8000-000000000002',
|
||||||
|
msg3: 'f0000004-0000-4000-8000-000000000003',
|
||||||
|
msg4: 'f0000004-0000-4000-8000-000000000004',
|
||||||
|
msg5: 'f0000004-0000-4000-8000-000000000005',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Shared handles for afterAll cleanup */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
let targetSql: ReturnType<typeof postgres> | undefined;
|
||||||
|
let pgliteDataDir: string | undefined;
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
if (targetSql) {
|
||||||
|
await cleanTarget(targetSql).catch(() => {});
|
||||||
|
await targetSql.end({ timeout: 5 }).catch(() => {});
|
||||||
|
}
|
||||||
|
if (pgliteDataDir) {
|
||||||
|
await fs.rm(pgliteDataDir, { recursive: true, force: true }).catch(() => {});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Helpers */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
/** Delete all test-owned rows from target in safe FK order. */
|
||||||
|
async function cleanTarget(sql: ReturnType<typeof postgres>): Promise<void> {
|
||||||
|
// Reverse FK order: messages → conversations → team_members → teams → users
|
||||||
|
await sql.unsafe(`DELETE FROM messages WHERE id = ANY($1)`, [
|
||||||
|
[IDS.msg1, IDS.msg2, IDS.msg3, IDS.msg4, IDS.msg5],
|
||||||
|
] as never[]);
|
||||||
|
await sql.unsafe(`DELETE FROM conversations WHERE id = ANY($1)`, [
|
||||||
|
[IDS.conv1, IDS.conv2],
|
||||||
|
] as never[]);
|
||||||
|
await sql.unsafe(`DELETE FROM team_members WHERE id = ANY($1)`, [
|
||||||
|
[IDS.teamMember1, IDS.teamMember2],
|
||||||
|
] as never[]);
|
||||||
|
await sql.unsafe(`DELETE FROM teams WHERE id = $1`, [IDS.team1] as never[]);
|
||||||
|
await sql.unsafe(`DELETE FROM users WHERE id = ANY($1)`, [[IDS.user1, IDS.user2]] as never[]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Test suite */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
describe.skipIf(!run)('migrate-tier — PGlite → federated PG', () => {
|
||||||
|
it('seeds PGlite, runs migrate-tier, asserts row counts and sample rows on target', async () => {
|
||||||
|
/* ---- 1. Create a temp PGlite db ---------------------------------- */
|
||||||
|
|
||||||
|
pgliteDataDir = await fs.mkdtemp(path.join(os.tmpdir(), 'fed-m1-08-'));
|
||||||
|
const handle = createPgliteDbWithVector(pgliteDataDir);
|
||||||
|
|
||||||
|
// Run Drizzle migrations against PGlite.
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
await runPgliteMigrations(handle.db as any);
|
||||||
|
|
||||||
|
/* ---- 2. Seed representative data --------------------------------- */
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const db = handle.db;
|
||||||
|
|
||||||
|
// users (2 rows)
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
await (db as any).insert(users).values([
|
||||||
|
{
|
||||||
|
id: IDS.user1,
|
||||||
|
name: 'Fed Test User One',
|
||||||
|
email: 'fed-m1-08-user1@test.invalid',
|
||||||
|
emailVerified: false,
|
||||||
|
role: 'member',
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: IDS.user2,
|
||||||
|
name: 'Fed Test User Two',
|
||||||
|
email: 'fed-m1-08-user2@test.invalid',
|
||||||
|
emailVerified: false,
|
||||||
|
role: 'member',
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
// teams (1 row)
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
await (db as any).insert(teams).values([
|
||||||
|
{
|
||||||
|
id: IDS.team1,
|
||||||
|
name: 'Fed M1-08 Team',
|
||||||
|
slug: 'fed-m1-08-team',
|
||||||
|
ownerId: IDS.user1,
|
||||||
|
managerId: IDS.user1,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
// team_members (2 rows linking both users to the team)
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
await (db as any).insert(teamMembers).values([
|
||||||
|
{
|
||||||
|
id: IDS.teamMember1,
|
||||||
|
teamId: IDS.team1,
|
||||||
|
userId: IDS.user1,
|
||||||
|
role: 'manager',
|
||||||
|
joinedAt: now,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: IDS.teamMember2,
|
||||||
|
teamId: IDS.team1,
|
||||||
|
userId: IDS.user2,
|
||||||
|
role: 'member',
|
||||||
|
joinedAt: now,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
// conversations (2 rows)
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
await (db as any).insert(conversations).values([
|
||||||
|
{
|
||||||
|
id: IDS.conv1,
|
||||||
|
title: 'Fed M1-08 Conversation Alpha',
|
||||||
|
userId: IDS.user1,
|
||||||
|
archived: false,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: IDS.conv2,
|
||||||
|
title: 'Fed M1-08 Conversation Beta',
|
||||||
|
userId: IDS.user2,
|
||||||
|
archived: false,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
// messages (5 rows across both conversations)
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
await (db as any).insert(messages).values([
|
||||||
|
{
|
||||||
|
id: IDS.msg1,
|
||||||
|
conversationId: IDS.conv1,
|
||||||
|
role: 'user',
|
||||||
|
content: 'Hello from conv1 msg1',
|
||||||
|
createdAt: now,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: IDS.msg2,
|
||||||
|
conversationId: IDS.conv1,
|
||||||
|
role: 'assistant',
|
||||||
|
content: 'Reply in conv1 msg2',
|
||||||
|
createdAt: now,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: IDS.msg3,
|
||||||
|
conversationId: IDS.conv1,
|
||||||
|
role: 'user',
|
||||||
|
content: 'Follow-up in conv1 msg3',
|
||||||
|
createdAt: now,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: IDS.msg4,
|
||||||
|
conversationId: IDS.conv2,
|
||||||
|
role: 'user',
|
||||||
|
content: 'Hello from conv2 msg4',
|
||||||
|
createdAt: now,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: IDS.msg5,
|
||||||
|
conversationId: IDS.conv2,
|
||||||
|
role: 'assistant',
|
||||||
|
content: 'Reply in conv2 msg5',
|
||||||
|
createdAt: now,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
/* ---- 3. Pre-clean the target so the test is repeatable ----------- */
|
||||||
|
|
||||||
|
targetSql = postgres(FEDERATED_PG_URL, {
|
||||||
|
max: 3,
|
||||||
|
connect_timeout: 10,
|
||||||
|
idle_timeout: 30,
|
||||||
|
});
|
||||||
|
|
||||||
|
await cleanTarget(targetSql);
|
||||||
|
|
||||||
|
/* ---- 4. Build source / target adapters and run migration --------- */
|
||||||
|
|
||||||
|
const source = new DrizzleMigrationSource(db, /* sourceHasVector= */ false);
|
||||||
|
const target = new PostgresMigrationTarget(FEDERATED_PG_URL);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await runMigrateTier(
|
||||||
|
source,
|
||||||
|
target,
|
||||||
|
{
|
||||||
|
targetUrl: FEDERATED_PG_URL,
|
||||||
|
dryRun: false,
|
||||||
|
allowNonEmpty: true,
|
||||||
|
batchSize: 500,
|
||||||
|
onProgress: (_msg) => {
|
||||||
|
// Uncomment for debugging: console.log(_msg);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
/* sourceHasVector= */ false,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await target.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- 5. Assert: row counts in target match seed ------------------ */
|
||||||
|
|
||||||
|
const countUsers = await targetSql.unsafe<Array<{ n: string }>>(
|
||||||
|
`SELECT COUNT(*)::text AS n FROM users WHERE id = ANY($1)`,
|
||||||
|
[[IDS.user1, IDS.user2]] as never[],
|
||||||
|
);
|
||||||
|
expect(Number(countUsers[0]?.n)).toBe(2);
|
||||||
|
|
||||||
|
const countTeams = await targetSql.unsafe<Array<{ n: string }>>(
|
||||||
|
`SELECT COUNT(*)::text AS n FROM teams WHERE id = $1`,
|
||||||
|
[IDS.team1] as never[],
|
||||||
|
);
|
||||||
|
expect(Number(countTeams[0]?.n)).toBe(1);
|
||||||
|
|
||||||
|
const countTeamMembers = await targetSql.unsafe<Array<{ n: string }>>(
|
||||||
|
`SELECT COUNT(*)::text AS n FROM team_members WHERE id = ANY($1)`,
|
||||||
|
[[IDS.teamMember1, IDS.teamMember2]] as never[],
|
||||||
|
);
|
||||||
|
expect(Number(countTeamMembers[0]?.n)).toBe(2);
|
||||||
|
|
||||||
|
const countConvs = await targetSql.unsafe<Array<{ n: string }>>(
|
||||||
|
`SELECT COUNT(*)::text AS n FROM conversations WHERE id = ANY($1)`,
|
||||||
|
[[IDS.conv1, IDS.conv2]] as never[],
|
||||||
|
);
|
||||||
|
expect(Number(countConvs[0]?.n)).toBe(2);
|
||||||
|
|
||||||
|
const countMsgs = await targetSql.unsafe<Array<{ n: string }>>(
|
||||||
|
`SELECT COUNT(*)::text AS n FROM messages WHERE id = ANY($1)`,
|
||||||
|
[[IDS.msg1, IDS.msg2, IDS.msg3, IDS.msg4, IDS.msg5]] as never[],
|
||||||
|
);
|
||||||
|
expect(Number(countMsgs[0]?.n)).toBe(5);
|
||||||
|
|
||||||
|
/* ---- 6. Assert: sample row field values --------------------------- */
|
||||||
|
|
||||||
|
// User 1: check email and name
|
||||||
|
const userRows = await targetSql.unsafe<Array<{ id: string; email: string; name: string }>>(
|
||||||
|
`SELECT id, email, name FROM users WHERE id = $1`,
|
||||||
|
[IDS.user1] as never[],
|
||||||
|
);
|
||||||
|
expect(userRows[0]?.email).toBe('fed-m1-08-user1@test.invalid');
|
||||||
|
expect(userRows[0]?.name).toBe('Fed Test User One');
|
||||||
|
|
||||||
|
// Conversation 1: check title and user_id
|
||||||
|
const convRows = await targetSql.unsafe<Array<{ id: string; title: string; user_id: string }>>(
|
||||||
|
`SELECT id, title, user_id FROM conversations WHERE id = $1`,
|
||||||
|
[IDS.conv1] as never[],
|
||||||
|
);
|
||||||
|
expect(convRows[0]?.title).toBe('Fed M1-08 Conversation Alpha');
|
||||||
|
expect(convRows[0]?.user_id).toBe(IDS.user1);
|
||||||
|
|
||||||
|
/* ---- 7. Cleanup: delete test rows from target -------------------- */
|
||||||
|
|
||||||
|
await cleanTarget(targetSql);
|
||||||
|
|
||||||
|
// Close PGlite
|
||||||
|
await handle.close();
|
||||||
|
}, 60_000);
|
||||||
|
});
|
||||||
@@ -29,6 +29,7 @@
|
|||||||
import postgres from 'postgres';
|
import postgres from 'postgres';
|
||||||
import * as schema from '@mosaicstack/db';
|
import * as schema from '@mosaicstack/db';
|
||||||
import { sql as drizzleSql } from '@mosaicstack/db';
|
import { sql as drizzleSql } from '@mosaicstack/db';
|
||||||
|
import { redactErrMsg } from './redact-error.js';
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
/* Types */
|
/* Types */
|
||||||
@@ -72,6 +73,20 @@ export interface MigrationTarget {
|
|||||||
|
|
||||||
/** Close the target connection. */
|
/** Close the target connection. */
|
||||||
close(): Promise<void>;
|
close(): Promise<void>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attempt to acquire a session-level Postgres advisory lock for migrate-tier.
|
||||||
|
* Returns true if the lock was acquired, false if another process holds it.
|
||||||
|
* Targets that do not support advisory locks (e.g. test mocks) may omit this
|
||||||
|
* by not implementing the method — the caller skips locking gracefully.
|
||||||
|
*/
|
||||||
|
tryAcquireAdvisoryLock?(): Promise<boolean>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Release the session-level advisory lock acquired by tryAcquireAdvisoryLock.
|
||||||
|
* Must be called in a finally block.
|
||||||
|
*/
|
||||||
|
releaseAdvisoryLock?(): Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MigrateTierOptions {
|
export interface MigrateTierOptions {
|
||||||
@@ -107,9 +122,28 @@ export interface MigrateTierResult {
|
|||||||
/**
|
/**
|
||||||
* SKIP_TABLES: ephemeral or environment-specific tables not worth migrating.
|
* SKIP_TABLES: ephemeral or environment-specific tables not worth migrating.
|
||||||
*
|
*
|
||||||
* - sessions: TTL'd auth sessions — invalid in new environment.
|
* WHY these tables are skipped:
|
||||||
* - verifications: one-time tokens (email verify, etc.) — already expired.
|
* - sessions: TTL'd auth sessions — they are invalid in the new environment
|
||||||
* - admin_tokens: hashed tokens bound to old environment keys — re-issue.
|
* and would immediately expire or fail JWT verification anyway.
|
||||||
|
* - verifications: one-time tokens (email verify, password-reset links, etc.)
|
||||||
|
* — they have already expired or been consumed; re-sending is
|
||||||
|
* the correct action on the new environment.
|
||||||
|
* - admin_tokens: hashed tokens bound to the old environment's secret keys —
|
||||||
|
* the hash is environment-specific and must be re-issued on
|
||||||
|
* the target.
|
||||||
|
*
|
||||||
|
* WHY these tables are NOT skipped (intentionally migrated):
|
||||||
|
* - accounts (OAuth tokens): durable credentials bound to the user's identity,
|
||||||
|
* not to the deployment environment. OAuth tokens survive environment changes
|
||||||
|
* and should follow the user to the federated tier.
|
||||||
|
* - provider_credentials (AI provider keys): durable, user-owned API keys for
|
||||||
|
* AI providers (e.g. OpenAI, Anthropic). These are bound to the user, not
|
||||||
|
* the server, and must be preserved so AI features work immediately after
|
||||||
|
* migration.
|
||||||
|
*
|
||||||
|
* OPERATOR NOTE: If migrating to a shared or multi-tenant federated tier, review
|
||||||
|
* whether `accounts` and `provider_credentials` should be wiped post-migration
|
||||||
|
* to prevent unintended cross-tenant credential exposure.
|
||||||
*/
|
*/
|
||||||
export const SKIP_TABLES = new Set(['sessions', 'verifications', 'admin_tokens']);
|
export const SKIP_TABLES = new Set(['sessions', 'verifications', 'admin_tokens']);
|
||||||
|
|
||||||
@@ -482,6 +516,33 @@ export class PostgresMigrationTarget implements MigrationTarget {
|
|||||||
return rows.length > 0;
|
return rows.length > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attempt to acquire a non-blocking session-level Postgres advisory lock
|
||||||
|
* keyed by hashtext('mosaic-migrate-tier'). Returns true if acquired,
|
||||||
|
* false if another session already holds the lock.
|
||||||
|
*
|
||||||
|
* The lock is session-scoped: it is automatically released when the
|
||||||
|
* connection closes, and also explicitly released via releaseAdvisoryLock().
|
||||||
|
*/
|
||||||
|
async tryAcquireAdvisoryLock(): Promise<boolean> {
|
||||||
|
const rows = await this.sql`
|
||||||
|
SELECT pg_try_advisory_lock(hashtext('mosaic-migrate-tier')) AS acquired
|
||||||
|
`;
|
||||||
|
const row = rows[0] as { acquired: boolean } | undefined;
|
||||||
|
return row?.acquired ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Release the session-level advisory lock previously acquired by
|
||||||
|
* tryAcquireAdvisoryLock(). Safe to call even if the lock was not held
|
||||||
|
* (pg_advisory_unlock returns false but does not throw).
|
||||||
|
*/
|
||||||
|
async releaseAdvisoryLock(): Promise<void> {
|
||||||
|
await this.sql`
|
||||||
|
SELECT pg_advisory_unlock(hashtext('mosaic-migrate-tier'))
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
async close(): Promise<void> {
|
async close(): Promise<void> {
|
||||||
await this.sql.end();
|
await this.sql.end();
|
||||||
}
|
}
|
||||||
@@ -491,11 +552,24 @@ export class PostgresMigrationTarget implements MigrationTarget {
|
|||||||
/* Source-row normalisation */
|
/* Source-row normalisation */
|
||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert a camelCase key to snake_case.
|
||||||
|
* e.g. "userId" → "user_id", "emailVerified" → "email_verified".
|
||||||
|
* Keys that are already snake_case (no uppercase letters) are returned as-is.
|
||||||
|
*/
|
||||||
|
function toSnakeCase(key: string): string {
|
||||||
|
return key.replace(/[A-Z]/g, (c) => `_${c.toLowerCase()}`);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Drizzle returns rows as camelCase TypeScript objects (e.g. `userId`, not
|
* Drizzle returns rows as camelCase TypeScript objects (e.g. `userId`, not
|
||||||
* `user_id`). The PostgresMigrationTarget upserts via raw SQL and uses the
|
* `user_id`). The PostgresMigrationTarget upserts via raw SQL and uses the
|
||||||
* column names as given — the `insights` no-vector path uses snake_case column
|
* column names as given. We must convert camelCase keys → snake_case before
|
||||||
* aliases in the SELECT, so those rows already arrive as snake_case.
|
* building the INSERT statement so column names match the PG schema.
|
||||||
|
*
|
||||||
|
* Exception: the `insights` no-vector path already returns snake_case keys
|
||||||
|
* from its raw SQL projection — toSnakeCase() is idempotent for already-
|
||||||
|
* snake_case keys so this conversion is safe in all paths.
|
||||||
*
|
*
|
||||||
* For vector tables (insights), if `embedding` is absent from the source row
|
* For vector tables (insights), if `embedding` is absent from the source row
|
||||||
* (because DrizzleMigrationSource omitted it in the no-vector projection), we
|
* (because DrizzleMigrationSource omitted it in the no-vector projection), we
|
||||||
@@ -509,7 +583,11 @@ export function normaliseSourceRow(
|
|||||||
row: Record<string, unknown>,
|
row: Record<string, unknown>,
|
||||||
sourceHasVector: boolean,
|
sourceHasVector: boolean,
|
||||||
): Record<string, unknown> {
|
): Record<string, unknown> {
|
||||||
const out = { ...row };
|
// Convert all camelCase keys to snake_case for raw-SQL target compatibility.
|
||||||
|
const out: Record<string, unknown> = {};
|
||||||
|
for (const [k, v] of Object.entries(row)) {
|
||||||
|
out[toSnakeCase(k)] = v;
|
||||||
|
}
|
||||||
|
|
||||||
if (VECTOR_TABLES.has(tableName) && !sourceHasVector) {
|
if (VECTOR_TABLES.has(tableName) && !sourceHasVector) {
|
||||||
// Source cannot have embeddings — explicitly null them so ON CONFLICT
|
// Source cannot have embeddings — explicitly null them so ON CONFLICT
|
||||||
@@ -630,6 +708,24 @@ export async function runMigrateTier(
|
|||||||
return { tables, totalRows: 0, dryRun: true };
|
return { tables, totalRows: 0, dryRun: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Acquire a Postgres advisory lock on the target BEFORE checking preconditions
|
||||||
|
// so that two concurrent invocations cannot both pass the non-empty guard and
|
||||||
|
// race each other. Use non-blocking pg_try_advisory_lock so we fail fast
|
||||||
|
// instead of deadlocking.
|
||||||
|
//
|
||||||
|
// Targets that don't implement tryAcquireAdvisoryLock (e.g. test mocks) skip
|
||||||
|
// this step — the optional chaining guard handles that case.
|
||||||
|
const lockAcquired = target.tryAcquireAdvisoryLock ? await target.tryAcquireAdvisoryLock() : true; // mocks / test doubles — no locking needed
|
||||||
|
|
||||||
|
if (!lockAcquired) {
|
||||||
|
throw new Error(
|
||||||
|
'Another migrate-tier process is already running against this target. ' +
|
||||||
|
'Wait for it to complete or check for stuck locks via ' +
|
||||||
|
"SELECT * FROM pg_locks WHERE locktype='advisory'.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
// Check preconditions before writing.
|
// Check preconditions before writing.
|
||||||
await checkTargetPreconditions(target, allowNonEmpty, tablesToMigrate);
|
await checkTargetPreconditions(target, allowNonEmpty, tablesToMigrate);
|
||||||
|
|
||||||
@@ -669,7 +765,7 @@ export async function runMigrateTier(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const errMsg = err instanceof Error ? err.message : String(err);
|
const errMsg = redactErrMsg(err instanceof Error ? err.message : String(err));
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`[migrate-tier] Failed on table "${table}" after ${tableTotal.toString()} rows ` +
|
`[migrate-tier] Failed on table "${table}" after ${tableTotal.toString()} rows ` +
|
||||||
`(last id: ${lastSuccessfulId ?? 'none'}). Error: ${errMsg}\n` +
|
`(last id: ${lastSuccessfulId ?? 'none'}). Error: ${errMsg}\n` +
|
||||||
@@ -694,4 +790,10 @@ export async function runMigrateTier(
|
|||||||
|
|
||||||
onProgress(`[migrate-tier] Complete. ${totalRows.toString()} total rows migrated.`);
|
onProgress(`[migrate-tier] Complete. ${totalRows.toString()} total rows migrated.`);
|
||||||
return { tables: results, totalRows, dryRun: false };
|
return { tables: results, totalRows, dryRun: false };
|
||||||
|
} finally {
|
||||||
|
// Release the advisory lock regardless of success or failure.
|
||||||
|
if (target.releaseAdvisoryLock) {
|
||||||
|
await target.releaseAdvisoryLock();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
62
packages/storage/src/redact-error.test.ts
Normal file
62
packages/storage/src/redact-error.test.ts
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { redactErrMsg } from './redact-error.js';
|
||||||
|
|
||||||
|
describe('redactErrMsg', () => {
|
||||||
|
it('redacts user:password from a postgres:// URL embedded in an error message', () => {
|
||||||
|
const msg = 'connect ECONNREFUSED postgres://admin:s3cr3t@db.example.com:5432/mosaic';
|
||||||
|
expect(redactErrMsg(msg)).toBe(
|
||||||
|
'connect ECONNREFUSED postgres://***@db.example.com:5432/mosaic',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('redacts user:password from a postgresql:// URL', () => {
|
||||||
|
const msg = 'connection failed: postgresql://myuser:mypass@localhost:5432/testdb';
|
||||||
|
expect(redactErrMsg(msg)).toBe('connection failed: postgresql://***@localhost:5432/testdb');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles URLs with no password (user only) — still redacts userinfo', () => {
|
||||||
|
const msg = 'error postgres://justuser@host:5432/db';
|
||||||
|
expect(redactErrMsg(msg)).toBe('error postgres://***@host:5432/db');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns the original message unchanged when no connection URL is present', () => {
|
||||||
|
const msg = 'connection timed out after 5 seconds';
|
||||||
|
expect(redactErrMsg(msg)).toBe('connection timed out after 5 seconds');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is case-insensitive for the scheme (scheme is normalized to lowercase in output)', () => {
|
||||||
|
// The regex replacement uses a lowercase literal, so the matched scheme is
|
||||||
|
// replaced with the lowercase form regardless of the original casing.
|
||||||
|
const msg = 'POSTGRES://admin:pass@host:5432/db';
|
||||||
|
expect(redactErrMsg(msg)).toBe('postgres://***@host:5432/db');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('redacts multiple URLs in a single message', () => {
|
||||||
|
const msg = 'src postgres://u:p@host1/db1 dst postgresql://v:q@host2/db2';
|
||||||
|
expect(redactErrMsg(msg)).toBe('src postgres://***@host1/db1 dst postgresql://***@host2/db2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not alter a message with a postgres URL that has no userinfo', () => {
|
||||||
|
// No userinfo component — pattern does not match, message unchanged.
|
||||||
|
const msg = 'error at postgres://host:5432/db';
|
||||||
|
expect(redactErrMsg(msg)).toBe('error at postgres://host:5432/db');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('redacts user:password from a redis:// URL', () => {
|
||||||
|
const msg = 'connect ECONNREFUSED redis://user:pass@host:6379';
|
||||||
|
expect(redactErrMsg(msg)).toBe('connect ECONNREFUSED redis://***@host:6379');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('redacts user:password from a rediss:// URL (TLS)', () => {
|
||||||
|
const msg = 'connect ECONNREFUSED rediss://user:pass@host:6379';
|
||||||
|
expect(redactErrMsg(msg)).toBe('connect ECONNREFUSED rediss://***@host:6379');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('redacts both a postgres URL and a redis URL in the same message', () => {
|
||||||
|
const msg =
|
||||||
|
'primary postgres://admin:s3cr3t@db:5432/mosaic cache redis://cacheuser:cachepass@cache:6379';
|
||||||
|
expect(redactErrMsg(msg)).toBe(
|
||||||
|
'primary postgres://***@db:5432/mosaic cache redis://***@cache:6379',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
39
packages/storage/src/redact-error.ts
Normal file
39
packages/storage/src/redact-error.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
/**
|
||||||
|
* redact-error.ts — Internal credential-scrubbing helper.
|
||||||
|
*
|
||||||
|
* The `postgres` npm package can embed the full DSN (including the password)
|
||||||
|
* in connection-failure error messages. This module provides a single helper
|
||||||
|
* that strips the user:password portion from any such message before it is
|
||||||
|
* re-thrown, logged, or surfaced in a structured health report.
|
||||||
|
*
|
||||||
|
* This file is intentionally NOT re-exported from the package index — it is
|
||||||
|
* an internal utility for use within packages/storage/src only.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Redacts credentials from error messages that may include connection URLs.
|
||||||
|
* The `postgres` npm package can embed the full DSN in connection-failure
|
||||||
|
* messages, and ioredis can embed `redis://` / `rediss://` URLs similarly.
|
||||||
|
* This helper strips the user:password portion before display.
|
||||||
|
*
|
||||||
|
* Handles `postgres://`, `postgresql://`, `redis://`, and `rediss://`
|
||||||
|
* schemes (case-insensitive). Everything between `://` and `@` (the userinfo
|
||||||
|
* component) is replaced with `***` so that the host, port, and database name
|
||||||
|
* remain visible for diagnostics while the secret is never written to logs or
|
||||||
|
* CI output.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* redactErrMsg('connect ECONNREFUSED postgres://admin:s3cr3t@db:5432/mosaic')
|
||||||
|
* // → 'connect ECONNREFUSED postgres://***@db:5432/mosaic'
|
||||||
|
*
|
||||||
|
* redactErrMsg('connect ECONNREFUSED redis://user:pass@cache:6379')
|
||||||
|
* // → 'connect ECONNREFUSED redis://***@cache:6379'
|
||||||
|
*/
|
||||||
|
const CREDENTIAL_URL_RE = /(postgres(?:ql)?|rediss?):\/\/[^@\s]*@/gi;
|
||||||
|
|
||||||
|
export function redactErrMsg(msg: string): string {
|
||||||
|
return msg.replace(
|
||||||
|
CREDENTIAL_URL_RE,
|
||||||
|
(_match, scheme: string) => `${scheme.toLowerCase()}://***@`,
|
||||||
|
);
|
||||||
|
}
|
||||||
52
packages/storage/src/test-utils/pglite-with-vector.ts
Normal file
52
packages/storage/src/test-utils/pglite-with-vector.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
/**
|
||||||
|
* 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 });
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@
|
|||||||
|
|
||||||
import postgres from 'postgres';
|
import postgres from 'postgres';
|
||||||
import { Redis } from 'ioredis';
|
import { Redis } from 'ioredis';
|
||||||
|
import { redactErrMsg } from './redact-error.js';
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
/* Local structural type — avoids circular dependency */
|
/* Local structural type — avoids circular dependency */
|
||||||
@@ -159,7 +160,7 @@ async function probePostgresMeasured(url: string): Promise<ProbeResult> {
|
|||||||
port,
|
port,
|
||||||
durationMs: Date.now() - start,
|
durationMs: Date.now() - start,
|
||||||
error: {
|
error: {
|
||||||
message: cause instanceof Error ? cause.message : String(cause),
|
message: redactErrMsg(cause instanceof Error ? cause.message : String(cause)),
|
||||||
remediation:
|
remediation:
|
||||||
'Start Postgres: `docker compose -f docker-compose.federated.yml --profile federated up -d postgres-federated`',
|
'Start Postgres: `docker compose -f docker-compose.federated.yml --profile federated up -d postgres-federated`',
|
||||||
},
|
},
|
||||||
@@ -231,7 +232,10 @@ async function probePgvectorMeasured(url: string): Promise<ProbeResult> {
|
|||||||
host,
|
host,
|
||||||
port,
|
port,
|
||||||
durationMs: Date.now() - start,
|
durationMs: Date.now() - start,
|
||||||
error: { message: cause instanceof Error ? cause.message : String(cause), remediation },
|
error: {
|
||||||
|
message: redactErrMsg(cause instanceof Error ? cause.message : String(cause)),
|
||||||
|
remediation,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
} finally {
|
} finally {
|
||||||
if (sql) {
|
if (sql) {
|
||||||
@@ -299,7 +303,7 @@ async function probeValkeyMeasured(url: string): Promise<ProbeResult> {
|
|||||||
port,
|
port,
|
||||||
durationMs: Date.now() - start,
|
durationMs: Date.now() - start,
|
||||||
error: {
|
error: {
|
||||||
message: cause instanceof Error ? cause.message : String(cause),
|
message: redactErrMsg(cause instanceof Error ? cause.message : String(cause)),
|
||||||
remediation:
|
remediation:
|
||||||
'Start Valkey: `docker compose -f docker-compose.federated.yml --profile federated up -d valkey-federated`',
|
'Start Valkey: `docker compose -f docker-compose.federated.yml --profile federated up -d valkey-federated`',
|
||||||
},
|
},
|
||||||
|
|||||||
219
pnpm-lock.yaml
generated
219
pnpm-lock.yaml
generated
@@ -131,6 +131,9 @@ importers:
|
|||||||
'@opentelemetry/semantic-conventions':
|
'@opentelemetry/semantic-conventions':
|
||||||
specifier: ^1.40.0
|
specifier: ^1.40.0
|
||||||
version: 1.40.0
|
version: 1.40.0
|
||||||
|
'@peculiar/x509':
|
||||||
|
specifier: ^2.0.0
|
||||||
|
version: 2.0.0
|
||||||
'@sinclair/typebox':
|
'@sinclair/typebox':
|
||||||
specifier: ^0.34.48
|
specifier: ^0.34.48
|
||||||
version: 0.34.48
|
version: 0.34.48
|
||||||
@@ -155,6 +158,9 @@ importers:
|
|||||||
ioredis:
|
ioredis:
|
||||||
specifier: ^5.10.0
|
specifier: ^5.10.0
|
||||||
version: 5.10.0
|
version: 5.10.0
|
||||||
|
jose:
|
||||||
|
specifier: ^6.2.2
|
||||||
|
version: 6.2.2
|
||||||
node-cron:
|
node-cron:
|
||||||
specifier: ^4.2.1
|
specifier: ^4.2.1
|
||||||
version: 4.2.1
|
version: 4.2.1
|
||||||
@@ -655,6 +661,9 @@ importers:
|
|||||||
specifier: ^3.4.8
|
specifier: ^3.4.8
|
||||||
version: 3.4.8
|
version: 3.4.8
|
||||||
devDependencies:
|
devDependencies:
|
||||||
|
drizzle-orm:
|
||||||
|
specifier: ^0.45.1
|
||||||
|
version: 0.45.1(@electric-sql/pglite@0.2.17)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.15.6)(better-sqlite3@12.8.0)(kysely@0.28.11)(postgres@3.4.8)
|
||||||
typescript:
|
typescript:
|
||||||
specifier: ^5.8.0
|
specifier: ^5.8.0
|
||||||
version: 5.9.3
|
version: 5.9.3
|
||||||
@@ -3057,6 +3066,40 @@ packages:
|
|||||||
'@paralleldrive/cuid2@2.3.1':
|
'@paralleldrive/cuid2@2.3.1':
|
||||||
resolution: {integrity: sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==}
|
resolution: {integrity: sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==}
|
||||||
|
|
||||||
|
'@peculiar/asn1-cms@2.6.1':
|
||||||
|
resolution: {integrity: sha512-vdG4fBF6Lkirkcl53q6eOdn3XYKt+kJTG59edgRZORlg/3atWWEReRCx5rYE1ZzTTX6vLK5zDMjHh7vbrcXGtw==}
|
||||||
|
|
||||||
|
'@peculiar/asn1-csr@2.6.1':
|
||||||
|
resolution: {integrity: sha512-WRWnKfIocHyzFYQTka8O/tXCiBquAPSrRjXbOkHbO4qdmS6loffCEGs+rby6WxxGdJCuunnhS2duHURhjyio6w==}
|
||||||
|
|
||||||
|
'@peculiar/asn1-ecc@2.6.1':
|
||||||
|
resolution: {integrity: sha512-+Vqw8WFxrtDIN5ehUdvlN2m73exS2JVG0UAyfVB31gIfor3zWEAQPD+K9ydCxaj3MLen9k0JhKpu9LqviuCE1g==}
|
||||||
|
|
||||||
|
'@peculiar/asn1-pfx@2.6.1':
|
||||||
|
resolution: {integrity: sha512-nB5jVQy3MAAWvq0KY0R2JUZG8bO/bTLpnwyOzXyEh/e54ynGTatAR+csOnXkkVD9AFZ2uL8Z7EV918+qB1qDvw==}
|
||||||
|
|
||||||
|
'@peculiar/asn1-pkcs8@2.6.1':
|
||||||
|
resolution: {integrity: sha512-JB5iQ9Izn5yGMw3ZG4Nw3Xn/hb/G38GYF3lf7WmJb8JZUydhVGEjK/ZlFSWhnlB7K/4oqEs8HnfFIKklhR58Tw==}
|
||||||
|
|
||||||
|
'@peculiar/asn1-pkcs9@2.6.1':
|
||||||
|
resolution: {integrity: sha512-5EV8nZoMSxeWmcxWmmcolg22ojZRgJg+Y9MX2fnE2bGRo5KQLqV5IL9kdSQDZxlHz95tHvIq9F//bvL1OeNILw==}
|
||||||
|
|
||||||
|
'@peculiar/asn1-rsa@2.6.1':
|
||||||
|
resolution: {integrity: sha512-1nVMEh46SElUt5CB3RUTV4EG/z7iYc7EoaDY5ECwganibQPkZ/Y2eMsTKB/LeyrUJ+W/tKoD9WUqIy8vB+CEdA==}
|
||||||
|
|
||||||
|
'@peculiar/asn1-schema@2.6.0':
|
||||||
|
resolution: {integrity: sha512-xNLYLBFTBKkCzEZIw842BxytQQATQv+lDTCEMZ8C196iJcJJMBUZxrhSTxLaohMyKK8QlzRNTRkUmanucnDSqg==}
|
||||||
|
|
||||||
|
'@peculiar/asn1-x509-attr@2.6.1':
|
||||||
|
resolution: {integrity: sha512-tlW6cxoHwgcQghnJwv3YS+9OO1737zgPogZ+CgWRUK4roEwIPzRH4JEiG770xe5HX2ATfCpmX60gurfWIF9dcQ==}
|
||||||
|
|
||||||
|
'@peculiar/asn1-x509@2.6.1':
|
||||||
|
resolution: {integrity: sha512-O9jT5F1A2+t3r7C4VT7LYGXqkGLK7Kj1xFpz7U0isPrubwU5PbDoyYtx6MiGst29yq7pXN5vZbQFKRCP+lLZlA==}
|
||||||
|
|
||||||
|
'@peculiar/x509@2.0.0':
|
||||||
|
resolution: {integrity: sha512-r10lkuy6BNfRmyYdRAfgu6dq0HOmyIV2OLhXWE3gDEPBdX1b8miztJVyX/UxWhLwemNyDP3CLZHpDxDwSY0xaA==}
|
||||||
|
engines: {node: '>=20.0.0'}
|
||||||
|
|
||||||
'@pinojs/redact@0.4.0':
|
'@pinojs/redact@0.4.0':
|
||||||
resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==}
|
resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==}
|
||||||
|
|
||||||
@@ -3952,6 +3995,10 @@ packages:
|
|||||||
asap@2.0.6:
|
asap@2.0.6:
|
||||||
resolution: {integrity: sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==}
|
resolution: {integrity: sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==}
|
||||||
|
|
||||||
|
asn1js@3.0.10:
|
||||||
|
resolution: {integrity: sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg==}
|
||||||
|
engines: {node: '>=12.0.0'}
|
||||||
|
|
||||||
assertion-error@2.0.1:
|
assertion-error@2.0.1:
|
||||||
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
|
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
@@ -5323,6 +5370,9 @@ packages:
|
|||||||
jose@6.2.1:
|
jose@6.2.1:
|
||||||
resolution: {integrity: sha512-jUaKr1yrbfaImV7R2TN/b3IcZzsw38/chqMpo2XJ7i2F8AfM/lA4G1goC3JVEwg0H7UldTmSt3P68nt31W7/mw==}
|
resolution: {integrity: sha512-jUaKr1yrbfaImV7R2TN/b3IcZzsw38/chqMpo2XJ7i2F8AfM/lA4G1goC3JVEwg0H7UldTmSt3P68nt31W7/mw==}
|
||||||
|
|
||||||
|
jose@6.2.2:
|
||||||
|
resolution: {integrity: sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==}
|
||||||
|
|
||||||
js-tokens@4.0.0:
|
js-tokens@4.0.0:
|
||||||
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
|
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
|
||||||
|
|
||||||
@@ -6261,6 +6311,13 @@ packages:
|
|||||||
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
|
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
|
|
||||||
|
pvtsutils@1.3.6:
|
||||||
|
resolution: {integrity: sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==}
|
||||||
|
|
||||||
|
pvutils@1.1.5:
|
||||||
|
resolution: {integrity: sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA==}
|
||||||
|
engines: {node: '>=16.0.0'}
|
||||||
|
|
||||||
qrcode-terminal@0.12.0:
|
qrcode-terminal@0.12.0:
|
||||||
resolution: {integrity: sha512-EXtzRZmC+YGmGlDFbXKxQiMZNwCLEO6BANKXG4iCtSIM0yqc/pappSx3RIKr4r0uh5JsBckOXeKrB3Iz7mdQpQ==}
|
resolution: {integrity: sha512-EXtzRZmC+YGmGlDFbXKxQiMZNwCLEO6BANKXG4iCtSIM0yqc/pappSx3RIKr4r0uh5JsBckOXeKrB3Iz7mdQpQ==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
@@ -6829,6 +6886,9 @@ packages:
|
|||||||
ts-mixer@6.0.4:
|
ts-mixer@6.0.4:
|
||||||
resolution: {integrity: sha512-ufKpbmrugz5Aou4wcr5Wc1UUFWOLhq+Fm6qa6P0w0K5Qw2yhaUoiWszhCVuNQyNwrlGiscHOmqYoAox1PtvgjA==}
|
resolution: {integrity: sha512-ufKpbmrugz5Aou4wcr5Wc1UUFWOLhq+Fm6qa6P0w0K5Qw2yhaUoiWszhCVuNQyNwrlGiscHOmqYoAox1PtvgjA==}
|
||||||
|
|
||||||
|
tslib@1.14.1:
|
||||||
|
resolution: {integrity: sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==}
|
||||||
|
|
||||||
tslib@2.8.1:
|
tslib@2.8.1:
|
||||||
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
|
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
|
||||||
|
|
||||||
@@ -6841,6 +6901,10 @@ packages:
|
|||||||
engines: {node: '>=18.0.0'}
|
engines: {node: '>=18.0.0'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
tsyringe@4.10.0:
|
||||||
|
resolution: {integrity: sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw==}
|
||||||
|
engines: {node: '>= 6.0.0'}
|
||||||
|
|
||||||
tunnel-agent@0.6.0:
|
tunnel-agent@0.6.0:
|
||||||
resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==}
|
resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==}
|
||||||
|
|
||||||
@@ -7756,49 +7820,49 @@ snapshots:
|
|||||||
|
|
||||||
'@bcoe/v8-coverage@0.2.3': {}
|
'@bcoe/v8-coverage@0.2.3': {}
|
||||||
|
|
||||||
'@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.2.1)(kysely@0.28.11)(nanostores@1.1.1)':
|
'@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.2.2)(kysely@0.28.11)(nanostores@1.1.1)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@better-auth/utils': 0.3.1
|
'@better-auth/utils': 0.3.1
|
||||||
'@better-fetch/fetch': 1.1.21
|
'@better-fetch/fetch': 1.1.21
|
||||||
'@standard-schema/spec': 1.1.0
|
'@standard-schema/spec': 1.1.0
|
||||||
better-call: 1.3.2(zod@4.3.6)
|
better-call: 1.3.2(zod@4.3.6)
|
||||||
jose: 6.2.1
|
jose: 6.2.2
|
||||||
kysely: 0.28.11
|
kysely: 0.28.11
|
||||||
nanostores: 1.1.1
|
nanostores: 1.1.1
|
||||||
zod: 4.3.6
|
zod: 4.3.6
|
||||||
|
|
||||||
'@better-auth/drizzle-adapter@1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.2.1)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.1)(drizzle-orm@0.45.1(@electric-sql/pglite@0.2.17)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.15.6)(better-sqlite3@12.8.0)(kysely@0.28.11)(postgres@3.4.8))':
|
'@better-auth/drizzle-adapter@1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.2.2)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.1)(drizzle-orm@0.45.1(@electric-sql/pglite@0.2.17)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.15.6)(better-sqlite3@12.8.0)(kysely@0.28.11)(postgres@3.4.8))':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@better-auth/core': 1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.2.1)(kysely@0.28.11)(nanostores@1.1.1)
|
'@better-auth/core': 1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.2.2)(kysely@0.28.11)(nanostores@1.1.1)
|
||||||
'@better-auth/utils': 0.3.1
|
'@better-auth/utils': 0.3.1
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
drizzle-orm: 0.45.1(@electric-sql/pglite@0.2.17)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.15.6)(better-sqlite3@12.8.0)(kysely@0.28.11)(postgres@3.4.8)
|
drizzle-orm: 0.45.1(@electric-sql/pglite@0.2.17)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.15.6)(better-sqlite3@12.8.0)(kysely@0.28.11)(postgres@3.4.8)
|
||||||
|
|
||||||
'@better-auth/kysely-adapter@1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.2.1)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.1)(kysely@0.28.11)':
|
'@better-auth/kysely-adapter@1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.2.2)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.1)(kysely@0.28.11)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@better-auth/core': 1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.2.1)(kysely@0.28.11)(nanostores@1.1.1)
|
'@better-auth/core': 1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.2.2)(kysely@0.28.11)(nanostores@1.1.1)
|
||||||
'@better-auth/utils': 0.3.1
|
'@better-auth/utils': 0.3.1
|
||||||
kysely: 0.28.11
|
kysely: 0.28.11
|
||||||
|
|
||||||
'@better-auth/memory-adapter@1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.2.1)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.1)':
|
'@better-auth/memory-adapter@1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.2.2)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.1)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@better-auth/core': 1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.2.1)(kysely@0.28.11)(nanostores@1.1.1)
|
'@better-auth/core': 1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.2.2)(kysely@0.28.11)(nanostores@1.1.1)
|
||||||
'@better-auth/utils': 0.3.1
|
'@better-auth/utils': 0.3.1
|
||||||
|
|
||||||
'@better-auth/mongo-adapter@1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.2.1)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.1)(mongodb@7.1.0(socks@2.8.7))':
|
'@better-auth/mongo-adapter@1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.2.2)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.1)(mongodb@7.1.0(socks@2.8.7))':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@better-auth/core': 1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.2.1)(kysely@0.28.11)(nanostores@1.1.1)
|
'@better-auth/core': 1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.2.2)(kysely@0.28.11)(nanostores@1.1.1)
|
||||||
'@better-auth/utils': 0.3.1
|
'@better-auth/utils': 0.3.1
|
||||||
mongodb: 7.1.0(socks@2.8.7)
|
mongodb: 7.1.0(socks@2.8.7)
|
||||||
|
|
||||||
'@better-auth/prisma-adapter@1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.2.1)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.1)':
|
'@better-auth/prisma-adapter@1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.2.2)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.1)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@better-auth/core': 1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.2.1)(kysely@0.28.11)(nanostores@1.1.1)
|
'@better-auth/core': 1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.2.2)(kysely@0.28.11)(nanostores@1.1.1)
|
||||||
'@better-auth/utils': 0.3.1
|
'@better-auth/utils': 0.3.1
|
||||||
|
|
||||||
'@better-auth/telemetry@1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.2.1)(kysely@0.28.11)(nanostores@1.1.1))':
|
'@better-auth/telemetry@1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.2.2)(kysely@0.28.11)(nanostores@1.1.1))':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@better-auth/core': 1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.2.1)(kysely@0.28.11)(nanostores@1.1.1)
|
'@better-auth/core': 1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.2.2)(kysely@0.28.11)(nanostores@1.1.1)
|
||||||
'@better-auth/utils': 0.3.1
|
'@better-auth/utils': 0.3.1
|
||||||
'@better-fetch/fetch': 1.1.21
|
'@better-fetch/fetch': 1.1.21
|
||||||
|
|
||||||
@@ -8817,7 +8881,7 @@ snapshots:
|
|||||||
express: 5.2.1
|
express: 5.2.1
|
||||||
express-rate-limit: 8.3.1(express@5.2.1)
|
express-rate-limit: 8.3.1(express@5.2.1)
|
||||||
hono: 4.12.8
|
hono: 4.12.8
|
||||||
jose: 6.2.1
|
jose: 6.2.2
|
||||||
json-schema-typed: 8.0.2
|
json-schema-typed: 8.0.2
|
||||||
pkce-challenge: 5.0.1
|
pkce-challenge: 5.0.1
|
||||||
raw-body: 3.0.2
|
raw-body: 3.0.2
|
||||||
@@ -9947,6 +10011,95 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@noble/hashes': 1.8.0
|
'@noble/hashes': 1.8.0
|
||||||
|
|
||||||
|
'@peculiar/asn1-cms@2.6.1':
|
||||||
|
dependencies:
|
||||||
|
'@peculiar/asn1-schema': 2.6.0
|
||||||
|
'@peculiar/asn1-x509': 2.6.1
|
||||||
|
'@peculiar/asn1-x509-attr': 2.6.1
|
||||||
|
asn1js: 3.0.10
|
||||||
|
tslib: 2.8.1
|
||||||
|
|
||||||
|
'@peculiar/asn1-csr@2.6.1':
|
||||||
|
dependencies:
|
||||||
|
'@peculiar/asn1-schema': 2.6.0
|
||||||
|
'@peculiar/asn1-x509': 2.6.1
|
||||||
|
asn1js: 3.0.10
|
||||||
|
tslib: 2.8.1
|
||||||
|
|
||||||
|
'@peculiar/asn1-ecc@2.6.1':
|
||||||
|
dependencies:
|
||||||
|
'@peculiar/asn1-schema': 2.6.0
|
||||||
|
'@peculiar/asn1-x509': 2.6.1
|
||||||
|
asn1js: 3.0.10
|
||||||
|
tslib: 2.8.1
|
||||||
|
|
||||||
|
'@peculiar/asn1-pfx@2.6.1':
|
||||||
|
dependencies:
|
||||||
|
'@peculiar/asn1-cms': 2.6.1
|
||||||
|
'@peculiar/asn1-pkcs8': 2.6.1
|
||||||
|
'@peculiar/asn1-rsa': 2.6.1
|
||||||
|
'@peculiar/asn1-schema': 2.6.0
|
||||||
|
asn1js: 3.0.10
|
||||||
|
tslib: 2.8.1
|
||||||
|
|
||||||
|
'@peculiar/asn1-pkcs8@2.6.1':
|
||||||
|
dependencies:
|
||||||
|
'@peculiar/asn1-schema': 2.6.0
|
||||||
|
'@peculiar/asn1-x509': 2.6.1
|
||||||
|
asn1js: 3.0.10
|
||||||
|
tslib: 2.8.1
|
||||||
|
|
||||||
|
'@peculiar/asn1-pkcs9@2.6.1':
|
||||||
|
dependencies:
|
||||||
|
'@peculiar/asn1-cms': 2.6.1
|
||||||
|
'@peculiar/asn1-pfx': 2.6.1
|
||||||
|
'@peculiar/asn1-pkcs8': 2.6.1
|
||||||
|
'@peculiar/asn1-schema': 2.6.0
|
||||||
|
'@peculiar/asn1-x509': 2.6.1
|
||||||
|
'@peculiar/asn1-x509-attr': 2.6.1
|
||||||
|
asn1js: 3.0.10
|
||||||
|
tslib: 2.8.1
|
||||||
|
|
||||||
|
'@peculiar/asn1-rsa@2.6.1':
|
||||||
|
dependencies:
|
||||||
|
'@peculiar/asn1-schema': 2.6.0
|
||||||
|
'@peculiar/asn1-x509': 2.6.1
|
||||||
|
asn1js: 3.0.10
|
||||||
|
tslib: 2.8.1
|
||||||
|
|
||||||
|
'@peculiar/asn1-schema@2.6.0':
|
||||||
|
dependencies:
|
||||||
|
asn1js: 3.0.10
|
||||||
|
pvtsutils: 1.3.6
|
||||||
|
tslib: 2.8.1
|
||||||
|
|
||||||
|
'@peculiar/asn1-x509-attr@2.6.1':
|
||||||
|
dependencies:
|
||||||
|
'@peculiar/asn1-schema': 2.6.0
|
||||||
|
'@peculiar/asn1-x509': 2.6.1
|
||||||
|
asn1js: 3.0.10
|
||||||
|
tslib: 2.8.1
|
||||||
|
|
||||||
|
'@peculiar/asn1-x509@2.6.1':
|
||||||
|
dependencies:
|
||||||
|
'@peculiar/asn1-schema': 2.6.0
|
||||||
|
asn1js: 3.0.10
|
||||||
|
pvtsutils: 1.3.6
|
||||||
|
tslib: 2.8.1
|
||||||
|
|
||||||
|
'@peculiar/x509@2.0.0':
|
||||||
|
dependencies:
|
||||||
|
'@peculiar/asn1-cms': 2.6.1
|
||||||
|
'@peculiar/asn1-csr': 2.6.1
|
||||||
|
'@peculiar/asn1-ecc': 2.6.1
|
||||||
|
'@peculiar/asn1-pkcs9': 2.6.1
|
||||||
|
'@peculiar/asn1-rsa': 2.6.1
|
||||||
|
'@peculiar/asn1-schema': 2.6.0
|
||||||
|
'@peculiar/asn1-x509': 2.6.1
|
||||||
|
pvtsutils: 1.3.6
|
||||||
|
tslib: 2.8.1
|
||||||
|
tsyringe: 4.10.0
|
||||||
|
|
||||||
'@pinojs/redact@0.4.0': {}
|
'@pinojs/redact@0.4.0': {}
|
||||||
|
|
||||||
'@pkgjs/parseargs@0.11.0':
|
'@pkgjs/parseargs@0.11.0':
|
||||||
@@ -10903,6 +11056,12 @@ snapshots:
|
|||||||
|
|
||||||
asap@2.0.6: {}
|
asap@2.0.6: {}
|
||||||
|
|
||||||
|
asn1js@3.0.10:
|
||||||
|
dependencies:
|
||||||
|
pvtsutils: 1.3.6
|
||||||
|
pvutils: 1.1.5
|
||||||
|
tslib: 2.8.1
|
||||||
|
|
||||||
assertion-error@2.0.1: {}
|
assertion-error@2.0.1: {}
|
||||||
|
|
||||||
ast-types@0.13.4:
|
ast-types@0.13.4:
|
||||||
@@ -10947,20 +11106,20 @@ snapshots:
|
|||||||
|
|
||||||
better-auth@1.5.5(better-sqlite3@12.8.0)(drizzle-kit@0.31.9)(drizzle-orm@0.45.1(@electric-sql/pglite@0.2.17)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.15.6)(better-sqlite3@12.8.0)(kysely@0.28.11)(postgres@3.4.8))(mongodb@7.1.0(socks@2.8.7))(next@16.1.6(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@2.1.9(@types/node@22.19.15)(jsdom@29.0.0(@noble/hashes@2.0.1))(lightningcss@1.31.1)):
|
better-auth@1.5.5(better-sqlite3@12.8.0)(drizzle-kit@0.31.9)(drizzle-orm@0.45.1(@electric-sql/pglite@0.2.17)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.15.6)(better-sqlite3@12.8.0)(kysely@0.28.11)(postgres@3.4.8))(mongodb@7.1.0(socks@2.8.7))(next@16.1.6(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@2.1.9(@types/node@22.19.15)(jsdom@29.0.0(@noble/hashes@2.0.1))(lightningcss@1.31.1)):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@better-auth/core': 1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.2.1)(kysely@0.28.11)(nanostores@1.1.1)
|
'@better-auth/core': 1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.2.2)(kysely@0.28.11)(nanostores@1.1.1)
|
||||||
'@better-auth/drizzle-adapter': 1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.2.1)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.1)(drizzle-orm@0.45.1(@electric-sql/pglite@0.2.17)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.15.6)(better-sqlite3@12.8.0)(kysely@0.28.11)(postgres@3.4.8))
|
'@better-auth/drizzle-adapter': 1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.2.2)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.1)(drizzle-orm@0.45.1(@electric-sql/pglite@0.2.17)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.15.6)(better-sqlite3@12.8.0)(kysely@0.28.11)(postgres@3.4.8))
|
||||||
'@better-auth/kysely-adapter': 1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.2.1)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.1)(kysely@0.28.11)
|
'@better-auth/kysely-adapter': 1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.2.2)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.1)(kysely@0.28.11)
|
||||||
'@better-auth/memory-adapter': 1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.2.1)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.1)
|
'@better-auth/memory-adapter': 1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.2.2)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.1)
|
||||||
'@better-auth/mongo-adapter': 1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.2.1)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.1)(mongodb@7.1.0(socks@2.8.7))
|
'@better-auth/mongo-adapter': 1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.2.2)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.1)(mongodb@7.1.0(socks@2.8.7))
|
||||||
'@better-auth/prisma-adapter': 1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.2.1)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.1)
|
'@better-auth/prisma-adapter': 1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.2.2)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.1)
|
||||||
'@better-auth/telemetry': 1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.2.1)(kysely@0.28.11)(nanostores@1.1.1))
|
'@better-auth/telemetry': 1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.2.2)(kysely@0.28.11)(nanostores@1.1.1))
|
||||||
'@better-auth/utils': 0.3.1
|
'@better-auth/utils': 0.3.1
|
||||||
'@better-fetch/fetch': 1.1.21
|
'@better-fetch/fetch': 1.1.21
|
||||||
'@noble/ciphers': 2.1.1
|
'@noble/ciphers': 2.1.1
|
||||||
'@noble/hashes': 2.0.1
|
'@noble/hashes': 2.0.1
|
||||||
better-call: 1.3.2(zod@4.3.6)
|
better-call: 1.3.2(zod@4.3.6)
|
||||||
defu: 6.1.4
|
defu: 6.1.4
|
||||||
jose: 6.2.1
|
jose: 6.2.2
|
||||||
kysely: 0.28.11
|
kysely: 0.28.11
|
||||||
nanostores: 1.1.1
|
nanostores: 1.1.1
|
||||||
zod: 4.3.6
|
zod: 4.3.6
|
||||||
@@ -12403,6 +12562,8 @@ snapshots:
|
|||||||
|
|
||||||
jose@6.2.1: {}
|
jose@6.2.1: {}
|
||||||
|
|
||||||
|
jose@6.2.2: {}
|
||||||
|
|
||||||
js-tokens@4.0.0: {}
|
js-tokens@4.0.0: {}
|
||||||
|
|
||||||
js-yaml@4.1.1:
|
js-yaml@4.1.1:
|
||||||
@@ -13498,6 +13659,12 @@ snapshots:
|
|||||||
|
|
||||||
punycode@2.3.1: {}
|
punycode@2.3.1: {}
|
||||||
|
|
||||||
|
pvtsutils@1.3.6:
|
||||||
|
dependencies:
|
||||||
|
tslib: 2.8.1
|
||||||
|
|
||||||
|
pvutils@1.1.5: {}
|
||||||
|
|
||||||
qrcode-terminal@0.12.0: {}
|
qrcode-terminal@0.12.0: {}
|
||||||
|
|
||||||
qs@6.15.0:
|
qs@6.15.0:
|
||||||
@@ -14181,6 +14348,8 @@ snapshots:
|
|||||||
|
|
||||||
ts-mixer@6.0.4: {}
|
ts-mixer@6.0.4: {}
|
||||||
|
|
||||||
|
tslib@1.14.1: {}
|
||||||
|
|
||||||
tslib@2.8.1: {}
|
tslib@2.8.1: {}
|
||||||
|
|
||||||
tslog@4.10.2: {}
|
tslog@4.10.2: {}
|
||||||
@@ -14192,6 +14361,10 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
fsevents: 2.3.3
|
fsevents: 2.3.3
|
||||||
|
|
||||||
|
tsyringe@4.10.0:
|
||||||
|
dependencies:
|
||||||
|
tslib: 1.14.1
|
||||||
|
|
||||||
tunnel-agent@0.6.0:
|
tunnel-agent@0.6.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
safe-buffer: 5.2.1
|
safe-buffer: 5.2.1
|
||||||
|
|||||||
Reference in New Issue
Block a user