Return the registration invites to the domain that consumes them

invite_tokens gates signup. Its consumer is the registration path in
AuthService, not anything in admin/, which held the four management methods
only because the routes sit under /api/admin.

Worth spelling out because the names invite it: invite_tokens and
trip_invite_tokens are different tables for different jobs, and this one has
nothing to do with adding a member to a trip. An earlier reading of the plan
had these going to trip-invite/, which would have been the wrong home.

The routes keep their paths and their admin guard; AdminController injects
the service instead of carrying another domain's SQL.

Also recorded, in the file, why the app-settings pair did NOT move to
settings/ with the other app_settings readers: updateAppSettings runs the
lockout guard that stops an admin disabling every login method, and that
guard is auth's own resolveAuthToggles. Moving it would put an AuthModule
import inside a leaf module half the container pulls in.
This commit is contained in:
Maurice
2026-08-09 21:53:42 +02:00
committed by Maurice
parent 45611e2514
commit 93bbc65520
8 changed files with 194 additions and 125 deletions
+6 -4
View File
@@ -3,6 +3,7 @@ import type { Request } from 'express';
import { readEnv } from '../../app-config';
import { AdminService } from './admin.service';
import { TokenService } from '../tokens/token.service';
import { RegistrationInvitesService } from '../auth/registration-invites.service';
import {
AdminUserCreateDto,
AdminUserUpdateDto,
@@ -63,6 +64,7 @@ export class AdminController {
private readonly audit: AuditService,
private readonly notifications: NotificationsService,
private readonly tokens: TokenService,
private readonly invites: RegistrationInvitesService,
) {}
// ── Users ──
@@ -139,23 +141,23 @@ export class AdminController {
// ── Invites ──
@Get('invites')
listInvites() { return { invites: this.admin.listInvites() }; }
listInvites() { return { invites: this.invites.listInvites() }; }
// Trips an admin can optionally bind a registration invite to (#1402).
@Get('invites/trips')
listInviteTrips() { return { trips: this.admin.listTripsForInvite() }; }
listInviteTrips() { return { trips: this.invites.listTripsForInvite() }; }
@Post('invites')
@HttpCode(201)
createInvite(@CurrentUser() user: User, @Body() body: AdminInviteCreateDto, @Req() req: Request) {
const result = this.admin.createInvite(user.id, body);
const result = this.invites.createInvite(user.id, body);
this.audit.writeAudit({ userId: user.id, action: 'admin.invite_create', resource: String(result.inviteId), ip: getClientIp(req), details: { max_uses: result.uses, expires_in_days: result.expiresInDays, trip_id: result.tripId } });
return { invite: result.invite };
}
@Delete('invites/:id')
deleteInvite(@CurrentUser() user: User, @Param('id') id: string, @Req() req: Request) {
ok(this.admin.deleteInvite(id));
ok(this.invites.deleteInvite(id));
this.audit.writeAudit({ userId: user.id, action: 'admin.invite_delete', resource: String(id), ip: getClientIp(req) });
return { success: true };
}
-65
View File
@@ -456,71 +456,6 @@ export class AdminService {
}
}
// ── Invite Tokens ──────────────────────────────────────────────────────────
listInvites() {
return this.db.all(`
SELECT i.*, u.username as created_by_name, t.title as trip_title
FROM invite_tokens i
JOIN users u ON i.created_by = u.id
LEFT JOIN trips t ON i.trip_id = t.id
ORDER BY i.created_at DESC
`);
}
/** Trips an admin can bind an invite to — id + title only, for the picker (#1402). */
listTripsForInvite() {
return this.db.all('SELECT id, title FROM trips ORDER BY title COLLATE NOCASE ASC');
}
createInvite(
createdBy: number,
data: { max_uses?: string | number; expires_in_days?: string | number; trip_id?: string | number | null },
) {
const rawUses = parseInt(String(data.max_uses));
const uses = rawUses === 0 ? 0 : Math.min(Math.max(rawUses || 1, 1), 5);
const token = crypto.randomBytes(16).toString('hex');
const expiresAt = data.expires_in_days
? new Date(Date.now() + parseInt(String(data.expires_in_days)) * 86400000).toISOString()
: null;
// Optional trip binding: only persist a trip that actually exists, so a stale
// or forged id can never bind (and never auto-adds anyone on registration).
let tripId: number | null = null;
if (data.trip_id != null && String(data.trip_id).trim() !== '') {
const parsed = parseInt(String(data.trip_id));
if (!Number.isInteger(parsed) || !this.db.get('SELECT id FROM trips WHERE id = ?', parsed)) {
// Used to bind null silently, handing back a plain registration invite
// the admin never asked for.
return { error: 'Trip not found', status: 404 };
}
tripId = parsed;
}
const ins = this.db.run(
'INSERT INTO invite_tokens (token, max_uses, expires_at, created_by, trip_id) VALUES (?, ?, ?, ?, ?)',
token, uses, expiresAt, createdBy, tripId,
);
const inviteId = Number(ins.lastInsertRowid);
const invite = this.db.get(`
SELECT i.*, u.username as created_by_name, t.title as trip_title
FROM invite_tokens i
JOIN users u ON i.created_by = u.id
LEFT JOIN trips t ON i.trip_id = t.id
WHERE i.id = ?
`, inviteId);
return { invite, inviteId, uses, expiresInDays: data.expires_in_days ?? null, tripId };
}
deleteInvite(id: string) {
const invite = this.db.get('SELECT id FROM invite_tokens WHERE id = ?', id);
if (!invite) return { error: 'Invite not found', status: 404 };
this.db.run('DELETE FROM invite_tokens WHERE id = ?', id);
return {};
}
// ── Addons ─────────────────────────────────────────────────────────────────
listAddons() {
+3 -2
View File
@@ -6,6 +6,7 @@ import { AuthController } from './auth.controller';
import { PasskeyController } from './passkey.controller';
import { AuthService } from './auth.service';
import { UserProfileService } from './user-profile.service';
import { RegistrationInvitesService } from './registration-invites.service';
import { PasskeyService } from './passkey.service';
import { AuthMcp } from './auth.mcp';
import { UserCleanupService } from './user-cleanup.service';
@@ -39,7 +40,7 @@ import { TripMembershipModule } from '../trip-membership/trip-membership.module'
@Module({
imports: [RateLimitModule, AuditModule, PermissionsModule, TripMembershipModule, MailerModule, AppConfigModule, TokensModule],
controllers: [AuthPublicController, AuthController, PasskeyController],
providers: [AuthService, UserProfileService, PasskeyService, UserCleanupService, WebauthnConfigService, AuthMcp],
exports: [AuthService, PasskeyService, UserCleanupService],
providers: [AuthService, UserProfileService, RegistrationInvitesService, PasskeyService, UserCleanupService, WebauthnConfigService, AuthMcp],
exports: [AuthService, RegistrationInvitesService, PasskeyService, UserCleanupService],
})
export class AuthModule {}
+10
View File
@@ -574,6 +574,16 @@ export class AuthService implements OnModuleInit, OnModuleDestroy {
// -------------------------------------------------------------------------
// Admin settings
//
// These stay here, against the first instinct to file them under settings/
// with the other app_settings readers. updateAppSettings runs the lockout
// guard that stops an admin disabling every login method at once, and that
// guard is resolveAuthToggles — auth's own view of which methods exist.
// Moving the pair would put an AuthModule import inside SettingsModule, a
// leaf that half the container pulls in, to buy nothing but a tidier
// directory. The role check below is likewise left as written: swapping it
// for AdminGuard changes the response body, so it belongs with the guard
// work, not with a move.
// -------------------------------------------------------------------------
getAppSettings(userId: number): { error?: string; status?: number; data?: Record<string, string> } {
@@ -0,0 +1,86 @@
import { Injectable } from '@nestjs/common';
import crypto from 'crypto';
import { DatabaseService } from '../database/database.service';
/**
* Registration invites: the tokens an admin hands out so someone can create an
* account on a closed instance, optionally dropping them straight into a trip.
*
* This lives in auth/ and not in trip-invite/, which is the trap the name sets.
* invite_tokens and trip_invite_tokens are different tables for different
* things — the first gates signup, the second adds an existing user to a trip.
* The consumer of this one is the registration path in AuthService, so the
* table belongs to the auth domain.
*
* The four methods moved verbatim out of AdminService, which held them only
* because the management routes are under /api/admin. Those routes keep their
* paths and their guards; AdminController now injects this instead of carrying
* another domain's SQL.
*/
@Injectable()
export class RegistrationInvitesService {
constructor(private readonly db: DatabaseService) {}
listInvites() {
return this.db.all(`
SELECT i.*, u.username as created_by_name, t.title as trip_title
FROM invite_tokens i
JOIN users u ON i.created_by = u.id
LEFT JOIN trips t ON i.trip_id = t.id
ORDER BY i.created_at DESC
`);
}
/** Trips an admin can bind an invite to — id + title only, for the picker (#1402). */
listTripsForInvite() {
return this.db.all('SELECT id, title FROM trips ORDER BY title COLLATE NOCASE ASC');
}
createInvite(
createdBy: number,
data: { max_uses?: string | number; expires_in_days?: string | number; trip_id?: string | number | null },
) {
const rawUses = parseInt(String(data.max_uses));
const uses = rawUses === 0 ? 0 : Math.min(Math.max(rawUses || 1, 1), 5);
const token = crypto.randomBytes(16).toString('hex');
const expiresAt = data.expires_in_days
? new Date(Date.now() + parseInt(String(data.expires_in_days)) * 86400000).toISOString()
: null;
// Optional trip binding: only persist a trip that actually exists, so a stale
// or forged id can never bind (and never auto-adds anyone on registration).
let tripId: number | null = null;
if (data.trip_id != null && String(data.trip_id).trim() !== '') {
const parsed = parseInt(String(data.trip_id));
if (!Number.isInteger(parsed) || !this.db.get('SELECT id FROM trips WHERE id = ?', parsed)) {
// Used to bind null silently, handing back a plain registration invite
// the admin never asked for.
return { error: 'Trip not found', status: 404 };
}
tripId = parsed;
}
const ins = this.db.run(
'INSERT INTO invite_tokens (token, max_uses, expires_at, created_by, trip_id) VALUES (?, ?, ?, ?, ?)',
token, uses, expiresAt, createdBy, tripId,
);
const inviteId = Number(ins.lastInsertRowid);
const invite = this.db.get(`
SELECT i.*, u.username as created_by_name, t.title as trip_title
FROM invite_tokens i
JOIN users u ON i.created_by = u.id
LEFT JOIN trips t ON i.trip_id = t.id
WHERE i.id = ?
`, inviteId);
return { invite, inviteId, uses, expiresInDays: data.expires_in_days ?? null, tripId };
}
deleteInvite(id: string) {
const invite = this.db.get('SELECT id FROM invite_tokens WHERE id = ?', id);
if (!invite) return { error: 'Invite not found', status: 404 };
this.db.run('DELETE FROM invite_tokens WHERE id = ?', id);
return {};
}
}
@@ -8,6 +8,7 @@ vi.mock('../../../src/nest/audit/audit-log.logger', () => ({ LOG_LEVEL: 'error',
import type { AddonsService } from '../../../src/nest/addons/addons.service';
import { AdminController } from '../../../src/nest/admin/admin.controller';
import type { TokenService } from '../../../src/nest/tokens/token.service';
import type { RegistrationInvitesService } from '../../../src/nest/auth/registration-invites.service';
import type { AdminService } from '../../../src/nest/admin/admin.service';
import type { PluginRuntimeService } from '../../../src/nest/plugins/plugin-runtime.service';
import type { AuditService } from '../../../src/nest/audit/audit.service';
@@ -45,8 +46,8 @@ const addonsStub = () => ({
// The MCP-token routes read TokenService now, not AdminService. Stubbed via a
// fourth, optional argument so every existing call site stays as it was.
const adminCtl = (s: AdminService, rt?: PluginRuntimeService, addons: AddonsService = addonsStub(), tokens: Partial<TokenService> = {}) =>
new AdminController(s, addons, rt as unknown as PluginRuntimeService, audit, notifications, tokens as TokenService);
const adminCtl = (s: AdminService, rt?: PluginRuntimeService, addons: AddonsService = addonsStub(), tokens: Partial<TokenService> = {}, invites: Partial<RegistrationInvitesService> = {}) =>
new AdminController(s, addons, rt as unknown as PluginRuntimeService, audit, notifications, tokens as TokenService, invites as RegistrationInvitesService);
function thrown(fn: () => unknown): { status: number; body: unknown } {
try { fn(); } catch (err) {
if (err instanceof NotFoundException) return { status: 404, body: err.getResponse() };
@@ -99,10 +100,10 @@ describe('AdminController permissions + oidc + misc', () => {
// admin-packing-templates.controller.test.ts.
describe('AdminController invites', () => {
it('invites: create 201 + audit, delete maps error', () => {
const c = adminCtl(svc({ createInvite: vi.fn().mockReturnValue({ invite: { id: 5 }, inviteId: 5, uses: 1, expiresInDays: 7 }) } as Partial<AdminService>));
const c = adminCtl(svc(), undefined, undefined, {}, { createInvite: vi.fn().mockReturnValue({ invite: { id: 5 }, inviteId: 5, uses: 1, expiresInDays: 7 }) });
expect(c.createInvite(user, {}, req)).toEqual({ invite: { id: 5 } });
expect(writeAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'admin.invite_create' }));
expect(thrown(() => adminCtl(svc({ deleteInvite: vi.fn().mockReturnValue({ error: 'not found', status: 404 }) } as Partial<AdminService>)).deleteInvite(user, '5', req))).toEqual({ status: 404, body: { error: 'not found' } });
expect(thrown(() => adminCtl(svc(), undefined, undefined, {}, { deleteInvite: vi.fn().mockReturnValue({ error: 'not found', status: 404 }) }).deleteInvite(user, '5', req))).toEqual({ status: 404, body: { error: 'not found' } });
});
@@ -154,7 +155,7 @@ describe('AdminController read-only getters', () => {
expect(adminCtl(svc({ getPermissions: vi.fn().mockReturnValue({ a: 1 }) } as Partial<AdminService>)).permissions()).toEqual({ a: 1 });
expect(adminCtl(svc({ getAuditLog: vi.fn().mockReturnValue({ entries: [] }) } as Partial<AdminService>)).auditLog({})).toEqual({ entries: [] });
expect(adminCtl(svc({ checkVersion: vi.fn().mockResolvedValue({ current: '1' }) } as Partial<AdminService>)).versionCheck()).resolves.toEqual({ current: '1' });
expect(adminCtl(svc({ listInvites: vi.fn().mockReturnValue([{ id: 1 }]) } as Partial<AdminService>)).listInvites()).toEqual({ invites: [{ id: 1 }] });
expect(adminCtl(svc(), undefined, undefined, {}, { listInvites: vi.fn().mockReturnValue([{ id: 1 }]) }).listInvites()).toEqual({ invites: [{ id: 1 }] });
expect(adminCtl(svc({ listAddons: vi.fn().mockReturnValue([{ id: 'mcp' }]) } as Partial<AdminService>)).listAddons()).toEqual({ addons: [{ id: 'mcp' }] });
expect(adminCtl(svc(), undefined, undefined, { listAllMcpTokens: vi.fn().mockReturnValue([{ id: 1 }]) }).listMcpTokens()).toEqual({ tokens: [{ id: 1 }] });
expect(adminCtl(svc({ listOAuthSessions: vi.fn().mockReturnValue([{ id: 1 }]) } as Partial<AdminService>)).listOAuthSessions()).toEqual({ sessions: [{ id: 1 }] });
@@ -100,9 +100,6 @@ const getStats = () => svc.getStats();
const getPermissions = () => svc.getPermissions();
const savePermissions = (p: Record<string, string>) => svc.savePermissions(p);
const getAuditLog = (q: { limit?: string; offset?: string }) => svc.getAuditLog(q);
const listInvites = () => svc.listInvites();
const createInvite = (by: number, d: Parameters<AdminService['createInvite']>[1]) => svc.createInvite(by, d);
const deleteInvite = (id: string) => svc.deleteInvite(id);
const getOidcSettings = () => svc.getOidcSettings();
const updateOidcSettings = (d: Parameters<AdminService['updateOidcSettings']>[0]) => svc.updateOidcSettings(d);
const saveDemoBaseline = () => svc.saveDemoBaseline();
@@ -299,44 +296,6 @@ describe('getAuditLog', () => {
});
});
// ── Invites ───────────────────────────────────────────────────────────────────
describe('Invites', () => {
it('ADMIN-SVC-024 — createInvite returns invite with token', () => {
const { user: admin } = createAdmin(testDb);
const result = createInvite(admin.id, { max_uses: 5 }) as any;
expect(result.invite.token).toBeDefined();
expect(result.invite.max_uses).toBe(5);
});
it('ADMIN-SVC-025 — createInvite defaults to 1 use', () => {
const { user: admin } = createAdmin(testDb);
const result = createInvite(admin.id, {}) as any;
expect(result.uses).toBe(1);
});
it('ADMIN-SVC-026 — listInvites returns array', () => {
const { user: admin } = createAdmin(testDb);
createInvite(admin.id, {});
const invites = listInvites() as any[];
expect(invites.length).toBeGreaterThanOrEqual(1);
});
it('ADMIN-SVC-027 — deleteInvite removes invite', () => {
const { user: admin } = createAdmin(testDb);
const invite = createInviteToken(testDb, { created_by: admin.id }) as any;
const result = deleteInvite(String(invite.id)) as any;
expect(result.error).toBeUndefined();
const check = testDb.prepare('SELECT id FROM invite_tokens WHERE id = ?').get(invite.id);
expect(check).toBeUndefined();
});
it('ADMIN-SVC-028 — deleteInvite returns 404 for non-existent invite', () => {
const result = deleteInvite('99999') as any;
expect(result.status).toBe(404);
});
});
// ── getAuditLog — JSON details parsing ───────────────────────────────────────
describe('getAuditLog — JSON details', () => {
@@ -555,14 +514,6 @@ describe('admin quirk fixes (post-fold)', () => {
expect(row.username).toBe(user.username);
});
it('ADMIN-SVC-073 — createInvite 404s on a trip_id that does not resolve', () => {
const { user: admin } = createAdmin(testDb);
expect(createInvite(admin.id, { trip_id: 99999 }) as any).toMatchObject({ status: 404, error: 'Trip not found' });
expect(createInvite(admin.id, { trip_id: 'not-a-number' }) as any).toMatchObject({ status: 404 });
expect(testDb.prepare('SELECT COUNT(*) as c FROM invite_tokens').get()).toEqual({ c: 0 });
// An absent/blank binding is still a plain registration invite.
expect((createInvite(admin.id, {}) as any).tripId).toBeNull();
});
it('ADMIN-SVC-074 — listOAuthSessions survives a row with malformed scopes JSON', () => {
const { user } = createUser(testDb);
@@ -0,0 +1,83 @@
/**
* registration-invites.service.test.ts
*
* DB-centric unit tests for RegistrationInvitesService against a real in-memory
* SQLite database. The cases moved here with the methods, out of
* admin.service.test.ts; the ADMIN-SVC-* case IDs are preserved so the history
* stays greppable. Constructed directly (no TestingModule, repo convention).
*/
const { testDb, dbMock } = vi.hoisted(() => {
const Database = require('better-sqlite3');
const db = new Database(':memory:');
db.exec('PRAGMA journal_mode = WAL');
db.exec('PRAGMA foreign_keys = ON');
db.exec('PRAGMA busy_timeout = 5000');
const mock = { db, closeDb: () => {}, reinitialize: () => {}, canAccessTrip: () => undefined, isOwner: () => false };
return { testDb: db, dbMock: mock };
});
vi.mock('../../../src/db/database', () => dbMock);
import { describe, it, expect, beforeAll, beforeEach, afterAll, vi } from 'vitest';
import { createTables } from '../../../src/db/schema';
import { runMigrations } from '../../../src/db/migrations';
import { resetTestDb } from '../../helpers/test-db';
import { createUser, createAdmin, createTrip, createInviteToken } from '../../helpers/factories';
import { RegistrationInvitesService } from '../../../src/nest/auth/registration-invites.service';
import { DatabaseService } from '../../../src/nest/database/database.service';
const svc = new RegistrationInvitesService(new DatabaseService(testDb));
beforeAll(() => { createTables(testDb); runMigrations(testDb); });
beforeEach(() => { resetTestDb(testDb); vi.clearAllMocks(); });
afterAll(() => { testDb.close(); });
// ── Invites ───────────────────────────────────────────────────────────────────
describe('Invites', () => {
it('ADMIN-SVC-024 — createInvite returns invite with token', () => {
const { user: admin } = createAdmin(testDb);
const result = svc.createInvite(admin.id, { max_uses: 5 }) as any;
expect(result.invite.token).toBeDefined();
expect(result.invite.max_uses).toBe(5);
});
it('ADMIN-SVC-025 — createInvite defaults to 1 use', () => {
const { user: admin } = createAdmin(testDb);
const result = svc.createInvite(admin.id, {}) as any;
expect(result.uses).toBe(1);
});
it('ADMIN-SVC-026 — listInvites returns array', () => {
const { user: admin } = createAdmin(testDb);
svc.createInvite(admin.id, {});
const invites = svc.listInvites() as any[];
expect(invites.length).toBeGreaterThanOrEqual(1);
});
it('ADMIN-SVC-027 — deleteInvite removes invite', () => {
const { user: admin } = createAdmin(testDb);
const invite = createInviteToken(testDb, { created_by: admin.id }) as any;
const result = svc.deleteInvite(String(invite.id)) as any;
expect(result.error).toBeUndefined();
const check = testDb.prepare('SELECT id FROM invite_tokens WHERE id = ?').get(invite.id);
expect(check).toBeUndefined();
});
it('ADMIN-SVC-028 — deleteInvite returns 404 for non-existent invite', () => {
const result = svc.deleteInvite('99999') as any;
expect(result.status).toBe(404);
});
});
describe('Invites — trip binding', () => {
it('ADMIN-SVC-073 — createInvite 404s on a trip_id that does not resolve', () => {
const { user: admin } = createAdmin(testDb);
expect(svc.createInvite(admin.id, { trip_id: 99999 }) as any).toMatchObject({ status: 404, error: 'Trip not found' });
expect(svc.createInvite(admin.id, { trip_id: 'not-a-number' }) as any).toMatchObject({ status: 404 });
expect(testDb.prepare('SELECT COUNT(*) as c FROM invite_tokens').get()).toEqual({ c: 0 });
// An absent/blank binding is still a plain registration invite.
expect((svc.createInvite(admin.id, {}) as any).tripId).toBeNull();
});
});