mirror of
https://github.com/saltbo/zpan.git
synced 2026-09-01 15:49:00 +08:00
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:
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user