feat: retroactive gate — per-team storage quota Pro-only (Z10) (#349)

- server/routes/quotas.ts: gate PUT /:orgId with requireFeature('team_quotas') → 402 on Community
- server/services/matter.ts: add teamQuotaEnabled param to incrementUsageIfAllowed and confirmUpload; when false, skip per-team quota check but still track storage usage
- server/services/save-to-drive.ts: thread teamQuotaEnabled through saveShareToDrive → saveFile/saveFolderRecursive
- server/routes/objects.ts: check hasFeature('team_quotas') before confirmUpload
- server/routes/shares.ts: skip isQuotaSufficient pre-check and pass teamQuotaEnabled to saveShareToDriveService when not Pro
- src/routes/_authenticated/admin/users/index.tsx: hide quota column/button behind useEntitlement('team_quotas'); show UpgradeHint when not Pro
- server/test/setup.ts: add seedProLicense helper for integration tests
- Update affected integration tests to seed Pro license where quota enforcement is expected

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 09:03:15 -04:00
committed by GitHub
co-authored by Bob
parent de5ff92f03
commit 3f2890c6ea
11 changed files with 158 additions and 58 deletions
@@ -3,7 +3,7 @@ import { nanoid } from 'nanoid'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { orgQuotas } from '../db/schema.js'
import { S3Service } from '../services/s3.js'
import { authedHeaders, createTestApp } from '../test/setup.js'
import { authedHeaders, createTestApp, seedProLicense } from '../test/setup.js'
beforeEach(() => {
vi.restoreAllMocks()
@@ -206,6 +206,7 @@ describe('POST /api/objects/copy — quota enforcement', () => {
describe('PATCH /api/objects/:id (action: confirm) — quota enforcement via confirmUpload', () => {
it('returns 200 and increments usage when quota allows', async () => {
const { app, db } = await createTestApp()
await seedProLicense(db)
const headers = await authedHeaders(app)
await insertStorage(db)
const orgId = await getOrgId(db)
@@ -245,6 +246,7 @@ describe('PATCH /api/objects/:id (action: confirm) — quota enforcement via con
it('returns 422 when confirming upload would exceed quota', async () => {
const { app, db } = await createTestApp()
await seedProLicense(db)
const headers = await authedHeaders(app)
await insertStorage(db)
const orgId = await getOrgId(db)
+3
View File
@@ -9,6 +9,7 @@ import {
patchMatterSchema,
} from '../../shared/schemas'
import type { Storage as S3Storage } from '../../shared/types'
import { hasFeature, loadBindingState } from '../licensing/has-feature'
import { requireAuth, requireTeamRole } from '../middleware/auth'
import type { Env } from '../middleware/platform'
import {
@@ -198,9 +199,11 @@ const app = new Hono<Env>()
}
case 'confirm': {
try {
const state = await loadBindingState(db)
const { matter, quotaExceeded } = await confirmUpload(db, c.req.param('id'), orgId, {
onConflict: body.onConflict,
userId,
teamQuotaEnabled: hasFeature('team_quotas', state),
})
if (quotaExceeded) return c.json({ error: 'Quota exceeded' }, 422)
if (!matter) return c.json({ error: 'Not found or not in draft status' }, 404)
+27 -2
View File
@@ -1,6 +1,6 @@
import { sql } from 'drizzle-orm'
import { describe, expect, it } from 'vitest'
import { authedHeaders, createTestApp } from '../test/setup.js'
import { authedHeaders, createTestApp, seedProLicense } from '../test/setup.js'
async function adminHeaders(app: ReturnType<typeof import('../app')['createApp']>) {
// Sign up first user (gets promoted to admin via hook)
@@ -48,6 +48,7 @@ describe('Admin Quotas API', () => {
it('PUT /api/admin/quotas/:orgId creates quota for org', async () => {
const { app, db } = await createTestApp()
await seedProLicense(db)
const headers = await adminHeaders(app)
// Find the admin's personal org
@@ -73,6 +74,7 @@ describe('Admin Quotas API', () => {
it('PUT /api/admin/quotas/:orgId updates existing quota', async () => {
const { app, db } = await createTestApp()
await seedProLicense(db)
const headers = await adminHeaders(app)
const orgs = await db.all<{ id: string }>(
@@ -99,7 +101,8 @@ describe('Admin Quotas API', () => {
})
it('PUT /api/admin/quotas/:orgId rejects negative quota', async () => {
const { app } = await createTestApp()
const { app, db } = await createTestApp()
await seedProLicense(db)
const headers = await adminHeaders(app)
const res = await app.request('/api/admin/quotas/some-org', {
method: 'PUT',
@@ -109,8 +112,29 @@ describe('Admin Quotas API', () => {
expect(res.status).toBe(400)
})
it('PUT /api/admin/quotas/:orgId returns 402 without Pro license', async () => {
const { app, db } = await createTestApp()
const headers = await adminHeaders(app)
const orgs = await db.all<{ id: string }>(
sql`SELECT o.id FROM organization o WHERE o.metadata LIKE '%"type":"personal"%' LIMIT 1`,
)
const orgId = orgs[0].id
const res = await app.request(`/api/admin/quotas/${orgId}`, {
method: 'PUT',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ quota: 1000 }),
})
expect(res.status).toBe(402)
const body = (await res.json()) as Record<string, unknown>
expect(body.error).toBe('feature_not_available')
expect(body.feature).toBe('team_quotas')
})
it('GET /api/admin/quotas lists quotas with org info', async () => {
const { app, db } = await createTestApp()
await seedProLicense(db)
const headers = await adminHeaders(app)
const orgs = await db.all<{ id: string }>(
@@ -173,6 +197,7 @@ describe('User Quotas API — /api/quotas', () => {
it('GET /api/quotas/me returns quota after admin sets it', async () => {
const { app, db } = await createTestApp()
await seedProLicense(db)
const adminH = await adminHeaders(app)
// Find admin's org
+2 -1
View File
@@ -7,6 +7,7 @@ import { organization } from '../db/auth-schema'
import { orgQuotas } from '../db/schema'
import { requireAdmin, requireAuth } from '../middleware/auth'
import type { Env } from '../middleware/platform'
import { requireFeature } from '../middleware/require-feature'
import { findPersonalOrg } from '../services/org'
const updateQuotaSchema = z.object({
@@ -41,7 +42,7 @@ const adminQuotas = new Hono<Env>()
return c.json({ items, total: items.length })
})
.put('/:orgId', zValidator('json', updateQuotaSchema), async (c) => {
.put('/:orgId', requireFeature('team_quotas'), zValidator('json', updateQuotaSchema), async (c) => {
const db = c.get('platform').db
const orgId = c.req.param('orgId')
const { quota } = c.req.valid('json')
+2 -1
View File
@@ -4,7 +4,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { shareRecipients, shares } from '../db/schema.js'
import * as emailService from '../services/email.js'
import { S3Service } from '../services/s3.js'
import { authedHeaders, createTestApp } from '../test/setup.js'
import { authedHeaders, createTestApp, seedProLicense } from '../test/setup.js'
type TestApp = Awaited<ReturnType<typeof createTestApp>>['app']
type TestDb = Awaited<ReturnType<typeof createTestApp>>['db']
@@ -628,6 +628,7 @@ describe('POST /api/shares/:token/objects', () => {
it('returns 400 QUOTA_EXCEEDED when target org quota is exhausted', async () => {
const { app, db } = await createTestApp()
await seedProLicense(db)
const headers = await authedHeaders(app)
await insertStorage(db)
const orgId = await getOrgId(db)
+11 -4
View File
@@ -8,6 +8,7 @@ import { createShareRequestSchema, listSharesQuerySchema, saveShareRequestSchema
import type { Storage as S3Storage } from '../../shared/types'
import { user } from '../db/auth-schema'
import { matters } from '../db/schema'
import { hasFeature, loadBindingState } from '../licensing/has-feature'
import { requireAuth, requireTeamRole } from '../middleware/auth'
import type { Env } from '../middleware/platform'
import { listMatters } from '../services/matter'
@@ -404,10 +405,15 @@ export const authedShares = new Hono<Env>()
return c.json({ error: 'Forbidden' }, 403)
}
const totalBytes = await computeSourceBytes(db, matter)
const quotaOk = await isQuotaSufficient(db, targetOrgId, totalBytes)
if (!quotaOk) {
return c.json({ error: 'Quota exceeded', code: 'QUOTA_EXCEEDED' }, 400)
const state = await loadBindingState(db)
const teamQuotaEnabled = hasFeature('team_quotas', state)
if (teamQuotaEnabled) {
const totalBytes = await computeSourceBytes(db, matter)
const quotaOk = await isQuotaSufficient(db, targetOrgId, totalBytes)
if (!quotaOk) {
return c.json({ error: 'Quota exceeded', code: 'QUOTA_EXCEEDED' }, 400)
}
}
const result = await saveShareToDriveService(db, {
@@ -416,6 +422,7 @@ export const authedShares = new Hono<Env>()
currentUserId,
targetOrgId,
targetParent,
teamQuotaEnabled,
})
return c.json(result, 201)
})
+20 -17
View File
@@ -251,7 +251,7 @@ export async function confirmUpload(
db: Database,
id: string,
orgId: string,
opts: { onConflict?: ConflictStrategy; userId?: string } = {},
opts: { onConflict?: ConflictStrategy; userId?: string; teamQuotaEnabled?: boolean } = {},
): Promise<{ matter: Matter | null; quotaExceeded?: boolean }> {
const existing = await getMatter(db, id, orgId)
if (!existing) return { matter: null }
@@ -270,7 +270,7 @@ export async function confirmUpload(
const bytes = existing.size ?? 0
if (bytes > 0) {
const allowed = await incrementUsageIfAllowed(db, orgId, existing.storageId, bytes)
const allowed = await incrementUsageIfAllowed(db, orgId, existing.storageId, bytes, opts.teamQuotaEnabled ?? true)
if (!allowed) return { matter: null, quotaExceeded: true }
}
@@ -300,24 +300,27 @@ export async function incrementUsageIfAllowed(
orgId: string,
storageId: string,
bytes: number,
teamQuotaEnabled = true,
): Promise<boolean> {
// Check if a quota row exists and try atomic check-and-increment in one flow
const rows = await db
.select({ quota: orgQuotas.quota, used: orgQuotas.used })
.from(orgQuotas)
.where(eq(orgQuotas.orgId, orgId))
const [row] = rows
if (row) {
// quota=0 means unlimited; otherwise enforce the limit
if (row.quota > 0 && row.used + bytes > row.quota) return false
await db
.update(orgQuotas)
.set({ used: sql`${orgQuotas.used} + ${bytes}` })
if (teamQuotaEnabled) {
// Check if a quota row exists and try atomic check-and-increment in one flow
const rows = await db
.select({ quota: orgQuotas.quota, used: orgQuotas.used })
.from(orgQuotas)
.where(eq(orgQuotas.orgId, orgId))
const [row] = rows
if (row) {
// quota=0 means unlimited; otherwise enforce the limit
if (row.quota > 0 && row.used + bytes > row.quota) return false
await db
.update(orgQuotas)
.set({ used: sql`${orgQuotas.used} + ${bytes}` })
.where(eq(orgQuotas.orgId, orgId))
}
// No quota row means no org-level limit — allow, but still track per-storage usage
}
// No quota row means no org-level limit — allow, but still track per-storage usage
await db
.update(storages)
@@ -4,7 +4,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { DirType } from '../../shared/constants'
import { activityEvents, matters, orgQuotas, shares } from '../db/schema'
import { S3Service } from '../services/s3.js'
import { authedHeaders, createTestApp } from '../test/setup.js'
import { authedHeaders, createTestApp, seedProLicense } from '../test/setup.js'
import { computeSourceBytes, isQuotaSufficient, saveShareToDrive } from './save-to-drive.js'
import { createShare, resolveShareByToken } from './share.js'
@@ -604,6 +604,7 @@ describe('POST /api/shares/:token/objects', () => {
it('returns 400 QUOTA_EXCEEDED when target org has insufficient quota', async () => {
const { app, db, share, headers } = await setup()
await seedProLicense(db)
// Get the current user's ID to add them as editor in the quota-restricted org
const sessionRes = await app.request('/api/auth/get-session', { headers: new Headers(headers) })
+28 -4
View File
@@ -21,6 +21,7 @@ export interface SaveShareInput {
currentUserId: string
targetOrgId: string
targetParent: string
teamQuotaEnabled?: boolean
}
export interface SaveShareResult {
@@ -90,11 +91,12 @@ async function saveFile(
targetOrgId: string,
targetParent: string,
shareId: string,
teamQuotaEnabled = true,
): Promise<Matter> {
const bytes = sourceMatter.size ?? 0
if (bytes > 0) {
const allowed = await incrementUsageIfAllowed(db, targetOrgId, targetStorage.id, bytes)
const allowed = await incrementUsageIfAllowed(db, targetOrgId, targetStorage.id, bytes, teamQuotaEnabled)
if (!allowed) throw new Error('QUOTA_EXCEEDED')
}
@@ -143,6 +145,7 @@ async function saveFolderRecursive(
targetOrgId: string,
targetParent: string,
shareId: string,
teamQuotaEnabled = true,
): Promise<SaveShareResult> {
const saved: Matter[] = []
const skipped: Array<{ name: string; reason: string }> = []
@@ -185,6 +188,7 @@ async function saveFolderRecursive(
targetOrgId,
targetPath,
shareId,
teamQuotaEnabled,
)
saved.push(newFile)
} catch (e) {
@@ -218,7 +222,7 @@ async function saveFolderRecursive(
// ─── Public API ───────────────────────────────────────────────────────────────
export async function saveShareToDrive(db: Database, input: SaveShareInput): Promise<SaveShareResult> {
const { share, matter: sourceMatter, currentUserId, targetOrgId, targetParent } = input
const { share, matter: sourceMatter, currentUserId, targetOrgId, targetParent, teamQuotaEnabled = true } = input
const sourceStorage = await getStorage(db, sourceMatter.storageId)
if (!sourceStorage) throw new Error('Source storage not found')
@@ -229,9 +233,29 @@ export async function saveShareToDrive(db: Database, input: SaveShareInput): Pro
const dst = targetStorage as unknown as S3StorageType
if (sourceMatter.dirtype === DirType.FILE) {
const newMatter = await saveFile(db, sourceMatter, src, dst, currentUserId, targetOrgId, targetParent, share.id)
const newMatter = await saveFile(
db,
sourceMatter,
src,
dst,
currentUserId,
targetOrgId,
targetParent,
share.id,
teamQuotaEnabled,
)
return { saved: [newMatter], skipped: [] }
}
return saveFolderRecursive(db, sourceMatter, src, dst, currentUserId, targetOrgId, targetParent, share.id)
return saveFolderRecursive(
db,
sourceMatter,
src,
dst,
currentUserId,
targetOrgId,
targetParent,
share.id,
teamQuotaEnabled,
)
}
+17
View File
@@ -1,4 +1,5 @@
import Database from 'better-sqlite3'
import { sql } from 'drizzle-orm'
import { drizzle } from 'drizzle-orm/better-sqlite3'
import { createApp } from '../app'
import { createAuth } from '../auth'
@@ -318,3 +319,19 @@ export async function authedHeaders(
const cookies = signUpRes.headers.getSetCookie()
return { Cookie: cookies.join('; ') }
}
/**
* Insert a Pro license binding row so that feature gates resolve as enabled.
* Pass specific features to restrict which Pro features are active; defaults
* to all four.
*/
export async function seedProLicense(
db: Awaited<ReturnType<typeof createTestApp>>['db'],
features: string[] = ['white_label', 'open_registration', 'teams_unlimited', 'team_quotas'],
) {
const cert = JSON.stringify({ plan: 'pro', features })
await db.run(sql`
INSERT OR REPLACE INTO license_binding (id, instance_id, refresh_token, cached_cert, bound_at)
VALUES (1, 'test-instance', 'test-refresh-token', ${cert}, ${Date.now()})
`)
}
+43 -27
View File
@@ -6,8 +6,10 @@ import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { DeleteUserDialog } from '@/components/admin/delete-user-dialog'
import { UserQuotaDialog } from '@/components/admin/user-quota-dialog'
import { UpgradeHint } from '@/components/UpgradeHint'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { useEntitlement } from '@/hooks/useEntitlement'
import { listQuotas, listUsers, type QuotaItem, type UserWithOrg, updateUserStatus } from '@/lib/api'
export const Route = createFileRoute('/_authenticated/admin/users/')({
@@ -22,6 +24,8 @@ interface UserRow extends UserWithOrg {
function UsersPage() {
const { t } = useTranslation()
const queryClient = useQueryClient()
const { hasFeature } = useEntitlement()
const teamQuotasEnabled = hasFeature('team_quotas')
const [search, setSearch] = useState('')
const [page, setPage] = useState(1)
const pageSize = 20
@@ -37,6 +41,7 @@ function UsersPage() {
const quotasQuery = useQuery({
queryKey: ['admin', 'quotas'],
queryFn: listQuotas,
enabled: teamQuotasEnabled,
})
const toggleStatusMutation = useMutation({
@@ -77,7 +82,7 @@ function UsersPage() {
const total = usersQuery.data?.total ?? 0
const totalPages = Math.max(1, Math.ceil(total / pageSize))
const isLoading = usersQuery.isLoading || quotasQuery.isLoading
const isLoading = usersQuery.isLoading || (teamQuotasEnabled && quotasQuery.isLoading)
function handleSearchChange(e: React.ChangeEvent<HTMLInputElement>) {
setSearch(e.target.value)
@@ -115,7 +120,9 @@ function UsersPage() {
<th className="hidden px-4 py-3 text-left font-medium sm:table-cell">{t('admin.users.colEmail')}</th>
<th className="px-4 py-3 text-left font-medium">{t('admin.users.colRole')}</th>
<th className="px-4 py-3 text-left font-medium">{t('admin.users.colStatus')}</th>
<th className="hidden px-4 py-3 text-left font-medium md:table-cell">{t('admin.users.colQuota')}</th>
{teamQuotasEnabled && (
<th className="hidden px-4 py-3 text-left font-medium md:table-cell">{t('admin.users.colQuota')}</th>
)}
<th className="hidden px-4 py-3 text-left font-medium lg:table-cell">{t('admin.users.colCreatedAt')}</th>
<th className="px-4 py-3 text-right font-medium">{t('admin.users.colActions')}</th>
</tr>
@@ -126,6 +133,7 @@ function UsersPage() {
key={user.id}
user={user}
isToggling={toggleStatusMutation.isPending}
showQuota={teamQuotasEnabled}
onSetQuota={() => setQuotaDialogUser(user)}
onToggleStatus={() =>
toggleStatusMutation.mutate({
@@ -138,7 +146,7 @@ function UsersPage() {
))}
{filtered.length === 0 && (
<tr>
<td colSpan={7} className="px-4 py-8 text-center text-muted-foreground">
<td colSpan={teamQuotasEnabled ? 7 : 6} className="px-4 py-8 text-center text-muted-foreground">
{t('admin.users.noUsers')}
</td>
</tr>
@@ -161,20 +169,24 @@ function UsersPage() {
</div>
)}
<UserQuotaDialog
open={quotaDialogUser !== null}
onOpenChange={(open) => !open && setQuotaDialogUser(null)}
user={
quotaDialogUser?.orgId
? {
name: quotaDialogUser.name || quotaDialogUser.username,
orgId: quotaDialogUser.orgId,
quotaUsed: quotaDialogUser.quotaUsed,
quotaTotal: quotaDialogUser.quotaTotal,
}
: null
}
/>
{!teamQuotasEnabled && <UpgradeHint feature="team_quotas" />}
{teamQuotasEnabled && (
<UserQuotaDialog
open={quotaDialogUser !== null}
onOpenChange={(open) => !open && setQuotaDialogUser(null)}
user={
quotaDialogUser?.orgId
? {
name: quotaDialogUser.name || quotaDialogUser.username,
orgId: quotaDialogUser.orgId,
quotaUsed: quotaDialogUser.quotaUsed,
quotaTotal: quotaDialogUser.quotaTotal,
}
: null
}
/>
)}
<DeleteUserDialog
open={deleteDialogUser !== null}
@@ -188,12 +200,14 @@ function UsersPage() {
function UserTableRow({
user,
isToggling,
showQuota,
onSetQuota,
onToggleStatus,
onDelete,
}: {
user: UserRow
isToggling: boolean
showQuota: boolean
onSetQuota: () => void
onToggleStatus: () => void
onDelete: () => void
@@ -220,19 +234,21 @@ function UserTableRow({
{user.banned ? t('admin.users.disabled') : t('admin.users.active')}
</span>
</td>
<td className="hidden px-4 py-3 text-muted-foreground md:table-cell">{quotaLabel}</td>
{showQuota && <td className="hidden px-4 py-3 text-muted-foreground md:table-cell">{quotaLabel}</td>}
<td className="hidden px-4 py-3 text-muted-foreground lg:table-cell">{formatDate(user.createdAt)}</td>
<td className="px-4 py-3">
<div className="flex items-center justify-end gap-1">
<Button
variant="ghost"
size="icon-xs"
disabled={!user.orgId}
onClick={onSetQuota}
title={t('admin.users.setQuota')}
>
<Settings2 />
</Button>
{showQuota && (
<Button
variant="ghost"
size="icon-xs"
disabled={!user.orgId}
onClick={onSetQuota}
title={t('admin.users.setQuota')}
>
<Settings2 />
</Button>
)}
<Button
variant="ghost"
size="icon-xs"