Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 65 additions & 8 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# CI: install, lint, format check, build, unit tests, e2e (Playwright)
# CI: install, lint, format check, build, unit tests, migrations + DB integration tests, e2e (Playwright)
# Uses latest action versions; Node 24 + pnpm 10 per package.json engines.
name: CI

Expand Down Expand Up @@ -31,8 +31,8 @@ jobs:
- name: Set up Node.js
uses: actions/setup-node@v6
with:
node-version: "24"
cache: "pnpm"
node-version: '24'
cache: 'pnpm'

- name: Install dependencies
run: pnpm install --frozen-lockfile
Expand Down Expand Up @@ -73,8 +73,8 @@ jobs:
- name: Set up Node.js
uses: actions/setup-node@v6
with:
node-version: "24"
cache: "pnpm"
node-version: '24'
cache: 'pnpm'

- name: Install dependencies
run: pnpm install --frozen-lockfile
Expand All @@ -85,6 +85,63 @@ jobs:
- name: Run unit tests
run: pnpm run test

integration:
name: Migrations & DB integration tests
runs-on: ubuntu-latest
# postgres:17-alpine matches production (docker-compose.yml), so the migration chain is
# exercised against the same major version it runs on in prod.
services:
postgres:
image: postgres:17-alpine
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: bunker46
options: >-
--health-cmd "pg_isready -U postgres"
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 5432:5432
env:
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/bunker46
steps:
- name: Checkout
uses: actions/checkout@v6

- name: Install pnpm
uses: pnpm/action-setup@v4
with:
run_install: false

- name: Set up Node.js
uses: actions/setup-node@v6
with:
node-version: '24'
cache: 'pnpm'

- name: Install dependencies
run: pnpm install --frozen-lockfile

- name: Build workspace packages
run: pnpm --filter @bunker46/shared-types --filter @bunker46/config run build

# Applies every committed migration in order against an empty DB: proves the full chain
# (initial schema -> current) applies cleanly. Uses `migrate deploy`, NOT `db:push`, so it
# is the migration SQL that actually runs — db:push would bypass the migration files.
- name: Apply migrations from scratch (full chain)
run: pnpm --filter @bunker46/server run db:deploy

# Fails (exit code 2) if the migrated DB and schema.prisma disagree — catches a schema.prisma
# edit that was never captured in a migration.
- name: Check migrations match schema (no drift)
run: pnpm --filter @bunker46/server run db:check-drift

# Runs the RUN_DB_TESTS-gated integration specs against the migration-built schema.
- name: Run DB integration tests
run: pnpm --filter @bunker46/server run test:integration

e2e:
name: E2E (Playwright)
runs-on: ubuntu-latest
Expand Down Expand Up @@ -118,8 +175,8 @@ jobs:
- name: Set up Node.js
uses: actions/setup-node@v6
with:
node-version: "24"
cache: "pnpm"
node-version: '24'
cache: 'pnpm'

- name: Install dependencies
run: pnpm install --frozen-lockfile
Expand Down Expand Up @@ -156,4 +213,4 @@ jobs:
pnpm run e2e
env:
CI: true
E2E_USE_PREVIEW: "1"
E2E_USE_PREVIEW: '1'
3 changes: 3 additions & 0 deletions apps/server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,11 @@
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage",
"test:integration": "RUN_DB_TESTS=1 vitest run int.spec",
"db:generate": "prisma generate",
"db:migrate": "prisma migrate dev",
"db:deploy": "prisma migrate deploy",
"db:check-drift": "prisma migrate diff --from-config-datasource --to-schema prisma/schema.prisma --exit-code",
"db:baseline-legacy": "prisma db execute --file prisma/baseline-from-legacy.sql && prisma migrate resolve --applied 20260330100000_initial_schema",
"db:push": "prisma db push",
"db:seed": "tsx prisma/seed.ts",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
-- Preserve audit logs after a connection is deleted.
--
-- Previously signing_logs.connection_id was NOT NULL with ON DELETE CASCADE, so deleting a
-- connection wiped its entire signing history. We now decouple the log from the connection
-- lifecycle: the FK becomes nullable + ON DELETE SET NULL, and the owning user, connection name
-- and client pubkey are denormalized onto each log so an orphaned record stays scoped to its
-- user and remains readable. Deleting a *user* still purges their logs (new FK below cascades).

