feat: gate open registration behind Pro feature (Z8) (#347)

- Add server/services/signup-mode-guard.ts: getEffectiveSignupMode()
  applies Pro check when stored mode is 'open'; non-Pro falls back to
  invite-only so downgraded instances stay secure
- Update server/auth.ts: replace internal getSignupMode() with
  getEffectiveSignupMode() from new service
- Update server/routes/system.ts: PUT auth_signup_mode=open returns 402
  feature_not_available when open_registration feature is absent
- Update RegistrationModeSection: 'open' radio disabled with ProBadge
  for non-Pro; clicking it opens UpgradeHint dialog instead of saving
- Add integration tests: 6-combination matrix (3 modes × 2 plans) plus
  admin API guard tests
- Update auth.integration.test.ts: split open-mode tests for Pro/non-Pro

Agent-Profile: https://agent-kanban.dev/agents/a6bb038c4226a87f

Co-authored-by: Bob <aibob@mails.agent-kanban.dev>
This commit is contained in:
Jasper Van
2026-04-24 08:40:28 -04:00
committed by GitHub
parent 29102e623d
commit 1cbe92640c
6 changed files with 340 additions and 34 deletions
+24 -1
View File
@@ -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', () => {
+3 -12
View File
@@ -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<SignupMode> {
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<boolean> {
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) {
+13
View File
@@ -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<Env>()
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)
@@ -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<ReturnType<typeof createTestApp>>
async function signUp(ctx: TestCtx, email: string, extra?: Record<string, unknown>) {
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<string, string>, 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)
})
})
+28
View File
@@ -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<SignupMode> {
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
}
@@ -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 (
<div className="space-y-4 rounded-md border p-4">
<h3 className="text-sm font-medium text-muted-foreground">{t('admin.auth.registrationSection')}</h3>
<div className="space-y-3">
{modes.map((mode) => (
<Label key={mode.value} className="flex items-start gap-3 cursor-pointer">
<input
type="radio"
name="signupMode"
value={mode.value}
checked={authSignupMode === mode.value}
onChange={() => mutation.mutate(mode.value)}
disabled={mutation.isPending}
className="mt-1"
/>
<div>
<div className="font-medium">{t(mode.labelKey)}</div>
<div className="text-xs text-muted-foreground">{t(mode.descKey)}</div>
</div>
</Label>
))}
<>
<div className="space-y-4 rounded-md border p-4">
<h3 className="text-sm font-medium text-muted-foreground">{t('admin.auth.registrationSection')}</h3>
<div className="space-y-3">
{modes.map((mode) => {
const isOpenGated = mode.value === SignupMode.OPEN && !hasOpenReg
return (
<Label
key={mode.value}
className="flex items-start gap-3 cursor-pointer"
onClick={isOpenGated ? () => setUpgradeOpen(true) : undefined}
>
<input
type="radio"
name="signupMode"
value={mode.value}
checked={authSignupMode === mode.value}
onChange={() => handleModeChange(mode.value)}
disabled={mutation.isPending || isOpenGated}
className="mt-1"
/>
<div>
<div className="font-medium flex items-center gap-2">
{t(mode.labelKey)}
{isOpenGated && <ProBadge />}
</div>
<div className="text-xs text-muted-foreground">{t(mode.descKey)}</div>
</div>
</Label>
)
})}
</div>
</div>
</div>
<Dialog open={upgradeOpen} onOpenChange={setUpgradeOpen}>
<DialogContent>
<UpgradeHint feature="open_registration" />
</DialogContent>
</Dialog>
</>
)
}