From 3f2890c6eaf261e9bfeb76c4b2e640c43405afd7 Mon Sep 17 00:00:00 2001 From: Jasper Van Date: Fri, 24 Apr 2026 09:03:15 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20retroactive=20gate=20=E2=80=94=20per-te?= =?UTF-8?q?am=20storage=20quota=20Pro-only=20(Z10)=20(#349)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../routes/objects-quota.integration.test.ts | 4 +- server/routes/objects.ts | 3 + server/routes/quotas.integration.test.ts | 29 +++++++- server/routes/quotas.ts | 3 +- server/routes/shares.integration.test.ts | 3 +- server/routes/shares.ts | 15 ++-- server/services/matter.ts | 37 +++++----- .../save-to-drive.integration.test.ts | 3 +- server/services/save-to-drive.ts | 32 +++++++-- server/test/setup.ts | 17 +++++ .../_authenticated/admin/users/index.tsx | 70 ++++++++++++------- 11 files changed, 158 insertions(+), 58 deletions(-) diff --git a/server/routes/objects-quota.integration.test.ts b/server/routes/objects-quota.integration.test.ts index dedaaf5b..42bec6a7 100644 --- a/server/routes/objects-quota.integration.test.ts +++ b/server/routes/objects-quota.integration.test.ts @@ -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) diff --git a/server/routes/objects.ts b/server/routes/objects.ts index 969ff9a0..fbfabc15 100644 --- a/server/routes/objects.ts +++ b/server/routes/objects.ts @@ -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() } 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) diff --git a/server/routes/quotas.integration.test.ts b/server/routes/quotas.integration.test.ts index 6e8c5c1c..506ed8b2 100644 --- a/server/routes/quotas.integration.test.ts +++ b/server/routes/quotas.integration.test.ts @@ -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) { // 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 + 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 diff --git a/server/routes/quotas.ts b/server/routes/quotas.ts index 75315c9a..9e06f769 100644 --- a/server/routes/quotas.ts +++ b/server/routes/quotas.ts @@ -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() 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') diff --git a/server/routes/shares.integration.test.ts b/server/routes/shares.integration.test.ts index 443bf9b6..e4ec80c1 100644 --- a/server/routes/shares.integration.test.ts +++ b/server/routes/shares.integration.test.ts @@ -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>['app'] type TestDb = Awaited>['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) diff --git a/server/routes/shares.ts b/server/routes/shares.ts index 19ece369..0f3840a2 100644 --- a/server/routes/shares.ts +++ b/server/routes/shares.ts @@ -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() 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() currentUserId, targetOrgId, targetParent, + teamQuotaEnabled, }) return c.json(result, 201) }) diff --git a/server/services/matter.ts b/server/services/matter.ts index fdce6157..33661fa9 100644 --- a/server/services/matter.ts +++ b/server/services/matter.ts @@ -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 { - // 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) diff --git a/server/services/save-to-drive.integration.test.ts b/server/services/save-to-drive.integration.test.ts index 5978ef5c..517b07dc 100644 --- a/server/services/save-to-drive.integration.test.ts +++ b/server/services/save-to-drive.integration.test.ts @@ -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) }) diff --git a/server/services/save-to-drive.ts b/server/services/save-to-drive.ts index 766acaaa..a8fd0f39 100644 --- a/server/services/save-to-drive.ts +++ b/server/services/save-to-drive.ts @@ -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 { 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 { 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 { - 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, + ) } diff --git a/server/test/setup.ts b/server/test/setup.ts index 6b17eef2..ebe0b7ea 100644 --- a/server/test/setup.ts +++ b/server/test/setup.ts @@ -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>['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()}) + `) +} diff --git a/src/routes/_authenticated/admin/users/index.tsx b/src/routes/_authenticated/admin/users/index.tsx index 4c5c414d..b252a21a 100644 --- a/src/routes/_authenticated/admin/users/index.tsx +++ b/src/routes/_authenticated/admin/users/index.tsx @@ -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) { setSearch(e.target.value) @@ -115,7 +120,9 @@ function UsersPage() { {t('admin.users.colEmail')} {t('admin.users.colRole')} {t('admin.users.colStatus')} - {t('admin.users.colQuota')} + {teamQuotasEnabled && ( + {t('admin.users.colQuota')} + )} {t('admin.users.colCreatedAt')} {t('admin.users.colActions')} @@ -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 && ( - + {t('admin.users.noUsers')} @@ -161,20 +169,24 @@ function UsersPage() { )} - !open && setQuotaDialogUser(null)} - user={ - quotaDialogUser?.orgId - ? { - name: quotaDialogUser.name || quotaDialogUser.username, - orgId: quotaDialogUser.orgId, - quotaUsed: quotaDialogUser.quotaUsed, - quotaTotal: quotaDialogUser.quotaTotal, - } - : null - } - /> + {!teamQuotasEnabled && } + + {teamQuotasEnabled && ( + !open && setQuotaDialogUser(null)} + user={ + quotaDialogUser?.orgId + ? { + name: quotaDialogUser.name || quotaDialogUser.username, + orgId: quotaDialogUser.orgId, + quotaUsed: quotaDialogUser.quotaUsed, + quotaTotal: quotaDialogUser.quotaTotal, + } + : null + } + /> + )} void onToggleStatus: () => void onDelete: () => void @@ -220,19 +234,21 @@ function UserTableRow({ {user.banned ? t('admin.users.disabled') : t('admin.users.active')} - {quotaLabel} + {showQuota && {quotaLabel}} {formatDate(user.createdAt)}
- + {showQuota && ( + + )}