-- AlterTable: add denormalized columns (nullable first so existing rows can be backfilled)
ALTER TABLE "signing_logs"
ADD COLUMN "user_id" TEXT,
ADD COLUMN "connection_name" TEXT,
ADD COLUMN "client_pubkey" TEXT;

-- Backfill from the connections that still exist (every current log has a valid connection,
-- since the old FK was NOT NULL + CASCADE, so there are no orphans to leave behind).
UPDATE "signing_logs" sl
SET "user_id" = bc."user_id",
"connection_name" = bc."name",
"client_pubkey" = bc."client_pubkey"
FROM "bunker_connections" bc
WHERE sl."connection_id" = bc."id";

-- Enforce NOT NULL now that the columns are populated
ALTER TABLE "signing_logs"
ALTER COLUMN "user_id" SET NOT NULL,
ALTER COLUMN "connection_name" SET NOT NULL,
ALTER COLUMN "client_pubkey" SET NOT NULL;

-- Make connection_id nullable so ON DELETE SET NULL can apply
ALTER TABLE "signing_logs" ALTER COLUMN "connection_id" DROP NOT NULL;

-- Swap the connection FK from CASCADE to SET NULL
ALTER TABLE "signing_logs" DROP CONSTRAINT "signing_logs_connection_id_fkey";
ALTER TABLE "signing_logs" ADD CONSTRAINT "signing_logs_connection_id_fkey"
FOREIGN KEY ("connection_id") REFERENCES "bunker_connections"("id") ON DELETE SET NULL ON UPDATE CASCADE;

-- Add the user FK (CASCADE so deleting a user still purges their audit logs)
ALTER TABLE "signing_logs" ADD CONSTRAINT "signing_logs_user_id_fkey"
FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;

