From 3c765db0c09eebf216425d0dc9e783b6cc935012 Mon Sep 17 00:00:00 2001 From: syn Date: Mon, 10 Aug 2026 10:04:04 -0500 Subject: [PATCH 1/2] feat(exports): enable user data exports for all users --- .../src/app/(app)/components/SidebarUserFooter.tsx | 11 ++++------- apps/web/src/app/(app)/data-exports/page.test.tsx | 4 ++-- apps/web/src/app/(app)/data-exports/page.tsx | 2 +- apps/web/src/routers/user-exports-router.test.ts | 12 ++++++------ apps/web/src/routers/user-exports-router.ts | 8 ++++---- 5 files changed, 17 insertions(+), 20 deletions(-) diff --git a/apps/web/src/app/(app)/components/SidebarUserFooter.tsx b/apps/web/src/app/(app)/components/SidebarUserFooter.tsx index 47384f4a4e..ef063589f6 100644 --- a/apps/web/src/app/(app)/components/SidebarUserFooter.tsx +++ b/apps/web/src/app/(app)/components/SidebarUserFooter.tsx @@ -18,7 +18,6 @@ type User = { google_user_name: string; google_user_email: string; google_user_image_url: string; - is_admin: boolean; }; type SidebarUserFooterProps = { @@ -86,12 +85,10 @@ export default function SidebarUserFooter({ user, isLoading }: SidebarUserFooter Connected Accounts - {user.is_admin && ( - router.push('/data-exports')}> - - Data exports - - )} + router.push('/data-exports')}> + + Data exports + router.push('/install')}> Install diff --git a/apps/web/src/app/(app)/data-exports/page.test.tsx b/apps/web/src/app/(app)/data-exports/page.test.tsx index 177fca073c..0ccec9233a 100644 --- a/apps/web/src/app/(app)/data-exports/page.test.tsx +++ b/apps/web/src/app/(app)/data-exports/page.test.tsx @@ -12,11 +12,11 @@ jest.mock('next/navigation', () => ({ notFound: mockNotFound })); jest.mock('./DataExportsClient', () => ({ DataExportsClient: () => null })); describe('DataExportsPage', () => { - it('requires Kilo admin authentication', async () => { + it('requires user authentication', async () => { mockGetUserFromAuth.mockResolvedValue({ user: null }); const { default: DataExportsPage } = await import('./page'); await expect(DataExportsPage()).rejects.toThrow('NEXT_NOT_FOUND'); - expect(mockGetUserFromAuth).toHaveBeenCalledWith({ adminOnly: true }); + expect(mockGetUserFromAuth).toHaveBeenCalledWith({ adminOnly: false }); }); }); diff --git a/apps/web/src/app/(app)/data-exports/page.tsx b/apps/web/src/app/(app)/data-exports/page.tsx index a64254b320..64c918c746 100644 --- a/apps/web/src/app/(app)/data-exports/page.tsx +++ b/apps/web/src/app/(app)/data-exports/page.tsx @@ -4,7 +4,7 @@ import { PageLayout } from '@/components/PageLayout'; import { DataExportsClient } from './DataExportsClient'; export default async function DataExportsPage() { - const { user } = await getUserFromAuth({ adminOnly: true }); + const { user } = await getUserFromAuth({ adminOnly: false }); if (!user) notFound(); return ( diff --git a/apps/web/src/routers/user-exports-router.test.ts b/apps/web/src/routers/user-exports-router.test.ts index 26c328caea..34f273e337 100644 --- a/apps/web/src/routers/user-exports-router.test.ts +++ b/apps/web/src/routers/user-exports-router.test.ts @@ -28,9 +28,9 @@ describe('user exports router guards and serialization', () => { expect(() => __test__.requireWebSession(false)).not.toThrow(); }); - it('rejects non-admin users', async () => { + it('allows non-admin users to list their exports', async () => { const caller = await createCallerForUser(stranger.id); - await expect(caller.userExports.list()).rejects.toMatchObject({ code: 'FORBIDDEN' }); + await expect(caller.userExports.list()).resolves.toEqual({ exports: [], nextCursor: null }); }); it('normalizes database timestamp text into strict UTC ISO strings', () => { @@ -87,7 +87,7 @@ describe('user exports router guards and serialization', () => { }); it('uses the fixed August 2 08:40 UTC data cutoff for new exports', async () => { - const caller = await createCallerForUser(owner.id); + const caller = await createCallerForUser(stranger.id); const requested = await caller.userExports.request(); const [row] = await db @@ -153,7 +153,7 @@ describe('user exports router guards and serialization', () => { const [ready] = await db .insert(user_data_exports) .values({ - kilo_user_id: stranger.id, + kilo_user_id: owner.id, snapshot_at: new Date().toISOString(), status: 'ready', r2_object_key: `exports/${crypto.randomUUID()}/export.jsonl.gz`, @@ -165,7 +165,7 @@ describe('user exports router guards and serialization', () => { const [expired] = await db .insert(user_data_exports) .values({ - kilo_user_id: owner.id, + kilo_user_id: stranger.id, snapshot_at: new Date().toISOString(), status: 'ready', r2_object_key: `exports/${crypto.randomUUID()}/export.jsonl.gz`, @@ -174,7 +174,7 @@ describe('user exports router guards and serialization', () => { expires_at: new Date(Date.now() - 60_000).toISOString(), }) .returning(); - const caller = await createCallerForUser(owner.id); + const caller = await createCallerForUser(stranger.id); await expect(caller.userExports.createDownload({ exportId: ready.id })).rejects.toMatchObject({ code: 'NOT_FOUND', diff --git a/apps/web/src/routers/user-exports-router.ts b/apps/web/src/routers/user-exports-router.ts index a68e65df69..316347f088 100644 --- a/apps/web/src/routers/user-exports-router.ts +++ b/apps/web/src/routers/user-exports-router.ts @@ -4,7 +4,7 @@ import { TRPCError } from '@trpc/server'; import { sql } from 'drizzle-orm'; import * as z from 'zod'; import { db } from '@/lib/drizzle'; -import { adminProcedure, createTRPCRouter } from '@/lib/trpc/init'; +import { baseProcedure, createTRPCRouter } from '@/lib/trpc/init'; import { dispatchUserDataExport, requestUserDataExportDownload, @@ -64,7 +64,7 @@ function requireWebSession(authViaToken: boolean | undefined): void { } export const userExportsRouter = createTRPCRouter({ - request: adminProcedure.mutation(async ({ ctx }) => { + request: baseProcedure.mutation(async ({ ctx }) => { requireWebSession(ctx.authViaToken); const { rows } = await db.transaction(async tx => { await tx.execute(sql`SELECT pg_advisory_xact_lock(hashtextextended(${ctx.user.id}, 0))`); @@ -127,7 +127,7 @@ export const userExportsRouter = createTRPCRouter({ return serialize(row); }), - list: adminProcedure.input(ListInputSchema).query(async ({ ctx, input }) => { + list: baseProcedure.input(ListInputSchema).query(async ({ ctx, input }) => { requireWebSession(ctx.authViaToken); const cursorFilter = input?.cursor ? sql`AND (created_at, id) < ( @@ -151,7 +151,7 @@ export const userExportsRouter = createTRPCRouter({ }; }), - createDownload: adminProcedure.input(ExportIdSchema).mutation(async ({ ctx, input }) => { + createDownload: baseProcedure.input(ExportIdSchema).mutation(async ({ ctx, input }) => { requireWebSession(ctx.authViaToken); const { rows } = await db.execute<{ id: string }>(sql` SELECT id FROM user_data_exports From 5bc27de486df6eaa0183c385653d6ccb24fe72a8 Mon Sep 17 00:00:00 2001 From: syn Date: Mon, 10 Aug 2026 10:31:47 -0500 Subject: [PATCH 2/2] fix(exports): set request throttle to one hour --- .../src/routers/user-exports-router.test.ts | 23 ++++++++++++++++++- apps/web/src/routers/user-exports-router.ts | 5 ++-- 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/apps/web/src/routers/user-exports-router.test.ts b/apps/web/src/routers/user-exports-router.test.ts index 34f273e337..df5efd067f 100644 --- a/apps/web/src/routers/user-exports-router.test.ts +++ b/apps/web/src/routers/user-exports-router.test.ts @@ -126,8 +126,29 @@ describe('user exports router guards and serialization', () => { ); }); + it('rejects a new request within one hour of a ready export', async () => { + const withinThrottle = new Date(Date.now() - 30 * 60 * 1000).toISOString(); + await db.insert(user_data_exports).values({ + kilo_user_id: owner.id, + snapshot_at: withinThrottle, + status: 'ready', + r2_object_key: `exports/${crypto.randomUUID()}/export.jsonl.gz`, + size_bytes: 1, + requested_at: withinThrottle, + completed_at: withinThrottle, + expires_at: new Date(Date.now() + 60_000).toISOString(), + }); + + const request = (await createCallerForUser(owner.id)).userExports.request(); + + await expect(request).rejects.toMatchObject({ + code: 'TOO_MANY_REQUESTS', + message: 'You can request one data export every hour', + }); + }); + it('allows a fresh request when a ready export exists but is past the throttle window', async () => { - const pastThrottle = new Date(Date.now() - 25 * 60 * 60 * 1000).toISOString(); + const pastThrottle = new Date(Date.now() - 61 * 60 * 1000).toISOString(); const [ready] = await db .insert(user_data_exports) .values({ diff --git a/apps/web/src/routers/user-exports-router.ts b/apps/web/src/routers/user-exports-router.ts index 316347f088..2fdb89d8eb 100644 --- a/apps/web/src/routers/user-exports-router.ts +++ b/apps/web/src/routers/user-exports-router.ts @@ -87,15 +87,14 @@ export const userExportsRouter = createTRPCRouter({ FROM user_data_exports WHERE kilo_user_id = ${ctx.user.id} AND status <> 'failed' - -- TEMPORARY: throttle lowered to 5 minutes for pre-launch testing; restore to 24 hours before going live. - AND requested_at > now() - interval '5 minutes' + AND requested_at > now() - interval '1 hour' ORDER BY requested_at DESC LIMIT 1 `); if (recent.rows[0]) { throw new TRPCError({ code: 'TOO_MANY_REQUESTS', - message: 'You can request one data export every 24 hours', + message: 'You can request one data export every hour', }); }