Skip to content

Commit dbdf75a

Browse files
feat: add runtime signup approval setting
Adds a D1-backed runtime signup approval setting with REQUIRE_APPROVAL fallback, shared approval gate resolution, superadmin admin API endpoints, Admin Users UI control, docs, tests, staging validation, and CI evidence.
1 parent c07886a commit dbdf75a

26 files changed

Lines changed: 912 additions & 29 deletions

apps/api/src/auth.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import type { Env } from './env';
1010
import { createModuleLogger } from './lib/logger';
1111
import { readResponseJson } from './lib/runtime-validation';
1212
import { getBetterAuthSecret } from './lib/secrets';
13+
import { isSignupApprovalRequired } from './services/signup-approval';
1314

1415
const log = createModuleLogger('auth');
1516

@@ -320,7 +321,7 @@ export function createAuth(env: Env) {
320321
user: {
321322
create: {
322323
before: async (user) => {
323-
if (env.REQUIRE_APPROVAL !== 'true') {
324+
if (!(await isSignupApprovalRequired(env))) {
324325
// Open registration — defaults are fine (role='user', status='active')
325326
return { data: user };
326327
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
CREATE TABLE IF NOT EXISTS platform_settings (
2+
key TEXT PRIMARY KEY NOT NULL,
3+
value TEXT NOT NULL,
4+
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
5+
updated_by TEXT REFERENCES users(id) ON DELETE SET NULL
6+
);

apps/api/src/db/schema.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,18 @@ export const users = sqliteTable('users', {
8080
.default(sql`(cast(unixepoch() * 1000 as integer))`),
8181
});
8282

83+
// =============================================================================
84+
// Platform Settings
85+
// =============================================================================
86+
export const platformSettings = sqliteTable('platform_settings', {
87+
key: text('key').primaryKey(),
88+
value: text('value').notNull(),
89+
updatedAt: text('updated_at')
90+
.notNull()
91+
.default(sql`CURRENT_TIMESTAMP`),
92+
updatedBy: text('updated_by').references(() => users.id, { onDelete: 'set null' }),
93+
});
94+
8395
// =============================================================================
8496
// Sessions (BetterAuth)
8597
// =============================================================================

apps/api/src/middleware/auth.ts

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@ import { createAuth } from '../auth';
55
import type { Env } from '../env';
66
import { log } from '../lib/logger';
77
import { expectJsonRecord } from '../lib/runtime-validation';
8-
import { AppError, errors } from './error';
8+
import { assertUserAllowedBySignupApproval, isSignupApprovalRequired } from '../services/signup-approval';
9+
import { errors } from './error';
910

1011
/**
1112
* Extended context with authenticated user.
@@ -133,7 +134,7 @@ export function optionalAuth(): MiddlewareHandler<{ Bindings: Env }> {
133134
*/
134135
export function requireApproved(): MiddlewareHandler<{ Bindings: Env }> {
135136
return async (c: Context<{ Bindings: Env }>, next: Next) => {
136-
if (c.env.REQUIRE_APPROVAL !== 'true') {
137+
if (!(await isSignupApprovalRequired(c.env))) {
137138
await next();
138139
return;
139140
}
@@ -154,12 +155,8 @@ export function requireApproved(): MiddlewareHandler<{ Bindings: Env }> {
154155
return;
155156
}
156157

157-
if (auth.user.status === 'suspended') {
158-
throw errors.forbidden('Your account has been suspended');
159-
}
160-
161-
// Default: pending
162-
throw new AppError(403, 'APPROVAL_REQUIRED', 'Your account is pending admin approval');
158+
assertUserAllowedBySignupApproval(true, auth.user);
159+
await next();
163160
};
164161
}
165162

apps/api/src/routes/admin.ts

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,15 +9,37 @@ import type { Env } from '../env';
99
import { getUserId,requireApproved, requireAuth, requireSuperadmin } from '../middleware/auth';
1010
import { errors } from '../middleware/error';
1111
import { rateLimit } from '../middleware/rate-limit';
12-
import { AdminLogQuerySchema,AdminUserActionSchema, AdminUserRoleSchema, jsonValidator } from '../schemas';
12+
import { AdminLogQuerySchema,AdminUserActionSchema, AdminUserRoleSchema, jsonValidator, UpdateSignupApprovalConfigSchema } from '../schemas';
1313
import { getRuntimeLimits } from '../services/limits';
1414
import { CfApiError,getErrorTrends, getHealthSummary, getLogQueryRateLimit, queryCloudflareLogs, queryErrors } from '../services/observability';
15+
import { getSignupApprovalConfig, setSignupApprovalConfig } from '../services/signup-approval';
1516

1617
const adminRoutes = new Hono<{ Bindings: Env }>();
1718

1819
// All admin routes require auth + approval + superadmin
1920
adminRoutes.use('/*', requireAuth(), requireApproved(), requireSuperadmin());
2021

22+
/**
23+
* GET /api/admin/signup-approval - Read runtime signup approval config
24+
*/
25+
adminRoutes.get('/signup-approval', async (c) => {
26+
const config = await getSignupApprovalConfig(c.env);
27+
return c.json({ config });
28+
});
29+
30+
/**
31+
* PUT /api/admin/signup-approval - Update runtime signup approval config
32+
* Body: { requireApproval: boolean }
33+
*/
34+
adminRoutes.put('/signup-approval', jsonValidator(UpdateSignupApprovalConfigSchema), async (c) => {
35+
const body = c.req.valid('json');
36+
const config = await setSignupApprovalConfig(c.env, {
37+
requireApproval: body.requireApproval,
38+
updatedBy: getUserId(c),
39+
});
40+
return c.json({ config });
41+
});
42+
2143
/**
2244
* GET /api/admin/users - List all users
2345
* Optional query param: ?status=pending|active|suspended

apps/api/src/schemas/admin.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,10 @@ export const AdminUserRoleSchema = v.object({
88
role: v.picklist(['admin', 'user']),
99
});
1010

11+
export const UpdateSignupApprovalConfigSchema = v.object({
12+
requireApproval: v.boolean(),
13+
});
14+
1115
export const AnalyticsForwardSchema = v.object({
1216
startDate: v.optional(v.string()),
1317
endDate: v.optional(v.string()),

apps/api/src/schemas/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,7 @@ export {
9797
AnalyticsForwardSchema,
9898
CreatePlatformCredentialSchema,
9999
UpdatePlatformCredentialSchema,
100+
UpdateSignupApprovalConfigSchema,
100101
} from './admin';
101102

102103
// Trigger schemas

apps/api/src/services/session-factory.ts

Lines changed: 4 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { createAuth } from '../auth';
44
import type { Env } from '../env';
55
import { getBetterAuthSecret } from '../lib/secrets';
66
import { errors } from '../middleware/error';
7+
import { assertSessionUserApproved } from './signup-approval';
78

89
const DEFAULT_SESSION_DURATION_SECONDS = 7 * 24 * 60 * 60;
910

@@ -84,7 +85,7 @@ export async function buildSessionLoginResponse(
8485
env: Env,
8586
user: SessionFactoryUser,
8687
): Promise<Response> {
87-
assertUserCanCreateSession(env, user);
88+
await assertUserCanCreateSession(env, user);
8889
const { cookieHeader, sessionCookie } = await createSessionCookieForUser(env, user.id);
8990
return new Response(
9091
JSON.stringify({
@@ -102,16 +103,6 @@ export async function buildSessionLoginResponse(
102103
);
103104
}
104105

105-
export function assertUserCanCreateSession(env: Env, user: SessionFactoryUser): void {
106-
if (env.REQUIRE_APPROVAL !== 'true') {
107-
return;
108-
}
109-
110-
const isAdmin = user.role === 'superadmin' || user.role === 'admin';
111-
if (user.status === 'suspended') {
112-
throw errors.forbidden('Your account has been suspended');
113-
}
114-
if (user.status !== 'active' && !isAdmin) {
115-
throw errors.forbidden('Your account is pending admin approval');
116-
}
106+
export async function assertUserCanCreateSession(env: Env, user: SessionFactoryUser): Promise<void> {
107+
await assertSessionUserApproved(env, user);
117108
}
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
import type { SignupApprovalConfig } from '@simple-agent-manager/shared';
2+
import { eq } from 'drizzle-orm';
3+
import { drizzle } from 'drizzle-orm/d1';
4+
5+
import * as schema from '../db/schema';
6+
import type { Env } from '../env';
7+
import { createModuleLogger } from '../lib/logger';
8+
import { AppError, errors } from '../middleware/error';
9+
10+
const log = createModuleLogger('signup-approval');
11+
12+
export const SIGNUP_APPROVAL_SETTING_KEY = 'signup.requireApproval';
13+
14+
function envRequiresApproval(env: Env): boolean {
15+
return env.REQUIRE_APPROVAL === 'true';
16+
}
17+
18+
function parseStoredBoolean(value: string | null | undefined): boolean | null {
19+
if (value === 'true') return true;
20+
if (value === 'false') return false;
21+
return null;
22+
}
23+
24+
function toIsoString(value: Date | string | null | undefined): string | null {
25+
if (!value) return null;
26+
if (value instanceof Date) return value.toISOString();
27+
return value;
28+
}
29+
30+
export async function getSignupApprovalConfig(env: Env): Promise<SignupApprovalConfig> {
31+
const db = drizzle(env.DATABASE, { schema });
32+
const row = await db
33+
.select({
34+
value: schema.platformSettings.value,
35+
updatedAt: schema.platformSettings.updatedAt,
36+
updatedBy: schema.platformSettings.updatedBy,
37+
})
38+
.from(schema.platformSettings)
39+
.where(eq(schema.platformSettings.key, SIGNUP_APPROVAL_SETTING_KEY))
40+
.get();
41+
42+
if (!row) {
43+
return {
44+
requireApproval: envRequiresApproval(env),
45+
source: 'environment',
46+
updatedAt: null,
47+
updatedBy: null,
48+
};
49+
}
50+
51+
const parsed = parseStoredBoolean(row.value);
52+
if (parsed === null) {
53+
log.error('invalid_runtime_value', { key: SIGNUP_APPROVAL_SETTING_KEY });
54+
return {
55+
requireApproval: envRequiresApproval(env),
56+
source: 'environment',
57+
updatedAt: toIsoString(row.updatedAt),
58+
updatedBy: row.updatedBy ?? null,
59+
};
60+
}
61+
62+
return {
63+
requireApproval: parsed,
64+
source: 'runtime',
65+
updatedAt: toIsoString(row.updatedAt),
66+
updatedBy: row.updatedBy ?? null,
67+
};
68+
}
69+
70+
export async function setSignupApprovalConfig(
71+
env: Env,
72+
input: { requireApproval: boolean; updatedBy: string },
73+
): Promise<SignupApprovalConfig> {
74+
const db = drizzle(env.DATABASE, { schema });
75+
const now = new Date().toISOString();
76+
77+
await db
78+
.insert(schema.platformSettings)
79+
.values({
80+
key: SIGNUP_APPROVAL_SETTING_KEY,
81+
value: input.requireApproval ? 'true' : 'false',
82+
updatedAt: now,
83+
updatedBy: input.updatedBy,
84+
})
85+
.onConflictDoUpdate({
86+
target: schema.platformSettings.key,
87+
set: {
88+
value: input.requireApproval ? 'true' : 'false',
89+
updatedAt: now,
90+
updatedBy: input.updatedBy,
91+
},
92+
});
93+
94+
return {
95+
requireApproval: input.requireApproval,
96+
source: 'runtime',
97+
updatedAt: now,
98+
updatedBy: input.updatedBy,
99+
};
100+
}
101+
102+
export async function isSignupApprovalRequired(env: Env): Promise<boolean> {
103+
const config = await getSignupApprovalConfig(env);
104+
return config.requireApproval;
105+
}
106+
107+
export async function assertSessionUserApproved(
108+
env: Env,
109+
user: { role?: string | null; status?: string | null },
110+
): Promise<void> {
111+
assertUserAllowedBySignupApproval(await isSignupApprovalRequired(env), user);
112+
}
113+
114+
export function assertUserAllowedBySignupApproval(
115+
requireApproval: boolean,
116+
user: { role?: string | null; status?: string | null },
117+
): void {
118+
if (!requireApproval) {
119+
return;
120+
}
121+
122+
const isAdmin = user.role === 'superadmin' || user.role === 'admin';
123+
if (user.status === 'suspended') {
124+
throw errors.forbidden('Your account has been suspended');
125+
}
126+
if (user.status !== 'active' && !isAdmin) {
127+
throw new AppError(403, 'APPROVAL_REQUIRED', 'Your account is pending admin approval');
128+
}
129+
}

apps/api/tests/unit/auth.test.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -60,15 +60,21 @@ function fakeEnv(requireApproval = 'true') {
6060
}
6161

6262
function installExistingUsersQuery(existingUsers: Array<{ id: string }>) {
63+
const get = vi.fn(async () => undefined);
6364
const all = vi.fn(async () => existingUsers);
6465
const limit = vi.fn(() => ({ all }));
6566
const where = vi.fn(() => ({ limit }));
6667
const from = vi.fn(() => ({ where }));
67-
const select = vi.fn(() => ({ from }));
68+
const select = vi.fn((fields?: Record<string, unknown>) => {
69+
if (fields && 'value' in fields) {
70+
return { from: vi.fn(() => ({ where: vi.fn(() => ({ get })) })) };
71+
}
72+
return { from };
73+
});
6874

6975
mocks.drizzle.mockReturnValue({ select });
7076

71-
return { all, from, limit, select, where };
77+
return { all, from, get, limit, select, where };
7278
}
7379

7480
async function getBeforeCreateHook(): Promise<BeforeCreateHook> {

0 commit comments

Comments
 (0)