Compare commits

..
Author SHA1 Message Date
fred 8a2473da11 fix(gateway): serialize bootstrap setup with an advisory lock (#1430)
ci/woodpecker/pr/ci Pipeline was successful
POST /api/bootstrap/setup checked the zero-user count and then created the
first admin with nothing making the two atomic, so two concurrent setup
requests could both pass the check and each create an admin user plus an
admin API token.

Setup now runs inside a db.transaction that first takes
pg_advisory_xact_lock(BOOTSTRAP_SETUP_LOCK_KEY): the second concurrent
caller blocks until the first commits, re-reads the count, and gets 403.
The lock is transaction-scoped, so it also serializes across gateway
replicas sharing the database.

Spec: mock db gained transaction/execute; new assertions pin
lock-before-count ordering, and a new suite pins 403 + no createUser call
when users already exist.

Closes #1430
2026-08-26 17:23:58 -05:00
5 changed files with 174 additions and 83 deletions
+5
View File
@@ -40,6 +40,11 @@ BETTER_AUTH_SECRET=change-me-to-a-random-32-char-string
BETTER_AUTH_URL=http://localhost:14242
# ─── Web App (Next.js) ───────────────────────────────────────────────────────
# Public gateway URL — accessible from the browser, not just the server.
NEXT_PUBLIC_GATEWAY_URL=http://localhost:14242
# ─── OpenTelemetry ───────────────────────────────────────────────────────────
# OTLP HTTP endpoint (otel-collector or any OpenTelemetry-compatible backend)
OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318
+82 -65
View File
@@ -8,7 +8,7 @@ import {
Post,
} from '@nestjs/common';
import { randomBytes, createHash } from 'node:crypto';
import { count, eq, type Db, users as usersTable, adminTokens } from '@mosaicstack/db';
import { count, eq, sql, type Db, users as usersTable, adminTokens } from '@mosaicstack/db';
import type { Auth } from '@mosaicstack/auth';
import { v4 as uuid } from 'uuid';
import { AUTH } from '../auth/auth.tokens.js';
@@ -16,6 +16,12 @@ import { DB } from '../database/database.module.js';
import { BootstrapSetupDto } from './bootstrap.dto.js';
import type { BootstrapStatusDto, BootstrapResultDto } from './bootstrap.dto.js';
/**
* Advisory lock key serializing bootstrap setup. Arbitrary constant; must only
* be unique among advisory lock keys used against this database.
*/
export const BOOTSTRAP_SETUP_LOCK_KEY = 0x626f6f74; // 'boot'
@Controller('api/bootstrap')
export class BootstrapController {
constructor(
@@ -31,72 +37,83 @@ export class BootstrapController {
@Post('setup')
async setup(@Body() dto: BootstrapSetupDto): Promise<BootstrapResultDto> {
// Only allow setup when zero users exist
const [result] = await this.db.select({ total: count() }).from(usersTable);
if ((result?.total ?? 0) > 0) {
throw new ForbiddenException('Setup already completed — users exist');
}
// #1430: the zero-user check and the admin creation must be one critical
// section, or two concurrent setup calls can each create an admin. The
// transaction-scoped advisory lock serializes setup across all gateway
// instances sharing this database; the second caller blocks on the lock,
// then re-reads the count and gets 403.
return this.db.transaction(async (tx) => {
await tx.execute(sql`select pg_advisory_xact_lock(${BOOTSTRAP_SETUP_LOCK_KEY})`);
// Create admin user via BetterAuth API
const authApi = this.auth.api as unknown as {
createUser: (opts: {
body: { name: string; email: string; password: string; role?: string };
}) => Promise<{
user: { id: string; name: string; email: string };
}>;
};
// Only allow setup when zero users exist
const [result] = await tx.select({ total: count() }).from(usersTable);
if ((result?.total ?? 0) > 0) {
throw new ForbiddenException('Setup already completed — users exist');
}
const created = await authApi.createUser({
body: {
name: dto.name,
email: dto.email,
password: dto.password,
role: 'admin',
},
// Create admin user via BetterAuth API. BetterAuth writes on its own
// connection and commits independently of this transaction; the reads
// below still see the committed row (READ COMMITTED statement snapshot).
const authApi = this.auth.api as unknown as {
createUser: (opts: {
body: { name: string; email: string; password: string; role?: string };
}) => Promise<{
user: { id: string; name: string; email: string };
}>;
};
const created = await authApi.createUser({
body: {
name: dto.name,
email: dto.email,
password: dto.password,
role: 'admin',
},
});
// Verify user was created
const [user] = await tx
.select()
.from(usersTable)
.where(eq(usersTable.id, created.user.id))
.limit(1);
if (!user) throw new InternalServerErrorException('User created but not found');
// Ensure role is admin (createUser may not set it via BetterAuth)
if (user.role !== 'admin') {
await tx.update(usersTable).set({ role: 'admin' }).where(eq(usersTable.id, user.id));
}
// Generate admin API token
const plaintext = randomBytes(32).toString('hex');
const tokenHash = createHash('sha256').update(plaintext).digest('hex');
const tokenId = uuid();
const [token] = await tx
.insert(adminTokens)
.values({
id: tokenId,
userId: user.id,
tokenHash,
label: 'Initial setup token',
scope: 'admin',
})
.returning();
return {
user: {
id: user.id,
name: user.name,
email: user.email,
role: 'admin',
},
token: {
id: token!.id,
plaintext,
label: token!.label,
},
};
});
// Verify user was created
const [user] = await this.db
.select()
.from(usersTable)
.where(eq(usersTable.id, created.user.id))
.limit(1);
if (!user) throw new InternalServerErrorException('User created but not found');
// Ensure role is admin (createUser may not set it via BetterAuth)
if (user.role !== 'admin') {
await this.db.update(usersTable).set({ role: 'admin' }).where(eq(usersTable.id, user.id));
}
// Generate admin API token
const plaintext = randomBytes(32).toString('hex');
const tokenHash = createHash('sha256').update(plaintext).digest('hex');
const tokenId = uuid();
const [token] = await this.db
.insert(adminTokens)
.values({
id: tokenId,
userId: user.id,
tokenHash,
label: 'Initial setup token',
scope: 'admin',
})
.returning();
return {
user: {
id: user.id,
name: user.name,
email: user.email,
role: 'admin',
},
token: {
id: token!.id,
plaintext,
label: token!.label,
},
};
}
}
+73 -4
View File
@@ -20,7 +20,7 @@
*/
import 'reflect-metadata';
import { describe, it, expect, afterAll, beforeAll } from 'vitest';
import { describe, it, expect, afterAll, beforeAll, vi } from 'vitest';
import { Test } from '@nestjs/testing';
import { ValidationPipe, type INestApplication } from '@nestjs/common';
import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify';
@@ -52,13 +52,22 @@ const mockAuth = {
},
};
// Override db.select() so the second query (verify user exists) returns a user.
// The bootstrap controller calls select().from() twice:
// The controller runs setup inside db.transaction(tx) and first takes the
// #1430 advisory lock via tx.execute(). Inside the transaction it calls
// select().from() twice:
// 1. count() to check zero users → returns [{total: 0}]
// 2. select().where().limit() → returns [the created user]
// callLog records the tx call order so the lock-before-check invariant is
// testable.
let selectCallCount = 0;
const mockDbWithUser = {
const callLog: string[] = [];
const mockTx = {
execute: () => {
callLog.push('execute');
return Promise.resolve([]);
},
select: () => {
callLog.push('select');
selectCallCount++;
return {
from: () => {
@@ -100,6 +109,9 @@ const mockDbWithUser = {
}),
}),
};
const mockDbWithUser = {
transaction: <T>(cb: (tx: typeof mockTx) => Promise<T>) => cb(mockTx),
};
// ─── Test suite ───────────────────────────────────────────────────────────────
@@ -153,6 +165,11 @@ describe('POST /api/bootstrap/setup — ValidationPipe DTO binding', () => {
expect(body.user.email).toBe('[email protected]');
expect(body.token).toBeDefined();
expect(body.token.plaintext).toBeDefined();
// #1430: the advisory lock must be taken before the zero-user count, or
// two concurrent setups can both pass the check.
expect(callLog[0]).toBe('execute');
expect(callLog[1]).toBe('select');
});
it('returns 400 when extra forbidden properties are sent', async () => {
@@ -188,3 +205,55 @@ describe('POST /api/bootstrap/setup — ValidationPipe DTO binding', () => {
expect(res.status).toBe(400);
});
});
// ─── #1430 regression: users-exist check runs inside the locked transaction ──
describe('POST /api/bootstrap/setup — setup already completed', () => {
let app: INestApplication;
const createUserSpy = vi.fn();
const lockedTx = {
execute: () => Promise.resolve([]),
select: () => ({
// count() sees an existing user — the locked re-check must reject.
from: () => Promise.resolve([{ total: 1 }]),
}),
};
const mockDbUsersExist = {
transaction: <T>(cb: (tx: typeof lockedTx) => Promise<T>) => cb(lockedTx),
};
beforeAll(async () => {
const moduleRef = await Test.createTestingModule({
controllers: [BootstrapController],
providers: [
{ provide: AUTH, useValue: { api: { createUser: createUserSpy } } },
{ provide: DB, useValue: mockDbUsersExist },
],
}).compile();
app = moduleRef.createNestApplication<NestFastifyApplication>(new FastifyAdapter());
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
}),
);
await app.init();
await app.getHttpAdapter().getInstance().ready();
});
afterAll(async () => {
await app.close();
});
it('returns 403 and never calls createUser when users already exist', async () => {
const res = await request(app.getHttpServer())
.post('/api/bootstrap/setup')
.send({ name: 'Admin', email: '[email protected]', password: 'password123' })
.set('Content-Type', 'application/json');
expect(res.status).toBe(403);
expect(createUserSpy).not.toHaveBeenCalled();
});
});
+3 -2
View File
@@ -376,8 +376,9 @@ Session cleanup is scoped to one session identifier and only removes that sessio
### Web App
The web app uses origin-relative paths only; it needs no gateway URL variable.
(`NEXT_PUBLIC_GATEWAY_URL` is obsolete and no longer read by anything.)
| Variable | Default | Description |
| ------------------------- | ------------------------ | -------------------------------------- |
| `NEXT_PUBLIC_GATEWAY_URL` | `http://localhost:14242` | Gateway URL used by the Next.js client |
### Coordination
+11 -12
View File
@@ -1,7 +1,7 @@
# WebUI Phase P — File / Folder Structure & Migration Map
> **Status:** living document. Structure and increment status are verified against `next` as of
> merge `3bd490c0` (2026-08-26). Details (per-surface component inventories, exact route tables,
> **Status:** living document — first pass. Structure and increment status are verified against
> `next` as of merge `8c27024d`. Details (per-surface component inventories, exact route tables,
> test matrices) are still being fleshed out; extend the stub sections below rather than rewriting
> the verified structure.
@@ -29,8 +29,7 @@ apps/web/
├── routes.tsx # ── SPA React Router route table
├── spa/ # ── NEW SPA surfaces
│ ├── guards.tsx # guest / authenticated route guards
│ ├── pages/ # login, register, sso-callback (P2); chat + error boundary (P3);
│ │ # projects, project-detail, tasks (P4-1)
│ ├── pages/ # login, register, sso-callback (P2); chat + error boundary (P3)
│ └── chat/ # P3 typed chat: use-chat-connection, commands-panel,
│ # session-panel, message-transcript, tool-call-list, composer
@@ -81,14 +80,14 @@ build path is retired.
## 5. Increment map (P1P6)
| # | Increment | Branch | Status |
| ------ | ---------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------- | ---------------------------------------------------------------------------------------------- |
| **P1** | Vite + React Router skeleton beside Next (entry, router, guards, vitest) | `feat/webui-p1-vite-skeleton` | ✅ merged — PR **#1143** |
| **P2** | SPA data layer + same-origin auth (login/register/SSO pages, guards, relative api/socket/auth-client) | `feat/webui-p2-data-auth` | ✅ merged — PR **#1144** |
| **P3** | Typed SPA **chat** (`spa/chat/*`, `chat-contract.ts`, chat page + error boundary) | `feat/webui-p3-chat` | ✅ merged — PR **#1151** (+ repair PR **#1154**) |
| **P4** | Port **projects / tasks / settings / admin** dashboard surfaces into the SPA | `feat/webui-p4-1` | 🚧 in progress — P4-1 (read-only projects + tasks) merged, PR **#1153**; settings/admin remain |
| **P5** | **Cutover**: Gateway serves the Vite `dist` on `:14242`; flip `dev`/`build` to vite; **remove** the legacy Next `app/` tree + `next.config.ts` | _tbd_ | ⏳ not started |
| **P6** | CI / images (trails): build the SPA in CI, ship images | _tbd_ | ⏳ trails |
| # | Increment | Branch | Status |
| ------ | ---------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------- | ------------------------- |
| **P1** | Vite + React Router skeleton beside Next (entry, router, guards, vitest) | `feat/webui-p1-vite-skeleton` | ✅ merged — PR **#1143** |
| **P2** | SPA data layer + same-origin auth (login/register/SSO pages, guards, relative api/socket/auth-client) | `feat/webui-p2-data-auth` | ✅ merged — PR **#1144** |
| **P3** | Typed SPA **chat** (`spa/chat/*`, `chat-contract.ts`, chat page + error boundary) | `feat/webui-p3-chat` | 🚧 in progress (unmerged) |
| **P4** | Port **projects / tasks / settings / admin** dashboard surfaces into the SPA | _tbd_ | ⏳ not started |
| **P5** | **Cutover**: Gateway serves the Vite `dist` on `:14242`; flip `dev`/`build` to vite; **remove** the legacy Next `app/` tree + `next.config.ts` | _tbd_ | ⏳ not started |
| **P6** | CI / images (trails): build the SPA in CI, ship images | _tbd_ | ⏳ trails |
Each increment follows the same delivery pipeline: brief traceable to the RFC → author →
**independent** integrator verification (build+test+typecheck+lint) → **independent** code + security