-- Index for user-scoped log queries
CREATE INDEX "signing_logs_user_id_idx" ON "signing_logs"("user_id");
31 changes: 20 additions & 11 deletions apps/server/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ model User {
passkeys Passkey[]
nsecKeys NsecKey[]
connections BunkerConnection[]
signingLogs SigningLog[]
relayConfigs RelayConfig[]

@@map("users")
Expand Down Expand Up @@ -131,19 +132,27 @@ model ConnectionPermission {
}

model SigningLog {
id String @id @default(cuid())
connectionId String @map("connection_id")
method String
eventKind Int? @map("event_kind")
result LogResult
durationMs Int @map("duration_ms")
errorMessage String? @map("error_message")
metadata Json?
createdAt DateTime @default(now()) @map("created_at")

connection BunkerConnection @relation(fields: [connectionId], references: [id], onDelete: Cascade)
id String @id @default(cuid())
// Nullable: when a connection is deleted the FK is set to null (ON DELETE SET NULL) so the
// audit record survives. The owning user, connection name and client pubkey below are
// denormalized at write time so an orphaned log stays scoped to its user and readable.
connectionId String? @map("connection_id")
userId String @map("user_id")
connectionName String @map("connection_name")
clientPubkey String @map("client_pubkey")
method String
eventKind Int? @map("event_kind")
result LogResult
durationMs Int @map("duration_ms")
errorMessage String? @map("error_message")
metadata Json?
createdAt DateTime @default(now()) @map("created_at")

connection BunkerConnection? @relation(fields: [connectionId], references: [id], onDelete: SetNull)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)

@@index([connectionId])
@@index([userId])
@@index([createdAt])
@@index([method])
@@map("signing_logs")
Expand Down
3 changes: 3 additions & 0 deletions apps/server/src/bunker/bunker-rpc.handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,9 @@ export class BunkerRpcHandler {

await this.loggingService.logSigningAction({
connectionId: connection.id,
userId: connection.userId,
connectionName: connection.name,
clientPubkey: connection.clientPubkey,
method: request.method,
eventKind,
result: error ? 'ERROR' : 'APPROVED',
Expand Down
57 changes: 57 additions & 0 deletions apps/server/src/logging/logging.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,13 +24,19 @@ describe('LoggingService', () => {
it('should create signing log entry', async () => {
await service.logSigningAction({
connectionId: 'conn-1',
userId: 'user-1',
connectionName: 'My App',
clientPubkey: 'a'.repeat(64),
method: 'sign_event',
result: 'APPROVED',
durationMs: 100,
});
expect(prisma.signingLog?.create).toHaveBeenCalledWith({
data: expect.objectContaining({
connectionId: 'conn-1',
userId: 'user-1',
connectionName: 'My App',
clientPubkey: 'a'.repeat(64),
method: 'sign_event',
result: 'APPROVED',
durationMs: 100,
Expand All @@ -43,6 +49,9 @@ describe('LoggingService', () => {
it('should include eventKind and metadata when provided', async () => {
await service.logSigningAction({
connectionId: 'conn-1',
userId: 'user-1',
connectionName: 'My App',
clientPubkey: 'a'.repeat(64),
method: 'sign_event',
eventKind: 1,
result: 'DENIED',
Expand All @@ -58,6 +67,54 @@ describe('LoggingService', () => {
});
});

describe('getDashboardActivity', () => {
it('scopes by denormalized userId (not the connection relation) so deleted-connection logs survive', async () => {
vi.mocked(prisma.signingLog!.findMany!).mockResolvedValue([
{
id: 'log-1',
connectionId: null, // connection was deleted; FK is SET NULL
connectionName: 'Deleted App',
method: 'sign_event',
eventKind: 1,
result: 'APPROVED',
createdAt: new Date('2026-01-01T00:00:00Z'),
} as never,
]);
vi.mocked(prisma.signingLog!.count!).mockResolvedValue(1);

const result = await service.getDashboardActivity('user-1');

// Must filter on the denormalized userId, never via `connection: { userId }`,
// otherwise orphaned (connectionId=null) logs drop out of the feed.
const findManyArgs = vi.mocked(prisma.signingLog!.findMany!).mock.calls[0]![0]!;
expect(findManyArgs.where).toEqual({ userId: 'user-1' });
expect(findManyArgs.where).not.toHaveProperty('connection');
// No relation include anymore — the name comes from the denormalized column.
expect(findManyArgs).not.toHaveProperty('include');

expect(result.data[0]).toMatchObject({
id: 'log-1',
connectionName: 'Deleted App',
method: 'sign_event',
});
expect(result.total).toBe(1);
});

it('applies connectionName and method filters against denormalized columns', async () => {
vi.mocked(prisma.signingLog!.findMany!).mockResolvedValue([]);
vi.mocked(prisma.signingLog!.count!).mockResolvedValue(0);

await service.getDashboardActivity('user-1', 1, 15, 'My App', 'sign_event');

const findManyArgs = vi.mocked(prisma.signingLog!.findMany!).mock.calls[0]![0]!;
expect(findManyArgs.where).toEqual({
userId: 'user-1',
connectionName: 'My App',
method: 'sign_event',
});
});
});

describe('getLogsForConnection', () => {
it('should return paginated logs with total', async () => {
vi.mocked(prisma.signingLog!.findMany!).mockResolvedValue([
Expand Down
18 changes: 12 additions & 6 deletions apps/server/src/logging/logging.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@ import type { Prisma } from '@/generated/prisma/client.js';

interface LogEntry {
connectionId: string;
// Denormalized so the log survives connection deletion (FK is SET NULL) and stays scoped/readable.
userId: string;
connectionName: string;
clientPubkey: string;
method: string;
eventKind?: number;
result: 'APPROVED' | 'DENIED' | 'ERROR';
Expand All @@ -24,6 +28,9 @@ export class LoggingService {
await this.prisma.signingLog.create({
data: {
connectionId: entry.connectionId,
userId: entry.userId,
connectionName: entry.connectionName,
clientPubkey: entry.clientPubkey,
method: entry.method,
eventKind: entry.eventKind,
result: entry.result as LogResult,
Expand All @@ -44,18 +51,17 @@ export class LoggingService {
connectionName?: string,
method?: string,
) {
// Scope by the denormalized userId/connectionName so logs from deleted connections
// (connectionId is null) still appear in the owner's activity feed.
const where: Prisma.SigningLogWhereInput = {
connection: {
userId,
...(connectionName ? { name: connectionName } : {}),
},
userId,
...(connectionName ? { connectionName } : {}),
...(method ? { method } : {}),
};

const [data, total] = await Promise.all([
this.prisma.signingLog.findMany({
where,
include: { connection: { select: { name: true } } },
orderBy: { createdAt: 'desc' },
skip: (page - 1) * limit,
take: limit,
Expand All @@ -68,7 +74,7 @@ export class LoggingService {
id: log.id,
method: log.method,
eventKind: log.eventKind,
connectionName: log.connection.name,
connectionName: log.connectionName,
result: log.result,
timestamp: log.createdAt.toISOString(),
})),
Expand Down
Loading
Loading