diff --git a/server/auth.integration.test.ts b/server/auth.integration.test.ts index 2c177cc1..cf9f7c4d 100644 --- a/server/auth.integration.test.ts +++ b/server/auth.integration.test.ts @@ -56,13 +56,36 @@ describe('registration gate — open mode', () => { expect(res.status).toBe(200) }) - it('second user can register when auth_signup_mode is explicitly open', async () => { + it('second user can register when auth_signup_mode is explicitly open and instance has Pro license', async () => { const ctx = await createTestApp() + // Simulate Pro license with open_registration feature + const cert = JSON.stringify({ + account_id: 'test', + instance_id: 'test', + plan: 'pro', + features: ['open_registration'], + issued_at: new Date().toISOString(), + expires_at: new Date(Date.now() + 86400 * 1000).toISOString(), + }) + await ctx.db.insert(schema.licenseBinding).values({ + id: 1, + instanceId: 'test', + refreshToken: 'test-token', + cachedCert: cert, + }) await ctx.db.insert(schema.systemOptions).values({ key: 'auth_signup_mode', value: 'open' }) await signUp(ctx, 'first@example.com') const res = await signUp(ctx, 'second@example.com') expect(res.status).toBe(200) }) + + it('second user is rejected when auth_signup_mode is explicitly open but instance has no Pro license', async () => { + const ctx = await createTestApp() + await ctx.db.insert(schema.systemOptions).values({ key: 'auth_signup_mode', value: 'open' }) + await signUp(ctx, 'first@example.com') + const res = await signUp(ctx, 'second@example.com') + expect(res.status).toBe(422) + }) }) describe('registration gate — closed mode', () => { diff --git a/server/auth.ts b/server/auth.ts index 950b8b8b..818f8d91 100644 --- a/server/auth.ts +++ b/server/auth.ts @@ -20,6 +20,7 @@ import type { Database } from './platform/interface' import { sendEmail } from './services/email' import { redeemInviteCode, validateInviteCode } from './services/invite' import { findPersonalOrg } from './services/org' +import { getEffectiveSignupMode } from './services/signup-mode-guard' // better-auth's default password hasher is pure-JS scrypt from @noble/hashes, // which blows past Cloudflare Workers' CPU budget and triggers error 1102. @@ -75,16 +76,6 @@ function buildDynamicSocialProviders(db: Database) { return providers } -async function getSignupMode(db: Database): Promise { - const rows = await db - .select({ value: systemOptions.value }) - .from(systemOptions) - .where(eq(systemOptions.key, 'auth_signup_mode')) - const raw = rows[0]?.value - if (raw === SignupMode.INVITE_ONLY || raw === SignupMode.CLOSED) return raw - return SignupMode.OPEN -} - async function isEmailConfigured(db: Database): Promise { const rows = await db .select({ value: systemOptions.value }) @@ -213,7 +204,7 @@ export async function createAuth(db: Database, secret: string, baseURL?: string, // Registration gate: skip for the very first user so bootstrap works if (!firstUser) { - const mode = await getSignupMode(db) + const mode = await getEffectiveSignupMode(db) if (mode === SignupMode.CLOSED) { throw new Error('Registration is currently closed') } @@ -248,7 +239,7 @@ export async function createAuth(db: Database, secret: string, baseURL?: string, }, after: async (user, context) => { // Redeem invite code after user is created (user.id is now available) - const mode = await getSignupMode(db) + const mode = await getEffectiveSignupMode(db) if (mode === SignupMode.INVITE_ONLY) { const inviteCode = (context?.body as { inviteCode?: string })?.inviteCode if (inviteCode) { diff --git a/server/routes/system.ts b/server/routes/system.ts index e3e7cdef..ffb5fd04 100644 --- a/server/routes/system.ts +++ b/server/routes/system.ts @@ -2,7 +2,9 @@ import { zValidator } from '@hono/zod-validator' import { eq } from 'drizzle-orm' import { Hono } from 'hono' import { z } from 'zod' +import { SignupMode } from '../../shared/constants' import { systemOptions } from '../db/schema' +import { hasFeature, loadBindingState } from '../licensing/has-feature' import { requireAdmin } from '../middleware/auth' import type { Env } from '../middleware/platform' @@ -36,6 +38,17 @@ const app = new Hono() const db = c.get('platform').db const key = c.req.param('key') const body = c.req.valid('json') + + if (key === 'auth_signup_mode' && body.value === SignupMode.OPEN) { + const state = await loadBindingState(db) + if (!hasFeature('open_registration', state)) { + return c.json( + { error: 'feature_not_available', feature: 'open_registration', upgrade_url: '/settings/billing' }, + 402, + ) + } + } + const existing = await db .select({ key: systemOptions.key, public: systemOptions.public }) .from(systemOptions) diff --git a/server/services/signup-mode-guard.integration.test.ts b/server/services/signup-mode-guard.integration.test.ts new file mode 100644 index 00000000..51087928 --- /dev/null +++ b/server/services/signup-mode-guard.integration.test.ts @@ -0,0 +1,218 @@ +import { describe, expect, it } from 'vitest' +import * as schema from '../db/schema.js' +import { createTestApp } from '../test/setup.js' +import { generateInviteCodes } from './invite.js' + +type TestCtx = Awaited> + +async function signUp(ctx: TestCtx, email: string, extra?: Record) { + return ctx.app.request('/api/auth/sign-up/email', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: 'Test User', email, password: 'password123456', ...extra }), + }) +} + +async function seedProLicense(ctx: TestCtx, features: string[] = ['open_registration']) { + const cert = JSON.stringify({ + account_id: 'test-account', + instance_id: 'test-instance', + plan: 'pro', + features, + issued_at: new Date().toISOString(), + expires_at: new Date(Date.now() + 86400 * 1000).toISOString(), + }) + await ctx.db.insert(schema.licenseBinding).values({ + id: 1, + instanceId: 'test-instance', + refreshToken: 'test-refresh-token', + cachedCert: cert, + }) +} + +async function seedFirstUser(ctx: TestCtx) { + await signUp(ctx, 'admin@example.com') +} + +// ─── open mode × Pro ───────────────────────────────────────────────────────── + +describe('open mode (Pro instance)', () => { + it('second user can register without invite code', async () => { + const ctx = await createTestApp() + await seedProLicense(ctx) + await ctx.db.insert(schema.systemOptions).values({ key: 'auth_signup_mode', value: 'open' }) + await seedFirstUser(ctx) + const res = await signUp(ctx, 'second@example.com') + expect(res.status).toBe(200) + }) + + it('third user can also register without invite code', async () => { + const ctx = await createTestApp() + await seedProLicense(ctx) + await ctx.db.insert(schema.systemOptions).values({ key: 'auth_signup_mode', value: 'open' }) + await seedFirstUser(ctx) + await signUp(ctx, 'second@example.com') + const res = await signUp(ctx, 'third@example.com') + expect(res.status).toBe(200) + }) +}) + +// ─── open mode × non-Pro (retroactive gate) ────────────────────────────────── + +describe('open mode (non-Pro instance) — retroactive gate', () => { + it('second user is rejected when stored mode is open but instance has no Pro license', async () => { + const ctx = await createTestApp() + await ctx.db.insert(schema.systemOptions).values({ key: 'auth_signup_mode', value: 'open' }) + await seedFirstUser(ctx) + const res = await signUp(ctx, 'second@example.com') + expect(res.status).not.toBe(200) + }) + + it('rejection returns 422 (same as invite_only behaviour)', async () => { + const ctx = await createTestApp() + await ctx.db.insert(schema.systemOptions).values({ key: 'auth_signup_mode', value: 'open' }) + await seedFirstUser(ctx) + const res = await signUp(ctx, 'second@example.com') + expect(res.status).toBe(422) + }) + + it('second user can register when a valid invite code is supplied (falls back to invite-only)', async () => { + const ctx = await createTestApp() + await ctx.db.insert(schema.systemOptions).values({ key: 'auth_signup_mode', value: 'open' }) + await seedFirstUser(ctx) + const [codeRow] = await generateInviteCodes(ctx.db, 'admin-1', 1) + const res = await signUp(ctx, 'invited@example.com', { inviteCode: codeRow.code }) + expect(res.status).toBe(200) + }) +}) + +// ─── invite_only mode × Pro ────────────────────────────────────────────────── + +describe('invite_only mode (Pro instance)', () => { + it('second user is rejected without invite code', async () => { + const ctx = await createTestApp() + await seedProLicense(ctx) + await ctx.db.insert(schema.systemOptions).values({ key: 'auth_signup_mode', value: 'invite_only' }) + await seedFirstUser(ctx) + const res = await signUp(ctx, 'noinvite@example.com') + expect(res.status).not.toBe(200) + }) + + it('second user can register with valid invite code', async () => { + const ctx = await createTestApp() + await seedProLicense(ctx) + await ctx.db.insert(schema.systemOptions).values({ key: 'auth_signup_mode', value: 'invite_only' }) + await seedFirstUser(ctx) + const [codeRow] = await generateInviteCodes(ctx.db, 'admin-1', 1) + const res = await signUp(ctx, 'invited@example.com', { inviteCode: codeRow.code }) + expect(res.status).toBe(200) + }) +}) + +// ─── invite_only mode × non-Pro ────────────────────────────────────────────── + +describe('invite_only mode (non-Pro instance)', () => { + it('second user is rejected without invite code', async () => { + const ctx = await createTestApp() + await ctx.db.insert(schema.systemOptions).values({ key: 'auth_signup_mode', value: 'invite_only' }) + await seedFirstUser(ctx) + const res = await signUp(ctx, 'noinvite@example.com') + expect(res.status).not.toBe(200) + }) + + it('second user can register with valid invite code', async () => { + const ctx = await createTestApp() + await ctx.db.insert(schema.systemOptions).values({ key: 'auth_signup_mode', value: 'invite_only' }) + await seedFirstUser(ctx) + const [codeRow] = await generateInviteCodes(ctx.db, 'admin-1', 1) + const res = await signUp(ctx, 'invited@example.com', { inviteCode: codeRow.code }) + expect(res.status).toBe(200) + }) +}) + +// ─── closed mode × Pro ─────────────────────────────────────────────────────── + +describe('closed mode (Pro instance)', () => { + it('second user is rejected', async () => { + const ctx = await createTestApp() + await seedProLicense(ctx) + await ctx.db.insert(schema.systemOptions).values({ key: 'auth_signup_mode', value: 'closed' }) + await seedFirstUser(ctx) + const res = await signUp(ctx, 'second@example.com') + expect(res.status).not.toBe(200) + }) +}) + +// ─── closed mode × non-Pro ─────────────────────────────────────────────────── + +describe('closed mode (non-Pro instance)', () => { + it('second user is rejected', async () => { + const ctx = await createTestApp() + await ctx.db.insert(schema.systemOptions).values({ key: 'auth_signup_mode', value: 'closed' }) + await seedFirstUser(ctx) + const res = await signUp(ctx, 'second@example.com') + expect(res.status).not.toBe(200) + }) +}) + +// ─── PUT /api/system/options/auth_signup_mode ───────────────────────────────── + +describe('PUT auth_signup_mode via admin API', () => { + async function adminHeaders(ctx: TestCtx) { + await signUp(ctx, 'admin@example.com') + const signInRes = await ctx.app.request('/api/auth/sign-in/email', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email: 'admin@example.com', password: 'password123456' }), + }) + return { Cookie: signInRes.headers.getSetCookie().join('; ') } + } + + async function putSignupMode(ctx: TestCtx, headers: Record, value: string) { + return ctx.app.request('/api/system/options/auth_signup_mode', { + method: 'PUT', + headers: { ...headers, 'Content-Type': 'application/json' }, + body: JSON.stringify({ value, public: true }), + }) + } + + it('setting open without Pro returns 402', async () => { + const ctx = await createTestApp() + const headers = await adminHeaders(ctx) + const res = await putSignupMode(ctx, headers, 'open') + expect(res.status).toBe(402) + const body = (await res.json()) as { error: string; feature: string } + expect(body.error).toBe('feature_not_available') + expect(body.feature).toBe('open_registration') + }) + + it('setting open with Pro succeeds', async () => { + const ctx = await createTestApp() + await seedProLicense(ctx) + const headers = await adminHeaders(ctx) + const res = await putSignupMode(ctx, headers, 'open') + expect(res.status).toBe(201) + }) + + it('setting invite_only without Pro succeeds', async () => { + const ctx = await createTestApp() + const headers = await adminHeaders(ctx) + const res = await putSignupMode(ctx, headers, 'invite_only') + expect(res.status).toBe(201) + }) + + it('setting closed without Pro succeeds', async () => { + const ctx = await createTestApp() + const headers = await adminHeaders(ctx) + const res = await putSignupMode(ctx, headers, 'closed') + expect(res.status).toBe(201) + }) + + it('setting invite_only with Pro succeeds', async () => { + const ctx = await createTestApp() + await seedProLicense(ctx) + const headers = await adminHeaders(ctx) + const res = await putSignupMode(ctx, headers, 'invite_only') + expect(res.status).toBe(201) + }) +}) diff --git a/server/services/signup-mode-guard.ts b/server/services/signup-mode-guard.ts new file mode 100644 index 00000000..71fe4b7e --- /dev/null +++ b/server/services/signup-mode-guard.ts @@ -0,0 +1,28 @@ +import { eq } from 'drizzle-orm' +import { SignupMode } from '../../shared/constants' +import { systemOptions } from '../db/schema' +import { hasFeature, loadBindingState } from '../licensing/has-feature' +import type { Database } from '../platform/interface' + +/** + * Returns the effective signup mode. + * + * Rule: `open` requires the `open_registration` Pro feature. Without it the + * effective mode falls back to `invite-only`. All other stored values + * (invite_only, closed) are returned unchanged. Unknown/empty values retain + * the existing default-to-open behaviour and are not subject to the Pro check. + */ +export async function getEffectiveSignupMode(db: Database): Promise { + const rows = await db + .select({ value: systemOptions.value }) + .from(systemOptions) + .where(eq(systemOptions.key, 'auth_signup_mode')) + const raw = rows[0]?.value + + if (raw === SignupMode.INVITE_ONLY || raw === SignupMode.CLOSED) return raw + if (raw !== SignupMode.OPEN) return SignupMode.OPEN // unknown/empty → open (existing behaviour) + + // Stored value is explicitly 'open' — gate behind Pro feature + const state = await loadBindingState(db) + return hasFeature('open_registration', state) ? SignupMode.OPEN : SignupMode.INVITE_ONLY +} diff --git a/src/components/admin/registration-mode-section.tsx b/src/components/admin/registration-mode-section.tsx index c52e1f99..8e01a086 100644 --- a/src/components/admin/registration-mode-section.tsx +++ b/src/components/admin/registration-mode-section.tsx @@ -1,9 +1,14 @@ import { SignupMode } from '@shared/constants' import { useMutation, useQueryClient } from '@tanstack/react-query' +import { useState } from 'react' import { useTranslation } from 'react-i18next' import { toast } from 'sonner' +import { ProBadge } from '@/components/ProBadge' +import { UpgradeHint } from '@/components/UpgradeHint' +import { Dialog, DialogContent } from '@/components/ui/dialog' import { Label } from '@/components/ui/label' import { siteOptionsQueryKey, useSiteOptions } from '@/hooks/use-site-options' +import { useEntitlement } from '@/hooks/useEntitlement' import { setSystemOption } from '@/lib/api' const modes = [ @@ -24,6 +29,9 @@ export function RegistrationModeSection() { const { t } = useTranslation() const queryClient = useQueryClient() const { authSignupMode } = useSiteOptions() + const { hasFeature } = useEntitlement() + const [upgradeOpen, setUpgradeOpen] = useState(false) + const hasOpenReg = hasFeature('open_registration') const mutation = useMutation({ mutationFn: (mode: string) => setSystemOption('auth_signup_mode', mode, true), @@ -34,28 +42,53 @@ export function RegistrationModeSection() { onError: (err) => toast.error(err.message), }) + function handleModeChange(mode: string) { + if (mode === SignupMode.OPEN && !hasOpenReg) { + setUpgradeOpen(true) + return + } + mutation.mutate(mode) + } + return ( -
-

{t('admin.auth.registrationSection')}

-
- {modes.map((mode) => ( - - ))} + <> +
+

{t('admin.auth.registrationSection')}

+
+ {modes.map((mode) => { + const isOpenGated = mode.value === SignupMode.OPEN && !hasOpenReg + return ( + + ) + })} +
-
+ + + + + + ) }