From 66a53f25f231ff52651bcf841f5a3b0e070c54f3 Mon Sep 17 00:00:00 2001 From: Sean O'Connor Date: Fri, 14 Aug 2026 16:45:02 -0400 Subject: [PATCH] Harden demo access and restore clean checks --- README.md | 9 +-- drizzle/0027_disable_public_demo_password.sql | 17 ++++++ drizzle/meta/_journal.json | 7 +++ package.json | 1 + scripts/provision-demo-account.ts | 61 +++++++++++++++++++ src/app/i/[token]/page.tsx | 3 + src/lib/object-storage.ts | 3 +- src/lib/time-clock.ts | 5 ++ src/server/api/routers/expenses.ts | 14 +++-- src/server/api/routers/time-entries.ts | 5 +- 10 files changed, 114 insertions(+), 11 deletions(-) create mode 100644 drizzle/0027_disable_public_demo_password.sql create mode 100644 scripts/provision-demo-account.ts diff --git a/README.md b/README.md index 6387412..bacfeb7 100644 --- a/README.md +++ b/README.md @@ -82,12 +82,13 @@ bun run db:push # fast iteration during development # bun run db:migrate # same migrations the Docker image runs in production ``` -**Demo account.** For App Store review and local testing, `bun run db:migrate` applies `0014_seed_demo_account.sql`, which creates a pre-populated user (`db:push` does not). Sign in at `/auth/login`: +**Demo account.** For App Store review and local testing, `bun run db:migrate` creates a pre-populated but locked `demo@example.com` account (`db:push` does not). Provision a private, temporary password when review access is needed: -- Email: `demo@example.com` -- Password: `demo123` +```bash +DEMO_ACCOUNT_PASSWORD='' bun run demo:provision +``` -The account includes a sample business, clients, and invoices (draft, sent, and paid). +Provisioning rotates the credential and invalidates prior sessions. Do not commit or publish the password. Re-run the command to rotate it; migration 0027 disables the previously public credential. The account includes a sample business, clients, and invoices (draft, sent, and paid). ### 4. Run diff --git a/drizzle/0027_disable_public_demo_password.sql b/drizzle/0027_disable_public_demo_password.sql new file mode 100644 index 0000000..7d9c63b --- /dev/null +++ b/drizzle/0027_disable_public_demo_password.sql @@ -0,0 +1,17 @@ +-- The original App Review credential was committed publicly in migration 0014. +-- Rotate it to an unknown value and invalidate its sessions. Use +-- `bun run demo:provision` with a private DEMO_ACCOUNT_PASSWORD when review +-- access is needed. +UPDATE "beenvoice_user" +SET "password" = '$2b$12$GyM6.bLv2.sZMsWytNz1L.j7pLwc79a55Nww6bSLQJ9OJarqY9oZW', + "updatedAt" = NOW() +WHERE "id" = 'a0000000-0000-4000-8000-000000000001'; + +UPDATE "beenvoice_account" +SET "password" = '$2b$12$GyM6.bLv2.sZMsWytNz1L.j7pLwc79a55Nww6bSLQJ9OJarqY9oZW', + "updatedAt" = NOW() +WHERE "userId" = 'a0000000-0000-4000-8000-000000000001' + AND "providerId" = 'credential'; + +DELETE FROM "beenvoice_session" +WHERE "userId" = 'a0000000-0000-4000-8000-000000000001'; diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index c353aef..e4dbc88 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -190,6 +190,13 @@ "when": 1784000000000, "tag": "0026_business_hide_name_with_logo_fix", "breakpoints": true + }, + { + "idx": 27, + "version": "7", + "when": 1786740000000, + "tag": "0027_disable_public_demo_password", + "breakpoints": true } ] } diff --git a/package.json b/package.json index d36a7a1..36ec6a7 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "db:push": "drizzle-kit push", "db:studio": "drizzle-kit studio", "db:clone": "./scripts/clone-local.sh", + "demo:provision": "bun scripts/provision-demo-account.ts", "docker:up": "colima start && docker compose -f docker-compose.dev.yml up -d", "docker:down": "docker compose -f docker-compose.dev.yml down && colima stop", "docker:dev:down": "docker compose -f docker-compose.dev.yml down && colima stop", diff --git a/scripts/provision-demo-account.ts b/scripts/provision-demo-account.ts new file mode 100644 index 0000000..7b03cb7 --- /dev/null +++ b/scripts/provision-demo-account.ts @@ -0,0 +1,61 @@ +import "dotenv/config"; + +import bcrypt from "bcryptjs"; +import { Pool } from "pg"; + +const DEMO_USER_ID = "a0000000-0000-4000-8000-000000000001"; +const password = process.env.DEMO_ACCOUNT_PASSWORD?.trim(); +const databaseUrl = process.env.DATABASE_URL?.trim(); + +if (!databaseUrl) { + throw new Error("DATABASE_URL is required"); +} + +if (!password || password.length < 12) { + throw new Error("DEMO_ACCOUNT_PASSWORD must be at least 12 characters"); +} + +const pool = new Pool({ connectionString: databaseUrl, ssl: false }); + +try { + const passwordHash = await bcrypt.hash(password, 12); + const client = await pool.connect(); + + try { + await client.query("BEGIN"); + const userResult = await client.query( + `UPDATE "beenvoice_user" + SET "password" = $1, "updatedAt" = NOW() + WHERE "id" = $2`, + [passwordHash, DEMO_USER_ID], + ); + const accountResult = await client.query( + `UPDATE "beenvoice_account" + SET "password" = $1, "updatedAt" = NOW() + WHERE "userId" = $2 AND "providerId" = 'credential'`, + [passwordHash, DEMO_USER_ID], + ); + + if (userResult.rowCount !== 1 || accountResult.rowCount !== 1) { + throw new Error( + "Demo account is missing. Apply database migrations before provisioning it.", + ); + } + + await client.query(`DELETE FROM "beenvoice_session" WHERE "userId" = $1`, [ + DEMO_USER_ID, + ]); + await client.query("COMMIT"); + } catch (error) { + await client.query("ROLLBACK"); + throw error; + } finally { + client.release(); + } + + console.log( + "Demo review account provisioned; previous sessions were invalidated.", + ); +} finally { + await pool.end(); +} diff --git a/src/app/i/[token]/page.tsx b/src/app/i/[token]/page.tsx index 6cd4f7a..a2a30a8 100644 --- a/src/app/i/[token]/page.tsx +++ b/src/app/i/[token]/page.tsx @@ -103,6 +103,9 @@ function PublicInvoiceView({ token }: { token: string }) { {/* Header */}
{hasLogo && ( + // Uploaded SVGs are sanitized and served by our route. next/image's + // optimizer intentionally rejects SVG, so a native img is required. + // eslint-disable-next-line @next/next/no-img-element | null = null; let s3Client: InstanceType | null = null; diff --git a/src/lib/time-clock.ts b/src/lib/time-clock.ts index 90482cb..68a4e19 100644 --- a/src/lib/time-clock.ts +++ b/src/lib/time-clock.ts @@ -1,6 +1,11 @@ /** Stored on entries clocked in before empty descriptions were allowed. */ export const LEGACY_DEFAULT_CLOCK_DESCRIPTION = "Professional services"; +export function normalizeOptionalId(value?: string | null): string | null { + const trimmed = value?.trim(); + return trimmed == null || trimmed === "" ? null : trimmed; +} + export function resolveEffectiveHourlyRate( enteredRate: number, client?: { defaultHourlyRate?: number | null } | null, diff --git a/src/server/api/routers/expenses.ts b/src/server/api/routers/expenses.ts index 8b9116e..27b822b 100644 --- a/src/server/api/routers/expenses.ts +++ b/src/server/api/routers/expenses.ts @@ -20,9 +20,15 @@ import { RECEIPT_MAX_BYTES, } from "~/lib/object-storage"; import { parseReceiptText } from "~/lib/receipt-parse"; +import type { db } from "~/server/db"; export { EXPENSE_CATEGORIES }; +type ExpenseContext = { + db: typeof db; + session: { user: { id: string } }; +}; + const createExpenseSchema = z.object({ date: z.date(), description: z.string().min(1, "Description is required"), @@ -43,7 +49,7 @@ const updateExpenseSchema = createExpenseSchema.partial().extend({ }); async function verifyClientAccess( - ctx: { db: typeof import("~/server/db").db; session: { user: { id: string } } }, + ctx: ExpenseContext, clientId: string, ) { const client = await ctx.db.query.clients.findFirst({ @@ -62,7 +68,7 @@ async function verifyClientAccess( } async function verifyInvoiceAccess( - ctx: { db: typeof import("~/server/db").db; session: { user: { id: string } } }, + ctx: ExpenseContext, invoiceId: string, ) { const invoice = await ctx.db.query.invoices.findFirst({ @@ -81,7 +87,7 @@ async function verifyInvoiceAccess( } async function resolveExpenseBusinessId( - ctx: { db: typeof import("~/server/db").db; session: { user: { id: string } } }, + ctx: ExpenseContext, businessId: string | null, invoice?: { businessId: string | null } | null, ) { @@ -98,7 +104,7 @@ async function resolveExpenseBusinessId( } async function getOwnedExpense( - ctx: { db: typeof import("~/server/db").db; session: { user: { id: string } } }, + ctx: ExpenseContext, expenseId: string, ) { const expense = await ctx.db.query.expenses.findFirst({ diff --git a/src/server/api/routers/time-entries.ts b/src/server/api/routers/time-entries.ts index 91e585c..2eb231a 100644 --- a/src/server/api/routers/time-entries.ts +++ b/src/server/api/routers/time-entries.ts @@ -6,6 +6,7 @@ import { TRPCError } from "@trpc/server"; import type { db } from "~/server/db"; import { computeTrackedHours, + normalizeOptionalId, resolveBillingDescription, type ClockOutOutcome, } from "~/lib/time-clock"; @@ -242,7 +243,7 @@ export const timeEntriesRouter = createTRPCRouter({ }); } - const clientId = input.clientId?.trim() || null; + const clientId = normalizeOptionalId(input.clientId); let clientRecord: { defaultHourlyRate: number | null } | null = null; if (clientId) { const found = await ctx.db.query.clients.findFirst({ @@ -514,7 +515,7 @@ export const timeEntriesRouter = createTRPCRouter({ create: protectedProcedure .input(createSchema) .mutation(async ({ ctx, input }) => { - const clientId = input.clientId?.trim() || null; + const clientId = normalizeOptionalId(input.clientId); if (clientId) { const client = await ctx.db.query.clients.findFirst({ where: and(eq(clients.id, clientId), eq(clients.createdById, ctx.session.user.id)),