From fd1ad73417db892e8b9869a1ffb20dcce6bc1390 Mon Sep 17 00:00:00 2001 From: shivam Date: Sat, 18 Apr 2026 01:02:38 +0000 Subject: [PATCH 1/6] fix(frontend): reject empty submit days before persistence --- .../frontend/__tests__/api/submit.test.ts | 141 +++++++++++++++ .../frontend/__tests__/api/submitAuth.test.ts | 170 ++++++++++++++++++ .../lib/renderIsometric3DSvg.test.ts | 10 +- .../lib/renderProfileEmbedSvg.test.ts | 10 +- .../frontend/src/lib/validation/submission.ts | 27 +-- 5 files changed, 336 insertions(+), 22 deletions(-) diff --git a/packages/frontend/__tests__/api/submit.test.ts b/packages/frontend/__tests__/api/submit.test.ts index 09abf29d..87a55123 100644 --- a/packages/frontend/__tests__/api/submit.test.ts +++ b/packages/frontend/__tests__/api/submit.test.ts @@ -310,6 +310,26 @@ describe('POST /api/submit - Client-Level Merge', () => { // API should return 400 for this }); + it('rejects placeholder days with no client contributions', () => { + const payload = { + meta: { generatedAt: new Date().toISOString(), version: '1.0.0', dateRange: { start: '2024-12-01', end: '2024-12-01' } }, + summary: { totalTokens: 0, totalCost: 0, totalDays: 1, activeDays: 0, averagePerDay: 0, maxCostInSingleDay: 0, clients: [], models: [] }, + years: [], + contributions: [{ + date: '2024-12-01', + totals: { tokens: 0, cost: 0, messages: 0 }, + intensity: 0 as const, + tokenBreakdown: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, reasoning: 0 }, + clients: [], + }], + }; + + const result = validateSubmission(payload); + + expect(result.valid).toBe(false); + expect(result.errors).toContain('Contribution day 2024-12-01 has no client data'); + }); + it('should handle day with no data for submitted client', () => { // User submits --claude but a day only has opencode data const dayWithOnlyOpencode = { @@ -368,6 +388,127 @@ describe('POST /api/submit - Client-Level Merge', () => { expect(submittedClients.has('pi')).toBe(true); }); + it('hard-rejects duplicate dates before persistence', () => { + const payload = { + meta: { generatedAt: new Date().toISOString(), version: '1.0.0', dateRange: { start: '2024-12-01', end: '2024-12-01' } }, + summary: { totalTokens: 300, totalCost: 3, totalDays: 2, activeDays: 2, averagePerDay: 1.5, maxCostInSingleDay: 1.5, clients: ['claude' as const], models: ['claude-sonnet-4'] }, + years: [], + contributions: [ + { + date: '2024-12-01', + totals: { tokens: 150, cost: 1.5, messages: 1 }, + intensity: 1 as const, + tokenBreakdown: { input: 100, output: 50, cacheRead: 0, cacheWrite: 0, reasoning: 0 }, + clients: [{ client: 'claude' as const, modelId: 'claude-sonnet-4', tokens: { input: 100, output: 50, cacheRead: 0, cacheWrite: 0, reasoning: 0 }, cost: 1.5, messages: 1 }], + }, + { + date: '2024-12-01', + totals: { tokens: 150, cost: 1.5, messages: 1 }, + intensity: 1 as const, + tokenBreakdown: { input: 100, output: 50, cacheRead: 0, cacheWrite: 0, reasoning: 0 }, + clients: [{ client: 'claude' as const, modelId: 'claude-sonnet-4', tokens: { input: 100, output: 50, cacheRead: 0, cacheWrite: 0, reasoning: 0 }, cost: 1.5, messages: 1 }], + }, + ], + }; + + const result = validateSubmission(payload); + + expect(result.valid).toBe(false); + expect(result.errors).toContain('Duplicate date found: 2024-12-01'); + }); + + it('hard-rejects unsupported clients', () => { + const payload = { + meta: { generatedAt: new Date().toISOString(), version: '1.0.0', dateRange: { start: '2024-12-01', end: '2024-12-01' } }, + summary: { totalTokens: 150, totalCost: 1.5, totalDays: 1, activeDays: 1, averagePerDay: 1.5, maxCostInSingleDay: 1.5, clients: ['claude' as const], models: ['claude-sonnet-4'] }, + years: [], + contributions: [{ + date: '2024-12-01', + totals: { tokens: 150, cost: 1.5, messages: 1 }, + intensity: 1 as const, + tokenBreakdown: { input: 100, output: 50, cacheRead: 0, cacheWrite: 0, reasoning: 0 }, + clients: [{ client: 'not-a-client', modelId: 'claude-sonnet-4', tokens: { input: 100, output: 50, cacheRead: 0, cacheWrite: 0, reasoning: 0 }, cost: 1.5, messages: 1 }], + }], + }; + + const result = validateSubmission(payload); + + expect(result.valid).toBe(false); + expect(result.errors.some((error) => error.includes('Invalid enum value'))).toBe(true); + }); + + it('hard-rejects negative counters in nested token breakdowns', () => { + const payload = { + meta: { generatedAt: new Date().toISOString(), version: '1.0.0', dateRange: { start: '2024-12-01', end: '2024-12-01' } }, + summary: { totalTokens: 150, totalCost: 1.5, totalDays: 1, activeDays: 1, averagePerDay: 1.5, maxCostInSingleDay: 1.5, clients: ['claude' as const], models: ['claude-sonnet-4'] }, + years: [], + contributions: [{ + date: '2024-12-01', + totals: { tokens: 150, cost: 1.5, messages: 1 }, + intensity: 1 as const, + tokenBreakdown: { input: 100, output: 50, cacheRead: 0, cacheWrite: 0, reasoning: 0 }, + clients: [{ client: 'claude' as const, modelId: 'claude-sonnet-4', tokens: { input: 100, output: -50, cacheRead: 0, cacheWrite: 0, reasoning: 0 }, cost: 1.5, messages: 1 }], + }], + }; + + const result = validateSubmission(payload); + + expect(result.valid).toBe(false); + expect(result.errors.some((error) => error.includes('Number must be greater than or equal to 0'))).toBe(true); + }); + + it('hard-rejects future-dated history beyond the tolerance window', () => { + const futureDate = new Date(Date.now() + 5 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10); + const payload = { + meta: { generatedAt: new Date().toISOString(), version: '1.0.0', dateRange: { start: futureDate, end: futureDate } }, + summary: { totalTokens: 150, totalCost: 1.5, totalDays: 1, activeDays: 1, averagePerDay: 1.5, maxCostInSingleDay: 1.5, clients: ['claude' as const], models: ['claude-sonnet-4'] }, + years: [], + contributions: [{ + date: futureDate, + totals: { tokens: 150, cost: 1.5, messages: 1 }, + intensity: 1 as const, + tokenBreakdown: { input: 100, output: 50, cacheRead: 0, cacheWrite: 0, reasoning: 0 }, + clients: [{ client: 'claude' as const, modelId: 'claude-sonnet-4', tokens: { input: 100, output: 50, cacheRead: 0, cacheWrite: 0, reasoning: 0 }, cost: 1.5, messages: 1 }], + }], + }; + + const result = validateSubmission(payload); + + expect(result.valid).toBe(false); + expect(result.errors).toContain(`Date range extends into the future: ${futureDate}`); + expect(result.errors).toContain(`Future date found in contributions: ${futureDate}`); + }); + + it('rejects mixed batches when any contribution day is invalid', () => { + const futureDate = new Date(Date.now() + 5 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10); + const payload = { + meta: { generatedAt: new Date().toISOString(), version: '1.0.0', dateRange: { start: '2024-12-01', end: futureDate } }, + summary: { totalTokens: 300, totalCost: 3, totalDays: 2, activeDays: 2, averagePerDay: 1.5, maxCostInSingleDay: 1.5, clients: ['claude' as const], models: ['claude-sonnet-4'] }, + years: [], + contributions: [ + { + date: '2024-12-01', + totals: { tokens: 150, cost: 1.5, messages: 1 }, + intensity: 1 as const, + tokenBreakdown: { input: 100, output: 50, cacheRead: 0, cacheWrite: 0, reasoning: 0 }, + clients: [{ client: 'claude' as const, modelId: 'claude-sonnet-4', tokens: { input: 100, output: 50, cacheRead: 0, cacheWrite: 0, reasoning: 0 }, cost: 1.5, messages: 1 }], + }, + { + date: futureDate, + totals: { tokens: 150, cost: 1.5, messages: 1 }, + intensity: 1 as const, + tokenBreakdown: { input: 100, output: 50, cacheRead: 0, cacheWrite: 0, reasoning: 0 }, + clients: [{ client: 'claude' as const, modelId: 'claude-sonnet-4', tokens: { input: 100, output: 50, cacheRead: 0, cacheWrite: 0, reasoning: 0 }, cost: 1.5, messages: 1 }], + }, + ], + }; + + const result = validateSubmission(payload); + + expect(result.valid).toBe(false); + expect(result.errors).toContain(`Future date found in contributions: ${futureDate}`); + }); + }); diff --git a/packages/frontend/__tests__/api/submitAuth.test.ts b/packages/frontend/__tests__/api/submitAuth.test.ts index c71870cb..a81face6 100644 --- a/packages/frontend/__tests__/api/submitAuth.test.ts +++ b/packages/frontend/__tests__/api/submitAuth.test.ts @@ -73,6 +73,22 @@ beforeEach(() => { }); describe("POST /api/submit auth path", () => { + it("rejects missing bearer auth before any downstream work", async () => { + const response = await POST( + new Request("http://localhost:3000/api/submit", { + method: "POST", + body: JSON.stringify({}), + }) + ); + + expect(response.status).toBe(401); + expect(mockState.authenticatePersonalToken).not.toHaveBeenCalled(); + expect(mockState.validateSubmission).not.toHaveBeenCalled(); + expect(mockState.db.transaction).not.toHaveBeenCalled(); + expect(mockState.revalidateTag).not.toHaveBeenCalled(); + expect(await response.json()).toEqual({ error: "Missing or invalid Authorization header" }); + }); + it("rejects invalid API tokens through the shared auth service", async () => { mockState.authenticatePersonalToken.mockResolvedValue({ status: "invalid" }); @@ -114,6 +130,36 @@ describe("POST /api/submit auth path", () => { expect(mockState.db.transaction).not.toHaveBeenCalled(); }); + it("rejects malformed JSON before validation or persistence", async () => { + mockState.authenticatePersonalToken.mockResolvedValue({ + status: "valid", + tokenId: "token-1", + userId: "user-1", + username: "alice", + displayName: "Alice", + avatarUrl: null, + isAdmin: false, + expiresAt: null, + }); + + const response = await POST( + new Request("http://localhost:3000/api/submit", { + method: "POST", + headers: { + Authorization: "Bearer tt_valid", + "Content-Type": "application/json", + }, + body: "{", + }) + ); + + expect(response.status).toBe(400); + expect(mockState.validateSubmission).not.toHaveBeenCalled(); + expect(mockState.db.transaction).not.toHaveBeenCalled(); + expect(mockState.revalidateTag).not.toHaveBeenCalled(); + expect(await response.json()).toEqual({ error: "Invalid JSON body" }); + }); + it("accepts a valid token and continues into submission validation", async () => { mockState.authenticatePersonalToken.mockResolvedValue({ status: "valid", @@ -153,4 +199,128 @@ describe("POST /api/submit auth path", () => { details: ["bad payload"], }); }); + + it("rejects empty validated submissions before entering the transaction", async () => { + mockState.authenticatePersonalToken.mockResolvedValue({ + status: "valid", + tokenId: "token-1", + userId: "user-1", + username: "alice", + displayName: "Alice", + avatarUrl: null, + isAdmin: false, + expiresAt: null, + }); + mockState.validateSubmission.mockReturnValue({ + valid: true, + errors: [], + warnings: [], + data: { + meta: { + generatedAt: new Date().toISOString(), + version: "1.0.0", + dateRange: { start: "2024-12-01", end: "2024-12-01" }, + }, + summary: { + totalTokens: 0, + totalCost: 0, + totalDays: 0, + activeDays: 0, + averagePerDay: 0, + maxCostInSingleDay: 0, + clients: [], + models: [], + }, + years: [], + contributions: [], + }, + }); + + const response = await POST( + new Request("http://localhost:3000/api/submit", { + method: "POST", + headers: { + Authorization: "Bearer tt_valid", + "Content-Type": "application/json", + }, + body: JSON.stringify({ meta: {}, contributions: [] }), + }) + ); + + expect(response.status).toBe(400); + expect(mockState.db.transaction).not.toHaveBeenCalled(); + expect(mockState.revalidateTag).not.toHaveBeenCalled(); + expect(await response.json()).toEqual({ error: "No contribution data to submit" }); + }); + + it("keeps mixed invalid batches out of the transaction path", async () => { + mockState.authenticatePersonalToken.mockResolvedValue({ + status: "valid", + tokenId: "token-1", + userId: "user-1", + username: "alice", + displayName: "Alice", + avatarUrl: null, + isAdmin: false, + expiresAt: null, + }); + mockState.validateSubmission.mockReturnValue({ + valid: false, + data: null, + errors: ["Future date found in contributions: 2099-01-01"], + warnings: [], + }); + + const response = await POST( + new Request("http://localhost:3000/api/submit", { + method: "POST", + headers: { + Authorization: "Bearer tt_valid", + "Content-Type": "application/json", + }, + body: JSON.stringify({ + meta: { + generatedAt: new Date().toISOString(), + version: "1.0.0", + dateRange: { start: "2024-12-01", end: "2099-01-01" }, + }, + summary: { + totalTokens: 200, + totalCost: 2, + totalDays: 2, + activeDays: 2, + averagePerDay: 1, + maxCostInSingleDay: 1, + clients: ["claude"], + models: ["claude-sonnet-4"], + }, + years: [], + contributions: [ + { + date: "2024-12-01", + totals: { tokens: 100, cost: 1, messages: 1 }, + intensity: 1, + tokenBreakdown: { input: 50, output: 50, cacheRead: 0, cacheWrite: 0, reasoning: 0 }, + clients: [], + }, + { + date: "2099-01-01", + totals: { tokens: 100, cost: 1, messages: 1 }, + intensity: 1, + tokenBreakdown: { input: 50, output: 50, cacheRead: 0, cacheWrite: 0, reasoning: 0 }, + clients: [], + }, + ], + }), + }) + ); + + expect(response.status).toBe(400); + expect(mockState.db.transaction).not.toHaveBeenCalled(); + expect(mockState.revalidateTag).not.toHaveBeenCalled(); + expect(await response.json()).toEqual({ + error: "Validation failed", + details: ["Future date found in contributions: 2099-01-01"], + }); + }); }); diff --git a/packages/frontend/__tests__/lib/renderIsometric3DSvg.test.ts b/packages/frontend/__tests__/lib/renderIsometric3DSvg.test.ts index 6113e422..79a0c456 100644 --- a/packages/frontend/__tests__/lib/renderIsometric3DSvg.test.ts +++ b/packages/frontend/__tests__/lib/renderIsometric3DSvg.test.ts @@ -22,11 +22,11 @@ const mockStats: UserEmbedStats = { }; const mockContributions: EmbedContributionDay[] = [ - { date: "2026-01-15", intensity: 0 }, - { date: "2026-02-10", intensity: 2 }, - { date: "2026-02-20", intensity: 4 }, - { date: "2026-03-01", intensity: 1 }, - { date: "2026-03-10", intensity: 3 }, + { date: "2026-01-15", intensity: 0, totalTokens: 0, totalCost: 0 }, + { date: "2026-02-10", intensity: 2, totalTokens: 250, totalCost: 2.5 }, + { date: "2026-02-20", intensity: 4, totalTokens: 1000, totalCost: 10 }, + { date: "2026-03-01", intensity: 1, totalTokens: 100, totalCost: 1 }, + { date: "2026-03-10", intensity: 3, totalTokens: 600, totalCost: 6 }, ]; describe("renderIsometric3DEmbedSvg", () => { diff --git a/packages/frontend/__tests__/lib/renderProfileEmbedSvg.test.ts b/packages/frontend/__tests__/lib/renderProfileEmbedSvg.test.ts index 76b65fae..6158ef68 100644 --- a/packages/frontend/__tests__/lib/renderProfileEmbedSvg.test.ts +++ b/packages/frontend/__tests__/lib/renderProfileEmbedSvg.test.ts @@ -3,7 +3,7 @@ import { renderProfileEmbedErrorSvg, renderProfileEmbedSvg, } from "../../src/lib/embed/renderProfileEmbedSvg"; -import type { UserEmbedStats } from "../../src/lib/embed/getUserEmbedStats"; +import type { EmbedContributionDay, UserEmbedStats } from "../../src/lib/embed/getUserEmbedStats"; const mockStats: UserEmbedStats = { user: { @@ -199,10 +199,10 @@ describe("renderProfileEmbedSvg", () => { }); describe("renderProfileEmbedSvg with contributions graph", () => { - const mockContributions = [ - { date: "2026-01-15", intensity: 0 as const }, - { date: "2026-02-10", intensity: 2 as const }, - { date: "2026-02-20", intensity: 4 as const }, + const mockContributions: EmbedContributionDay[] = [ + { date: "2026-01-15", intensity: 0, totalTokens: 0, totalCost: 0 }, + { date: "2026-02-10", intensity: 2, totalTokens: 250, totalCost: 2.5 }, + { date: "2026-02-20", intensity: 4, totalTokens: 1000, totalCost: 10 }, ]; it("extends card height when contributions provided", () => { diff --git a/packages/frontend/src/lib/validation/submission.ts b/packages/frontend/src/lib/validation/submission.ts index 8df1ae98..1f1bbe3a 100644 --- a/packages/frontend/src/lib/validation/submission.ts +++ b/packages/frontend/src/lib/validation/submission.ts @@ -259,19 +259,22 @@ export function validateSubmission(data: unknown): ValidationResult { // 3c. Day token breakdown should sum to totals for (const day of submission.contributions) { + if (day.clients.length === 0) { + errors.push(`Contribution day ${day.date} has no client data`); + continue; + } + // Check clients sum to day totals - if (day.clients.length > 0) { - const clientsTokenSum = day.clients.reduce((sum, c) => { - const t = c.tokens; - return sum + t.input + t.output + t.cacheRead + t.cacheWrite + t.reasoning; - }, 0); - - // Allow some tolerance - if (Math.abs(clientsTokenSum - day.totals.tokens) > day.totals.tokens * 0.05 && day.totals.tokens > 100) { - warnings.push( - `Day ${day.date}: client tokens (${clientsTokenSum}) don't match total (${day.totals.tokens})` - ); - } + const clientsTokenSum = day.clients.reduce((sum, c) => { + const t = c.tokens; + return sum + t.input + t.output + t.cacheRead + t.cacheWrite + t.reasoning; + }, 0); + + // Allow some tolerance + if (Math.abs(clientsTokenSum - day.totals.tokens) > day.totals.tokens * 0.05 && day.totals.tokens > 100) { + warnings.push( + `Day ${day.date}: client tokens (${clientsTokenSum}) don't match total (${day.totals.tokens})` + ); } } From c4bce3c8f42a2095b477419a9cc4299e49362e68 Mon Sep 17 00:00:00 2001 From: shivam Date: Sat, 18 Apr 2026 21:00:53 +0000 Subject: [PATCH 2/6] fix(frontend): add submit trust-state decisioning --- .../frontend/__tests__/api/submit.test.ts | 106 ++ .../frontend/__tests__/api/submitAuth.test.ts | 8 + .../__tests__/api/submitDecisioning.test.ts | 316 +++++ packages/frontend/src/app/api/submit/route.ts | 140 ++- .../0005_concerned_justin_hammer.sql | 25 + .../lib/db/migrations/meta/0005_snapshot.json | 1114 +++++++++++++++++ .../src/lib/db/migrations/meta/_journal.json | 7 + packages/frontend/src/lib/db/schema.ts | 59 +- .../frontend/src/lib/validation/submission.ts | 26 +- .../src/lib/validation/submissionTrust.ts | 142 +++ 10 files changed, 1925 insertions(+), 18 deletions(-) create mode 100644 packages/frontend/__tests__/api/submitDecisioning.test.ts create mode 100644 packages/frontend/src/lib/db/migrations/0005_concerned_justin_hammer.sql create mode 100644 packages/frontend/src/lib/db/migrations/meta/0005_snapshot.json create mode 100644 packages/frontend/src/lib/validation/submissionTrust.ts diff --git a/packages/frontend/__tests__/api/submit.test.ts b/packages/frontend/__tests__/api/submit.test.ts index 87a55123..d1c83181 100644 --- a/packages/frontend/__tests__/api/submit.test.ts +++ b/packages/frontend/__tests__/api/submit.test.ts @@ -92,6 +92,8 @@ function createMockSubmissionData(overrides: Partial<{ } describe('POST /api/submit - Client-Level Merge', () => { + const getUtcDateString = (date: Date) => date.toISOString().slice(0, 10); + describe('First Submission (Create Mode)', () => { it('should create new submission with all clients', () => { const data = createMockSubmissionData({ clients: ['claude', 'cursor'] }); @@ -512,6 +514,110 @@ describe('POST /api/submit - Client-Level Merge', () => { }); + describe('Trust decisioning', () => { + it('hard-rejects impossible model timelines before persistence', () => { + const payload = { + meta: { generatedAt: new Date().toISOString(), version: '1.0.0', dateRange: { start: '2024-12-01', end: '2024-12-01' } }, + summary: { totalTokens: 150, totalCost: 1.5, totalDays: 1, activeDays: 1, averagePerDay: 1.5, maxCostInSingleDay: 1.5, clients: ['claude' as const], models: ['claude-sonnet-4-20250514'] }, + years: [], + contributions: [{ + date: '2024-12-01', + timestampMs: Date.parse('2024-12-01T10:00:00.000Z'), + totals: { tokens: 150, cost: 1.5, messages: 1 }, + intensity: 1 as const, + tokenBreakdown: { input: 100, output: 50, cacheRead: 0, cacheWrite: 0, reasoning: 0 }, + clients: [{ client: 'claude' as const, modelId: 'claude-sonnet-4-20250514', tokens: { input: 100, output: 50, cacheRead: 0, cacheWrite: 0, reasoning: 0 }, cost: 1.5, messages: 1 }], + }], + }; + + const result = validateSubmission(payload); + + expect(result.valid).toBe(false); + expect(result.trustState).toBe('rejected'); + expect(result.rejectionReasonCodes).toContain('model_predates_public_availability'); + expect(result.errors).toContain( + 'Model claude-sonnet-4-20250514 cannot be submitted for 2024-12-01 before 2025-05-14' + ); + }); + + it('hard-rejects timestamps that do not align with the claimed UTC day bucket', () => { + const payload = { + meta: { generatedAt: new Date().toISOString(), version: '1.0.0', dateRange: { start: '2024-12-01', end: '2024-12-01' } }, + summary: { totalTokens: 150, totalCost: 1.5, totalDays: 1, activeDays: 1, averagePerDay: 1.5, maxCostInSingleDay: 1.5, clients: ['claude' as const], models: ['claude-sonnet-4-20250514'] }, + years: [], + contributions: [{ + date: '2024-12-01', + timestampMs: Date.parse('2024-12-02T00:05:00.000Z'), + totals: { tokens: 150, cost: 1.5, messages: 1 }, + intensity: 1 as const, + tokenBreakdown: { input: 100, output: 50, cacheRead: 0, cacheWrite: 0, reasoning: 0 }, + clients: [{ client: 'claude' as const, modelId: 'claude-sonnet-4-20250514', tokens: { input: 100, output: 50, cacheRead: 0, cacheWrite: 0, reasoning: 0 }, cost: 1.5, messages: 1 }], + }], + }; + + const result = validateSubmission(payload); + + expect(result.valid).toBe(false); + expect(result.trustState).toBe('rejected'); + expect(result.rejectionReasonCodes).toContain('timestamp_day_mismatch'); + expect(result.errors).toContain( + `Day 2024-12-01 has timestamp ${Date.parse('2024-12-02T00:05:00.000Z')} outside its claimed UTC bucket` + ); + }); + + it('accepts aligned timestamp metadata as trusted history', () => { + const now = new Date(); + const date = getUtcDateString(now); + const payload = { + meta: { generatedAt: now.toISOString(), version: '1.0.0', dateRange: { start: date, end: date } }, + summary: { totalTokens: 150, totalCost: 1.5, totalDays: 1, activeDays: 1, averagePerDay: 1.5, maxCostInSingleDay: 1.5, clients: ['claude' as const], models: ['claude-sonnet-4-20250514'] }, + years: [], + contributions: [{ + date, + timestampMs: Date.parse(`${date}T12:00:00.000Z`), + totals: { tokens: 150, cost: 1.5, messages: 1 }, + intensity: 1 as const, + tokenBreakdown: { input: 100, output: 50, cacheRead: 0, cacheWrite: 0, reasoning: 0 }, + clients: [{ client: 'claude' as const, modelId: 'claude-sonnet-4-20250514', tokens: { input: 100, output: 50, cacheRead: 0, cacheWrite: 0, reasoning: 0 }, cost: 1.5, messages: 1 }], + }], + }; + + const result = validateSubmission(payload); + + expect(result.valid).toBe(true); + expect(result.trustState).toBe('trusted'); + expect(result.reasonCodes).toEqual([]); + expect(result.rejectionReasonCodes).toEqual([]); + }); + + it('routes historical untimestamped history into the review-required path', () => { + const historicalDate = new Date(); + historicalDate.setUTCDate(historicalDate.getUTCDate() - 90); + const date = getUtcDateString(historicalDate); + const payload = { + meta: { generatedAt: new Date().toISOString(), version: '1.0.0', dateRange: { start: date, end: date } }, + summary: { totalTokens: 150, totalCost: 1.5, totalDays: 1, activeDays: 1, averagePerDay: 1.5, maxCostInSingleDay: 1.5, clients: ['claude' as const], models: ['claude-sonnet-4-20250514'] }, + years: [], + contributions: [{ + date, + totals: { tokens: 150, cost: 1.5, messages: 1 }, + intensity: 1 as const, + tokenBreakdown: { input: 100, output: 50, cacheRead: 0, cacheWrite: 0, reasoning: 0 }, + clients: [{ client: 'claude' as const, modelId: 'claude-sonnet-4-20250514', tokens: { input: 100, output: 50, cacheRead: 0, cacheWrite: 0, reasoning: 0 }, cost: 1.5, messages: 1 }], + }], + }; + + const result = validateSubmission(payload); + + expect(result.valid).toBe(true); + expect(result.trustState).toBe('review_required'); + expect(result.reasonCodes).toContain('historical_day_missing_timestamp'); + expect(result.warnings).toContain( + `Day ${date} is older than 30 days and has no timestampMs audit metadata` + ); + }); + }); + describe('Multi-Model Per Client', () => { it('should aggregate multiple models per client correctly', () => { const dayClientEntries = [ diff --git a/packages/frontend/__tests__/api/submitAuth.test.ts b/packages/frontend/__tests__/api/submitAuth.test.ts index a81face6..2b78debf 100644 --- a/packages/frontend/__tests__/api/submitAuth.test.ts +++ b/packages/frontend/__tests__/api/submitAuth.test.ts @@ -36,10 +36,16 @@ vi.mock("@/lib/auth/personalTokens", () => ({ vi.mock("@/lib/db", () => ({ db: mockState.db, + apiTokens: { + id: "apiTokens.id", + }, submissions: { id: "submissions.id", userId: "submissions.userId", }, + submissionReviews: { + id: "submissionReviews.id", + }, dailyBreakdown: { id: "dailyBreakdown.id", submissionId: "dailyBreakdown.submissionId", @@ -197,6 +203,7 @@ describe("POST /api/submit auth path", () => { expect(await response.json()).toEqual({ error: "Validation failed", details: ["bad payload"], + trustState: "rejected", }); }); @@ -321,6 +328,7 @@ describe("POST /api/submit auth path", () => { expect(await response.json()).toEqual({ error: "Validation failed", details: ["Future date found in contributions: 2099-01-01"], + trustState: "rejected", }); }); }); diff --git a/packages/frontend/__tests__/api/submitDecisioning.test.ts b/packages/frontend/__tests__/api/submitDecisioning.test.ts new file mode 100644 index 00000000..66766a47 --- /dev/null +++ b/packages/frontend/__tests__/api/submitDecisioning.test.ts @@ -0,0 +1,316 @@ +import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; + +const mockState = vi.hoisted(() => { + const authenticatePersonalToken = vi.fn(); + const validateSubmission = vi.fn(); + const generateSubmissionHash = vi.fn(() => "submission-hash"); + const revalidateTag = vi.fn(); + + const db = { + transaction: vi.fn(), + }; + + return { + authenticatePersonalToken, + validateSubmission, + generateSubmissionHash, + revalidateTag, + db, + reset() { + authenticatePersonalToken.mockReset(); + validateSubmission.mockReset(); + generateSubmissionHash.mockClear(); + revalidateTag.mockClear(); + db.transaction.mockReset(); + }, + }; +}); + +vi.mock("next/cache", () => ({ + revalidateTag: mockState.revalidateTag, +})); + +vi.mock("@/lib/auth/personalTokens", () => ({ + authenticatePersonalToken: mockState.authenticatePersonalToken, +})); + +vi.mock("@/lib/db", () => ({ + db: mockState.db, + apiTokens: { + id: "apiTokens.id", + }, + submissions: { + id: "submissions.id", + userId: "submissions.userId", + schemaVersion: "submissions.schemaVersion", + }, + submissionReviews: { + id: "submissionReviews.id", + }, + dailyBreakdown: { + id: "dailyBreakdown.id", + submissionId: "dailyBreakdown.submissionId", + }, +})); + +vi.mock("@/lib/validation/submission", () => ({ + validateSubmission: mockState.validateSubmission, + generateSubmissionHash: mockState.generateSubmissionHash, +})); + +vi.mock("@/lib/db/helpers", () => ({ + mergeClientBreakdowns: vi.fn(), + recalculateDayTotals: vi.fn(), + buildModelBreakdown: vi.fn(), + clientContributionToBreakdownData: vi.fn(), + mergeTimestampMs: vi.fn(), +})); + +type ModuleExports = typeof import("../../src/app/api/submit/route"); + +let POST: ModuleExports["POST"]; + +beforeAll(async () => { + const routeModule = await import("../../src/app/api/submit/route"); + POST = routeModule.POST; +}); + +beforeEach(() => { + mockState.reset(); +}); + +function createValidAuthRecord() { + return { + status: "valid" as const, + tokenId: "token-1", + userId: "user-1", + username: "alice", + displayName: "Alice", + avatarUrl: null, + isAdmin: false, + expiresAt: null, + }; +} + +function createSubmissionPayload(date: string) { + return { + meta: { + generatedAt: new Date().toISOString(), + version: "1.0.0", + dateRange: { + start: date, + end: date, + }, + }, + summary: { + totalTokens: 150, + totalCost: 1.5, + totalDays: 1, + activeDays: 1, + averagePerDay: 1.5, + maxCostInSingleDay: 1.5, + clients: ["claude"], + models: ["claude-sonnet-4-20250514"], + }, + years: [], + contributions: [ + { + date, + timestampMs: Date.parse(`${date}T10:00:00.000Z`), + totals: { tokens: 150, cost: 1.5, messages: 1 }, + intensity: 1 as const, + tokenBreakdown: { + input: 100, + output: 50, + cacheRead: 0, + cacheWrite: 0, + reasoning: 0, + }, + clients: [ + { + client: "claude" as const, + modelId: "claude-sonnet-4-20250514", + tokens: { + input: 100, + output: 50, + cacheRead: 0, + cacheWrite: 0, + reasoning: 0, + }, + cost: 1.5, + messages: 1, + }, + ], + }, + ], + }; +} + +describe("POST /api/submit trust decisioning", () => { + it("returns an explicit trusted response envelope for accepted competitive writes", async () => { + const today = new Date().toISOString().slice(0, 10); + const payload = createSubmissionPayload(today); + + mockState.authenticatePersonalToken.mockResolvedValue(createValidAuthRecord()); + mockState.validateSubmission.mockReturnValue({ + valid: true, + errors: [], + warnings: ["Cost total minor mismatch: summary=1.50, calculated=1.50"], + trustState: "trusted", + reasonCodes: [], + rejectionReasonCodes: [], + data: payload, + }); + mockState.db.transaction.mockResolvedValue({ + trustState: "trusted", + submissionId: "submission-1", + isNewSubmission: true, + metrics: { + totalTokens: 150, + totalCost: 1.5, + dateRange: { start: today, end: today }, + activeDays: 1, + clients: ["claude"], + }, + }); + + const response = await POST( + new Request("http://localhost:3000/api/submit", { + method: "POST", + headers: { + Authorization: "Bearer tt_valid", + "Content-Type": "application/json", + }, + body: JSON.stringify(payload), + }) + ); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ + success: true, + submissionId: "submission-1", + reviewId: undefined, + username: "alice", + metrics: { + totalTokens: 150, + totalCost: 1.5, + dateRange: { start: today, end: today }, + activeDays: 1, + clients: ["claude"], + }, + trustState: "trusted", + mode: "create", + reasonCodes: undefined, + competitiveWriteApplied: true, + warnings: ["Cost total minor mismatch: summary=1.50, calculated=1.50"], + }); + expect(mockState.revalidateTag).toHaveBeenCalledTimes(4); + }); + + it("hard-rejects impossible timeline payloads with machine-readable error codes", async () => { + const payload = createSubmissionPayload("2024-12-01"); + + mockState.authenticatePersonalToken.mockResolvedValue(createValidAuthRecord()); + mockState.validateSubmission.mockReturnValue({ + valid: false, + errors: ["Day 2024-12-01 has timestamp 1733097900000 outside its claimed UTC bucket"], + warnings: [], + trustState: "rejected", + reasonCodes: [], + rejectionReasonCodes: ["timestamp_day_mismatch"], + data: undefined, + }); + + const response = await POST( + new Request("http://localhost:3000/api/submit", { + method: "POST", + headers: { + Authorization: "Bearer tt_valid", + "Content-Type": "application/json", + }, + body: JSON.stringify(payload), + }) + ); + + expect(response.status).toBe(400); + expect(await response.json()).toEqual({ + error: "Validation failed", + details: ["Day 2024-12-01 has timestamp 1733097900000 outside its claimed UTC bucket"], + trustState: "rejected", + errorCodes: ["timestamp_day_mismatch"], + }); + expect(mockState.db.transaction).not.toHaveBeenCalled(); + expect(mockState.revalidateTag).not.toHaveBeenCalled(); + }); + + it("accepts suspicious history into the review path with explicit non-trusted decisioning", async () => { + const oldDate = "2024-12-01"; + const payload = { + ...createSubmissionPayload(oldDate), + contributions: [ + { + ...createSubmissionPayload(oldDate).contributions[0], + timestampMs: undefined, + }, + ], + }; + + mockState.authenticatePersonalToken.mockResolvedValue(createValidAuthRecord()); + mockState.validateSubmission.mockReturnValue({ + valid: true, + errors: [], + warnings: [ + "Day 2024-12-01 is older than 30 days and has no timestampMs audit metadata", + ], + trustState: "review_required", + reasonCodes: ["historical_day_missing_timestamp"], + rejectionReasonCodes: [], + data: payload, + }); + mockState.db.transaction.mockResolvedValue({ + trustState: "review_required", + reviewId: "review-1", + metrics: { + totalTokens: 150, + totalCost: 1.5, + dateRange: { start: oldDate, end: oldDate }, + activeDays: 1, + clients: ["claude"], + }, + }); + + const response = await POST( + new Request("http://localhost:3000/api/submit", { + method: "POST", + headers: { + Authorization: "Bearer tt_valid", + "Content-Type": "application/json", + }, + body: JSON.stringify(payload), + }) + ); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ + success: true, + submissionId: undefined, + reviewId: "review-1", + username: "alice", + metrics: { + totalTokens: 150, + totalCost: 1.5, + dateRange: { start: oldDate, end: oldDate }, + activeDays: 1, + clients: ["claude"], + }, + trustState: "review_required", + mode: "review", + reasonCodes: ["historical_day_missing_timestamp"], + competitiveWriteApplied: false, + warnings: [ + "Day 2024-12-01 is older than 30 days and has no timestampMs audit metadata", + ], + }); + expect(mockState.revalidateTag).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/frontend/src/app/api/submit/route.ts b/packages/frontend/src/app/api/submit/route.ts index 21ff23f5..573be1ce 100644 --- a/packages/frontend/src/app/api/submit/route.ts +++ b/packages/frontend/src/app/api/submit/route.ts @@ -1,6 +1,12 @@ import { NextResponse } from "next/server"; import { revalidateTag } from "next/cache"; -import { db, apiTokens, submissions, dailyBreakdown } from "@/lib/db"; +import { + db, + apiTokens, + submissions, + dailyBreakdown, + submissionReviews, +} from "@/lib/db"; import { eq, sql } from "drizzle-orm"; import { validateSubmission, @@ -8,6 +14,10 @@ import { type SubmissionData, } from "@/lib/validation/submission"; import { authenticatePersonalToken } from "@/lib/auth/personalTokens"; +import { + SUBMISSION_TRUST_STATE, + type SubmissionTrustState, +} from "../../../lib/validation/submissionTrust"; import { mergeClientBreakdowns, recalculateDayTotals, @@ -45,6 +55,49 @@ function normalizeSubmissionData(data: unknown): void { } } +interface SubmissionResponseMetrics { + totalTokens: number; + totalCost: number; + dateRange: { + start: string; + end: string; + }; + activeDays: number; + clients: string[]; +} + +function buildSubmittedMetrics( + data: SubmissionData, + submittedClients: Set +): SubmissionResponseMetrics { + const totalTokens = data.contributions.reduce( + (sum, contribution) => sum + contribution.totals.tokens, + 0 + ); + const totalCost = data.contributions.reduce( + (sum, contribution) => sum + contribution.totals.cost, + 0 + ); + const activeDays = data.contributions.filter( + (contribution) => contribution.totals.tokens > 0 + ).length; + + return { + totalTokens, + totalCost, + dateRange: { + start: data.meta.dateRange.start, + end: data.meta.dateRange.end, + }, + activeDays, + clients: Array.from(submittedClients).sort(), + }; +} + +function shouldRevalidatePublicCaches(trustState: SubmissionTrustState): boolean { + return trustState === SUBMISSION_TRUST_STATE.TRUSTED; +} + /** * POST /api/submit * Submit token usage data from CLI @@ -100,15 +153,28 @@ export async function POST(request: Request) { normalizeSubmissionData(rawData); const validation = validateSubmission(rawData); + const validationWarnings = validation.warnings ?? []; + const validationReasonCodes = validation.reasonCodes ?? []; + const validationRejectionReasonCodes = + validation.rejectionReasonCodes ?? []; if (!validation.valid || !validation.data) { return NextResponse.json( - { error: "Validation failed", details: validation.errors }, + { + error: "Validation failed", + details: validation.errors, + trustState: SUBMISSION_TRUST_STATE.REJECTED, + errorCodes: + validationRejectionReasonCodes.length > 0 + ? validationRejectionReasonCodes + : undefined, + }, { status: 400 } ); } const data = validation.data; + const trustState = validation.trustState; if (data.contributions.length === 0) { return NextResponse.json( @@ -133,6 +199,8 @@ export async function POST(request: Request) { clients: Array.from(submittedClients).sort(), }, }; + const submittedMetrics = buildSubmittedMetrics(data, submittedClients); + const schemaVersion = data.contributions.some((c) => c.timestampMs != null) ? 1 : 0; // ======================================== // STEP 3: DATABASE OPERATIONS IN TRANSACTION @@ -143,6 +211,34 @@ export async function POST(request: Request) { .set({ lastUsedAt: new Date() }) .where(eq(apiTokens.id, tokenRecord.tokenId)); + if (trustState === SUBMISSION_TRUST_STATE.REVIEW_REQUIRED) { + const [review] = await tx + .insert(submissionReviews) + .values({ + userId: tokenRecord.userId, + submissionHash: generateSubmissionHash(hashData), + trustState, + reasonCodes: validationReasonCodes, + payload: data as unknown as Record, + totalTokens: submittedMetrics.totalTokens, + totalCost: submittedMetrics.totalCost.toFixed(4), + activeDays: submittedMetrics.activeDays, + dateStart: submittedMetrics.dateRange.start, + dateEnd: submittedMetrics.dateRange.end, + sourcesUsed: submittedMetrics.clients, + modelsUsed: data.summary.models, + cliVersion: data.meta.version, + schemaVersion, + }) + .returning({ id: submissionReviews.id }); + + return { + trustState, + reviewId: review.id, + metrics: submittedMetrics, + }; + } + // ------------------------------------------ // STEP 3a: Get or create user's submission // ------------------------------------------ @@ -401,43 +497,57 @@ export async function POST(request: Request) { cliVersion: data.meta.version, submissionHash: generateSubmissionHash(hashData), submitCount: sql`COALESCE(submit_count, 0) + 1`, - schemaVersion: sql`GREATEST(COALESCE(${submissions.schemaVersion}, 0), ${data.contributions.some((c) => c.timestampMs != null) ? 1 : 0})`, + schemaVersion: sql`GREATEST(COALESCE(${submissions.schemaVersion}, 0), ${schemaVersion})`, updatedAt: new Date(), }) .where(eq(submissions.id, submissionId)); return { + trustState, submissionId, isNewSubmission, metrics: { - totalTokens: aggregates.totalTokens, + totalTokens: Number(aggregates.totalTokens ?? 0), totalCost: parseFloat(aggregates.totalCost), dateRange: { start: aggregates.dateStart, end: aggregates.dateEnd, }, - activeDays: aggregates.activeDays, + activeDays: Number(aggregates.activeDays ?? 0), clients: Array.from(allClients), }, }; }); - try { - revalidateTag("leaderboard", "max"); - revalidateTag(`user:${tokenRecord.username}`, "max"); - revalidateTag("user-rank", "max"); - revalidateTag(`user-rank:${tokenRecord.username}`, "max"); - } catch (e) { - console.error("Cache invalidation failed:", e); + if (shouldRevalidatePublicCaches(result.trustState)) { + try { + revalidateTag("leaderboard", "max"); + revalidateTag(`user:${tokenRecord.username}`, "max"); + revalidateTag("user-rank", "max"); + revalidateTag(`user-rank:${tokenRecord.username}`, "max"); + } catch (e) { + console.error("Cache invalidation failed:", e); + } } return NextResponse.json({ success: true, - submissionId: result.submissionId, username: tokenRecord.username, metrics: result.metrics, - mode: result.isNewSubmission ? "create" : "merge", - warnings: validation.warnings.length > 0 ? validation.warnings : undefined, + trustState: result.trustState, + submissionId: "submissionId" in result ? result.submissionId : undefined, + reviewId: "reviewId" in result ? result.reviewId : undefined, + mode: + result.trustState === SUBMISSION_TRUST_STATE.REVIEW_REQUIRED + ? "review" + : result.isNewSubmission + ? "create" + : "merge", + reasonCodes: + validationReasonCodes.length > 0 ? validationReasonCodes : undefined, + competitiveWriteApplied: + result.trustState === SUBMISSION_TRUST_STATE.TRUSTED, + warnings: validationWarnings.length > 0 ? validationWarnings : undefined, }); } catch (error) { console.error("Submit error:", error); diff --git a/packages/frontend/src/lib/db/migrations/0005_concerned_justin_hammer.sql b/packages/frontend/src/lib/db/migrations/0005_concerned_justin_hammer.sql new file mode 100644 index 00000000..c9e0e088 --- /dev/null +++ b/packages/frontend/src/lib/db/migrations/0005_concerned_justin_hammer.sql @@ -0,0 +1,25 @@ +CREATE TABLE "submission_reviews" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "user_id" uuid NOT NULL, + "submission_hash" varchar(64), + "trust_state" varchar(20) DEFAULT 'review_required' NOT NULL, + "reason_codes" text[] DEFAULT ARRAY[]::text[] NOT NULL, + "payload" jsonb NOT NULL, + "total_tokens" bigint NOT NULL, + "total_cost" numeric(12, 4) NOT NULL, + "active_days" integer NOT NULL, + "date_start" date NOT NULL, + "date_end" date NOT NULL, + "sources_used" text[] NOT NULL, + "models_used" text[] NOT NULL, + "cli_version" varchar(20), + "schema_version" integer DEFAULT 0 NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "submissions" ADD COLUMN "submit_count" integer DEFAULT 1 NOT NULL;--> statement-breakpoint +ALTER TABLE "submission_reviews" ADD CONSTRAINT "submission_reviews_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "idx_submission_reviews_user_id" ON "submission_reviews" USING btree ("user_id");--> statement-breakpoint +CREATE INDEX "idx_submission_reviews_trust_state" ON "submission_reviews" USING btree ("trust_state");--> statement-breakpoint +CREATE INDEX "idx_submission_reviews_created_at" ON "submission_reviews" USING btree ("created_at");--> statement-breakpoint \ No newline at end of file diff --git a/packages/frontend/src/lib/db/migrations/meta/0005_snapshot.json b/packages/frontend/src/lib/db/migrations/meta/0005_snapshot.json new file mode 100644 index 00000000..af54e804 --- /dev/null +++ b/packages/frontend/src/lib/db/migrations/meta/0005_snapshot.json @@ -0,0 +1,1114 @@ +{ + "id": "e8e62f01-c548-4b01-bbe8-ce48645dfed4", + "prevId": "4342d7b5-5562-431f-afd1-0dd773563dff", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.api_tokens": { + "name": "api_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_api_tokens_token": { + "name": "idx_api_tokens_token", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_api_tokens_user_id": { + "name": "idx_api_tokens_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "api_tokens_user_id_users_id_fk": { + "name": "api_tokens_user_id_users_id_fk", + "tableFrom": "api_tokens", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "api_tokens_token_unique": { + "name": "api_tokens_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + }, + "api_tokens_user_name_unique": { + "name": "api_tokens_user_name_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id", + "name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.daily_breakdown": { + "name": "daily_breakdown", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "submission_id": { + "name": "submission_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "date": { + "name": "date", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "tokens": { + "name": "tokens", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "cost": { + "name": "cost", + "type": "numeric(10, 4)", + "primaryKey": false, + "notNull": true + }, + "input_tokens": { + "name": "input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "output_tokens": { + "name": "output_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "timestamp_ms": { + "name": "timestamp_ms", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "provider_breakdown": { + "name": "provider_breakdown", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "source_breakdown": { + "name": "source_breakdown", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "model_breakdown": { + "name": "model_breakdown", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_daily_breakdown_submission_id": { + "name": "idx_daily_breakdown_submission_id", + "columns": [ + { + "expression": "submission_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_daily_breakdown_date": { + "name": "idx_daily_breakdown_date", + "columns": [ + { + "expression": "date", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "daily_breakdown_submission_id_submissions_id_fk": { + "name": "daily_breakdown_submission_id_submissions_id_fk", + "tableFrom": "daily_breakdown", + "tableTo": "submissions", + "columnsFrom": [ + "submission_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "daily_breakdown_submission_date_unique": { + "name": "daily_breakdown_submission_date_unique", + "nullsNotDistinct": false, + "columns": [ + "submission_id", + "date" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.device_codes": { + "name": "device_codes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "device_code": { + "name": "device_code", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "user_code": { + "name": "user_code", + "type": "varchar(9)", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "device_name": { + "name": "device_name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_device_codes_device_code": { + "name": "idx_device_codes_device_code", + "columns": [ + { + "expression": "device_code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_device_codes_user_code": { + "name": "idx_device_codes_user_code", + "columns": [ + { + "expression": "user_code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_device_codes_expires_at": { + "name": "idx_device_codes_expires_at", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "device_codes_user_id_users_id_fk": { + "name": "device_codes_user_id_users_id_fk", + "tableFrom": "device_codes", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "device_codes_device_code_unique": { + "name": "device_codes_device_code_unique", + "nullsNotDistinct": false, + "columns": [ + "device_code" + ] + }, + "device_codes_user_code_unique": { + "name": "device_codes_user_code_unique", + "nullsNotDistinct": false, + "columns": [ + "user_code" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true, + "default": "'web'" + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_sessions_token": { + "name": "idx_sessions_token", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_sessions_user_id": { + "name": "idx_sessions_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_sessions_expires_at": { + "name": "idx_sessions_expires_at", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "sessions_token_unique": { + "name": "sessions_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.submission_reviews": { + "name": "submission_reviews", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "submission_hash": { + "name": "submission_hash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "trust_state": { + "name": "trust_state", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'review_required'" + }, + "reason_codes": { + "name": "reason_codes", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "ARRAY[]::text[]" + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "total_tokens": { + "name": "total_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "total_cost": { + "name": "total_cost", + "type": "numeric(12, 4)", + "primaryKey": false, + "notNull": true + }, + "active_days": { + "name": "active_days", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "date_start": { + "name": "date_start", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "date_end": { + "name": "date_end", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "sources_used": { + "name": "sources_used", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "models_used": { + "name": "models_used", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "cli_version": { + "name": "cli_version", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "schema_version": { + "name": "schema_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_submission_reviews_user_id": { + "name": "idx_submission_reviews_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_submission_reviews_trust_state": { + "name": "idx_submission_reviews_trust_state", + "columns": [ + { + "expression": "trust_state", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_submission_reviews_created_at": { + "name": "idx_submission_reviews_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "submission_reviews_user_id_users_id_fk": { + "name": "submission_reviews_user_id_users_id_fk", + "tableFrom": "submission_reviews", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.submissions": { + "name": "submissions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "total_tokens": { + "name": "total_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "total_cost": { + "name": "total_cost", + "type": "numeric(12, 4)", + "primaryKey": false, + "notNull": true + }, + "input_tokens": { + "name": "input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "output_tokens": { + "name": "output_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "cache_creation_tokens": { + "name": "cache_creation_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "cache_read_tokens": { + "name": "cache_read_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "reasoning_tokens": { + "name": "reasoning_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "date_start": { + "name": "date_start", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "date_end": { + "name": "date_end", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "sources_used": { + "name": "sources_used", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "models_used": { + "name": "models_used", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'verified'" + }, + "cli_version": { + "name": "cli_version", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "submission_hash": { + "name": "submission_hash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "submit_count": { + "name": "submit_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "schema_version": { + "name": "schema_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_submissions_user_id": { + "name": "idx_submissions_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_submissions_status": { + "name": "idx_submissions_status", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_submissions_total_tokens": { + "name": "idx_submissions_total_tokens", + "columns": [ + { + "expression": "total_tokens", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_submissions_created_at": { + "name": "idx_submissions_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_submissions_date_range": { + "name": "idx_submissions_date_range", + "columns": [ + { + "expression": "date_start", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_end", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_submissions_leaderboard": { + "name": "idx_submissions_leaderboard", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "total_tokens", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "total_cost", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "submissions_user_id_users_id_fk": { + "name": "submissions_user_id_users_id_fk", + "tableFrom": "submissions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "submissions_user_id_unique": { + "name": "submissions_user_id_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id" + ] + }, + "submissions_user_hash_unique": { + "name": "submissions_user_hash_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id", + "submission_hash" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "github_id": { + "name": "github_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "username": { + "name": "username", + "type": "varchar(39)", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "is_admin": { + "name": "is_admin", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_users_username": { + "name": "idx_users_username", + "columns": [ + { + "expression": "username", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_users_github_id": { + "name": "idx_users_github_id", + "columns": [ + { + "expression": "github_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_github_id_unique": { + "name": "users_github_id_unique", + "nullsNotDistinct": false, + "columns": [ + "github_id" + ] + }, + "users_username_unique": { + "name": "users_username_unique", + "nullsNotDistinct": false, + "columns": [ + "username" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/frontend/src/lib/db/migrations/meta/_journal.json b/packages/frontend/src/lib/db/migrations/meta/_journal.json index f2d23cd9..97fc3fdb 100644 --- a/packages/frontend/src/lib/db/migrations/meta/_journal.json +++ b/packages/frontend/src/lib/db/migrations/meta/_journal.json @@ -36,6 +36,13 @@ "when": 1771322400000, "tag": "0004_add_timestamp_and_schema_version", "breakpoints": true + }, + { + "idx": 5, + "version": "7", + "when": 1776474932168, + "tag": "0005_concerned_justin_hammer", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/frontend/src/lib/db/schema.ts b/packages/frontend/src/lib/db/schema.ts index 247c8592..33d600f5 100644 --- a/packages/frontend/src/lib/db/schema.ts +++ b/packages/frontend/src/lib/db/schema.ts @@ -13,7 +13,7 @@ import { index, unique, } from "drizzle-orm/pg-core"; -import { relations } from "drizzle-orm"; +import { relations, sql } from "drizzle-orm"; // ============================================================================ // USERS @@ -45,6 +45,7 @@ export const usersRelations = relations(users, ({ many }) => ({ sessions: many(sessions), apiTokens: many(apiTokens), submissions: many(submissions), + submissionReviews: many(submissionReviews), })); // ============================================================================ @@ -200,6 +201,60 @@ export const submissionsRelations = relations(submissions, ({ one, many }) => ({ dailyBreakdown: many(dailyBreakdown), })); +// ============================================================================ +// SUBMISSION REVIEWS +// ============================================================================ +export const submissionReviews = pgTable( + "submission_reviews", + { + id: uuid("id").primaryKey().defaultRandom(), + userId: uuid("user_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + + submissionHash: varchar("submission_hash", { length: 64 }), + trustState: varchar("trust_state", { length: 20 }) + .notNull() + .default("review_required"), + reasonCodes: text("reason_codes") + .array() + .notNull() + .default(sql`ARRAY[]::text[]`), + payload: jsonb("payload") + .$type>() + .notNull(), + + totalTokens: bigint("total_tokens", { mode: "number" }).notNull(), + totalCost: decimal("total_cost", { precision: 12, scale: 4 }).notNull(), + activeDays: integer("active_days").notNull(), + dateStart: date("date_start").notNull(), + dateEnd: date("date_end").notNull(), + sourcesUsed: text("sources_used").array().notNull(), + modelsUsed: text("models_used").array().notNull(), + cliVersion: varchar("cli_version", { length: 20 }), + schemaVersion: integer("schema_version").notNull().default(0), + + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }) + .notNull() + .defaultNow(), + }, + (table) => [ + index("idx_submission_reviews_user_id").on(table.userId), + index("idx_submission_reviews_trust_state").on(table.trustState), + index("idx_submission_reviews_created_at").on(table.createdAt), + ] +); + +export const submissionReviewsRelations = relations(submissionReviews, ({ one }) => ({ + user: one(users, { + fields: [submissionReviews.userId], + references: [users.id], + }), +})); + // ============================================================================ // DAILY BREAKDOWN // ============================================================================ @@ -280,5 +335,7 @@ export type DeviceCode = typeof deviceCodes.$inferSelect; export type NewDeviceCode = typeof deviceCodes.$inferInsert; export type Submission = typeof submissions.$inferSelect; export type NewSubmission = typeof submissions.$inferInsert; +export type SubmissionReview = typeof submissionReviews.$inferSelect; +export type NewSubmissionReview = typeof submissionReviews.$inferInsert; export type DailyBreakdown = typeof dailyBreakdown.$inferSelect; export type NewDailyBreakdown = typeof dailyBreakdown.$inferInsert; diff --git a/packages/frontend/src/lib/validation/submission.ts b/packages/frontend/src/lib/validation/submission.ts index 1f1bbe3a..52cad95e 100644 --- a/packages/frontend/src/lib/validation/submission.ts +++ b/packages/frontend/src/lib/validation/submission.ts @@ -6,6 +6,12 @@ */ import { z } from "zod"; +import { + assessSubmissionTrust, + SUBMISSION_TRUST_STATE, + type SubmissionReasonCode, + type SubmissionTrustState, +} from "./submissionTrust"; // ============================================================================ // SCHEMAS @@ -173,6 +179,9 @@ export interface ValidationResult { valid: boolean; errors: string[]; warnings: string[]; + trustState: SubmissionTrustState; + reasonCodes: SubmissionReasonCode[]; + rejectionReasonCodes: SubmissionReasonCode[]; data?: SubmissionData; } @@ -192,6 +201,9 @@ export function validateSubmission(data: unknown): ValidationResult { (e: z.ZodIssue) => `${e.path.join(".")}: ${e.message}` ), warnings: [], + trustState: SUBMISSION_TRUST_STATE.REJECTED, + reasonCodes: [], + rejectionReasonCodes: [], }; } @@ -323,11 +335,21 @@ export function validateSubmission(data: unknown): ValidationResult { } } + const trustAssessment = assessSubmissionTrust(submission); + errors.push(...trustAssessment.errors); + warnings.push(...trustAssessment.warnings); + const valid = errors.length === 0; + return { - valid: errors.length === 0, + valid, errors, warnings, - data: errors.length === 0 ? submission : undefined, + trustState: valid + ? trustAssessment.trustState + : SUBMISSION_TRUST_STATE.REJECTED, + reasonCodes: valid ? trustAssessment.reasonCodes : [], + rejectionReasonCodes: valid ? [] : trustAssessment.rejectionReasonCodes, + data: valid ? submission : undefined, }; } diff --git a/packages/frontend/src/lib/validation/submissionTrust.ts b/packages/frontend/src/lib/validation/submissionTrust.ts new file mode 100644 index 00000000..78b93a19 --- /dev/null +++ b/packages/frontend/src/lib/validation/submissionTrust.ts @@ -0,0 +1,142 @@ +import type { SubmissionData } from "./submission"; + +export const SUBMISSION_TRUST_STATE = { + TRUSTED: "trusted", + REVIEW_REQUIRED: "review_required", + REJECTED: "rejected", +} as const; + +export type SubmissionTrustState = + (typeof SUBMISSION_TRUST_STATE)[keyof typeof SUBMISSION_TRUST_STATE]; + +export const SUBMISSION_REASON_CODE = { + TIMESTAMP_DAY_MISMATCH: "timestamp_day_mismatch", + MODEL_PREDATES_PUBLIC_AVAILABILITY: "model_predates_public_availability", + HISTORICAL_DAY_MISSING_TIMESTAMP: "historical_day_missing_timestamp", + PARTIAL_TIMESTAMP_COVERAGE: "partial_timestamp_coverage", +} as const; + +export type SubmissionReasonCode = + (typeof SUBMISSION_REASON_CODE)[keyof typeof SUBMISSION_REASON_CODE]; + +export interface SubmissionTrustAssessment { + trustState: SubmissionTrustState; + reasonCodes: SubmissionReasonCode[]; + rejectionReasonCodes: SubmissionReasonCode[]; + errors: string[]; + warnings: string[]; +} + +const TRUSTED_RETROACTIVE_WINDOW_DAYS = 30; + +function getUtcDateStringFromTimestamp(timestampMs: number): string { + return new Date(timestampMs).toISOString().slice(0, 10); +} + +function getRetroactiveThresholdDate(now: Date): string { + const utcMidnight = new Date( + Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()) + ); + utcMidnight.setUTCDate(utcMidnight.getUTCDate() - TRUSTED_RETROACTIVE_WINDOW_DAYS); + return utcMidnight.toISOString().slice(0, 10); +} + +function extractDatedModelAvailability(modelId: string): string | null { + const match = modelId.match(/(?:^|[-_])(20\d{2})(\d{2})(\d{2})(?:$|[-_])/); + if (!match) { + return null; + } + + const [, year, month, day] = match; + const parsedMonth = Number(month); + const parsedDay = Number(day); + if (parsedMonth < 1 || parsedMonth > 12 || parsedDay < 1 || parsedDay > 31) { + return null; + } + + return `${year}-${month}-${day}`; +} + +export function assessSubmissionTrust( + submission: SubmissionData, + now: Date = new Date() +): SubmissionTrustAssessment { + const errors: string[] = []; + const warnings: string[] = []; + const rejectionReasonCodes = new Set(); + const reviewReasonCodes = new Set(); + const retroactiveThresholdDate = getRetroactiveThresholdDate(now); + + let sawTimestampedDay = false; + let sawUntimestampedDay = false; + + for (const day of submission.contributions) { + if (day.timestampMs != null) { + sawTimestampedDay = true; + const timestampDate = getUtcDateStringFromTimestamp(day.timestampMs); + if (timestampDate !== day.date) { + rejectionReasonCodes.add(SUBMISSION_REASON_CODE.TIMESTAMP_DAY_MISMATCH); + errors.push( + `Day ${day.date} has timestamp ${day.timestampMs} outside its claimed UTC bucket` + ); + } + } else { + sawUntimestampedDay = true; + if (day.date < retroactiveThresholdDate) { + reviewReasonCodes.add( + SUBMISSION_REASON_CODE.HISTORICAL_DAY_MISSING_TIMESTAMP + ); + warnings.push( + `Day ${day.date} is older than ${TRUSTED_RETROACTIVE_WINDOW_DAYS} days and has no timestampMs audit metadata` + ); + } + } + + for (const client of day.clients) { + const availabilityDate = extractDatedModelAvailability(client.modelId); + if (availabilityDate && day.date < availabilityDate) { + rejectionReasonCodes.add( + SUBMISSION_REASON_CODE.MODEL_PREDATES_PUBLIC_AVAILABILITY + ); + errors.push( + `Model ${client.modelId} cannot be submitted for ${day.date} before ${availabilityDate}` + ); + } + } + } + + if (sawTimestampedDay && sawUntimestampedDay) { + reviewReasonCodes.add(SUBMISSION_REASON_CODE.PARTIAL_TIMESTAMP_COVERAGE); + warnings.push( + "Submission mixes timestamped and untimestamped contribution days; review is required before trusting the full history" + ); + } + + if (rejectionReasonCodes.size > 0) { + return { + trustState: SUBMISSION_TRUST_STATE.REJECTED, + reasonCodes: [], + rejectionReasonCodes: Array.from(rejectionReasonCodes), + errors, + warnings, + }; + } + + if (reviewReasonCodes.size > 0) { + return { + trustState: SUBMISSION_TRUST_STATE.REVIEW_REQUIRED, + reasonCodes: Array.from(reviewReasonCodes), + rejectionReasonCodes: [], + errors: [], + warnings, + }; + } + + return { + trustState: SUBMISSION_TRUST_STATE.TRUSTED, + reasonCodes: [], + rejectionReasonCodes: [], + errors: [], + warnings: [], + }; +} From 76e31320f5b097175d37ca8ae54bac2989dfcd36 Mon Sep 17 00:00:00 2001 From: shivam Date: Sat, 18 Apr 2026 01:48:31 +0000 Subject: [PATCH 3/6] fix(frontend): defer legacy submit token upgrades until accepted writes --- .../frontend/__tests__/api/submitAuth.test.ts | 22 +++++ .../__tests__/api/submitDecisioning.test.ts | 93 +++++++++++++++++++ .../__tests__/lib/personalTokens.test.ts | 56 +++++++++++ packages/frontend/src/app/api/submit/route.ts | 14 ++- .../frontend/src/lib/auth/personalTokens.ts | 8 +- 5 files changed, 191 insertions(+), 2 deletions(-) diff --git a/packages/frontend/__tests__/api/submitAuth.test.ts b/packages/frontend/__tests__/api/submitAuth.test.ts index 2b78debf..423b09cf 100644 --- a/packages/frontend/__tests__/api/submitAuth.test.ts +++ b/packages/frontend/__tests__/api/submitAuth.test.ts @@ -4,6 +4,7 @@ const mockState = vi.hoisted(() => { const authenticatePersonalToken = vi.fn(); const validateSubmission = vi.fn(); const generateSubmissionHash = vi.fn(() => "submission-hash"); + const hashToken = vi.fn((token: string) => `hashed_${token}`); const revalidateTag = vi.fn(); const db = { @@ -14,12 +15,14 @@ const mockState = vi.hoisted(() => { authenticatePersonalToken, validateSubmission, generateSubmissionHash, + hashToken, revalidateTag, db, reset() { authenticatePersonalToken.mockReset(); validateSubmission.mockReset(); generateSubmissionHash.mockClear(); + hashToken.mockClear(); revalidateTag.mockClear(); db.transaction.mockReset(); }, @@ -57,6 +60,10 @@ vi.mock("@/lib/validation/submission", () => ({ generateSubmissionHash: mockState.generateSubmissionHash, })); +vi.mock("@/lib/auth/utils", () => ({ + hashToken: mockState.hashToken, +})); + vi.mock("@/lib/db/helpers", () => ({ mergeClientBreakdowns: vi.fn(), recalculateDayTotals: vi.fn(), @@ -111,6 +118,7 @@ describe("POST /api/submit auth path", () => { expect(response.status).toBe(401); expect(mockState.authenticatePersonalToken).toHaveBeenCalledWith("tt_invalid", { touchLastUsedAt: false, + upgradeLegacyTokenHash: false, }); expect(await response.json()).toEqual({ error: "Invalid API token" }); }); @@ -131,6 +139,7 @@ describe("POST /api/submit auth path", () => { expect(response.status).toBe(401); expect(mockState.authenticatePersonalToken).toHaveBeenCalledWith("tt_expired", { touchLastUsedAt: false, + upgradeLegacyTokenHash: false, }); expect(await response.json()).toEqual({ error: "API token has expired" }); expect(mockState.db.transaction).not.toHaveBeenCalled(); @@ -160,6 +169,10 @@ describe("POST /api/submit auth path", () => { ); expect(response.status).toBe(400); + expect(mockState.authenticatePersonalToken).toHaveBeenCalledWith("tt_valid", { + touchLastUsedAt: false, + upgradeLegacyTokenHash: false, + }); expect(mockState.validateSubmission).not.toHaveBeenCalled(); expect(mockState.db.transaction).not.toHaveBeenCalled(); expect(mockState.revalidateTag).not.toHaveBeenCalled(); @@ -197,6 +210,7 @@ describe("POST /api/submit auth path", () => { expect(response.status).toBe(400); expect(mockState.authenticatePersonalToken).toHaveBeenCalledWith("tt_valid", { touchLastUsedAt: false, + upgradeLegacyTokenHash: false, }); expect(mockState.validateSubmission).toHaveBeenCalledTimes(1); expect(mockState.db.transaction).not.toHaveBeenCalled(); @@ -255,6 +269,10 @@ describe("POST /api/submit auth path", () => { ); expect(response.status).toBe(400); + expect(mockState.authenticatePersonalToken).toHaveBeenCalledWith("tt_valid", { + touchLastUsedAt: false, + upgradeLegacyTokenHash: false, + }); expect(mockState.db.transaction).not.toHaveBeenCalled(); expect(mockState.revalidateTag).not.toHaveBeenCalled(); expect(await response.json()).toEqual({ error: "No contribution data to submit" }); @@ -323,6 +341,10 @@ describe("POST /api/submit auth path", () => { ); expect(response.status).toBe(400); + expect(mockState.authenticatePersonalToken).toHaveBeenCalledWith("tt_valid", { + touchLastUsedAt: false, + upgradeLegacyTokenHash: false, + }); expect(mockState.db.transaction).not.toHaveBeenCalled(); expect(mockState.revalidateTag).not.toHaveBeenCalled(); expect(await response.json()).toEqual({ diff --git a/packages/frontend/__tests__/api/submitDecisioning.test.ts b/packages/frontend/__tests__/api/submitDecisioning.test.ts index 66766a47..64296fc1 100644 --- a/packages/frontend/__tests__/api/submitDecisioning.test.ts +++ b/packages/frontend/__tests__/api/submitDecisioning.test.ts @@ -4,6 +4,7 @@ const mockState = vi.hoisted(() => { const authenticatePersonalToken = vi.fn(); const validateSubmission = vi.fn(); const generateSubmissionHash = vi.fn(() => "submission-hash"); + const hashToken = vi.fn((token: string) => `hashed_${token}`); const revalidateTag = vi.fn(); const db = { @@ -14,12 +15,14 @@ const mockState = vi.hoisted(() => { authenticatePersonalToken, validateSubmission, generateSubmissionHash, + hashToken, revalidateTag, db, reset() { authenticatePersonalToken.mockReset(); validateSubmission.mockReset(); generateSubmissionHash.mockClear(); + hashToken.mockClear(); revalidateTag.mockClear(); db.transaction.mockReset(); }, @@ -58,6 +61,10 @@ vi.mock("@/lib/validation/submission", () => ({ generateSubmissionHash: mockState.generateSubmissionHash, })); +vi.mock("@/lib/auth/utils", () => ({ + hashToken: mockState.hashToken, +})); + vi.mock("@/lib/db/helpers", () => ({ mergeClientBreakdowns: vi.fn(), recalculateDayTotals: vi.fn(), @@ -204,6 +211,10 @@ describe("POST /api/submit trust decisioning", () => { competitiveWriteApplied: true, warnings: ["Cost total minor mismatch: summary=1.50, calculated=1.50"], }); + expect(mockState.authenticatePersonalToken).toHaveBeenCalledWith("tt_valid", { + touchLastUsedAt: false, + upgradeLegacyTokenHash: false, + }); expect(mockState.revalidateTag).toHaveBeenCalledTimes(4); }); @@ -243,6 +254,84 @@ describe("POST /api/submit trust decisioning", () => { expect(mockState.revalidateTag).not.toHaveBeenCalled(); }); + it("defers legacy token rehashes until an accepted write transaction runs", async () => { + const oldDate = "2024-12-01"; + const payload = { + ...createSubmissionPayload(oldDate), + contributions: [ + { + ...createSubmissionPayload(oldDate).contributions[0], + timestampMs: undefined, + }, + ], + }; + const txUpdateSet = vi.fn(); + const txInsertValues = vi.fn(); + + mockState.authenticatePersonalToken.mockResolvedValue({ + ...createValidAuthRecord(), + needsLegacyTokenHashUpgrade: true, + }); + mockState.validateSubmission.mockReturnValue({ + valid: true, + errors: [], + warnings: [ + "Day 2024-12-01 is older than 30 days and has no timestampMs audit metadata", + ], + trustState: "review_required", + reasonCodes: ["historical_day_missing_timestamp"], + rejectionReasonCodes: [], + data: payload, + }); + mockState.db.transaction.mockImplementation(async (callback) => + callback({ + update: vi.fn(() => ({ + set: txUpdateSet.mockImplementation((value) => ({ + where: vi.fn(async () => value), + })), + })), + insert: vi.fn(() => ({ + values: txInsertValues.mockImplementation(() => ({ + returning: vi.fn(async () => [{ id: "review-legacy" }]), + })), + })), + }) + ); + + const response = await POST( + new Request("http://localhost:3000/api/submit", { + method: "POST", + headers: { + Authorization: "Bearer tt_valid", + "Content-Type": "application/json", + }, + body: JSON.stringify(payload), + }) + ); + + expect(response.status).toBe(200); + expect(mockState.authenticatePersonalToken).toHaveBeenCalledWith("tt_valid", { + touchLastUsedAt: false, + upgradeLegacyTokenHash: false, + }); + expect(txUpdateSet).toHaveBeenCalledWith( + expect.objectContaining({ + lastUsedAt: expect.any(Date), + token: "hashed_tt_valid", + }) + ); + expect(txInsertValues).toHaveBeenCalledTimes(1); + expect(mockState.hashToken).toHaveBeenCalledWith("tt_valid"); + expect(await response.json()).toEqual( + expect.objectContaining({ + success: true, + trustState: "review_required", + reviewId: "review-legacy", + competitiveWriteApplied: false, + }) + ); + }); + it("accepts suspicious history into the review path with explicit non-trusted decisioning", async () => { const oldDate = "2024-12-01"; const payload = { @@ -311,6 +400,10 @@ describe("POST /api/submit trust decisioning", () => { "Day 2024-12-01 is older than 30 days and has no timestampMs audit metadata", ], }); + expect(mockState.authenticatePersonalToken).toHaveBeenCalledWith("tt_valid", { + touchLastUsedAt: false, + upgradeLegacyTokenHash: false, + }); expect(mockState.revalidateTag).not.toHaveBeenCalled(); }); }); diff --git a/packages/frontend/__tests__/lib/personalTokens.test.ts b/packages/frontend/__tests__/lib/personalTokens.test.ts index 0a9cfe40..84b10a12 100644 --- a/packages/frontend/__tests__/lib/personalTokens.test.ts +++ b/packages/frontend/__tests__/lib/personalTokens.test.ts @@ -393,6 +393,35 @@ describe("personal token service", () => { expect(mockState.updateValues[0]).toHaveProperty("lastUsedAt"); }); + it("upgrades legacy plaintext tokens by default when authentication succeeds", async () => { + mockState.pushSelectResult([ + { + tokenId: "token-legacy", + tokenValue: "tt_test_token", + userId: "user-1", + username: "alice", + displayName: "Alice", + avatarUrl: null, + isAdmin: false, + expiresAt: null, + }, + ]); + mockState.pushUpdateResult(); + + const result = await authenticatePersonalToken("tt_test_token"); + + expect(result).toMatchObject({ + status: "valid", + tokenId: "token-legacy", + needsLegacyTokenHashUpgrade: false, + }); + expect(mockState.db.update).toHaveBeenCalledTimes(1); + expect(mockState.updateValues[0]).toMatchObject({ + token: "hashed_tt_test_token", + }); + expect(mockState.updateValues[0]).toHaveProperty("lastUsedAt"); + }); + it("can skip touching lastUsedAt when the caller opts out", async () => { mockState.pushSelectResult([ { @@ -418,6 +447,33 @@ describe("personal token service", () => { expect(mockState.db.update).not.toHaveBeenCalled(); }); + it("can authenticate legacy plaintext tokens without mutating them before the caller accepts a write", async () => { + mockState.pushSelectResult([ + { + tokenId: "token-legacy", + tokenValue: "tt_test_token", + userId: "user-1", + username: "alice", + displayName: "Alice", + avatarUrl: null, + isAdmin: false, + expiresAt: null, + }, + ]); + + const result = await authenticatePersonalToken("tt_test_token", { + touchLastUsedAt: false, + upgradeLegacyTokenHash: false, + }); + + expect(result).toMatchObject({ + status: "valid", + tokenId: "token-legacy", + needsLegacyTokenHashUpgrade: true, + }); + expect(mockState.db.update).not.toHaveBeenCalled(); + }); + it("lists tokens for a user", async () => { mockState.pushSelectResult([ { diff --git a/packages/frontend/src/app/api/submit/route.ts b/packages/frontend/src/app/api/submit/route.ts index 573be1ce..d543eed6 100644 --- a/packages/frontend/src/app/api/submit/route.ts +++ b/packages/frontend/src/app/api/submit/route.ts @@ -14,6 +14,7 @@ import { type SubmissionData, } from "@/lib/validation/submission"; import { authenticatePersonalToken } from "@/lib/auth/personalTokens"; +import { hashToken } from "@/lib/auth/utils"; import { SUBMISSION_TRUST_STATE, type SubmissionTrustState, @@ -128,6 +129,7 @@ export async function POST(request: Request) { const token = authHeader.slice(7); const authResult = await authenticatePersonalToken(token, { touchLastUsedAt: false, + upgradeLegacyTokenHash: false, }); if (authResult.status === "invalid") { @@ -206,9 +208,19 @@ export async function POST(request: Request) { // STEP 3: DATABASE OPERATIONS IN TRANSACTION // ======================================== const result = await db.transaction(async (tx) => { + const tokenWriteUpdates: { + lastUsedAt: Date; + token?: string; + } = { + lastUsedAt: new Date(), + }; + if (tokenRecord.needsLegacyTokenHashUpgrade) { + tokenWriteUpdates.token = hashToken(token); + } + await tx .update(apiTokens) - .set({ lastUsedAt: new Date() }) + .set(tokenWriteUpdates) .where(eq(apiTokens.id, tokenRecord.tokenId)); if (trustState === SUBMISSION_TRUST_STATE.REVIEW_REQUIRED) { diff --git a/packages/frontend/src/lib/auth/personalTokens.ts b/packages/frontend/src/lib/auth/personalTokens.ts index 15441fbf..04758757 100644 --- a/packages/frontend/src/lib/auth/personalTokens.ts +++ b/packages/frontend/src/lib/auth/personalTokens.ts @@ -30,6 +30,7 @@ export interface AuthenticatedPersonalToken { avatarUrl: string | null; isAdmin: boolean; expiresAt: Date | null; + needsLegacyTokenHashUpgrade: boolean; } export type PersonalTokenAuthResult = @@ -39,6 +40,7 @@ export type PersonalTokenAuthResult = export interface AuthenticatePersonalTokenOptions { touchLastUsedAt?: boolean; + upgradeLegacyTokenHash?: boolean; } const TOKEN_NAME_LOCK_NAMESPACE = "personal_token_names"; @@ -202,7 +204,9 @@ export async function authenticatePersonalToken( if (options.touchLastUsedAt !== false) { updates.lastUsedAt = new Date(); } - if (isLegacyPlaintext) { + const shouldUpgradeLegacyTokenHash = + isLegacyPlaintext && options.upgradeLegacyTokenHash !== false; + if (shouldUpgradeLegacyTokenHash) { updates.token = tokenHashed; } if (Object.keys(updates).length > 0) { @@ -221,5 +225,7 @@ export async function authenticatePersonalToken( avatarUrl: record.avatarUrl, isAdmin: record.isAdmin, expiresAt: record.expiresAt, + needsLegacyTokenHashUpgrade: + isLegacyPlaintext && !shouldUpgradeLegacyTokenHash, }; } From 23ebaebcee5b8345ce9bad9450a7ebba7c3097aa Mon Sep 17 00:00:00 2001 From: shivam Date: Sat, 18 Apr 2026 01:53:41 +0000 Subject: [PATCH 4/6] fix(frontend): parse separated snapshot dates in submit trust checks --- .../frontend/__tests__/api/submit.test.ts | 54 +++++++++++++++++++ .../src/lib/validation/submissionTrust.ts | 14 ++++- 2 files changed, 67 insertions(+), 1 deletion(-) diff --git a/packages/frontend/__tests__/api/submit.test.ts b/packages/frontend/__tests__/api/submit.test.ts index d1c83181..0be0cf8d 100644 --- a/packages/frontend/__tests__/api/submit.test.ts +++ b/packages/frontend/__tests__/api/submit.test.ts @@ -540,6 +540,60 @@ describe('POST /api/submit - Client-Level Merge', () => { ); }); + it.each([ + ['gpt-4o-2024-08-06', '2024-08-05', '2024-08-06'], + ['gpt-4.1-2025-04-14', '2025-04-13', '2025-04-14'], + ])( + 'hard-rejects separator-based dated snapshots for %s before %s', + (modelId, submittedDate, availabilityDate) => { + const payload = { + meta: { generatedAt: new Date().toISOString(), version: '1.0.0', dateRange: { start: submittedDate, end: submittedDate } }, + summary: { totalTokens: 150, totalCost: 1.5, totalDays: 1, activeDays: 1, averagePerDay: 1.5, maxCostInSingleDay: 1.5, clients: ['claude' as const], models: [modelId] }, + years: [], + contributions: [{ + date: submittedDate, + timestampMs: Date.parse(`${submittedDate}T10:00:00.000Z`), + totals: { tokens: 150, cost: 1.5, messages: 1 }, + intensity: 1 as const, + tokenBreakdown: { input: 100, output: 50, cacheRead: 0, cacheWrite: 0, reasoning: 0 }, + clients: [{ client: 'claude' as const, modelId, tokens: { input: 100, output: 50, cacheRead: 0, cacheWrite: 0, reasoning: 0 }, cost: 1.5, messages: 1 }], + }], + }; + + const result = validateSubmission(payload); + + expect(result.valid).toBe(false); + expect(result.trustState).toBe('rejected'); + expect(result.rejectionReasonCodes).toContain('model_predates_public_availability'); + expect(result.errors).toContain( + `Model ${modelId} cannot be submitted for ${submittedDate} before ${availabilityDate}` + ); + } + ); + + it('keeps undated model identifiers on the normal trusted path', () => { + const payload = { + meta: { generatedAt: new Date().toISOString(), version: '1.0.0', dateRange: { start: '2024-01-01', end: '2024-01-01' } }, + summary: { totalTokens: 150, totalCost: 1.5, totalDays: 1, activeDays: 1, averagePerDay: 1.5, maxCostInSingleDay: 1.5, clients: ['claude' as const], models: ['gpt-4o'] }, + years: [], + contributions: [{ + date: '2024-01-01', + timestampMs: Date.parse('2024-01-01T10:00:00.000Z'), + totals: { tokens: 150, cost: 1.5, messages: 1 }, + intensity: 1 as const, + tokenBreakdown: { input: 100, output: 50, cacheRead: 0, cacheWrite: 0, reasoning: 0 }, + clients: [{ client: 'claude' as const, modelId: 'gpt-4o', tokens: { input: 100, output: 50, cacheRead: 0, cacheWrite: 0, reasoning: 0 }, cost: 1.5, messages: 1 }], + }], + }; + + const result = validateSubmission(payload); + + expect(result.valid).toBe(true); + expect(result.trustState).toBe('trusted'); + expect(result.reasonCodes).toEqual([]); + expect(result.rejectionReasonCodes).toEqual([]); + }); + it('hard-rejects timestamps that do not align with the claimed UTC day bucket', () => { const payload = { meta: { generatedAt: new Date().toISOString(), version: '1.0.0', dateRange: { start: '2024-12-01', end: '2024-12-01' } }, diff --git a/packages/frontend/src/lib/validation/submissionTrust.ts b/packages/frontend/src/lib/validation/submissionTrust.ts index 78b93a19..ea59db30 100644 --- a/packages/frontend/src/lib/validation/submissionTrust.ts +++ b/packages/frontend/src/lib/validation/submissionTrust.ts @@ -42,18 +42,30 @@ function getRetroactiveThresholdDate(now: Date): string { } function extractDatedModelAvailability(modelId: string): string | null { - const match = modelId.match(/(?:^|[-_])(20\d{2})(\d{2})(\d{2})(?:$|[-_])/); + const match = + modelId.match(/(?:^|[-_])(20\d{2})(\d{2})(\d{2})(?:$|[-_])/) ?? + modelId.match(/(?:^|[-_])(20\d{2})[-_](\d{2})[-_](\d{2})(?:$|[-_])/); if (!match) { return null; } const [, year, month, day] = match; + const parsedYear = Number(year); const parsedMonth = Number(month); const parsedDay = Number(day); if (parsedMonth < 1 || parsedMonth > 12 || parsedDay < 1 || parsedDay > 31) { return null; } + const parsedDate = new Date(Date.UTC(parsedYear, parsedMonth - 1, parsedDay)); + if ( + parsedDate.getUTCFullYear() !== parsedYear || + parsedDate.getUTCMonth() + 1 !== parsedMonth || + parsedDate.getUTCDate() !== parsedDay + ) { + return null; + } + return `${year}-${month}-${day}`; } From 24810fc5bf9f81b11645f0f139e1776546b607a7 Mon Sep 17 00:00:00 2001 From: shivam Date: Sat, 18 Apr 2026 05:14:00 +0000 Subject: [PATCH 5/6] fix(frontend): remove stale replayed submission history --- .../__tests__/lib/submissionReplay.test.ts | 241 ++++++++++++++++++ packages/frontend/src/app/api/submit/route.ts | 116 +++------ packages/frontend/src/lib/db/helpers.ts | 156 ++++++++++++ 3 files changed, 436 insertions(+), 77 deletions(-) create mode 100644 packages/frontend/__tests__/lib/submissionReplay.test.ts diff --git a/packages/frontend/__tests__/lib/submissionReplay.test.ts b/packages/frontend/__tests__/lib/submissionReplay.test.ts new file mode 100644 index 00000000..8a7da35e --- /dev/null +++ b/packages/frontend/__tests__/lib/submissionReplay.test.ts @@ -0,0 +1,241 @@ +import { describe, expect, it } from "vitest"; +import { + planSubmittedReplayMutations, + type ClientBreakdownData, + type ExistingReplayDay, + type IncomingReplayDay, +} from "../../src/lib/db/helpers"; + +function createClientBreakdown( + client: string, + tokens: number, + cost: number, + modelId = `${client}-model` +): Record { + return { + [client]: { + tokens, + cost, + input: tokens, + output: 0, + cacheRead: 0, + cacheWrite: 0, + reasoning: 0, + messages: 1, + models: { + [modelId]: { + tokens, + cost, + input: tokens, + output: 0, + cacheRead: 0, + cacheWrite: 0, + reasoning: 0, + messages: 1, + }, + }, + }, + }; +} + +function mergeBreakdowns( + ...breakdowns: Array> +): Record { + return Object.assign({}, ...breakdowns); +} + +function createExistingDay( + id: string, + date: string, + sourceBreakdown: Record, + timestampMs?: number +): ExistingReplayDay { + return { + id, + date, + timestampMs: timestampMs ?? null, + sourceBreakdown, + }; +} + +function createIncomingDay( + date: string, + sourceBreakdown: Record, + timestampMs?: number +): IncomingReplayDay { + return { + date, + timestampMs: timestampMs ?? null, + sourceBreakdown, + }; +} + +describe("planSubmittedReplayMutations", () => { + it("replaces overlapping in-scope history without stacking and preserves out-of-scope clients", () => { + const result = planSubmittedReplayMutations({ + existingDays: [ + createExistingDay( + "day-1", + "2024-12-02", + mergeBreakdowns( + createClientBreakdown("claude", 100, 1), + createClientBreakdown("cursor", 40, 0.4) + ), + Date.parse("2024-12-02T09:00:00.000Z") + ), + ], + incomingDays: [ + createIncomingDay( + "2024-12-02", + createClientBreakdown("claude", 30, 0.3), + Date.parse("2024-12-02T12:00:00.000Z") + ), + ], + submittedClients: new Set(["claude"]), + replayWindow: { start: "2024-12-02", end: "2024-12-02" }, + submissionId: "submission-1", + }); + + expect(result.inserts).toEqual([]); + expect(result.deletes).toEqual([]); + expect(result.updates).toHaveLength(1); + expect(result.updates[0]).toMatchObject({ + id: "day-1", + date: "2024-12-02", + tokens: 70, + cost: "0.7000", + timestampMs: Date.parse("2024-12-02T09:00:00.000Z"), + }); + expect(result.updates[0].sourceBreakdown).toEqual( + mergeBreakdowns( + createClientBreakdown("claude", 30, 0.3), + createClientBreakdown("cursor", 40, 0.4) + ) + ); + }); + + it("removes an intentionally omitted in-scope client from an overlapping day only", () => { + const result = planSubmittedReplayMutations({ + existingDays: [ + createExistingDay( + "day-1", + "2024-12-02", + mergeBreakdowns( + createClientBreakdown("claude", 100, 1), + createClientBreakdown("cursor", 40, 0.4) + ) + ), + ], + incomingDays: [ + createIncomingDay( + "2024-12-02", + createClientBreakdown("opencode", 20, 0.2) + ), + ], + submittedClients: new Set(["claude", "opencode"]), + replayWindow: { start: "2024-12-02", end: "2024-12-02" }, + submissionId: "submission-1", + }); + + expect(result.deletes).toEqual([]); + expect(result.updates).toHaveLength(1); + expect(result.updates[0].tokens).toBe(60); + expect(result.updates[0].sourceBreakdown).toEqual( + mergeBreakdowns( + createClientBreakdown("cursor", 40, 0.4), + createClientBreakdown("opencode", 20, 0.2) + ) + ); + }); + + it("removes omitted in-scope days from the replayed window while preserving untouched dates outside it", () => { + const result = planSubmittedReplayMutations({ + existingDays: [ + createExistingDay( + "day-1", + "2024-12-01", + createClientBreakdown("claude", 50, 0.5), + Date.parse("2024-12-01T08:00:00.000Z") + ), + createExistingDay( + "day-2", + "2024-12-02", + mergeBreakdowns( + createClientBreakdown("claude", 60, 0.6), + createClientBreakdown("cursor", 10, 0.1) + ), + Date.parse("2024-12-02T08:00:00.000Z") + ), + createExistingDay( + "day-3", + "2024-12-03", + createClientBreakdown("cursor", 70, 0.7), + Date.parse("2024-12-03T08:00:00.000Z") + ), + ], + incomingDays: [ + createIncomingDay( + "2024-12-02", + createClientBreakdown("claude", 30, 0.3), + Date.parse("2024-12-02T11:00:00.000Z") + ), + ], + submittedClients: new Set(["claude"]), + replayWindow: { start: "2024-12-01", end: "2024-12-02" }, + submissionId: "submission-1", + }); + + expect(result.inserts).toEqual([]); + expect(result.deletes).toEqual([{ id: "day-1", date: "2024-12-01" }]); + expect(result.updates).toHaveLength(1); + expect(result.updates[0]).toMatchObject({ + id: "day-2", + date: "2024-12-02", + tokens: 40, + cost: "0.4000", + }); + expect(result.updates[0].sourceBreakdown).toEqual( + mergeBreakdowns( + createClientBreakdown("claude", 30, 0.3), + createClientBreakdown("cursor", 10, 0.1) + ) + ); + expect(result.updates.some((update) => update.date === "2024-12-03")).toBe(false); + expect(result.deletes.some((deleted) => deleted.date === "2024-12-03")).toBe(false); + }); + + it("keeps replay totals idempotent when the same logical history is submitted again", () => { + const existingBreakdown = createClientBreakdown("claude", 120, 1.2); + const result = planSubmittedReplayMutations({ + existingDays: [ + createExistingDay( + "day-1", + "2024-12-04", + existingBreakdown, + Date.parse("2024-12-04T10:00:00.000Z") + ), + ], + incomingDays: [ + createIncomingDay( + "2024-12-04", + createClientBreakdown("claude", 120, 1.2), + Date.parse("2024-12-04T10:00:00.000Z") + ), + ], + submittedClients: new Set(["claude"]), + replayWindow: { start: "2024-12-04", end: "2024-12-04" }, + submissionId: "submission-1", + }); + + expect(result.inserts).toEqual([]); + expect(result.deletes).toEqual([]); + expect(result.updates).toHaveLength(1); + expect(result.updates[0]).toMatchObject({ + id: "day-1", + tokens: 120, + cost: "1.2000", + timestampMs: Date.parse("2024-12-04T10:00:00.000Z"), + sourceBreakdown: existingBreakdown, + }); + }); +}); diff --git a/packages/frontend/src/app/api/submit/route.ts b/packages/frontend/src/app/api/submit/route.ts index d543eed6..1e833e1c 100644 --- a/packages/frontend/src/app/api/submit/route.ts +++ b/packages/frontend/src/app/api/submit/route.ts @@ -7,7 +7,7 @@ import { dailyBreakdown, submissionReviews, } from "@/lib/db"; -import { eq, sql } from "drizzle-orm"; +import { eq, inArray, sql } from "drizzle-orm"; import { validateSubmission, generateSubmissionHash, @@ -20,12 +20,9 @@ import { type SubmissionTrustState, } from "../../../lib/validation/submissionTrust"; import { - mergeClientBreakdowns, - recalculateDayTotals, - buildModelBreakdown, clientContributionToBreakdownData, - mergeTimestampMs, type ClientBreakdownData, + planSubmittedReplayMutations, } from "@/lib/db/helpers"; function normalizeSubmissionData(data: unknown): void { @@ -305,37 +302,10 @@ export async function POST(request: Request) { .where(eq(dailyBreakdown.submissionId, submissionId)) .for('update'); - const existingDaysMap = new Map( - existingDays.map((d) => [d.date, d]) - ); - // ------------------------------------------ - // STEP 3c: Compute merge results in memory, then batch write + // STEP 3c: Compute replay results in memory, then batch write // ------------------------------------------ - const toInsert: Array<{ - submissionId: string; - date: string; - tokens: number; - cost: string; - inputTokens: number; - outputTokens: number; - timestampMs: number | null; - sourceBreakdown: Record; - modelBreakdown: Record; - }> = []; - - const toUpdate: Array<{ - id: string; - tokens: number; - cost: string; - inputTokens: number; - outputTokens: number; - timestampMs: number | null; - sourceBreakdown: Record; - modelBreakdown: Record; - }> = []; - - for (const incomingDay of data.contributions) { + const incomingDays = data.contributions.map((incomingDay) => { const incomingClientBreakdown: Record = {}; for (const client_contrib of incomingDay.clients) { const modelData = clientContributionToBreakdownData(client_contrib); @@ -370,54 +340,35 @@ export async function POST(request: Request) { } } - const existingDay = existingDaysMap.get(incomingDay.date); - - if (existingDay) { - const existingClientBreakdown = (existingDay.sourceBreakdown || {}) as Record; - const mergedClientBreakdown = mergeClientBreakdowns( - existingClientBreakdown, - incomingClientBreakdown, - submittedClients - ); - const dayTotals = recalculateDayTotals(mergedClientBreakdown); - const modelBreakdown = buildModelBreakdown(mergedClientBreakdown); - - toUpdate.push({ - id: existingDay.id, - tokens: dayTotals.tokens, - cost: dayTotals.cost.toFixed(4), - inputTokens: dayTotals.inputTokens, - outputTokens: dayTotals.outputTokens, - timestampMs: mergeTimestampMs(existingDay.timestampMs, incomingDay.timestampMs ?? null), - sourceBreakdown: mergedClientBreakdown, - modelBreakdown, - }); - } else { - const dayTotals = recalculateDayTotals(incomingClientBreakdown); - const modelBreakdown = buildModelBreakdown(incomingClientBreakdown); - - toInsert.push({ - submissionId, - date: incomingDay.date, - tokens: dayTotals.tokens, - cost: dayTotals.cost.toFixed(4), - inputTokens: dayTotals.inputTokens, - outputTokens: dayTotals.outputTokens, - timestampMs: incomingDay.timestampMs ?? null, - sourceBreakdown: incomingClientBreakdown, - modelBreakdown, - }); - } - } + return { + date: incomingDay.date, + timestampMs: incomingDay.timestampMs ?? null, + sourceBreakdown: incomingClientBreakdown, + }; + }); + + const replayMutations = planSubmittedReplayMutations({ + existingDays: existingDays.map((existingDay) => ({ + id: existingDay.id, + date: existingDay.date, + timestampMs: existingDay.timestampMs, + sourceBreakdown: + (existingDay.sourceBreakdown || {}) as Record, + })), + incomingDays, + submittedClients, + replayWindow: data.meta.dateRange, + submissionId, + }); // Batch INSERT new days - if (toInsert.length > 0) { - await tx.insert(dailyBreakdown).values(toInsert); + if (replayMutations.inserts.length > 0) { + await tx.insert(dailyBreakdown).values(replayMutations.inserts); } // Batch UPDATE existing days via raw SQL VALUES list - if (toUpdate.length > 0) { - const valuesClauses = toUpdate.map( + if (replayMutations.updates.length > 0) { + const valuesClauses = replayMutations.updates.map( (row) => sql`(${row.id}::uuid, ${row.tokens}::bigint, ${row.cost}::numeric(10,4), ${row.inputTokens}::bigint, ${row.outputTokens}::bigint, ${row.timestampMs}::bigint, ${JSON.stringify(row.sourceBreakdown)}::jsonb, ${JSON.stringify(row.modelBreakdown)}::jsonb)` ); @@ -439,6 +390,17 @@ export async function POST(request: Request) { `); } + if (replayMutations.deletes.length > 0) { + await tx + .delete(dailyBreakdown) + .where( + inArray( + dailyBreakdown.id, + replayMutations.deletes.map((day) => day.id) + ) + ); + } + // ------------------------------------------ // STEP 3d: Recalculate submission totals from ALL daily breakdown // ------------------------------------------ diff --git a/packages/frontend/src/lib/db/helpers.ts b/packages/frontend/src/lib/db/helpers.ts index 1103053e..6e03921d 100644 --- a/packages/frontend/src/lib/db/helpers.ts +++ b/packages/frontend/src/lib/db/helpers.ts @@ -37,6 +37,67 @@ export interface DayTotals { reasoningTokens: number; } +export interface ReplayWindow { + start: string; + end: string; +} + +export interface ExistingReplayDay { + id: string; + date: string; + timestampMs: number | null; + sourceBreakdown: Record | null | undefined; +} + +export interface IncomingReplayDay { + date: string; + timestampMs: number | null | undefined; + sourceBreakdown: Record; +} + +export interface ReplayInsertDay { + submissionId: string; + date: string; + tokens: number; + cost: string; + inputTokens: number; + outputTokens: number; + timestampMs: number | null; + sourceBreakdown: Record; + modelBreakdown: Record; +} + +export interface ReplayUpdateDay { + id: string; + date: string; + tokens: number; + cost: string; + inputTokens: number; + outputTokens: number; + timestampMs: number | null; + sourceBreakdown: Record; + modelBreakdown: Record; +} + +export interface ReplayDeleteDay { + id: string; + date: string; +} + +export interface PlannedReplayMutations { + inserts: ReplayInsertDay[]; + updates: ReplayUpdateDay[]; + deletes: ReplayDeleteDay[]; +} + +export interface PlanSubmittedReplayArgs { + existingDays: ExistingReplayDay[]; + incomingDays: IncomingReplayDay[]; + submittedClients: Set; + replayWindow: ReplayWindow; + submissionId: string; +} + export function recalculateDayTotals( clientBreakdown: Record ): DayTotals { @@ -87,6 +148,101 @@ export function mergeClientBreakdowns( return merged; } +function isDateWithinReplayWindow(date: string, replayWindow: ReplayWindow): boolean { + return date >= replayWindow.start && date <= replayWindow.end; +} + +export function planSubmittedReplayMutations({ + existingDays, + incomingDays, + submittedClients, + replayWindow, + submissionId, +}: PlanSubmittedReplayArgs): PlannedReplayMutations { + const existingByDate = new Map(existingDays.map((day) => [day.date, day])); + const incomingByDate = new Map(incomingDays.map((day) => [day.date, day])); + const replayDates = new Set(incomingDays.map((day) => day.date)); + + for (const existingDay of existingDays) { + if (isDateWithinReplayWindow(existingDay.date, replayWindow)) { + replayDates.add(existingDay.date); + } + } + + const sortedReplayDates = Array.from(replayDates).sort((left, right) => + left.localeCompare(right) + ); + + const inserts: ReplayInsertDay[] = []; + const updates: ReplayUpdateDay[] = []; + const deletes: ReplayDeleteDay[] = []; + + for (const replayDate of sortedReplayDates) { + const existingDay = existingByDate.get(replayDate); + const incomingDay = incomingByDate.get(replayDate); + + const mergedClientBreakdown = existingDay + ? mergeClientBreakdowns( + existingDay.sourceBreakdown, + incomingDay?.sourceBreakdown ?? {}, + submittedClients + ) + : incomingDay?.sourceBreakdown ?? {}; + + if (Object.keys(mergedClientBreakdown).length === 0) { + if (existingDay) { + deletes.push({ + id: existingDay.id, + date: existingDay.date, + }); + } + continue; + } + + const dayTotals = recalculateDayTotals(mergedClientBreakdown); + const modelBreakdown = buildModelBreakdown(mergedClientBreakdown); + + if (existingDay) { + updates.push({ + id: existingDay.id, + date: existingDay.date, + tokens: dayTotals.tokens, + cost: dayTotals.cost.toFixed(4), + inputTokens: dayTotals.inputTokens, + outputTokens: dayTotals.outputTokens, + timestampMs: incomingDay + ? mergeTimestampMs(existingDay.timestampMs, incomingDay.timestampMs ?? null) + : existingDay.timestampMs ?? null, + sourceBreakdown: mergedClientBreakdown, + modelBreakdown, + }); + continue; + } + + if (!incomingDay) { + continue; + } + + inserts.push({ + submissionId, + date: incomingDay.date, + tokens: dayTotals.tokens, + cost: dayTotals.cost.toFixed(4), + inputTokens: dayTotals.inputTokens, + outputTokens: dayTotals.outputTokens, + timestampMs: incomingDay.timestampMs ?? null, + sourceBreakdown: mergedClientBreakdown, + modelBreakdown, + }); + } + + return { + inserts, + updates, + deletes, + }; +} + export function buildModelBreakdown( clientBreakdown: Record ): Record { From ff3134c9128344128fed4674216610a965df128b Mon Sep 17 00:00:00 2001 From: shivam Date: Sat, 18 Apr 2026 05:33:08 +0000 Subject: [PATCH 6/6] fix(frontend): recompute replay day timestamps --- .../__tests__/lib/submissionReplay.test.ts | 81 ++++++++++++++++++- packages/frontend/src/lib/db/helpers.ts | 37 ++++++++- 2 files changed, 113 insertions(+), 5 deletions(-) diff --git a/packages/frontend/__tests__/lib/submissionReplay.test.ts b/packages/frontend/__tests__/lib/submissionReplay.test.ts index 8a7da35e..8f6f3432 100644 --- a/packages/frontend/__tests__/lib/submissionReplay.test.ts +++ b/packages/frontend/__tests__/lib/submissionReplay.test.ts @@ -71,7 +71,7 @@ function createIncomingDay( } describe("planSubmittedReplayMutations", () => { - it("replaces overlapping in-scope history without stacking and preserves out-of-scope clients", () => { + it("clears stale earliest timestamps when a later in-scope replay replaces the prior earliest contributor", () => { const result = planSubmittedReplayMutations({ existingDays: [ createExistingDay( @@ -104,7 +104,7 @@ describe("planSubmittedReplayMutations", () => { date: "2024-12-02", tokens: 70, cost: "0.7000", - timestampMs: Date.parse("2024-12-02T09:00:00.000Z"), + timestampMs: null, }); expect(result.updates[0].sourceBreakdown).toEqual( mergeBreakdowns( @@ -114,6 +114,40 @@ describe("planSubmittedReplayMutations", () => { ); }); + it("clears stale earliest timestamps when removing the in-scope client that originally supplied them", () => { + const result = planSubmittedReplayMutations({ + existingDays: [ + createExistingDay( + "day-1", + "2024-12-02", + mergeBreakdowns( + createClientBreakdown("claude", 100, 1), + createClientBreakdown("cursor", 40, 0.4) + ), + Date.parse("2024-12-02T09:00:00.000Z") + ), + ], + incomingDays: [], + submittedClients: new Set(["claude"]), + replayWindow: { start: "2024-12-02", end: "2024-12-02" }, + submissionId: "submission-1", + }); + + expect(result.inserts).toEqual([]); + expect(result.deletes).toEqual([]); + expect(result.updates).toHaveLength(1); + expect(result.updates[0]).toMatchObject({ + id: "day-1", + date: "2024-12-02", + tokens: 40, + cost: "0.4000", + timestampMs: null, + }); + expect(result.updates[0].sourceBreakdown).toEqual( + createClientBreakdown("cursor", 40, 0.4) + ); + }); + it("removes an intentionally omitted in-scope client from an overlapping day only", () => { const result = planSubmittedReplayMutations({ existingDays: [ @@ -204,6 +238,49 @@ describe("planSubmittedReplayMutations", () => { expect(result.deletes.some((deleted) => deleted.date === "2024-12-03")).toBe(false); }); + it("recomputes the day timestamp when the surviving replay can still prove the earliest value", () => { + const result = planSubmittedReplayMutations({ + existingDays: [ + createExistingDay( + "day-1", + "2024-12-02", + mergeBreakdowns( + createClientBreakdown("claude", 100, 1), + createClientBreakdown("cursor", 40, 0.4) + ), + Date.parse("2024-12-02T09:00:00.000Z") + ), + ], + incomingDays: [ + createIncomingDay( + "2024-12-02", + createClientBreakdown("claude", 30, 0.3), + Date.parse("2024-12-02T08:00:00.000Z") + ), + ], + submittedClients: new Set(["claude"]), + replayWindow: { start: "2024-12-02", end: "2024-12-02" }, + submissionId: "submission-1", + }); + + expect(result.inserts).toEqual([]); + expect(result.deletes).toEqual([]); + expect(result.updates).toHaveLength(1); + expect(result.updates[0]).toMatchObject({ + id: "day-1", + date: "2024-12-02", + tokens: 70, + cost: "0.7000", + timestampMs: Date.parse("2024-12-02T08:00:00.000Z"), + }); + expect(result.updates[0].sourceBreakdown).toEqual( + mergeBreakdowns( + createClientBreakdown("claude", 30, 0.3), + createClientBreakdown("cursor", 40, 0.4) + ) + ); + }); + it("keeps replay totals idempotent when the same logical history is submitted again", () => { const existingBreakdown = createClientBreakdown("claude", 120, 1.2); const result = planSubmittedReplayMutations({ diff --git a/packages/frontend/src/lib/db/helpers.ts b/packages/frontend/src/lib/db/helpers.ts index 6e03921d..fb26df40 100644 --- a/packages/frontend/src/lib/db/helpers.ts +++ b/packages/frontend/src/lib/db/helpers.ts @@ -152,6 +152,34 @@ function isDateWithinReplayWindow(date: string, replayWindow: ReplayWindow): boo return date >= replayWindow.start && date <= replayWindow.end; } +function hasOutOfScopeReplaySurvivors( + mergedClientBreakdown: Record, + submittedClients: Set +): boolean { + return Object.keys(mergedClientBreakdown).some( + (clientName) => !submittedClients.has(clientName) + ); +} + +export function resolveReplayTimestampMs( + existingTimestampMs: number | null | undefined, + incomingTimestampMs: number | null | undefined, + mergedClientBreakdown: Record, + submittedClients: Set +): number | null { + if ( + !hasOutOfScopeReplaySurvivors(mergedClientBreakdown, submittedClients) + ) { + return incomingTimestampMs ?? null; + } + + if (incomingTimestampMs == null || existingTimestampMs == null) { + return null; + } + + return incomingTimestampMs <= existingTimestampMs ? incomingTimestampMs : null; +} + export function planSubmittedReplayMutations({ existingDays, incomingDays, @@ -210,9 +238,12 @@ export function planSubmittedReplayMutations({ cost: dayTotals.cost.toFixed(4), inputTokens: dayTotals.inputTokens, outputTokens: dayTotals.outputTokens, - timestampMs: incomingDay - ? mergeTimestampMs(existingDay.timestampMs, incomingDay.timestampMs ?? null) - : existingDay.timestampMs ?? null, + timestampMs: resolveReplayTimestampMs( + existingDay.timestampMs, + incomingDay?.timestampMs ?? null, + mergedClientBreakdown, + submittedClients + ), sourceBreakdown: mergedClientBreakdown, modelBreakdown, });