From 19e256727d9be0aa717e42f542628c4d8be93f43 Mon Sep 17 00:00:00 2001 From: Alaeddin Date: Tue, 14 Apr 2026 04:03:57 +0100 Subject: [PATCH 001/198] =?UTF-8?q?feat:=20add=20db:migrate=20scripts=20(d?= =?UTF-8?q?ev/deploy/reset)=20=E2=80=94=20proper=20migrations=20replace=20?= =?UTF-8?q?db:push?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/caramel-app/package.json | 3 +++ 1 file changed, 3 insertions(+) diff --git a/apps/caramel-app/package.json b/apps/caramel-app/package.json index 97cef21..da4a62a 100644 --- a/apps/caramel-app/package.json +++ b/apps/caramel-app/package.json @@ -12,6 +12,9 @@ "db:push": "dotenv -e ../../local-dev/.env.ports -- pnpm prisma db push --skip-generate", "db:generate": "dotenv -e ../../local-dev/.env.ports -- pnpm prisma generate", "db:studio": "dotenv -e ../../local-dev/.env.ports -- pnpm prisma studio", + "db:migrate": "dotenv -e ../../local-dev/.env.ports -- pnpm prisma migrate dev", + "db:migrate:deploy": "dotenv -e ../../local-dev/.env.ports -- pnpm prisma migrate deploy", + "db:migrate:reset": "dotenv -e ../../local-dev/.env.ports -- pnpm prisma migrate reset", "prettier-check": "prettier --check '**/*.{ts,tsx,js,jsx,html,css,json,md}'", "prettier-write": "prettier --write '**/*.{ts,tsx,js,jsx,html,css,json,md}'", "knip": "knip", From df949628ef610b0f508661e7d037d51bcad83230 Mon Sep 17 00:00:00 2001 From: Alaeddin Date: Tue, 14 Apr 2026 15:51:24 +0100 Subject: [PATCH 002/198] fix(local-dev): change ports away from Windows-excluded range 57956-58055 --- local-dev/.env.ports | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/local-dev/.env.ports b/local-dev/.env.ports index 3dfb62c..8232621 100644 --- a/local-dev/.env.ports +++ b/local-dev/.env.ports @@ -1,8 +1,8 @@ # Caramel local port defaults (can be overridden per developer) -PORT=58000 -PG_PORT=58005 -REDIS_PORT=58006 +PORT=3001 +PG_PORT=5433 +REDIS_PORT=6380 # Optional future expansions -SOCKET_PORT=58003 -WORKER_PORT=58002 -TYPESENSE_PORT=58007 +SOCKET_PORT=3003 +WORKER_PORT=3002 +TYPESENSE_PORT=8109 From b7d38c9de2017b4468bd89cbfb2a7e7c09824698 Mon Sep 17 00:00:00 2001 From: Alaeddin Date: Tue, 14 Apr 2026 23:22:21 +0100 Subject: [PATCH 003/198] feat: Next.js reads coupon catalog from caramel_coupons (drop Prisma Coupon/Source) --- apps/caramel-app/package.json | 3 +- .../migration.sql | 16 +++ apps/caramel-app/prisma/schema.prisma | 51 +--------- .../app/(marketing)/coupons/[store]/page.tsx | 45 ++++----- .../src/app/api/coupons/expire/route.ts | 21 ++-- .../src/app/api/coupons/filters/route.ts | 52 ++++------ .../src/app/api/coupons/increment/route.ts | 32 ++++-- apps/caramel-app/src/app/api/coupons/route.ts | 99 +++++++------------ .../src/app/api/coupons/seed/route.ts | 60 ----------- .../src/app/api/coupons/stats/route.ts | 26 ++--- .../src/app/api/coupons/stores/route.ts | 33 +++---- .../src/app/api/sites/top-sites/route.ts | 19 ++-- apps/caramel-app/src/app/api/sources/route.ts | 96 ++++++++++++------ apps/caramel-app/src/lib/couponsDb.ts | 47 +++++++++ pnpm-lock.yaml | 20 ++-- 15 files changed, 301 insertions(+), 319 deletions(-) create mode 100644 apps/caramel-app/prisma/migrations/20260414231559_drop_coupon_source_catalog/migration.sql delete mode 100644 apps/caramel-app/src/app/api/coupons/seed/route.ts create mode 100644 apps/caramel-app/src/lib/couponsDb.ts diff --git a/apps/caramel-app/package.json b/apps/caramel-app/package.json index da4a62a..d5e536c 100644 --- a/apps/caramel-app/package.json +++ b/apps/caramel-app/package.json @@ -41,7 +41,7 @@ "jsonwebtoken": "^9.0.2", "lodash.debounce": "^4.0.8", "next": "16.1.1", - "usesend-js": "^1.6.3", + "postgres": "^3.4.9", "prisma": "^6.14.0", "react": "19.2.3", "react-awesome-button": "^7.0.5", @@ -55,6 +55,7 @@ "recharts": "^3.1.2", "sass": "^1.90.0", "sonner": "^2.0.7", + "usesend-js": "^1.6.3", "yup": "^1.7.0" }, "devDependencies": { diff --git a/apps/caramel-app/prisma/migrations/20260414231559_drop_coupon_source_catalog/migration.sql b/apps/caramel-app/prisma/migrations/20260414231559_drop_coupon_source_catalog/migration.sql new file mode 100644 index 0000000..3548f18 --- /dev/null +++ b/apps/caramel-app/prisma/migrations/20260414231559_drop_coupon_source_catalog/migration.sql @@ -0,0 +1,16 @@ +-- Coupon catalog ownership moved to the Python verification service, +-- which manages these tables in the separate `caramel_coupons` database +-- via SQLModel + Alembic. Next.js now reads the catalog over a direct +-- postgres client (see src/lib/couponsDb.ts). + +-- Drop FK before dropping tables +ALTER TABLE IF EXISTS "Coupon" DROP CONSTRAINT IF EXISTS "Coupon_sourceId_fkey"; + +DROP TABLE IF EXISTS "Coupon"; +DROP TABLE IF EXISTS "Source"; + +DROP TYPE IF EXISTS "DiscountType"; +DROP TYPE IF EXISTS "SourceStatus"; + +-- pg_trgm was only used for Coupon trigram search; no other model uses it +DROP EXTENSION IF EXISTS "pg_trgm"; diff --git a/apps/caramel-app/prisma/schema.prisma b/apps/caramel-app/prisma/schema.prisma index fb6b129..64c3453 100644 --- a/apps/caramel-app/prisma/schema.prisma +++ b/apps/caramel-app/prisma/schema.prisma @@ -1,43 +1,10 @@ generator client { - provider = "prisma-client-js" - previewFeatures = ["postgresqlExtensions"] + provider = "prisma-client-js" } datasource db { - provider = "postgresql" - url = env("DATABASE_URL") - extensions = [pg_trgm] -} - -model Source { - id String @id @default(cuid()) - source String - websites String[] - status SourceStatus @default(REQUESTED) - createdAt DateTime @default(now()) - updatedAt DateTime @default(now()) @updatedAt - coupons Coupon[] -} - -model Coupon { - id String @id @default(cuid()) - code String - site String - title String - description String - rating Float - discount_type DiscountType @default(PERCENTAGE) - discount_amount Float? - expiry String - expired Boolean @default(false) - timesUsed Int @default(0) - last_time_used DateTime? - createdAt DateTime @default(now()) - updatedAt DateTime @default(now()) @updatedAt - sourceId String? - Source Source? @relation(fields: [sourceId], references: [id]) - - @@unique([code, site]) + provider = "postgresql" + url = env("DATABASE_URL") } model Account { @@ -110,12 +77,6 @@ model User { @@map("users") } -enum SourceStatus { - ACTIVE - INACTIVE - REQUESTED -} - enum UserStatus { ACTIVE_USER DELETE_REQUESTED_BY_USER @@ -129,9 +90,3 @@ enum Role { ADMIN SUPER_ADMIN } - -enum DiscountType { - PERCENTAGE - CASH - SAVE -} diff --git a/apps/caramel-app/src/app/(marketing)/coupons/[store]/page.tsx b/apps/caramel-app/src/app/(marketing)/coupons/[store]/page.tsx index bbc6eeb..9dd608d 100644 --- a/apps/caramel-app/src/app/(marketing)/coupons/[store]/page.tsx +++ b/apps/caramel-app/src/app/(marketing)/coupons/[store]/page.tsx @@ -1,5 +1,5 @@ import CouponsSection from '@/components/coupons/coupons-section' -import prisma from '@/lib/prisma' +import { couponsSql } from '@/lib/couponsDb' import type { Coupon } from '@/types/coupon' import type { Metadata } from 'next' import { notFound } from 'next/navigation' @@ -34,35 +34,28 @@ type StoreParams = { store: string } async function fetchStoreCoupons(storeParam: string) { const base = getBaseDomain(storeParam) if (!base) { - return { coupons: [], total: 0, base: storeParam } - } - const filters: any = { - expired: false, - AND: [{ OR: [{ site: base }, { site: { endsWith: `.${base}` } }] }], + return { coupons: [] as Coupon[], total: 0, base: storeParam } } - const [coupons, total] = await prisma.$transaction([ - prisma.coupon.findMany({ - where: filters, - take: PAGE_SIZE, - orderBy: [{ rating: 'desc' }, { createdAt: 'desc' }], - select: { - id: true, - code: true, - site: true, - title: true, - description: true, - rating: true, - discount_type: true, - discount_amount: true, - expiry: true, - expired: true, - timesUsed: true, - }, - }), - prisma.coupon.count({ where: filters }), + const [coupons, totalRow] = await Promise.all([ + couponsSql` + SELECT id, code, site, title, description, rating, + discount_type, discount_amount, expiry, expired, + times_used AS "timesUsed" + FROM coupons + WHERE expired = FALSE + AND (site = ${base} OR site LIKE ${'%.' + base}) + ORDER BY rating DESC, created_at DESC + LIMIT ${PAGE_SIZE} + `, + couponsSql` + SELECT COUNT(*)::int AS total FROM coupons + WHERE expired = FALSE + AND (site = ${base} OR site LIKE ${'%.' + base}) + `, ]) + const total = (totalRow[0] as { total: number } | undefined)?.total ?? 0 return { coupons, total, base } } diff --git a/apps/caramel-app/src/app/api/coupons/expire/route.ts b/apps/caramel-app/src/app/api/coupons/expire/route.ts index a5ca922..18b8b82 100644 --- a/apps/caramel-app/src/app/api/coupons/expire/route.ts +++ b/apps/caramel-app/src/app/api/coupons/expire/route.ts @@ -1,17 +1,26 @@ -import prisma from '@/lib/prisma' +import { couponsSql } from '@/lib/couponsDb' import { NextRequest, NextResponse } from 'next/server' export async function POST(req: NextRequest) { const { ids = [] } = (await req.json().catch(() => ({}))) as { ids?: string[] } + if (!Array.isArray(ids) || ids.length === 0) { + return NextResponse.json({ count: 0 }) + } + try { - const updated = await prisma.coupon.updateMany({ - where: { id: { in: ids } }, - data: { expired: true, expiry: new Date().toISOString() }, - }) - return NextResponse.json({ count: updated.count }) + const rows = await couponsSql` + UPDATE coupons + SET expired = TRUE, + expiry = NOW()::text, + updated_at = NOW() + WHERE id = ANY(${ids}) AND expired = FALSE + RETURNING id + ` + return NextResponse.json({ count: rows.length }) } catch (error) { + console.error('Error expiring coupons:', error) return NextResponse.json( { error: 'Error marking coupons as expired.' }, { status: 500 }, diff --git a/apps/caramel-app/src/app/api/coupons/filters/route.ts b/apps/caramel-app/src/app/api/coupons/filters/route.ts index 7d52827..5281829 100644 --- a/apps/caramel-app/src/app/api/coupons/filters/route.ts +++ b/apps/caramel-app/src/app/api/coupons/filters/route.ts @@ -1,4 +1,4 @@ -import prisma from '@/lib/prisma' +import { couponsSql } from '@/lib/couponsDb' import { NextRequest, NextResponse } from 'next/server' export async function GET(req: NextRequest) { @@ -9,42 +9,32 @@ export async function GET(req: NextRequest) { const sitesLimit = Math.min(Math.max(rawLimit, 0), 100) try { - const queries: Promise[] = [] + const sitesPromise: Promise> = + includeSites && sitesLimit > 0 + ? couponsSql>` + SELECT DISTINCT site FROM coupons + WHERE expired = FALSE AND site IS NOT NULL + ORDER BY site ASC + LIMIT ${sitesLimit} + ` + : Promise.resolve([]) - if (includeSites && sitesLimit > 0) { - queries.push( - prisma.coupon.findMany({ - where: { expired: false }, - distinct: ['site'], - select: { site: true }, - orderBy: { site: 'asc' }, - take: sitesLimit, - }), - ) - } else { - queries.push(Promise.resolve([])) - } + const typesPromise = couponsSql>` + SELECT DISTINCT discount_type FROM coupons + WHERE expired = FALSE AND discount_type IS NOT NULL + ` - queries.push( - prisma.coupon.findMany({ - where: { expired: false }, - distinct: ['discount_type'], - select: { discount_type: true }, - }), - ) - - const [sitesRaw, discountTypesRaw] = await Promise.all(queries) + const [sitesRaw, discountTypesRaw] = await Promise.all([ + sitesPromise, + typesPromise, + ]) const sites = sitesRaw - .map((s: any) => s.site) + .map(s => s.site) .filter(Boolean) - .sort((a: string, b: string) => a.localeCompare(b)) + .sort((a, b) => a.localeCompare(b)) const discountTypes = Array.from( - new Set( - discountTypesRaw - .map((d: any) => d.discount_type) - .filter(Boolean), - ), + new Set(discountTypesRaw.map(d => d.discount_type).filter(Boolean)), ) return NextResponse.json({ sites, discountTypes }) diff --git a/apps/caramel-app/src/app/api/coupons/increment/route.ts b/apps/caramel-app/src/app/api/coupons/increment/route.ts index 9816a43..1653227 100644 --- a/apps/caramel-app/src/app/api/coupons/increment/route.ts +++ b/apps/caramel-app/src/app/api/coupons/increment/route.ts @@ -1,24 +1,36 @@ -import prisma from '@/lib/prisma' +import { couponsSql } from '@/lib/couponsDb' import { NextRequest, NextResponse } from 'next/server' export async function GET(req: NextRequest) { const url = new URL(req.url) const id = url.searchParams.get('id') - if (!id) + if (!id) { return NextResponse.json( { error: 'Invalid or missing coupon ID' }, { status: 400 }, ) + } + try { - const updatedCoupon = await prisma.coupon.update({ - where: { id }, - data: { - timesUsed: { increment: 1 }, - last_time_used: new Date().toISOString(), - }, - }) - return NextResponse.json(updatedCoupon) + const rows = await couponsSql` + UPDATE coupons + SET times_used = times_used + 1, + last_time_used = NOW(), + updated_at = NOW() + WHERE id = ${id} + RETURNING id, code, site, + times_used AS "timesUsed", + last_time_used AS "last_time_used" + ` + if (rows.length === 0) { + return NextResponse.json( + { error: 'Coupon not found' }, + { status: 404 }, + ) + } + return NextResponse.json(rows[0]) } catch (error) { + console.error('Error incrementing coupon usage:', error) return NextResponse.json( { error: 'Error updating coupon usage.' }, { status: 500 }, diff --git a/apps/caramel-app/src/app/api/coupons/route.ts b/apps/caramel-app/src/app/api/coupons/route.ts index 62ce016..dce7553 100644 --- a/apps/caramel-app/src/app/api/coupons/route.ts +++ b/apps/caramel-app/src/app/api/coupons/route.ts @@ -1,4 +1,4 @@ -import prisma from '@/lib/prisma' +import { couponsSql } from '@/lib/couponsDb' import { NextRequest, NextResponse } from 'next/server' function getBaseDomain(raw: string): string { @@ -23,92 +23,61 @@ export async function GET(req: NextRequest) { Number.isFinite(rawLimit) && rawLimit > 0 ? Math.min(rawLimit, 50) : 10 const search = url.searchParams.get('search') || undefined const type = url.searchParams.get('type') || undefined - const key_words = url.searchParams.get('key_words') || undefined + const keyWords = url.searchParams.get('key_words') || undefined try { - const filters: any = { expired: false } + const conditions = [couponsSql`expired = FALSE`] - // Site filter if (site) { const base = getBaseDomain(site) - filters.AND = [ - { OR: [{ site: base }, { site: { endsWith: `.${base}` } }] }, - ] + conditions.push( + couponsSql`(site = ${base} OR site LIKE ${'%.' + base})`, + ) } - // Search filter (searches across site, title, description) if (search) { - filters.OR = [ - { site: { contains: search, mode: 'insensitive' } }, - { title: { contains: search, mode: 'insensitive' } }, - { description: { contains: search, mode: 'insensitive' } }, - { code: { contains: search, mode: 'insensitive' } }, - ] + const s = `%${search}%` + conditions.push( + couponsSql`(site ILIKE ${s} OR title ILIKE ${s} OR description ILIKE ${s} OR code ILIKE ${s})`, + ) } - // Keywords filter - if (key_words) { - const keywordsArray = key_words.split(',').map(k => k.trim()) - if (!filters.OR) { - filters.OR = [] + if (keyWords) { + const patterns = keyWords + .split(',') + .map(k => `%${k.trim()}%`) + .filter(k => k.length > 2) + if (patterns.length > 0) { + conditions.push(couponsSql`description ILIKE ANY(${patterns})`) } - filters.OR.push( - ...keywordsArray.map(keyword => ({ - description: { contains: keyword, mode: 'insensitive' }, - })), - ) } - // Discount type filter if (type && type !== 'all') { - filters.discount_type = type + conditions.push(couponsSql`discount_type = ${type}`) } - const skip = Math.max(0, (page - 1) * limit) + const whereClause = conditions.reduce( + (acc, cond) => couponsSql`${acc} AND ${cond}`, + ) - console.info('[API][coupons] request', { - page, - limit, - skip, - site, - search, - type, - key_words, - }) + const skip = Math.max(0, (page - 1) * limit) - const [coupons, total] = await prisma.$transaction([ - prisma.coupon.findMany({ - where: filters, - skip, - take: limit, - orderBy: [{ rating: 'desc' }, { createdAt: 'desc' }], - select: { - id: true, - code: true, - site: true, - title: true, - description: true, - rating: true, - discount_type: true, - discount_amount: true, - expiry: true, - expired: true, - timesUsed: true, - }, - }), - prisma.coupon.count({ where: filters }), + const [coupons, totalRow] = await Promise.all([ + couponsSql` + SELECT id, code, site, title, description, rating, + discount_type, discount_amount, expiry, expired, + times_used AS "timesUsed" + FROM coupons + WHERE ${whereClause} + ORDER BY rating DESC, created_at DESC + LIMIT ${limit} OFFSET ${skip} + `, + couponsSql`SELECT COUNT(*)::int AS total FROM coupons WHERE ${whereClause}`, ]) + const total = (totalRow[0] as { total: number } | undefined)?.total ?? 0 const hasMore = skip + coupons.length < total - console.info('[API][coupons] response', { - returned: coupons.length, - total, - hasMore, - page, - limit, - }) - return NextResponse.json({ coupons, page, limit, total, hasMore }) } catch (error) { console.error('Error fetching coupons:', error) diff --git a/apps/caramel-app/src/app/api/coupons/seed/route.ts b/apps/caramel-app/src/app/api/coupons/seed/route.ts deleted file mode 100644 index fa1c8ef..0000000 --- a/apps/caramel-app/src/app/api/coupons/seed/route.ts +++ /dev/null @@ -1,60 +0,0 @@ -import prisma from '@/lib/prisma' -import { NextRequest, NextResponse } from 'next/server' - -interface CouponSeedData { - site: string - description: string - code: string - title: string - rating: number - expiry: string - discount_type?: 'PERCENTAGE' | 'CASH' | 'SAVE' - discount_amount?: number - expired?: boolean - timesUsed?: number - last_time_used?: string - sourceId?: string -} - -export async function POST(req: NextRequest) { - const coupons: CouponSeedData[] = await req.json() - - if (!Array.isArray(coupons)) { - return NextResponse.json( - { message: 'Invalid data format. Expected an array.' }, - { status: 400 }, - ) - } - - if ( - coupons.some( - coupon => - !coupon.site || - !coupon.description || - !coupon.code || - !coupon.title || - coupon.rating === undefined || - !coupon.expiry, - ) - ) { - return NextResponse.json( - { message: 'Missing required fields in some coupons.' }, - { status: 400 }, - ) - } - - try { - await prisma.coupon.createMany({ - data: coupons, - skipDuplicates: true, - }) - - return NextResponse.json({ message: 'Coupons seeded successfully!' }) - } catch (error) { - console.error((error as Error).message) - return NextResponse.json( - { error: 'Error seeding coupons.' }, - { status: 500 }, - ) - } -} diff --git a/apps/caramel-app/src/app/api/coupons/stats/route.ts b/apps/caramel-app/src/app/api/coupons/stats/route.ts index 21a9625..c927631 100644 --- a/apps/caramel-app/src/app/api/coupons/stats/route.ts +++ b/apps/caramel-app/src/app/api/coupons/stats/route.ts @@ -1,21 +1,23 @@ -import prisma from '@/lib/prisma' +import { couponsSql } from '@/lib/couponsDb' import { NextResponse } from 'next/server' export async function GET() { try { - const [total, expired] = await Promise.all([ - prisma.coupon.count(), - prisma.coupon.count({ - where: { - expired: true, - }, - }), - ]) + const rows = await couponsSql` + SELECT + COUNT(*)::int AS total, + COUNT(*) FILTER (WHERE expired = TRUE)::int AS expired + FROM coupons + ` + const row = (rows[0] ?? { total: 0, expired: 0 }) as { + total: number + expired: number + } return NextResponse.json({ - total, - expired, - active: total - expired, + total: row.total, + expired: row.expired, + active: row.total - row.expired, }) } catch (error) { console.error('Failed to fetch coupon stats:', error) diff --git a/apps/caramel-app/src/app/api/coupons/stores/route.ts b/apps/caramel-app/src/app/api/coupons/stores/route.ts index a897261..4e997f8 100644 --- a/apps/caramel-app/src/app/api/coupons/stores/route.ts +++ b/apps/caramel-app/src/app/api/coupons/stores/route.ts @@ -1,4 +1,4 @@ -import prisma from '@/lib/prisma' +import { couponsSql } from '@/lib/couponsDb' import { NextRequest, NextResponse } from 'next/server' export async function GET(req: NextRequest) { @@ -7,24 +7,23 @@ export async function GET(req: NextRequest) { const rawLimit = parseInt(url.searchParams.get('limit') || '20', 10) const limit = Math.min(Math.max(rawLimit, 1), 50) - const filters: any = { expired: false } - if (q) { - filters.OR = [ - { site: { contains: q, mode: 'insensitive' } }, - { site: { startsWith: q, mode: 'insensitive' } }, - ] - } - try { - const sitesRaw = await prisma.coupon.findMany({ - where: filters, - distinct: ['site'], - select: { site: true }, - take: limit, - orderBy: { site: 'asc' }, - }) + const rows = q + ? await couponsSql>` + SELECT DISTINCT site FROM coupons + WHERE expired = FALSE + AND (site ILIKE ${'%' + q + '%'} OR site ILIKE ${q + '%'}) + ORDER BY site ASC + LIMIT ${limit} + ` + : await couponsSql>` + SELECT DISTINCT site FROM coupons + WHERE expired = FALSE + ORDER BY site ASC + LIMIT ${limit} + ` - const sites = sitesRaw.map(s => s.site).filter(Boolean) + const sites = rows.map(s => s.site).filter(Boolean) return NextResponse.json({ sites }) } catch (error) { diff --git a/apps/caramel-app/src/app/api/sites/top-sites/route.ts b/apps/caramel-app/src/app/api/sites/top-sites/route.ts index bd33d0e..478a561 100644 --- a/apps/caramel-app/src/app/api/sites/top-sites/route.ts +++ b/apps/caramel-app/src/app/api/sites/top-sites/route.ts @@ -1,17 +1,20 @@ -import prisma from '@/lib/prisma' +import { couponsSql } from '@/lib/couponsDb' import { NextResponse } from 'next/server' export async function GET() { try { - const topSites = await prisma.coupon.groupBy({ - by: ['site'], - _count: { id: true }, - orderBy: { _count: { id: 'desc' } }, - take: 4, - }) - const sites = topSites.map(i => i.site) + const rows = await couponsSql>` + SELECT site, COUNT(*)::int AS coupon_count + FROM coupons + WHERE expired = FALSE + GROUP BY site + ORDER BY coupon_count DESC + LIMIT 4 + ` + const sites = rows.map(r => r.site) return NextResponse.json({ sites }) } catch (err) { + console.error('Failed to fetch top sites:', err) return NextResponse.json( { error: 'Failed to fetch top sites' }, { status: 500 }, diff --git a/apps/caramel-app/src/app/api/sources/route.ts b/apps/caramel-app/src/app/api/sources/route.ts index 9c4808c..d54f7b2 100644 --- a/apps/caramel-app/src/app/api/sources/route.ts +++ b/apps/caramel-app/src/app/api/sources/route.ts @@ -1,35 +1,62 @@ import { nextApiResponse } from '@/lib/apiResponseNext' -import prisma from '@/lib/prisma' +import { couponsSql } from '@/lib/couponsDb' import { NextRequest } from 'next/server' +type SourceMetrics = { + id: string + source: string + websites: string[] + numberOfCoupons: number + successRate: number + status: string +} + export async function GET(req: NextRequest) { try { - const sources = await prisma.source.findMany({ - where: { status: 'ACTIVE' }, - include: { coupons: true }, - }) - const sourcesWithMetrics = sources.map(src => { - const totalUsed = src.coupons.reduce( - (acc, c) => acc + c.timesUsed, - 0, - ) - const totalFail = src.coupons.filter(c => c.expired).length - const successRate = - totalUsed + totalFail === 0 - ? 0 - : (totalUsed / (totalUsed + totalFail)) * 100 - return { - id: src.id, - source: src.source, - websites: src.websites, - numberOfCoupons: src.coupons.length, - successRate: parseFloat(successRate.toFixed(2)), - status: src.status, - } - }) - sourcesWithMetrics.sort((a, b) => b.successRate - a.successRate) + const rows = await couponsSql< + Array<{ + id: string + source: string + websites: string[] + status: string + total_coupons: number + total_used: number + total_expired: number + }> + >` + SELECT + s.id, + s.source, + s.websites, + s.status, + COALESCE(COUNT(c.id), 0)::int AS total_coupons, + COALESCE(SUM(c.times_used), 0)::int AS total_used, + COALESCE(SUM(CASE WHEN c.expired THEN 1 ELSE 0 END), 0)::int AS total_expired + FROM sources s + LEFT JOIN coupons c ON c.source_id = s.id + WHERE s.status = 'ACTIVE' + GROUP BY s.id, s.source, s.websites, s.status + ` + + const sourcesWithMetrics: SourceMetrics[] = rows + .map(r => { + const denom = r.total_used + r.total_expired + const successRate = + denom === 0 ? 0 : (r.total_used / denom) * 100 + return { + id: r.id, + source: r.source, + websites: r.websites, + numberOfCoupons: r.total_coupons, + successRate: parseFloat(successRate.toFixed(2)), + status: r.status, + } + }) + .sort((a, b) => b.successRate - a.successRate) + return nextApiResponse(req, 200, 'sources', sourcesWithMetrics) } catch (error) { + console.error('Error fetching sources:', error) return nextApiResponse(req, 500, 'Error fetching sources.', null) } } @@ -40,17 +67,28 @@ export async function POST(req: NextRequest) { website?: string } const website = body.website?.trim() - if (!website) + if (!website) { return nextApiResponse(req, 400, 'Missing required fields.', null) - await prisma.source.create({ - data: { source: website, status: 'REQUESTED' }, - }) + } + + await couponsSql` + INSERT INTO sources (id, source, websites, status, created_at, updated_at) + VALUES ( + gen_random_uuid()::text, + ${website}, + ${[] as string[]}, + 'REQUESTED', + NOW(), + NOW() + ) + ` return nextApiResponse( req, 200, 'Source submission requested successfully!', ) } catch (error) { + console.error('Error creating source:', error) return nextApiResponse(req, 500, 'Error creating source.', null) } } diff --git a/apps/caramel-app/src/lib/couponsDb.ts b/apps/caramel-app/src/lib/couponsDb.ts new file mode 100644 index 0000000..154272e --- /dev/null +++ b/apps/caramel-app/src/lib/couponsDb.ts @@ -0,0 +1,47 @@ +// lib/couponsDb.ts +// +// Read-only connection to the `caramel_coupons` database owned by the +// Python verification service. All mutations to the coupon catalog flow +// through that service — Next.js only reads (plus two narrow mutations: +// usage-increment and expire, both exposed to the extension). +import postgres from 'postgres' + +const connectionString = process.env.COUPONS_DATABASE_URL +if (!connectionString) { + throw new Error('COUPONS_DATABASE_URL is not set') +} + +// Reuse one client across hot-reloads in dev to avoid pool leaks. +const globalForSql = globalThis as unknown as { + couponsSql?: ReturnType +} + +export const couponsSql = + globalForSql.couponsSql ?? + postgres(connectionString, { + max: 10, + idle_timeout: 20, + connect_timeout: 10, + prepare: false, + }) + +if (process.env.NODE_ENV !== 'production') { + globalForSql.couponsSql = couponsSql +} + +export type DiscountType = 'PERCENTAGE' | 'CASH' | 'SAVE' + +export type CouponRow = { + id: string + code: string + site: string + title: string + description: string + rating: number + discount_type: DiscountType + discount_amount: number | null + expiry: string + expired: boolean + times_used: number + last_time_used: Date | null +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 27e3be1..48a7104 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -92,6 +92,9 @@ importers: next: specifier: 16.1.1 version: 16.1.1(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.90.0) + postgres: + specifier: ^3.4.9 + version: 3.4.9 prisma: specifier: ^6.14.0 version: 6.14.0(typescript@5.9.2) @@ -4587,6 +4590,10 @@ packages: resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==} engines: {node: '>=0.10.0'} + postgres@3.4.9: + resolution: {integrity: sha512-GD3qdB0x1z9xgFI6cdRD6xu2Sp2WCOEoe3mtnyB5Ee0XrrL5Pe+e4CCnJrRMnL1zYtRDZmQQVbvOttLnKDLnaw==} + engines: {node: '>=12'} + potpack@1.0.2: resolution: {integrity: sha512-choctRBIV9EMT9WGAZHn3V7t0Z2pMQyl0EZE6pFc/6ml3ssw7Dlf/oAOvFwjm1HVsqfQN8GfeFyJ+d8tRzqueQ==} @@ -8915,23 +8922,22 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-module-utils@2.12.1(@typescript-eslint/parser@8.47.0(eslint@9.31.0(jiti@2.6.1))(typescript@5.9.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@9.31.0(jiti@2.6.1)))(eslint@9.31.0(jiti@2.6.1)): + eslint-module-utils@2.12.1(@typescript-eslint/parser@8.47.0(eslint@9.31.0(jiti@2.6.1))(typescript@5.9.2))(eslint-import-resolver-node@0.3.9)(eslint@8.57.1): dependencies: debug: 3.2.7 optionalDependencies: '@typescript-eslint/parser': 8.47.0(eslint@9.31.0(jiti@2.6.1))(typescript@5.9.2) - eslint: 9.31.0(jiti@2.6.1) + eslint: 8.57.1 eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.31.0(jiti@2.6.1)) transitivePeerDependencies: - supports-color - eslint-module-utils@2.12.1(@typescript-eslint/parser@8.47.0(eslint@9.31.0(jiti@2.6.1))(typescript@5.9.2))(eslint-import-resolver-node@0.3.9)(eslint@8.57.1): + eslint-module-utils@2.12.1(@typescript-eslint/parser@8.47.0(eslint@9.31.0(jiti@2.6.1))(typescript@5.9.2))(eslint-import-resolver-node@0.3.9)(eslint@9.31.0(jiti@2.6.1)): dependencies: debug: 3.2.7 optionalDependencies: '@typescript-eslint/parser': 8.47.0(eslint@9.31.0(jiti@2.6.1))(typescript@5.9.2) - eslint: 8.57.1 + eslint: 9.31.0(jiti@2.6.1) eslint-import-resolver-node: 0.3.9 transitivePeerDependencies: - supports-color @@ -9009,7 +9015,7 @@ snapshots: doctrine: 2.1.0 eslint: 9.31.0(jiti@2.6.1) eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.47.0(eslint@9.31.0(jiti@2.6.1))(typescript@5.9.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@9.31.0(jiti@2.6.1)))(eslint@9.31.0(jiti@2.6.1)) + eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.47.0(eslint@9.31.0(jiti@2.6.1))(typescript@5.9.2))(eslint-import-resolver-node@0.3.9)(eslint@9.31.0(jiti@2.6.1)) hasown: 2.0.2 is-core-module: 2.16.1 is-glob: 4.0.3 @@ -10658,6 +10664,8 @@ snapshots: dependencies: xtend: 4.0.2 + postgres@3.4.9: {} + potpack@1.0.2: {} prelude-ls@1.2.1: {} From e2df5a951c956dc0309959709f0513b372225521 Mon Sep 17 00:00:00 2001 From: Alaeddin Date: Wed, 15 Apr 2026 00:11:57 +0100 Subject: [PATCH 004/198] feat: per-IP rate limit + hostname validation on public coupon routes --- apps/caramel-app/package.json | 1 + .../app/(marketing)/coupons/[store]/page.tsx | 3 + .../src/app/api/coupons/expire/route.ts | 4 + .../src/app/api/coupons/filters/route.ts | 4 + .../src/app/api/coupons/increment/route.ts | 4 + apps/caramel-app/src/app/api/coupons/route.ts | 16 ++- .../src/app/api/coupons/stats/route.ts | 8 +- .../src/app/api/coupons/stores/route.ts | 4 + .../src/app/api/sites/top-sites/route.ts | 8 +- apps/caramel-app/src/app/api/sources/route.ts | 7 ++ apps/caramel-app/src/lib/rateLimit.ts | 108 ++++++++++++++++++ local-dev/.env.ports | 12 +- pnpm-lock.yaml | 8 ++ 13 files changed, 175 insertions(+), 12 deletions(-) create mode 100644 apps/caramel-app/src/lib/rateLimit.ts diff --git a/apps/caramel-app/package.json b/apps/caramel-app/package.json index d5e536c..2ecf68f 100644 --- a/apps/caramel-app/package.json +++ b/apps/caramel-app/package.json @@ -43,6 +43,7 @@ "next": "16.1.1", "postgres": "^3.4.9", "prisma": "^6.14.0", + "rate-limiter-flexible": "^11.0.0", "react": "19.2.3", "react-awesome-button": "^7.0.5", "react-dom": "19.2.3", diff --git a/apps/caramel-app/src/app/(marketing)/coupons/[store]/page.tsx b/apps/caramel-app/src/app/(marketing)/coupons/[store]/page.tsx index 9dd608d..6cf61f2 100644 --- a/apps/caramel-app/src/app/(marketing)/coupons/[store]/page.tsx +++ b/apps/caramel-app/src/app/(marketing)/coupons/[store]/page.tsx @@ -25,6 +25,9 @@ function getBaseDomain(raw: string): string { } catch { // fallback to raw slug } + // Strip anything that isn't a valid hostname character so injection + // attempts through the URL slug can't smuggle SQL patterns downstream. + if (!/^[a-z0-9.-]+$/i.test(hostname)) return '' const parts = hostname.split('.') return parts.length > 2 ? parts.slice(-2).join('.') : hostname } diff --git a/apps/caramel-app/src/app/api/coupons/expire/route.ts b/apps/caramel-app/src/app/api/coupons/expire/route.ts index 18b8b82..2b689b7 100644 --- a/apps/caramel-app/src/app/api/coupons/expire/route.ts +++ b/apps/caramel-app/src/app/api/coupons/expire/route.ts @@ -1,7 +1,11 @@ import { couponsSql } from '@/lib/couponsDb' +import { checkRateLimit } from '@/lib/rateLimit' import { NextRequest, NextResponse } from 'next/server' export async function POST(req: NextRequest) { + const limited = await checkRateLimit(req, 'mutation') + if (limited) return limited + const { ids = [] } = (await req.json().catch(() => ({}))) as { ids?: string[] } diff --git a/apps/caramel-app/src/app/api/coupons/filters/route.ts b/apps/caramel-app/src/app/api/coupons/filters/route.ts index 5281829..1ba4874 100644 --- a/apps/caramel-app/src/app/api/coupons/filters/route.ts +++ b/apps/caramel-app/src/app/api/coupons/filters/route.ts @@ -1,7 +1,11 @@ import { couponsSql } from '@/lib/couponsDb' +import { checkRateLimit } from '@/lib/rateLimit' import { NextRequest, NextResponse } from 'next/server' export async function GET(req: NextRequest) { + const limited = await checkRateLimit(req, 'read') + if (limited) return limited + const url = new URL(req.url) const includeSitesParam = url.searchParams.get('includeSites') const includeSites = includeSitesParam !== 'false' diff --git a/apps/caramel-app/src/app/api/coupons/increment/route.ts b/apps/caramel-app/src/app/api/coupons/increment/route.ts index 1653227..b485f8e 100644 --- a/apps/caramel-app/src/app/api/coupons/increment/route.ts +++ b/apps/caramel-app/src/app/api/coupons/increment/route.ts @@ -1,7 +1,11 @@ import { couponsSql } from '@/lib/couponsDb' +import { checkRateLimit } from '@/lib/rateLimit' import { NextRequest, NextResponse } from 'next/server' export async function GET(req: NextRequest) { + const limited = await checkRateLimit(req, 'mutation') + if (limited) return limited + const url = new URL(req.url) const id = url.searchParams.get('id') if (!id) { diff --git a/apps/caramel-app/src/app/api/coupons/route.ts b/apps/caramel-app/src/app/api/coupons/route.ts index dce7553..f8ab54e 100644 --- a/apps/caramel-app/src/app/api/coupons/route.ts +++ b/apps/caramel-app/src/app/api/coupons/route.ts @@ -1,19 +1,25 @@ import { couponsSql } from '@/lib/couponsDb' +import { checkRateLimit } from '@/lib/rateLimit' import { NextRequest, NextResponse } from 'next/server' -function getBaseDomain(raw: string): string { +function getBaseDomain(raw: string): string | null { let hostname = raw try { const u = new URL(raw.startsWith('http') ? raw : `https://${raw}`) hostname = u.hostname } catch { - throw new Error('Could not find base domain') + return null } + // Hostnames must be ASCII letters, digits, dots, and hyphens only + if (!/^[a-z0-9.-]+$/i.test(hostname)) return null const parts = hostname.split('.') return parts.length > 2 ? parts.slice(-2).join('.') : hostname } export async function GET(req: NextRequest) { + const limited = await checkRateLimit(req, 'read') + if (limited) return limited + const url = new URL(req.url) const site = url.searchParams.get('site') || undefined const rawPage = parseInt(url.searchParams.get('page') || '1', 10) @@ -30,6 +36,12 @@ export async function GET(req: NextRequest) { if (site) { const base = getBaseDomain(site) + if (!base) { + return NextResponse.json( + { error: 'Invalid site parameter' }, + { status: 400 }, + ) + } conditions.push( couponsSql`(site = ${base} OR site LIKE ${'%.' + base})`, ) diff --git a/apps/caramel-app/src/app/api/coupons/stats/route.ts b/apps/caramel-app/src/app/api/coupons/stats/route.ts index c927631..dc08721 100644 --- a/apps/caramel-app/src/app/api/coupons/stats/route.ts +++ b/apps/caramel-app/src/app/api/coupons/stats/route.ts @@ -1,7 +1,11 @@ import { couponsSql } from '@/lib/couponsDb' -import { NextResponse } from 'next/server' +import { checkRateLimit } from '@/lib/rateLimit' +import { NextRequest, NextResponse } from 'next/server' + +export async function GET(req: NextRequest) { + const limited = await checkRateLimit(req, 'read') + if (limited) return limited -export async function GET() { try { const rows = await couponsSql` SELECT diff --git a/apps/caramel-app/src/app/api/coupons/stores/route.ts b/apps/caramel-app/src/app/api/coupons/stores/route.ts index 4e997f8..e22740b 100644 --- a/apps/caramel-app/src/app/api/coupons/stores/route.ts +++ b/apps/caramel-app/src/app/api/coupons/stores/route.ts @@ -1,7 +1,11 @@ import { couponsSql } from '@/lib/couponsDb' +import { checkRateLimit } from '@/lib/rateLimit' import { NextRequest, NextResponse } from 'next/server' export async function GET(req: NextRequest) { + const limited = await checkRateLimit(req, 'read') + if (limited) return limited + const url = new URL(req.url) const q = url.searchParams.get('q')?.trim() || '' const rawLimit = parseInt(url.searchParams.get('limit') || '20', 10) diff --git a/apps/caramel-app/src/app/api/sites/top-sites/route.ts b/apps/caramel-app/src/app/api/sites/top-sites/route.ts index 478a561..2cbf671 100644 --- a/apps/caramel-app/src/app/api/sites/top-sites/route.ts +++ b/apps/caramel-app/src/app/api/sites/top-sites/route.ts @@ -1,7 +1,11 @@ import { couponsSql } from '@/lib/couponsDb' -import { NextResponse } from 'next/server' +import { checkRateLimit } from '@/lib/rateLimit' +import { NextRequest, NextResponse } from 'next/server' + +export async function GET(req: NextRequest) { + const limited = await checkRateLimit(req, 'read') + if (limited) return limited -export async function GET() { try { const rows = await couponsSql>` SELECT site, COUNT(*)::int AS coupon_count diff --git a/apps/caramel-app/src/app/api/sources/route.ts b/apps/caramel-app/src/app/api/sources/route.ts index d54f7b2..b330b70 100644 --- a/apps/caramel-app/src/app/api/sources/route.ts +++ b/apps/caramel-app/src/app/api/sources/route.ts @@ -1,5 +1,6 @@ import { nextApiResponse } from '@/lib/apiResponseNext' import { couponsSql } from '@/lib/couponsDb' +import { checkRateLimit } from '@/lib/rateLimit' import { NextRequest } from 'next/server' type SourceMetrics = { @@ -12,6 +13,9 @@ type SourceMetrics = { } export async function GET(req: NextRequest) { + const limited = await checkRateLimit(req, 'read') + if (limited) return limited + try { const rows = await couponsSql< Array<{ @@ -62,6 +66,9 @@ export async function GET(req: NextRequest) { } export async function POST(req: NextRequest) { + const limited = await checkRateLimit(req, 'mutation') + if (limited) return limited + try { const body = (await req.json().catch(() => ({}))) as { website?: string diff --git a/apps/caramel-app/src/lib/rateLimit.ts b/apps/caramel-app/src/lib/rateLimit.ts new file mode 100644 index 0000000..a020dda --- /dev/null +++ b/apps/caramel-app/src/lib/rateLimit.ts @@ -0,0 +1,108 @@ +// src/lib/rateLimit.ts +// +// Per-IP rate limiting for public API routes. In-memory token buckets — +// sufficient for single-instance dev + prod. Swap to +// `RateLimiterRedis` (same API) when we scale to multiple instances. +// +// Design goals: +// * be generous to real users (a page load bursts ~5 requests) +// * be boring and predictable under scraping load +// * fail open if the limiter itself throws (never block legitimate +// traffic because of an internal bug) +// +// Limits are intentionally per-IP, not per-route, so a scraper pivoting +// between endpoints doesn't get a fresh budget on each one. +import type { NextRequest } from 'next/server' +import { NextResponse } from 'next/server' +import { RateLimiterMemory, type RateLimiterRes } from 'rate-limiter-flexible' + +export type LimitKind = 'read' | 'mutation' + +// Per-minute sustained rate. Burst = the full minute's budget. +const LIMITS: Record = { + // 120/min ≈ 2/sec sustained. A real user hitting /coupons pages + // comes nowhere near this even with search + pagination. + read: { points: 120, duration: 60 }, + // 30/min — /increment, /expire, /sources POST. Extension calls + // /increment once per coupon copy, which is nowhere near this cap. + mutation: { points: 30, duration: 60 }, +} + +const limiters: Record = { + read: new RateLimiterMemory(LIMITS.read), + mutation: new RateLimiterMemory(LIMITS.mutation), +} + +function getClientIp(req: NextRequest): string { + // Trusted in order: our own proxy (X-Real-IP), then the first + // public IP in X-Forwarded-For. Fall back to a constant so that + // when no headers are present (edge runtime, direct hits) we still + // apply a global cap instead of silently letting everything through. + const realIp = req.headers.get('x-real-ip') + if (realIp) return realIp + const xff = req.headers.get('x-forwarded-for') + if (xff) { + const first = xff.split(',')[0]?.trim() + if (first) return first + } + return 'unknown' +} + +function isExtensionClient(req: NextRequest): boolean { + const key = req.headers.get('x-extension-api-key') + const expected = process.env.EXTENSION_API_KEY + return Boolean(key && expected && key === expected) +} + +function buildHeaders(kind: LimitKind, res: RateLimiterRes | null): Headers { + const h = new Headers() + h.set('X-RateLimit-Limit', String(LIMITS[kind].points)) + if (res) { + h.set('X-RateLimit-Remaining', String(Math.max(0, res.remainingPoints))) + h.set( + 'X-RateLimit-Reset', + String(Math.ceil((Date.now() + res.msBeforeNext) / 1000)), + ) + } + return h +} + +/** + * Call at the top of a route handler. Returns null if the request is + * allowed, or a ready-to-return 429 NextResponse if it is blocked. + * + * ```ts + * const limited = await checkRateLimit(req, 'read') + * if (limited) return limited + * ``` + */ +export async function checkRateLimit( + req: NextRequest, + kind: LimitKind = 'read', +): Promise { + if (isExtensionClient(req)) return null + + const ip = getClientIp(req) + try { + const res = await limiters[kind].consume(ip, 1) + // Success — we could also return headers here, but merging them + // into the success response is up to the caller. Most callers + // don't need this, so we just return null. + void res + return null + } catch (error) { + const res = (error as RateLimiterRes) ?? null + const retryAfterSec = res + ? Math.max(1, Math.ceil(res.msBeforeNext / 1000)) + : 60 + const headers = buildHeaders(kind, res) + headers.set('Retry-After', String(retryAfterSec)) + return NextResponse.json( + { + error: 'Too many requests. Please slow down.', + retryAfter: retryAfterSec, + }, + { status: 429, headers }, + ) + } +} diff --git a/local-dev/.env.ports b/local-dev/.env.ports index 8232621..3dfb62c 100644 --- a/local-dev/.env.ports +++ b/local-dev/.env.ports @@ -1,8 +1,8 @@ # Caramel local port defaults (can be overridden per developer) -PORT=3001 -PG_PORT=5433 -REDIS_PORT=6380 +PORT=58000 +PG_PORT=58005 +REDIS_PORT=58006 # Optional future expansions -SOCKET_PORT=3003 -WORKER_PORT=3002 -TYPESENSE_PORT=8109 +SOCKET_PORT=58003 +WORKER_PORT=58002 +TYPESENSE_PORT=58007 diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 48a7104..18d54b1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -98,6 +98,9 @@ importers: prisma: specifier: ^6.14.0 version: 6.14.0(typescript@5.9.2) + rate-limiter-flexible: + specifier: ^11.0.0 + version: 11.0.0 react: specifier: 19.2.3 version: 19.2.3 @@ -4757,6 +4760,9 @@ packages: randombytes@2.1.0: resolution: {integrity: sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==} + rate-limiter-flexible@11.0.0: + resolution: {integrity: sha512-UhN3xVeU6Az3y6hAuxMUwFsKcKD1HffhMGK0MknbSxH9vkwslS/p19ovCvJqIVT97pE778nKu2sUgYAcxj4dmQ==} + rc9@2.1.2: resolution: {integrity: sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==} @@ -10754,6 +10760,8 @@ snapshots: dependencies: safe-buffer: 5.2.1 + rate-limiter-flexible@11.0.0: {} + rc9@2.1.2: dependencies: defu: 6.1.4 From b3b383531515f7756bbfcc1281dabc391063c631 Mon Sep 17 00:00:00 2001 From: Alaeddin Date: Wed, 15 Apr 2026 00:56:12 +0100 Subject: [PATCH 005/198] feat: harden public coupon API (headers + burst + origin + cache + caps) --- apps/caramel-app/next.config.mjs | 24 ++++- .../src/app/api/coupons/expire/route.ts | 7 +- .../src/app/api/coupons/filters/route.ts | 10 ++- .../src/app/api/coupons/increment/route.ts | 7 +- apps/caramel-app/src/app/api/coupons/route.ts | 25 +++++- .../src/app/api/coupons/stats/route.ts | 18 ++-- .../src/app/api/coupons/stores/route.ts | 10 ++- .../src/app/api/sites/top-sites/route.ts | 10 ++- apps/caramel-app/src/app/api/sources/route.ts | 7 +- apps/caramel-app/src/lib/rateLimit.ts | 88 ++++++++++++++++++- 10 files changed, 186 insertions(+), 20 deletions(-) diff --git a/apps/caramel-app/next.config.mjs b/apps/caramel-app/next.config.mjs index 0cbacad..3732a12 100644 --- a/apps/caramel-app/next.config.mjs +++ b/apps/caramel-app/next.config.mjs @@ -1,10 +1,27 @@ -import { fileURLToPath } from 'node:url' -import path from 'node:path' import { withSentryConfig } from '@sentry/nextjs' +import path from 'node:path' +import { fileURLToPath } from 'node:url' const packageRoot = fileURLToPath(new URL('.', import.meta.url)) const workspaceRoot = path.resolve(packageRoot, '..', '..') +// Universally-safe security headers. CSP is deliberately NOT included +// here — it's easy to break third-party scripts (Sentry, GA, RevenueCat) +// with a wrong policy and it deserves its own rollout. +const SECURITY_HEADERS = [ + { key: 'X-Content-Type-Options', value: 'nosniff' }, + { key: 'X-Frame-Options', value: 'DENY' }, + { key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' }, + { + key: 'Permissions-Policy', + value: 'camera=(), microphone=(), geolocation=(), payment=()', + }, + { + key: 'Strict-Transport-Security', + value: 'max-age=31536000; includeSubDomains', + }, +] + /** @type {import('next').NextConfig} */ const nextConfig = { outputFileTracingRoot: workspaceRoot, @@ -21,6 +38,9 @@ const nextConfig = { }, ], }, + async headers() { + return [{ source: '/:path*', headers: SECURITY_HEADERS }] + }, } // Only apply Sentry in production to allow Turbopack in development diff --git a/apps/caramel-app/src/app/api/coupons/expire/route.ts b/apps/caramel-app/src/app/api/coupons/expire/route.ts index 2b689b7..888c9e5 100644 --- a/apps/caramel-app/src/app/api/coupons/expire/route.ts +++ b/apps/caramel-app/src/app/api/coupons/expire/route.ts @@ -1,8 +1,13 @@ import { couponsSql } from '@/lib/couponsDb' -import { checkRateLimit } from '@/lib/rateLimit' +import { + checkRateLimit, + forbiddenOrigin, + isOriginAllowed, +} from '@/lib/rateLimit' import { NextRequest, NextResponse } from 'next/server' export async function POST(req: NextRequest) { + if (!isOriginAllowed(req)) return forbiddenOrigin() const limited = await checkRateLimit(req, 'mutation') if (limited) return limited diff --git a/apps/caramel-app/src/app/api/coupons/filters/route.ts b/apps/caramel-app/src/app/api/coupons/filters/route.ts index 1ba4874..03dde11 100644 --- a/apps/caramel-app/src/app/api/coupons/filters/route.ts +++ b/apps/caramel-app/src/app/api/coupons/filters/route.ts @@ -41,7 +41,15 @@ export async function GET(req: NextRequest) { new Set(discountTypesRaw.map(d => d.discount_type).filter(Boolean)), ) - return NextResponse.json({ sites, discountTypes }) + return NextResponse.json( + { sites, discountTypes }, + { + headers: { + 'Cache-Control': + 'public, s-maxage=300, stale-while-revalidate=300', + }, + }, + ) } catch (error) { console.error('Failed to load coupon filter metadata:', error) return NextResponse.json( diff --git a/apps/caramel-app/src/app/api/coupons/increment/route.ts b/apps/caramel-app/src/app/api/coupons/increment/route.ts index b485f8e..ab42788 100644 --- a/apps/caramel-app/src/app/api/coupons/increment/route.ts +++ b/apps/caramel-app/src/app/api/coupons/increment/route.ts @@ -1,8 +1,13 @@ import { couponsSql } from '@/lib/couponsDb' -import { checkRateLimit } from '@/lib/rateLimit' +import { + checkRateLimit, + forbiddenOrigin, + isOriginAllowed, +} from '@/lib/rateLimit' import { NextRequest, NextResponse } from 'next/server' export async function GET(req: NextRequest) { + if (!isOriginAllowed(req)) return forbiddenOrigin() const limited = await checkRateLimit(req, 'mutation') if (limited) return limited diff --git a/apps/caramel-app/src/app/api/coupons/route.ts b/apps/caramel-app/src/app/api/coupons/route.ts index f8ab54e..8186687 100644 --- a/apps/caramel-app/src/app/api/coupons/route.ts +++ b/apps/caramel-app/src/app/api/coupons/route.ts @@ -24,12 +24,18 @@ export async function GET(req: NextRequest) { const site = url.searchParams.get('site') || undefined const rawPage = parseInt(url.searchParams.get('page') || '1', 10) const rawLimit = parseInt(url.searchParams.get('limit') || '10', 10) - const page = Number.isFinite(rawPage) && rawPage > 0 ? rawPage : 1 + // Cap page at 500 so scrapers can't walk the catalog indefinitely + // with `page=999999`. Real users never paginate that far. + const page = + Number.isFinite(rawPage) && rawPage > 0 ? Math.min(rawPage, 500) : 1 const limit = Number.isFinite(rawLimit) && rawLimit > 0 ? Math.min(rawLimit, 50) : 10 - const search = url.searchParams.get('search') || undefined + // Cap search + keyword params to keep ILIKE patterns cheap. + const search = + (url.searchParams.get('search') || '').slice(0, 100) || undefined const type = url.searchParams.get('type') || undefined - const keyWords = url.searchParams.get('key_words') || undefined + const keyWords = + (url.searchParams.get('key_words') || '').slice(0, 200) || undefined try { const conditions = [couponsSql`expired = FALSE`] @@ -90,7 +96,18 @@ export async function GET(req: NextRequest) { const total = (totalRow[0] as { total: number } | undefined)?.total ?? 0 const hasMore = skip + coupons.length < total - return NextResponse.json({ coupons, page, limit, total, hasMore }) + // 60s edge cache with a 60s grace window. Coupons change on a + // scrape cycle (minutes-hours), so 60s staleness is invisible + // to users and offloads almost all scraping traffic to CDN. + return NextResponse.json( + { coupons, page, limit, total, hasMore }, + { + headers: { + 'Cache-Control': + 'public, s-maxage=60, stale-while-revalidate=60', + }, + }, + ) } catch (error) { console.error('Error fetching coupons:', error) return NextResponse.json( diff --git a/apps/caramel-app/src/app/api/coupons/stats/route.ts b/apps/caramel-app/src/app/api/coupons/stats/route.ts index dc08721..fc4cec8 100644 --- a/apps/caramel-app/src/app/api/coupons/stats/route.ts +++ b/apps/caramel-app/src/app/api/coupons/stats/route.ts @@ -18,11 +18,19 @@ export async function GET(req: NextRequest) { expired: number } - return NextResponse.json({ - total: row.total, - expired: row.expired, - active: row.total - row.expired, - }) + return NextResponse.json( + { + total: row.total, + expired: row.expired, + active: row.total - row.expired, + }, + { + headers: { + 'Cache-Control': + 'public, s-maxage=300, stale-while-revalidate=300', + }, + }, + ) } catch (error) { console.error('Failed to fetch coupon stats:', error) return NextResponse.json( diff --git a/apps/caramel-app/src/app/api/coupons/stores/route.ts b/apps/caramel-app/src/app/api/coupons/stores/route.ts index e22740b..1f9f8c6 100644 --- a/apps/caramel-app/src/app/api/coupons/stores/route.ts +++ b/apps/caramel-app/src/app/api/coupons/stores/route.ts @@ -29,7 +29,15 @@ export async function GET(req: NextRequest) { const sites = rows.map(s => s.site).filter(Boolean) - return NextResponse.json({ sites }) + return NextResponse.json( + { sites }, + { + headers: { + 'Cache-Control': + 'public, s-maxage=120, stale-while-revalidate=120', + }, + }, + ) } catch (error) { console.error('Failed to load store options:', error) return NextResponse.json( diff --git a/apps/caramel-app/src/app/api/sites/top-sites/route.ts b/apps/caramel-app/src/app/api/sites/top-sites/route.ts index 2cbf671..4f3b1c7 100644 --- a/apps/caramel-app/src/app/api/sites/top-sites/route.ts +++ b/apps/caramel-app/src/app/api/sites/top-sites/route.ts @@ -16,7 +16,15 @@ export async function GET(req: NextRequest) { LIMIT 4 ` const sites = rows.map(r => r.site) - return NextResponse.json({ sites }) + return NextResponse.json( + { sites }, + { + headers: { + 'Cache-Control': + 'public, s-maxage=300, stale-while-revalidate=300', + }, + }, + ) } catch (err) { console.error('Failed to fetch top sites:', err) return NextResponse.json( diff --git a/apps/caramel-app/src/app/api/sources/route.ts b/apps/caramel-app/src/app/api/sources/route.ts index b330b70..f8cb7d8 100644 --- a/apps/caramel-app/src/app/api/sources/route.ts +++ b/apps/caramel-app/src/app/api/sources/route.ts @@ -1,6 +1,10 @@ import { nextApiResponse } from '@/lib/apiResponseNext' import { couponsSql } from '@/lib/couponsDb' -import { checkRateLimit } from '@/lib/rateLimit' +import { + checkRateLimit, + forbiddenOrigin, + isOriginAllowed, +} from '@/lib/rateLimit' import { NextRequest } from 'next/server' type SourceMetrics = { @@ -66,6 +70,7 @@ export async function GET(req: NextRequest) { } export async function POST(req: NextRequest) { + if (!isOriginAllowed(req)) return forbiddenOrigin() const limited = await checkRateLimit(req, 'mutation') if (limited) return limited diff --git a/apps/caramel-app/src/lib/rateLimit.ts b/apps/caramel-app/src/lib/rateLimit.ts index a020dda..15c0a3f 100644 --- a/apps/caramel-app/src/lib/rateLimit.ts +++ b/apps/caramel-app/src/lib/rateLimit.ts @@ -28,10 +28,15 @@ const LIMITS: Record = { mutation: { points: 30, duration: 60 }, } +// Short-window burst limiter catches rapid-fire attempts that would +// otherwise crawl under the per-minute budget (e.g. 15 req/sec for 8s). +const BURST = { points: 20, duration: 2 } + const limiters: Record = { read: new RateLimiterMemory(LIMITS.read), mutation: new RateLimiterMemory(LIMITS.mutation), } +const burstLimiter = new RateLimiterMemory(BURST) function getClientIp(req: NextRequest): string { // Trusted in order: our own proxy (X-Real-IP), then the first @@ -83,11 +88,32 @@ export async function checkRateLimit( if (isExtensionClient(req)) return null const ip = getClientIp(req) + + // Burst first — cheaper to reject here than to touch the minute + // limiter. A hit against the burst limiter does NOT consume the + // minute budget so a brief hiccup doesn't penalise the user + // long-term. + try { + await burstLimiter.consume(ip, 1) + } catch (error) { + const res = (error as RateLimiterRes) ?? null + const retryAfterSec = res + ? Math.max(1, Math.ceil(res.msBeforeNext / 1000)) + : 2 + logAbuse(req, ip, 'burst', retryAfterSec) + const headers = buildHeaders(kind, res) + headers.set('Retry-After', String(retryAfterSec)) + return NextResponse.json( + { + error: 'Too many requests. Please slow down.', + retryAfter: retryAfterSec, + }, + { status: 429, headers }, + ) + } + try { const res = await limiters[kind].consume(ip, 1) - // Success — we could also return headers here, but merging them - // into the success response is up to the caller. Most callers - // don't need this, so we just return null. void res return null } catch (error) { @@ -95,6 +121,7 @@ export async function checkRateLimit( const retryAfterSec = res ? Math.max(1, Math.ceil(res.msBeforeNext / 1000)) : 60 + logAbuse(req, ip, kind, retryAfterSec) const headers = buildHeaders(kind, res) headers.set('Retry-After', String(retryAfterSec)) return NextResponse.json( @@ -106,3 +133,58 @@ export async function checkRateLimit( ) } } + +function logAbuse( + req: NextRequest, + ip: string, + kind: string, + retryAfterSec: number, +) { + // One-line structured log so you can grep the dev console / prod + // log aggregator for "[ratelimit]" to see abuse patterns. + const path = new URL(req.url).pathname + const ua = req.headers.get('user-agent')?.slice(0, 80) ?? '-' + console.warn( + `[ratelimit] kind=${kind} ip=${ip} path=${path} retry_after=${retryAfterSec}s ua="${ua}"`, + ) +} + +/** + * Allow-list check for mutation routes. Rejects cross-origin browser + * requests from random websites. Accepts: + * - no Origin header (server-to-server, curl) + * - same-origin (our own Host) + * - chrome-extension:// / moz-extension:// / safari-web-extension:// + * - any origin listed in ALLOWED_ORIGINS (comma-separated env var) + */ +export function isOriginAllowed(req: NextRequest): boolean { + const origin = req.headers.get('origin') + if (!origin) return true + + try { + const originUrl = new URL(origin) + // Browser extensions are trusted by protocol. + if ( + originUrl.protocol === 'chrome-extension:' || + originUrl.protocol === 'moz-extension:' || + originUrl.protocol === 'safari-web-extension:' + ) { + return true + } + const host = req.headers.get('host') + if (host && originUrl.host === host) return true + + const allowed = (process.env.ALLOWED_ORIGINS || '') + .split(',') + .map(s => s.trim()) + .filter(Boolean) + if (allowed.includes(origin)) return true + return false + } catch { + return false + } +} + +export function forbiddenOrigin(): NextResponse { + return NextResponse.json({ error: 'Forbidden origin' }, { status: 403 }) +} From 04472dea6a6e38ea4ea41175b2b7a4bb3ba5e8e1 Mon Sep 17 00:00:00 2001 From: Alaeddin Date: Fri, 17 Apr 2026 17:31:20 +0100 Subject: [PATCH 006/198] feat: cart category classifier + dynamic supported-stores in extension --- .prettierrc.json | 3 +- .../src/app/api/classify-cart/route.ts | 93 +++++++++ .../api/extension/supported-stores/route.ts | 78 ++++++++ apps/caramel-app/src/lib/cartClassifier.ts | 171 ++++++++++++++++ apps/caramel-app/src/lib/openrouter.ts | 74 +++++++ apps/caramel-extension/background.js | 57 +++++- apps/caramel-extension/cart-signals.js | 189 ++++++++++++++++++ apps/caramel-extension/manifest.json | 8 +- apps/caramel-extension/popup.js | 29 ++- apps/caramel-extension/shared-utils.js | 109 ++++++++-- tailwind.config.js | 2 + 11 files changed, 777 insertions(+), 36 deletions(-) create mode 100644 apps/caramel-app/src/app/api/classify-cart/route.ts create mode 100644 apps/caramel-app/src/app/api/extension/supported-stores/route.ts create mode 100644 apps/caramel-app/src/lib/cartClassifier.ts create mode 100644 apps/caramel-app/src/lib/openrouter.ts create mode 100644 apps/caramel-extension/cart-signals.js create mode 100644 tailwind.config.js diff --git a/.prettierrc.json b/.prettierrc.json index 9e6a24d..221e884 100644 --- a/.prettierrc.json +++ b/.prettierrc.json @@ -7,5 +7,6 @@ "plugins": [ "prettier-plugin-organize-imports", "prettier-plugin-tailwindcss" - ] + ], + "tailwindConfigPath": "./apps/caramel-app/tailwind.config.ts" } diff --git a/apps/caramel-app/src/app/api/classify-cart/route.ts b/apps/caramel-app/src/app/api/classify-cart/route.ts new file mode 100644 index 0000000..27f27db --- /dev/null +++ b/apps/caramel-app/src/app/api/classify-cart/route.ts @@ -0,0 +1,93 @@ +import { classifyCart, type CartSignals } from '@/lib/cartClassifier' +import { OpenRouterError } from '@/lib/openrouter' +import { + checkRateLimit, + forbiddenOrigin, + isOriginAllowed, +} from '@/lib/rateLimit' +import { NextRequest, NextResponse } from 'next/server' + +// Cap payload size to protect the route from noisy senders. +const MAX_BODY_BYTES = 8 * 1024 + +function sanitize(body: unknown): CartSignals | null { + if (!body || typeof body !== 'object') return null + const b = body as Record + const domain = + typeof b.domain === 'string' ? b.domain.trim().toLowerCase() : '' + if (!domain || !/^[a-z0-9.-]{3,120}$/.test(domain)) return null + + const str = (v: unknown, max: number) => + typeof v === 'string' ? v.slice(0, max) : undefined + const arr = (v: unknown, maxN: number, maxLen: number) => + Array.isArray(v) + ? v + .filter(x => typeof x === 'string') + .map(x => (x as string).slice(0, maxLen)) + .slice(0, maxN) + : undefined + + return { + domain, + url_path: str(b.url_path, 200), + title: str(b.title, 200), + meta_description: str(b.meta_description, 400), + og_site_name: str(b.og_site_name, 80), + og_type: str(b.og_type, 40), + cart_items: arr(b.cart_items, 6, 160), + platform_hints: + b.platform_hints && typeof b.platform_hints === 'object' + ? (Object.fromEntries( + ( + Object.entries( + b.platform_hints as Record, + ).filter(([, v]) => typeof v === 'boolean') as [ + string, + boolean, + ][] + ).slice(0, 10), + ) as Record) + : undefined, + } +} + +export async function POST(req: NextRequest) { + if (!isOriginAllowed(req)) return forbiddenOrigin() + const limited = await checkRateLimit(req, 'mutation') + if (limited) return limited + + const contentLength = Number(req.headers.get('content-length') || 0) + if (contentLength > MAX_BODY_BYTES) { + return NextResponse.json( + { error: 'payload too large' }, + { status: 413 }, + ) + } + + const raw = await req.json().catch(() => null) + const signals = sanitize(raw) + if (!signals) { + return NextResponse.json({ error: 'invalid payload' }, { status: 400 }) + } + + try { + const result = await classifyCart(signals) + return NextResponse.json(result, { + headers: { + 'Cache-Control': 'private, no-store', + }, + }) + } catch (error) { + const status = error instanceof OpenRouterError ? 502 : 500 + console.error('[classify-cart] failed', error) + return NextResponse.json( + { + error: + error instanceof Error + ? error.message + : 'classification failed', + }, + { status }, + ) + } +} diff --git a/apps/caramel-app/src/app/api/extension/supported-stores/route.ts b/apps/caramel-app/src/app/api/extension/supported-stores/route.ts new file mode 100644 index 0000000..a907935 --- /dev/null +++ b/apps/caramel-app/src/app/api/extension/supported-stores/route.ts @@ -0,0 +1,78 @@ +import { couponsSql } from '@/lib/couponsDb' +import { NextRequest, NextResponse } from 'next/server' + +const EXTENSION_API_KEY = process.env.EXTENSION_API_KEY + +function unauthorized() { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) +} + +function validateApiKey(req: NextRequest): boolean { + if (!EXTENSION_API_KEY) return false + const header = req.headers.get('x-api-key') + if (!header || header.length !== EXTENSION_API_KEY.length) return false + // Constant-time comparison to avoid timing-based key probing + let mismatch = 0 + for (let i = 0; i < header.length; i++) { + mismatch |= header.charCodeAt(i) ^ EXTENSION_API_KEY.charCodeAt(i) + } + return mismatch === 0 +} + +type Row = { + store_name: string + show_input_xpath: string | null + dismiss_button_xpath: string | null + coupon_input_xpath: string | null + apply_button_xpath: string | null + price_container_xpath: string | null +} + +export async function GET(req: NextRequest) { + if (!validateApiKey(req)) return unauthorized() + + try { + // One row per store, highest-priority active config that has xpath + // selectors (excludes API-only configs which the extension can't use). + const rows = (await couponsSql` + SELECT DISTINCT ON (s.store_name) + s.store_name, + cfg.show_input_xpath, + cfg.dismiss_button_xpath, + cfg.coupon_input_xpath, + cfg.apply_button_xpath, + cfg.price_container_xpath + FROM store_verification_configs cfg + JOIN verification_stores s ON s.id = cfg.store_id + WHERE cfg.is_active = TRUE + AND cfg.coupon_input_xpath IS NOT NULL + AND cfg.apply_button_xpath IS NOT NULL + ORDER BY s.store_name, cfg.priority DESC, cfg.updated_at DESC + `) as Row[] + + const supported = rows.map(r => ({ + domain: r.store_name, + couponInput: r.coupon_input_xpath, + couponSubmit: r.apply_button_xpath, + priceContainer: r.price_container_xpath ?? undefined, + showInput: r.show_input_xpath ?? undefined, + dismissButton: r.dismiss_button_xpath ?? undefined, + })) + + return NextResponse.json( + { supported }, + { + headers: { + 'Cache-Control': + 'public, s-maxage=300, stale-while-revalidate=300', + }, + }, + ) + } catch (error) { + console.error('[API][extension/supported-stores] error', error) + return NextResponse.json( + { error: 'Internal server error' }, + { status: 500 }, + ) + } +} diff --git a/apps/caramel-app/src/lib/cartClassifier.ts b/apps/caramel-app/src/lib/cartClassifier.ts new file mode 100644 index 0000000..ff6015b --- /dev/null +++ b/apps/caramel-app/src/lib/cartClassifier.ts @@ -0,0 +1,171 @@ +import { chat, OpenRouterError } from '@/lib/openrouter' +import crypto from 'node:crypto' + +export const CATEGORY_ENUM = [ + 'apparel', + 'beauty', + 'books_media', + 'electronics', + 'food_grocery', + 'health_supplements', + 'home_garden', + 'jewelry_accessories', + 'office_supplies', + 'pet', + 'services_subscriptions', + 'sports_outdoors', + 'tools_hardware', + 'toys_games', + 'travel', + 'other', +] as const + +export type Category = (typeof CATEGORY_ENUM)[number] + +export interface CartSignals { + domain: string + url_path?: string + title?: string + meta_description?: string + og_site_name?: string + og_type?: string + cart_items?: string[] + platform_hints?: Record +} + +export interface Classification { + primary: Category + secondary?: Category + confidence: number + cached: boolean +} + +// In-memory LRU-ish cache, capped at 2000 entries, 24h TTL. +const CACHE_TTL_MS = 24 * 60 * 60 * 1000 +const CACHE_MAX = 2000 +type CacheEntry = { expires: number; value: Omit } +const cache = new Map() + +function cacheKey(s: CartSignals): string { + const norm = { + d: s.domain, + t: (s.title || '').toLowerCase().slice(0, 120), + m: (s.meta_description || '').toLowerCase().slice(0, 160), + i: (s.cart_items || []).map(x => x.toLowerCase().slice(0, 80)).sort(), + } + return crypto + .createHash('sha256') + .update(JSON.stringify(norm)) + .digest('hex') + .slice(0, 32) +} + +function cacheGet(key: string): Omit | null { + const entry = cache.get(key) + if (!entry) return null + if (entry.expires < Date.now()) { + cache.delete(key) + return null + } + // Refresh LRU ordering + cache.delete(key) + cache.set(key, entry) + return entry.value +} + +function cacheSet(key: string, value: Omit) { + if (cache.size >= CACHE_MAX) { + const oldest = cache.keys().next().value + if (oldest) cache.delete(oldest) + } + cache.set(key, { expires: Date.now() + CACHE_TTL_MS, value }) +} + +function buildMessages(s: CartSignals) { + const payload = { + domain: s.domain, + title: s.title || '', + meta_description: s.meta_description || '', + site_name: s.og_site_name || '', + cart_items: (s.cart_items || []).slice(0, 6), + } + const allowed = CATEGORY_ENUM.join(', ') + return [ + { + role: 'system' as const, + content: + 'You classify e-commerce carts into a category so the app can show relevant coupons. ' + + 'You get a compact JSON snapshot of the shop and cart. ' + + `Return JSON ONLY, schema: {"primary":"","secondary":"","confidence":0..1}. ` + + `Allowed categories: ${allowed}. ` + + `Use "other" only when nothing fits. Use secondary when two categories are plausibly valid (e.g. a gift store with both jewelry and apparel).`, + }, + { + role: 'user' as const, + content: JSON.stringify(payload), + }, + ] +} + +function parseResponse(raw: string): Omit { + let parsed: unknown + try { + parsed = JSON.parse(raw) + } catch { + const m = raw.match(/\{[\s\S]*\}/) + if (!m) throw new Error('llm returned non-json') + parsed = JSON.parse(m[0]) + } + const obj = parsed as { + primary?: string + secondary?: string | null + confidence?: number + } + const primary = (obj.primary || '').trim() + if (!(CATEGORY_ENUM as readonly string[]).includes(primary)) { + throw new Error(`unknown primary category: ${primary}`) + } + const secondary = obj.secondary ? String(obj.secondary).trim() : undefined + const validSecondary = + secondary && + secondary !== primary && + (CATEGORY_ENUM as readonly string[]).includes(secondary) + ? (secondary as Category) + : undefined + const confidence = + typeof obj.confidence === 'number' && + obj.confidence >= 0 && + obj.confidence <= 1 + ? obj.confidence + : 0.5 + return { + primary: primary as Category, + secondary: validSecondary, + confidence, + } +} + +export async function classifyCart( + signals: CartSignals, +): Promise { + const key = cacheKey(signals) + const hit = cacheGet(key) + if (hit) return { ...hit, cached: true } + + const messages = buildMessages(signals) + let raw: string + try { + raw = await chat(messages, { + responseFormat: 'json_object', + maxTokens: 120, + temperature: 0, + timeoutMs: 7000, + }) + } catch (e) { + if (e instanceof OpenRouterError) throw e + throw new OpenRouterError(`classify failed: ${(e as Error).message}`) + } + const value = parseResponse(raw) + cacheSet(key, value) + return { ...value, cached: false } +} diff --git a/apps/caramel-app/src/lib/openrouter.ts b/apps/caramel-app/src/lib/openrouter.ts new file mode 100644 index 0000000..7ff3c10 --- /dev/null +++ b/apps/caramel-app/src/lib/openrouter.ts @@ -0,0 +1,74 @@ +const OPENROUTER_URL = 'https://openrouter.ai/api/v1/chat/completions' +const DEFAULT_MODEL = process.env.OPENROUTER_MODEL || 'openai/gpt-5-mini' + +export interface ChatMessage { + role: 'system' | 'user' | 'assistant' + content: string +} + +export interface ChatOptions { + model?: string + temperature?: number + maxTokens?: number + responseFormat?: 'json_object' | 'text' + timeoutMs?: number +} + +export class OpenRouterError extends Error { + status?: number + constructor(message: string, status?: number) { + super(message) + this.name = 'OpenRouterError' + this.status = status + } +} + +export async function chat( + messages: ChatMessage[], + opts: ChatOptions = {}, +): Promise { + const key = process.env.OPENROUTER_API_KEY + if (!key) throw new OpenRouterError('OPENROUTER_API_KEY not set') + + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), opts.timeoutMs ?? 8000) + try { + const body: Record = { + model: opts.model || DEFAULT_MODEL, + messages, + temperature: opts.temperature ?? 0, + max_tokens: opts.maxTokens ?? 200, + } + if (opts.responseFormat === 'json_object') { + body.response_format = { type: 'json_object' } + } + + const res = await fetch(OPENROUTER_URL, { + method: 'POST', + signal: controller.signal, + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${key}`, + 'HTTP-Referer': 'https://caramel.app', + 'X-Title': 'Caramel Extension', + }, + body: JSON.stringify(body), + }) + if (!res.ok) { + const detail = await res.text().catch(() => '') + throw new OpenRouterError( + `openrouter ${res.status}: ${detail.slice(0, 200)}`, + res.status, + ) + } + const json = (await res.json()) as { + choices?: { message?: { content?: string } }[] + } + const content = json?.choices?.[0]?.message?.content + if (typeof content !== 'string') + throw new OpenRouterError('empty response') + return content + } finally { + clearTimeout(timeout) + } +} diff --git a/apps/caramel-extension/background.js b/apps/caramel-extension/background.js index 0328c34..420d27c 100644 --- a/apps/caramel-extension/background.js +++ b/apps/caramel-extension/background.js @@ -4,6 +4,10 @@ const currentBrowser = (() => { throw new Error('Browser is not supported!') })() +const CARAMEL_BASE_URL = 'https://grabcaramel.com' +const EXTENSION_API_KEY = 'WXqEpm2uOV5jjJXPpnQFyZiNdaPVUrtd2LIrf4kc1JA' +const caramelUrl = path => new URL(path, `${CARAMEL_BASE_URL}/`).toString() + function isServiceWorkerContext() { return ( typeof ServiceWorkerGlobalScope !== 'undefined' && @@ -187,27 +191,66 @@ currentBrowser.runtime.onMessage.addListener( }) }) + return true + } else if (message.action === 'classifyCart') { + fetch(caramelUrl('api/classify-cart'), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(message.signals || {}), + }) + .then(async r => { + if (!r.ok) return { error: `HTTP ${r.status}` } + return r.json() + }) + .then(resp => sendResponse(resp)) + .catch(err => { + console.error('classifyCart error', err) + sendResponse({ error: String(err) }) + }) + return true } else if (message.action === 'fetchCoupons') { - const { site, kw } = message - const url = `https://grabcaramel.com/api/coupons?site=${site}&key_words=${encodeURIComponent( - kw || '', - )}&limit=20` + const { site, kw, category } = message + const url = new URL(caramelUrl('api/coupons')) + url.searchParams.set('site', site) + url.searchParams.set('key_words', kw || '') + url.searchParams.set('limit', '20') + if (category) url.searchParams.set('category', category) console.log('BACKGROUND: fetchCoupons', { site, kw, - url, + url: url.toString(), t: Date.now(), }) - fetch(url) + fetch(url.toString()) .then(async r => { if (!r.ok) return { coupons: [] } const json = await r.json() - return { coupons: json } + return { + coupons: Array.isArray(json) + ? json + : json.coupons || [], + } }) .then(resp => sendResponse(resp)) .catch(err => sendResponse({ coupons: [], error: String(err) })) + return true + } else if (message.action === 'fetchSupportedStores') { + const url = caramelUrl('api/extension/supported-stores') + fetch(url, { + headers: { 'x-api-key': EXTENSION_API_KEY }, + }) + .then(async r => { + if (!r.ok) return { supported: [] } + return r.json() + }) + .then(resp => sendResponse(resp)) + .catch(err => { + console.error('fetchSupportedStores error', err) + sendResponse({ supported: [], error: String(err) }) + }) + return true } else if (message.action === 'getActiveTabDomainRecord') { currentBrowser.tabs.query( diff --git a/apps/caramel-extension/cart-signals.js b/apps/caramel-extension/cart-signals.js new file mode 100644 index 0000000..e5a1e27 --- /dev/null +++ b/apps/caramel-extension/cart-signals.js @@ -0,0 +1,189 @@ +;(function () { + 'use strict' + + function text(el) { + if (!el) return '' + return (el.textContent || '').replace(/\s+/g, ' ').trim() + } + + function attr(sel, name) { + const el = document.querySelector(sel) + return el ? (el.getAttribute(name) || '').trim() : '' + } + + function metaContent(name) { + const byName = document.querySelector(`meta[name="${name}" i]`) + if (byName) return (byName.getAttribute('content') || '').trim() + const byProp = document.querySelector(`meta[property="${name}" i]`) + return byProp ? (byProp.getAttribute('content') || '').trim() : '' + } + + function baseDomain() { + const parts = location.hostname.split('.').filter(Boolean) + if (parts.length <= 2) return parts.join('.') + return parts.slice(-2).join('.') + } + + function titleTag() { + return text(document.querySelector('title')).slice(0, 200) + } + + const CART_ITEM_SELECTORS = [ + '[class*="line-item"]', + '[class*="cart-item"]', + '[class*="cart__item"]', + '[class*="CartItem"]', + '[class*="bag-item"]', + '[class*="bagItem"]', + '[class*="basket-item"]', + '[class*="order-item"]', + '[class*="product-line"]', + '[class*="productLine"]', + '[data-line-item]', + '[data-cart-item]', + '[itemtype*="Product"]', + ] + + const ITEM_TITLE_SELECTORS = [ + '[class*="title"]', + '[class*="name"]', + '[itemprop="name"]', + '[data-product-title]', + 'a[href*="/product"]', + 'a[href*="/products/"]', + 'a[href*="/p/"]', + 'h1, h2, h3, h4', + ] + + function extractCartItems(limit = 6) { + const seen = new Set() + const out = [] + for (const sel of CART_ITEM_SELECTORS) { + const rows = document.querySelectorAll(sel) + for (const row of rows) { + if (out.length >= limit) break + let title = '' + for (const tsel of ITEM_TITLE_SELECTORS) { + const t = row.querySelector(tsel) + if (t) { + title = text(t) + if (title.length > 3) break + } + } + if (!title) title = text(row).slice(0, 140) + if (title && !seen.has(title) && title.length >= 3) { + seen.add(title) + out.push(title.slice(0, 140)) + } + } + if (out.length >= limit) break + } + return out + } + + function extractJsonLdProductNames(limit = 6) { + const out = [] + const scripts = document.querySelectorAll( + 'script[type="application/ld+json"]', + ) + for (const s of scripts) { + let data + try { + data = JSON.parse(s.textContent || '') + } catch (_) { + continue + } + const queue = Array.isArray(data) ? [...data] : [data] + while (queue.length) { + const node = queue.shift() + if (!node || typeof node !== 'object') continue + const t = node['@type'] + const isProduct = + t === 'Product' || + (Array.isArray(t) && t.includes('Product')) + if (isProduct && node.name && typeof node.name === 'string') { + out.push(node.name.slice(0, 140)) + if (out.length >= limit) return out + } + if (node.hasOfferCatalog) queue.push(node.hasOfferCatalog) + if (Array.isArray(node.itemListElement)) + queue.push(...node.itemListElement) + } + } + return out + } + + async function tryShopifyCart() { + if ( + !/Shopify/i.test(document.documentElement.innerHTML) && + !document.querySelector('script[src*="shopify"]') + ) + return null + try { + const r = await fetch('/cart.js', { credentials: 'include' }) + if (!r.ok) return null + const j = await r.json() + if (!Array.isArray(j.items)) return null + return j.items + .slice(0, 6) + .map(i => + String(i.product_title || i.title || '').slice(0, 140), + ) + .filter(Boolean) + } catch (_) { + return null + } + } + + async function collectCartSignals() { + const shopifyItems = await tryShopifyCart() + const domItems = + shopifyItems && shopifyItems.length + ? shopifyItems + : extractCartItems() + const jsonLdItems = extractJsonLdProductNames() + + const payload = { + domain: baseDomain(), + url_path: location.pathname.slice(0, 120), + title: titleTag(), + meta_description: ( + metaContent('description') || metaContent('og:description') + ).slice(0, 300), + og_site_name: metaContent('og:site_name').slice(0, 80), + og_type: metaContent('og:type').slice(0, 40), + cart_items: (domItems.length ? domItems : jsonLdItems).slice(0, 6), + platform_hints: { + shopify: !!( + window.Shopify || + document.querySelector('script[src*="shopify"]') + ), + woocommerce: !!document.querySelector('[class*="woocommerce"]'), + bigcommerce: !!( + window.BCData || + document.querySelector('[class*="bc-cart"]') + ), + magento: !!( + document.querySelector('[data-mage-init]') || + /Magento/i.test(document.documentElement.innerHTML) + ), + }, + } + return payload + } + + if (typeof module !== 'undefined' && module.exports) { + module.exports = { + collectCartSignals, + extractCartItems, + extractJsonLdProductNames, + } + } + if (typeof window !== 'undefined') { + window.CaramelCartSignals = { + collectCartSignals, + extractCartItems, + extractJsonLdProductNames, + } + } +})() diff --git a/apps/caramel-extension/manifest.json b/apps/caramel-extension/manifest.json index 816ad59..0bede3b 100644 --- a/apps/caramel-extension/manifest.json +++ b/apps/caramel-extension/manifest.json @@ -38,7 +38,13 @@ "https://*.ebay.com/*", "https://*.codecademy.com/*" ], - "js": ["shared-utils.js", "UI-helpers.js", "inject.js", "amazon.js"] + "js": [ + "cart-signals.js", + "shared-utils.js", + "UI-helpers.js", + "inject.js", + "amazon.js" + ] } ], "background": { diff --git a/apps/caramel-extension/popup.js b/apps/caramel-extension/popup.js index eaed505..a8ed72a 100644 --- a/apps/caramel-extension/popup.js +++ b/apps/caramel-extension/popup.js @@ -1,5 +1,8 @@ /* global currentBrowser, fetchCoupons */ +const CARAMEL_BASE_URL = 'https://grabcaramel.com' +const caramelUrl = path => new URL(path, `${CARAMEL_BASE_URL}/`).toString() + /* ------------------------------------------------------------ */ /* Globals */ /* ------------------------------------------------------------ */ @@ -73,7 +76,7 @@ function renderUnsupportedSite(user) {
Don't have an account? Sign Up @@ -407,14 +409,11 @@ function renderSignInPrompt(backFn) { const email = document.getElementById('email').value.trim() const password = document.getElementById('password').value - const res = await fetch( - 'https://grabcaramel.com/api/extension/login', - { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ email, password }), - }, - ) + const res = await fetch(caramelUrl('api/extension/login'), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email, password }), + }) if (!res.ok) { const data = await res.json().catch(() => ({})) @@ -470,7 +469,7 @@ function renderProfileCard(user) { if (settingsIcon) { settingsIcon.style.display = 'block' settingsIcon.onclick = () => - window.open('https://grabcaramel.com/profile', '_blank') + window.open(caramelUrl('profile'), '_blank') } document.getElementById('logoutBtn').addEventListener('click', () => { @@ -514,7 +513,7 @@ function renderCouponsView(coupons, user, domain) {
${ coupons.length === 0 - ? '

No coupons found for this site

' + ? '

No coupons available for this store right now.

' : coupons .map( c => ` diff --git a/apps/caramel-extension/shared-utils.js b/apps/caramel-extension/shared-utils.js index 1e49e7b..e89eddc 100644 --- a/apps/caramel-extension/shared-utils.js +++ b/apps/caramel-extension/shared-utils.js @@ -66,6 +66,12 @@ if (typeof recordTiming === 'undefined') { } } +const CARAMEL_ALLOWED_ORIGINS = new Set([ + 'http://localhost:58300', + 'https://grabcaramel.com', + 'https://dev.grabcaramel.com', +]) + /* ---------- DOM waiters ---------- */ function waitForElement(sel, timeout = 4000) { return new Promise((res, rej) => { @@ -181,16 +187,69 @@ function getPrice(selector, { returnLargest } = {}) { } /* -------------------------------------------------- config cache */ +const STORE_CACHE_KEY = 'caramel_supported_stores' +const STORE_CACHE_TTL = 60 * 60 * 1000 // 1 hour + async function getDomainRecord(domain) { if (!getDomainRecord.cache) { - const r = await fetch(currentBrowser.runtime.getURL('supported.json')) - const dat = await r.json() - getDomainRecord.cache = Array.isArray(dat.supported) - ? dat.supported - : dat - log('Loaded supported domains') + // Check chrome.storage.local for a recent cached copy first + try { + const stored = await new Promise(r => + currentBrowser.storage.local.get([STORE_CACHE_KEY], r), + ) + const entry = stored?.[STORE_CACHE_KEY] + if ( + entry?.data?.length && + Date.now() - entry.ts < STORE_CACHE_TTL + ) { + getDomainRecord.cache = entry.data + log('Loaded supported domains from local cache') + } + } catch (_) { + /* storage read failed, proceed to API */ + } + + // Fetch fresh configs from the backend + if (!getDomainRecord.cache) { + try { + const resp = await new Promise(res => + currentBrowser.runtime.sendMessage( + { action: 'fetchSupportedStores' }, + res, + ), + ) + if (resp?.supported?.length) { + getDomainRecord.cache = resp.supported + currentBrowser.storage.local.set({ + [STORE_CACHE_KEY]: { + data: resp.supported, + ts: Date.now(), + }, + }) + log('Loaded supported domains from API') + } + } catch (e) { + log('fetchSupportedStores error', e) + } + } + + // If API failed, try expired cache as last resort + if (!getDomainRecord.cache) { + try { + const stored = await new Promise(r => + currentBrowser.storage.local.get([STORE_CACHE_KEY], r), + ) + const entry = stored?.[STORE_CACHE_KEY] + if (entry?.data?.length) { + getDomainRecord.cache = entry.data + log('Loaded supported domains from expired cache') + } + } catch (_) { + /* nothing we can do */ + } + } } - return getDomainRecord.cache.find(r => domain.includes(r.domain)) + return getDomainRecord.cache?.find(r => domain.includes(r.domain)) } getDomainRecord.cache = null @@ -312,9 +371,9 @@ async function applyCoupon(code, rec) { } /* -------------------------------------------------- coupon list */ -async function fetchCoupons(site, kw) { +async function fetchCoupons(site, kw, category) { // Delegate network fetch to background/service worker to avoid CORS failures - const meta = { site, kw } + const meta = { site, kw, category } try { log( 'AUTO_INSERT_FETCHCOUPONS_START', @@ -323,7 +382,7 @@ async function fetchCoupons(site, kw) { recordTiming('AUTO_INSERT_FETCHCOUPONS_START', meta) const resp = await new Promise(res => currentBrowser.runtime.sendMessage( - { action: 'fetchCoupons', site, kw }, + { action: 'fetchCoupons', site, kw, category }, res, ), ) @@ -354,6 +413,7 @@ async function fetchCoupons(site, kw) { } async function getCoupons(rec) { let kw = '' + let category = '' if (rec.domain === 'amazon.com') { // Use fast in-page scrape (or same-origin cart fetch) instead of opening a new tab recordTiming('AUTO_INSERT_AMAZON_SCRAPE_REQUEST') @@ -364,12 +424,37 @@ async function getCoupons(rec) { kw = (titles || []).join(',') log('Amazon keywords', kw) } + // Classify cart category for relevant coupon selection + try { + const cs = window.CaramelCartSignals + if (cs && typeof cs.collectCartSignals === 'function') { + const signals = await cs.collectCartSignals() + const result = await new Promise(res => + currentBrowser.runtime.sendMessage( + { action: 'classifyCart', signals }, + res, + ), + ) + if (result && result.primary && !result.error) { + category = result.primary + log( + 'Cart category:', + category, + '(conf:', + result.confidence, + ')', + ) + } + } + } catch (e) { + log('classifyCart non-fatal error', e) + } // Dev hook: deterministic coupons when using #caramel-test if (location.hash && location.hash.includes('caramel-test')) { log('DEV MODE: returning mocked coupons') return [{ code: 'TEST10' }, { code: 'TEST20' }, { code: 'TEST30' }] } - return fetchCoupons(rec.domain, kw) + return fetchCoupons(rec.domain, kw, category) } /* -------------------------------------------------- main runner */ @@ -461,7 +546,7 @@ async function startApplyingCoupons(rec) { /* -------------------------------------------------- listeners (unchanged) */ window.addEventListener('message', ev => { - if (ev.origin !== 'https://grabcaramel.com') return + if (!CARAMEL_ALLOWED_ORIGINS.has(ev.origin)) return if (ev.data?.token) { currentBrowser.storage.sync.set( { diff --git a/tailwind.config.js b/tailwind.config.js new file mode 100644 index 0000000..06d5564 --- /dev/null +++ b/tailwind.config.js @@ -0,0 +1,2 @@ +/** @type {import('tailwindcss').Config} */ +module.exports = { content: [] } From 0933970b7c64039ec71a12650d6086d85fc8591b Mon Sep 17 00:00:00 2001 From: Alaeddin Date: Sat, 18 Apr 2026 05:26:55 +0100 Subject: [PATCH 007/198] feat(extension): dev-mode auto-localhost + playwright e2e smoke test --- apps/caramel-extension/background.js | 15 +- apps/caramel-extension/manifest-firefox.json | 10 +- apps/caramel-extension/manifest.json | 10 +- apps/caramel-extension/package.json | 4 +- apps/caramel-extension/popup.js | 18 +- .../scripts/test-cart-signals.mjs | 146 ++++++++ .../scripts/test-extension.mjs | 308 ++++++++++++++++ package.json | 1 + pnpm-lock.yaml | 335 +++++++++++++++++- 9 files changed, 836 insertions(+), 11 deletions(-) create mode 100644 apps/caramel-extension/scripts/test-cart-signals.mjs create mode 100644 apps/caramel-extension/scripts/test-extension.mjs diff --git a/apps/caramel-extension/background.js b/apps/caramel-extension/background.js index 420d27c..4d92a29 100644 --- a/apps/caramel-extension/background.js +++ b/apps/caramel-extension/background.js @@ -4,9 +4,20 @@ const currentBrowser = (() => { throw new Error('Browser is not supported!') })() -const CARAMEL_BASE_URL = 'https://grabcaramel.com' +globalThis.CARAMEL_BASE_URL = 'https://grabcaramel.com' const EXTENSION_API_KEY = 'WXqEpm2uOV5jjJXPpnQFyZiNdaPVUrtd2LIrf4kc1JA' -const caramelUrl = path => new URL(path, `${CARAMEL_BASE_URL}/`).toString() +const caramelUrl = path => + new URL(path, `${globalThis.CARAMEL_BASE_URL}/`).toString() + +// Auto-switch to localhost when loaded as unpacked dev extension +if (typeof chrome !== 'undefined' && chrome.management) { + chrome.management.getSelf(info => { + if (info?.installType === 'development') { + globalThis.CARAMEL_BASE_URL = 'http://localhost:58000' + console.log('[caramel] DEV MODE: API → localhost:58000') + } + }) +} function isServiceWorkerContext() { return ( diff --git a/apps/caramel-extension/manifest-firefox.json b/apps/caramel-extension/manifest-firefox.json index 82a0b33..9996390 100644 --- a/apps/caramel-extension/manifest-firefox.json +++ b/apps/caramel-extension/manifest-firefox.json @@ -16,12 +16,20 @@ "192": "/icons/192.png", "512": "/icons/512.png" }, - "permissions": ["tabs", "activeTab", "storage", "scripting", "alarms"], + "permissions": [ + "tabs", + "activeTab", + "storage", + "scripting", + "alarms", + "management" + ], "host_permissions": [ "https://www.amazon.com/*", "https://*.ebay.com/*", "https://*.codecademy.com/*", "https://*.grabcaramel.com/*", + "http://localhost:58000/*", "https://accounts.google.com/*", "https://appleid.apple.com/*" ], diff --git a/apps/caramel-extension/manifest.json b/apps/caramel-extension/manifest.json index 0bede3b..5f94316 100644 --- a/apps/caramel-extension/manifest.json +++ b/apps/caramel-extension/manifest.json @@ -16,12 +16,20 @@ "192": "/icons/192.png", "512": "/icons/512.png" }, - "permissions": ["tabs", "activeTab", "storage", "scripting", "identity"], + "permissions": [ + "tabs", + "activeTab", + "storage", + "scripting", + "identity", + "management" + ], "host_permissions": [ "https://www.amazon.com/*", "https://*.ebay.com/*", "https://*.codecademy.com/*", "https://*.grabcaramel.com/*", + "http://localhost:58000/*", "https://accounts.google.com/*", "https://appleid.apple.com/*" ], diff --git a/apps/caramel-extension/package.json b/apps/caramel-extension/package.json index bdc9fb0..e2a5191 100644 --- a/apps/caramel-extension/package.json +++ b/apps/caramel-extension/package.json @@ -9,13 +9,15 @@ "prettier-write": "prettier --write '**/*.{js,html,css,json,md}'", "build": "rm -rf dist && mkdir -p dist && rsync -a --exclude='node_modules' --exclude='dist' --exclude='.git' --exclude='*.lock' --exclude='apple-extension' ./ dist/", "package": "zip -r extension.zip ./dist", - "dev": "web-ext run --target=chromium --source-dir=. --watch-file=./**/*" + "dev": "web-ext run --target=chromium --source-dir=. --watch-file=./**/*", + "test:e2e": "node scripts/test-extension.mjs" }, "devDependencies": { "eslint-config-prettier": "^10.1.2", "eslint-plugin-html": "^8.1.2", "eslint-plugin-import": "^2.31.0", "eslint-plugin-prettier": "^5.2.6", + "jsdom": "^29.0.2", "prettier": "^3.5.3", "prettier-plugin-organize-imports": "^4.1.0" }, diff --git a/apps/caramel-extension/popup.js b/apps/caramel-extension/popup.js index a8ed72a..10eb25d 100644 --- a/apps/caramel-extension/popup.js +++ b/apps/caramel-extension/popup.js @@ -1,8 +1,22 @@ /* global currentBrowser, fetchCoupons */ -const CARAMEL_BASE_URL = 'https://grabcaramel.com' +let CARAMEL_BASE_URL = 'https://grabcaramel.com' const caramelUrl = path => new URL(path, `${CARAMEL_BASE_URL}/`).toString() +async function _detectDevMode() { + return new Promise(resolve => { + if (typeof chrome === 'undefined' || !chrome.management) + return resolve() + chrome.management.getSelf(info => { + if (info?.installType === 'development') { + CARAMEL_BASE_URL = 'http://localhost:58000' + console.log('[caramel] DEV MODE: API → localhost:58000') + } + resolve() + }) + }) +} + /* ------------------------------------------------------------ */ /* Globals */ /* ------------------------------------------------------------ */ @@ -12,6 +26,8 @@ let returnView = null // callback for the “Back” button, set dynamically /* Bootstrap */ /* ------------------------------------------------------------ */ document.addEventListener('DOMContentLoaded', async () => { + await _detectDevMode() + const loader = document.getElementById('loading-container') if (loader) setTimeout(() => (loader.style.display = 'none'), 400) diff --git a/apps/caramel-extension/scripts/test-cart-signals.mjs b/apps/caramel-extension/scripts/test-cart-signals.mjs new file mode 100644 index 0000000..5de882f --- /dev/null +++ b/apps/caramel-extension/scripts/test-cart-signals.mjs @@ -0,0 +1,146 @@ +#!/usr/bin/env node +/** + * Runs cart-signals.js against the bot's cached debug HTML snapshots and + * prints the extracted payloads. Usage: + * node scripts/test-cart-signals.mjs [debug_root] [limit] + */ +import { JSDOM } from 'jsdom' +import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs' +import { createRequire } from 'node:module' +import { join, resolve } from 'node:path' + +const require = createRequire(import.meta.url) + +const DEBUG_ROOT = resolve( + process.argv[2] || 'c:/Users/alaed/Documents/Github/caramel-coupons/debug', +) +const LIMIT = parseInt(process.argv[3] || '20', 10) + +const PAGE_PRIORITY = [ + '01_known_page_cart.html', + '01_known_page_basket.html', + '01_known_page_bag.html', + '01_known_page_checkout.html', + '02_product_flow_homepage.html', +] + +function pickSnapshot(storeDir) { + for (const name of PAGE_PRIORITY) { + const p = join(storeDir, name) + if (existsSync(p) && statSync(p).size > 500) return p + } + // Fallback: any non-empty html file + const files = readdirSync(storeDir).filter(f => f.endsWith('.html')) + for (const f of files) { + const p = join(storeDir, f) + if (statSync(p).size > 500) return p + } + return null +} + +async function runStore(domain) { + const storeDir = join(DEBUG_ROOT, domain) + if (!existsSync(storeDir) || !statSync(storeDir).isDirectory()) return null + const snapshot = pickSnapshot(storeDir) + if (!snapshot) return { domain, error: 'no_snapshot' } + const html = readFileSync(snapshot, 'utf8') + const dom = new JSDOM(html, { + url: `https://www.${domain}/`, + runScripts: 'outside-only', + }) + const { window } = dom + window.Shopify = window.Shopify || undefined // extractor probes window only + // Inject the module into this window + const extractorSrc = readFileSync(resolve('./cart-signals.js'), 'utf8') + window.eval(extractorSrc) + // Stub fetch — we don't want to hit networks during the test + window.fetch = () => Promise.reject(new Error('fetch disabled in test')) + + const api = window.CaramelCartSignals + if (!api) return { domain, error: 'extractor_not_loaded', snapshot } + try { + const payload = await api.collectCartSignals() + return { domain, snapshot: snapshot.split(/[\\/]/).pop(), ...payload } + } catch (e) { + return { + domain, + snapshot: snapshot.split(/[\\/]/).pop(), + error: String((e && e.message) || e), + } + } +} + +async function main() { + if (!existsSync(DEBUG_ROOT)) { + console.error(`debug root missing: ${DEBUG_ROOT}`) + process.exit(1) + } + const all = readdirSync(DEBUG_ROOT).filter(n => !n.startsWith('.')) + const targets = all.slice(0, LIMIT) + console.error(`testing ${targets.length} stores from ${DEBUG_ROOT}`) + const results = [] + for (const d of targets) { + const r = await runStore(d) + if (r) results.push(r) + } + + // Compact report + const header = [ + 'domain', + 'snapshot', + 'title_len', + 'desc_len', + 'cart_items', + 'ld_hits', + 'platform', + ] + console.log(header.join('\t')) + let hasTitle = 0, + hasDesc = 0, + hasItems = 0 + for (const r of results) { + if (r.error) { + console.log( + [r.domain, r.snapshot || '-', 'err:', r.error].join('\t'), + ) + continue + } + const platform = + Object.entries(r.platform_hints) + .filter(([, v]) => v) + .map(([k]) => k) + .join(',') || 'custom' + const titleLen = (r.title || '').length + const descLen = (r.meta_description || '').length + const itemN = (r.cart_items || []).length + if (titleLen) hasTitle++ + if (descLen) hasDesc++ + if (itemN) hasItems++ + console.log( + [ + r.domain, + r.snapshot, + titleLen, + descLen, + itemN, + r.cart_items.length, + platform, + ].join('\t'), + ) + } + console.error( + `\nCoverage across ${results.length}: title=${hasTitle}, desc=${hasDesc}, cart_items=${hasItems}`, + ) + + // Dump 5 full payloads + console.error('\n--- Sample payloads (5) ---') + for (const r of results.slice(0, 5)) { + console.error(JSON.stringify(r, null, 2)) + console.error('---') + } +} + +main().catch(e => { + console.error(e) + process.exit(1) +}) diff --git a/apps/caramel-extension/scripts/test-extension.mjs b/apps/caramel-extension/scripts/test-extension.mjs new file mode 100644 index 0000000..45e96a8 --- /dev/null +++ b/apps/caramel-extension/scripts/test-extension.mjs @@ -0,0 +1,308 @@ +#!/usr/bin/env node +/** + * Automated extension test suite. + * + * Launches Chromium with the unpacked extension loaded, checks: + * 1. Background service worker boots and detects dev mode → localhost:58000 + * 2. Extension login via /api/extension/login succeeds + * 3. /api/extension/supported-stores returns XPath-configured stores + * 4. Supported store sample has valid selectors + * 5. Coupons endpoint reachable + * 6. Popup UI: fills the login form and verifies logged-in state + * 7. Injection logic: loads shared-utils.js against a fake store DOM and + * verifies applyCoupon() finds the input, fills the code, and clicks apply. + * + * Prereqs: + * - caramel-app dev server running on localhost:58000 (pnpm dev) + * - Test user test@caramel.dev / test1234 exists + * + * Run: pnpm -C apps/caramel-extension test:e2e + */ + +import { readFileSync } from 'node:fs' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { chromium } from 'playwright' + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) +const EXT_PATH = path.resolve(__dirname, '..') +const API_BASE = 'http://localhost:58000' +const TEST_EMAIL = 'test@caramel.dev' +const TEST_PASSWORD = 'test1234' +const EXTENSION_API_KEY = 'WXqEpm2uOV5jjJXPpnQFyZiNdaPVUrtd2LIrf4kc1JA' + +const results = [] +function log(step, ok, detail = '') { + const icon = ok ? '✓' : '✗' + console.log(`${icon} ${step}${detail ? ' — ' + detail : ''}`) + results.push({ step, ok, detail }) +} + +async function waitForServiceWorker(context, timeout = 15000) { + const start = Date.now() + while (Date.now() - start < timeout) { + const sw = context.serviceWorkers()[0] + if (sw) return sw + await new Promise(r => setTimeout(r, 200)) + } + return null +} + +async function main() { + console.log(`[test] extension path: ${EXT_PATH}`) + console.log(`[test] api base: ${API_BASE}`) + + const userDataDir = path.join( + process.env.TEMP || '/tmp', + `caramel-ext-test-${Date.now()}`, + ) + const context = await chromium.launchPersistentContext(userDataDir, { + headless: false, + channel: 'chromium', + args: [ + `--disable-extensions-except=${EXT_PATH}`, + `--load-extension=${EXT_PATH}`, + '--no-first-run', + ], + }) + + try { + // 1. Service worker boots + const sw = await waitForServiceWorker(context) + log('service worker booted', !!sw, sw ? sw.url() : 'timeout') + if (!sw) throw new Error('Background service worker never started') + + const extensionId = new URL(sw.url()).host + + // 2. Dev-mode URL switch + await new Promise(r => setTimeout(r, 1500)) + const baseUrl = await sw.evaluate(() => globalThis.CARAMEL_BASE_URL) + log( + 'dev-mode URL switch', + baseUrl === API_BASE, + `CARAMEL_BASE_URL=${baseUrl}`, + ) + + // 3. Direct API login + const loginRes = await sw.evaluate( + async ({ url, email, password }) => { + const r = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email, password }), + }) + return { + status: r.status, + body: await r.json().catch(() => null), + } + }, + { + url: `${API_BASE}/api/extension/login`, + email: TEST_EMAIL, + password: TEST_PASSWORD, + }, + ) + log( + 'extension login API', + loginRes.status === 200 && loginRes.body?.token, + `status=${loginRes.status} user=${loginRes.body?.username || 'n/a'}`, + ) + + // 4. Supported stores + const storesRes = await sw.evaluate( + async ({ url, key }) => { + const r = await fetch(url, { headers: { 'x-api-key': key } }) + return { + status: r.status, + body: await r.json().catch(() => null), + } + }, + { + url: `${API_BASE}/api/extension/supported-stores`, + key: EXTENSION_API_KEY, + }, + ) + const storeCount = storesRes.body?.supported?.length ?? 0 + log( + 'supported-stores endpoint', + storesRes.status === 200 && storeCount > 0, + `status=${storesRes.status} count=${storeCount}`, + ) + + // 5. Sample has XPath + const sample = storesRes.body?.supported?.[0] + log( + 'sample store has XPath', + !!(sample?.couponInput && sample?.couponSubmit), + sample ? `${sample.domain}` : 'no sample', + ) + + // 6. Coupons endpoint + const couponsRes = await sw.evaluate( + async ({ url, key }) => { + const r = await fetch(url, { headers: { 'x-api-key': key } }) + return { + status: r.status, + body: await r.json().catch(() => null), + } + }, + { + url: `${API_BASE}/api/coupons?site=${encodeURIComponent(sample?.domain || 'allbirds.com')}`, + key: EXTENSION_API_KEY, + }, + ) + log( + 'coupons endpoint', + couponsRes.status === 200, + `status=${couponsRes.status}`, + ) + + // 7. POPUP UI LOGIN FLOW + { + // Clear storage.sync so popup starts logged out (popup.js uses storage.sync) + await sw.evaluate( + () => + new Promise(res => + chrome.storage.sync.remove(['token', 'user'], res), + ), + ) + + const popup = await context.newPage() + await popup.goto(`chrome-extension://${extensionId}/index.html`) + await popup.waitForLoadState('domcontentloaded') + await popup.waitForTimeout(800) + + const hasLoginToggle = + (await popup.locator('#loginToggleBtn').count()) > 0 + if (hasLoginToggle) await popup.locator('#loginToggleBtn').click() + + await popup.waitForSelector('#email', { timeout: 5000 }) + await popup.fill('#email', TEST_EMAIL) + await popup.fill('#password', TEST_PASSWORD) + await popup.locator('#loginForm button[type="submit"]').click() + + let uiLoggedIn = false + try { + await popup.waitForSelector('#logoutBtn', { timeout: 10000 }) + uiLoggedIn = true + } catch { + /* fall through */ + } + + // Give storage.sync a moment to flush + await popup.waitForTimeout(500) + const stored = await sw.evaluate( + () => + new Promise(res => + chrome.storage.sync.get(['token', 'user'], res), + ), + ) + log( + 'popup UI login', + uiLoggedIn && !!stored.token, + `ui=${uiLoggedIn} token=${stored.token ? 'set' : 'missing'} user=${stored.user?.username || 'n/a'}`, + ) + + await popup.close() + } + + // 8. DOM INJECTION — real applyCoupon() against a synthetic supported-store DOM + { + const sharedUtilsSrc = readFileSync( + path.join(EXT_PATH, 'shared-utils.js'), + 'utf8', + ) + + const page = await context.newPage() + // about:blank has no CSP so we can freely inject via page.evaluate + await page.goto('about:blank') + await page.setContent(` +
$100.00
+ + + `) + + // Wire click handler and load shared-utils.js via evaluate (bypasses CSP) + await page.evaluate(utilsSrc => { + window.__clickLog = [] + document + .getElementById('apply-btn') + .addEventListener('click', () => { + window.__clickLog.push({ + code: document.getElementById('coupon-field').value, + }) + document.getElementById('total').textContent = '$90.00' + }) + // Stub chrome APIs that shared-utils.js touches at module scope + // Force-overwrite: Chromium provides `chrome` on about:blank but without .runtime + window.chrome = { + runtime: { + id: 'test-stub', + onMessage: { addListener: () => {} }, + sendMessage: (_msg, cb) => { + if (cb) cb({}) + return Promise.resolve({}) + }, + }, + storage: { sync: { get: (_, cb) => cb && cb({}) } }, + } + ;(0, eval)(utilsSrc) + }, sharedUtilsSrc) + + const rec = { + domain: 'test-store.local', + couponInput: '#coupon-field', + couponSubmit: '#apply-btn', + priceContainer: '.price', + } + + const result = await page.evaluate(async rec => { + const out = await applyCoupon('SAVE10', rec) + return { + result: out, + inputValue: document.getElementById('coupon-field').value, + clicks: window.__clickLog, + finalPrice: document.getElementById('total').textContent, + } + }, rec) + + const filledCorrectly = result.inputValue === 'SAVE10' + const clicked = + result.clicks.length === 1 && result.clicks[0].code === 'SAVE10' + log( + 'applyCoupon() fills input', + filledCorrectly, + `value="${result.inputValue}"`, + ) + log( + 'applyCoupon() clicks apply', + clicked, + `clicks=${JSON.stringify(result.clicks)}`, + ) + log( + 'applyCoupon() detects price change', + result.result?.success === true, + `final=${result.finalPrice} newTotal=${result.result?.newTotal}`, + ) + + await page.close() + } + } finally { + await context.close() + } + + const failed = results.filter(r => !r.ok) + console.log( + `\n=== Summary: ${results.length - failed.length}/${results.length} passed ===`, + ) + if (failed.length) { + console.log('FAILED:') + for (const f of failed) console.log(` - ${f.step}: ${f.detail}`) + process.exit(1) + } +} + +main().catch(err => { + console.error('FATAL:', err) + process.exit(1) +}) diff --git a/package.json b/package.json index f4c3768..418fe25 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "eslint-config-prettier": "^10.1.2", "husky": "^9.1.7", "lint-staged": "^16.4.0", + "playwright": "^1.59.1", "prettier": "^3.6.2", "prettier-plugin-organize-imports": "^4.1.0", "prettier-plugin-tailwindcss": "^0.6.14", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 18d54b1..fd2c95b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -23,6 +23,9 @@ importers: lint-staged: specifier: ^16.4.0 version: 16.4.0 + playwright: + specifier: ^1.59.1 + version: 1.59.1 prettier: specifier: ^3.6.2 version: 3.6.2 @@ -232,6 +235,9 @@ importers: eslint-plugin-prettier: specifier: ^5.2.6 version: 5.5.1(@types/eslint@9.6.1)(eslint-config-prettier@10.1.5(eslint@8.57.1))(eslint@8.57.1)(prettier@3.6.2) + jsdom: + specifier: ^29.0.2 + version: 29.0.2(@noble/hashes@2.0.1) prettier: specifier: ^3.5.3 version: 3.6.2 @@ -276,6 +282,21 @@ packages: resolution: {integrity: sha512-/Bn0qCH8VsdPv5WB9TUEf3oTgsIqsTMUEjPVDopHLzKK+j7nQYGOF3MnN7VhBow82BXeStBfJCS3UiZ6cgxRlw==} engines: {node: '>=20.0.0'} + '@asamuzakjp/css-color@5.1.11': + resolution: {integrity: sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + '@asamuzakjp/dom-selector@7.0.10': + resolution: {integrity: sha512-KyOb19eytNSELkmdqzZZUXWCU25byIlOld5qVFg0RYdS0T3tt7jeDByxk9hIAC73frclD8GKrHttr0SUjKCCdQ==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + '@asamuzakjp/generational-cache@1.0.1': + resolution: {integrity: sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + '@asamuzakjp/nwsapi@2.3.9': + resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==} + '@babel/code-frame@7.27.1': resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==} engines: {node: '>=6.9.0'} @@ -401,6 +422,46 @@ packages: '@better-fetch/fetch@1.1.21': resolution: {integrity: sha512-/ImESw0sskqlVR94jB+5+Pxjf+xBwDZF/N5+y2/q4EqD7IARUTSpPfIo8uf39SYpCxyOCtbyYpUrZ3F/k0zT4A==} + '@bramus/specificity@2.4.2': + resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==} + hasBin: true + + '@csstools/color-helpers@6.0.2': + resolution: {integrity: sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==} + engines: {node: '>=20.19.0'} + + '@csstools/css-calc@3.2.0': + resolution: {integrity: sha512-bR9e6o2BDB12jzN/gIbjHa5wLJ4UjD1CB9pM7ehlc0ddk6EBz+yYS1EV2MF55/HUxrHcB/hehAyt5vhsA3hx7w==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-color-parser@4.1.0': + resolution: {integrity: sha512-U0KhLYmy2GVj6q4T3WaAe6NPuFYCPQoE3b0dRGxejWDgcPp8TP7S5rVdM5ZrFaqu4N67X8YaPBw14dQSYx3IyQ==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-parser-algorithms@4.0.0': + resolution: {integrity: sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.1.3': + resolution: {integrity: sha512-SH60bMfrRCJF3morcdk57WklujF4Jr/EsQUzqkarfHXEFcAR1gg7fS/chAE922Sehgzc1/+Tz5H3Ypa1HiEKrg==} + peerDependencies: + css-tree: ^3.2.1 + peerDependenciesMeta: + css-tree: + optional: true + + '@csstools/css-tokenizer@4.0.0': + resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==} + engines: {node: '>=20.19.0'} + '@devicefarmer/adbkit-logcat@2.1.3': resolution: {integrity: sha512-yeaGFjNBc/6+svbDeul1tNHtNChw6h8pSHAt5D+JsedUrMTN7tla7B15WLDyekxsuS2XlZHRxpuC6m92wiwCNw==} engines: {node: '>= 4'} @@ -694,6 +755,15 @@ packages: resolution: {integrity: sha512-Z5kJ+wU3oA7MMIqVR9tyZRtjYPr4OC004Q4Rw7pgOKUOKkJfZ3O24nz3WYfGRpMDNmcOi3TwQOmgm7B7Tpii0w==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@exodus/bytes@1.15.0': + resolution: {integrity: sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + peerDependencies: + '@noble/hashes': ^1.8.0 || ^2.0.0 + peerDependenciesMeta: + '@noble/hashes': + optional: true + '@floating-ui/core@1.7.4': resolution: {integrity: sha512-C3HlIdsBxszvm5McXlB8PeOEWfBhcGBTZGkGlWc2U0KFY5IwG5OQEuQ8rq52DZmcHDlPLd+YFBK+cZcytwIFWg==} @@ -2753,6 +2823,10 @@ packages: css-select@5.2.2: resolution: {integrity: sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==} + css-tree@3.2.1: + resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} + css-what@6.2.2: resolution: {integrity: sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==} engines: {node: '>= 6'} @@ -2812,6 +2886,10 @@ packages: damerau-levenshtein@1.0.8: resolution: {integrity: sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==} + data-urls@7.0.0: + resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + data-view-buffer@1.0.2: resolution: {integrity: sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==} engines: {node: '>= 0.4'} @@ -2877,6 +2955,9 @@ packages: decimal.js-light@2.5.1: resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==} + decimal.js@10.6.0: + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + deep-extend@0.6.0: resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==} engines: {node: '>=4.0.0'} @@ -3609,6 +3690,10 @@ packages: hoist-non-react-statics@3.3.2: resolution: {integrity: sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==} + html-encoding-sniffer@6.0.0: + resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + html-to-text@9.0.5: resolution: {integrity: sha512-qY60FjREgVZL03vJU6IfMV4GDjGBIoOyvuFdpBDIX9yTlDw0TjxVBQp+P8NvpdIXNJvfWBTNul7fsAQJq2FNpg==} engines: {node: '>=14'} @@ -3820,6 +3905,9 @@ packages: resolution: {integrity: sha512-lJJV/5dYS+RcL8uQdBDW9c9uWFLLBNRyFhnAKXw5tVqLlKZ4RMGZKv+YQ/IA3OhD+RpbJa1LLFM1FQPGyIXvOA==} engines: {node: '>=12'} + is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + is-promise@2.2.2: resolution: {integrity: sha512-+lP4/6lKUBfQjZ2pdxThZvLUAafmZb8OAxFb8XXtiQmS35INgr85hdOGoEs124ez1FCnZJt6jau/T+alh58QFQ==} @@ -3938,6 +4026,15 @@ packages: resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} hasBin: true + jsdom@29.0.2: + resolution: {integrity: sha512-9VnGEBosc/ZpwyOsJBCQ/3I5p7Q5ngOY14a9bf5btenAORmZfDse1ZEheMiWcJ3h81+Fv7HmJFdS0szo/waF2w==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24.0.0} + peerDependencies: + canvas: ^3.0.0 + peerDependenciesMeta: + canvas: + optional: true + jsesc@3.1.0: resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} engines: {node: '>=6'} @@ -4119,6 +4216,10 @@ packages: lru-cache@10.4.3: resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + lru-cache@11.3.5: + resolution: {integrity: sha512-NxVFwLAnrd9i7KUBxC4DrUhmgjzOs+1Qm50D3oF1/oL+r1NpZ4gA7xvG0/zJ8evR7zIKn4vLf7qTNduWFtCrRw==} + engines: {node: 20 || >=22} + lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} @@ -4145,6 +4246,9 @@ packages: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} + mdn-data@2.27.1: + resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} + memoize-one@6.0.0: resolution: {integrity: sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==} @@ -4427,6 +4531,9 @@ packages: parse5@7.3.0: resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} + parse5@8.0.0: + resolution: {integrity: sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==} + parseley@0.12.1: resolution: {integrity: sha512-e6qHKe3a9HWr0oMRVDTRhKce+bRO8VGQR3NyVwcjwrbhMmFCX9KszEV35+rn4AdilFAq9VPxP/Fe1wC9Qjd2lw==} @@ -4523,11 +4630,21 @@ packages: engines: {node: '>=18'} hasBin: true + playwright-core@1.59.1: + resolution: {integrity: sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==} + engines: {node: '>=18'} + hasBin: true + playwright@1.57.0: resolution: {integrity: sha512-ilYQj1s8sr2ppEJ2YVadYBN0Mb3mdo9J0wQ+UuDhzYqURwSoW4n1Xs5vs7ORwgDGmyEh33tRMeS8KhdkMoLXQw==} engines: {node: '>=18'} hasBin: true + playwright@1.59.1: + resolution: {integrity: sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==} + engines: {node: '>=18'} + hasBin: true + possible-typed-array-names@1.1.0: resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} engines: {node: '>= 0.4'} @@ -5008,6 +5125,10 @@ packages: sax@1.4.1: resolution: {integrity: sha512-+aWOz7yVScEGoKNd4PA10LZ8sk0A/z5+nXQG5giUO5rprX9jgYsTdov9qCchZiPIZezbZH+jRut8nPodFAX4Jg==} + saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + scheduler@0.25.0: resolution: {integrity: sha512-xFVuu11jh+xcO7JOAGJNOXld8/TcEHK/4CituBUeUb5hqxJLj9YuemAEuvm9gQ/+pgXYfbQuqAkiYu+u7YEsNA==} @@ -5305,6 +5426,9 @@ packages: peerDependencies: react: '>=17.0' + symbol-tree@3.2.4: + resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + synckit@0.11.8: resolution: {integrity: sha512-+XZ+r1XGIJGeQk3VvXhT6xx/VpbHsRzsTkGgF6E5RX9TTXD0118l87puaEBZ566FhqblC6U0d4XnubznJDm30A==} engines: {node: ^14.18.0 || >=16.0.0} @@ -5395,6 +5519,13 @@ packages: resolution: {integrity: sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==} engines: {node: '>=12.0.0'} + tldts-core@7.0.28: + resolution: {integrity: sha512-7W5Efjhsc3chVdFhqtaU0KtK32J37Zcr9RKtID54nG+tIpcY79CQK/veYPODxtD/LJ4Lue66jvrQzIX2Z2/pUQ==} + + tldts@7.0.28: + resolution: {integrity: sha512-+Zg3vWhRUv8B1maGSTFdev9mjoo8Etn2Ayfs4cnjlD3CsGkxXX4QyW3j2WJ0wdjYcYmy7Lx2RDsZMhgCWafKIw==} + hasBin: true + tmp@0.2.3: resolution: {integrity: sha512-nZD7m9iCPC5g0pYmcaxogYKggSfLsdxl8of3Q/oIbqCqLLIO9IAF0GWjX1z9NZRHPiXv8Wex4yDCaZsgEw0Y8w==} engines: {node: '>=14.14'} @@ -5410,9 +5541,17 @@ packages: toposort@2.0.2: resolution: {integrity: sha512-0a5EOkAUp8D4moMi2W8ZF8jcga7BgZd91O/yabJCFY8az+XSzeGyTKs0Aoo897iV1Nj6guFq8orWDS96z91oGg==} + tough-cookie@6.0.1: + resolution: {integrity: sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==} + engines: {node: '>=16'} + tr46@0.0.3: resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} + tr46@6.0.0: + resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==} + engines: {node: '>=20'} + troika-three-text@0.52.4: resolution: {integrity: sha512-V50EwcYGruV5rUZ9F4aNsrytGdKcXKALjEtQXIOBfhVoZU9VAqZNIoGQ3TMiooVqFAbR1w15T+f+8gkzoFzawg==} peerDependencies: @@ -5545,6 +5684,10 @@ packages: undici-types@7.10.0: resolution: {integrity: sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag==} + undici@7.25.0: + resolution: {integrity: sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==} + engines: {node: '>=20.18.1'} + universalify@2.0.1: resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} engines: {node: '>= 10.0.0'} @@ -5617,6 +5760,10 @@ packages: victory-vendor@37.3.6: resolution: {integrity: sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==} + w3c-xmlserializer@5.0.0: + resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} + engines: {node: '>=18'} + walk-up-path@4.0.0: resolution: {integrity: sha512-3hu+tD8YzSLGuFYtPRb48vdhKMi0KQV5sn+uWr8+7dMEq/2G/dtLrdDinkLjqq5TIbIBjYJ4Ax/n3YiaW7QM8A==} engines: {node: 20 || >=22} @@ -5642,6 +5789,10 @@ packages: webidl-conversions@3.0.1: resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} + webidl-conversions@8.0.1: + resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==} + engines: {node: '>=20'} + webpack-sources@3.3.3: resolution: {integrity: sha512-yd1RBzSGanHkitROoPFd6qsrxt+oFhg/129YzheDGqeustzX0vTZJZsSsQjVQC4yzBQ56K55XU8gaNCtIzOnTg==} engines: {node: '>=10.13.0'} @@ -5659,6 +5810,14 @@ packages: webpack-cli: optional: true + whatwg-mimetype@5.0.0: + resolution: {integrity: sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==} + engines: {node: '>=20'} + + whatwg-url@16.0.1: + resolution: {integrity: sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + whatwg-url@5.0.0: resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} @@ -5723,6 +5882,10 @@ packages: resolution: {integrity: sha512-GCPAHLvrIH13+c0SuacwvRYj2SxJXQ4kaVTT5xgL3kPrz56XxkF21IGhjSE1+W0aw7gpBWRGXLCPnPby6lSpmQ==} engines: {node: '>=12'} + xml-name-validator@5.0.0: + resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} + engines: {node: '>=18'} + xml2js@0.6.2: resolution: {integrity: sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA==} engines: {node: '>=4.0.0'} @@ -5731,6 +5894,9 @@ packages: resolution: {integrity: sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==} engines: {node: '>=4.0'} + xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + xtend@4.0.2: resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} engines: {node: '>=0.4'} @@ -5887,6 +6053,26 @@ snapshots: '@argos-ci/util@3.2.0': {} + '@asamuzakjp/css-color@5.1.11': + dependencies: + '@asamuzakjp/generational-cache': 1.0.1 + '@csstools/css-calc': 3.2.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-color-parser': 4.1.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@asamuzakjp/dom-selector@7.0.10': + dependencies: + '@asamuzakjp/generational-cache': 1.0.1 + '@asamuzakjp/nwsapi': 2.3.9 + bidi-js: 1.0.3 + css-tree: 3.2.1 + is-potential-custom-element-name: 1.0.1 + + '@asamuzakjp/generational-cache@1.0.1': {} + + '@asamuzakjp/nwsapi@2.3.9': {} + '@babel/code-frame@7.27.1': dependencies: '@babel/helper-validator-identifier': 7.27.1 @@ -6032,6 +6218,34 @@ snapshots: '@better-fetch/fetch@1.1.21': {} + '@bramus/specificity@2.4.2': + dependencies: + css-tree: 3.2.1 + + '@csstools/color-helpers@6.0.2': {} + + '@csstools/css-calc@3.2.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-color-parser@4.1.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/color-helpers': 6.0.2 + '@csstools/css-calc': 3.2.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.1.3(css-tree@3.2.1)': + optionalDependencies: + css-tree: 3.2.1 + + '@csstools/css-tokenizer@4.0.0': {} + '@devicefarmer/adbkit-logcat@2.1.3': {} '@devicefarmer/adbkit-monkey@1.2.1': {} @@ -6307,6 +6521,10 @@ snapshots: '@eslint/core': 0.15.2 levn: 0.4.1 + '@exodus/bytes@1.15.0(@noble/hashes@2.0.1)': + optionalDependencies: + '@noble/hashes': 2.0.1 + '@floating-ui/core@1.7.4': dependencies: '@floating-ui/utils': 0.2.10 @@ -8452,6 +8670,11 @@ snapshots: domutils: 3.2.2 nth-check: 2.1.1 + css-tree@3.2.1: + dependencies: + mdn-data: 2.27.1 + source-map-js: 1.2.1 + css-what@6.2.2: {} cssesc@3.0.0: {} @@ -8498,6 +8721,13 @@ snapshots: damerau-levenshtein@1.0.8: {} + data-urls@7.0.0(@noble/hashes@2.0.1): + dependencies: + whatwg-mimetype: 5.0.0 + whatwg-url: 16.0.1(@noble/hashes@2.0.1) + transitivePeerDependencies: + - '@noble/hashes' + data-view-buffer@1.0.2: dependencies: call-bound: 1.0.4 @@ -8542,6 +8772,8 @@ snapshots: decimal.js-light@2.5.1: {} + decimal.js@10.6.0: {} + deep-extend@0.6.0: {} deep-is@0.1.4: {} @@ -8928,22 +9160,23 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-module-utils@2.12.1(@typescript-eslint/parser@8.47.0(eslint@9.31.0(jiti@2.6.1))(typescript@5.9.2))(eslint-import-resolver-node@0.3.9)(eslint@8.57.1): + eslint-module-utils@2.12.1(@typescript-eslint/parser@8.47.0(eslint@9.31.0(jiti@2.6.1))(typescript@5.9.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@9.31.0(jiti@2.6.1)))(eslint@9.31.0(jiti@2.6.1)): dependencies: debug: 3.2.7 optionalDependencies: '@typescript-eslint/parser': 8.47.0(eslint@9.31.0(jiti@2.6.1))(typescript@5.9.2) - eslint: 8.57.1 + eslint: 9.31.0(jiti@2.6.1) eslint-import-resolver-node: 0.3.9 + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.31.0(jiti@2.6.1)) transitivePeerDependencies: - supports-color - eslint-module-utils@2.12.1(@typescript-eslint/parser@8.47.0(eslint@9.31.0(jiti@2.6.1))(typescript@5.9.2))(eslint-import-resolver-node@0.3.9)(eslint@9.31.0(jiti@2.6.1)): + eslint-module-utils@2.12.1(@typescript-eslint/parser@8.47.0(eslint@9.31.0(jiti@2.6.1))(typescript@5.9.2))(eslint-import-resolver-node@0.3.9)(eslint@8.57.1): dependencies: debug: 3.2.7 optionalDependencies: '@typescript-eslint/parser': 8.47.0(eslint@9.31.0(jiti@2.6.1))(typescript@5.9.2) - eslint: 9.31.0(jiti@2.6.1) + eslint: 8.57.1 eslint-import-resolver-node: 0.3.9 transitivePeerDependencies: - supports-color @@ -9021,7 +9254,7 @@ snapshots: doctrine: 2.1.0 eslint: 9.31.0(jiti@2.6.1) eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.47.0(eslint@9.31.0(jiti@2.6.1))(typescript@5.9.2))(eslint-import-resolver-node@0.3.9)(eslint@9.31.0(jiti@2.6.1)) + eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.47.0(eslint@9.31.0(jiti@2.6.1))(typescript@5.9.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@9.31.0(jiti@2.6.1)))(eslint@9.31.0(jiti@2.6.1)) hasown: 2.0.2 is-core-module: 2.16.1 is-glob: 4.0.3 @@ -9664,6 +9897,12 @@ snapshots: dependencies: react-is: 16.13.1 + html-encoding-sniffer@6.0.0(@noble/hashes@2.0.1): + dependencies: + '@exodus/bytes': 1.15.0(@noble/hashes@2.0.1) + transitivePeerDependencies: + - '@noble/hashes' + html-to-text@9.0.5: dependencies: '@selderee/plugin-htmlparser2': 0.11.0 @@ -9862,6 +10101,8 @@ snapshots: is-path-inside@4.0.0: {} + is-potential-custom-element-name@1.0.1: {} + is-promise@2.2.2: {} is-reference@1.2.1: @@ -9977,6 +10218,32 @@ snapshots: dependencies: argparse: 2.0.1 + jsdom@29.0.2(@noble/hashes@2.0.1): + dependencies: + '@asamuzakjp/css-color': 5.1.11 + '@asamuzakjp/dom-selector': 7.0.10 + '@bramus/specificity': 2.4.2 + '@csstools/css-syntax-patches-for-csstree': 1.1.3(css-tree@3.2.1) + '@exodus/bytes': 1.15.0(@noble/hashes@2.0.1) + css-tree: 3.2.1 + data-urls: 7.0.0(@noble/hashes@2.0.1) + decimal.js: 10.6.0 + html-encoding-sniffer: 6.0.0(@noble/hashes@2.0.1) + is-potential-custom-element-name: 1.0.1 + lru-cache: 11.3.5 + parse5: 8.0.0 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 6.0.1 + undici: 7.25.0 + w3c-xmlserializer: 5.0.0 + webidl-conversions: 8.0.1 + whatwg-mimetype: 5.0.0 + whatwg-url: 16.0.1(@noble/hashes@2.0.1) + xml-name-validator: 5.0.0 + transitivePeerDependencies: + - '@noble/hashes' + jsesc@3.1.0: {} json-buffer@3.0.1: {} @@ -10173,6 +10440,8 @@ snapshots: lru-cache@10.4.3: {} + lru-cache@11.3.5: {} + lru-cache@5.1.1: dependencies: yallist: 3.1.1 @@ -10196,6 +10465,8 @@ snapshots: math-intrinsics@1.1.0: {} + mdn-data@2.27.1: {} + memoize-one@6.0.0: {} merge-stream@2.0.0: {} @@ -10510,6 +10781,10 @@ snapshots: dependencies: entities: 6.0.1 + parse5@8.0.0: + dependencies: + entities: 6.0.1 + parseley@0.12.1: dependencies: leac: 0.6.0 @@ -10609,12 +10884,20 @@ snapshots: playwright-core@1.57.0: {} + playwright-core@1.59.1: {} + playwright@1.57.0: dependencies: playwright-core: 1.57.0 optionalDependencies: fsevents: 2.3.2 + playwright@1.59.1: + dependencies: + playwright-core: 1.59.1 + optionalDependencies: + fsevents: 2.3.2 + possible-typed-array-names@1.1.0: {} postcss-import@15.1.0(postcss@8.5.6): @@ -11068,6 +11351,10 @@ snapshots: sax@1.4.1: {} + saxes@6.0.0: + dependencies: + xmlchars: 2.2.0 + scheduler@0.25.0: {} scheduler@0.27.0: {} @@ -11413,6 +11700,8 @@ snapshots: dependencies: react: 19.2.3 + symbol-tree@3.2.4: {} + synckit@0.11.8: dependencies: '@pkgr/core': 0.2.7 @@ -11515,6 +11804,12 @@ snapshots: fdir: 6.5.0(picomatch@4.0.3) picomatch: 4.0.3 + tldts-core@7.0.28: {} + + tldts@7.0.28: + dependencies: + tldts-core: 7.0.28 + tmp@0.2.3: {} tmp@0.2.5: {} @@ -11525,8 +11820,16 @@ snapshots: toposort@2.0.2: {} + tough-cookie@6.0.1: + dependencies: + tldts: 7.0.28 + tr46@0.0.3: {} + tr46@6.0.0: + dependencies: + punycode: 2.3.1 + troika-three-text@0.52.4(three@0.179.1): dependencies: bidi-js: 1.0.3 @@ -11680,6 +11983,8 @@ snapshots: undici-types@7.10.0: {} + undici@7.25.0: {} + universalify@2.0.1: {} unplugin@1.0.1: @@ -11788,6 +12093,10 @@ snapshots: d3-time: 3.1.0 d3-timer: 3.0.1 + w3c-xmlserializer@5.0.0: + dependencies: + xml-name-validator: 5.0.0 + walk-up-path@4.0.0: {} watchpack@2.4.4: @@ -11841,6 +12150,8 @@ snapshots: webidl-conversions@3.0.1: {} + webidl-conversions@8.0.1: {} + webpack-sources@3.3.3: {} webpack-virtual-modules@0.5.0: {} @@ -11877,6 +12188,16 @@ snapshots: - esbuild - uglify-js + whatwg-mimetype@5.0.0: {} + + whatwg-url@16.0.1(@noble/hashes@2.0.1): + dependencies: + '@exodus/bytes': 1.15.0(@noble/hashes@2.0.1) + tr46: 6.0.0 + webidl-conversions: 8.0.1 + transitivePeerDependencies: + - '@noble/hashes' + whatwg-url@5.0.0: dependencies: tr46: 0.0.3 @@ -11966,6 +12287,8 @@ snapshots: xdg-basedir@5.1.0: {} + xml-name-validator@5.0.0: {} + xml2js@0.6.2: dependencies: sax: 1.4.1 @@ -11973,6 +12296,8 @@ snapshots: xmlbuilder@11.0.1: {} + xmlchars@2.2.0: {} + xtend@4.0.2: {} y18n@5.0.8: {} From 704ec5ff8305fffc6fde6da08647a5fd4c448648 Mon Sep 17 00:00:00 2001 From: Alaeddin <15094821+BSalaeddin@users.noreply.github.com> Date: Sat, 25 Apr 2026 02:27:58 +0100 Subject: [PATCH 008/198] fix(coupons): filter on status='valid' after backend table merge --- apps/caramel-app/src/app/(marketing)/coupons/[store]/page.tsx | 4 ++-- apps/caramel-app/src/app/api/coupons/filters/route.ts | 4 ++-- apps/caramel-app/src/app/api/coupons/route.ts | 2 +- apps/caramel-app/src/app/api/coupons/stats/route.ts | 1 + apps/caramel-app/src/app/api/coupons/stores/route.ts | 4 ++-- apps/caramel-app/src/app/api/sites/top-sites/route.ts | 2 +- 6 files changed, 9 insertions(+), 8 deletions(-) diff --git a/apps/caramel-app/src/app/(marketing)/coupons/[store]/page.tsx b/apps/caramel-app/src/app/(marketing)/coupons/[store]/page.tsx index 6cf61f2..20f84ce 100644 --- a/apps/caramel-app/src/app/(marketing)/coupons/[store]/page.tsx +++ b/apps/caramel-app/src/app/(marketing)/coupons/[store]/page.tsx @@ -46,14 +46,14 @@ async function fetchStoreCoupons(storeParam: string) { discount_type, discount_amount, expiry, expired, times_used AS "timesUsed" FROM coupons - WHERE expired = FALSE + WHERE status = 'valid' AND status = 'valid' AND expired = FALSE AND (site = ${base} OR site LIKE ${'%.' + base}) ORDER BY rating DESC, created_at DESC LIMIT ${PAGE_SIZE} `, couponsSql` SELECT COUNT(*)::int AS total FROM coupons - WHERE expired = FALSE + WHERE status = 'valid' AND status = 'valid' AND expired = FALSE AND (site = ${base} OR site LIKE ${'%.' + base}) `, ]) diff --git a/apps/caramel-app/src/app/api/coupons/filters/route.ts b/apps/caramel-app/src/app/api/coupons/filters/route.ts index 03dde11..f655b30 100644 --- a/apps/caramel-app/src/app/api/coupons/filters/route.ts +++ b/apps/caramel-app/src/app/api/coupons/filters/route.ts @@ -17,7 +17,7 @@ export async function GET(req: NextRequest) { includeSites && sitesLimit > 0 ? couponsSql>` SELECT DISTINCT site FROM coupons - WHERE expired = FALSE AND site IS NOT NULL + WHERE status = 'valid' AND expired = FALSE AND site IS NOT NULL ORDER BY site ASC LIMIT ${sitesLimit} ` @@ -25,7 +25,7 @@ export async function GET(req: NextRequest) { const typesPromise = couponsSql>` SELECT DISTINCT discount_type FROM coupons - WHERE expired = FALSE AND discount_type IS NOT NULL + WHERE status = 'valid' AND expired = FALSE AND discount_type IS NOT NULL ` const [sitesRaw, discountTypesRaw] = await Promise.all([ diff --git a/apps/caramel-app/src/app/api/coupons/route.ts b/apps/caramel-app/src/app/api/coupons/route.ts index 8186687..719a100 100644 --- a/apps/caramel-app/src/app/api/coupons/route.ts +++ b/apps/caramel-app/src/app/api/coupons/route.ts @@ -38,7 +38,7 @@ export async function GET(req: NextRequest) { (url.searchParams.get('key_words') || '').slice(0, 200) || undefined try { - const conditions = [couponsSql`expired = FALSE`] + const conditions = [couponsSql`status = 'valid' AND expired = FALSE`] if (site) { const base = getBaseDomain(site) diff --git a/apps/caramel-app/src/app/api/coupons/stats/route.ts b/apps/caramel-app/src/app/api/coupons/stats/route.ts index fc4cec8..d08d9eb 100644 --- a/apps/caramel-app/src/app/api/coupons/stats/route.ts +++ b/apps/caramel-app/src/app/api/coupons/stats/route.ts @@ -12,6 +12,7 @@ export async function GET(req: NextRequest) { COUNT(*)::int AS total, COUNT(*) FILTER (WHERE expired = TRUE)::int AS expired FROM coupons + WHERE status = 'valid' ` const row = (rows[0] ?? { total: 0, expired: 0 }) as { total: number diff --git a/apps/caramel-app/src/app/api/coupons/stores/route.ts b/apps/caramel-app/src/app/api/coupons/stores/route.ts index 1f9f8c6..1e746d5 100644 --- a/apps/caramel-app/src/app/api/coupons/stores/route.ts +++ b/apps/caramel-app/src/app/api/coupons/stores/route.ts @@ -15,14 +15,14 @@ export async function GET(req: NextRequest) { const rows = q ? await couponsSql>` SELECT DISTINCT site FROM coupons - WHERE expired = FALSE + WHERE status = 'valid' AND status = 'valid' AND expired = FALSE AND (site ILIKE ${'%' + q + '%'} OR site ILIKE ${q + '%'}) ORDER BY site ASC LIMIT ${limit} ` : await couponsSql>` SELECT DISTINCT site FROM coupons - WHERE expired = FALSE + WHERE status = 'valid' AND status = 'valid' AND expired = FALSE ORDER BY site ASC LIMIT ${limit} ` diff --git a/apps/caramel-app/src/app/api/sites/top-sites/route.ts b/apps/caramel-app/src/app/api/sites/top-sites/route.ts index 4f3b1c7..1077732 100644 --- a/apps/caramel-app/src/app/api/sites/top-sites/route.ts +++ b/apps/caramel-app/src/app/api/sites/top-sites/route.ts @@ -10,7 +10,7 @@ export async function GET(req: NextRequest) { const rows = await couponsSql>` SELECT site, COUNT(*)::int AS coupon_count FROM coupons - WHERE expired = FALSE + WHERE status = 'valid' AND status = 'valid' AND expired = FALSE GROUP BY site ORDER BY coupon_count DESC LIMIT 4 From 404822526aba1c3e48d1b946ac9134597632cbcb Mon Sep 17 00:00:00 2001 From: Alaeddin <15094821+BSalaeddin@users.noreply.github.com> Date: Fri, 1 May 2026 02:54:53 +0100 Subject: [PATCH 009/198] feat(extension): try-verify-remove-retry loop, dynamic CSS/XPath selector dispatch (qOne/qAll), HARD GATE 2 contract fields supported, dev-mode cache bypass, restriction warning chips in popup --- apps/caramel-extension/assets/styles.css | 36 ++ apps/caramel-extension/manifest.json | 11 +- apps/caramel-extension/popup.js | 41 +- apps/caramel-extension/shared-utils.js | 469 ++++++++++++++++++----- 4 files changed, 446 insertions(+), 111 deletions(-) diff --git a/apps/caramel-extension/assets/styles.css b/apps/caramel-extension/assets/styles.css index 2e28322..79ddc18 100644 --- a/apps/caramel-extension/assets/styles.css +++ b/apps/caramel-extension/assets/styles.css @@ -825,3 +825,39 @@ body { font-size: 14px; } } + +/* Restriction warning chip on restricted coupons */ +.coupon-item-restricted { + border-color: #f0b34d; + background: linear-gradient(180deg, #fffaf0 0%, #fff 80%); +} +.coupon-restriction { + display: flex; + align-items: flex-start; + gap: 6px; + margin: 6px 0 8px; + padding: 6px 8px; + font-size: 12px; + color: #8a5a00; + background: #fff5e0; + border-left: 3px solid #f0b34d; + border-radius: 4px; +} +.coupon-restriction-icon { + font-size: 14px; + line-height: 1; +} +.coupon-restriction-text { + flex: 1; + line-height: 1.35; +} +.coupon-restriction-text b { + color: #d97706; +} +.coupon-restriction-detail { + margin-top: 2px; + color: #6b4500; + font-size: 11px; + opacity: 0.85; + font-style: italic; +} diff --git a/apps/caramel-extension/manifest.json b/apps/caramel-extension/manifest.json index 5f94316..cd65dd3 100644 --- a/apps/caramel-extension/manifest.json +++ b/apps/caramel-extension/manifest.json @@ -25,10 +25,7 @@ "management" ], "host_permissions": [ - "https://www.amazon.com/*", - "https://*.ebay.com/*", - "https://*.codecademy.com/*", - "https://*.grabcaramel.com/*", + "https://*/*", "http://localhost:58000/*", "https://accounts.google.com/*", "https://appleid.apple.com/*" @@ -41,11 +38,7 @@ ], "content_scripts": [ { - "matches": [ - "https://www.amazon.com/*", - "https://*.ebay.com/*", - "https://*.codecademy.com/*" - ], + "matches": ["https://*/*"], "js": [ "cart-signals.js", "shared-utils.js", diff --git a/apps/caramel-extension/popup.js b/apps/caramel-extension/popup.js index 10eb25d..8345724 100644 --- a/apps/caramel-extension/popup.js +++ b/apps/caramel-extension/popup.js @@ -531,16 +531,47 @@ function renderCouponsView(coupons, user, domain) { coupons.length === 0 ? '

No coupons available for this store right now.

' : coupons - .map( - c => ` -
+ .map(c => { + const restrictedSet = new Set([ + 'product_restriction', + 'category_restricted', + 'seller_specific', + 'valid_with_warning', + ]) + const isRestricted = restrictedSet.has(c.status) + let warning = '' + if (isRestricted) { + const baseMsg = + c.status === 'category_restricted' + ? 'Limited to specific categories' + : c.status === 'seller_specific' + ? 'Only for items from a specific seller' + : c.status === 'valid_with_warning' + ? 'May have restrictions' + : 'Limited to specific items' + const cartHint = c.cartCategory + ? ` — your cart looks like ${c.cartCategory}${c.cartCategorySecondary ? ` / ${c.cartCategorySecondary}` : ''}` + : '' + const verifierMsg = c.verificationMessage + ? `
${c.verificationMessage}
` + : '' + warning = ` +
+ + ${baseMsg}${cartHint} + ${verifierMsg} +
` + } + return ` +
${c.title || 'Untitled Coupon'}
${c.description || ''}
+ ${warning}
-
`, - ) +
` + }) .join('') }
diff --git a/apps/caramel-extension/shared-utils.js b/apps/caramel-extension/shared-utils.js index e89eddc..db7c3a8 100644 --- a/apps/caramel-extension/shared-utils.js +++ b/apps/caramel-extension/shared-utils.js @@ -75,9 +75,9 @@ const CARAMEL_ALLOWED_ORIGINS = new Set([ /* ---------- DOM waiters ---------- */ function waitForElement(sel, timeout = 4000) { return new Promise((res, rej) => { - if (document.querySelector(sel)) return res('found-immediately') + if (qOne(sel)) return res('found-immediately') const mo = new MutationObserver(() => { - if (document.querySelector(sel)) { + if (qOne(sel)) { mo.disconnect() res('appeared') } @@ -152,7 +152,7 @@ async function getAmazonCartKeywords() { /* ---------- UI readiness helper (new) ---------- */ async function waitUntilReady(rec, timeout = 2000) { - const btn = document.querySelector(rec.couponSubmit) + const btn = qOne(rec.couponSubmit) const start = performance.now() return new Promise(resolve => { ;(function loop() { @@ -165,8 +165,8 @@ async function waitUntilReady(rec, timeout = 2000) { /* -------------------------------------------------- price grabber */ function getPrice(selector, { returnLargest } = {}) { - let el = document.querySelector(selector) - if (!el && selector.includes('[id=')) { + let el = qOne(selector) + if (!el && typeof selector === 'string' && selector.includes('[id=')) { const id = selector.match(/\[id=['"]([^'"]+)['"]\]/)?.[1] if (id) el = document.getElementById(id) } @@ -186,22 +186,102 @@ function getPrice(selector, { returnLargest } = {}) { return returnLargest ? Math.max(...prices) : prices[0] } +/* -------------------------------------------------- selector helper + * Configs may store either a CSS selector or an XPath expression. The agent + * picks whichever uniquely identifies the element on each store. Detect by + * leading char and dispatch to the right DOM API. + * "input#code" → CSS → querySelector + * "//input[@id='code']" → XPath → document.evaluate + * "(//div)[2]" → XPath + */ +function _isXPath(sel) { + if (typeof sel !== 'string' || !sel) return false + const t = sel.trim() + return t.startsWith('/') || t.startsWith('(/') || t.startsWith('./') +} +function qOne(sel, root) { + if (!sel) return null + root = root || document + try { + if (_isXPath(sel)) { + const res = document.evaluate( + sel, + root, + null, + XPathResult.FIRST_ORDERED_NODE_TYPE, + null, + ) + return res.singleNodeValue + } + return root.querySelector(sel) + } catch (e) { + return null + } +} +function qAll(sel, root) { + if (!sel) return [] + root = root || document + try { + if (_isXPath(sel)) { + const res = document.evaluate( + sel, + root, + null, + XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, + null, + ) + const out = [] + for (let i = 0; i < res.snapshotLength; i++) + out.push(res.snapshotItem(i)) + return out + } + return Array.from(root.querySelectorAll(sel)) + } catch (e) { + return [] + } +} + /* -------------------------------------------------- config cache */ const STORE_CACHE_KEY = 'caramel_supported_stores' -const STORE_CACHE_TTL = 60 * 60 * 1000 // 1 hour +const STORE_CACHE_PROD_TTL = 60 * 60 * 1000 // 1 hour +const STORE_CACHE_DEV_TTL = 0 // bypass cache when loaded as unpacked dev extension + +// Dev-mode detection that works in BOTH popup AND content-script contexts. +// chrome.management only exists in the service worker, but +// chrome.runtime.getManifest() works everywhere. +// Production (Chrome Web Store) installs have an `update_url` field; +// unpacked dev extensions don't. +function _isDevInstall() { + try { + if (typeof chrome === 'undefined' || !chrome.runtime?.getManifest) + return false + const m = chrome.runtime.getManifest() + return !m.update_url + } catch (_) { + return false + } +} + +function _getCacheTtl() { + return _isDevInstall() ? STORE_CACHE_DEV_TTL : STORE_CACHE_PROD_TTL +} async function getDomainRecord(domain) { if (!getDomainRecord.cache) { + const ttl = _getCacheTtl() // Check chrome.storage.local for a recent cached copy first try { - const stored = await new Promise(r => - currentBrowser.storage.local.get([STORE_CACHE_KEY], r), - ) + const stored = + ttl > 0 + ? await new Promise(r => + currentBrowser.storage.local.get( + [STORE_CACHE_KEY], + r, + ), + ) + : null const entry = stored?.[STORE_CACHE_KEY] - if ( - entry?.data?.length && - Date.now() - entry.ts < STORE_CACHE_TTL - ) { + if (ttl > 0 && entry?.data?.length && Date.now() - entry.ts < ttl) { getDomainRecord.cache = entry.data log('Loaded supported domains from local cache') } @@ -257,20 +337,13 @@ getDomainRecord.cache = null async function isCheckout() { const rec = await getDomainRecord(location.hostname) if (!rec) return false - if ( - document.querySelector(rec.couponInput) || - document.querySelector(rec.showInput) - ) - return true + if (qOne(rec.couponInput) || qOne(rec.showInput)) return true try { await waitForElement(`${rec.couponInput},${rec.showInput}`, 3000) } catch (e) { log(e) } - return !!( - document.querySelector(rec.couponInput) || - document.querySelector(rec.showInput) - ) + return !!(qOne(rec.couponInput) || qOne(rec.showInput)) } /* -------------------------------------------------- init hook */ @@ -281,6 +354,91 @@ async function tryInitialize() { } } +// Generic selectors used when the per-store config doesn't specify them. +// These cover the most common Honey-style cart UIs. +const GENERIC_APPLIED_SELECTORS = + '[class*="coupon-applied" i], [class*="discount-applied" i], ' + + '[class*="cart-coupon-list" i] li, [class*="applied-coupon" i], ' + + '[class*="coupon-list-item" i], [class*="redeemed" i]' +const GENERIC_REMOVE_SELECTORS = + '[class*="cart-coupon-list" i] li button, [class*="coupon-list-item" i] button, ' + + '[class*="applied-coupon" i] button, [aria-label*="Remove" i], ' + + '[aria-label*="Delete" i], button[title*="Remove" i]' +const GENERIC_ERROR_TEXT_RE = + /\b(invalid|expired|not\s+(valid|applicable|eligible)|limited\s+to|cannot\s+be\s+(applied|redeemed)|doesn'?t\s+apply|no\s+eligible|enter\s+a\s+valid|nicht|ungültig)\b/i + +function findAppliedSelector(rec) { + return rec.successIndicator || GENERIC_APPLIED_SELECTORS +} +function findRemoveSelector(rec) { + return rec.couponRemove || GENERIC_REMOVE_SELECTORS +} + +// Set value on a (possibly React-controlled) input + fire input/change events. +function setInputValue(input, code) { + const proto = window.HTMLInputElement.prototype + const setter = Object.getOwnPropertyDescriptor(proto, 'value')?.set + if (setter) setter.call(input, code) + else input.value = code + input.dispatchEvent(new Event('input', { bubbles: true })) + input.dispatchEvent(new Event('change', { bubbles: true })) +} + +// Try to remove the most-recently-applied coupon. Returns true if a remove +// button was clicked. Caller should wait for the cart to update. +async function removeAppliedCoupon(rec) { + // Prefer per-config remove; fall back to generic. + const sel = findRemoveSelector(rec) + const candidates = [...document.querySelectorAll(sel)].filter( + b => b.offsetParent !== null && !b.disabled, + ) + if (candidates.length) { + // The newest applied coupon is usually rendered last → click last one. + const btn = candidates[candidates.length - 1] + btn.click() + await sleep(600) + log('Removed applied coupon via', sel) + return true + } + // Last resort: clear the input. Some sites tie this to "remove". + const input = qOne(rec.couponInput) + if (input && input.value) { + setInputValue(input, '') + await sleep(300) + log('Cleared input as remove fallback') + return true + } + return false +} + +// Detect if an error message appeared near the cart/coupon UI. +function detectCouponError(rec) { + if (rec.errorIndicator) { + const el = qOne(rec.errorIndicator) + if (el && el.offsetParent !== null) { + const t = (el.innerText || '').trim() + if (t.length) return t + } + } + // Generic: look near the input for an inline error matching common phrases. + const input = qOne(rec.couponInput) + if (!input) return null + let scope = input.parentElement + for (let d = 0; d < 5 && scope; d++) { + const text = (scope.innerText || '').trim() + if (text && GENERIC_ERROR_TEXT_RE.test(text)) { + const m = text.match(GENERIC_ERROR_TEXT_RE) + const idx = text.search(GENERIC_ERROR_TEXT_RE) + return text + .slice(Math.max(0, idx - 40), idx + 120) + .replace(/\s+/g, ' ') + .trim() + } + scope = scope.parentElement + } + return null +} + /* -------------------------------------------------- coupon attempt */ async function applyCoupon(code, rec) { const attemptStart = performance.now() @@ -289,7 +447,7 @@ async function applyCoupon(code, rec) { try { /* 1] dismiss popup if present */ if (rec.dismissButton) { - const btn = document.querySelector(rec.dismissButton) + const btn = qOne(rec.dismissButton) if (btn) { btn.click() await sleep(180) @@ -298,9 +456,9 @@ async function applyCoupon(code, rec) { } /* 2] ensure input visible */ - let input = document.querySelector(rec.couponInput) + let input = qOne(rec.couponInput) if (!input && rec.showInput) { - const showBtn = document.querySelector(rec.showInput) + const showBtn = qOne(rec.showInput) if (showBtn) { showBtn.click() try { @@ -308,33 +466,58 @@ async function applyCoupon(code, rec) { } catch (e) { log(e) } - input = document.querySelector(rec.couponInput) + input = qOne(rec.couponInput) } } - const applyBtn = document.querySelector(rec.couponSubmit) + const applyBtn = qOne(rec.couponSubmit) if (!input || !applyBtn) { log('Input / apply button missing') log('AUTO_INSERT_ATTEMPT_END', code, { success: false, elapsed: performance.now() - attemptStart, }) - return { success: false } + return { success: false, applied: false } } - const original = getPrice(rec.priceContainer, { returnLargest: true }) - - /* 3] fill & click */ - input.value = code - input.dispatchEvent(new Event('input', { bubbles: true })) - applyBtn.click() + const hasPriceCfg = !!rec.priceContainer + const original = hasPriceCfg + ? getPrice(rec.priceContainer, { returnLargest: true }) + : NaN + + // Snapshot DOM signals BEFORE we apply, so we can compare after. + const appliedSel = findAppliedSelector(rec) + const beforeAppliedNodes = qAll(appliedSel).length + + /* 3] fill & apply — choose method dynamically: + a) if applyBtn === input → auto-validate on input event + b) if applyBtn is a button/element distinct → click + c) Enter-key submit also dispatched as a fallback for forms */ + setInputValue(input, code) + if (applyBtn !== input) { + applyBtn.click() + } + // Always dispatch Enter on the input — harmless when no submit handler, + // but lets sites that listen to keydown="Enter" pick it up. + input.dispatchEvent( + new KeyboardEvent('keydown', { + key: 'Enter', + code: 'Enter', + keyCode: 13, + bubbles: true, + }), + ) /* 4] wait for result */ - const waiters = [sleep(1200).then(() => 'timeout-1.2s')] // shorter fallback - const priceEl = - document.querySelector(rec.priceContainer) || - document.getElementById( - rec.priceContainer.match(/\[id=['"]([^'"]+)['"]\]/)?.[1] || '', - ) + const waiters = [sleep(1200).then(() => 'timeout-1.2s')] + let priceEl = null + if (hasPriceCfg) { + priceEl = + qOne(rec.priceContainer) || + document.getElementById( + rec.priceContainer.match(/\[id=['"]([^'"]+)['"]\]/)?.[1] || + '', + ) + } if (priceEl && rec.domain !== 'amazon.com') waiters.push(waitForTextChange(priceEl, 3000)) if (rec.domain === 'amazon.com') waiters.push(waitForAmazonFetch()) @@ -342,17 +525,41 @@ async function applyCoupon(code, rec) { const via = await Promise.race(waiters) log('Wait finished via', via) - const newTotal = getPrice(rec.priceContainer, { returnLargest: true }) - const success = !isNaN(newTotal) && newTotal < original + // 5] Determine outcome: + // - committed = something visibly applied (DOM mutation) + // - errorMsg = error text appeared near input + // - savings = price actually dropped + const afterAppliedNodes = qAll(appliedSel).length + const committed = afterAppliedNodes > beforeAppliedNodes + const errorMsg = detectCouponError(rec) + let newTotal = NaN + let priceDropped = false + if (hasPriceCfg) { + newTotal = getPrice(rec.priceContainer, { returnLargest: true }) + priceDropped = !isNaN(newTotal) && newTotal < original + } + // Success rules (in priority order): + // 1. price dropped → real win + // 2. committed AND no error → likely worked, treat as success + // 3. otherwise → fail + const success = priceDropped || (committed && !errorMsg) const elapsed = performance.now() - attemptStart - log('AUTO_INSERT_ATTEMPT_END', code, { success, newTotal, elapsed }) + log('AUTO_INSERT_ATTEMPT_END', code, { + success, + newTotal, + committed, + errorMsg, + elapsed, + }) recordTiming('AUTO_INSERT_ATTEMPT_END', { code, success, newTotal, + committed, + errorMsg, elapsed, }) - return { success, newTotal } + return { success, newTotal, committed, errorMsg } } catch (err) { console.error('applyCoupon error', err) log('AUTO_INSERT_ATTEMPT_END', code, { @@ -366,7 +573,7 @@ async function applyCoupon(code, rec) { error: String(err), elapsed: performance.now() - attemptStart, }) - return { success: false } + return { success: false, committed: false, errorMsg: String(err) } } } @@ -411,11 +618,50 @@ async function fetchCoupons(site, kw, category) { throw e } } +// Statuses that signal a coupon has restrictions the user might trip over. +// When ANY returned coupon carries one of these, we classify the cart so the +// UI can warn the user "your cart is X, this code is for Y." +const RESTRICTED_STATUSES = new Set([ + 'product_restriction', + 'category_restricted', + 'seller_specific', + 'valid_with_warning', +]) + +async function classifyCartCategory() { + try { + const cs = window.CaramelCartSignals + if (!cs || typeof cs.collectCartSignals !== 'function') return null + const signals = await cs.collectCartSignals() + const result = await new Promise(res => + currentBrowser.runtime.sendMessage( + { action: 'classifyCart', signals }, + res, + ), + ) + if (result && result.primary && !result.error) { + log( + 'Cart category:', + result.primary, + '(conf:', + result.confidence, + ')', + ) + return { + primary: result.primary, + secondary: result.secondary, + confidence: result.confidence, + } + } + } catch (e) { + log('classifyCart non-fatal error', e) + } + return null +} + async function getCoupons(rec) { let kw = '' - let category = '' if (rec.domain === 'amazon.com') { - // Use fast in-page scrape (or same-origin cart fetch) instead of opening a new tab recordTiming('AUTO_INSERT_AMAZON_SCRAPE_REQUEST') const titles = await getAmazonCartKeywords() recordTiming('AUTO_INSERT_AMAZON_SCRAPE_RESPONSE', { @@ -424,37 +670,45 @@ async function getCoupons(rec) { kw = (titles || []).join(',') log('Amazon keywords', kw) } - // Classify cart category for relevant coupon selection - try { - const cs = window.CaramelCartSignals - if (cs && typeof cs.collectCartSignals === 'function') { - const signals = await cs.collectCartSignals() - const result = await new Promise(res => - currentBrowser.runtime.sendMessage( - { action: 'classifyCart', signals }, - res, - ), - ) - if (result && result.primary && !result.error) { - category = result.primary - log( - 'Cart category:', - category, - '(conf:', - result.confidence, - ')', - ) - } - } - } catch (e) { - log('classifyCart non-fatal error', e) - } + // Dev hook: deterministic coupons when using #caramel-test if (location.hash && location.hash.includes('caramel-test')) { log('DEV MODE: returning mocked coupons') return [{ code: 'TEST10' }, { code: 'TEST20' }, { code: 'TEST30' }] } - return fetchCoupons(rec.domain, kw, category) + + // 1) Fetch coupons FIRST — no LLM call yet. + const list = await fetchCoupons(rec.domain, kw, '') + + // 2) Only classify the cart if any returned coupon is flagged as restricted + // — that's when the category meaningfully helps the user decide. + const hasRestricted = (list || []).some(c => + RESTRICTED_STATUSES.has(c.status), + ) + if (!hasRestricted) { + log( + `getCoupons: ${list?.length || 0} coupons, none restricted — skipping classify-cart`, + ) + return list + } + log( + `getCoupons: restricted coupon(s) present — classifying cart for insights`, + ) + const cat = await classifyCartCategory() + // 3) Annotate restricted coupons with cart category so the popup can render + // a contextual "may not apply — your cart is X" hint. + if (cat?.primary) { + return list.map(c => + RESTRICTED_STATUSES.has(c.status) + ? { + ...c, + cartCategory: cat.primary, + cartCategorySecondary: cat.secondary, + } + : c, + ) + } + return list } /* -------------------------------------------------- main runner */ @@ -489,50 +743,71 @@ async function startApplyingCoupons(rec) { const MAX_ATTEMPTS = 8 if (coupons.length > MAX_ATTEMPTS) coupons = coupons.slice(0, MAX_ATTEMPTS) - const original = getPrice(rec.priceContainer, { returnLargest: true }) - let bestSave = 0, - bestCode = null + const hasPriceCfg = !!rec.priceContainer + const original = hasPriceCfg + ? getPrice(rec.priceContainer, { returnLargest: true }) + : NaN + let bestSave = 0 + let bestCode = null + const triedCodes = [] for (let i = 0; i < coupons.length; i++) { const { code } = coupons[i] + triedCodes.push(code) await updateTestingModal(i + 1, coupons.length, code) const res = await applyCoupon(code, rec) - /* clear field & wait until UI ready for next pass */ - const inp = document.querySelector(rec.couponInput) - if (inp) { - inp.value = '' - inp.dispatchEvent(new Event('input', { bubbles: true })) + if (res.success) { + // Real success — keep this code applied, stop here. + const diff = + hasPriceCfg && !isNaN(res.newTotal) + ? original - res.newTotal + : 0 + log(`✓ ${code} saved ${diff || '(unknown — no priceContainer)'}`) + bestSave = diff + bestCode = code + break } - await waitUntilReady(rec) - await sleep(120) // tiny visual pause - if (res.success) { - const diff = original - res.newTotal - log(`✓ ${code} saved ${diff}`) - if (diff > bestSave) { - bestSave = diff - bestCode = code - } + // Apply FAILED. Decide what cleanup is needed before next attempt: + // - If the cart visibly accepted the code (`committed`) but an error + // showed up → remove that pending coupon so the next try starts + // from a clean cart. + // - If nothing committed → just clear the input field so the next + // try doesn't append to leftover text. + // - Either way, then move on to the next code. + log(`✗ ${code} failed`, { + committed: res.committed, + errorMsg: res.errorMsg, + }) + if (res.committed) { + await removeAppliedCoupon(rec) } else { - log(`✗ ${code} no savings`) + const inp = qOne(rec.couponInput) + if (inp && inp.value) { + setInputValue(inp, '') + } } + await waitUntilReady(rec) + await sleep(160) // tiny visual pause between tries } if (bestCode) { - await applyCoupon(bestCode, rec) + // bestCode was already applied during the successful loop iteration. + // Do NOT re-apply — would double-stack on sites that don't dedupe. log('AUTO_INSERT_STOP', { result: 'applied', bestCode, bestSave, + tried: triedCodes, t: performance.now(), }) - showFinalModal( - bestSave, - bestCode, - 'We found a coupon that saves you money!', - ) + const headline = + hasPriceCfg && bestSave > 0 + ? 'We found a coupon that saves you money!' + : `Applied ${bestCode}` + showFinalModal(bestSave, bestCode, headline) } else { log('AUTO_INSERT_STOP', { result: 'none', From b2969a9faf4808cc3df11e8814a1c30a73a722b8 Mon Sep 17 00:00:00 2001 From: Alaeddin <15094821+BSalaeddin@users.noreply.github.com> Date: Sat, 2 May 2026 04:31:46 +0100 Subject: [PATCH 010/198] =?UTF-8?q?fix(extension):=20detectCouponError=20n?= =?UTF-8?q?ow=20compares=20to=20a=20baseline=20snapshot=20taken=20before?= =?UTF-8?q?=20apply=20=E2=80=94=20was=20treating=20permanently-mounted=20e?= =?UTF-8?q?rror=20containers=20(aria-live=20regions,=20empty=20banners)=20?= =?UTF-8?q?as=20'error=20present',=20causing=20the=20loop=20to=20never=20s?= =?UTF-8?q?top=20on=20valid=20coupons?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/caramel-app/src/app/api/coupons/route.ts | 10 ++++- .../api/extension/supported-stores/route.ts | 11 ++++- apps/caramel-extension/shared-utils.js | 43 +++++++++++++++++-- local-dev/verify_test_small3.txt | 1 + 4 files changed, 58 insertions(+), 7 deletions(-) create mode 100644 local-dev/verify_test_small3.txt diff --git a/apps/caramel-app/src/app/api/coupons/route.ts b/apps/caramel-app/src/app/api/coupons/route.ts index 719a100..dd3ecbf 100644 --- a/apps/caramel-app/src/app/api/coupons/route.ts +++ b/apps/caramel-app/src/app/api/coupons/route.ts @@ -38,7 +38,12 @@ export async function GET(req: NextRequest) { (url.searchParams.get('key_words') || '').slice(0, 200) || undefined try { - const conditions = [couponsSql`status = 'valid' AND expired = FALSE`] + // Surface valid + restriction-tagged coupons. The extension uses the + // status field to decide whether to classify the cart and surface + // a "may not apply" warning to the user. + const conditions = [ + couponsSql`status IN ('valid','valid_with_warning','product_restriction','category_restricted','seller_specific') AND expired = FALSE`, + ] if (site) { const base = getBaseDomain(site) @@ -84,7 +89,8 @@ export async function GET(req: NextRequest) { couponsSql` SELECT id, code, site, title, description, rating, discount_type, discount_amount, expiry, expired, - times_used AS "timesUsed" + times_used AS "timesUsed", + status, verification_message AS "verificationMessage" FROM coupons WHERE ${whereClause} ORDER BY rating DESC, created_at DESC diff --git a/apps/caramel-app/src/app/api/extension/supported-stores/route.ts b/apps/caramel-app/src/app/api/extension/supported-stores/route.ts index a907935..4687753 100644 --- a/apps/caramel-app/src/app/api/extension/supported-stores/route.ts +++ b/apps/caramel-app/src/app/api/extension/supported-stores/route.ts @@ -26,6 +26,9 @@ type Row = { coupon_input_xpath: string | null apply_button_xpath: string | null price_container_xpath: string | null + success_indicator_xpath: string | null + error_indicator_xpath: string | null + coupon_remove_xpath: string | null } export async function GET(req: NextRequest) { @@ -41,7 +44,10 @@ export async function GET(req: NextRequest) { cfg.dismiss_button_xpath, cfg.coupon_input_xpath, cfg.apply_button_xpath, - cfg.price_container_xpath + cfg.price_container_xpath, + cfg.success_indicator_xpath, + cfg.error_indicator_xpath, + cfg.coupon_remove_xpath FROM store_verification_configs cfg JOIN verification_stores s ON s.id = cfg.store_id WHERE cfg.is_active = TRUE @@ -57,6 +63,9 @@ export async function GET(req: NextRequest) { priceContainer: r.price_container_xpath ?? undefined, showInput: r.show_input_xpath ?? undefined, dismissButton: r.dismiss_button_xpath ?? undefined, + successIndicator: r.success_indicator_xpath ?? undefined, + errorIndicator: r.error_indicator_xpath ?? undefined, + couponRemove: r.coupon_remove_xpath ?? undefined, })) return NextResponse.json( diff --git a/apps/caramel-extension/shared-utils.js b/apps/caramel-extension/shared-utils.js index db7c3a8..5044c21 100644 --- a/apps/caramel-extension/shared-utils.js +++ b/apps/caramel-extension/shared-utils.js @@ -412,12 +412,47 @@ async function removeAppliedCoupon(rec) { } // Detect if an error message appeared near the cart/coupon UI. -function detectCouponError(rec) { +function snapshotErrorState(rec) { + // Capture the error region's text + visibility BEFORE we apply, so we can + // tell whether an error appeared *because of this attempt* vs. a stale + // error container that some sites keep mounted (aria-live regions, + // placeholder error rows, etc.). Without this snapshot the extension + // would treat permanently-mounted empty error containers as "error + // present" and loop forever even after a valid coupon applied. + if (!rec.errorIndicator) return { text: '', visible: false } + const el = qOne(rec.errorIndicator) + if (!el) return { text: '', visible: false } + return { + text: (el.innerText || '').trim(), + visible: el.offsetParent !== null, + } +} + +function detectCouponError(rec, baseline) { + // baseline is what snapshotErrorState() returned BEFORE the apply. + // We only call something an "error" if the state CHANGED — first + // appearance, or text changed (e.g. now mentions the failed code). if (rec.errorIndicator) { const el = qOne(rec.errorIndicator) if (el && el.offsetParent !== null) { const t = (el.innerText || '').trim() - if (t.length) return t + if (t.length) { + if (!baseline) return t // back-compat, no snapshot + const wasVisible = baseline.visible + const wasText = baseline.text + // Treat as a real error only when: + // - text appeared (was empty, now has content), OR + // - text actually changed (new error string), OR + // - element became visible (was hidden, now shown) + if ( + (!wasVisible && el.offsetParent !== null) || + t !== wasText + ) { + return t + } + // Same text + same visibility = stale container, ignore. + return null + } } } // Generic: look near the input for an inline error matching common phrases. @@ -427,7 +462,6 @@ function detectCouponError(rec) { for (let d = 0; d < 5 && scope; d++) { const text = (scope.innerText || '').trim() if (text && GENERIC_ERROR_TEXT_RE.test(text)) { - const m = text.match(GENERIC_ERROR_TEXT_RE) const idx = text.search(GENERIC_ERROR_TEXT_RE) return text .slice(Math.max(0, idx - 40), idx + 120) @@ -487,6 +521,7 @@ async function applyCoupon(code, rec) { // Snapshot DOM signals BEFORE we apply, so we can compare after. const appliedSel = findAppliedSelector(rec) const beforeAppliedNodes = qAll(appliedSel).length + const errorBaseline = snapshotErrorState(rec) /* 3] fill & apply — choose method dynamically: a) if applyBtn === input → auto-validate on input event @@ -531,7 +566,7 @@ async function applyCoupon(code, rec) { // - savings = price actually dropped const afterAppliedNodes = qAll(appliedSel).length const committed = afterAppliedNodes > beforeAppliedNodes - const errorMsg = detectCouponError(rec) + const errorMsg = detectCouponError(rec, errorBaseline) let newTotal = NaN let priceDropped = false if (hasPriceCfg) { diff --git a/local-dev/verify_test_small3.txt b/local-dev/verify_test_small3.txt new file mode 100644 index 0000000..9b1eec2 --- /dev/null +++ b/local-dev/verify_test_small3.txt @@ -0,0 +1 @@ +C:\Python313\python.exe: can't open file 'C:\\Users\\alaed\\Documents\\Github\\caramel\\local-dev\\main.py': [Errno 2] No such file or directory From 8e6dfdc7363322d3ea1fa3874bf5d52e8df4c8ed Mon Sep 17 00:00:00 2001 From: Alaeddin <15094821+BSalaeddin@users.noreply.github.com> Date: Sat, 2 May 2026 04:42:37 +0100 Subject: [PATCH 011/198] =?UTF-8?q?fix(extension):=20detectCouponError=20r?= =?UTF-8?q?equires=20code-echo=20or=20rejection-word=20=E2=80=94=20fixes?= =?UTF-8?q?=20'no=20savings'=20on=20aria-live=20status=20regions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/caramel-extension/shared-utils.js | 39 +++++++++++++++----------- 1 file changed, 22 insertions(+), 17 deletions(-) diff --git a/apps/caramel-extension/shared-utils.js b/apps/caramel-extension/shared-utils.js index 5044c21..dfa3694 100644 --- a/apps/caramel-extension/shared-utils.js +++ b/apps/caramel-extension/shared-utils.js @@ -428,30 +428,35 @@ function snapshotErrorState(rec) { } } -function detectCouponError(rec, baseline) { +const ERROR_WORDS_RE = + /\b(invalid|expired|not valid|doesn'?t apply|cannot be applied|cannot apply|already used|no longer|reached the limit|minimum|coupon code is required|wrong code)\b/i + +function detectCouponError(rec, baseline, code) { // baseline is what snapshotErrorState() returned BEFORE the apply. - // We only call something an "error" if the state CHANGED — first - // appearance, or text changed (e.g. now mentions the failed code). + // code is the coupon string we just tried. + // + // We only call something an "error" if the change is "about" this + // attempt. Sites like logos.com keep an aria-live status region + // permanently mounted and rotate text through it (success messages, + // hints, prior errors) — naive presence checks on those false-positive + // forever and the loop never stops on a valid coupon. if (rec.errorIndicator) { const el = qOne(rec.errorIndicator) if (el && el.offsetParent !== null) { const t = (el.innerText || '').trim() if (t.length) { - if (!baseline) return t // back-compat, no snapshot - const wasVisible = baseline.visible - const wasText = baseline.text - // Treat as a real error only when: - // - text appeared (was empty, now has content), OR - // - text actually changed (new error string), OR - // - element became visible (was hidden, now shown) - if ( - (!wasVisible && el.offsetParent !== null) || - t !== wasText - ) { + const tl = t.toLowerCase() + // Strongest signal: text mentions the code we just tried. + if (code && tl.includes(code.toLowerCase())) return t + // Strong signal: classic rejection vocabulary. + if (ERROR_WORDS_RE.test(tl)) return t + // Otherwise — ambiguous status text. Treat as new error + // only if the container first appeared (was hidden, now + // shown) — never on text-changed-but-still-vague. + if (baseline && !baseline.visible && el.offsetParent !== null) { return t } - // Same text + same visibility = stale container, ignore. - return null + return null // generic / stale status copy → not our error } } } @@ -566,7 +571,7 @@ async function applyCoupon(code, rec) { // - savings = price actually dropped const afterAppliedNodes = qAll(appliedSel).length const committed = afterAppliedNodes > beforeAppliedNodes - const errorMsg = detectCouponError(rec, errorBaseline) + const errorMsg = detectCouponError(rec, errorBaseline, code) let newTotal = NaN let priceDropped = false if (hasPriceCfg) { From 5aedad3a85d5d195cc689b6f947e905947af2dcf Mon Sep 17 00:00:00 2001 From: Alaeddin <15094821+BSalaeddin@users.noreply.github.com> Date: Sat, 2 May 2026 06:13:02 +0100 Subject: [PATCH 012/198] =?UTF-8?q?fix(extension):=20success=20when=20comm?= =?UTF-8?q?itted=20AND=20row=20sticks=20after=201.2s=20=E2=80=94=20sites?= =?UTF-8?q?=20without=20priceContainer=20(logos.com)=20need=20stickiness?= =?UTF-8?q?=20check,=20not=20error-region-text=20checks=20alone?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/caramel-extension/shared-utils.js | 28 ++++++++++++++++++++------ 1 file changed, 22 insertions(+), 6 deletions(-) diff --git a/apps/caramel-extension/shared-utils.js b/apps/caramel-extension/shared-utils.js index dfa3694..ba40dad 100644 --- a/apps/caramel-extension/shared-utils.js +++ b/apps/caramel-extension/shared-utils.js @@ -567,10 +567,21 @@ async function applyCoupon(code, rec) { // 5] Determine outcome: // - committed = something visibly applied (DOM mutation) - // - errorMsg = error text appeared near input - // - savings = price actually dropped + // - stuck = the row was still there 1.2s later (site didn't + // auto-revert it). On no-priceContainer sites this + // is the real "did it apply?" signal — sites like + // logos.com keep an aria-live error region with + // stale text that defeats errorMsg-based detection. + // - errorMsg = error text appeared near input + // - savings = price actually dropped const afterAppliedNodes = qAll(appliedSel).length const committed = afterAppliedNodes > beforeAppliedNodes + let stuck = false + if (committed) { + await sleep(1200) + const stuckCount = qAll(appliedSel).length + stuck = stuckCount > beforeAppliedNodes + } const errorMsg = detectCouponError(rec, errorBaseline, code) let newTotal = NaN let priceDropped = false @@ -579,10 +590,15 @@ async function applyCoupon(code, rec) { priceDropped = !isNaN(newTotal) && newTotal < original } // Success rules (in priority order): - // 1. price dropped → real win - // 2. committed AND no error → likely worked, treat as success - // 3. otherwise → fail - const success = priceDropped || (committed && !errorMsg) + // 1. price dropped → real win + // 2. committed AND row stuck → site accepted it (no + // need to trust the + // often-noisy error region) + // 3. committed AND no errorMsg → fallback for sites that + // don't keep their list mounted + // 4. otherwise → fail + const success = + priceDropped || (committed && stuck) || (committed && !errorMsg) const elapsed = performance.now() - attemptStart log('AUTO_INSERT_ATTEMPT_END', code, { success, From 864656d089d83ecc205d2abdf52b4d130a6ab209 Mon Sep 17 00:00:00 2001 From: Alaeddin <15094821+BSalaeddin@users.noreply.github.com> Date: Sat, 2 May 2026 06:15:21 +0100 Subject: [PATCH 013/198] =?UTF-8?q?feat(extension):=20celebrate=20applied-?= =?UTF-8?q?code=20state=20distinctly=20from=20price-drop=20state=20?= =?UTF-8?q?=E2=80=94=20'=E2=9C=93=20Coupon=20Applied'=20headline=20+=20'Di?= =?UTF-8?q?scount=20visible=20in=20your=20cart'=20instead=20of=20misleadin?= =?UTF-8?q?g=20'No=20Savings=20This=20Time'=20on=20sites=20without=20price?= =?UTF-8?q?Container?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/caramel-extension/UI-helpers.js | 68 ++++++++++++++++++++-------- 1 file changed, 49 insertions(+), 19 deletions(-) diff --git a/apps/caramel-extension/UI-helpers.js b/apps/caramel-extension/UI-helpers.js index 5fd8a90..4cb2459 100644 --- a/apps/caramel-extension/UI-helpers.js +++ b/apps/caramel-extension/UI-helpers.js @@ -223,17 +223,31 @@ async function showFinalModal(savingsAmount, code, message, isSignIn = false) { modal.style.textAlign = 'center' modal.style.position = 'relative' - // Determine if user saved money - const isSuccess = savingsAmount > 0 - const noCouponsAvailable = !!message // when message is provided, it means no coupons or error + // Three terminal states the user might be in: + // savedMoney = we computed a real price drop ($X off) + // appliedCode = we applied a code but couldn't measure savings + // (site has no priceContainer in config — the cart + // may show the discount inline; we just can't read + // the number reliably). Still a win for the user. + // noLuck = nothing applied (or signed-out / network error) + const savedMoney = savingsAmount > 0 + const appliedCode = !savedMoney && !!code + const isSuccess = savedMoney || appliedCode - // If no savings found, encourage the user that it's already the best price - const defaultMessage = isSuccess - ? `We found a coupon that saves you $${savingsAmount.toFixed(2)}!` - : "Looks like you're already getting the best deal. Go ahead and buy!" - - // You can decide whether to use `message` or `defaultMessage` or combine them - const finalMessage = message || defaultMessage + // Build the secondary message based on which state we landed in. + let defaultMessage + if (savedMoney) { + defaultMessage = `We found a coupon that saves you $${savingsAmount.toFixed(2)}!` + } else if (appliedCode) { + defaultMessage = `Code ${code} is applied to your cart — review the discount before you check out.` + } else { + defaultMessage = + "Looks like you're already getting the best deal. Go ahead and buy!" + } + // Caller-supplied message takes precedence ONLY when we have no win + // to celebrate (otherwise the appliedCode message wins so the user + // sees what we did for them). + const finalMessage = isSuccess ? defaultMessage : message || defaultMessage // Caramel brand/logo const brandColor = '#ea6925' @@ -257,22 +271,38 @@ async function showFinalModal(savingsAmount, code, message, isSignIn = false) { font-size: 24px; font-weight: bold; "> - ${isSuccess ? '🎉 Savings Found! 🎉' : isSignIn ? 'Oups..' : noCouponsAvailable ? 'No Savings This Time' : 'Best Price Already!'} + ${ + savedMoney + ? '🎉 Savings Found! 🎉' + : appliedCode + ? '✓ Coupon Applied' + : isSignIn + ? 'Oups..' + : message + ? 'No Savings This Time' + : 'Best Price Already!' + }

${finalMessage}

- - + ${ isSuccess ? ` -

- Coupon: ${code} -

-

- You saved $${savingsAmount.toFixed(2)}! -

` +

+ Code: ${code} +

+ ${ + savedMoney + ? `

+ You saved $${savingsAmount.toFixed(2)}! +

` + : `

+ Discount visible in your cart. +

` + } + ` : '' } From c511c9b2478e3c5421fb5e6c75740bd497cf044e Mon Sep 17 00:00:00 2001 From: Alaeddin <15094821+BSalaeddin@users.noreply.github.com> Date: Sat, 2 May 2026 07:14:50 +0100 Subject: [PATCH 014/198] =?UTF-8?q?fix(extension):=20smart=20cart-signal?= =?UTF-8?q?=20waiter=20(poll=20up=20to=204s=20for=20success=20row=20OR=20e?= =?UTF-8?q?rror=20text)=20=E2=80=94=20replaces=20fixed=201.2s=20sleep=20th?= =?UTF-8?q?at=20caused=20next=20attempt=20to=20mis-attribute=20prior=20cod?= =?UTF-8?q?e's=20slow-rendered=20row=20on=20logos.com=20et=20al?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/caramel-extension/shared-utils.js | 26 +++++++++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) diff --git a/apps/caramel-extension/shared-utils.js b/apps/caramel-extension/shared-utils.js index ba40dad..7606ad4 100644 --- a/apps/caramel-extension/shared-utils.js +++ b/apps/caramel-extension/shared-utils.js @@ -547,8 +547,27 @@ async function applyCoupon(code, rec) { }), ) - /* 4] wait for result */ - const waiters = [sleep(1200).then(() => 'timeout-1.2s')] + /* 4] wait for result — smart waiter: poll DOM up to 4s for the + FIRST observable signal (success row appears OR error region + gains text). Many sites (logos.com, hybris-style carts) need + 1.5-3s to render their applied row; fixed 1.2s sleeps cause + the next attempt to mis-attribute the prior code's row. */ + async function waitForCartSignal(maxMs) { + const baseSuccess = qAll(appliedSel).length + const baseErr = (qOne(rec.errorIndicator)?.innerText || '').trim() + const startedAt = performance.now() + while (performance.now() - startedAt < maxMs) { + const nowSuccess = qAll(appliedSel).length + if (nowSuccess > baseSuccess) return 'committed' + const errEl = qOne(rec.errorIndicator) + if (errEl && errEl.offsetParent !== null) { + const t = (errEl.innerText || '').trim() + if (t.length && t !== baseErr) return 'errored' + } + await sleep(200) + } + return 'timeout-' + maxMs + 'ms' + } let priceEl = null if (hasPriceCfg) { priceEl = @@ -558,8 +577,9 @@ async function applyCoupon(code, rec) { '', ) } + const waiters = [waitForCartSignal(4000)] if (priceEl && rec.domain !== 'amazon.com') - waiters.push(waitForTextChange(priceEl, 3000)) + waiters.push(waitForTextChange(priceEl, 4000)) if (rec.domain === 'amazon.com') waiters.push(waitForAmazonFetch()) const via = await Promise.race(waiters) From 4997c48d0dbc36f7f00a95f10d7309b6f3cf64ef Mon Sep 17 00:00:00 2001 From: Alaeddin <15094821+BSalaeddin@users.noreply.github.com> Date: Sat, 2 May 2026 10:40:39 +0100 Subject: [PATCH 015/198] =?UTF-8?q?fix(api/supported-stores):=20require=20?= =?UTF-8?q?ALL=205=20contract=20fields,=20not=20just=20input+apply=20?= =?UTF-8?q?=E2=80=94=20partial=20configs=20caused=20try-loop=20to=20stack?= =?UTF-8?q?=20invalid=20coupons?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/app/api/extension/supported-stores/route.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/apps/caramel-app/src/app/api/extension/supported-stores/route.ts b/apps/caramel-app/src/app/api/extension/supported-stores/route.ts index 4687753..9ce3cbe 100644 --- a/apps/caramel-app/src/app/api/extension/supported-stores/route.ts +++ b/apps/caramel-app/src/app/api/extension/supported-stores/route.ts @@ -51,8 +51,15 @@ export async function GET(req: NextRequest) { FROM store_verification_configs cfg JOIN verification_stores s ON s.id = cfg.store_id WHERE cfg.is_active = TRUE - AND cfg.coupon_input_xpath IS NOT NULL - AND cfg.apply_button_xpath IS NOT NULL + -- Require the FULL 5-field extension contract. Partial configs + -- (missing success/error/remove) cause the try-loop to either + -- false-success on rejected codes or stack invalid coupons — + -- the exact UX failures observed on logos.com pre-2026-05-02. + AND cfg.coupon_input_xpath IS NOT NULL + AND cfg.apply_button_xpath IS NOT NULL + AND cfg.success_indicator_xpath IS NOT NULL + AND cfg.error_indicator_xpath IS NOT NULL + AND cfg.coupon_remove_xpath IS NOT NULL ORDER BY s.store_name, cfg.priority DESC, cfg.updated_at DESC `) as Row[] From 066ef47335437d4661a74b0a495d28e0aa5292bf Mon Sep 17 00:00:00 2001 From: Alaeddin <15094821+BSalaeddin@users.noreply.github.com> Date: Sat, 2 May 2026 10:56:15 +0100 Subject: [PATCH 016/198] fix(api/extension): exclude stores flagged metadata.extension_compatible=false christianbook.com has full 5-field xpath contract but selectors live on the post-login checkout page, not the cart entry_url. Extension cannot reach them without driving the multi-step quick-checkout flow. Honor the extension_compatible=false verdict (set by agent HARD validation or manual review) so these stores drop out of the extension's effective list while keeping the API verifier path operational. --- .../src/app/api/extension/supported-stores/route.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/apps/caramel-app/src/app/api/extension/supported-stores/route.ts b/apps/caramel-app/src/app/api/extension/supported-stores/route.ts index 9ce3cbe..eb4bbbc 100644 --- a/apps/caramel-app/src/app/api/extension/supported-stores/route.ts +++ b/apps/caramel-app/src/app/api/extension/supported-stores/route.ts @@ -60,6 +60,12 @@ export async function GET(req: NextRequest) { AND cfg.success_indicator_xpath IS NOT NULL AND cfg.error_indicator_xpath IS NOT NULL AND cfg.coupon_remove_xpath IS NOT NULL + -- Honor the agent's extension_compatible verdict. Stores like + -- christianbook.com have full xpaths but they live on the + -- checkout page, not the entry_url cart page — the extension + -- can't reach them. Agent / manual review sets this to false + -- when it observes that selectors don't render at entry_url. + AND COALESCE(cfg.metadata->>'extension_compatible', 'true') <> 'false' ORDER BY s.store_name, cfg.priority DESC, cfg.updated_at DESC `) as Row[] From cb3d69b297bb8c47a14e73b4d8826e94f8b2439f Mon Sep 17 00:00:00 2001 From: Alaeddin <15094821+BSalaeddin@users.noreply.github.com> Date: Sat, 2 May 2026 11:05:10 +0100 Subject: [PATCH 017/198] fix(extension): pick first visible+non-empty match in detectCouponError MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Some Shopify themes (paragonsports and similar) render two .alert.alert--error containers — a hidden empty placeholder and a real one that fills with text. qOne returned the placeholder and missed the actual rejection message, so the extension thought every code applied successfully when in fact every code was being rejected by the API silently. New _firstVisibleErrorEl helper iterates all selector matches and returns the first visible non-empty one. Both snapshotErrorState (baseline) and detectCouponError now use it. Verified live: the selector .alert.alert--error has 2 matches on paragonsports — the hidden empty one and a visible one reading 'Discount code cannot be applied to your cart'. Old code missed it entirely. --- apps/caramel-extension/shared-utils.js | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/apps/caramel-extension/shared-utils.js b/apps/caramel-extension/shared-utils.js index 7606ad4..66f1cea 100644 --- a/apps/caramel-extension/shared-utils.js +++ b/apps/caramel-extension/shared-utils.js @@ -412,6 +412,24 @@ async function removeAppliedCoupon(rec) { } // Detect if an error message appeared near the cart/coupon UI. +// Pick the first VISIBLE non-empty match across all elements that match the +// errorIndicator selector. Some Shopify themes (paragonsports, others) ship +// two `.alert.alert--error` elements: a hidden empty placeholder and a +// real one that fills with text. `qOne` would return the placeholder and +// miss the real error. Iterate so we always observe the live one. +function _firstVisibleErrorEl(rec) { + if (!rec.errorIndicator) return null + const all = qAll(rec.errorIndicator) + for (const el of all) { + if (!el || el.offsetParent === null) continue + const t = (el.innerText || '').trim() + if (t.length) return el + } + // No visible non-empty match — return the first match (so callers can + // still observe "exists but hidden/empty" baseline state). + return all[0] || null +} + function snapshotErrorState(rec) { // Capture the error region's text + visibility BEFORE we apply, so we can // tell whether an error appeared *because of this attempt* vs. a stale @@ -420,7 +438,7 @@ function snapshotErrorState(rec) { // would treat permanently-mounted empty error containers as "error // present" and loop forever even after a valid coupon applied. if (!rec.errorIndicator) return { text: '', visible: false } - const el = qOne(rec.errorIndicator) + const el = _firstVisibleErrorEl(rec) if (!el) return { text: '', visible: false } return { text: (el.innerText || '').trim(), @@ -441,7 +459,7 @@ function detectCouponError(rec, baseline, code) { // hints, prior errors) — naive presence checks on those false-positive // forever and the loop never stops on a valid coupon. if (rec.errorIndicator) { - const el = qOne(rec.errorIndicator) + const el = _firstVisibleErrorEl(rec) if (el && el.offsetParent !== null) { const t = (el.innerText || '').trim() if (t.length) { From f1f0413b5770bce35eb9a0c8db570b4a5e6d6a21 Mon Sep 17 00:00:00 2001 From: Alaeddin <15094821+BSalaeddin@users.noreply.github.com> Date: Sat, 2 May 2026 12:08:27 +0100 Subject: [PATCH 018/198] fix(extension): smart waiter uses _firstVisibleErrorEl too Cycle-9 fix only updated detectCouponError + snapshotErrorState. The smart cart-signal waiter (waitForCartSignal) was still using qOne(errorIndicator) directly, which on multi-error-container sites latches to the empty placeholder forever. The waiter would never trip the 'errored' early-exit and would burn the full 4s timeout on every rejected code instead of returning fast. Switching to _firstVisibleErrorEl matches the rest of the cycle-9 plumbing. paragonsports-class sites now exit the waiter as soon as the visible non-empty error renders, instead of waiting for the timeout. --- apps/caramel-extension/shared-utils.js | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/apps/caramel-extension/shared-utils.js b/apps/caramel-extension/shared-utils.js index 66f1cea..e118f90 100644 --- a/apps/caramel-extension/shared-utils.js +++ b/apps/caramel-extension/shared-utils.js @@ -572,12 +572,16 @@ async function applyCoupon(code, rec) { the next attempt to mis-attribute the prior code's row. */ async function waitForCartSignal(maxMs) { const baseSuccess = qAll(appliedSel).length - const baseErr = (qOne(rec.errorIndicator)?.innerText || '').trim() + // Use _firstVisibleErrorEl so multi-error-container sites + // (paragonsports-class — empty placeholder + real error) aren't + // permanently latched to the empty placeholder's "" baseline. + const baseErrEl = _firstVisibleErrorEl(rec) + const baseErr = baseErrEl ? (baseErrEl.innerText || '').trim() : '' const startedAt = performance.now() while (performance.now() - startedAt < maxMs) { const nowSuccess = qAll(appliedSel).length if (nowSuccess > baseSuccess) return 'committed' - const errEl = qOne(rec.errorIndicator) + const errEl = _firstVisibleErrorEl(rec) if (errEl && errEl.offsetParent !== null) { const t = (errEl.innerText || '').trim() if (t.length && t !== baseErr) return 'errored' From ab8603e1e539423f3ae082579ef178369cb8c634 Mon Sep 17 00:00:00 2001 From: Alaeddin <15094821+BSalaeddin@users.noreply.github.com> Date: Sat, 2 May 2026 13:32:06 +0100 Subject: [PATCH 019/198] feat(extension): apply via full pointer+mouse+click sequence Some Shopify themes (paragonsports-class) gate the discount apply on pointerdown rather than click. A bare .click() fires the click handler but not the pointer chain, so the site's own JS skips it. Now the extension dispatches the full sequence pointerdown -> pointerup -> mousedown -> mouseup -> click before the keydown Enter fallback. Matches what a real user click produces. Sites that genuinely require event.isTrusted=true (olaplex, paleoonthego) still won't accept synthetic events - those are correctly flagged extension_compatible:false by the agent's HARD VALIDATION 2 which now uses the same probe sequence to avoid false-flagging sites that pass on pointer events but fail on bare .click(). --- apps/caramel-extension/shared-utils.js | 34 ++++++++++++++++++++++++-- 1 file changed, 32 insertions(+), 2 deletions(-) diff --git a/apps/caramel-extension/shared-utils.js b/apps/caramel-extension/shared-utils.js index e118f90..80ca8c6 100644 --- a/apps/caramel-extension/shared-utils.js +++ b/apps/caramel-extension/shared-utils.js @@ -548,10 +548,40 @@ async function applyCoupon(code, rec) { /* 3] fill & apply — choose method dynamically: a) if applyBtn === input → auto-validate on input event - b) if applyBtn is a button/element distinct → click - c) Enter-key submit also dispatched as a fallback for forms */ + b) if applyBtn is a button/element distinct → full pointer+click sequence + c) Enter-key submit also dispatched as a fallback for forms + + Why a full pointer sequence and not just .click(): some Shopify themes + (paragonsports-class) gate the apply on `pointerdown` rather than the + click event. A bare .click() fires the click handler but not the + pointer chain, so the site's own JS rejects it. This sequence makes + the extension match what a real user click dispatches (pointerdown → + pointerup → mousedown → mouseup → click). Sites that genuinely + require event.isTrusted=true (olaplex / paleoonthego) still won't + accept this — those are correctly flagged extension_compatible:false + by the agent's HARD VALIDATION 2 step. */ setInputValue(input, code) if (applyBtn !== input) { + try { + const r = applyBtn.getBoundingClientRect() + const cx = r.x + r.width / 2 + const cy = r.y + r.height / 2 + const evtInit = { + bubbles: true, + cancelable: true, + clientX: cx, + clientY: cy, + view: window, + button: 0, + } + applyBtn.dispatchEvent(new PointerEvent('pointerdown', evtInit)) + applyBtn.dispatchEvent(new PointerEvent('pointerup', evtInit)) + applyBtn.dispatchEvent(new MouseEvent('mousedown', evtInit)) + applyBtn.dispatchEvent(new MouseEvent('mouseup', evtInit)) + } catch (_) { + // Older browsers without PointerEvent constructor — fall through + // to plain click which still works on most sites. + } applyBtn.click() } // Always dispatch Enter on the input — harmless when no submit handler, From 19c78564848bd8dcea2daabecbfd6a975cc8f485 Mon Sep 17 00:00:00 2001 From: Alaeddin <15094821+BSalaeddin@users.noreply.github.com> Date: Mon, 4 May 2026 12:50:00 +0100 Subject: [PATCH 020/198] =?UTF-8?q?fix(extension):=20canonical=20event-ord?= =?UTF-8?q?er=20pd=E2=86=92md=E2=86=92pu=E2=86=92mu=E2=86=92click=20+=20ap?= =?UTF-8?q?ply=20wait=204s=E2=86=9210s=20=E2=80=94=20Polaris=20checkouts?= =?UTF-8?q?=20respond=20in=205-8s;=20pd=E2=86=92pu=E2=86=92md=E2=86=92mu?= =?UTF-8?q?=E2=86=92click=20broke=20React=20handlers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/caramel-extension/shared-utils.js | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/apps/caramel-extension/shared-utils.js b/apps/caramel-extension/shared-utils.js index 80ca8c6..d9e188b 100644 --- a/apps/caramel-extension/shared-utils.js +++ b/apps/caramel-extension/shared-utils.js @@ -574,9 +574,16 @@ async function applyCoupon(code, rec) { view: window, button: 0, } + // Canonical browser ordering for a primary-pointer click: + // pointerdown → mousedown → pointerup → mouseup → click. + // The previous pd→pu→md→mu order broke React handlers on + // Polaris-class checkouts (alwaystoast / skinnyfit / others + // — verifier-validated codes silently dropped). Real-click + // probe confirmed the UI accepts these codes when events fire + // in canonical order. applyBtn.dispatchEvent(new PointerEvent('pointerdown', evtInit)) - applyBtn.dispatchEvent(new PointerEvent('pointerup', evtInit)) applyBtn.dispatchEvent(new MouseEvent('mousedown', evtInit)) + applyBtn.dispatchEvent(new PointerEvent('pointerup', evtInit)) applyBtn.dispatchEvent(new MouseEvent('mouseup', evtInit)) } catch (_) { // Older browsers without PointerEvent constructor — fall through @@ -629,9 +636,13 @@ async function applyCoupon(code, rec) { '', ) } - const waiters = [waitForCartSignal(4000)] + // Polaris (Shopify) checkouts often respond in 5-8s for the apply + // round-trip — 4s was clipping valid codes. Bumped to 10s; the loop + // still races so a faster site exits as soon as a signal lands. + const APPLY_WAIT_MS = 10000 + const waiters = [waitForCartSignal(APPLY_WAIT_MS)] if (priceEl && rec.domain !== 'amazon.com') - waiters.push(waitForTextChange(priceEl, 4000)) + waiters.push(waitForTextChange(priceEl, APPLY_WAIT_MS)) if (rec.domain === 'amazon.com') waiters.push(waitForAmazonFetch()) const via = await Promise.race(waiters) From af2d7ce91264fa3326216caa40e31c166259a6b8 Mon Sep 17 00:00:00 2001 From: Alaeddin <15094821+BSalaeddin@users.noreply.github.com> Date: Fri, 19 Jun 2026 20:27:45 +0100 Subject: [PATCH 021/198] feat(coupons): surface pending coupons + verification badges (verified/unverified/not-valid) on page, SSR, and extension --- .../app/(marketing)/coupons/[store]/page.tsx | 12 +++++-- apps/caramel-app/src/app/api/coupons/route.ts | 11 +++--- .../src/components/coupons/coupon-card.tsx | 33 +++++++++++++++++- apps/caramel-app/src/types/coupon.ts | 13 +++++++ apps/caramel-extension/popup.js | 34 +++++++++++++++++++ 5 files changed, 95 insertions(+), 8 deletions(-) diff --git a/apps/caramel-app/src/app/(marketing)/coupons/[store]/page.tsx b/apps/caramel-app/src/app/(marketing)/coupons/[store]/page.tsx index 20f84ce..f46722d 100644 --- a/apps/caramel-app/src/app/(marketing)/coupons/[store]/page.tsx +++ b/apps/caramel-app/src/app/(marketing)/coupons/[store]/page.tsx @@ -40,20 +40,26 @@ async function fetchStoreCoupons(storeParam: string) { return { coupons: [] as Coupon[], total: 0, base: storeParam } } + // Match /api/coupons: surface verified, restricted, AND not-yet-verified + // (pending/retry) coupons so SSR HTML and the client fetch agree (no + // hydration flash). Known-dead (invalid/expired) stay excluded. The + // status field drives the per-coupon verification badge. + const VISIBLE_STATUSES = couponsSql`status IN ('valid','valid_with_warning','product_restriction','category_restricted','seller_specific','pending','retry') AND expired = FALSE` const [coupons, totalRow] = await Promise.all([ couponsSql` SELECT id, code, site, title, description, rating, discount_type, discount_amount, expiry, expired, - times_used AS "timesUsed" + times_used AS "timesUsed", + status, verification_message AS "verificationMessage" FROM coupons - WHERE status = 'valid' AND status = 'valid' AND expired = FALSE + WHERE ${VISIBLE_STATUSES} AND (site = ${base} OR site LIKE ${'%.' + base}) ORDER BY rating DESC, created_at DESC LIMIT ${PAGE_SIZE} `, couponsSql` SELECT COUNT(*)::int AS total FROM coupons - WHERE status = 'valid' AND status = 'valid' AND expired = FALSE + WHERE ${VISIBLE_STATUSES} AND (site = ${base} OR site LIKE ${'%.' + base}) `, ]) diff --git a/apps/caramel-app/src/app/api/coupons/route.ts b/apps/caramel-app/src/app/api/coupons/route.ts index dd3ecbf..323a010 100644 --- a/apps/caramel-app/src/app/api/coupons/route.ts +++ b/apps/caramel-app/src/app/api/coupons/route.ts @@ -38,11 +38,14 @@ export async function GET(req: NextRequest) { (url.searchParams.get('key_words') || '').slice(0, 200) || undefined try { - // Surface valid + restriction-tagged coupons. The extension uses the - // status field to decide whether to classify the cart and surface - // a "may not apply" warning to the user. + // Surface verified, restriction-tagged, AND not-yet-verified coupons. + // The bulk of the catalog sits at status='pending' (scraped, not yet + // run through the verification module); those must still show to users + // with a neutral "Unverified" badge. 'retry' = mid-verification. We + // exclude 'invalid'/'expired' so known-dead codes are never surfaced. + // The status field drives the verified/unverified/not-valid badge. const conditions = [ - couponsSql`status IN ('valid','valid_with_warning','product_restriction','category_restricted','seller_specific') AND expired = FALSE`, + couponsSql`status IN ('valid','valid_with_warning','product_restriction','category_restricted','seller_specific','pending','retry') AND expired = FALSE`, ] if (site) { diff --git a/apps/caramel-app/src/components/coupons/coupon-card.tsx b/apps/caramel-app/src/components/coupons/coupon-card.tsx index 33c95a0..4cf4ab3 100644 --- a/apps/caramel-app/src/components/coupons/coupon-card.tsx +++ b/apps/caramel-app/src/components/coupons/coupon-card.tsx @@ -1,6 +1,6 @@ 'use client' -import type { Coupon } from '@/types/coupon' +import type { Coupon, CouponStatus } from '@/types/coupon' import { motion } from 'framer-motion' import { useState } from 'react' import { toast } from 'sonner' @@ -10,6 +10,29 @@ interface CouponCardProps { index: number } +// Verification badge: green = machine-verified, amber = verified-but-restricted, +// grey = not yet verified (grace), red = known not valid. +const AMBER = + 'bg-amber-100 text-amber-700 ring-amber-200 dark:bg-amber-900/30 dark:text-amber-300 dark:ring-amber-900/50' +const GREY = + 'bg-gray-100 text-gray-600 ring-gray-200 dark:bg-gray-800 dark:text-gray-300 dark:ring-gray-700' +const RED = + 'bg-red-100 text-red-700 ring-red-200 dark:bg-red-900/30 dark:text-red-300 dark:ring-red-900/50' +const STATUS_BADGE: Record = { + valid: { + label: '✓ Verified', + cls: 'bg-green-100 text-green-700 ring-green-200 dark:bg-green-900/30 dark:text-green-300 dark:ring-green-900/50', + }, + valid_with_warning: { label: 'Verified · may vary', cls: AMBER }, + product_restriction: { label: 'Restrictions apply', cls: AMBER }, + category_restricted: { label: 'Category-limited', cls: AMBER }, + seller_specific: { label: 'Seller-specific', cls: AMBER }, + pending: { label: 'Unverified', cls: GREY }, + retry: { label: 'Checking…', cls: GREY }, + invalid: { label: 'Not valid', cls: RED }, + expired: { label: 'Expired', cls: RED }, +} + export default function CouponCard({ coupon, index }: CouponCardProps) { const [showCode, setShowCode] = useState(false) @@ -64,6 +87,14 @@ export default function CouponCard({ coupon, index }: CouponCardProps) { {coupon.timesUsed} used today

)} + {coupon.status && STATUS_BADGE[coupon.status] && ( + + {STATUS_BADGE[coupon.status].label} + + )}
{/* Right: CTA Button */} diff --git a/apps/caramel-app/src/types/coupon.ts b/apps/caramel-app/src/types/coupon.ts index ff3ddfb..b7cdc74 100644 --- a/apps/caramel-app/src/types/coupon.ts +++ b/apps/caramel-app/src/types/coupon.ts @@ -1,3 +1,14 @@ +export type CouponStatus = + | 'valid' + | 'valid_with_warning' + | 'product_restriction' + | 'category_restricted' + | 'seller_specific' + | 'pending' + | 'retry' + | 'invalid' + | 'expired' + export interface Coupon { id: string code: string @@ -10,6 +21,8 @@ export interface Coupon { expiry: string expired: boolean timesUsed: number + status?: CouponStatus + verificationMessage?: string | null } export interface CouponFilters { diff --git a/apps/caramel-extension/popup.js b/apps/caramel-extension/popup.js index 8345724..89f9621 100644 --- a/apps/caramel-extension/popup.js +++ b/apps/caramel-extension/popup.js @@ -562,10 +562,44 @@ function renderCouponsView(coupons, user, domain) { ${verifierMsg} ` } + // Verification badge: green=verified, amber=restricted, + // grey=not yet verified (grace), red=known not valid. + const BADGE = { + valid: ['✓ Verified', '#15803d', '#dcfce7'], + valid_with_warning: [ + 'Verified · may vary', + '#b45309', + '#fef3c7', + ], + product_restriction: [ + 'Restrictions apply', + '#b45309', + '#fef3c7', + ], + category_restricted: [ + 'Category-limited', + '#b45309', + '#fef3c7', + ], + seller_specific: [ + 'Seller-specific', + '#b45309', + '#fef3c7', + ], + pending: ['Unverified', '#4b5563', '#f3f4f6'], + retry: ['Checking…', '#4b5563', '#f3f4f6'], + invalid: ['Not valid', '#b91c1c', '#fee2e2'], + expired: ['Expired', '#b91c1c', '#fee2e2'], + } + const bd = BADGE[c.status] + const badge = bd + ? `${bd[0]}` + : '' return `
${c.title || 'Untitled Coupon'}
${c.description || ''}
+ ${badge} ${warning}
From 477ccaac00f57a2c474de9634ce43ea4a8ca456a Mon Sep 17 00:00:00 2001 From: Alaeddin <15094821+BSalaeddin@users.noreply.github.com> Date: Fri, 19 Jun 2026 20:32:15 +0100 Subject: [PATCH 022/198] fix(security): remove hardcoded auth secret fallback; fix duplicate status filter + include pending in store lists --- apps/caramel-app/src/app/api/coupons/stores/route.ts | 4 ++-- apps/caramel-app/src/app/api/sites/top-sites/route.ts | 2 +- apps/caramel-app/src/lib/auth/auth.ts | 6 +++++- 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/apps/caramel-app/src/app/api/coupons/stores/route.ts b/apps/caramel-app/src/app/api/coupons/stores/route.ts index 1e746d5..af34b63 100644 --- a/apps/caramel-app/src/app/api/coupons/stores/route.ts +++ b/apps/caramel-app/src/app/api/coupons/stores/route.ts @@ -15,14 +15,14 @@ export async function GET(req: NextRequest) { const rows = q ? await couponsSql>` SELECT DISTINCT site FROM coupons - WHERE status = 'valid' AND status = 'valid' AND expired = FALSE + WHERE status IN ('valid','valid_with_warning','product_restriction','category_restricted','seller_specific','pending','retry') AND expired = FALSE AND (site ILIKE ${'%' + q + '%'} OR site ILIKE ${q + '%'}) ORDER BY site ASC LIMIT ${limit} ` : await couponsSql>` SELECT DISTINCT site FROM coupons - WHERE status = 'valid' AND status = 'valid' AND expired = FALSE + WHERE status IN ('valid','valid_with_warning','product_restriction','category_restricted','seller_specific','pending','retry') AND expired = FALSE ORDER BY site ASC LIMIT ${limit} ` diff --git a/apps/caramel-app/src/app/api/sites/top-sites/route.ts b/apps/caramel-app/src/app/api/sites/top-sites/route.ts index 1077732..a815fc7 100644 --- a/apps/caramel-app/src/app/api/sites/top-sites/route.ts +++ b/apps/caramel-app/src/app/api/sites/top-sites/route.ts @@ -10,7 +10,7 @@ export async function GET(req: NextRequest) { const rows = await couponsSql>` SELECT site, COUNT(*)::int AS coupon_count FROM coupons - WHERE status = 'valid' AND status = 'valid' AND expired = FALSE + WHERE status IN ('valid','valid_with_warning','product_restriction','category_restricted','seller_specific','pending','retry') AND expired = FALSE GROUP BY site ORDER BY coupon_count DESC LIMIT 4 diff --git a/apps/caramel-app/src/lib/auth/auth.ts b/apps/caramel-app/src/lib/auth/auth.ts index fb5d464..bcbb8e8 100644 --- a/apps/caramel-app/src/lib/auth/auth.ts +++ b/apps/caramel-app/src/lib/auth/auth.ts @@ -121,7 +121,11 @@ export const auth = betterAuth({ secret: process.env.BETTER_AUTH_SECRET || process.env.JWT_SECRET || - 'caramel-better-auth-secret', + (() => { + throw new Error( + 'BETTER_AUTH_SECRET (or JWT_SECRET) must be set — refusing to start with a hardcoded default signing key', + ) + })(), baseURL, trustedOrigins, advanced: { From fcaf129aa952bcafd3e99edb4754a62fe3b44a4f Mon Sep 17 00:00:00 2001 From: Alaeddin <15094821+BSalaeddin@users.noreply.github.com> Date: Fri, 19 Jun 2026 20:49:40 +0100 Subject: [PATCH 023/198] fix(security): increment POST+int-validate, expire api-key+cap, sources domain-validate, search-supported via couponsSql, align extension api-key header --- .../src/app/api/coupons/expire/route.ts | 31 +++++++++++- .../src/app/api/coupons/increment/route.ts | 14 ++++-- .../app/api/sites/search-supported/route.ts | 50 +++++++++++-------- apps/caramel-app/src/app/api/sources/route.ts | 13 +++++ apps/caramel-app/src/lib/rateLimit.ts | 5 +- 5 files changed, 85 insertions(+), 28 deletions(-) diff --git a/apps/caramel-app/src/app/api/coupons/expire/route.ts b/apps/caramel-app/src/app/api/coupons/expire/route.ts index 888c9e5..17c0331 100644 --- a/apps/caramel-app/src/app/api/coupons/expire/route.ts +++ b/apps/caramel-app/src/app/api/coupons/expire/route.ts @@ -6,17 +6,44 @@ import { } from '@/lib/rateLimit' import { NextRequest, NextResponse } from 'next/server' +// Bulk-expire coupons. Privileged: this permanently flips `expired=TRUE`, so +// it requires the extension/server API key (x-api-key) — not just an origin +// check. Bounded to 50 integer ids per call. export async function POST(req: NextRequest) { if (!isOriginAllowed(req)) return forbiddenOrigin() + + const key = req.headers.get('x-api-key') + const expected = process.env.EXTENSION_API_KEY + if (!expected || !key || key !== expected) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + const limited = await checkRateLimit(req, 'mutation') if (limited) return limited const { ids = [] } = (await req.json().catch(() => ({}))) as { - ids?: string[] + ids?: unknown[] } if (!Array.isArray(ids) || ids.length === 0) { return NextResponse.json({ count: 0 }) } + if (ids.length > 50) { + return NextResponse.json( + { error: 'Too many ids (max 50)' }, + { status: 400 }, + ) + } + const clean = Array.from( + new Set( + ids + .map(i => String(i)) + .filter(s => /^\d{1,18}$/.test(s)) + .map(Number), + ), + ) + if (clean.length === 0) { + return NextResponse.json({ count: 0 }) + } try { const rows = await couponsSql` @@ -24,7 +51,7 @@ export async function POST(req: NextRequest) { SET expired = TRUE, expiry = NOW()::text, updated_at = NOW() - WHERE id = ANY(${ids}) AND expired = FALSE + WHERE id = ANY(${clean}) AND expired = FALSE RETURNING id ` return NextResponse.json({ count: rows.length }) diff --git a/apps/caramel-app/src/app/api/coupons/increment/route.ts b/apps/caramel-app/src/app/api/coupons/increment/route.ts index ab42788..20f1a40 100644 --- a/apps/caramel-app/src/app/api/coupons/increment/route.ts +++ b/apps/caramel-app/src/app/api/coupons/increment/route.ts @@ -6,19 +6,27 @@ import { } from '@/lib/rateLimit' import { NextRequest, NextResponse } from 'next/server' -export async function GET(req: NextRequest) { +// Usage counter for a coupon. POST (not GET) so it can't be triggered by +// browser/CDN prefetch — a mutation must never ride a cacheable GET. +// id accepted via ?id= (back-compat) or JSON body; coupons.id is an integer. +export async function POST(req: NextRequest) { if (!isOriginAllowed(req)) return forbiddenOrigin() const limited = await checkRateLimit(req, 'mutation') if (limited) return limited const url = new URL(req.url) - const id = url.searchParams.get('id') - if (!id) { + let idRaw = url.searchParams.get('id') || '' + if (!idRaw) { + const body = (await req.json().catch(() => ({}))) as { id?: unknown } + idRaw = body?.id != null ? String(body.id) : '' + } + if (!/^\d{1,18}$/.test(idRaw)) { return NextResponse.json( { error: 'Invalid or missing coupon ID' }, { status: 400 }, ) } + const id = Number(idRaw) try { const rows = await couponsSql` diff --git a/apps/caramel-app/src/app/api/sites/search-supported/route.ts b/apps/caramel-app/src/app/api/sites/search-supported/route.ts index dc2722f..8260e8a 100644 --- a/apps/caramel-app/src/app/api/sites/search-supported/route.ts +++ b/apps/caramel-app/src/app/api/sites/search-supported/route.ts @@ -1,31 +1,37 @@ -import prisma from '@/lib/prisma' +import { couponsSql } from '@/lib/couponsDb' +import { checkRateLimit } from '@/lib/rateLimit' import { NextRequest, NextResponse } from 'next/server' +// Store-name autocomplete. Post-DB-split this must read the coupons catalog +// via couponsSql (the old Prisma "Coupon" model was dropped). Surfaces any +// store that has visible coupons (verified, restricted, or not-yet-verified). export async function POST(req: NextRequest) { - let body: any = {} + const limited = await checkRateLimit(req, 'read') + if (limited) return limited + + let body: { query?: string } = {} try { - body = await req.json() + body = (await req.json()) as { query?: string } } catch {} - const q = String((body?.query ?? '') as string).trim() + const q = String(body?.query ?? '') + .trim() + .slice(0, 100) if (!q) return NextResponse.json({ sites: [] }) - const likePattern = `%${q}%` + try { - const rows = await prisma.$queryRaw< - { site: string; sim_score: number }[] - >` - SELECT DISTINCT c.site, - COALESCE(similarity(c.site, ${q}), 0) as sim_score - FROM "Coupon" c - WHERE c.site ILIKE ${likePattern} - OR similarity(c.site, ${q}) > 0.25 - ORDER BY sim_score DESC, c.site ASC - ` - const sites = rows.map(r => r.site) - return NextResponse.json({ sites }) - } catch (err: any) { - return NextResponse.json( - { error: 'Search failed', details: err?.message }, - { status: 500 }, - ) + const rows = await couponsSql>` + SELECT DISTINCT site FROM coupons + WHERE status IN ('valid','valid_with_warning','product_restriction','category_restricted','seller_specific','pending','retry') + AND expired = FALSE + AND (site ILIKE ${'%' + q + '%'} OR site ILIKE ${q + '%'}) + ORDER BY site ASC + LIMIT 20 + ` + return NextResponse.json({ + sites: rows.map(r => r.site).filter(Boolean), + }) + } catch (err) { + console.error('search-supported failed:', err) + return NextResponse.json({ error: 'Search failed' }, { status: 500 }) } } diff --git a/apps/caramel-app/src/app/api/sources/route.ts b/apps/caramel-app/src/app/api/sources/route.ts index f8cb7d8..76cc83c 100644 --- a/apps/caramel-app/src/app/api/sources/route.ts +++ b/apps/caramel-app/src/app/api/sources/route.ts @@ -82,6 +82,19 @@ export async function POST(req: NextRequest) { if (!website) { return nextApiResponse(req, 400, 'Missing required fields.', null) } + // Validate it's a plausible domain/URL and bound the length, so this + // public "request a store" endpoint can't be used to bulk-insert junk. + let host = website + try { + host = new URL( + website.startsWith('http') ? website : `https://${website}`, + ).hostname + } catch { + return nextApiResponse(req, 400, 'Invalid website.', null) + } + if (host.length > 253 || !/^[a-z0-9.-]+\.[a-z]{2,}$/i.test(host)) { + return nextApiResponse(req, 400, 'Invalid website.', null) + } await couponsSql` INSERT INTO sources (id, source, websites, status, created_at, updated_at) diff --git a/apps/caramel-app/src/lib/rateLimit.ts b/apps/caramel-app/src/lib/rateLimit.ts index 15c0a3f..6115816 100644 --- a/apps/caramel-app/src/lib/rateLimit.ts +++ b/apps/caramel-app/src/lib/rateLimit.ts @@ -54,7 +54,10 @@ function getClientIp(req: NextRequest): string { } function isExtensionClient(req: NextRequest): boolean { - const key = req.headers.get('x-extension-api-key') + // Accept both header names: the extension sends `x-api-key` (same name the + // supported-stores route validates); `x-extension-api-key` kept for back-compat. + const key = + req.headers.get('x-api-key') || req.headers.get('x-extension-api-key') const expected = process.env.EXTENSION_API_KEY return Boolean(key && expected && key === expected) } From 8a261e5f0568ab2543be1790934e40db414de535 Mon Sep 17 00:00:00 2001 From: Alaeddin <15094821+BSalaeddin@users.noreply.github.com> Date: Tue, 23 Jun 2026 10:57:11 +0100 Subject: [PATCH 024/198] =?UTF-8?q?feat(extension):=20graceful=20degradati?= =?UTF-8?q?on=20=E2=80=94=20early-exit,=20dismiss,=20copy-list,=20friendly?= =?UTF-8?q?=20messages?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/caramel-extension/UI-helpers.js | 154 +++++++++++++++++++++---- apps/caramel-extension/shared-utils.js | 50 +++++++- 2 files changed, 177 insertions(+), 27 deletions(-) diff --git a/apps/caramel-extension/UI-helpers.js b/apps/caramel-extension/UI-helpers.js index 4cb2459..d7c910f 100644 --- a/apps/caramel-extension/UI-helpers.js +++ b/apps/caramel-extension/UI-helpers.js @@ -79,7 +79,11 @@ async function insertCaramelPrompt(domainRecord) { } catch (e) { // ignore } - startApplyingCoupons(domainRecord) + startApplyingCoupons(domainRecord).catch(err => { + // A throw mid-flow must never leave the overlay trapping the page. + console.error('Caramel: apply flow error', err) + hideTestingModal() + }) document.body.removeChild(container) }) @@ -142,6 +146,7 @@ async function showTestingModal(title = '', noLoading = false) { ">
` modal.innerHTML = ` +
Caramel Logo

@@ -168,6 +173,14 @@ async function showTestingModal(title = '', noLoading = false) { // Append modal to overlay, and overlay to body overlay.appendChild(modal) document.body.appendChild(overlay) + + // Let the user bail out — never trap them behind the overlay. + const _close = modal.querySelector('#caramel-testing-close') + if (_close) + _close.addEventListener('click', () => { + _caramelCancelled = true + hideTestingModal() + }) } /** @@ -196,7 +209,43 @@ function hideTestingModal() { } } -async function showFinalModal(savingsAmount, code, message, isSignIn = false) { +/* Robust, accurate copy of an exact coupon code. Tries the async clipboard + * API first (works on the user-gesture from the Copy click); falls back to a + * hidden textarea + execCommand for pages that block the async API. */ +async function caramelCopyText(text) { + try { + if (navigator.clipboard && navigator.clipboard.writeText) { + await navigator.clipboard.writeText(text) + return true + } + } catch (e) { + // page may block the async clipboard API — fall through to execCommand + } + try { + const ta = document.createElement('textarea') + ta.value = text + ta.setAttribute('readonly', '') + ta.style.position = 'fixed' + ta.style.top = '-1000px' + ta.style.opacity = '0' + document.body.appendChild(ta) + ta.focus() + ta.select() + const ok = document.execCommand('copy') + document.body.removeChild(ta) + return ok + } catch (e) { + return false + } +} + +async function showFinalModal( + savingsAmount, + code, + message, + isSignIn = false, + couponList = [], +) { hideTestingModal() // Create overlay const overlay = document.createElement('div') @@ -234,25 +283,77 @@ async function showFinalModal(savingsAmount, code, message, isSignIn = false) { const appliedCode = !savedMoney && !!code const isSuccess = savedMoney || appliedCode + // Manual fallback: auto-apply found nothing but we still have codes. Many + // codes are valid yet the store's checkout ignores the extension's + // synthetic click (Shopify one-page checkout requires event.isTrusted), + // so a hand-pasted code still works — offer the user a copy list. + const manualCodes = ( + !isSuccess && Array.isArray(couponList) ? couponList : [] + ) + .filter(c => c && c.code) + .slice(0, 8) + const hasManual = manualCodes.length > 0 + + const esc = s => + String(s == null ? '' : s).replace( + /[&<>"']/g, + ch => + ({ + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''', + })[ch], + ) + // Build the secondary message based on which state we landed in. - let defaultMessage + let finalMessage if (savedMoney) { - defaultMessage = `We found a coupon that saves you $${savingsAmount.toFixed(2)}!` + finalMessage = `We found a coupon that saves you $${savingsAmount.toFixed(2)}!` } else if (appliedCode) { - defaultMessage = `Code ${code} is applied to your cart — review the discount before you check out.` + finalMessage = `Code ${esc(code)} is applied to your cart — review the discount before you check out.` + } else if (hasManual) { + finalMessage = + "Auto-apply didn't stick this time — no biggie! Copy a code and drop it in the store's promo box 👇" } else { - defaultMessage = + finalMessage = + message || "Looks like you're already getting the best deal. Go ahead and buy!" } - // Caller-supplied message takes precedence ONLY when we have no win - // to celebrate (otherwise the appliedCode message wins so the user - // sees what we did for them). - const finalMessage = isSuccess ? defaultMessage : message || defaultMessage // Caramel brand/logo const brandColor = '#ea6925' const logoUrl = currentBrowser.runtime.getURL('assets/logo.png') // Adjust if needed + const heading = savedMoney + ? '🎉 Savings Found! 🎉' + : appliedCode + ? '✓ Coupon Applied' + : isSignIn + ? 'Oups..' + : hasManual + ? 'Grab a code 🎟️' + : 'Heads up 🙂' + + const manualBlock = hasManual + ? ` +
+ ${manualCodes + .map( + c => ` +
+
+
${esc(c.code)}
+ ${c.title ? `
${esc(c.title)}
` : ''} +
+ +
`, + ) + .join('')} +
` + : '' + // Modal inner HTML modal.innerHTML = ` @@ -271,27 +372,18 @@ async function showFinalModal(savingsAmount, code, message, isSignIn = false) { font-size: 24px; font-weight: bold; "> - ${ - savedMoney - ? '🎉 Savings Found! 🎉' - : appliedCode - ? '✓ Coupon Applied' - : isSignIn - ? 'Oups..' - : message - ? 'No Savings This Time' - : 'Best Price Already!' - } + ${heading}

${finalMessage}

+ ${manualBlock} ${ isSuccess ? `

- Code: ${code} + Code: ${esc(code)}

${ savedMoney @@ -322,7 +414,7 @@ async function showFinalModal(savingsAmount, code, message, isSignIn = false) { transition: background 0.3s; " > - ${isSignIn ? 'sing In' : 'Proceed to Checkout'} + ${isSignIn ? 'Sign In' : hasManual ? 'Done' : 'Proceed to Checkout'} ` @@ -338,6 +430,22 @@ async function showFinalModal(savingsAmount, code, message, isSignIn = false) { overlay.appendChild(modal) document.body.appendChild(overlay) + // Wire manual-copy buttons — copies the EXACT code shown (data-code). + modal.querySelectorAll('.caramel-manual-copy').forEach(btn => { + btn.addEventListener('click', async ev => { + ev.stopPropagation() + const cc = btn.getAttribute('data-code') + const ok = await caramelCopyText(cc) + const prev = btn.textContent + btn.textContent = ok ? 'Copied!' : 'Press Ctrl+C' + btn.style.background = ok ? '#1f9d55' : brandColor + setTimeout(() => { + btn.textContent = prev + btn.style.background = brandColor + }, 1600) + }) + }) + // Close the modal on button click modal .querySelector('#caramel-final-ok-btn') diff --git a/apps/caramel-extension/shared-utils.js b/apps/caramel-extension/shared-utils.js index d9e188b..d4c0bae 100644 --- a/apps/caramel-extension/shared-utils.js +++ b/apps/caramel-extension/shared-utils.js @@ -851,9 +851,17 @@ async function getCoupons(rec) { } /* -------------------------------------------------- main runner */ +// Set true when the user dismisses the testing overlay, so the loop stops +// instead of trapping them. Shared across content-script files (same realm). +// Guarded `var` (matches this file's re-injection convention — see sleep/log) +// so a second content-script injection doesn't throw on redeclaration. +if (typeof _caramelCancelled === 'undefined') { + var _caramelCancelled = false +} async function startApplyingCoupons(rec) { log('=== Starting coupon flow ===') log('AUTO_INSERT_START', { domain: rec?.domain, t: performance.now() }) + _caramelCancelled = false await showTestingModal() let coupons @@ -865,7 +873,11 @@ async function startApplyingCoupons(rec) { error: String(e), t: performance.now(), }) - showFinalModal(0, null, 'Network error fetching coupons') + showFinalModal( + 0, + null, + "Couldn't load codes right now — give it another go in a sec 🙂", + ) return } if (!Array.isArray(coupons) || !coupons.length) { @@ -873,7 +885,7 @@ async function startApplyingCoupons(rec) { showFinalModal( 0, null, - 'No coupons available for this store right now.', + "No codes for this store just yet — we're on it! 🐝", ) return } @@ -889,8 +901,15 @@ async function startApplyingCoupons(rec) { let bestSave = 0 let bestCode = null const triedCodes = [] + // Pattern-based early-exit: if the checkout gives ZERO feedback (no applied + // row, no error text) for the first couple of codes, it isn't accepting our + // injected input at all — stop probing instead of freezing the page ~10s × + // every code. Checks DOM *signals*, never the config's content. + const EARLY_PROBE = 2 + let sawSignal = false for (let i = 0; i < coupons.length; i++) { + if (_caramelCancelled) break const { code } = coupons[i] triedCodes.push(code) await updateTestingModal(i + 1, coupons.length, code) @@ -928,10 +947,26 @@ async function startApplyingCoupons(rec) { setInputValue(inp, '') } } + // A committed row or an error message means the checkout IS reacting to + // us — keep going. Zero signal after EARLY_PROBE codes means it isn't. + if (res.committed || res.errorMsg) sawSignal = true + if (!sawSignal && i + 1 >= EARLY_PROBE) { + log('AUTO_INSERT_EARLY_EXIT', { + tried: i + 1, + reason: 'no cart signal — checkout not accepting injection', + t: performance.now(), + }) + break + } await waitUntilReady(rec) await sleep(160) // tiny visual pause between tries } + if (_caramelCancelled) { + log('AUTO_INSERT_STOP', { result: 'cancelled', t: performance.now() }) + return + } + if (bestCode) { // bestCode was already applied during the successful loop iteration. // Do NOT re-apply — would double-stack on sites that don't dedupe. @@ -952,9 +987,13 @@ async function startApplyingCoupons(rec) { result: 'none', bestCode: null, bestSave: 0, + tried: triedCodes, t: performance.now(), }) - showFinalModal(0, null, 'Already the best price.') + // Nothing auto-applied. Hand the tried codes to the modal so the user + // gets a manual copy/paste fallback (covers valid codes the store's + // checkout rejected only because our synthetic click isn't trusted). + showFinalModal(0, null, null, false, coupons) } } @@ -978,7 +1017,10 @@ currentBrowser.runtime.onMessage.addListener(async (req, _s, send) => { if (req.action === 'userLoggedIn') { log('AUTO_INSERT_TRIGGERED_BY_MESSAGE', { t: performance.now() }) const rec = await getDomainRecord(location.hostname) - await startApplyingCoupons(rec) + await startApplyingCoupons(rec).catch(err => { + console.error('Caramel: apply flow error', err) + hideTestingModal() + }) send({ success: true }) } }) From d8ab2fc464dd3c5a5f128e9ba760064365ee7554 Mon Sep 17 00:00:00 2001 From: Alaeddin <15094821+BSalaeddin@users.noreply.github.com> Date: Wed, 24 Jun 2026 01:04:48 +0100 Subject: [PATCH 025/198] fix(extension): remove Amazon hardcode, add fetch timeouts, SPA listener guard, external CSS --- apps/caramel-extension/UI-helpers.js | 232 +++-------------- apps/caramel-extension/background.js | 76 +----- apps/caramel-extension/caramel-content.css | 290 +++++++++++++++++++++ apps/caramel-extension/manifest.json | 4 +- apps/caramel-extension/shared-utils.js | 119 +++------ 5 files changed, 368 insertions(+), 353 deletions(-) create mode 100644 apps/caramel-extension/caramel-content.css diff --git a/apps/caramel-extension/UI-helpers.js b/apps/caramel-extension/UI-helpers.js index d7c910f..5e7a94d 100644 --- a/apps/caramel-extension/UI-helpers.js +++ b/apps/caramel-extension/UI-helpers.js @@ -3,65 +3,18 @@ /* -------------------------------------------------- UI prompt */ async function insertCaramelPrompt(domainRecord) { - // Avoid inserting the prompt more than once - if (document.getElementById('caramel-small-prompt')) { - return - } + if (document.getElementById('caramel-small-prompt')) return const container = document.createElement('div') container.id = 'caramel-small-prompt' - - container.style.position = 'fixed' - container.style.top = '60px' - container.style.right = '20px' - container.style.zIndex = '999999' - container.style.background = '#ea6925' - container.style.padding = '20px' - container.style.borderRadius = '12px' - container.style.boxShadow = '0 4px 10px rgba(0, 0, 0, 0.3)' - container.style.cursor = 'pointer' - container.style.color = 'white' - container.style.fontFamily = 'Arial, sans-serif' - container.style.fontSize = '16px' - container.style.textAlign = 'center' - container.style.animation = 'fadeIn 0.5s ease-in-out, bounce 2s infinite' const logoUrl = await currentBrowser.runtime.getURL('assets/logo-light.png') container.innerHTML = ` -
- logo -
Try Caramel Coupons?
+
+ +
Try Caramel Coupons?

- - Save more with automatic coupons! -` - - const style = document.createElement('style') - style.textContent = ` - @keyframes fadeIn { - from { opacity: 0; transform: translateY(-10px); } - to { opacity: 1; transform: translateY(0); } - } - @keyframes bounce { - 0%, 20%, 50%, 80%, 100% { transform: translateY(0); } - 40% { transform: translateY(-10px); } - 60% { transform: translateY(-5px); } - } + + Save more with automatic coupons! ` - document.head.appendChild(style) container.addEventListener('click', event => { if (event.target.id === 'caramel-close-btn') { @@ -91,86 +44,27 @@ async function insertCaramelPrompt(domainRecord) { } async function showTestingModal(title = '', noLoading = false) { - // Create overlay const overlay = document.createElement('div') overlay.id = 'caramel-testing-overlay' - overlay.style.position = 'fixed' - overlay.style.top = '0' - overlay.style.left = '0' - overlay.style.width = '100vw' - overlay.style.height = '100vh' - overlay.style.backgroundColor = 'rgba(0, 0, 0, 0.6)' - overlay.style.zIndex = '1000000' - overlay.style.display = 'flex' - overlay.style.justifyContent = 'center' - overlay.style.alignItems = 'center' - // Create the modal container const modal = document.createElement('div') modal.id = 'caramel-testing-modal' - // Main modal styling - modal.style.position = 'relative' - modal.style.backgroundColor = '#ea6925' // Brand color - modal.style.padding = '20px' - modal.style.borderRadius = '12px' - modal.style.boxShadow = '0 4px 10px rgba(0, 0, 0, 0.3)' - modal.style.color = 'white' - modal.style.width = '320px' - modal.style.fontFamily = 'Arial, sans-serif' - modal.style.textAlign = 'center' - modal.style.animation = 'fadeIn 0.5s ease-in-out, bounce 2s infinite' - - // Fetch the Caramel logo const logoUrl = currentBrowser.runtime.getURL('assets/logo-light.png') - const loadingHTML = `

Loading...

- - -
- -
+ const loadingHTML = `

Loading...

+
+
` + modal.innerHTML = ` - -
- Caramel Logo -

- ${title ? title : 'Applying Coupons...'} -

+ +
+ +

${title || 'Applying Coupons...'}

${noLoading ? '' : loadingHTML}` - // Add keyframe animations - const style = document.createElement('style') - style.textContent = ` - @keyframes fadeIn { - from { opacity: 0; transform: translateY(-10px); } - to { opacity: 1; transform: translateY(0); } - } - @keyframes bounce { - 0%, 20%, 50%, 80%, 100% { transform: translateY(0); } - 40% { transform: translateY(-10px); } - 60% { transform: translateY(-5px); } - } - ` - document.head.appendChild(style) - - // Append modal to overlay, and overlay to body overlay.appendChild(modal) document.body.appendChild(overlay) @@ -247,30 +141,11 @@ async function showFinalModal( couponList = [], ) { hideTestingModal() - // Create overlay const overlay = document.createElement('div') overlay.id = 'caramel-final-overlay' - overlay.style.position = 'fixed' - overlay.style.top = '0' - overlay.style.left = '0' - overlay.style.width = '100vw' - overlay.style.height = '100vh' - overlay.style.backgroundColor = 'rgba(0, 0, 0, 0.7)' - overlay.style.zIndex = '1000000' - overlay.style.display = 'flex' - overlay.style.justifyContent = 'center' - overlay.style.alignItems = 'center' - // Create the modal const modal = document.createElement('div') - modal.style.backgroundColor = '#fff' - modal.style.padding = '30px' - modal.style.borderRadius = '12px' - modal.style.width = '400px' // Increased width - modal.style.boxShadow = '0 4px 15px rgba(0, 0, 0, 0.4)' - modal.style.fontFamily = 'Arial, sans-serif' - modal.style.textAlign = 'center' - modal.style.position = 'relative' + modal.className = 'caramel-final-modal' // Three terminal states the user might be in: // savedMoney = we computed a real price drop ($X off) @@ -338,95 +213,48 @@ async function showFinalModal( const manualBlock = hasManual ? ` -
+
${manualCodes .map( c => ` -
-
-
${esc(c.code)}
- ${c.title ? `
${esc(c.title)}
` : ''} +
+
+
${esc(c.code)}
+ ${c.title ? `
${esc(c.title)}
` : ''}
- +
`, ) .join('')}
` : '' - // Modal inner HTML modal.innerHTML = ` - -
- Caramel Logo + - - -

- ${heading} -

-

- ${finalMessage} -

+

${heading}

+

${finalMessage}

${manualBlock} - ${ isSuccess ? ` -

- Code: ${esc(code)} +

+ Code: ${esc(code)}

${ savedMoney - ? `

- You saved $${savingsAmount.toFixed(2)}! -

` - : `

- Discount visible in your cart. -

` + ? `

You saved $${savingsAmount.toFixed(2)}!

` + : `

Discount visible in your cart.

` } ` : '' } - - ` - // Add hover effect to the button - const style = document.createElement('style') - style.textContent = ` - #caramel-final-ok-btn:hover { - background: #ffbf47; - } - ` - document.head.appendChild(style) - overlay.appendChild(modal) document.body.appendChild(overlay) diff --git a/apps/caramel-extension/background.js b/apps/caramel-extension/background.js index 4d92a29..9185d35 100644 --- a/apps/caramel-extension/background.js +++ b/apps/caramel-extension/background.js @@ -9,6 +9,15 @@ const EXTENSION_API_KEY = 'WXqEpm2uOV5jjJXPpnQFyZiNdaPVUrtd2LIrf4kc1JA' const caramelUrl = path => new URL(path, `${globalThis.CARAMEL_BASE_URL}/`).toString() +const FETCH_TIMEOUT_MS = 8000 +function fetchWithTimeout(url, opts = {}) { + const ctrl = new AbortController() + const timer = setTimeout(() => ctrl.abort(), FETCH_TIMEOUT_MS) + return fetch(url, { ...opts, signal: ctrl.signal }).finally(() => + clearTimeout(timer), + ) +} + // Auto-switch to localhost when loaded as unpacked dev extension if (typeof chrome !== 'undefined' && chrome.management) { chrome.management.getSelf(info => { @@ -142,69 +151,8 @@ currentBrowser.runtime.onMessage.addListener( } else if (message.action === 'keepAlive') { console.log('Received keep-alive message from content script') sendResponse({ status: 'alive' }) // Respond to the message - } else if (message.action === 'scrapeAmazonCartKeywords') { - const originalTabId = sender?.tab?.id - try { - console.log('AUTO_INSERT_AMAZON_SCRAPE_START', { - originalTabId, - t: performance.now(), - }) - } catch (e) {} - currentBrowser.tabs - .create({ - url: 'https://www.amazon.com/gp/cart/view.html?ref_=nav_cart', - active: false, - }) - .then(async cartTab => { - try { - await waitForTabComplete(cartTab.id) - - const resp = await sendMessageToTab(cartTab.id, { - action: 'caramel:scrapeAmazonCartKeywordsFromCart', - }) - - try { - console.log('AUTO_INSERT_AMAZON_SCRAPE_END', { - count: (resp?.keywords || []).length, - t: performance.now(), - }) - } catch (e) {} - - sendResponse({ keywords: resp?.keywords || [] }) - } catch (error) { - console.error( - 'Error during Amazon cart scraping:', - error, - ) - try { - console.log('AUTO_INSERT_AMAZON_SCRAPE_ERROR', { - error: String(error), - t: performance.now(), - }) - } catch (e) {} - sendResponse({ - keywords: [], - error: 'Failed to scrape Amazon cart', - }) - } finally { - if (cartTab?.id) currentBrowser.tabs.remove(cartTab.id) - if (originalTabId) - currentBrowser.tabs.update(originalTabId, { - active: true, - }) - } - }) - .catch(error => { - console.error('Error creating Amazon cart tab:', error) - sendResponse({ - keywords: [], - error: 'Failed to open Amazon cart', - }) - }) - - return true } else if (message.action === 'classifyCart') { - fetch(caramelUrl('api/classify-cart'), { + fetchWithTimeout(caramelUrl('api/classify-cart'), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(message.signals || {}), @@ -233,7 +181,7 @@ currentBrowser.runtime.onMessage.addListener( url: url.toString(), t: Date.now(), }) - fetch(url.toString()) + fetchWithTimeout(url.toString()) .then(async r => { if (!r.ok) return { coupons: [] } const json = await r.json() @@ -249,7 +197,7 @@ currentBrowser.runtime.onMessage.addListener( return true } else if (message.action === 'fetchSupportedStores') { const url = caramelUrl('api/extension/supported-stores') - fetch(url, { + fetchWithTimeout(url, { headers: { 'x-api-key': EXTENSION_API_KEY }, }) .then(async r => { diff --git a/apps/caramel-extension/caramel-content.css b/apps/caramel-extension/caramel-content.css new file mode 100644 index 0000000..e45f2f9 --- /dev/null +++ b/apps/caramel-extension/caramel-content.css @@ -0,0 +1,290 @@ +/* Caramel extension content-script styles. + * External file so strict-CSP sites don't block the UI. + * Loaded via manifest.json content_scripts.css. */ + +@keyframes caramelFadeIn { + from { + opacity: 0; + transform: translateY(-10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} +@keyframes caramelBounce { + 0%, + 20%, + 50%, + 80%, + 100% { + transform: translateY(0); + } + 40% { + transform: translateY(-10px); + } + 60% { + transform: translateY(-5px); + } +} + +/* ---- Prompt ("Try Caramel Coupons?") ---- */ +#caramel-small-prompt { + position: fixed; + top: 60px; + right: 20px; + z-index: 999999; + background: #ea6925; + padding: 20px; + border-radius: 12px; + box-shadow: 0 4px 10px rgba(0, 0, 0, 0.3); + cursor: pointer; + color: white; + font-family: Arial, sans-serif; + font-size: 16px; + text-align: center; + animation: + caramelFadeIn 0.5s ease-in-out, + caramelBounce 2s infinite; +} +#caramel-small-prompt .caramel-prompt-header { + font-weight: bold; + display: flex; + justify-content: center; +} +#caramel-small-prompt .caramel-prompt-logo { + width: 30px; + height: 30px; + margin-top: auto; + margin-bottom: auto; +} +#caramel-small-prompt .caramel-prompt-label { + margin-top: auto; + margin-bottom: auto; + padding-top: 5px; +} +#caramel-close-btn { + background: white; + position: absolute; + top: -5px; + right: -5px; + width: 20px; + height: 20px; + padding: 1px; + border-radius: 50%; + color: #ea6925; + border: none; + font-size: 18px; + cursor: pointer; + margin-left: 10px; +} +#caramel-small-prompt small { + font-size: 14px; +} + +/* ---- Testing overlay ("Applying Coupons...") ---- */ +#caramel-testing-overlay { + position: fixed; + top: 0; + left: 0; + width: 100vw; + height: 100vh; + background-color: rgba(0, 0, 0, 0.6); + z-index: 1000000; + display: flex; + justify-content: center; + align-items: center; +} +#caramel-testing-modal { + position: relative; + background-color: #ea6925; + padding: 20px; + border-radius: 12px; + box-shadow: 0 4px 10px rgba(0, 0, 0, 0.3); + color: white; + width: 320px; + font-family: Arial, sans-serif; + text-align: center; + animation: + caramelFadeIn 0.5s ease-in-out, + caramelBounce 2s infinite; +} +#caramel-testing-close { + position: absolute; + top: -8px; + right: -8px; + width: 24px; + height: 24px; + border: none; + border-radius: 50%; + background: #fff; + color: #ea6925; + font-size: 16px; + line-height: 1; + cursor: pointer; + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.3); +} +#caramel-testing-modal .caramel-modal-header { + display: flex; + justify-content: center; + align-items: center; + margin-bottom: 10px; +} +#caramel-testing-modal .caramel-modal-logo { + width: 40px; + height: 40px; + margin-right: 8px; +} +#caramel-testing-modal h2 { + margin: 0; + font-size: 18px; + text-align: center; +} +#caramel-test-status { + margin: 10px 0; + font-size: 15px; +} +#caramel-progress-container { + background: rgba(255, 255, 255, 0.2); + border-radius: 6px; + width: 100%; + height: 10px; + margin: 10px 0; + position: relative; + overflow: hidden; +} +#caramel-progress-bar { + background: #ffbf47; + width: 0%; + height: 100%; + border-radius: 6px; + transition: width 0.3s ease; +} + +/* ---- Final modal ---- */ +#caramel-final-overlay { + position: fixed; + top: 0; + left: 0; + width: 100vw; + height: 100vh; + background-color: rgba(0, 0, 0, 0.7); + z-index: 1000000; + display: flex; + justify-content: center; + align-items: center; +} +#caramel-final-overlay .caramel-final-modal { + background-color: #fff; + padding: 30px; + border-radius: 12px; + width: 400px; + box-shadow: 0 4px 15px rgba(0, 0, 0, 0.4); + font-family: Arial, sans-serif; + text-align: center; + position: relative; +} +#caramel-final-overlay .caramel-final-logo { + display: flex; + justify-content: center; + margin-bottom: 10px; +} +#caramel-final-overlay .caramel-final-logo img { + width: 60px; + height: 60px; +} +#caramel-final-overlay h2 { + margin: 0 0 15px 0; + color: #ea6925; + font-size: 24px; + font-weight: bold; +} +#caramel-final-overlay .caramel-final-msg { + font-size: 13px; + color: #333; + margin: 0 0 10px 0; +} +#caramel-final-overlay .caramel-final-code { + font-size: 22px; + margin: 6px 0; +} +#caramel-final-overlay .caramel-final-code span { + color: #ea6925; + text-decoration: underline; + font-weight: bold; +} +#caramel-final-overlay .caramel-final-savings { + font-size: 18px; + color: #ea6925; + font-weight: bold; + margin: 4px 0 0; +} +#caramel-final-overlay .caramel-final-hint { + font-size: 13px; + color: #777; + margin: 4px 0 0; +} +#caramel-final-ok-btn { + margin-top: 20px; + background: #ea6925; + border: none; + color: #fff; + padding: 12px 24px; + border-radius: 8px; + cursor: pointer; + font-size: 16px; + font-weight: bold; + box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2); + transition: background 0.3s; +} +#caramel-final-ok-btn:hover { + background: #ffbf47; +} + +/* ---- Manual copy list ---- */ +.caramel-manual-list { + max-height: 190px; + overflow-y: auto; + margin: 10px 0 2px; + text-align: left; +} +.caramel-manual-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + border: 1px solid #eee; + border-radius: 8px; + padding: 8px 10px; + margin: 6px 0; +} +.caramel-manual-row .caramel-manual-info { + min-width: 0; + flex: 1; +} +.caramel-manual-row .caramel-manual-code { + font-weight: bold; + color: #333; + font-size: 14px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.caramel-manual-row .caramel-manual-title { + font-size: 11px; + color: #888; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.caramel-manual-copy { + flex: none; + background: #ea6925; + border: none; + color: #fff; + padding: 7px 14px; + border-radius: 6px; + cursor: pointer; + font-size: 13px; + font-weight: bold; +} diff --git a/apps/caramel-extension/manifest.json b/apps/caramel-extension/manifest.json index cd65dd3..9a6b802 100644 --- a/apps/caramel-extension/manifest.json +++ b/apps/caramel-extension/manifest.json @@ -39,12 +39,12 @@ "content_scripts": [ { "matches": ["https://*/*"], + "css": ["caramel-content.css"], "js": [ "cart-signals.js", "shared-utils.js", "UI-helpers.js", - "inject.js", - "amazon.js" + "inject.js" ] } ], diff --git a/apps/caramel-extension/shared-utils.js b/apps/caramel-extension/shared-utils.js index d4c0bae..f3fa2c5 100644 --- a/apps/caramel-extension/shared-utils.js +++ b/apps/caramel-extension/shared-utils.js @@ -105,52 +105,7 @@ function waitForTextChange(el, timeout = 3000) { }, timeout) }) } -function waitForAmazonFetch() { - return new Promise(resolve => { - const orig = window.fetch - window.fetch = (...args) => { - const [url] = args - const p = orig(...args) - if (url.includes('/apply-discount')) { - p.finally(() => { - window.fetch = orig - resolve('network-reply') - }) - } - return p - } - }) -} - -/* ---------- Amazon helpers (fast scrape without opening a new tab) ---------- */ -async function getAmazonCartKeywords() { - try { - // 1) try to read from current DOM - const titles = Array.from( - document.querySelectorAll('.sc-product-title'), - ) - .map(el => el.textContent.trim()) - .filter(Boolean) - if (titles.length) return titles - - // 2) fetch cart HTML same-origin (keeps cookies) - const r = await fetch('/gp/cart/view.html?ref_=nav_cart', { - credentials: 'include', - }) - if (!r.ok) return [] - const html = await r.text() - const doc = new DOMParser().parseFromString(html, 'text/html') - const fetched = Array.from(doc.querySelectorAll('.sc-product-title')) - .map(el => el.textContent.trim()) - .filter(Boolean) - return fetched - } catch (e) { - log('getAmazonCartKeywords error', e) - return [] - } -} - -/* ---------- UI readiness helper (new) ---------- */ +/* ---------- UI readiness helper ---------- */ async function waitUntilReady(rec, timeout = 2000) { const btn = qOne(rec.couponSubmit) const start = performance.now() @@ -641,9 +596,7 @@ async function applyCoupon(code, rec) { // still races so a faster site exits as soon as a signal lands. const APPLY_WAIT_MS = 10000 const waiters = [waitForCartSignal(APPLY_WAIT_MS)] - if (priceEl && rec.domain !== 'amazon.com') - waiters.push(waitForTextChange(priceEl, APPLY_WAIT_MS)) - if (rec.domain === 'amazon.com') waiters.push(waitForAmazonFetch()) + if (priceEl) waiters.push(waitForTextChange(priceEl, APPLY_WAIT_MS)) const via = await Promise.race(waiters) log('Wait finished via', via) @@ -800,16 +753,6 @@ async function classifyCartCategory() { async function getCoupons(rec) { let kw = '' - if (rec.domain === 'amazon.com') { - recordTiming('AUTO_INSERT_AMAZON_SCRAPE_REQUEST') - const titles = await getAmazonCartKeywords() - recordTiming('AUTO_INSERT_AMAZON_SCRAPE_RESPONSE', { - count: titles.length, - }) - kw = (titles || []).join(',') - log('Amazon keywords', kw) - } - // Dev hook: deterministic coupons when using #caramel-test if (location.hash && location.hash.includes('caramel-test')) { log('DEV MODE: returning mocked coupons') @@ -997,30 +940,36 @@ async function startApplyingCoupons(rec) { } } -/* -------------------------------------------------- listeners (unchanged) */ -window.addEventListener('message', ev => { - if (!CARAMEL_ALLOWED_ORIGINS.has(ev.origin)) return - if (ev.data?.token) { - currentBrowser.storage.sync.set( - { - token: ev.data.token, - user: { - username: ev.data.username || 'CaramelUser', - image: ev.data.image, +/* -------------------------------------------------- listeners + * Guard: register once per realm. Without this, SPA re-injections stack + * duplicate listeners → double-fires, memory leaks. */ +if (!window.__caramel_listeners_bound) { + window.__caramel_listeners_bound = true + + window.addEventListener('message', ev => { + if (!CARAMEL_ALLOWED_ORIGINS.has(ev.origin)) return + if (ev.data?.token) { + currentBrowser.storage.sync.set( + { + token: ev.data.token, + user: { + username: ev.data.username || 'CaramelUser', + image: ev.data.image, + }, }, - }, - tryInitialize, - ) - } -}) -currentBrowser.runtime.onMessage.addListener(async (req, _s, send) => { - if (req.action === 'userLoggedIn') { - log('AUTO_INSERT_TRIGGERED_BY_MESSAGE', { t: performance.now() }) - const rec = await getDomainRecord(location.hostname) - await startApplyingCoupons(rec).catch(err => { - console.error('Caramel: apply flow error', err) - hideTestingModal() - }) - send({ success: true }) - } -}) + tryInitialize, + ) + } + }) + currentBrowser.runtime.onMessage.addListener(async (req, _s, send) => { + if (req.action === 'userLoggedIn') { + log('AUTO_INSERT_TRIGGERED_BY_MESSAGE', { t: performance.now() }) + const rec = await getDomainRecord(location.hostname) + await startApplyingCoupons(rec).catch(err => { + console.error('Caramel: apply flow error', err) + hideTestingModal() + }) + send({ success: true }) + } + }) +} From e3109f5494e01ca917a613a3d025cd20024cdf9e Mon Sep 17 00:00:00 2001 From: Alaeddin <15094821+BSalaeddin@users.noreply.github.com> Date: Wed, 24 Jun 2026 01:06:17 +0100 Subject: [PATCH 026/198] chore(extension): remove orphaned amazon.js (no longer in manifest) --- apps/caramel-extension/amazon.js | 38 -------------------------------- 1 file changed, 38 deletions(-) delete mode 100644 apps/caramel-extension/amazon.js diff --git a/apps/caramel-extension/amazon.js b/apps/caramel-extension/amazon.js deleted file mode 100644 index e43d1a3..0000000 --- a/apps/caramel-extension/amazon.js +++ /dev/null @@ -1,38 +0,0 @@ -;(() => { - window.getAmazonOrderTotal = async () => { - const totalEl = document.querySelector( - '#sc-subtotal-amount-buybox .a-size-medium.a-color-base', - ) - if (!totalEl) { - // Another fallback or return 0 - return '0.00' - } - const text = totalEl.innerText.replace(/[^0-9.]/g, '') - return text || '0.00' - } -})() - -// Message handler for background to request cart keywords -currentBrowser.runtime.onMessage.addListener((req, _sender, sendResponse) => { - if (req.action !== 'caramel:scrapeAmazonCartKeywordsFromCart') return - - try { - const titles = Array.from( - document.querySelectorAll('.sc-product-title'), - ) - .map(el => (el.textContent || '').trim()) - .filter(Boolean) - - const words = titles - .join(' ') - .split(/\s+/) - .map(w => w.replace(/[^\w'-]/g, '').toLowerCase()) - .filter(w => w.length >= 3) - - const keywords = Array.from(new Set(words)) - sendResponse({ keywords }) - } catch (e) { - sendResponse({ keywords: [] }) - } - // sync response; no need to return true -}) From 2ed6617dbe524a49de764a416f1b5e3727e3d4f7 Mon Sep 17 00:00:00 2001 From: Alaeddin <15094821+BSalaeddin@users.noreply.github.com> Date: Fri, 26 Jun 2026 06:11:21 +0100 Subject: [PATCH 027/198] fix(ext): boundary-aware domain matching in getDomainRecord (eliminates substring false-positives, fixes hyphen-prefixed checkout hosts) --- apps/caramel-extension/shared-utils.js | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/apps/caramel-extension/shared-utils.js b/apps/caramel-extension/shared-utils.js index f3fa2c5..456e16d 100644 --- a/apps/caramel-extension/shared-utils.js +++ b/apps/caramel-extension/shared-utils.js @@ -284,10 +284,32 @@ async function getDomainRecord(domain) { } } } - return getDomainRecord.cache?.find(r => domain.includes(r.domain)) + return getDomainRecord.cache?.find(r => + _hostMatchesDomain(domain, r.domain), + ) } getDomainRecord.cache = null +// Match the active hostname to a supported-store domain. Accept an exact match, +// a real dotted subdomain (www./checkout./secure.), or a hyphen-prefixed +// checkout host (gapfactory-style `secure-.gapfactory.com`). Plain +// substring matching (the old behavior) false-matched unrelated hosts — +// `art.com` ⊂ `walmart.com`, `bestbuy.com` ⊂ `notbestbuy.com`, even +// `target.com` ⊂ `evil-target.com.attacker.net` — which would apply the wrong +// store's selectors to an unrelated site. Require a label boundary (start, '.' +// or '-') so only genuine same-site hosts match. +function _hostMatchesDomain(host, domain) { + if (!host || !domain) return false + host = String(host).toLowerCase() + domain = String(domain).toLowerCase() + if (host === domain) return true + const i = host.length - domain.length + if (i <= 0) return false + if (host.slice(i) !== domain) return false + const sep = host[i - 1] + return sep === '.' || sep === '-' +} + /* -------------------------------------------------- checkout detector */ async function isCheckout() { const rec = await getDomainRecord(location.hostname) From 3a0f51a9e75b3faf67dfd216768a0c63a67ed203 Mon Sep 17 00:00:00 2001 From: Alaeddin <15094821+BSalaeddin@users.noreply.github.com> Date: Fri, 26 Jun 2026 15:07:17 +0100 Subject: [PATCH 028/198] fix(ext): robust popup copy via caramelCopyText fallback + escape coupon data in render (accurate data-code, no markup injection) --- apps/caramel-extension/popup.js | 50 ++++++++++++++++++++++++--------- 1 file changed, 37 insertions(+), 13 deletions(-) diff --git a/apps/caramel-extension/popup.js b/apps/caramel-extension/popup.js index 89f9621..aeee2f0 100644 --- a/apps/caramel-extension/popup.js +++ b/apps/caramel-extension/popup.js @@ -3,6 +3,23 @@ let CARAMEL_BASE_URL = 'https://grabcaramel.com' const caramelUrl = path => new URL(path, `${CARAMEL_BASE_URL}/`).toString() +// Escape coupon/API data before interpolating into innerHTML. Codes, titles and +// messages come from the API; without this a code containing a quote/angle +// bracket would break its `data-code` attribute (corrupting the copied value) +// or leak stray markup into the layout. +const escHtml = s => + String(s == null ? '' : s).replace( + /[&<>"']/g, + ch => + ({ + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''', + })[ch], + ) + async function _detectDevMode() { return new Promise(resolve => { if (typeof chrome === 'undefined' || !chrome.management) @@ -550,13 +567,13 @@ function renderCouponsView(coupons, user, domain) { ? 'May have restrictions' : 'Limited to specific items' const cartHint = c.cartCategory - ? ` — your cart looks like ${c.cartCategory}${c.cartCategorySecondary ? ` / ${c.cartCategorySecondary}` : ''}` + ? ` — your cart looks like ${escHtml(c.cartCategory)}${c.cartCategorySecondary ? ` / ${escHtml(c.cartCategorySecondary)}` : ''}` : '' const verifierMsg = c.verificationMessage - ? `
${c.verificationMessage}
` + ? `
${escHtml(c.verificationMessage)}
` : '' warning = ` -
+
${baseMsg}${cartHint} ${verifierMsg} @@ -593,16 +610,16 @@ function renderCouponsView(coupons, user, domain) { } const bd = BADGE[c.status] const badge = bd - ? `${bd[0]}` + ? `${bd[0]}` : '' return ` -
-
${c.title || 'Untitled Coupon'}
-
${c.description || ''}
+
+
${escHtml(c.title || 'Untitled Coupon')}
+
${escHtml(c.description || '')}
${badge} ${warning}
- +
` }) @@ -635,12 +652,19 @@ function renderCouponsView(coupons, user, domain) { /* copy-to-clipboard */ container.querySelectorAll('.coupon-item').forEach(item => { - item.addEventListener('click', e => { + item.addEventListener('click', async e => { const code = e.currentTarget.getAttribute('data-code') - navigator.clipboard - .writeText(code) - .then(() => showCopyToast(`Copied "${code}" to clipboard!`)) - .catch(() => {}) + // Robust copy: async clipboard API with an execCommand fallback + // (shared caramelCopyText from UI-helpers.js, already loaded here). + // The bare navigator.clipboard path silently did nothing when the + // API was blocked — now the user always gets either the code on the + // clipboard or honest feedback instead of a dead click. + const ok = await caramelCopyText(code) + showCopyToast( + ok + ? `Copied "${code}" to clipboard!` + : `Couldn't copy — code is ${code}`, + ) }) }) } From 28a2423074a11dc877b2ca15f11add26858d7671 Mon Sep 17 00:00:00 2001 From: Alaeddin <15094821+BSalaeddin@users.noreply.github.com> Date: Fri, 26 Jun 2026 15:52:43 +0100 Subject: [PATCH 029/198] =?UTF-8?q?feat(ext):=20modern=20UI=20refresh=20?= =?UTF-8?q?=E2=80=94=20refined=20coupon=20ticket,=20SVG=20icons=20(no=20em?= =?UTF-8?q?oji),=20responsive=20modals,=20system=20type?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/caramel-extension/UI-helpers.js | 103 +- apps/caramel-extension/assets/styles.css | 1329 ++++++++++---------- apps/caramel-extension/caramel-content.css | 483 ++++--- apps/caramel-extension/index.html | 18 - apps/caramel-extension/popup.js | 124 +- apps/caramel-extension/shared-utils.js | 4 +- 6 files changed, 1102 insertions(+), 959 deletions(-) diff --git a/apps/caramel-extension/UI-helpers.js b/apps/caramel-extension/UI-helpers.js index 5e7a94d..68a610e 100644 --- a/apps/caramel-extension/UI-helpers.js +++ b/apps/caramel-extension/UI-helpers.js @@ -1,5 +1,18 @@ //UI HELPERS +// Inline icon set (Lucide-style strokes, no emoji). Guarded `var` so a second +// content-script injection into the same realm doesn't throw on redeclaration. +if (typeof CARAMEL_ICONS === 'undefined') { + var CARAMEL_ICONS = { + x: '', + check: '', + tag: '', + info: '', + spark: '', + copy: '', + } +} + /* -------------------------------------------------- UI prompt */ async function insertCaramelPrompt(domainRecord) { @@ -8,16 +21,18 @@ async function insertCaramelPrompt(domainRecord) { container.id = 'caramel-small-prompt' const logoUrl = await currentBrowser.runtime.getURL('assets/logo-light.png') container.innerHTML = ` -
- -
Try Caramel Coupons?
-

- - Save more with automatic coupons! + +
+ +
+
Coupons available
+
${CARAMEL_ICONS.spark}Apply the best code automatically
+
+
` container.addEventListener('click', event => { - if (event.target.id === 'caramel-close-btn') { + if (event.target.closest('#caramel-close-btn')) { // If the close button is clicked, just remove the popup document.body.removeChild(container) return @@ -50,18 +65,16 @@ async function showTestingModal(title = '', noLoading = false) { const modal = document.createElement('div') modal.id = 'caramel-testing-modal' - const logoUrl = currentBrowser.runtime.getURL('assets/logo-light.png') - - const loadingHTML = `

Loading...

+ const loadingHTML = `

Looking for the best code…

` modal.innerHTML = ` - -
- -

${title || 'Applying Coupons...'}

+ +
+ +

${title || 'Applying coupons'}

${noLoading ? '' : loadingHTML}` @@ -185,31 +198,35 @@ async function showFinalModal( // Build the secondary message based on which state we landed in. let finalMessage if (savedMoney) { - finalMessage = `We found a coupon that saves you $${savingsAmount.toFixed(2)}!` + finalMessage = 'Caramel applied the best code it found at checkout.' } else if (appliedCode) { finalMessage = `Code ${esc(code)} is applied to your cart — review the discount before you check out.` } else if (hasManual) { finalMessage = - "Auto-apply didn't stick this time — no biggie! Copy a code and drop it in the store's promo box 👇" + "Auto-apply didn't catch this one. Copy a code and paste it at checkout." } else { finalMessage = message || - "Looks like you're already getting the best deal. Go ahead and buy!" + 'Looks like you already have the best price. Go ahead and check out.' } - // Caramel brand/logo - const brandColor = '#ea6925' - const logoUrl = currentBrowser.runtime.getURL('assets/logo.png') // Adjust if needed - const heading = savedMoney - ? '🎉 Savings Found! 🎉' + ? 'Savings found' : appliedCode - ? '✓ Coupon Applied' + ? 'Coupon applied' : isSignIn - ? 'Oups..' + ? 'Sign in to continue' : hasManual - ? 'Grab a code 🎟️' - : 'Heads up 🙂' + ? 'Grab a code' + : "You're all set" + const tone = isSuccess ? 'success' : hasManual ? 'brand' : 'info' + const iconSvg = savedMoney + ? CARAMEL_ICONS.spark + : appliedCode + ? CARAMEL_ICONS.check + : hasManual + ? CARAMEL_ICONS.tag + : CARAMEL_ICONS.info const manualBlock = hasManual ? ` @@ -222,7 +239,7 @@ async function showFinalModal(
${esc(c.code)}
${c.title ? `
${esc(c.title)}
` : ''}
- +
`, ) .join('')} @@ -230,28 +247,27 @@ async function showFinalModal( : '' modal.innerHTML = ` - +
${iconSvg}

${heading}

${finalMessage}

- ${manualBlock} ${ isSuccess ? ` -

- Code: ${esc(code)} -

+
+ Code + ${esc(code)} +
${ savedMoney - ? `

You saved $${savingsAmount.toFixed(2)}!

` - : `

Discount visible in your cart.

` + ? `

You saved $${savingsAmount.toFixed(2)}

` + : `

Discount applied in your cart

` } ` : '' } + ${manualBlock} ` @@ -264,12 +280,15 @@ async function showFinalModal( ev.stopPropagation() const cc = btn.getAttribute('data-code') const ok = await caramelCopyText(cc) - const prev = btn.textContent - btn.textContent = ok ? 'Copied!' : 'Press Ctrl+C' - btn.style.background = ok ? '#1f9d55' : brandColor + btn.textContent = ok ? 'Copied' : 'Press Ctrl+C' + btn.style.background = ok ? '#15803d' : '' + btn.style.borderColor = ok ? '#15803d' : '' + btn.style.color = ok ? '#fff' : '' setTimeout(() => { - btn.textContent = prev - btn.style.background = brandColor + btn.innerHTML = `${CARAMEL_ICONS.copy}Copy` + btn.style.background = '' + btn.style.borderColor = '' + btn.style.color = '' }, 1600) }) }) diff --git a/apps/caramel-extension/assets/styles.css b/apps/caramel-extension/assets/styles.css index 79ddc18..a1115af 100644 --- a/apps/caramel-extension/assets/styles.css +++ b/apps/caramel-extension/assets/styles.css @@ -1,863 +1,822 @@ -/* --- Global Reset & Base --- */ +/* ============================================================ + Caramel popup — modern UI (2026 refresh) + Brand caramel kept; refined toward a clean, professional system. + ============================================================ */ + +:root { + /* Brand */ + --cm-brand: #ea6925; + --cm-brand-600: #d65d1f; + --cm-brand-700: #b94d18; + --cm-brand-50: #fff4ec; + --cm-brand-100: #ffe6d5; + + /* Neutrals (warm) */ + --cm-ink: #1c1917; + --cm-ink-2: #57534e; + --cm-ink-3: #8a817c; + --cm-line: #ece9e6; + --cm-line-2: #f4f2f0; + --cm-surface: #ffffff; + --cm-surface-2: #faf8f6; + + /* Semantic */ + --cm-green: #15803d; + --cm-green-bg: #e7f6ec; + --cm-amber: #b45309; + --cm-amber-bg: #fdf2e0; + --cm-red: #b91c1c; + --cm-red-bg: #fdeaea; + --cm-gray: #57534e; + --cm-gray-bg: #f1efed; + + /* Form */ + --cm-r-sm: 8px; + --cm-r: 12px; + --cm-r-lg: 16px; + --cm-pill: 999px; + + --cm-shadow-sm: + 0 1px 2px rgba(28, 25, 23, 0.06), 0 1px 3px rgba(28, 25, 23, 0.08); + --cm-shadow-md: 0 6px 16px rgba(28, 25, 23, 0.1); + --cm-shadow-lg: 0 16px 40px rgba(28, 25, 23, 0.16); + + --cm-font: + -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, + 'Apple Color Emoji', sans-serif; +} + +* { + box-sizing: border-box; +} + html, body { margin: 0; padding: 0; - font-family: 'Arial', sans-serif; - overflow: hidden; /* Keep extension popup from scrolling */ -} -body { - width: 420px; - height: 470px; - display: flex; - justify-content: center; - position: relative; - background: #ffffff; -} - -/* --- Doodles & Wave Background --- */ -.popup-bg { - position: absolute; - top: 0; - left: 0; - width: 420px; /* Match body width */ - height: 600px; /* Match body height */ - overflow: hidden; - z-index: 1; + font-family: var(--cm-font); + color: var(--cm-ink); + -webkit-font-smoothing: antialiased; + text-rendering: optimizeLegibility; } -/* Wave shape at top */ -.popup-wave { - position: absolute; - top: 0; - left: 0; - width: 420px; - height: 220px; - z-index: 2; +body { + width: 384px; + min-height: 240px; + background: var(--cm-surface); + overflow-x: hidden; } -/* Doodles */ +/* Kill the old decorative layers */ +.popup-bg, +.popup-wave, .doodle { - position: absolute; - width: 420px; - opacity: 0.15; - z-index: 1; + display: none !important; } -/* --- Loading Overlay --- */ +/* --- Loading overlay --- */ .loading-container { - position: absolute; - top: 0; - left: 0; - width: 420px; - height: 600px; - background: rgba(0, 0, 0, 0.35); + position: fixed; + inset: 0; + background: rgba(255, 255, 255, 0.72); + backdrop-filter: blur(2px); display: flex; justify-content: center; align-items: center; - border-radius: 20px; z-index: 999; } .loading-container img { - width: 60px; - height: 60px; - animation: fadeIn 0.5s ease-in-out infinite alternate; + width: 40px; + height: 40px; + animation: cmSpin 0.9s linear infinite; } -/* --- Popup Container (on top of wave) --- */ +/* --- Shell --- */ .popup-container { position: relative; z-index: 10; - width: 380px; - border-radius: 20px; - margin: auto; /* space from wave */ - background: rgba(255, 255, 255, 0.75); - backdrop-filter: blur(8px); - box-shadow: 0 8px 20px rgba(0, 0, 0, 0.2); - animation: fadeIn 0.8s ease forwards; + width: 100%; + background: var(--cm-surface); + animation: cmRise 0.28s ease both; } /* --- Header --- */ .popup-header { position: relative; - text-align: center; - padding: 12px; + display: flex; + align-items: center; + justify-content: space-between; + padding: 14px 16px; + border-bottom: 1px solid var(--cm-line); + background: var(--cm-surface); +} +.popup-header a { + display: inline-flex; + line-height: 0; } - .popup-logo { - width: 140px; + width: 116px; height: auto; - animation: bounce 2s ease-in-out; + animation: none; } .popup-logo:hover { - animation: bounce 2s infinite; + animation: none; } -/* Profile settings icon in top-right corner */ .profile-settings { - position: absolute; - top: 12px; - right: 12px; - width: 28px; - height: 28px; - background: url('assets/default-profile.png') no-repeat center center; + width: 30px; + height: 30px; + border-radius: 50%; + background: url('default-profile.png') no-repeat center center; background-size: cover; + border: 1px solid var(--cm-line); cursor: pointer; - display: none; /* shown only on login */ + display: none; } -/* --- Main Content --- */ #mainContent { - padding: 16px; - text-align: center; + padding: 14px 16px 18px; + text-align: left; } -/* --- Login Prompt --- */ -.login-prompt h2 { - font-size: 24px; - color: #ea6925; - margin-bottom: 12px; -} -.login-prompt p { - font-size: 14px !important; - color: #444; - margin-bottom: 16px; -} -.login-prompt a { - color: #ea6925; - text-decoration: none; -} -.login-button { - background: #ea6925; - color: #fff; - border: none; - padding: 12px 24px; - font-size: 16px; - border-radius: 8px; - cursor: pointer; - transition: background 0.3s ease; - box-shadow: 0 3px 8px rgba(0, 0, 0, 0.2); +/* ============================================================ + Coupons view + ============================================================ */ +.coupons-profile-card { + background: transparent; + padding: 0; + border-radius: 0; + box-shadow: none; + animation: cmRise 0.28s ease both; + max-width: none; + margin: 0; + overflow: visible; } -/* --- Profile Card --- */ -.profile-card { - padding: 16px; +.coupons-profile-row { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 12px; } -.profile-image { - width: 80px; - height: 80px; +.coupons-profile-info { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; +} +.coupons-profile-image { + width: 32px; + height: 32px; border-radius: 50%; object-fit: cover; - box-shadow: 0 2px 6px rgba(0, 0, 0, 0.2); + border: 1px solid var(--cm-line); } -.welcome-message { - font-size: 18px; - color: #ea6925; - margin-top: 12px; - font-weight: bold; +.coupons-user-label { + font-size: 13px; + font-weight: 600; + color: var(--cm-ink-2); } -.username { - font-size: 14px; - color: #555; - margin-bottom: 16px; + +.coupon-header { + font-size: 12px; + letter-spacing: 0.02em; + color: var(--cm-ink-3); + margin: 0 0 10px 0; + font-weight: 600; + text-align: left; + text-transform: none; } -.profile-actions { +.coupon-header strong { + color: var(--cm-ink); + font-weight: 700; +} + +/* --- Coupon list --- */ +.coupon-list { + max-height: 372px; + overflow-y: auto; + overflow-x: hidden; + margin: 0 -4px; + padding: 2px 4px; display: flex; flex-direction: column; - justify-content: center; - gap: 12px; -} -.settings-button, -.logout-button { - border: none; - padding: 8px 16px; - border-radius: 8px; - cursor: pointer; - font-size: 14px; - transition: background 0.3s ease; - box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15); -} -.settings-button { - background: #ea6925; - color: #fff; + gap: 10px; + scrollbar-width: thin; + scrollbar-color: var(--cm-line) transparent; } -.logout-button { - border: 1px solid #ea6925; - color: #ea6925; +.coupon-list::-webkit-scrollbar { + width: 8px; } -.settings-button:hover { - background: #da7f52; +.coupon-list::-webkit-scrollbar-thumb { + background: var(--cm-line); + border-radius: 999px; + border: 2px solid var(--cm-surface); } -.logout-button:hover { - background: #ea6925; - color: #fff; +.coupon-list > p { + color: var(--cm-ink-3); + font-size: 13px; + text-align: center; + padding: 24px 0; + margin: 0; } -/* --- Animations --- */ -@keyframes fadeIn { - from { - opacity: 0; - transform: scale(0.96); - } - to { - opacity: 1; - transform: scale(1); - } -} -@keyframes bounce { - 0%, - 20%, - 50%, - 80%, - 100% { - transform: translateY(0); - } - 40% { - transform: translateY(-10px); - } - 60% { - transform: translateY(-5px); - } +/* --- Coupon card (ticket): keep the signature perforation + notches, modernized --- */ +.coupon-item { + cursor: pointer; + border: 1.5px dashed #f0b48b; + border-radius: var(--cm-r); + padding: 13px 15px; + margin: 0; + background: var(--cm-surface); + box-shadow: var(--cm-shadow-sm); + transition: + border-color 0.16s ease, + box-shadow 0.16s ease, + transform 0.16s ease, + background 0.16s ease; + position: relative; } -.fade-in-up { - animation: fadeInUp 0.6s ease forwards; +.coupon-item:hover { + border-color: var(--cm-brand); + background: var(--cm-brand-50); + box-shadow: var(--cm-shadow-md); + transform: translateY(-1px); } -@keyframes fadeInUp { - from { - opacity: 0; - transform: translateY(15px); - } - to { - opacity: 1; - transform: translateY(0); - } +.coupon-item:active { + transform: translateY(0); } - -.login-prompt { - max-width: 250px; - margin: 0 auto; - text-align: center; - font-family: sans-serif; +/* punched ticket notches on the left & right edges */ +.coupon-item::before, +.coupon-item::after { + content: ''; + position: absolute; + top: 50%; + transform: translateY(-50%); + width: 14px; + height: 14px; + background: var(--cm-surface); + border: 1px solid var(--cm-line); + border-radius: 50%; } - -.login-prompt h2 { - margin: 0 0 10px; - font-size: 1.5em; +.coupon-item::before { + left: -8px; } - -.login-prompt p { - font-size: 0.9em; - color: #555; - margin-bottom: 6px; - margin-top: 5px; +.coupon-item::after { + right: -8px; } -.login-form { +/* top row: code + badge */ +.coupon-row-top { display: flex; - flex-direction: column; + align-items: center; + justify-content: space-between; gap: 8px; - text-align: left; + margin-bottom: 6px; } - -.login-form label { - text-align: left; - font-weight: 500; - margin-bottom: 5px; - font-size: 0.8rem; - color: #444; +.coupon-code { + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 14px; + font-weight: 700; + letter-spacing: 0.04em; + color: var(--cm-brand-700); + background: var(--cm-brand-50); + border: 1px solid var(--cm-brand-100); + border-radius: var(--cm-r-sm); + padding: 4px 9px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 62%; } -.login-form input { - padding: 0.5rem 1rem; - border: 1px solid #ccc; - border-radius: 0.375rem; - font-size: 1rem; - outline: none; -} -.login-form div { - display: flex; - flex-direction: column; +.coupon-title { + font-weight: 600; + font-size: 13.5px; + line-height: 1.35; + color: var(--cm-ink); + margin: 0 0 2px 0; } - -.login-form input:focus { - border-color: #ea6925; /* highlight border on focus */ +.coupon-desc { + font-size: 12.5px; + line-height: 1.4; + color: var(--cm-ink-2); + margin: 0 0 8px 0; } -.login-button { - padding: 10px; - border: none; - border-radius: 4px; +/* --- Badges --- */ +.coupon-badge { + display: inline-flex; + align-items: center; + gap: 4px; + flex: none; + padding: 3px 9px; + border-radius: var(--cm-pill); + font-size: 11px; font-weight: 600; - cursor: pointer; - background-color: #ea6925; - color: #fff; - transform: scale(1); - transition: 0.3s; + line-height: 1.4; + white-space: nowrap; } - -.login-button:hover { - transform: scale(1.05); +.coupon-badge svg { + width: 12px; + height: 12px; } - -.oauth-buttons { - display: flex; - flex-direction: column; - gap: 8px; - margin-bottom: 16px; +.coupon-badge--valid { + color: var(--cm-green); + background: var(--cm-green-bg); +} +.coupon-badge--warn { + color: var(--cm-amber); + background: var(--cm-amber-bg); +} +.coupon-badge--neutral { + color: var(--cm-gray); + background: var(--cm-gray-bg); +} +.coupon-badge--bad { + color: var(--cm-red); + background: var(--cm-red-bg); } -.oauth-button { +/* --- Action row --- */ +.coupon-action { display: flex; align-items: center; - justify-content: center; - gap: 8px; - padding: 10px 16px; - border: 1px solid #ea6925; - border-radius: 4px; - background-color: #fff; - color: #333; - font-size: 14px; - font-weight: 500; + margin-top: 10px; + justify-content: flex-end; +} +.copyBtn { + display: inline-flex; + align-items: center; + gap: 6px; + background: var(--cm-brand); + color: #fff; + border: none; + border-radius: var(--cm-r-sm); + padding: 8px 14px; cursor: pointer; - transition: all 0.3s ease; - width: 100%; + font-size: 13px; + font-weight: 600; + font-family: inherit; + transition: + background 0.16s ease, + transform 0.1s ease; } - -.oauth-button:hover:not(:disabled) { - background-color: rgba(234, 105, 37, 0.1); - border-color: #ea6925; - transform: scale(1.02); +.copyBtn svg { + width: 14px; + height: 14px; } - -.oauth-button:disabled { - opacity: 0.6; - cursor: not-allowed; +.copyBtn:hover { + background: var(--cm-brand-600); } - -.oauth-icon { - flex-shrink: 0; - width: 18px; - height: 18px; +.copyBtn:active { + transform: scale(0.97); +} +.copyBtn:focus-visible { + outline: 2px solid var(--cm-brand); + outline-offset: 2px; } -.oauth-divider { +/* --- Restriction note --- */ +.coupon-item-restricted { + border-color: var(--cm-amber-bg); +} +.coupon-restriction { display: flex; - align-items: center; - text-align: center; - margin: 16px 0; - color: #666; - font-size: 14px; + align-items: flex-start; + gap: 7px; + margin: 8px 0 0; + padding: 8px 10px; + font-size: 12px; + color: var(--cm-amber); + background: var(--cm-amber-bg); + border-radius: var(--cm-r-sm); } - -.oauth-divider::before, -.oauth-divider::after { - content: ''; +.coupon-restriction-icon { + flex: none; + line-height: 0; + margin-top: 1px; +} +.coupon-restriction-icon svg { + width: 14px; + height: 14px; +} +.coupon-restriction-text { flex: 1; - border-bottom: 1px solid #ddd; + line-height: 1.4; + color: #7c4a12; } - -.oauth-divider span { - padding: 0 12px; +.coupon-restriction-text b { + color: var(--cm-amber); + font-weight: 700; } - -.error-message { - padding: 8px; - margin: 8px 0; - border: 1px solid #cc0000; - border-radius: 4px; - background-color: #f8d7da; /* light red background */ - color: #721c24; /* darker red text */ +.coupon-restriction-detail { + margin-top: 2px; + color: #8a5a1f; + font-size: 11px; + opacity: 0.9; } -.resend-verification-btn { - background: none; - border: none; - color: #ea6925; - font-size: 14px; - font-weight: 600; - text-decoration: underline; +/* --- Logout chip in coupons header --- */ +.coupons-logout-button { + background: var(--cm-surface); + color: var(--cm-ink-2); + border: 1px solid var(--cm-line); + border-radius: var(--cm-r-sm); + padding: 6px 12px; cursor: pointer; - padding: 8px; - transition: all 0.2s ease; + font-size: 12px; + font-weight: 600; + font-family: inherit; + transition: + background 0.16s ease, + border-color 0.16s ease; } - -.resend-verification-btn:hover { - text-decoration: none; - opacity: 0.8; +.coupons-logout-button:hover { + background: var(--cm-surface-2); + border-color: var(--cm-ink-3); } -.resend-verification-btn:disabled { - opacity: 0.5; - cursor: not-allowed; -} - -.mt-6 { - margin-top: 6px; -} - -/* -------------- Overall Card -------------- */ -.coupons-profile-card { - /* Subtle gradient with your brand color (#ea6925) in mind */ - background: linear-gradient(135deg, #fff 50%, #fff3ec 100%); - padding: 16px; - border-radius: 12px; - box-shadow: 0 4px 14px rgba(0, 0, 0, 0.12); - animation: fadeInUp 0.3s ease forwards; - max-width: 420px; - margin: 0 auto; - position: relative; - overflow: hidden; -} - -/* Optional fade-in keyframe for the card */ -@keyframes fadeInUp { - 0% { - opacity: 0; - transform: translateY(20px); - } - 100% { - opacity: 1; - transform: translateY(0); - } -} - -/* -------------- Header / Profile Row -------------- */ -.coupons-profile-row { - display: flex; - align-items: center; - justify-content: space-between; - margin-bottom: 14px; -} - -.coupons-profile-info { - display: flex; - align-items: center; -} - -/* The user profile image */ -.coupons-profile-image { - width: 40px; - height: 40px; - border-radius: 50%; - object-fit: cover; - margin-right: 10px; - border: 2px solid #ea6925; -} - -/* The user label (e.g., "@username" or "Guest") */ -.coupons-user-label { - font-size: 14px; - font-weight: bold; - color: #333; -} - -/* -------------- Header text for the coupon list -------------- */ -.coupon-header { - font-size: 16px; - color: #ea6925; - margin: 0 0 8px 0; - font-weight: bold; - text-align: center; -} - -/* -------------- Coupon List + Items -------------- */ -.coupon-list { - max-height: 220px; - overflow-y: auto; - overflow-x: hidden; - margin: 8px 0; - padding-right: 4px; /* helps avoid overlap with scrollbar */ -} - -/* - "Coupon" item style: - - dashed border to evoke real coupons - - slight background tint -*/ -.coupon-item { - cursor: - url('data:image/svg+xml;base64,PHN2Zw0KICAgICAgICB3aWR0aD0iNjAiDQogICAgICAgIGhlaWdodD0iMTYiDQogICAgICAgIHZpZXdCb3g9IjAgMCA2MCAxNiINCiAgICAgICAgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIg0KPg0KICAgIDwhLS0gT3ZlcmxhcHBpbmcgc3F1YXJlcyAocmVwcmVzZW50IHRoZSAiY29weSIgaWNvbikgLS0+DQogICAgPHJlY3QNCiAgICAgICAgICAgIHg9IjAiDQogICAgICAgICAgICB5PSIzIg0KICAgICAgICAgICAgd2lkdGg9IjgiDQogICAgICAgICAgICBoZWlnaHQ9IjEwIg0KICAgICAgICAgICAgZmlsbD0ibm9uZSINCiAgICAgICAgICAgIHN0cm9rZT0iI2VhNjkyNSINCiAgICAgICAgICAgIHN0cm9rZS13aWR0aD0iMSINCiAgICAgICAgICAgIHN0cm9rZS1saW5lY2FwPSJyb3VuZCINCiAgICAgICAgICAgIHN0cm9rZS1saW5lam9pbj0icm91bmQiDQogICAgLz4NCiAgICA8cmVjdA0KICAgICAgICAgICAgeD0iMiINCiAgICAgICAgICAgIHk9IjEiDQogICAgICAgICAgICB3aWR0aD0iOCINCiAgICAgICAgICAgIGhlaWdodD0iMTAiDQogICAgICAgICAgICBmaWxsPSJub25lIg0KICAgICAgICAgICAgc3Ryb2tlPSIjZWE2OTI1Ig0KICAgICAgICAgICAgc3Ryb2tlLXdpZHRoPSIxIg0KICAgICAgICAgICAgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIg0KICAgICAgICAgICAgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCINCiAgICAvPg0KDQogICAgPCEtLSBUaGUgd29yZCAiQ29weSIgdG8gdGhlIHJpZ2h0IC0tPg0KICAgIDx0ZXh0DQogICAgICAgICAgICB4PSIxOCINCiAgICAgICAgICAgIHk9IjEyIg0KICAgICAgICAgICAgZmlsbD0iI2VhNjkyNSINCiAgICAgICAgICAgIGZvbnQtc2l6ZT0iMTAiDQogICAgICAgICAgICBmb250LWZhbWlseT0ic2Fucy1zZXJpZiINCiAgICA+DQogICAgICAgIENvcHkNCiAgICA8L3RleHQ+DQo8L3N2Zz4NCg==') - 8 8, - copy; - border: 2px dashed #ea6925; - border-radius: 8px; - padding: 10px 14px; - margin-bottom: 12px; - transition: - background-color 0.2s, - box-shadow 0.2s; - background-color: #fff; - position: relative; -} - -/* Subtle hover effect */ -.coupon-item:hover { - background-color: #fff8f5; - box-shadow: 0 3px 8px rgba(0, 0, 0, 0.07); -} - -/* Optional "ticket notch" effect (using :before/:after) */ -.coupon-item:before, -.coupon-item:after { - content: ''; - position: absolute; - width: 14px; - height: 14px; - background: #fff; - border: 2px solid #ea6925; - border-radius: 50%; -} - -.coupon-item:before { - top: -9px; - left: -9px; -} -.coupon-item:after { - bottom: -9px; - right: -9px; -} - -/* The coupon's title */ -.coupon-title { - font-weight: bold; - font-size: 14px; - margin-bottom: 4px; - color: #333; -} - -/* The coupon's description text */ -.coupon-desc { - font-size: 13px; - color: #666; - margin-bottom: 6px; -} - -/* A container for the copy button */ -.coupon-action { - display: flex; - align-items: center; - margin-top: 5px; - justify-content: flex-end; -} - -/* -------------- The big, obvious Copy button -------------- */ -.copyBtn { - background: #ea6925; - color: #fff; - border: none; - border-radius: 4px; - padding: 10px 16px; - cursor: pointer; - font-size: 14px; - font-weight: bold; - transition: - background 0.3s ease, - transform 0.1s; -} - -.copyBtn:hover { - background: #cf581f; -} - -.copyBtn:active { - transform: scale(0.97); -} - -/* -------------- Logout / Login Button -------------- */ -.coupons-logout-button { - background: #fff; - color: #ea6925; - border: 2px solid #ea6925; - border-radius: 6px; - padding: 6px 16px; - cursor: pointer; - font-size: 13px; - font-weight: bold; - transition: all 0.3s ease; -} - -.coupons-logout-button:hover { - background: #ea6925; - color: #fff; -} - -/* -------------- Toast / Confirmation -------------- */ +/* ============================================================ + Toast + ============================================================ */ .copy-toast-container { position: fixed; - bottom: 20px; + bottom: 16px; left: 50%; transform: translateX(-50%); z-index: 9999; + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; + width: max-content; + max-width: 90%; } - .copy-toast { - background-color: #333; + display: inline-flex; + align-items: center; + gap: 8px; + background: var(--cm-ink); color: #fff; - padding: 10px 16px; - border-radius: 4px; - margin-bottom: 8px; - opacity: 0.95; - animation: fadeIn 0.3s ease forwards; - font-size: 14px; + padding: 9px 14px; + border-radius: var(--cm-pill); + font-size: 12.5px; + font-weight: 500; + box-shadow: var(--cm-shadow-lg); + animation: cmToastIn 0.22s ease forwards; } - -/* Fade out after the setTimeout triggers 'fade-out' class */ .copy-toast.fade-out { - animation: fadeOut 0.3s ease forwards; + animation: cmToastOut 0.25s ease forwards; } -@keyframes fadeIn { - from { - opacity: 0; - transform: translateY(10px); - } - to { - opacity: 1; - transform: translateY(0); - } +/* ============================================================ + Empty / unsupported / login states + ============================================================ */ +.no-coupons-view { + background: var(--cm-surface); + border-radius: var(--cm-r); + padding: 26px 18px; + text-align: center; + animation: cmRise 0.28s ease both; } - -@keyframes fadeOut { - from { - opacity: 1; - transform: translateY(0); - } - to { - opacity: 0; - transform: translateY(10px); - } +.no-coupons-illus { + width: 48px; + height: 48px; + margin: 0 auto 12px; + color: var(--cm-brand); + display: flex; + align-items: center; + justify-content: center; + background: var(--cm-brand-50); + border-radius: 50%; } - -/* -------------- Media Queries (for small screens) -------------- */ -/* only apply when viewport ≤480px AND on a touch device (no hover) */ -@media only screen and (max-width: 480px) and (hover: none) and (pointer: coarse) { - body, - .popup-bg, - .popup-wave { - width: 100%; - max-width: none; - } - - .popup-container { - width: 90%; - max-width: none; - } +.no-coupons-illus svg { + width: 24px; + height: 24px; } - -.no-coupons-view { - background: #fff; - border-radius: 12px; - padding: 20px; - box-shadow: 0 4px 14px rgba(0, 0, 0, 0.12); +.no-coupons-view h3 { + font-size: 15px; + font-weight: 700; + color: var(--cm-ink); + margin: 0 0 4px; +} +.no-coupons-view p { + font-size: 13px; + color: var(--cm-ink-2); + line-height: 1.45; + margin: 0 auto 16px; + max-width: 260px; } - .no-coupons-avatar { - width: 48px; - height: 48px; + width: 44px; + height: 44px; border-radius: 50%; object-fit: cover; margin-bottom: 10px; + border: 1px solid var(--cm-line); } - .no-coupons-actions { display: flex; align-items: center; justify-content: center; gap: 10px; flex-wrap: wrap; - margin-top: 14px; +} +.cm-gh-link { + display: inline-flex; + margin-top: 16px; } +/* --- Buttons (primary / ghost) --- */ .supported-sites-btn, -.toggle-login-btn { +.toggle-login-btn, +.login-button, +.settings-button, +.logout-button { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; padding: 10px 16px; - border-radius: 6px; - font-weight: bold; - font-size: 14px; + border-radius: var(--cm-r-sm); + font-weight: 600; + font-size: 13.5px; + font-family: inherit; cursor: pointer; text-decoration: none; transition: - background 0.3s ease, - color 0.3s ease; + background 0.16s ease, + border-color 0.16s ease, + transform 0.1s ease; } - -.supported-sites-btn { - background: #ea6925; +.supported-sites-btn, +.login-button, +.settings-button { + background: var(--cm-brand); color: #fff; - border: none; + border: 1px solid var(--cm-brand); + box-shadow: var(--cm-shadow-sm); } -.supported-sites-btn:hover { - background: #cf581f; +.supported-sites-btn:hover, +.login-button:hover, +.settings-button:hover { + background: var(--cm-brand-600); + border-color: var(--cm-brand-600); } - -.toggle-login-btn { - background: #fff; - color: #ea6925; - border: 2px solid #ea6925; +.toggle-login-btn, +.logout-button { + background: var(--cm-surface); + color: var(--cm-ink); + border: 1px solid var(--cm-line); } -.toggle-login-btn:hover { - background: #ea6925; - color: #fff; +.toggle-login-btn:hover, +.logout-button:hover { + background: var(--cm-surface-2); + border-color: var(--cm-ink-3); +} +.supported-sites-btn:active, +.toggle-login-btn:active, +.login-button:active { + transform: scale(0.98); } .back-btn { background: none; border: none; - color: #ea6925; - font-size: 14px; + color: var(--cm-ink-2); + font-size: 13px; + font-weight: 600; + font-family: inherit; cursor: pointer; - margin-top: 16px; + padding: 8px; + margin-top: 8px; +} +.back-btn:hover { + color: var(--cm-ink); } - .github-icon { - width: 28px; - height: 28px; - opacity: 0.8; - transition: opacity 0.2s ease; + width: 24px; + height: 24px; + opacity: 0.7; + transition: opacity 0.16s ease; } .github-icon:hover { opacity: 1; } -/* --- Unsupported-site section ----------------------------------- */ -.no-coupons-view { - background: #fff; - border-radius: 12px; - padding: 20px; - box-shadow: 0 4px 14px rgba(0, 0, 0, 0.12); +/* ============================================================ + Login form + ============================================================ */ +.login-prompt { + max-width: 300px; + margin: 0 auto; + text-align: center; } - -.no-coupons-avatar { - width: 48px; - height: 48px; - border-radius: 50%; - object-fit: cover; - margin-bottom: 10px; +.login-prompt h2 { + margin: 0 0 6px; + font-size: 18px; + font-weight: 700; + color: var(--cm-ink); } - -.no-coupons-actions { +.login-prompt p { + font-size: 13px; + color: var(--cm-ink-2); + margin: 0 0 14px; + line-height: 1.45; +} +.login-prompt a { + color: var(--cm-brand); + text-decoration: none; + font-weight: 600; +} +.login-form { + display: flex; + flex-direction: column; + gap: 12px; + text-align: left; +} +.login-form > div { + display: flex; + flex-direction: column; + gap: 5px; +} +.login-form label { + font-weight: 600; + font-size: 12px; + color: var(--cm-ink-2); +} +.login-form input { + padding: 10px 12px; + border: 1px solid var(--cm-line); + border-radius: var(--cm-r-sm); + font-size: 14px; + font-family: inherit; + color: var(--cm-ink); + outline: none; + transition: + border-color 0.16s ease, + box-shadow 0.16s ease; +} +.login-form input:focus { + border-color: var(--cm-brand); + box-shadow: 0 0 0 3px var(--cm-brand-50); +} +.oauth-buttons { + display: flex; + flex-direction: column; + gap: 8px; + margin-bottom: 14px; +} +.oauth-button { display: flex; align-items: center; justify-content: center; - gap: 10px; - flex-wrap: wrap; - margin-top: 14px; -} - -.supported-sites-btn, -.toggle-login-btn { + gap: 8px; padding: 10px 16px; - border-radius: 6px; - font-weight: bold; - font-size: 14px; + border: 1px solid var(--cm-line); + border-radius: var(--cm-r-sm); + background: var(--cm-surface); + color: var(--cm-ink); + font-size: 13.5px; + font-weight: 600; + font-family: inherit; cursor: pointer; - text-decoration: none; transition: - background 0.3s ease, - color 0.3s ease; + background 0.16s ease, + border-color 0.16s ease; + width: 100%; } - -.supported-sites-btn { - background: #ea6925; - color: #fff; - border: none; +.oauth-button:hover:not(:disabled) { + background: var(--cm-surface-2); + border-color: var(--cm-ink-3); } -.supported-sites-btn:hover { - background: #cf581f; +.oauth-button:disabled { + opacity: 0.55; + cursor: not-allowed; } - -.toggle-login-btn { - background: #fff; - color: #ea6925; - border: 2px solid #ea6925; +.oauth-icon { + flex-shrink: 0; + width: 18px; + height: 18px; } -.toggle-login-btn:hover { - background: #ea6925; - color: #fff; +.oauth-divider { + display: flex; + align-items: center; + text-align: center; + margin: 14px 0; + color: var(--cm-ink-3); + font-size: 12px; } - -.back-btn { +.oauth-divider::before, +.oauth-divider::after { + content: ''; + flex: 1; + border-bottom: 1px solid var(--cm-line); +} +.oauth-divider span { + padding: 0 12px; +} +.error-message { + padding: 10px 12px; + margin: 10px 0; + border: 1px solid #f3c2c2; + border-radius: var(--cm-r-sm); + background: var(--cm-red-bg); + color: var(--cm-red); + font-size: 13px; + text-align: left; +} +.resend-verification-btn { background: none; border: none; - color: #ea6925; - font-size: 14px; + color: var(--cm-brand); + font-size: 13px; + font-weight: 600; cursor: pointer; - margin-bottom: 12px; + padding: 8px; + font-family: inherit; +} +.resend-verification-btn:hover { + text-decoration: underline; +} +.resend-verification-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} +.mt-6 { + margin-top: 6px; } -.github-icon { - width: 28px; - height: 28px; - opacity: 0.8; - transition: opacity 0.2s ease; +/* --- Profile card (logged-in landing) --- */ +.profile-card { + padding: 8px 0; + text-align: center; } -.github-icon:hover { - opacity: 1; +.profile-image { + width: 72px; + height: 72px; + border-radius: 50%; + object-fit: cover; + border: 1px solid var(--cm-line); +} +.welcome-message { + font-size: 17px; + color: var(--cm-ink); + margin-top: 12px; + font-weight: 700; +} +.username { + font-size: 13px; + color: var(--cm-ink-3); + margin-bottom: 16px; +} +.profile-actions { + display: flex; + flex-direction: column; + gap: 10px; } -@media (max-width: 480px) { - .no-coupons-view { - width: 90%; - padding: 12px; - } - .coupons-profile-card { - width: 90%; - padding: 12px; +/* ============================================================ + Animations + ============================================================ */ +@keyframes cmRise { + from { + opacity: 0; + transform: translateY(8px); } - - .coupon-item { - padding: 8px; - margin-bottom: 8px; + to { + opacity: 1; + transform: translateY(0); } - - .coupon-title { - font-size: 15px; +} +@keyframes cmSpin { + to { + transform: rotate(360deg); } - - .copyBtn { - width: 100%; - justify-content: center; - padding: 12px; - font-size: 15px; +} +@keyframes cmToastIn { + from { + opacity: 0; + transform: translateY(8px); } - - .coupons-logout-button { - padding: 6px 12px; - font-size: 14px; + to { + opacity: 1; + transform: translateY(0); } } - -/* Restriction warning chip on restricted coupons */ -.coupon-item-restricted { - border-color: #f0b34d; - background: linear-gradient(180deg, #fffaf0 0%, #fff 80%); -} -.coupon-restriction { - display: flex; - align-items: flex-start; - gap: 6px; - margin: 6px 0 8px; - padding: 6px 8px; - font-size: 12px; - color: #8a5a00; - background: #fff5e0; - border-left: 3px solid #f0b34d; - border-radius: 4px; -} -.coupon-restriction-icon { - font-size: 14px; - line-height: 1; -} -.coupon-restriction-text { - flex: 1; - line-height: 1.35; +@keyframes cmToastOut { + to { + opacity: 0; + transform: translateY(8px); + } } -.coupon-restriction-text b { - color: #d97706; +.fade-in-up { + animation: cmRise 0.28s ease both; } -.coupon-restriction-detail { - margin-top: 2px; - color: #6b4500; - font-size: 11px; - opacity: 0.85; - font-style: italic; + +@media (prefers-reduced-motion: reduce) { + * { + animation-duration: 0.01ms !important; + transition-duration: 0.01ms !important; + } } diff --git a/apps/caramel-extension/caramel-content.css b/apps/caramel-extension/caramel-content.css index e45f2f9..158df0b 100644 --- a/apps/caramel-extension/caramel-content.css +++ b/apps/caramel-extension/caramel-content.css @@ -1,290 +1,459 @@ -/* Caramel extension content-script styles. - * External file so strict-CSP sites don't block the UI. - * Loaded via manifest.json content_scripts.css. */ +/* Caramel content-script UI — modern refresh (2026). + * External file so strict-CSP sites don't block it (manifest content_scripts.css). + * All selectors scoped under Caramel IDs; key inherited props are reset on the + * roots so host-page styles can't leak in. No emoji, no infinite bounce. */ + +#caramel-small-prompt, +#caramel-testing-overlay, +#caramel-final-overlay { + --cm-brand: #ea6925; + --cm-brand-600: #d65d1f; + --cm-ink: #1c1917; + --cm-ink-2: #57534e; + --cm-ink-3: #8a817c; + --cm-line: #ece9e6; + --cm-surface: #ffffff; + --cm-surface-2: #faf8f6; + --cm-green: #15803d; + --cm-green-bg: #e7f6ec; + --cm-amber: #b45309; + --cm-amber-bg: #fdf2e0; + --cm-shadow-lg: 0 20px 48px rgba(20, 14, 10, 0.28); + --cm-font: + -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, + sans-serif; + + box-sizing: border-box; + font-family: var(--cm-font); + line-height: 1.5; + color: var(--cm-ink); + letter-spacing: normal; + text-align: left; + -webkit-font-smoothing: antialiased; +} +#caramel-small-prompt *, +#caramel-testing-overlay *, +#caramel-final-overlay * { + box-sizing: border-box; + font-family: var(--cm-font); +} +#caramel-small-prompt svg, +#caramel-testing-overlay svg, +#caramel-final-overlay svg { + display: block; +} @keyframes caramelFadeIn { from { opacity: 0; - transform: translateY(-10px); + transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } } -@keyframes caramelBounce { - 0%, - 20%, - 50%, - 80%, - 100% { - transform: translateY(0); +@keyframes caramelPop { + from { + opacity: 0; + transform: translateY(10px) scale(0.98); } - 40% { - transform: translateY(-10px); + to { + opacity: 1; + transform: translateY(0) scale(1); } - 60% { - transform: translateY(-5px); +} +@keyframes caramelSpin { + to { + transform: rotate(360deg); } } -/* ---- Prompt ("Try Caramel Coupons?") ---- */ +/* ============================================================ + Prompt + ============================================================ */ #caramel-small-prompt { position: fixed; - top: 60px; - right: 20px; - z-index: 999999; - background: #ea6925; - padding: 20px; - border-radius: 12px; - box-shadow: 0 4px 10px rgba(0, 0, 0, 0.3); + bottom: max(20px, env(safe-area-inset-bottom)); + right: max(20px, env(safe-area-inset-right)); + z-index: 2147483646; + width: min(86vw, 290px); + background: var(--cm-surface); + padding: 14px 16px; + border: 1px solid var(--cm-line); + border-radius: 14px; + box-shadow: var(--cm-shadow-lg); cursor: pointer; - color: white; - font-family: Arial, sans-serif; - font-size: 16px; - text-align: center; - animation: - caramelFadeIn 0.5s ease-in-out, - caramelBounce 2s infinite; + animation: caramelFadeIn 0.3s ease both; + transition: + transform 0.16s ease, + box-shadow 0.16s ease; } -#caramel-small-prompt .caramel-prompt-header { - font-weight: bold; +#caramel-small-prompt:hover { + transform: translateY(-2px); +} +#caramel-small-prompt .caramel-prompt-body { display: flex; - justify-content: center; + align-items: center; + gap: 12px; } #caramel-small-prompt .caramel-prompt-logo { - width: 30px; - height: 30px; - margin-top: auto; - margin-bottom: auto; + width: 34px; + height: 34px; + flex: none; + border-radius: 9px; + object-fit: contain; + background: var(--cm-surface-2); + padding: 4px; +} +#caramel-small-prompt .caramel-prompt-title { + font-size: 14px; + font-weight: 700; + color: var(--cm-ink); + margin: 0; +} +#caramel-small-prompt .caramel-prompt-sub { + font-size: 12.5px; + color: var(--cm-ink-2); + margin: 2px 0 0; + display: flex; + align-items: center; + gap: 4px; } -#caramel-small-prompt .caramel-prompt-label { - margin-top: auto; - margin-bottom: auto; - padding-top: 5px; +#caramel-small-prompt .caramel-prompt-sub svg { + width: 13px; + height: 13px; + color: var(--cm-brand); } #caramel-close-btn { - background: white; position: absolute; - top: -5px; - right: -5px; - width: 20px; - height: 20px; - padding: 1px; - border-radius: 50%; - color: #ea6925; + top: 8px; + right: 8px; + width: 22px; + height: 22px; + display: flex; + align-items: center; + justify-content: center; + padding: 0; + background: transparent; + color: var(--cm-ink-3); border: none; - font-size: 18px; + border-radius: 50%; cursor: pointer; - margin-left: 10px; + transition: + background 0.16s ease, + color 0.16s ease; } -#caramel-small-prompt small { - font-size: 14px; +#caramel-close-btn:hover { + background: var(--cm-surface-2); + color: var(--cm-ink); +} +#caramel-close-btn svg { + width: 14px; + height: 14px; } -/* ---- Testing overlay ("Applying Coupons...") ---- */ +/* ============================================================ + Testing overlay + ============================================================ */ #caramel-testing-overlay { position: fixed; - top: 0; - left: 0; + inset: 0; width: 100vw; height: 100vh; - background-color: rgba(0, 0, 0, 0.6); - z-index: 1000000; + background: rgba(15, 12, 10, 0.5); + -webkit-backdrop-filter: blur(3px); + backdrop-filter: blur(3px); + z-index: 2147483647; display: flex; justify-content: center; align-items: center; + padding: 20px; } #caramel-testing-modal { position: relative; - background-color: #ea6925; - padding: 20px; - border-radius: 12px; - box-shadow: 0 4px 10px rgba(0, 0, 0, 0.3); - color: white; - width: 320px; - font-family: Arial, sans-serif; + background: var(--cm-surface); + padding: 26px 24px 24px; + border-radius: 18px; + box-shadow: var(--cm-shadow-lg); + width: min(92vw, 360px); text-align: center; - animation: - caramelFadeIn 0.5s ease-in-out, - caramelBounce 2s infinite; + animation: caramelPop 0.26s ease both; } #caramel-testing-close { position: absolute; - top: -8px; - right: -8px; - width: 24px; - height: 24px; + top: 12px; + right: 12px; + width: 28px; + height: 28px; + display: flex; + align-items: center; + justify-content: center; border: none; border-radius: 50%; - background: #fff; - color: #ea6925; - font-size: 16px; - line-height: 1; + background: var(--cm-surface-2); + color: var(--cm-ink-3); cursor: pointer; - box-shadow: 0 1px 4px rgba(0, 0, 0, 0.3); + transition: + background 0.16s ease, + color 0.16s ease; +} +#caramel-testing-close:hover { + background: var(--cm-line); + color: var(--cm-ink); } -#caramel-testing-modal .caramel-modal-header { +#caramel-testing-close svg { + width: 15px; + height: 15px; +} +#caramel-testing-modal .caramel-modal-head { display: flex; - justify-content: center; + flex-direction: column; align-items: center; - margin-bottom: 10px; + gap: 14px; + margin-bottom: 8px; } -#caramel-testing-modal .caramel-modal-logo { - width: 40px; - height: 40px; - margin-right: 8px; +.caramel-spinner { + width: 38px; + height: 38px; + border-radius: 50%; + border: 3px solid var(--cm-brand-100, #ffe6d5); + border-top-color: var(--cm-brand); + animation: caramelSpin 0.8s linear infinite; } #caramel-testing-modal h2 { margin: 0; font-size: 18px; + font-weight: 700; + color: var(--cm-ink); text-align: center; } #caramel-test-status { - margin: 10px 0; - font-size: 15px; + margin: 6px 0 0; + font-size: 13.5px; + color: var(--cm-ink-2); } #caramel-progress-container { - background: rgba(255, 255, 255, 0.2); - border-radius: 6px; + background: var(--cm-surface-2); + border: 1px solid var(--cm-line); + border-radius: 999px; width: 100%; - height: 10px; - margin: 10px 0; - position: relative; + height: 8px; + margin: 16px 0 0; overflow: hidden; } #caramel-progress-bar { - background: #ffbf47; + background: var(--cm-brand); width: 0%; height: 100%; - border-radius: 6px; - transition: width 0.3s ease; + border-radius: 999px; + transition: width 0.35s ease; } -/* ---- Final modal ---- */ +/* ============================================================ + Final modal + ============================================================ */ #caramel-final-overlay { position: fixed; - top: 0; - left: 0; + inset: 0; width: 100vw; height: 100vh; - background-color: rgba(0, 0, 0, 0.7); - z-index: 1000000; + background: rgba(15, 12, 10, 0.55); + -webkit-backdrop-filter: blur(3px); + backdrop-filter: blur(3px); + z-index: 2147483647; display: flex; justify-content: center; align-items: center; + padding: 20px; } #caramel-final-overlay .caramel-final-modal { - background-color: #fff; - padding: 30px; - border-radius: 12px; - width: 400px; - box-shadow: 0 4px 15px rgba(0, 0, 0, 0.4); - font-family: Arial, sans-serif; + background: var(--cm-surface); + padding: 28px 24px 24px; + border-radius: 18px; + width: min(92vw, 384px); + max-height: 88vh; + overflow-y: auto; + box-shadow: var(--cm-shadow-lg); text-align: center; position: relative; } -#caramel-final-overlay .caramel-final-logo { +.caramel-final-icon { + width: 56px; + height: 56px; + margin: 0 auto 14px; + border-radius: 50%; display: flex; + align-items: center; justify-content: center; - margin-bottom: 10px; + background: var(--cm-surface-2); + color: var(--cm-ink-2); +} +.caramel-final-icon svg { + width: 28px; + height: 28px; +} +.caramel-final-icon--success { + background: var(--cm-green-bg); + color: var(--cm-green); +} +.caramel-final-icon--brand { + background: #fff1e7; + color: var(--cm-brand); } -#caramel-final-overlay .caramel-final-logo img { - width: 60px; - height: 60px; +.caramel-final-icon--info { + background: var(--cm-surface-2); + color: var(--cm-ink-3); } #caramel-final-overlay h2 { - margin: 0 0 15px 0; - color: #ea6925; - font-size: 24px; - font-weight: bold; + margin: 0 0 8px 0; + color: var(--cm-ink); + font-size: 20px; + font-weight: 700; + text-align: center; } #caramel-final-overlay .caramel-final-msg { - font-size: 13px; - color: #333; - margin: 0 0 10px 0; + font-size: 14px; + line-height: 1.5; + color: var(--cm-ink-2); + margin: 0 auto; + max-width: 300px; } #caramel-final-overlay .caramel-final-code { - font-size: 22px; - margin: 6px 0; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + margin: 18px 0 0; } -#caramel-final-overlay .caramel-final-code span { - color: #ea6925; - text-decoration: underline; - font-weight: bold; +.caramel-final-code-label { + font-size: 11px; + font-weight: 600; + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--cm-ink-3); +} +.caramel-final-code-val { + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 17px; + font-weight: 700; + letter-spacing: 0.04em; + color: var(--cm-ink); + background: var(--cm-surface-2); + border: 1px dashed var(--cm-line); + border-radius: 8px; + padding: 5px 12px; } #caramel-final-overlay .caramel-final-savings { - font-size: 18px; - color: #ea6925; - font-weight: bold; - margin: 4px 0 0; + font-size: 15px; + color: var(--cm-green); + font-weight: 700; + margin: 12px 0 0; } #caramel-final-overlay .caramel-final-hint { font-size: 13px; - color: #777; - margin: 4px 0 0; + color: var(--cm-ink-3); + margin: 10px 0 0; } #caramel-final-ok-btn { - margin-top: 20px; - background: #ea6925; + margin-top: 22px; + width: 100%; + background: var(--cm-brand); border: none; color: #fff; - padding: 12px 24px; - border-radius: 8px; + padding: 13px 24px; + border-radius: 12px; cursor: pointer; - font-size: 16px; - font-weight: bold; - box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2); - transition: background 0.3s; + font-size: 15px; + font-weight: 600; + font-family: var(--cm-font); + transition: + background 0.16s ease, + transform 0.1s ease; } #caramel-final-ok-btn:hover { - background: #ffbf47; + background: var(--cm-brand-600); +} +#caramel-final-ok-btn:active { + transform: scale(0.99); } /* ---- Manual copy list ---- */ .caramel-manual-list { - max-height: 190px; + max-height: 230px; overflow-y: auto; - margin: 10px 0 2px; + margin: 18px 0 0; text-align: left; + display: flex; + flex-direction: column; + gap: 8px; } .caramel-manual-row { display: flex; align-items: center; justify-content: space-between; - gap: 8px; - border: 1px solid #eee; - border-radius: 8px; - padding: 8px 10px; - margin: 6px 0; + gap: 10px; + border: 1px solid var(--cm-line); + border-radius: 12px; + padding: 10px 12px; + background: var(--cm-surface); + transition: border-color 0.16s ease; +} +.caramel-manual-row:hover { + border-color: #ffd9bf; } .caramel-manual-row .caramel-manual-info { min-width: 0; flex: 1; } .caramel-manual-row .caramel-manual-code { - font-weight: bold; - color: #333; - font-size: 14px; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-weight: 700; + color: var(--cm-ink); + font-size: 13.5px; + letter-spacing: 0.03em; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .caramel-manual-row .caramel-manual-title { - font-size: 11px; - color: #888; + font-size: 11.5px; + color: var(--cm-ink-3); + margin-top: 2px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .caramel-manual-copy { flex: none; - background: #ea6925; - border: none; - color: #fff; - padding: 7px 14px; - border-radius: 6px; + display: inline-flex; + align-items: center; + gap: 5px; + background: var(--cm-surface); + border: 1px solid var(--cm-brand); + color: var(--cm-brand); + padding: 7px 13px; + border-radius: 9px; cursor: pointer; - font-size: 13px; - font-weight: bold; + font-size: 12.5px; + font-weight: 600; + font-family: var(--cm-font); + transition: + background 0.16s ease, + color 0.16s ease; +} +.caramel-manual-copy:hover { + background: var(--cm-brand); + color: #fff; +} + +@media (prefers-reduced-motion: reduce) { + #caramel-small-prompt, + #caramel-testing-modal, + #caramel-final-overlay .caramel-final-modal, + .caramel-spinner, + #caramel-progress-bar { + animation: none !important; + transition: none !important; + } } diff --git a/apps/caramel-extension/index.html b/apps/caramel-extension/index.html index 7de1509..9e88c43 100644 --- a/apps/caramel-extension/index.html +++ b/apps/caramel-extension/index.html @@ -10,24 +10,6 @@ - - -
loading diff --git a/apps/caramel-extension/popup.js b/apps/caramel-extension/popup.js index aeee2f0..ed7d3ca 100644 --- a/apps/caramel-extension/popup.js +++ b/apps/caramel-extension/popup.js @@ -20,6 +20,47 @@ const escHtml = s => })[ch], ) +// Inline icon set (Lucide-style strokes; no emoji). Sized via CSS (currentColor). +const CM_ICONS = { + check: '', + warn: '', + copy: '', + tag: '', +} + +// Verification badge meta: label + class (color via CSS) + optional icon. +const BADGE_META = { + valid: { + label: 'Verified', + cls: 'coupon-badge--valid', + icon: CM_ICONS.check, + }, + valid_with_warning: { + label: 'Verified · varies', + cls: 'coupon-badge--warn', + icon: CM_ICONS.warn, + }, + product_restriction: { + label: 'Restrictions', + cls: 'coupon-badge--warn', + icon: CM_ICONS.warn, + }, + category_restricted: { + label: 'Category-limited', + cls: 'coupon-badge--warn', + icon: CM_ICONS.warn, + }, + seller_specific: { + label: 'Seller-specific', + cls: 'coupon-badge--warn', + icon: CM_ICONS.warn, + }, + pending: { label: 'Unverified', cls: 'coupon-badge--neutral', icon: '' }, + retry: { label: 'Checking', cls: 'coupon-badge--neutral', icon: '' }, + invalid: { label: 'Not valid', cls: 'coupon-badge--bad', icon: '' }, + expired: { label: 'Expired', cls: 'coupon-badge--bad', icon: '' }, +} + async function _detectDevMode() { return new Promise(resolve => { if (typeof chrome === 'undefined' || !chrome.management) @@ -96,16 +137,13 @@ async function getActiveTabDomainRecord() { /* ------------------------------------------------------------ */ function renderUnsupportedSite(user) { const container = document.getElementById('auth-container') - const avatar = user?.image?.length - ? user.image - : 'assets/default-profile.png' container.innerHTML = `
- User avatar +
${CM_ICONS.tag}
-

No coupons are available for this site.

-

Click below to see which sites we support.

+

No coupons for this site yet

+

We don't have codes for this store right now. Browse the stores we support, or check back soon.

- View Supported Stores + View supported stores ${ @@ -122,16 +160,17 @@ function renderUnsupportedSite(user) { ? '' : '' } - - - GitHub -
+ + + GitHub +
` @@ -523,7 +562,7 @@ function renderCouponsView(coupons, user, domain) { class="coupons-profile-image" alt="avatar" /> - @${user.username} + @${escHtml(user.username)} ` : ` avatar @@ -541,7 +580,7 @@ function renderCouponsView(coupons, user, domain) { ${headerRight}
-

Coupons for ${domain}

+

${coupons.length} code${coupons.length === 1 ? '' : 's'} for ${escHtml(domain)}

${ @@ -574,52 +613,27 @@ function renderCouponsView(coupons, user, domain) { : '' warning = `
- + ${CM_ICONS.warn} ${baseMsg}${cartHint} ${verifierMsg}
` } - // Verification badge: green=verified, amber=restricted, - // grey=not yet verified (grace), red=known not valid. - const BADGE = { - valid: ['✓ Verified', '#15803d', '#dcfce7'], - valid_with_warning: [ - 'Verified · may vary', - '#b45309', - '#fef3c7', - ], - product_restriction: [ - 'Restrictions apply', - '#b45309', - '#fef3c7', - ], - category_restricted: [ - 'Category-limited', - '#b45309', - '#fef3c7', - ], - seller_specific: [ - 'Seller-specific', - '#b45309', - '#fef3c7', - ], - pending: ['Unverified', '#4b5563', '#f3f4f6'], - retry: ['Checking…', '#4b5563', '#f3f4f6'], - invalid: ['Not valid', '#b91c1c', '#fee2e2'], - expired: ['Expired', '#b91c1c', '#fee2e2'], - } - const bd = BADGE[c.status] - const badge = bd - ? `${bd[0]}` + // Verification badge: class drives color (see CSS). + const meta = BADGE_META[c.status] + const badge = meta + ? `${meta.icon}${meta.label}` : '' return `
-
${escHtml(c.title || 'Untitled Coupon')}
-
${escHtml(c.description || '')}
- ${badge} +
+ ${escHtml(c.code)} + ${badge} +
+ ${c.title ? `
${escHtml(c.title)}
` : ''} + ${c.description ? `
${escHtml(c.description)}
` : ''} ${warning}
- +
` }) diff --git a/apps/caramel-extension/shared-utils.js b/apps/caramel-extension/shared-utils.js index 456e16d..e99e917 100644 --- a/apps/caramel-extension/shared-utils.js +++ b/apps/caramel-extension/shared-utils.js @@ -841,7 +841,7 @@ async function startApplyingCoupons(rec) { showFinalModal( 0, null, - "Couldn't load codes right now — give it another go in a sec 🙂", + "Couldn't load codes right now — give it another go in a moment.", ) return } @@ -850,7 +850,7 @@ async function startApplyingCoupons(rec) { showFinalModal( 0, null, - "No codes for this store just yet — we're on it! 🐝", + "No codes for this store just yet — we're working on it.", ) return } From 6b94376a29de116b5a0ca0365791987abc885218 Mon Sep 17 00:00:00 2001 From: Alaeddin <15094821+BSalaeddin@users.noreply.github.com> Date: Fri, 26 Jun 2026 16:17:51 +0100 Subject: [PATCH 030/198] =?UTF-8?q?Revert=20"feat(ext):=20modern=20UI=20re?= =?UTF-8?q?fresh=20=E2=80=94=20refined=20coupon=20ticket,=20SVG=20icons=20?= =?UTF-8?q?(no=20emoji),=20responsive=20modals,=20system=20type"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 28a2423074a11dc877b2ca15f11add26858d7671. --- apps/caramel-extension/UI-helpers.js | 103 +- apps/caramel-extension/assets/styles.css | 1357 ++++++++++---------- apps/caramel-extension/caramel-content.css | 483 +++---- apps/caramel-extension/index.html | 18 + apps/caramel-extension/popup.js | 124 +- apps/caramel-extension/shared-utils.js | 4 +- 6 files changed, 973 insertions(+), 1116 deletions(-) diff --git a/apps/caramel-extension/UI-helpers.js b/apps/caramel-extension/UI-helpers.js index 68a610e..5e7a94d 100644 --- a/apps/caramel-extension/UI-helpers.js +++ b/apps/caramel-extension/UI-helpers.js @@ -1,18 +1,5 @@ //UI HELPERS -// Inline icon set (Lucide-style strokes, no emoji). Guarded `var` so a second -// content-script injection into the same realm doesn't throw on redeclaration. -if (typeof CARAMEL_ICONS === 'undefined') { - var CARAMEL_ICONS = { - x: '', - check: '', - tag: '', - info: '', - spark: '', - copy: '', - } -} - /* -------------------------------------------------- UI prompt */ async function insertCaramelPrompt(domainRecord) { @@ -21,18 +8,16 @@ async function insertCaramelPrompt(domainRecord) { container.id = 'caramel-small-prompt' const logoUrl = await currentBrowser.runtime.getURL('assets/logo-light.png') container.innerHTML = ` - -
- -
-
Coupons available
-
${CARAMEL_ICONS.spark}Apply the best code automatically
-
-
+
+ +
Try Caramel Coupons?
+

+ + Save more with automatic coupons! ` container.addEventListener('click', event => { - if (event.target.closest('#caramel-close-btn')) { + if (event.target.id === 'caramel-close-btn') { // If the close button is clicked, just remove the popup document.body.removeChild(container) return @@ -65,16 +50,18 @@ async function showTestingModal(title = '', noLoading = false) { const modal = document.createElement('div') modal.id = 'caramel-testing-modal' - const loadingHTML = `

Looking for the best code…

+ const logoUrl = currentBrowser.runtime.getURL('assets/logo-light.png') + + const loadingHTML = `

Loading...

` modal.innerHTML = ` - -
- -

${title || 'Applying coupons'}

+ +
+ +

${title || 'Applying Coupons...'}

${noLoading ? '' : loadingHTML}` @@ -198,35 +185,31 @@ async function showFinalModal( // Build the secondary message based on which state we landed in. let finalMessage if (savedMoney) { - finalMessage = 'Caramel applied the best code it found at checkout.' + finalMessage = `We found a coupon that saves you $${savingsAmount.toFixed(2)}!` } else if (appliedCode) { finalMessage = `Code ${esc(code)} is applied to your cart — review the discount before you check out.` } else if (hasManual) { finalMessage = - "Auto-apply didn't catch this one. Copy a code and paste it at checkout." + "Auto-apply didn't stick this time — no biggie! Copy a code and drop it in the store's promo box 👇" } else { finalMessage = message || - 'Looks like you already have the best price. Go ahead and check out.' + "Looks like you're already getting the best deal. Go ahead and buy!" } + // Caramel brand/logo + const brandColor = '#ea6925' + const logoUrl = currentBrowser.runtime.getURL('assets/logo.png') // Adjust if needed + const heading = savedMoney - ? 'Savings found' + ? '🎉 Savings Found! 🎉' : appliedCode - ? 'Coupon applied' + ? '✓ Coupon Applied' : isSignIn - ? 'Sign in to continue' + ? 'Oups..' : hasManual - ? 'Grab a code' - : "You're all set" - const tone = isSuccess ? 'success' : hasManual ? 'brand' : 'info' - const iconSvg = savedMoney - ? CARAMEL_ICONS.spark - : appliedCode - ? CARAMEL_ICONS.check - : hasManual - ? CARAMEL_ICONS.tag - : CARAMEL_ICONS.info + ? 'Grab a code 🎟️' + : 'Heads up 🙂' const manualBlock = hasManual ? ` @@ -239,7 +222,7 @@ async function showFinalModal(
${esc(c.code)}
${c.title ? `
${esc(c.title)}
` : ''}
- +
`, ) .join('')} @@ -247,27 +230,28 @@ async function showFinalModal( : '' modal.innerHTML = ` -
${iconSvg}
+

${heading}

${finalMessage}

+ ${manualBlock} ${ isSuccess ? ` -
- Code - ${esc(code)} -
+

+ Code: ${esc(code)} +

${ savedMoney - ? `

You saved $${savingsAmount.toFixed(2)}

` - : `

Discount applied in your cart

` + ? `

You saved $${savingsAmount.toFixed(2)}!

` + : `

Discount visible in your cart.

` } ` : '' } - ${manualBlock} ` @@ -280,15 +264,12 @@ async function showFinalModal( ev.stopPropagation() const cc = btn.getAttribute('data-code') const ok = await caramelCopyText(cc) - btn.textContent = ok ? 'Copied' : 'Press Ctrl+C' - btn.style.background = ok ? '#15803d' : '' - btn.style.borderColor = ok ? '#15803d' : '' - btn.style.color = ok ? '#fff' : '' + const prev = btn.textContent + btn.textContent = ok ? 'Copied!' : 'Press Ctrl+C' + btn.style.background = ok ? '#1f9d55' : brandColor setTimeout(() => { - btn.innerHTML = `${CARAMEL_ICONS.copy}Copy` - btn.style.background = '' - btn.style.borderColor = '' - btn.style.color = '' + btn.textContent = prev + btn.style.background = brandColor }, 1600) }) }) diff --git a/apps/caramel-extension/assets/styles.css b/apps/caramel-extension/assets/styles.css index a1115af..79ddc18 100644 --- a/apps/caramel-extension/assets/styles.css +++ b/apps/caramel-extension/assets/styles.css @@ -1,822 +1,863 @@ -/* ============================================================ - Caramel popup — modern UI (2026 refresh) - Brand caramel kept; refined toward a clean, professional system. - ============================================================ */ - -:root { - /* Brand */ - --cm-brand: #ea6925; - --cm-brand-600: #d65d1f; - --cm-brand-700: #b94d18; - --cm-brand-50: #fff4ec; - --cm-brand-100: #ffe6d5; - - /* Neutrals (warm) */ - --cm-ink: #1c1917; - --cm-ink-2: #57534e; - --cm-ink-3: #8a817c; - --cm-line: #ece9e6; - --cm-line-2: #f4f2f0; - --cm-surface: #ffffff; - --cm-surface-2: #faf8f6; - - /* Semantic */ - --cm-green: #15803d; - --cm-green-bg: #e7f6ec; - --cm-amber: #b45309; - --cm-amber-bg: #fdf2e0; - --cm-red: #b91c1c; - --cm-red-bg: #fdeaea; - --cm-gray: #57534e; - --cm-gray-bg: #f1efed; - - /* Form */ - --cm-r-sm: 8px; - --cm-r: 12px; - --cm-r-lg: 16px; - --cm-pill: 999px; - - --cm-shadow-sm: - 0 1px 2px rgba(28, 25, 23, 0.06), 0 1px 3px rgba(28, 25, 23, 0.08); - --cm-shadow-md: 0 6px 16px rgba(28, 25, 23, 0.1); - --cm-shadow-lg: 0 16px 40px rgba(28, 25, 23, 0.16); - - --cm-font: - -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, - 'Apple Color Emoji', sans-serif; -} - -* { - box-sizing: border-box; -} - +/* --- Global Reset & Base --- */ html, body { margin: 0; padding: 0; - font-family: var(--cm-font); - color: var(--cm-ink); - -webkit-font-smoothing: antialiased; - text-rendering: optimizeLegibility; + font-family: 'Arial', sans-serif; + overflow: hidden; /* Keep extension popup from scrolling */ } - body { - width: 384px; - min-height: 240px; - background: var(--cm-surface); - overflow-x: hidden; + width: 420px; + height: 470px; + display: flex; + justify-content: center; + position: relative; + background: #ffffff; +} + +/* --- Doodles & Wave Background --- */ +.popup-bg { + position: absolute; + top: 0; + left: 0; + width: 420px; /* Match body width */ + height: 600px; /* Match body height */ + overflow: hidden; + z-index: 1; +} + +/* Wave shape at top */ +.popup-wave { + position: absolute; + top: 0; + left: 0; + width: 420px; + height: 220px; + z-index: 2; } -/* Kill the old decorative layers */ -.popup-bg, -.popup-wave, +/* Doodles */ .doodle { - display: none !important; + position: absolute; + width: 420px; + opacity: 0.15; + z-index: 1; } -/* --- Loading overlay --- */ +/* --- Loading Overlay --- */ .loading-container { - position: fixed; - inset: 0; - background: rgba(255, 255, 255, 0.72); - backdrop-filter: blur(2px); + position: absolute; + top: 0; + left: 0; + width: 420px; + height: 600px; + background: rgba(0, 0, 0, 0.35); display: flex; justify-content: center; align-items: center; + border-radius: 20px; z-index: 999; } .loading-container img { - width: 40px; - height: 40px; - animation: cmSpin 0.9s linear infinite; + width: 60px; + height: 60px; + animation: fadeIn 0.5s ease-in-out infinite alternate; } -/* --- Shell --- */ +/* --- Popup Container (on top of wave) --- */ .popup-container { position: relative; z-index: 10; - width: 100%; - background: var(--cm-surface); - animation: cmRise 0.28s ease both; + width: 380px; + border-radius: 20px; + margin: auto; /* space from wave */ + background: rgba(255, 255, 255, 0.75); + backdrop-filter: blur(8px); + box-shadow: 0 8px 20px rgba(0, 0, 0, 0.2); + animation: fadeIn 0.8s ease forwards; } /* --- Header --- */ .popup-header { position: relative; - display: flex; - align-items: center; - justify-content: space-between; - padding: 14px 16px; - border-bottom: 1px solid var(--cm-line); - background: var(--cm-surface); -} -.popup-header a { - display: inline-flex; - line-height: 0; + text-align: center; + padding: 12px; } + .popup-logo { - width: 116px; + width: 140px; height: auto; - animation: none; + animation: bounce 2s ease-in-out; } .popup-logo:hover { - animation: none; + animation: bounce 2s infinite; } +/* Profile settings icon in top-right corner */ .profile-settings { - width: 30px; - height: 30px; - border-radius: 50%; - background: url('default-profile.png') no-repeat center center; + position: absolute; + top: 12px; + right: 12px; + width: 28px; + height: 28px; + background: url('assets/default-profile.png') no-repeat center center; background-size: cover; - border: 1px solid var(--cm-line); cursor: pointer; - display: none; + display: none; /* shown only on login */ } +/* --- Main Content --- */ #mainContent { - padding: 14px 16px 18px; - text-align: left; -} - -/* ============================================================ - Coupons view - ============================================================ */ -.coupons-profile-card { - background: transparent; - padding: 0; - border-radius: 0; - box-shadow: none; - animation: cmRise 0.28s ease both; - max-width: none; - margin: 0; - overflow: visible; + padding: 16px; + text-align: center; } -.coupons-profile-row { - display: flex; - align-items: center; - justify-content: space-between; +/* --- Login Prompt --- */ +.login-prompt h2 { + font-size: 24px; + color: #ea6925; margin-bottom: 12px; } -.coupons-profile-info { - display: flex; - align-items: center; - gap: 8px; - min-width: 0; +.login-prompt p { + font-size: 14px !important; + color: #444; + margin-bottom: 16px; } -.coupons-profile-image { - width: 32px; - height: 32px; - border-radius: 50%; - object-fit: cover; - border: 1px solid var(--cm-line); +.login-prompt a { + color: #ea6925; + text-decoration: none; } -.coupons-user-label { - font-size: 13px; - font-weight: 600; - color: var(--cm-ink-2); +.login-button { + background: #ea6925; + color: #fff; + border: none; + padding: 12px 24px; + font-size: 16px; + border-radius: 8px; + cursor: pointer; + transition: background 0.3s ease; + box-shadow: 0 3px 8px rgba(0, 0, 0, 0.2); } -.coupon-header { - font-size: 12px; - letter-spacing: 0.02em; - color: var(--cm-ink-3); - margin: 0 0 10px 0; - font-weight: 600; - text-align: left; - text-transform: none; -} -.coupon-header strong { - color: var(--cm-ink); - font-weight: 700; +/* --- Profile Card --- */ +.profile-card { + padding: 16px; } - -/* --- Coupon list --- */ -.coupon-list { - max-height: 372px; - overflow-y: auto; - overflow-x: hidden; - margin: 0 -4px; - padding: 2px 4px; - display: flex; - flex-direction: column; - gap: 10px; - scrollbar-width: thin; - scrollbar-color: var(--cm-line) transparent; +.profile-image { + width: 80px; + height: 80px; + border-radius: 50%; + object-fit: cover; + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.2); } -.coupon-list::-webkit-scrollbar { - width: 8px; +.welcome-message { + font-size: 18px; + color: #ea6925; + margin-top: 12px; + font-weight: bold; } -.coupon-list::-webkit-scrollbar-thumb { - background: var(--cm-line); - border-radius: 999px; - border: 2px solid var(--cm-surface); +.username { + font-size: 14px; + color: #555; + margin-bottom: 16px; } -.coupon-list > p { - color: var(--cm-ink-3); - font-size: 13px; - text-align: center; - padding: 24px 0; - margin: 0; +.profile-actions { + display: flex; + flex-direction: column; + justify-content: center; + gap: 12px; } - -/* --- Coupon card (ticket): keep the signature perforation + notches, modernized --- */ -.coupon-item { +.settings-button, +.logout-button { + border: none; + padding: 8px 16px; + border-radius: 8px; cursor: pointer; - border: 1.5px dashed #f0b48b; - border-radius: var(--cm-r); - padding: 13px 15px; - margin: 0; - background: var(--cm-surface); - box-shadow: var(--cm-shadow-sm); - transition: - border-color 0.16s ease, - box-shadow 0.16s ease, - transform 0.16s ease, - background 0.16s ease; - position: relative; -} -.coupon-item:hover { - border-color: var(--cm-brand); - background: var(--cm-brand-50); - box-shadow: var(--cm-shadow-md); - transform: translateY(-1px); + font-size: 14px; + transition: background 0.3s ease; + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15); } -.coupon-item:active { - transform: translateY(0); +.settings-button { + background: #ea6925; + color: #fff; } -/* punched ticket notches on the left & right edges */ -.coupon-item::before, -.coupon-item::after { - content: ''; - position: absolute; - top: 50%; - transform: translateY(-50%); - width: 14px; - height: 14px; - background: var(--cm-surface); - border: 1px solid var(--cm-line); - border-radius: 50%; +.logout-button { + border: 1px solid #ea6925; + color: #ea6925; } -.coupon-item::before { - left: -8px; +.settings-button:hover { + background: #da7f52; } -.coupon-item::after { - right: -8px; +.logout-button:hover { + background: #ea6925; + color: #fff; } -/* top row: code + badge */ -.coupon-row-top { - display: flex; - align-items: center; - justify-content: space-between; - gap: 8px; - margin-bottom: 6px; +/* --- Animations --- */ +@keyframes fadeIn { + from { + opacity: 0; + transform: scale(0.96); + } + to { + opacity: 1; + transform: scale(1); + } } -.coupon-code { - font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; - font-size: 14px; - font-weight: 700; - letter-spacing: 0.04em; - color: var(--cm-brand-700); - background: var(--cm-brand-50); - border: 1px solid var(--cm-brand-100); - border-radius: var(--cm-r-sm); - padding: 4px 9px; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - max-width: 62%; +@keyframes bounce { + 0%, + 20%, + 50%, + 80%, + 100% { + transform: translateY(0); + } + 40% { + transform: translateY(-10px); + } + 60% { + transform: translateY(-5px); + } } - -.coupon-title { - font-weight: 600; - font-size: 13.5px; - line-height: 1.35; - color: var(--cm-ink); - margin: 0 0 2px 0; +.fade-in-up { + animation: fadeInUp 0.6s ease forwards; } -.coupon-desc { - font-size: 12.5px; - line-height: 1.4; - color: var(--cm-ink-2); - margin: 0 0 8px 0; +@keyframes fadeInUp { + from { + opacity: 0; + transform: translateY(15px); + } + to { + opacity: 1; + transform: translateY(0); + } } -/* --- Badges --- */ -.coupon-badge { - display: inline-flex; - align-items: center; - gap: 4px; - flex: none; - padding: 3px 9px; - border-radius: var(--cm-pill); - font-size: 11px; - font-weight: 600; - line-height: 1.4; - white-space: nowrap; -} -.coupon-badge svg { - width: 12px; - height: 12px; -} -.coupon-badge--valid { - color: var(--cm-green); - background: var(--cm-green-bg); -} -.coupon-badge--warn { - color: var(--cm-amber); - background: var(--cm-amber-bg); +.login-prompt { + max-width: 250px; + margin: 0 auto; + text-align: center; + font-family: sans-serif; } -.coupon-badge--neutral { - color: var(--cm-gray); - background: var(--cm-gray-bg); + +.login-prompt h2 { + margin: 0 0 10px; + font-size: 1.5em; } -.coupon-badge--bad { - color: var(--cm-red); - background: var(--cm-red-bg); + +.login-prompt p { + font-size: 0.9em; + color: #555; + margin-bottom: 6px; + margin-top: 5px; } -/* --- Action row --- */ -.coupon-action { +.login-form { display: flex; - align-items: center; - margin-top: 10px; - justify-content: flex-end; -} -.copyBtn { - display: inline-flex; - align-items: center; - gap: 6px; - background: var(--cm-brand); - color: #fff; - border: none; - border-radius: var(--cm-r-sm); - padding: 8px 14px; - cursor: pointer; - font-size: 13px; - font-weight: 600; - font-family: inherit; - transition: - background 0.16s ease, - transform 0.1s ease; -} -.copyBtn svg { - width: 14px; - height: 14px; -} -.copyBtn:hover { - background: var(--cm-brand-600); -} -.copyBtn:active { - transform: scale(0.97); + flex-direction: column; + gap: 8px; + text-align: left; } -.copyBtn:focus-visible { - outline: 2px solid var(--cm-brand); - outline-offset: 2px; + +.login-form label { + text-align: left; + font-weight: 500; + margin-bottom: 5px; + font-size: 0.8rem; + color: #444; } -/* --- Restriction note --- */ -.coupon-item-restricted { - border-color: var(--cm-amber-bg); +.login-form input { + padding: 0.5rem 1rem; + border: 1px solid #ccc; + border-radius: 0.375rem; + font-size: 1rem; + outline: none; } -.coupon-restriction { +.login-form div { display: flex; - align-items: flex-start; - gap: 7px; - margin: 8px 0 0; - padding: 8px 10px; - font-size: 12px; - color: var(--cm-amber); - background: var(--cm-amber-bg); - border-radius: var(--cm-r-sm); -} -.coupon-restriction-icon { - flex: none; - line-height: 0; - margin-top: 1px; -} -.coupon-restriction-icon svg { - width: 14px; - height: 14px; -} -.coupon-restriction-text { - flex: 1; - line-height: 1.4; - color: #7c4a12; -} -.coupon-restriction-text b { - color: var(--cm-amber); - font-weight: 700; + flex-direction: column; } -.coupon-restriction-detail { - margin-top: 2px; - color: #8a5a1f; - font-size: 11px; - opacity: 0.9; + +.login-form input:focus { + border-color: #ea6925; /* highlight border on focus */ } -/* --- Logout chip in coupons header --- */ -.coupons-logout-button { - background: var(--cm-surface); - color: var(--cm-ink-2); - border: 1px solid var(--cm-line); - border-radius: var(--cm-r-sm); - padding: 6px 12px; - cursor: pointer; - font-size: 12px; +.login-button { + padding: 10px; + border: none; + border-radius: 4px; font-weight: 600; - font-family: inherit; - transition: - background 0.16s ease, - border-color 0.16s ease; + cursor: pointer; + background-color: #ea6925; + color: #fff; + transform: scale(1); + transition: 0.3s; } -.coupons-logout-button:hover { - background: var(--cm-surface-2); - border-color: var(--cm-ink-3); + +.login-button:hover { + transform: scale(1.05); } -/* ============================================================ - Toast - ============================================================ */ -.copy-toast-container { - position: fixed; - bottom: 16px; - left: 50%; - transform: translateX(-50%); - z-index: 9999; +.oauth-buttons { display: flex; flex-direction: column; - align-items: center; gap: 8px; - width: max-content; - max-width: 90%; + margin-bottom: 16px; } -.copy-toast { - display: inline-flex; + +.oauth-button { + display: flex; align-items: center; + justify-content: center; gap: 8px; - background: var(--cm-ink); - color: #fff; - padding: 9px 14px; - border-radius: var(--cm-pill); - font-size: 12.5px; + padding: 10px 16px; + border: 1px solid #ea6925; + border-radius: 4px; + background-color: #fff; + color: #333; + font-size: 14px; font-weight: 500; - box-shadow: var(--cm-shadow-lg); - animation: cmToastIn 0.22s ease forwards; + cursor: pointer; + transition: all 0.3s ease; + width: 100%; } -.copy-toast.fade-out { - animation: cmToastOut 0.25s ease forwards; + +.oauth-button:hover:not(:disabled) { + background-color: rgba(234, 105, 37, 0.1); + border-color: #ea6925; + transform: scale(1.02); } -/* ============================================================ - Empty / unsupported / login states - ============================================================ */ -.no-coupons-view { - background: var(--cm-surface); - border-radius: var(--cm-r); - padding: 26px 18px; - text-align: center; - animation: cmRise 0.28s ease both; +.oauth-button:disabled { + opacity: 0.6; + cursor: not-allowed; } -.no-coupons-illus { - width: 48px; - height: 48px; - margin: 0 auto 12px; - color: var(--cm-brand); + +.oauth-icon { + flex-shrink: 0; + width: 18px; + height: 18px; +} + +.oauth-divider { display: flex; align-items: center; - justify-content: center; - background: var(--cm-brand-50); - border-radius: 50%; -} -.no-coupons-illus svg { - width: 24px; - height: 24px; -} -.no-coupons-view h3 { - font-size: 15px; - font-weight: 700; - color: var(--cm-ink); - margin: 0 0 4px; -} -.no-coupons-view p { - font-size: 13px; - color: var(--cm-ink-2); - line-height: 1.45; - margin: 0 auto 16px; - max-width: 260px; -} -.no-coupons-avatar { - width: 44px; - height: 44px; - border-radius: 50%; - object-fit: cover; - margin-bottom: 10px; - border: 1px solid var(--cm-line); -} -.no-coupons-actions { - display: flex; - align-items: center; - justify-content: center; - gap: 10px; - flex-wrap: wrap; -} -.cm-gh-link { - display: inline-flex; - margin-top: 16px; + text-align: center; + margin: 16px 0; + color: #666; + font-size: 14px; } -/* --- Buttons (primary / ghost) --- */ -.supported-sites-btn, -.toggle-login-btn, -.login-button, -.settings-button, -.logout-button { - display: inline-flex; - align-items: center; - justify-content: center; - gap: 6px; - padding: 10px 16px; - border-radius: var(--cm-r-sm); - font-weight: 600; - font-size: 13.5px; - font-family: inherit; - cursor: pointer; - text-decoration: none; - transition: - background 0.16s ease, - border-color 0.16s ease, - transform 0.1s ease; -} -.supported-sites-btn, -.login-button, -.settings-button { - background: var(--cm-brand); - color: #fff; - border: 1px solid var(--cm-brand); - box-shadow: var(--cm-shadow-sm); -} -.supported-sites-btn:hover, -.login-button:hover, -.settings-button:hover { - background: var(--cm-brand-600); - border-color: var(--cm-brand-600); -} -.toggle-login-btn, -.logout-button { - background: var(--cm-surface); - color: var(--cm-ink); - border: 1px solid var(--cm-line); +.oauth-divider::before, +.oauth-divider::after { + content: ''; + flex: 1; + border-bottom: 1px solid #ddd; } -.toggle-login-btn:hover, -.logout-button:hover { - background: var(--cm-surface-2); - border-color: var(--cm-ink-3); + +.oauth-divider span { + padding: 0 12px; } -.supported-sites-btn:active, -.toggle-login-btn:active, -.login-button:active { - transform: scale(0.98); + +.error-message { + padding: 8px; + margin: 8px 0; + border: 1px solid #cc0000; + border-radius: 4px; + background-color: #f8d7da; /* light red background */ + color: #721c24; /* darker red text */ } -.back-btn { +.resend-verification-btn { background: none; border: none; - color: var(--cm-ink-2); - font-size: 13px; + color: #ea6925; + font-size: 14px; font-weight: 600; - font-family: inherit; + text-decoration: underline; cursor: pointer; padding: 8px; - margin-top: 8px; + transition: all 0.2s ease; } -.back-btn:hover { - color: var(--cm-ink); + +.resend-verification-btn:hover { + text-decoration: none; + opacity: 0.8; } -.github-icon { - width: 24px; - height: 24px; - opacity: 0.7; - transition: opacity 0.16s ease; + +.resend-verification-btn:disabled { + opacity: 0.5; + cursor: not-allowed; } -.github-icon:hover { - opacity: 1; + +.mt-6 { + margin-top: 6px; } -/* ============================================================ - Login form - ============================================================ */ -.login-prompt { - max-width: 300px; +/* -------------- Overall Card -------------- */ +.coupons-profile-card { + /* Subtle gradient with your brand color (#ea6925) in mind */ + background: linear-gradient(135deg, #fff 50%, #fff3ec 100%); + padding: 16px; + border-radius: 12px; + box-shadow: 0 4px 14px rgba(0, 0, 0, 0.12); + animation: fadeInUp 0.3s ease forwards; + max-width: 420px; margin: 0 auto; - text-align: center; -} -.login-prompt h2 { - margin: 0 0 6px; - font-size: 18px; - font-weight: 700; - color: var(--cm-ink); -} -.login-prompt p { - font-size: 13px; - color: var(--cm-ink-2); - margin: 0 0 14px; - line-height: 1.45; + position: relative; + overflow: hidden; } -.login-prompt a { - color: var(--cm-brand); - text-decoration: none; - font-weight: 600; + +/* Optional fade-in keyframe for the card */ +@keyframes fadeInUp { + 0% { + opacity: 0; + transform: translateY(20px); + } + 100% { + opacity: 1; + transform: translateY(0); + } } -.login-form { + +/* -------------- Header / Profile Row -------------- */ +.coupons-profile-row { display: flex; - flex-direction: column; - gap: 12px; - text-align: left; + align-items: center; + justify-content: space-between; + margin-bottom: 14px; } -.login-form > div { + +.coupons-profile-info { display: flex; - flex-direction: column; - gap: 5px; + align-items: center; } -.login-form label { - font-weight: 600; - font-size: 12px; - color: var(--cm-ink-2); + +/* The user profile image */ +.coupons-profile-image { + width: 40px; + height: 40px; + border-radius: 50%; + object-fit: cover; + margin-right: 10px; + border: 2px solid #ea6925; } -.login-form input { - padding: 10px 12px; - border: 1px solid var(--cm-line); - border-radius: var(--cm-r-sm); + +/* The user label (e.g., "@username" or "Guest") */ +.coupons-user-label { font-size: 14px; - font-family: inherit; - color: var(--cm-ink); - outline: none; - transition: - border-color 0.16s ease, - box-shadow 0.16s ease; + font-weight: bold; + color: #333; } -.login-form input:focus { - border-color: var(--cm-brand); - box-shadow: 0 0 0 3px var(--cm-brand-50); + +/* -------------- Header text for the coupon list -------------- */ +.coupon-header { + font-size: 16px; + color: #ea6925; + margin: 0 0 8px 0; + font-weight: bold; + text-align: center; } -.oauth-buttons { - display: flex; - flex-direction: column; - gap: 8px; - margin-bottom: 14px; + +/* -------------- Coupon List + Items -------------- */ +.coupon-list { + max-height: 220px; + overflow-y: auto; + overflow-x: hidden; + margin: 8px 0; + padding-right: 4px; /* helps avoid overlap with scrollbar */ } -.oauth-button { - display: flex; - align-items: center; - justify-content: center; - gap: 8px; - padding: 10px 16px; - border: 1px solid var(--cm-line); - border-radius: var(--cm-r-sm); - background: var(--cm-surface); - color: var(--cm-ink); - font-size: 13.5px; - font-weight: 600; - font-family: inherit; - cursor: pointer; + +/* + "Coupon" item style: + - dashed border to evoke real coupons + - slight background tint +*/ +.coupon-item { + cursor: + url('data:image/svg+xml;base64,PHN2Zw0KICAgICAgICB3aWR0aD0iNjAiDQogICAgICAgIGhlaWdodD0iMTYiDQogICAgICAgIHZpZXdCb3g9IjAgMCA2MCAxNiINCiAgICAgICAgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIg0KPg0KICAgIDwhLS0gT3ZlcmxhcHBpbmcgc3F1YXJlcyAocmVwcmVzZW50IHRoZSAiY29weSIgaWNvbikgLS0+DQogICAgPHJlY3QNCiAgICAgICAgICAgIHg9IjAiDQogICAgICAgICAgICB5PSIzIg0KICAgICAgICAgICAgd2lkdGg9IjgiDQogICAgICAgICAgICBoZWlnaHQ9IjEwIg0KICAgICAgICAgICAgZmlsbD0ibm9uZSINCiAgICAgICAgICAgIHN0cm9rZT0iI2VhNjkyNSINCiAgICAgICAgICAgIHN0cm9rZS13aWR0aD0iMSINCiAgICAgICAgICAgIHN0cm9rZS1saW5lY2FwPSJyb3VuZCINCiAgICAgICAgICAgIHN0cm9rZS1saW5lam9pbj0icm91bmQiDQogICAgLz4NCiAgICA8cmVjdA0KICAgICAgICAgICAgeD0iMiINCiAgICAgICAgICAgIHk9IjEiDQogICAgICAgICAgICB3aWR0aD0iOCINCiAgICAgICAgICAgIGhlaWdodD0iMTAiDQogICAgICAgICAgICBmaWxsPSJub25lIg0KICAgICAgICAgICAgc3Ryb2tlPSIjZWE2OTI1Ig0KICAgICAgICAgICAgc3Ryb2tlLXdpZHRoPSIxIg0KICAgICAgICAgICAgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIg0KICAgICAgICAgICAgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCINCiAgICAvPg0KDQogICAgPCEtLSBUaGUgd29yZCAiQ29weSIgdG8gdGhlIHJpZ2h0IC0tPg0KICAgIDx0ZXh0DQogICAgICAgICAgICB4PSIxOCINCiAgICAgICAgICAgIHk9IjEyIg0KICAgICAgICAgICAgZmlsbD0iI2VhNjkyNSINCiAgICAgICAgICAgIGZvbnQtc2l6ZT0iMTAiDQogICAgICAgICAgICBmb250LWZhbWlseT0ic2Fucy1zZXJpZiINCiAgICA+DQogICAgICAgIENvcHkNCiAgICA8L3RleHQ+DQo8L3N2Zz4NCg==') + 8 8, + copy; + border: 2px dashed #ea6925; + border-radius: 8px; + padding: 10px 14px; + margin-bottom: 12px; transition: - background 0.16s ease, - border-color 0.16s ease; - width: 100%; -} -.oauth-button:hover:not(:disabled) { - background: var(--cm-surface-2); - border-color: var(--cm-ink-3); + background-color 0.2s, + box-shadow 0.2s; + background-color: #fff; + position: relative; } -.oauth-button:disabled { - opacity: 0.55; - cursor: not-allowed; + +/* Subtle hover effect */ +.coupon-item:hover { + background-color: #fff8f5; + box-shadow: 0 3px 8px rgba(0, 0, 0, 0.07); } -.oauth-icon { - flex-shrink: 0; - width: 18px; - height: 18px; + +/* Optional "ticket notch" effect (using :before/:after) */ +.coupon-item:before, +.coupon-item:after { + content: ''; + position: absolute; + width: 14px; + height: 14px; + background: #fff; + border: 2px solid #ea6925; + border-radius: 50%; } -.oauth-divider { - display: flex; - align-items: center; - text-align: center; - margin: 14px 0; - color: var(--cm-ink-3); - font-size: 12px; + +.coupon-item:before { + top: -9px; + left: -9px; } -.oauth-divider::before, -.oauth-divider::after { - content: ''; - flex: 1; - border-bottom: 1px solid var(--cm-line); +.coupon-item:after { + bottom: -9px; + right: -9px; } -.oauth-divider span { - padding: 0 12px; + +/* The coupon's title */ +.coupon-title { + font-weight: bold; + font-size: 14px; + margin-bottom: 4px; + color: #333; } -.error-message { - padding: 10px 12px; - margin: 10px 0; - border: 1px solid #f3c2c2; - border-radius: var(--cm-r-sm); - background: var(--cm-red-bg); - color: var(--cm-red); + +/* The coupon's description text */ +.coupon-desc { font-size: 13px; - text-align: left; + color: #666; + margin-bottom: 6px; } -.resend-verification-btn { - background: none; + +/* A container for the copy button */ +.coupon-action { + display: flex; + align-items: center; + margin-top: 5px; + justify-content: flex-end; +} + +/* -------------- The big, obvious Copy button -------------- */ +.copyBtn { + background: #ea6925; + color: #fff; border: none; - color: var(--cm-brand); - font-size: 13px; - font-weight: 600; + border-radius: 4px; + padding: 10px 16px; cursor: pointer; - padding: 8px; - font-family: inherit; -} -.resend-verification-btn:hover { - text-decoration: underline; + font-size: 14px; + font-weight: bold; + transition: + background 0.3s ease, + transform 0.1s; } -.resend-verification-btn:disabled { - opacity: 0.5; - cursor: not-allowed; + +.copyBtn:hover { + background: #cf581f; } -.mt-6 { - margin-top: 6px; + +.copyBtn:active { + transform: scale(0.97); } -/* --- Profile card (logged-in landing) --- */ -.profile-card { - padding: 8px 0; - text-align: center; +/* -------------- Logout / Login Button -------------- */ +.coupons-logout-button { + background: #fff; + color: #ea6925; + border: 2px solid #ea6925; + border-radius: 6px; + padding: 6px 16px; + cursor: pointer; + font-size: 13px; + font-weight: bold; + transition: all 0.3s ease; } -.profile-image { - width: 72px; - height: 72px; - border-radius: 50%; - object-fit: cover; - border: 1px solid var(--cm-line); + +.coupons-logout-button:hover { + background: #ea6925; + color: #fff; } -.welcome-message { - font-size: 17px; - color: var(--cm-ink); - margin-top: 12px; - font-weight: 700; + +/* -------------- Toast / Confirmation -------------- */ +.copy-toast-container { + position: fixed; + bottom: 20px; + left: 50%; + transform: translateX(-50%); + z-index: 9999; } -.username { - font-size: 13px; - color: var(--cm-ink-3); - margin-bottom: 16px; + +.copy-toast { + background-color: #333; + color: #fff; + padding: 10px 16px; + border-radius: 4px; + margin-bottom: 8px; + opacity: 0.95; + animation: fadeIn 0.3s ease forwards; + font-size: 14px; } -.profile-actions { - display: flex; - flex-direction: column; - gap: 10px; + +/* Fade out after the setTimeout triggers 'fade-out' class */ +.copy-toast.fade-out { + animation: fadeOut 0.3s ease forwards; } -/* ============================================================ - Animations - ============================================================ */ -@keyframes cmRise { +@keyframes fadeIn { from { opacity: 0; - transform: translateY(8px); + transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } } -@keyframes cmSpin { - to { - transform: rotate(360deg); - } -} -@keyframes cmToastIn { + +@keyframes fadeOut { from { - opacity: 0; - transform: translateY(8px); - } - to { opacity: 1; transform: translateY(0); } -} -@keyframes cmToastOut { to { opacity: 0; - transform: translateY(8px); + transform: translateY(10px); } } -.fade-in-up { - animation: cmRise 0.28s ease both; + +/* -------------- Media Queries (for small screens) -------------- */ +/* only apply when viewport ≤480px AND on a touch device (no hover) */ +@media only screen and (max-width: 480px) and (hover: none) and (pointer: coarse) { + body, + .popup-bg, + .popup-wave { + width: 100%; + max-width: none; + } + + .popup-container { + width: 90%; + max-width: none; + } +} + +.no-coupons-view { + background: #fff; + border-radius: 12px; + padding: 20px; + box-shadow: 0 4px 14px rgba(0, 0, 0, 0.12); +} + +.no-coupons-avatar { + width: 48px; + height: 48px; + border-radius: 50%; + object-fit: cover; + margin-bottom: 10px; +} + +.no-coupons-actions { + display: flex; + align-items: center; + justify-content: center; + gap: 10px; + flex-wrap: wrap; + margin-top: 14px; +} + +.supported-sites-btn, +.toggle-login-btn { + padding: 10px 16px; + border-radius: 6px; + font-weight: bold; + font-size: 14px; + cursor: pointer; + text-decoration: none; + transition: + background 0.3s ease, + color 0.3s ease; +} + +.supported-sites-btn { + background: #ea6925; + color: #fff; + border: none; +} +.supported-sites-btn:hover { + background: #cf581f; +} + +.toggle-login-btn { + background: #fff; + color: #ea6925; + border: 2px solid #ea6925; +} +.toggle-login-btn:hover { + background: #ea6925; + color: #fff; } -@media (prefers-reduced-motion: reduce) { - * { - animation-duration: 0.01ms !important; - transition-duration: 0.01ms !important; +.back-btn { + background: none; + border: none; + color: #ea6925; + font-size: 14px; + cursor: pointer; + margin-top: 16px; +} + +.github-icon { + width: 28px; + height: 28px; + opacity: 0.8; + transition: opacity 0.2s ease; +} +.github-icon:hover { + opacity: 1; +} +/* --- Unsupported-site section ----------------------------------- */ + +.no-coupons-view { + background: #fff; + border-radius: 12px; + padding: 20px; + box-shadow: 0 4px 14px rgba(0, 0, 0, 0.12); +} + +.no-coupons-avatar { + width: 48px; + height: 48px; + border-radius: 50%; + object-fit: cover; + margin-bottom: 10px; +} + +.no-coupons-actions { + display: flex; + align-items: center; + justify-content: center; + gap: 10px; + flex-wrap: wrap; + margin-top: 14px; +} + +.supported-sites-btn, +.toggle-login-btn { + padding: 10px 16px; + border-radius: 6px; + font-weight: bold; + font-size: 14px; + cursor: pointer; + text-decoration: none; + transition: + background 0.3s ease, + color 0.3s ease; +} + +.supported-sites-btn { + background: #ea6925; + color: #fff; + border: none; +} +.supported-sites-btn:hover { + background: #cf581f; +} + +.toggle-login-btn { + background: #fff; + color: #ea6925; + border: 2px solid #ea6925; +} +.toggle-login-btn:hover { + background: #ea6925; + color: #fff; +} + +.back-btn { + background: none; + border: none; + color: #ea6925; + font-size: 14px; + cursor: pointer; + margin-bottom: 12px; +} + +.github-icon { + width: 28px; + height: 28px; + opacity: 0.8; + transition: opacity 0.2s ease; +} +.github-icon:hover { + opacity: 1; +} + +@media (max-width: 480px) { + .no-coupons-view { + width: 90%; + padding: 12px; + } + .coupons-profile-card { + width: 90%; + padding: 12px; + } + + .coupon-item { + padding: 8px; + margin-bottom: 8px; + } + + .coupon-title { + font-size: 15px; } + + .copyBtn { + width: 100%; + justify-content: center; + padding: 12px; + font-size: 15px; + } + + .coupons-logout-button { + padding: 6px 12px; + font-size: 14px; + } +} + +/* Restriction warning chip on restricted coupons */ +.coupon-item-restricted { + border-color: #f0b34d; + background: linear-gradient(180deg, #fffaf0 0%, #fff 80%); +} +.coupon-restriction { + display: flex; + align-items: flex-start; + gap: 6px; + margin: 6px 0 8px; + padding: 6px 8px; + font-size: 12px; + color: #8a5a00; + background: #fff5e0; + border-left: 3px solid #f0b34d; + border-radius: 4px; +} +.coupon-restriction-icon { + font-size: 14px; + line-height: 1; +} +.coupon-restriction-text { + flex: 1; + line-height: 1.35; +} +.coupon-restriction-text b { + color: #d97706; +} +.coupon-restriction-detail { + margin-top: 2px; + color: #6b4500; + font-size: 11px; + opacity: 0.85; + font-style: italic; } diff --git a/apps/caramel-extension/caramel-content.css b/apps/caramel-extension/caramel-content.css index 158df0b..e45f2f9 100644 --- a/apps/caramel-extension/caramel-content.css +++ b/apps/caramel-extension/caramel-content.css @@ -1,459 +1,290 @@ -/* Caramel content-script UI — modern refresh (2026). - * External file so strict-CSP sites don't block it (manifest content_scripts.css). - * All selectors scoped under Caramel IDs; key inherited props are reset on the - * roots so host-page styles can't leak in. No emoji, no infinite bounce. */ - -#caramel-small-prompt, -#caramel-testing-overlay, -#caramel-final-overlay { - --cm-brand: #ea6925; - --cm-brand-600: #d65d1f; - --cm-ink: #1c1917; - --cm-ink-2: #57534e; - --cm-ink-3: #8a817c; - --cm-line: #ece9e6; - --cm-surface: #ffffff; - --cm-surface-2: #faf8f6; - --cm-green: #15803d; - --cm-green-bg: #e7f6ec; - --cm-amber: #b45309; - --cm-amber-bg: #fdf2e0; - --cm-shadow-lg: 0 20px 48px rgba(20, 14, 10, 0.28); - --cm-font: - -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, - sans-serif; - - box-sizing: border-box; - font-family: var(--cm-font); - line-height: 1.5; - color: var(--cm-ink); - letter-spacing: normal; - text-align: left; - -webkit-font-smoothing: antialiased; -} -#caramel-small-prompt *, -#caramel-testing-overlay *, -#caramel-final-overlay * { - box-sizing: border-box; - font-family: var(--cm-font); -} -#caramel-small-prompt svg, -#caramel-testing-overlay svg, -#caramel-final-overlay svg { - display: block; -} +/* Caramel extension content-script styles. + * External file so strict-CSP sites don't block the UI. + * Loaded via manifest.json content_scripts.css. */ @keyframes caramelFadeIn { from { opacity: 0; - transform: translateY(8px); + transform: translateY(-10px); } to { opacity: 1; transform: translateY(0); } } -@keyframes caramelPop { - from { - opacity: 0; - transform: translateY(10px) scale(0.98); +@keyframes caramelBounce { + 0%, + 20%, + 50%, + 80%, + 100% { + transform: translateY(0); } - to { - opacity: 1; - transform: translateY(0) scale(1); + 40% { + transform: translateY(-10px); } -} -@keyframes caramelSpin { - to { - transform: rotate(360deg); + 60% { + transform: translateY(-5px); } } -/* ============================================================ - Prompt - ============================================================ */ +/* ---- Prompt ("Try Caramel Coupons?") ---- */ #caramel-small-prompt { position: fixed; - bottom: max(20px, env(safe-area-inset-bottom)); - right: max(20px, env(safe-area-inset-right)); - z-index: 2147483646; - width: min(86vw, 290px); - background: var(--cm-surface); - padding: 14px 16px; - border: 1px solid var(--cm-line); - border-radius: 14px; - box-shadow: var(--cm-shadow-lg); + top: 60px; + right: 20px; + z-index: 999999; + background: #ea6925; + padding: 20px; + border-radius: 12px; + box-shadow: 0 4px 10px rgba(0, 0, 0, 0.3); cursor: pointer; - animation: caramelFadeIn 0.3s ease both; - transition: - transform 0.16s ease, - box-shadow 0.16s ease; -} -#caramel-small-prompt:hover { - transform: translateY(-2px); + color: white; + font-family: Arial, sans-serif; + font-size: 16px; + text-align: center; + animation: + caramelFadeIn 0.5s ease-in-out, + caramelBounce 2s infinite; } -#caramel-small-prompt .caramel-prompt-body { +#caramel-small-prompt .caramel-prompt-header { + font-weight: bold; display: flex; - align-items: center; - gap: 12px; + justify-content: center; } #caramel-small-prompt .caramel-prompt-logo { - width: 34px; - height: 34px; - flex: none; - border-radius: 9px; - object-fit: contain; - background: var(--cm-surface-2); - padding: 4px; -} -#caramel-small-prompt .caramel-prompt-title { - font-size: 14px; - font-weight: 700; - color: var(--cm-ink); - margin: 0; -} -#caramel-small-prompt .caramel-prompt-sub { - font-size: 12.5px; - color: var(--cm-ink-2); - margin: 2px 0 0; - display: flex; - align-items: center; - gap: 4px; + width: 30px; + height: 30px; + margin-top: auto; + margin-bottom: auto; } -#caramel-small-prompt .caramel-prompt-sub svg { - width: 13px; - height: 13px; - color: var(--cm-brand); +#caramel-small-prompt .caramel-prompt-label { + margin-top: auto; + margin-bottom: auto; + padding-top: 5px; } #caramel-close-btn { + background: white; position: absolute; - top: 8px; - right: 8px; - width: 22px; - height: 22px; - display: flex; - align-items: center; - justify-content: center; - padding: 0; - background: transparent; - color: var(--cm-ink-3); - border: none; + top: -5px; + right: -5px; + width: 20px; + height: 20px; + padding: 1px; border-radius: 50%; + color: #ea6925; + border: none; + font-size: 18px; cursor: pointer; - transition: - background 0.16s ease, - color 0.16s ease; + margin-left: 10px; } -#caramel-close-btn:hover { - background: var(--cm-surface-2); - color: var(--cm-ink); -} -#caramel-close-btn svg { - width: 14px; - height: 14px; +#caramel-small-prompt small { + font-size: 14px; } -/* ============================================================ - Testing overlay - ============================================================ */ +/* ---- Testing overlay ("Applying Coupons...") ---- */ #caramel-testing-overlay { position: fixed; - inset: 0; + top: 0; + left: 0; width: 100vw; height: 100vh; - background: rgba(15, 12, 10, 0.5); - -webkit-backdrop-filter: blur(3px); - backdrop-filter: blur(3px); - z-index: 2147483647; + background-color: rgba(0, 0, 0, 0.6); + z-index: 1000000; display: flex; justify-content: center; align-items: center; - padding: 20px; } #caramel-testing-modal { position: relative; - background: var(--cm-surface); - padding: 26px 24px 24px; - border-radius: 18px; - box-shadow: var(--cm-shadow-lg); - width: min(92vw, 360px); + background-color: #ea6925; + padding: 20px; + border-radius: 12px; + box-shadow: 0 4px 10px rgba(0, 0, 0, 0.3); + color: white; + width: 320px; + font-family: Arial, sans-serif; text-align: center; - animation: caramelPop 0.26s ease both; + animation: + caramelFadeIn 0.5s ease-in-out, + caramelBounce 2s infinite; } #caramel-testing-close { position: absolute; - top: 12px; - right: 12px; - width: 28px; - height: 28px; - display: flex; - align-items: center; - justify-content: center; + top: -8px; + right: -8px; + width: 24px; + height: 24px; border: none; border-radius: 50%; - background: var(--cm-surface-2); - color: var(--cm-ink-3); + background: #fff; + color: #ea6925; + font-size: 16px; + line-height: 1; cursor: pointer; - transition: - background 0.16s ease, - color 0.16s ease; -} -#caramel-testing-close:hover { - background: var(--cm-line); - color: var(--cm-ink); + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.3); } -#caramel-testing-close svg { - width: 15px; - height: 15px; -} -#caramel-testing-modal .caramel-modal-head { +#caramel-testing-modal .caramel-modal-header { display: flex; - flex-direction: column; + justify-content: center; align-items: center; - gap: 14px; - margin-bottom: 8px; + margin-bottom: 10px; } -.caramel-spinner { - width: 38px; - height: 38px; - border-radius: 50%; - border: 3px solid var(--cm-brand-100, #ffe6d5); - border-top-color: var(--cm-brand); - animation: caramelSpin 0.8s linear infinite; +#caramel-testing-modal .caramel-modal-logo { + width: 40px; + height: 40px; + margin-right: 8px; } #caramel-testing-modal h2 { margin: 0; font-size: 18px; - font-weight: 700; - color: var(--cm-ink); text-align: center; } #caramel-test-status { - margin: 6px 0 0; - font-size: 13.5px; - color: var(--cm-ink-2); + margin: 10px 0; + font-size: 15px; } #caramel-progress-container { - background: var(--cm-surface-2); - border: 1px solid var(--cm-line); - border-radius: 999px; + background: rgba(255, 255, 255, 0.2); + border-radius: 6px; width: 100%; - height: 8px; - margin: 16px 0 0; + height: 10px; + margin: 10px 0; + position: relative; overflow: hidden; } #caramel-progress-bar { - background: var(--cm-brand); + background: #ffbf47; width: 0%; height: 100%; - border-radius: 999px; - transition: width 0.35s ease; + border-radius: 6px; + transition: width 0.3s ease; } -/* ============================================================ - Final modal - ============================================================ */ +/* ---- Final modal ---- */ #caramel-final-overlay { position: fixed; - inset: 0; + top: 0; + left: 0; width: 100vw; height: 100vh; - background: rgba(15, 12, 10, 0.55); - -webkit-backdrop-filter: blur(3px); - backdrop-filter: blur(3px); - z-index: 2147483647; + background-color: rgba(0, 0, 0, 0.7); + z-index: 1000000; display: flex; justify-content: center; align-items: center; - padding: 20px; } #caramel-final-overlay .caramel-final-modal { - background: var(--cm-surface); - padding: 28px 24px 24px; - border-radius: 18px; - width: min(92vw, 384px); - max-height: 88vh; - overflow-y: auto; - box-shadow: var(--cm-shadow-lg); + background-color: #fff; + padding: 30px; + border-radius: 12px; + width: 400px; + box-shadow: 0 4px 15px rgba(0, 0, 0, 0.4); + font-family: Arial, sans-serif; text-align: center; position: relative; } -.caramel-final-icon { - width: 56px; - height: 56px; - margin: 0 auto 14px; - border-radius: 50%; +#caramel-final-overlay .caramel-final-logo { display: flex; - align-items: center; justify-content: center; - background: var(--cm-surface-2); - color: var(--cm-ink-2); -} -.caramel-final-icon svg { - width: 28px; - height: 28px; -} -.caramel-final-icon--success { - background: var(--cm-green-bg); - color: var(--cm-green); -} -.caramel-final-icon--brand { - background: #fff1e7; - color: var(--cm-brand); + margin-bottom: 10px; } -.caramel-final-icon--info { - background: var(--cm-surface-2); - color: var(--cm-ink-3); +#caramel-final-overlay .caramel-final-logo img { + width: 60px; + height: 60px; } #caramel-final-overlay h2 { - margin: 0 0 8px 0; - color: var(--cm-ink); - font-size: 20px; - font-weight: 700; - text-align: center; + margin: 0 0 15px 0; + color: #ea6925; + font-size: 24px; + font-weight: bold; } #caramel-final-overlay .caramel-final-msg { - font-size: 14px; - line-height: 1.5; - color: var(--cm-ink-2); - margin: 0 auto; - max-width: 300px; + font-size: 13px; + color: #333; + margin: 0 0 10px 0; } #caramel-final-overlay .caramel-final-code { - display: flex; - align-items: center; - justify-content: center; - gap: 8px; - margin: 18px 0 0; + font-size: 22px; + margin: 6px 0; } -.caramel-final-code-label { - font-size: 11px; - font-weight: 600; - letter-spacing: 0.04em; - text-transform: uppercase; - color: var(--cm-ink-3); -} -.caramel-final-code-val { - font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; - font-size: 17px; - font-weight: 700; - letter-spacing: 0.04em; - color: var(--cm-ink); - background: var(--cm-surface-2); - border: 1px dashed var(--cm-line); - border-radius: 8px; - padding: 5px 12px; +#caramel-final-overlay .caramel-final-code span { + color: #ea6925; + text-decoration: underline; + font-weight: bold; } #caramel-final-overlay .caramel-final-savings { - font-size: 15px; - color: var(--cm-green); - font-weight: 700; - margin: 12px 0 0; + font-size: 18px; + color: #ea6925; + font-weight: bold; + margin: 4px 0 0; } #caramel-final-overlay .caramel-final-hint { font-size: 13px; - color: var(--cm-ink-3); - margin: 10px 0 0; + color: #777; + margin: 4px 0 0; } #caramel-final-ok-btn { - margin-top: 22px; - width: 100%; - background: var(--cm-brand); + margin-top: 20px; + background: #ea6925; border: none; color: #fff; - padding: 13px 24px; - border-radius: 12px; + padding: 12px 24px; + border-radius: 8px; cursor: pointer; - font-size: 15px; - font-weight: 600; - font-family: var(--cm-font); - transition: - background 0.16s ease, - transform 0.1s ease; + font-size: 16px; + font-weight: bold; + box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2); + transition: background 0.3s; } #caramel-final-ok-btn:hover { - background: var(--cm-brand-600); -} -#caramel-final-ok-btn:active { - transform: scale(0.99); + background: #ffbf47; } /* ---- Manual copy list ---- */ .caramel-manual-list { - max-height: 230px; + max-height: 190px; overflow-y: auto; - margin: 18px 0 0; + margin: 10px 0 2px; text-align: left; - display: flex; - flex-direction: column; - gap: 8px; } .caramel-manual-row { display: flex; align-items: center; justify-content: space-between; - gap: 10px; - border: 1px solid var(--cm-line); - border-radius: 12px; - padding: 10px 12px; - background: var(--cm-surface); - transition: border-color 0.16s ease; -} -.caramel-manual-row:hover { - border-color: #ffd9bf; + gap: 8px; + border: 1px solid #eee; + border-radius: 8px; + padding: 8px 10px; + margin: 6px 0; } .caramel-manual-row .caramel-manual-info { min-width: 0; flex: 1; } .caramel-manual-row .caramel-manual-code { - font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; - font-weight: 700; - color: var(--cm-ink); - font-size: 13.5px; - letter-spacing: 0.03em; + font-weight: bold; + color: #333; + font-size: 14px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .caramel-manual-row .caramel-manual-title { - font-size: 11.5px; - color: var(--cm-ink-3); - margin-top: 2px; + font-size: 11px; + color: #888; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .caramel-manual-copy { flex: none; - display: inline-flex; - align-items: center; - gap: 5px; - background: var(--cm-surface); - border: 1px solid var(--cm-brand); - color: var(--cm-brand); - padding: 7px 13px; - border-radius: 9px; - cursor: pointer; - font-size: 12.5px; - font-weight: 600; - font-family: var(--cm-font); - transition: - background 0.16s ease, - color 0.16s ease; -} -.caramel-manual-copy:hover { - background: var(--cm-brand); + background: #ea6925; + border: none; color: #fff; -} - -@media (prefers-reduced-motion: reduce) { - #caramel-small-prompt, - #caramel-testing-modal, - #caramel-final-overlay .caramel-final-modal, - .caramel-spinner, - #caramel-progress-bar { - animation: none !important; - transition: none !important; - } + padding: 7px 14px; + border-radius: 6px; + cursor: pointer; + font-size: 13px; + font-weight: bold; } diff --git a/apps/caramel-extension/index.html b/apps/caramel-extension/index.html index 9e88c43..7de1509 100644 --- a/apps/caramel-extension/index.html +++ b/apps/caramel-extension/index.html @@ -10,6 +10,24 @@ + + +
loading diff --git a/apps/caramel-extension/popup.js b/apps/caramel-extension/popup.js index ed7d3ca..aeee2f0 100644 --- a/apps/caramel-extension/popup.js +++ b/apps/caramel-extension/popup.js @@ -20,47 +20,6 @@ const escHtml = s => })[ch], ) -// Inline icon set (Lucide-style strokes; no emoji). Sized via CSS (currentColor). -const CM_ICONS = { - check: '', - warn: '', - copy: '', - tag: '', -} - -// Verification badge meta: label + class (color via CSS) + optional icon. -const BADGE_META = { - valid: { - label: 'Verified', - cls: 'coupon-badge--valid', - icon: CM_ICONS.check, - }, - valid_with_warning: { - label: 'Verified · varies', - cls: 'coupon-badge--warn', - icon: CM_ICONS.warn, - }, - product_restriction: { - label: 'Restrictions', - cls: 'coupon-badge--warn', - icon: CM_ICONS.warn, - }, - category_restricted: { - label: 'Category-limited', - cls: 'coupon-badge--warn', - icon: CM_ICONS.warn, - }, - seller_specific: { - label: 'Seller-specific', - cls: 'coupon-badge--warn', - icon: CM_ICONS.warn, - }, - pending: { label: 'Unverified', cls: 'coupon-badge--neutral', icon: '' }, - retry: { label: 'Checking', cls: 'coupon-badge--neutral', icon: '' }, - invalid: { label: 'Not valid', cls: 'coupon-badge--bad', icon: '' }, - expired: { label: 'Expired', cls: 'coupon-badge--bad', icon: '' }, -} - async function _detectDevMode() { return new Promise(resolve => { if (typeof chrome === 'undefined' || !chrome.management) @@ -137,13 +96,16 @@ async function getActiveTabDomainRecord() { /* ------------------------------------------------------------ */ function renderUnsupportedSite(user) { const container = document.getElementById('auth-container') + const avatar = user?.image?.length + ? user.image + : 'assets/default-profile.png' container.innerHTML = `
-
${CM_ICONS.tag}
+ User avatar -

No coupons for this site yet

-

We don't have codes for this store right now. Browse the stores we support, or check back soon.

+

No coupons are available for this site.

+

Click below to see which sites we support.

- View supported stores + View Supported Stores ${ @@ -160,17 +122,16 @@ function renderUnsupportedSite(user) { ? '' : '' } -
- - GitHub - + + GitHub + +
` @@ -562,7 +523,7 @@ function renderCouponsView(coupons, user, domain) { class="coupons-profile-image" alt="avatar" /> - @${escHtml(user.username)} + @${user.username} ` : ` avatar @@ -580,7 +541,7 @@ function renderCouponsView(coupons, user, domain) { ${headerRight}
-

${coupons.length} code${coupons.length === 1 ? '' : 's'} for ${escHtml(domain)}

+

Coupons for ${domain}

${ @@ -613,27 +574,52 @@ function renderCouponsView(coupons, user, domain) { : '' warning = `
- ${CM_ICONS.warn} + ${baseMsg}${cartHint} ${verifierMsg}
` } - // Verification badge: class drives color (see CSS). - const meta = BADGE_META[c.status] - const badge = meta - ? `${meta.icon}${meta.label}` + // Verification badge: green=verified, amber=restricted, + // grey=not yet verified (grace), red=known not valid. + const BADGE = { + valid: ['✓ Verified', '#15803d', '#dcfce7'], + valid_with_warning: [ + 'Verified · may vary', + '#b45309', + '#fef3c7', + ], + product_restriction: [ + 'Restrictions apply', + '#b45309', + '#fef3c7', + ], + category_restricted: [ + 'Category-limited', + '#b45309', + '#fef3c7', + ], + seller_specific: [ + 'Seller-specific', + '#b45309', + '#fef3c7', + ], + pending: ['Unverified', '#4b5563', '#f3f4f6'], + retry: ['Checking…', '#4b5563', '#f3f4f6'], + invalid: ['Not valid', '#b91c1c', '#fee2e2'], + expired: ['Expired', '#b91c1c', '#fee2e2'], + } + const bd = BADGE[c.status] + const badge = bd + ? `${bd[0]}` : '' return `
-
- ${escHtml(c.code)} - ${badge} -
- ${c.title ? `
${escHtml(c.title)}
` : ''} - ${c.description ? `
${escHtml(c.description)}
` : ''} +
${escHtml(c.title || 'Untitled Coupon')}
+
${escHtml(c.description || '')}
+ ${badge} ${warning}
- +
` }) diff --git a/apps/caramel-extension/shared-utils.js b/apps/caramel-extension/shared-utils.js index e99e917..456e16d 100644 --- a/apps/caramel-extension/shared-utils.js +++ b/apps/caramel-extension/shared-utils.js @@ -841,7 +841,7 @@ async function startApplyingCoupons(rec) { showFinalModal( 0, null, - "Couldn't load codes right now — give it another go in a moment.", + "Couldn't load codes right now — give it another go in a sec 🙂", ) return } @@ -850,7 +850,7 @@ async function startApplyingCoupons(rec) { showFinalModal( 0, null, - "No codes for this store just yet — we're working on it.", + "No codes for this store just yet — we're on it! 🐝", ) return } From 7fa4daabce8e78880fd5f0fe0384d062b2acc15c Mon Sep 17 00:00:00 2001 From: Alaeddin <15094821+BSalaeddin@users.noreply.github.com> Date: Fri, 26 Jun 2026 16:23:42 +0100 Subject: [PATCH 031/198] =?UTF-8?q?polish(ext):=20quality=20pass=20on=20or?= =?UTF-8?q?iginal=20popup=20=E2=80=94=20system=20font,=20crisper=20ticket?= =?UTF-8?q?=20+=20notches,=20rounded=20copy=20button,=20modern=20scrollbar?= =?UTF-8?q?=20(identity=20kept)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/caramel-extension/assets/styles.css | 89 +++++++++++++++--------- 1 file changed, 58 insertions(+), 31 deletions(-) diff --git a/apps/caramel-extension/assets/styles.css b/apps/caramel-extension/assets/styles.css index 79ddc18..29eecaf 100644 --- a/apps/caramel-extension/assets/styles.css +++ b/apps/caramel-extension/assets/styles.css @@ -3,7 +3,10 @@ html, body { margin: 0; padding: 0; - font-family: 'Arial', sans-serif; + font-family: + -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, + sans-serif; + -webkit-font-smoothing: antialiased; overflow: hidden; /* Keep extension popup from scrolling */ } body { @@ -461,11 +464,24 @@ body { /* -------------- Coupon List + Items -------------- */ .coupon-list { - max-height: 220px; + max-height: 320px; overflow-y: auto; overflow-x: hidden; margin: 8px 0; - padding-right: 4px; /* helps avoid overlap with scrollbar */ + padding: 4px 6px 4px 2px; + scrollbar-width: thin; + scrollbar-color: #e8c4ab transparent; +} +.coupon-list::-webkit-scrollbar { + width: 8px; +} +.coupon-list::-webkit-scrollbar-thumb { + background: #f0cdb4; + border-radius: 999px; + border: 2px solid #fff; +} +.coupon-list::-webkit-scrollbar-thumb:hover { + background: #e8b894; } /* @@ -478,57 +494,64 @@ body { url('data:image/svg+xml;base64,PHN2Zw0KICAgICAgICB3aWR0aD0iNjAiDQogICAgICAgIGhlaWdodD0iMTYiDQogICAgICAgIHZpZXdCb3g9IjAgMCA2MCAxNiINCiAgICAgICAgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIg0KPg0KICAgIDwhLS0gT3ZlcmxhcHBpbmcgc3F1YXJlcyAocmVwcmVzZW50IHRoZSAiY29weSIgaWNvbikgLS0+DQogICAgPHJlY3QNCiAgICAgICAgICAgIHg9IjAiDQogICAgICAgICAgICB5PSIzIg0KICAgICAgICAgICAgd2lkdGg9IjgiDQogICAgICAgICAgICBoZWlnaHQ9IjEwIg0KICAgICAgICAgICAgZmlsbD0ibm9uZSINCiAgICAgICAgICAgIHN0cm9rZT0iI2VhNjkyNSINCiAgICAgICAgICAgIHN0cm9rZS13aWR0aD0iMSINCiAgICAgICAgICAgIHN0cm9rZS1saW5lY2FwPSJyb3VuZCINCiAgICAgICAgICAgIHN0cm9rZS1saW5lam9pbj0icm91bmQiDQogICAgLz4NCiAgICA8cmVjdA0KICAgICAgICAgICAgeD0iMiINCiAgICAgICAgICAgIHk9IjEiDQogICAgICAgICAgICB3aWR0aD0iOCINCiAgICAgICAgICAgIGhlaWdodD0iMTAiDQogICAgICAgICAgICBmaWxsPSJub25lIg0KICAgICAgICAgICAgc3Ryb2tlPSIjZWE2OTI1Ig0KICAgICAgICAgICAgc3Ryb2tlLXdpZHRoPSIxIg0KICAgICAgICAgICAgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIg0KICAgICAgICAgICAgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCINCiAgICAvPg0KDQogICAgPCEtLSBUaGUgd29yZCAiQ29weSIgdG8gdGhlIHJpZ2h0IC0tPg0KICAgIDx0ZXh0DQogICAgICAgICAgICB4PSIxOCINCiAgICAgICAgICAgIHk9IjEyIg0KICAgICAgICAgICAgZmlsbD0iI2VhNjkyNSINCiAgICAgICAgICAgIGZvbnQtc2l6ZT0iMTAiDQogICAgICAgICAgICBmb250LWZhbWlseT0ic2Fucy1zZXJpZiINCiAgICA+DQogICAgICAgIENvcHkNCiAgICA8L3RleHQ+DQo8L3N2Zz4NCg==') 8 8, copy; - border: 2px dashed #ea6925; - border-radius: 8px; - padding: 10px 14px; + border: 1.5px dashed #f0a874; + border-radius: 12px; + padding: 14px 16px; margin-bottom: 12px; transition: - background-color 0.2s, - box-shadow 0.2s; + background-color 0.18s ease, + border-color 0.18s ease, + box-shadow 0.18s ease, + transform 0.18s ease; background-color: #fff; + box-shadow: 0 1px 2px rgba(234, 105, 37, 0.05); position: relative; } /* Subtle hover effect */ .coupon-item:hover { - background-color: #fff8f5; - box-shadow: 0 3px 8px rgba(0, 0, 0, 0.07); + background-color: #fffaf6; + border-color: #ea6925; + box-shadow: 0 6px 16px rgba(234, 105, 37, 0.12); + transform: translateY(-1px); } -/* Optional "ticket notch" effect (using :before/:after) */ +/* "ticket notch" effect — punched circles at opposite corners (as the original) */ .coupon-item:before, .coupon-item:after { content: ''; position: absolute; - width: 14px; - height: 14px; + width: 13px; + height: 13px; background: #fff; - border: 2px solid #ea6925; + border: 1.5px solid #f0a874; border-radius: 50%; } .coupon-item:before { - top: -9px; - left: -9px; + top: -8px; + left: -8px; } .coupon-item:after { - bottom: -9px; - right: -9px; + bottom: -8px; + right: -8px; } /* The coupon's title */ .coupon-title { - font-weight: bold; + font-weight: 700; font-size: 14px; + line-height: 1.35; margin-bottom: 4px; - color: #333; + color: #1c1917; } /* The coupon's description text */ .coupon-desc { - font-size: 13px; - color: #666; - margin-bottom: 6px; + font-size: 12.5px; + line-height: 1.45; + color: #6b6460; + margin-bottom: 10px; } /* A container for the copy button */ @@ -544,22 +567,26 @@ body { background: #ea6925; color: #fff; border: none; - border-radius: 4px; - padding: 10px 16px; + border-radius: 9px; + padding: 11px 16px; cursor: pointer; - font-size: 14px; - font-weight: bold; + font-size: 13.5px; + font-weight: 600; + letter-spacing: 0.01em; + box-shadow: 0 2px 6px rgba(234, 105, 37, 0.28); transition: - background 0.3s ease, - transform 0.1s; + background 0.18s ease, + box-shadow 0.18s ease, + transform 0.1s ease; } .copyBtn:hover { - background: #cf581f; + background: #d65d1f; + box-shadow: 0 4px 12px rgba(234, 105, 37, 0.34); } .copyBtn:active { - transform: scale(0.97); + transform: scale(0.98); } /* -------------- Logout / Login Button -------------- */ From 9afaf6f7aa00dace8de03507c1d2325d4e23c52c Mon Sep 17 00:00:00 2001 From: Alaeddin <15094821+BSalaeddin@users.noreply.github.com> Date: Fri, 26 Jun 2026 20:02:25 +0100 Subject: [PATCH 032/198] =?UTF-8?q?polish(ext):=20quality=20pass=20on=20on?= =?UTF-8?q?-page=20prompt=20+=20modals=20=E2=80=94=20kill=20infinite=20bou?= =?UTF-8?q?nce=20+=20emoji,=20system=20font,=20backdrop=20blur,=20responsi?= =?UTF-8?q?ve=20widths=20(orange=20identity=20kept)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/caramel-extension/UI-helpers.js | 13 ++- apps/caramel-extension/caramel-content.css | 104 ++++++++++++++------- apps/caramel-extension/shared-utils.js | 4 +- 3 files changed, 78 insertions(+), 43 deletions(-) diff --git a/apps/caramel-extension/UI-helpers.js b/apps/caramel-extension/UI-helpers.js index 5e7a94d..450917e 100644 --- a/apps/caramel-extension/UI-helpers.js +++ b/apps/caramel-extension/UI-helpers.js @@ -190,11 +190,10 @@ async function showFinalModal( finalMessage = `Code ${esc(code)} is applied to your cart — review the discount before you check out.` } else if (hasManual) { finalMessage = - "Auto-apply didn't stick this time — no biggie! Copy a code and drop it in the store's promo box 👇" + "Auto-apply didn't stick this time. Copy a code and paste it in the store's promo box." } else { finalMessage = - message || - "Looks like you're already getting the best deal. Go ahead and buy!" + message || "Looks like you're already getting the best deal." } // Caramel brand/logo @@ -202,14 +201,14 @@ async function showFinalModal( const logoUrl = currentBrowser.runtime.getURL('assets/logo.png') // Adjust if needed const heading = savedMoney - ? '🎉 Savings Found! 🎉' + ? 'Savings Found' : appliedCode ? '✓ Coupon Applied' : isSignIn - ? 'Oups..' + ? 'Sign in to continue' : hasManual - ? 'Grab a code 🎟️' - : 'Heads up 🙂' + ? 'Grab a code' + : 'Heads up' const manualBlock = hasManual ? ` diff --git a/apps/caramel-extension/caramel-content.css b/apps/caramel-extension/caramel-content.css index e45f2f9..846a3e1 100644 --- a/apps/caramel-extension/caramel-content.css +++ b/apps/caramel-extension/caramel-content.css @@ -31,21 +31,29 @@ /* ---- Prompt ("Try Caramel Coupons?") ---- */ #caramel-small-prompt { position: fixed; - top: 60px; - right: 20px; - z-index: 999999; + top: max(20px, env(safe-area-inset-top)); + right: max(20px, env(safe-area-inset-right)); + z-index: 2147483646; + max-width: min(86vw, 280px); background: #ea6925; - padding: 20px; - border-radius: 12px; - box-shadow: 0 4px 10px rgba(0, 0, 0, 0.3); + padding: 16px 18px; + border-radius: 14px; + box-shadow: 0 10px 30px rgba(180, 70, 15, 0.35); cursor: pointer; - color: white; - font-family: Arial, sans-serif; - font-size: 16px; + color: #fff; + font-family: + -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, + sans-serif; + font-size: 15px; text-align: center; - animation: - caramelFadeIn 0.5s ease-in-out, - caramelBounce 2s infinite; + animation: caramelFadeIn 0.4s ease both; + transition: + transform 0.16s ease, + box-shadow 0.16s ease; +} +#caramel-small-prompt:hover { + transform: translateY(-2px); + box-shadow: 0 14px 36px rgba(180, 70, 15, 0.42); } #caramel-small-prompt .caramel-prompt-header { font-weight: bold; @@ -89,25 +97,30 @@ left: 0; width: 100vw; height: 100vh; - background-color: rgba(0, 0, 0, 0.6); - z-index: 1000000; + background-color: rgba(15, 12, 10, 0.5); + -webkit-backdrop-filter: blur(3px); + backdrop-filter: blur(3px); + z-index: 2147483647; display: flex; justify-content: center; align-items: center; + padding: 20px; + box-sizing: border-box; } #caramel-testing-modal { position: relative; background-color: #ea6925; - padding: 20px; - border-radius: 12px; - box-shadow: 0 4px 10px rgba(0, 0, 0, 0.3); - color: white; - width: 320px; - font-family: Arial, sans-serif; + padding: 24px; + border-radius: 16px; + box-shadow: 0 20px 48px rgba(20, 14, 10, 0.4); + color: #fff; + width: min(92vw, 340px); + box-sizing: border-box; + font-family: + -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, + sans-serif; text-align: center; - animation: - caramelFadeIn 0.5s ease-in-out, - caramelBounce 2s infinite; + animation: caramelFadeIn 0.3s ease both; } #caramel-testing-close { position: absolute; @@ -168,19 +181,28 @@ left: 0; width: 100vw; height: 100vh; - background-color: rgba(0, 0, 0, 0.7); - z-index: 1000000; + background-color: rgba(15, 12, 10, 0.55); + -webkit-backdrop-filter: blur(3px); + backdrop-filter: blur(3px); + z-index: 2147483647; display: flex; justify-content: center; align-items: center; + padding: 20px; + box-sizing: border-box; } #caramel-final-overlay .caramel-final-modal { background-color: #fff; - padding: 30px; - border-radius: 12px; - width: 400px; - box-shadow: 0 4px 15px rgba(0, 0, 0, 0.4); - font-family: Arial, sans-serif; + padding: 28px; + border-radius: 16px; + width: min(92vw, 400px); + max-height: 88vh; + overflow-y: auto; + box-sizing: border-box; + box-shadow: 0 20px 48px rgba(20, 14, 10, 0.32); + font-family: + -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, + sans-serif; text-align: center; position: relative; } @@ -238,7 +260,8 @@ transition: background 0.3s; } #caramel-final-ok-btn:hover { - background: #ffbf47; + background: #d65d1f; + box-shadow: 0 4px 12px rgba(234, 105, 37, 0.3); } /* ---- Manual copy list ---- */ @@ -282,9 +305,22 @@ background: #ea6925; border: none; color: #fff; - padding: 7px 14px; - border-radius: 6px; + padding: 8px 14px; + border-radius: 8px; cursor: pointer; font-size: 13px; - font-weight: bold; + font-weight: 600; + transition: background 0.16s ease; +} +.caramel-manual-copy:hover { + background: #d65d1f; +} + +@media (prefers-reduced-motion: reduce) { + #caramel-small-prompt, + #caramel-testing-modal, + #caramel-final-overlay .caramel-final-modal { + animation: none !important; + transition: none !important; + } } diff --git a/apps/caramel-extension/shared-utils.js b/apps/caramel-extension/shared-utils.js index 456e16d..e99e917 100644 --- a/apps/caramel-extension/shared-utils.js +++ b/apps/caramel-extension/shared-utils.js @@ -841,7 +841,7 @@ async function startApplyingCoupons(rec) { showFinalModal( 0, null, - "Couldn't load codes right now — give it another go in a sec 🙂", + "Couldn't load codes right now — give it another go in a moment.", ) return } @@ -850,7 +850,7 @@ async function startApplyingCoupons(rec) { showFinalModal( 0, null, - "No codes for this store just yet — we're on it! 🐝", + "No codes for this store just yet — we're working on it.", ) return } From cb565a0daa5af8d63b6038fde3c0842a5cde9f0d Mon Sep 17 00:00:00 2001 From: Alaeddin <15094821+BSalaeddin@users.noreply.github.com> Date: Fri, 26 Jun 2026 20:11:11 +0100 Subject: [PATCH 033/198] polish(popup): top-right verified badge, icon+code copy button, softer ticket notches --- apps/caramel-extension/assets/styles.css | 75 ++++++++++++++++++++---- apps/caramel-extension/popup.js | 13 +++- 2 files changed, 75 insertions(+), 13 deletions(-) diff --git a/apps/caramel-extension/assets/styles.css b/apps/caramel-extension/assets/styles.css index 29eecaf..fa32673 100644 --- a/apps/caramel-extension/assets/styles.css +++ b/apps/caramel-extension/assets/styles.css @@ -516,25 +516,45 @@ body { transform: translateY(-1px); } -/* "ticket notch" effect — punched circles at opposite corners (as the original) */ +/* "ticket notch" effect — circles punched out of opposite corners. + Fill matches the peach card backdrop and the edge is a faint hairline + (not a solid ring) so each notch reads as a bite taken out of the + ticket rather than a dot stuck on top of it. */ .coupon-item:before, .coupon-item:after { content: ''; position: absolute; - width: 13px; - height: 13px; - background: #fff; - border: 1.5px solid #f0a874; + width: 14px; + height: 14px; + background: #fdf1e8; + box-shadow: inset 0 0 0 1px rgba(234, 105, 37, 0.16); border-radius: 50%; } .coupon-item:before { - top: -8px; - left: -8px; + top: -7px; + left: -7px; } .coupon-item:after { - bottom: -8px; - right: -8px; + bottom: -7px; + right: -7px; +} + +/* Verification status tag — pinned to the ticket's top-right corner so it + reads as a stamp on the coupon. Colour/background come from popup.js per + status (green=verified, amber=restricted, grey=pending, red=invalid). */ +.coupon-badge { + position: absolute; + top: 12px; + right: 13px; + padding: 2px 9px; + border-radius: 999px; + font-size: 10.5px; + font-weight: 700; + letter-spacing: 0.02em; + line-height: 1.55; + white-space: nowrap; + box-shadow: 0 1px 2px rgba(20, 14, 10, 0.06); } /* The coupon's title */ @@ -544,6 +564,7 @@ body { line-height: 1.35; margin-bottom: 4px; color: #1c1917; + padding-right: 96px; /* reserve room for the top-right status tag */ } /* The coupon's description text */ @@ -564,11 +585,15 @@ body { /* -------------- The big, obvious Copy button -------------- */ .copyBtn { + display: inline-flex; + align-items: center; + gap: 7px; + max-width: 100%; background: #ea6925; color: #fff; border: none; border-radius: 9px; - padding: 11px 16px; + padding: 10px 14px; cursor: pointer; font-size: 13.5px; font-weight: 600; @@ -589,6 +614,36 @@ body { transform: scale(0.98); } +.copyBtn-ico { + flex: none; + opacity: 0.92; +} + +/* "Copy" label sits a touch lighter than the code it precedes */ +.copyBtn-txt { + flex: none; + font-weight: 600; + opacity: 0.92; +} + +/* The actual code — monospaced on a faint chip, truncates if very long. + The full code is still copied (read from the item's data-code). */ +.copyBtn-code { + flex: 0 1 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-family: + 'SF Mono', ui-monospace, 'Cascadia Code', Menlo, Consolas, monospace; + font-size: 12.5px; + font-weight: 700; + letter-spacing: 0.02em; + padding: 1px 7px; + border-radius: 6px; + background: rgba(255, 255, 255, 0.18); +} + /* -------------- Logout / Login Button -------------- */ .coupons-logout-button { background: #fff; diff --git a/apps/caramel-extension/popup.js b/apps/caramel-extension/popup.js index aeee2f0..698c6a1 100644 --- a/apps/caramel-extension/popup.js +++ b/apps/caramel-extension/popup.js @@ -610,16 +610,23 @@ function renderCouponsView(coupons, user, domain) { } const bd = BADGE[c.status] const badge = bd - ? `${bd[0]}` + ? `${bd[0]}` : '' return `
+ ${badge}
${escHtml(c.title || 'Untitled Coupon')}
${escHtml(c.description || '')}
- ${badge} ${warning}
- +
` }) From c7f6f62fdd8ceeda2ee69d96474593402aab2b86 Mon Sep 17 00:00:00 2001 From: Alaeddin <15094821+BSalaeddin@users.noreply.github.com> Date: Fri, 26 Jun 2026 20:13:32 +0100 Subject: [PATCH 034/198] polish(modals): code as monospace chip not underlined link; monospace manual-list codes --- apps/caramel-extension/caramel-content.css | 30 +++++++++++++++++----- 1 file changed, 23 insertions(+), 7 deletions(-) diff --git a/apps/caramel-extension/caramel-content.css b/apps/caramel-extension/caramel-content.css index 846a3e1..2f5df54 100644 --- a/apps/caramel-extension/caramel-content.css +++ b/apps/caramel-extension/caramel-content.css @@ -227,13 +227,26 @@ margin: 0 0 10px 0; } #caramel-final-overlay .caramel-final-code { - font-size: 22px; - margin: 6px 0; + font-size: 15px; + color: #6b6460; + margin: 12px 0 6px; } +/* The applied/found code rendered as a monospace chip (matches the popup + code pills) rather than an underlined hyperlink. */ #caramel-final-overlay .caramel-final-code span { + display: inline-block; + margin-left: 6px; + padding: 4px 12px; color: #ea6925; - text-decoration: underline; - font-weight: bold; + background: #fff3ec; + border: 1px solid #f6d3bd; + border-radius: 8px; + font-family: + 'SF Mono', ui-monospace, 'Cascadia Code', Menlo, Consolas, monospace; + font-size: 16px; + font-weight: 700; + letter-spacing: 0.04em; + text-decoration: none; } #caramel-final-overlay .caramel-final-savings { font-size: 18px; @@ -286,9 +299,12 @@ flex: 1; } .caramel-manual-row .caramel-manual-code { - font-weight: bold; - color: #333; - font-size: 14px; + font-family: + 'SF Mono', ui-monospace, 'Cascadia Code', Menlo, Consolas, monospace; + font-weight: 700; + color: #1c1917; + font-size: 13.5px; + letter-spacing: 0.02em; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; From dddb00150a599ca9e6ccc6258ed585971d10a8d0 Mon Sep 17 00:00:00 2001 From: Alaeddin <15094821+BSalaeddin@users.noreply.github.com> Date: Fri, 26 Jun 2026 21:14:20 +0100 Subject: [PATCH 035/198] fix(popup): flex title+badge row so wide status tags never overlap the title --- apps/caramel-extension/assets/styles.css | 26 ++++++++++++++++-------- apps/caramel-extension/popup.js | 6 ++++-- 2 files changed, 22 insertions(+), 10 deletions(-) diff --git a/apps/caramel-extension/assets/styles.css b/apps/caramel-extension/assets/styles.css index fa32673..0f51415 100644 --- a/apps/caramel-extension/assets/styles.css +++ b/apps/caramel-extension/assets/styles.css @@ -540,13 +540,23 @@ body { right: -7px; } -/* Verification status tag — pinned to the ticket's top-right corner so it - reads as a stamp on the coupon. Colour/background come from popup.js per - status (green=verified, amber=restricted, grey=pending, red=invalid). */ +/* Title + status tag share a flex row: the title (flex:1) shrinks and wraps + beside the badge, so a wide badge ("Verified · may vary") can never overlap + the title text regardless of length. */ +.coupon-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 8px; + margin-bottom: 4px; +} + +/* Verification status tag — sits at the top-right of the ticket as a stamp. + Colour/background come from popup.js per status (green=verified, + amber=restricted, grey=pending, red=invalid). */ .coupon-badge { - position: absolute; - top: 12px; - right: 13px; + flex: none; + margin-top: 1px; padding: 2px 9px; border-radius: 999px; font-size: 10.5px; @@ -559,12 +569,12 @@ body { /* The coupon's title */ .coupon-title { + flex: 1; + min-width: 0; font-weight: 700; font-size: 14px; line-height: 1.35; - margin-bottom: 4px; color: #1c1917; - padding-right: 96px; /* reserve room for the top-right status tag */ } /* The coupon's description text */ diff --git a/apps/caramel-extension/popup.js b/apps/caramel-extension/popup.js index 698c6a1..b7de8fd 100644 --- a/apps/caramel-extension/popup.js +++ b/apps/caramel-extension/popup.js @@ -614,8 +614,10 @@ function renderCouponsView(coupons, user, domain) { : '' return `
- ${badge} -
${escHtml(c.title || 'Untitled Coupon')}
+
+
${escHtml(c.title || 'Untitled Coupon')}
+ ${badge} +
${escHtml(c.description || '')}
${warning}
From 9f21226b69dd520eccda011e8e068662f895df2b Mon Sep 17 00:00:00 2001 From: Alaeddin <15094821+BSalaeddin@users.noreply.github.com> Date: Fri, 26 Jun 2026 23:19:47 +0100 Subject: [PATCH 036/198] =?UTF-8?q?polish(popup):=20redesign=20unsupported?= =?UTF-8?q?-site=20view=20=E2=80=94=20tag=20empty-state,=20stacked=20butto?= =?UTF-8?q?ns,=20github=20demoted=20to=20subtle=20footer=20link;=20dedupe?= =?UTF-8?q?=20css?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/caramel-extension/assets/styles.css | 167 +++++++++-------------- apps/caramel-extension/popup.js | 45 +++--- 2 files changed, 90 insertions(+), 122 deletions(-) diff --git a/apps/caramel-extension/assets/styles.css b/apps/caramel-extension/assets/styles.css index 0f51415..f99b5ab 100644 --- a/apps/caramel-extension/assets/styles.css +++ b/apps/caramel-extension/assets/styles.css @@ -735,136 +735,109 @@ body { } } +/* --- Unsupported-site / no-coupons view ------------------------- */ .no-coupons-view { background: #fff; - border-radius: 12px; - padding: 20px; - box-shadow: 0 4px 14px rgba(0, 0, 0, 0.12); + border-radius: 16px; + padding: 28px 22px; + box-shadow: 0 4px 16px rgba(20, 14, 10, 0.1); + text-align: center; } -.no-coupons-avatar { - width: 48px; - height: 48px; +/* Empty-state illustration: a brand-orange tag glyph in a soft peach disc, + replacing the meaningless default-avatar that used to sit here. */ +.empty-illu { + width: 60px; + height: 60px; + margin: 0 auto 14px; + display: flex; + align-items: center; + justify-content: center; + background: #fff1e8; border-radius: 50%; - object-fit: cover; - margin-bottom: 10px; } +.no-coupons-view h3 { + margin: 0 0 6px; + font-size: 17px; + font-weight: 700; + color: #1c1917; +} +.no-coupons-view p { + margin: 0 0 18px; + font-size: 13px; + line-height: 1.5; + color: #6b6460; +} + +/* Stacked full-width actions read far cleaner than a wrapped icon+button row. */ .no-coupons-actions { display: flex; - align-items: center; - justify-content: center; + flex-direction: column; gap: 10px; - flex-wrap: wrap; - margin-top: 14px; } .supported-sites-btn, .toggle-login-btn { - padding: 10px 16px; - border-radius: 6px; - font-weight: bold; + display: block; + width: 100%; + box-sizing: border-box; + padding: 12px 16px; + border-radius: 10px; + font-weight: 700; font-size: 14px; cursor: pointer; text-decoration: none; + text-align: center; transition: - background 0.3s ease, - color 0.3s ease; + background 0.18s ease, + border-color 0.18s ease, + box-shadow 0.18s ease, + transform 0.1s ease; } .supported-sites-btn { background: #ea6925; color: #fff; border: none; + box-shadow: 0 2px 8px rgba(234, 105, 37, 0.28); } .supported-sites-btn:hover { - background: #cf581f; + background: #d65d1f; + box-shadow: 0 4px 12px rgba(234, 105, 37, 0.34); +} +.supported-sites-btn:active { + transform: scale(0.99); } .toggle-login-btn { background: #fff; color: #ea6925; - border: 2px solid #ea6925; + border: 1.5px solid #f0c3a6; } .toggle-login-btn:hover { - background: #ea6925; - color: #fff; -} - -.back-btn { - background: none; - border: none; - color: #ea6925; - font-size: 14px; - cursor: pointer; - margin-top: 16px; -} - -.github-icon { - width: 28px; - height: 28px; - opacity: 0.8; - transition: opacity 0.2s ease; -} -.github-icon:hover { - opacity: 1; -} -/* --- Unsupported-site section ----------------------------------- */ - -.no-coupons-view { - background: #fff; - border-radius: 12px; - padding: 20px; - box-shadow: 0 4px 14px rgba(0, 0, 0, 0.12); -} - -.no-coupons-avatar { - width: 48px; - height: 48px; - border-radius: 50%; - object-fit: cover; - margin-bottom: 10px; + background: #fff5ef; + border-color: #ea6925; } -.no-coupons-actions { - display: flex; +/* "Open source" footer link — small GitHub mark + label, de-emphasized, + replacing the stray black PNG circle that used to crowd the action row. */ +.oss-link { + display: inline-flex; align-items: center; - justify-content: center; - gap: 10px; - flex-wrap: wrap; - margin-top: 14px; -} - -.supported-sites-btn, -.toggle-login-btn { - padding: 10px 16px; - border-radius: 6px; - font-weight: bold; - font-size: 14px; - cursor: pointer; + gap: 6px; + margin-top: 16px; + color: #9b938d; + font-size: 12px; + font-weight: 600; text-decoration: none; - transition: - background 0.3s ease, - color 0.3s ease; -} - -.supported-sites-btn { - background: #ea6925; - color: #fff; - border: none; + transition: color 0.18s ease; } -.supported-sites-btn:hover { - background: #cf581f; -} - -.toggle-login-btn { - background: #fff; - color: #ea6925; - border: 2px solid #ea6925; +.oss-link:hover { + color: #6b6460; } -.toggle-login-btn:hover { - background: #ea6925; - color: #fff; +.oss-link svg { + opacity: 0.85; } .back-btn { @@ -873,17 +846,7 @@ body { color: #ea6925; font-size: 14px; cursor: pointer; - margin-bottom: 12px; -} - -.github-icon { - width: 28px; - height: 28px; - opacity: 0.8; - transition: opacity 0.2s ease; -} -.github-icon:hover { - opacity: 1; + margin-top: 16px; } @media (max-width: 480px) { diff --git a/apps/caramel-extension/popup.js b/apps/caramel-extension/popup.js index b7de8fd..2418ca9 100644 --- a/apps/caramel-extension/popup.js +++ b/apps/caramel-extension/popup.js @@ -96,16 +96,19 @@ async function getActiveTabDomainRecord() { /* ------------------------------------------------------------ */ function renderUnsupportedSite(user) { const container = document.getElementById('auth-container') - const avatar = user?.image?.length - ? user.image - : 'assets/default-profile.png' container.innerHTML = `
- User avatar + -

No coupons are available for this site.

-

Click below to see which sites we support.

+

No coupons for this site yet

+

We're adding new stores all the time — see the ones we support.

- View Supported Stores - + >View Supported Stores ${ user - ? '' - : '' + ? '' + : '' } - - - GitHub -
+ + + + Open source +
` From 4e47d2f9981ba0685a8e731a28bd35dd608b08fc Mon Sep 17 00:00:00 2001 From: Alaeddin <15094821+BSalaeddin@users.noreply.github.com> Date: Fri, 26 Jun 2026 23:25:19 +0100 Subject: [PATCH 037/198] polish(popup): ticket notches as clean mid-edge tears, not stray corner dots --- apps/caramel-extension/assets/styles.css | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/apps/caramel-extension/assets/styles.css b/apps/caramel-extension/assets/styles.css index f99b5ab..187f6a4 100644 --- a/apps/caramel-extension/assets/styles.css +++ b/apps/caramel-extension/assets/styles.css @@ -516,27 +516,26 @@ body { transform: translateY(-1px); } -/* "ticket notch" effect — circles punched out of opposite corners. - Fill matches the peach card backdrop and the edge is a faint hairline - (not a solid ring) so each notch reads as a bite taken out of the - ticket rather than a dot stuck on top of it. */ +/* Ticket "tear" notches — two clean semicircles bitten out of the left and + right edges at the vertical centre (the real ticket-stub cut), instead of + the stray dots that used to float at the rounded corners. They're filled + with the popup's page colour so they read as holes punched through the card, + and pinned to the middle of each edge regardless of card height. */ .coupon-item:before, .coupon-item:after { content: ''; position: absolute; + top: 50%; width: 14px; height: 14px; - background: #fdf1e8; - box-shadow: inset 0 0 0 1px rgba(234, 105, 37, 0.16); + background: #fbeee4; border-radius: 50%; + transform: translateY(-50%); } - .coupon-item:before { - top: -7px; left: -7px; } .coupon-item:after { - bottom: -7px; right: -7px; } From 502ec18535e5e2c8843024f88727a3e98b1bcecf Mon Sep 17 00:00:00 2001 From: Alaeddin <15094821+BSalaeddin@users.noreply.github.com> Date: Sat, 27 Jun 2026 00:25:08 +0100 Subject: [PATCH 038/198] =?UTF-8?q?polish(popup):=20premium=20card=20pass?= =?UTF-8?q?=20=E2=80=94=20calmer=20dashed=20border,=20layered=20depth,=20d?= =?UTF-8?q?ead=20codes=20recede=20for=20hierarchy?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/caramel-extension/assets/styles.css | 30 ++++++++++++++++++++---- apps/caramel-extension/popup.js | 4 +++- 2 files changed, 28 insertions(+), 6 deletions(-) diff --git a/apps/caramel-extension/assets/styles.css b/apps/caramel-extension/assets/styles.css index 187f6a4..e332cab 100644 --- a/apps/caramel-extension/assets/styles.css +++ b/apps/caramel-extension/assets/styles.css @@ -494,25 +494,45 @@ body { url('data:image/svg+xml;base64,PHN2Zw0KICAgICAgICB3aWR0aD0iNjAiDQogICAgICAgIGhlaWdodD0iMTYiDQogICAgICAgIHZpZXdCb3g9IjAgMCA2MCAxNiINCiAgICAgICAgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIg0KPg0KICAgIDwhLS0gT3ZlcmxhcHBpbmcgc3F1YXJlcyAocmVwcmVzZW50IHRoZSAiY29weSIgaWNvbikgLS0+DQogICAgPHJlY3QNCiAgICAgICAgICAgIHg9IjAiDQogICAgICAgICAgICB5PSIzIg0KICAgICAgICAgICAgd2lkdGg9IjgiDQogICAgICAgICAgICBoZWlnaHQ9IjEwIg0KICAgICAgICAgICAgZmlsbD0ibm9uZSINCiAgICAgICAgICAgIHN0cm9rZT0iI2VhNjkyNSINCiAgICAgICAgICAgIHN0cm9rZS13aWR0aD0iMSINCiAgICAgICAgICAgIHN0cm9rZS1saW5lY2FwPSJyb3VuZCINCiAgICAgICAgICAgIHN0cm9rZS1saW5lam9pbj0icm91bmQiDQogICAgLz4NCiAgICA8cmVjdA0KICAgICAgICAgICAgeD0iMiINCiAgICAgICAgICAgIHk9IjEiDQogICAgICAgICAgICB3aWR0aD0iOCINCiAgICAgICAgICAgIGhlaWdodD0iMTAiDQogICAgICAgICAgICBmaWxsPSJub25lIg0KICAgICAgICAgICAgc3Ryb2tlPSIjZWE2OTI1Ig0KICAgICAgICAgICAgc3Ryb2tlLXdpZHRoPSIxIg0KICAgICAgICAgICAgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIg0KICAgICAgICAgICAgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCINCiAgICAvPg0KDQogICAgPCEtLSBUaGUgd29yZCAiQ29weSIgdG8gdGhlIHJpZ2h0IC0tPg0KICAgIDx0ZXh0DQogICAgICAgICAgICB4PSIxOCINCiAgICAgICAgICAgIHk9IjEyIg0KICAgICAgICAgICAgZmlsbD0iI2VhNjkyNSINCiAgICAgICAgICAgIGZvbnQtc2l6ZT0iMTAiDQogICAgICAgICAgICBmb250LWZhbWlseT0ic2Fucy1zZXJpZiINCiAgICA+DQogICAgICAgIENvcHkNCiAgICA8L3RleHQ+DQo8L3N2Zz4NCg==') 8 8, copy; - border: 1.5px dashed #f0a874; - border-radius: 12px; + border: 1.5px dashed #ecd5c2; + border-radius: 13px; padding: 14px 16px; margin-bottom: 12px; transition: background-color 0.18s ease, border-color 0.18s ease, box-shadow 0.18s ease, + opacity 0.18s ease, transform 0.18s ease; background-color: #fff; - box-shadow: 0 1px 2px rgba(234, 105, 37, 0.05); + box-shadow: + 0 1px 2px rgba(20, 14, 10, 0.04), + 0 8px 20px -12px rgba(20, 14, 10, 0.18); position: relative; } -/* Subtle hover effect */ +/* Hover brings the brand orange back and lifts the ticket. */ .coupon-item:hover { background-color: #fffaf6; border-color: #ea6925; - box-shadow: 0 6px 16px rgba(234, 105, 37, 0.12); + box-shadow: + 0 2px 4px rgba(20, 14, 10, 0.05), + 0 12px 24px -10px rgba(234, 105, 37, 0.26); + transform: translateY(-1px); +} + +/* Dead codes (invalid / expired) recede so live codes draw the eye first — + the visual rhythm that was missing when every card shouted equally. */ +.coupon-item-dead { + opacity: 0.6; + border-color: #e3ddd6; + background-color: #fcfbfa; + box-shadow: none; +} +.coupon-item-dead:hover { + opacity: 0.9; + border-color: #d2cbc4; + box-shadow: 0 8px 18px -12px rgba(20, 14, 10, 0.22); transform: translateY(-1px); } diff --git a/apps/caramel-extension/popup.js b/apps/caramel-extension/popup.js index 2418ca9..b39791c 100644 --- a/apps/caramel-extension/popup.js +++ b/apps/caramel-extension/popup.js @@ -561,6 +561,8 @@ function renderCouponsView(coupons, user, domain) { 'valid_with_warning', ]) const isRestricted = restrictedSet.has(c.status) + const isDead = + c.status === 'invalid' || c.status === 'expired' let warning = '' if (isRestricted) { const baseMsg = @@ -618,7 +620,7 @@ function renderCouponsView(coupons, user, domain) { ? `${bd[0]}` : '' return ` -
+
${escHtml(c.title || 'Untitled Coupon')}
${badge} From fea0c5f492027e3bb8892a567b431bc343f0c86c Mon Sep 17 00:00:00 2001 From: Alaeddin <15094821+BSalaeddin@users.noreply.github.com> Date: Sat, 27 Jun 2026 00:27:22 +0100 Subject: [PATCH 039/198] =?UTF-8?q?polish(ext):=20on-page=20prompt=20as=20?= =?UTF-8?q?clean=20horizontal=20toast=20=E2=80=94=20logo=20+=20title/subti?= =?UTF-8?q?tle=20+=20chevron,=20gradient,=20drop=20br=20hack?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/caramel-extension/UI-helpers.js | 17 +++-- apps/caramel-extension/caramel-content.css | 80 +++++++++++++--------- 2 files changed, 60 insertions(+), 37 deletions(-) diff --git a/apps/caramel-extension/UI-helpers.js b/apps/caramel-extension/UI-helpers.js index 450917e..dbce0a6 100644 --- a/apps/caramel-extension/UI-helpers.js +++ b/apps/caramel-extension/UI-helpers.js @@ -8,12 +8,17 @@ async function insertCaramelPrompt(domainRecord) { container.id = 'caramel-small-prompt' const logoUrl = await currentBrowser.runtime.getURL('assets/logo-light.png') container.innerHTML = ` -
- -
Try Caramel Coupons?
-

- - Save more with automatic coupons! + +
+ +
+
Try Caramel Coupons
+ Auto-apply the best code at checkout +
+ +
` container.addEventListener('click', event => { diff --git a/apps/caramel-extension/caramel-content.css b/apps/caramel-extension/caramel-content.css index 2f5df54..a936a21 100644 --- a/apps/caramel-extension/caramel-content.css +++ b/apps/caramel-extension/caramel-content.css @@ -28,24 +28,22 @@ } } -/* ---- Prompt ("Try Caramel Coupons?") ---- */ +/* ---- Prompt ("Try Caramel Coupons") ---- */ #caramel-small-prompt { position: fixed; top: max(20px, env(safe-area-inset-top)); right: max(20px, env(safe-area-inset-right)); z-index: 2147483646; - max-width: min(86vw, 280px); - background: #ea6925; - padding: 16px 18px; + width: min(88vw, 300px); + background: linear-gradient(135deg, #ef7733 0%, #e15f18 100%); + padding: 14px 16px; border-radius: 14px; - box-shadow: 0 10px 30px rgba(180, 70, 15, 0.35); + box-shadow: 0 14px 34px rgba(150, 58, 12, 0.34); cursor: pointer; color: #fff; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; - font-size: 15px; - text-align: center; animation: caramelFadeIn 0.4s ease both; transition: transform 0.16s ease, @@ -53,41 +51,61 @@ } #caramel-small-prompt:hover { transform: translateY(-2px); - box-shadow: 0 14px 36px rgba(180, 70, 15, 0.42); + box-shadow: 0 18px 40px rgba(150, 58, 12, 0.42); } -#caramel-small-prompt .caramel-prompt-header { - font-weight: bold; +#caramel-small-prompt .caramel-prompt-row { display: flex; - justify-content: center; + align-items: center; + gap: 11px; } #caramel-small-prompt .caramel-prompt-logo { - width: 30px; - height: 30px; - margin-top: auto; - margin-bottom: auto; + width: 32px; + height: 32px; + flex: none; + object-fit: contain; +} +#caramel-small-prompt .caramel-prompt-copy { + flex: 1; + min-width: 0; + text-align: left; } #caramel-small-prompt .caramel-prompt-label { - margin-top: auto; - margin-bottom: auto; - padding-top: 5px; + font-weight: 700; + font-size: 14.5px; + line-height: 1.25; +} +#caramel-small-prompt small { + display: block; + margin-top: 2px; + font-size: 12.5px; + line-height: 1.3; + opacity: 0.92; +} +#caramel-small-prompt .caramel-prompt-arrow { + flex: none; + opacity: 0.8; + transition: transform 0.16s ease; +} +#caramel-small-prompt:hover .caramel-prompt-arrow { + transform: translateX(2px); + opacity: 1; } #caramel-close-btn { - background: white; position: absolute; - top: -5px; - right: -5px; - width: 20px; - height: 20px; - padding: 1px; - border-radius: 50%; - color: #ea6925; + top: -7px; + right: -7px; + width: 22px; + height: 22px; + padding: 0; border: none; - font-size: 18px; + border-radius: 50%; + background: #fff; + color: #e15f18; + font-size: 16px; + line-height: 22px; + text-align: center; cursor: pointer; - margin-left: 10px; -} -#caramel-small-prompt small { - font-size: 14px; + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.25); } /* ---- Testing overlay ("Applying Coupons...") ---- */ From d70e653f48436eb04b4301fba2f14c9a3c4d40d4 Mon Sep 17 00:00:00 2001 From: Alaeddin <15094821+BSalaeddin@users.noreply.github.com> Date: Sat, 27 Jun 2026 02:15:56 +0100 Subject: [PATCH 040/198] =?UTF-8?q?polish(popup):=20remove=20ticket=20notc?= =?UTF-8?q?h=20cut-out=20circles=20=E2=80=94=20dashed=20border=20carries?= =?UTF-8?q?=20the=20coupon=20look?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/caramel-extension/assets/styles.css | 23 ----------------------- 1 file changed, 23 deletions(-) diff --git a/apps/caramel-extension/assets/styles.css b/apps/caramel-extension/assets/styles.css index e332cab..e0d5d64 100644 --- a/apps/caramel-extension/assets/styles.css +++ b/apps/caramel-extension/assets/styles.css @@ -536,29 +536,6 @@ body { transform: translateY(-1px); } -/* Ticket "tear" notches — two clean semicircles bitten out of the left and - right edges at the vertical centre (the real ticket-stub cut), instead of - the stray dots that used to float at the rounded corners. They're filled - with the popup's page colour so they read as holes punched through the card, - and pinned to the middle of each edge regardless of card height. */ -.coupon-item:before, -.coupon-item:after { - content: ''; - position: absolute; - top: 50%; - width: 14px; - height: 14px; - background: #fbeee4; - border-radius: 50%; - transform: translateY(-50%); -} -.coupon-item:before { - left: -7px; -} -.coupon-item:after { - right: -7px; -} - /* Title + status tag share a flex row: the title (flex:1) shrinks and wraps beside the badge, so a wide badge ("Verified · may vary") can never overlap the title text regardless of length. */ From 715f338bb3a0d03c598b646bb3d0f4bfdcbd8195 Mon Sep 17 00:00:00 2001 From: Alaeddin <15094821+BSalaeddin@users.noreply.github.com> Date: Sun, 28 Jun 2026 20:53:19 +0100 Subject: [PATCH 041/198] harden(ext): update_url dev-detection (drop management permission), extension-pages CSP frame-ancestors none, robust background message router, gate dev logs --- apps/caramel-extension/background.js | 46 ++++++++++++++++------------ apps/caramel-extension/manifest.json | 12 +++----- 2 files changed, 31 insertions(+), 27 deletions(-) diff --git a/apps/caramel-extension/background.js b/apps/caramel-extension/background.js index 9185d35..0f81476 100644 --- a/apps/caramel-extension/background.js +++ b/apps/caramel-extension/background.js @@ -4,7 +4,20 @@ const currentBrowser = (() => { throw new Error('Browser is not supported!') })() -globalThis.CARAMEL_BASE_URL = 'https://grabcaramel.com' +// Dev detection WITHOUT the `management` permission: packed Chrome Web Store +// builds carry an `update_url` in the manifest; unpacked dev installs don't. +// This is synchronous, so the base URL is correct before the first message is +// handled (the old chrome.management.getSelf callback raced inbound messages). +const _isDevInstall = () => { + try { + return !currentBrowser.runtime.getManifest().update_url + } catch (_) { + return false + } +} +globalThis.CARAMEL_BASE_URL = _isDevInstall() + ? 'http://localhost:58000' + : 'https://grabcaramel.com' const EXTENSION_API_KEY = 'WXqEpm2uOV5jjJXPpnQFyZiNdaPVUrtd2LIrf4kc1JA' const caramelUrl = path => new URL(path, `${globalThis.CARAMEL_BASE_URL}/`).toString() @@ -18,16 +31,6 @@ function fetchWithTimeout(url, opts = {}) { ) } -// Auto-switch to localhost when loaded as unpacked dev extension -if (typeof chrome !== 'undefined' && chrome.management) { - chrome.management.getSelf(info => { - if (info?.installType === 'development') { - globalThis.CARAMEL_BASE_URL = 'http://localhost:58000' - console.log('[caramel] DEV MODE: API → localhost:58000') - } - }) -} - function isServiceWorkerContext() { return ( typeof ServiceWorkerGlobalScope !== 'undefined' && @@ -132,10 +135,12 @@ keepAlive() currentBrowser.runtime.onMessage.addListener( (message, sender, sendResponse) => { + if (!message || typeof message.action !== 'string') return if (message.action === 'openPopup') { currentBrowser.windows.create({ url: currentBrowser.runtime.getURL( - 'index.html?isPopup=true&callerId=' + sender.tab.id, + 'index.html?isPopup=true&callerId=' + + (sender.tab?.id ?? ''), ), type: 'popup', width: 400, @@ -149,7 +154,6 @@ currentBrowser.runtime.onMessage.addListener( }) sendResponse({ success: true }) } else if (message.action === 'keepAlive') { - console.log('Received keep-alive message from content script') sendResponse({ status: 'alive' }) // Respond to the message } else if (message.action === 'classifyCart') { fetchWithTimeout(caramelUrl('api/classify-cart'), { @@ -175,12 +179,13 @@ currentBrowser.runtime.onMessage.addListener( url.searchParams.set('key_words', kw || '') url.searchParams.set('limit', '20') if (category) url.searchParams.set('category', category) - console.log('BACKGROUND: fetchCoupons', { - site, - kw, - url: url.toString(), - t: Date.now(), - }) + if (_isDevInstall()) + console.log('BACKGROUND: fetchCoupons', { + site, + kw, + url: url.toString(), + t: Date.now(), + }) fetchWithTimeout(url.toString()) .then(async r => { if (!r.ok) return { coupons: [] } @@ -260,6 +265,9 @@ currentBrowser.runtime.onMessage.addListener( ) return true + } else { + // Unknown action — respond so the caller's promise never hangs. + sendResponse({ error: 'unknown_action' }) } }, ) diff --git a/apps/caramel-extension/manifest.json b/apps/caramel-extension/manifest.json index 9a6b802..46cead3 100644 --- a/apps/caramel-extension/manifest.json +++ b/apps/caramel-extension/manifest.json @@ -16,14 +16,7 @@ "192": "/icons/192.png", "512": "/icons/512.png" }, - "permissions": [ - "tabs", - "activeTab", - "storage", - "scripting", - "identity", - "management" - ], + "permissions": ["tabs", "activeTab", "storage", "scripting", "identity"], "host_permissions": [ "https://*/*", "http://localhost:58000/*", @@ -36,6 +29,9 @@ "matches": [""] } ], + "content_security_policy": { + "extension_pages": "script-src 'self'; object-src 'self'; frame-ancestors 'none'" + }, "content_scripts": [ { "matches": ["https://*/*"], From ef00dfd7a2faa332c60d743bbd1122ff7ef6faf9 Mon Sep 17 00:00:00 2001 From: Alaeddin <15094821+BSalaeddin@users.noreply.github.com> Date: Sun, 28 Jun 2026 20:53:53 +0100 Subject: [PATCH 042/198] harden(ext): gate dev origins/#caramel-test/logs to dev installs, qAll for XPath couponRemove, null-rec guard, fire-and-forget userLoggedIn listener, guard isCheckout selector --- apps/caramel-extension/inject.js | 2 +- apps/caramel-extension/shared-utils.js | 69 +++++++++++++++++++------- 2 files changed, 53 insertions(+), 18 deletions(-) diff --git a/apps/caramel-extension/inject.js b/apps/caramel-extension/inject.js index ea234bc..fbd476b 100644 --- a/apps/caramel-extension/inject.js +++ b/apps/caramel-extension/inject.js @@ -1,4 +1,4 @@ -console.log('Caramel: Injected script') +log('Injected script') window.addEventListener('load', async () => { await tryInitialize() }) diff --git a/apps/caramel-extension/shared-utils.js b/apps/caramel-extension/shared-utils.js index e99e917..212d683 100644 --- a/apps/caramel-extension/shared-utils.js +++ b/apps/caramel-extension/shared-utils.js @@ -43,7 +43,11 @@ if (typeof sleep === 'undefined') { var sleep = ms => new Promise(r => setTimeout(r, ms)) } if (typeof log === 'undefined') { - var log = (...a) => console.log('Caramel:', ...a) + // Verbose only on unpacked dev installs; silent in the packed Web Store + // build so we don't leak coupon/flow internals into every store's console. + var log = _isDevInstall() + ? (...a) => console.log('Caramel:', ...a) + : () => {} } if (typeof recordTiming === 'undefined') { var recordTiming = (event, meta = {}) => { @@ -66,10 +70,15 @@ if (typeof recordTiming === 'undefined') { } } +// Origins trusted to inject a login token via window.postMessage. The dev +// origins are ONLY trusted on an unpacked dev install — in the packed Web Store +// build a tab on dev.grabcaramel.com or a local server must NOT be able to write +// credentials into a real user's extension storage. const CARAMEL_ALLOWED_ORIGINS = new Set([ - 'http://localhost:58300', 'https://grabcaramel.com', - 'https://dev.grabcaramel.com', + ...(_isDevInstall() + ? ['http://localhost:58300', 'https://dev.grabcaramel.com'] + : []), ]) /* ---------- DOM waiters ---------- */ @@ -315,10 +324,16 @@ async function isCheckout() { const rec = await getDomainRecord(location.hostname) if (!rec) return false if (qOne(rec.couponInput) || qOne(rec.showInput)) return true - try { - await waitForElement(`${rec.couponInput},${rec.showInput}`, 3000) - } catch (e) { - log(e) + // Only wait on the selectors the config actually provides — a bare + // `${null},${null}`/`,${x}` compound is a wasted 3s wait (or a thrown + // selector that waitForElement just swallows). + const waitSel = [rec.couponInput, rec.showInput].filter(Boolean).join(',') + if (waitSel) { + try { + await waitForElement(waitSel, 3000) + } catch (e) { + log(e) + } } return !!(qOne(rec.couponInput) || qOne(rec.showInput)) } @@ -366,7 +381,9 @@ function setInputValue(input, code) { async function removeAppliedCoupon(rec) { // Prefer per-config remove; fall back to generic. const sel = findRemoveSelector(rec) - const candidates = [...document.querySelectorAll(sel)].filter( + // qAll (not raw querySelectorAll) so an XPath couponRemove selector is + // evaluated correctly instead of throwing a SyntaxError that aborts removal. + const candidates = qAll(sel).filter( b => b.offsetParent !== null && !b.disabled, ) if (candidates.length) { @@ -775,8 +792,14 @@ async function classifyCartCategory() { async function getCoupons(rec) { let kw = '' - // Dev hook: deterministic coupons when using #caramel-test - if (location.hash && location.hash.includes('caramel-test')) { + // Dev hook: deterministic coupons when using #caramel-test. Gated to + // unpacked dev installs so a #caramel-test link can't make the packed + // production build fire mock codes against a real store's checkout. + if ( + _isDevInstall() && + location.hash && + location.hash.includes('caramel-test') + ) { log('DEV MODE: returning mocked coupons') return [{ code: 'TEST10' }, { code: 'TEST20' }, { code: 'TEST30' }] } @@ -825,7 +848,14 @@ if (typeof _caramelCancelled === 'undefined') { } async function startApplyingCoupons(rec) { log('=== Starting coupon flow ===') - log('AUTO_INSERT_START', { domain: rec?.domain, t: performance.now() }) + if (!rec) { + // No store config (unsupported host / lookup failed). Degrade cleanly + // instead of throwing mid-flow behind the overlay. + log('AUTO_INSERT_STOP', { result: 'no-domain-record' }) + showFinalModal(0, null, "We don't have codes for this store yet.") + return + } + log('AUTO_INSERT_START', { domain: rec.domain, t: performance.now() }) _caramelCancelled = false await showTestingModal() @@ -983,15 +1013,20 @@ if (!window.__caramel_listeners_bound) { ) } }) - currentBrowser.runtime.onMessage.addListener(async (req, _s, send) => { + currentBrowser.runtime.onMessage.addListener((req, _s, send) => { if (req.action === 'userLoggedIn') { log('AUTO_INSERT_TRIGGERED_BY_MESSAGE', { t: performance.now() }) - const rec = await getDomainRecord(location.hostname) - await startApplyingCoupons(rec).catch(err => { - console.error('Caramel: apply flow error', err) - hideTestingModal() - }) + // Fire-and-forget: an async listener returns a Promise (not `true`), + // so Chrome would close the channel before a post-await send(). Reply + // immediately and run the long apply flow detached. + getDomainRecord(location.hostname) + .then(rec => startApplyingCoupons(rec)) + .catch(err => { + console.error('Caramel: apply flow error', err) + hideTestingModal() + }) send({ success: true }) + return false } }) } From 19046a265fe5b5b8c9c6392bede3c7136d7fd549 Mon Sep 17 00:00:00 2001 From: Alaeddin <15094821+BSalaeddin@users.noreply.github.com> Date: Sun, 28 Jun 2026 20:54:05 +0100 Subject: [PATCH 043/198] harden(popup): escape user/domain in innerHTML, guard SW reply + DOM nodes, _isDevInstall base URL, clear OAuth-cancel message, fix fetchCoupons arity --- apps/caramel-extension/popup.js | 62 ++++++++++++++++++--------------- 1 file changed, 33 insertions(+), 29 deletions(-) diff --git a/apps/caramel-extension/popup.js b/apps/caramel-extension/popup.js index b39791c..5974914 100644 --- a/apps/caramel-extension/popup.js +++ b/apps/caramel-extension/popup.js @@ -1,6 +1,12 @@ /* global currentBrowser, fetchCoupons */ -let CARAMEL_BASE_URL = 'https://grabcaramel.com' +// Dev/prod base URL via the shared _isDevInstall() (defined in shared-utils.js, +// loaded before this script). Packed Web Store builds have a manifest +// update_url → prod; unpacked dev installs → localhost. No `management` perm. +const CARAMEL_BASE_URL = + typeof _isDevInstall === 'function' && _isDevInstall() + ? 'http://localhost:58000' + : 'https://grabcaramel.com' const caramelUrl = path => new URL(path, `${CARAMEL_BASE_URL}/`).toString() // Escape coupon/API data before interpolating into innerHTML. Codes, titles and @@ -20,20 +26,6 @@ const escHtml = s => })[ch], ) -async function _detectDevMode() { - return new Promise(resolve => { - if (typeof chrome === 'undefined' || !chrome.management) - return resolve() - chrome.management.getSelf(info => { - if (info?.installType === 'development') { - CARAMEL_BASE_URL = 'http://localhost:58000' - console.log('[caramel] DEV MODE: API → localhost:58000') - } - resolve() - }) - }) -} - /* ------------------------------------------------------------ */ /* Globals */ /* ------------------------------------------------------------ */ @@ -43,8 +35,6 @@ let returnView = null // callback for the “Back” button, set dynamically /* Bootstrap */ /* ------------------------------------------------------------ */ document.addEventListener('DOMContentLoaded', async () => { - await _detectDevMode() - const loader = document.getElementById('loading-container') if (loader) setTimeout(() => (loader.style.display = 'none'), 400) @@ -55,7 +45,15 @@ document.addEventListener('DOMContentLoaded', async () => { /* Init */ /* ------------------------------------------------------------ */ async function initPopup() { - const { url } = await getActiveTabDomainRecord() + // The service worker can reply undefined on a cold start / error; never let + // destructuring throw and leave the user staring at a blank popup. + let url = null + try { + const resp = await getActiveTabDomainRecord() + url = resp?.url ?? null + } catch (_) { + url = null + } currentBrowser.storage.sync.get(['token', 'user'], async res => { const token = res.token || null @@ -63,7 +61,7 @@ async function initPopup() { if (url) { const domain = url.replace(/^(?:https?:\/\/)?(?:www\.)?/, '') - const coupons = await fetchCoupons(domain, []) + const coupons = await fetchCoupons(domain, '') if (coupons?.length) { await renderCouponsView(coupons, user, domain) @@ -238,6 +236,10 @@ async function handleSocialSignIn(provider) { interactive: true, }) + // User closed the OAuth window without finishing → undefined callback. + // Surface a clear "cancelled" message, not a cryptic `new URL(undefined)`. + if (!finalCallbackUrl) throw new Error('Sign-in was cancelled.') + // Extract code and state from the callback URL // Google redirects to the extension's redirect URI: https://[extension-id].chromiumapp.org/?code=...&state=... // chrome.identity captures this URL, and we extract the code from it @@ -433,7 +435,7 @@ function renderSignInPrompt(backFn) { } const loginForm = document.getElementById('loginForm') - loginForm.addEventListener('submit', async e => { + loginForm?.addEventListener('submit', async e => { e.preventDefault() const errorBox = document.getElementById('loginErrorMessage') @@ -493,9 +495,9 @@ function renderProfileCard(user) { container.innerHTML = `
- Profile -
Welcome back, ${user.username}!
-
@${user.username}
+ Profile +
Welcome back, ${escHtml(user.username)}!
+
@${escHtml(user.username)}
@@ -510,9 +512,11 @@ function renderProfileCard(user) { window.open(caramelUrl('profile'), '_blank') } - document.getElementById('logoutBtn').addEventListener('click', () => { - currentBrowser.storage.sync.remove(['token', 'user'], initPopup) - }) + const logoutBtn = document.getElementById('logoutBtn') + if (logoutBtn) + logoutBtn.addEventListener('click', () => { + currentBrowser.storage.sync.remove(['token', 'user'], initPopup) + }) } /* ------------------------------------------------------------ */ @@ -524,11 +528,11 @@ function renderCouponsView(coupons, user, domain) { const headerLeft = user ? ` avatar - @${user.username} + @${escHtml(user.username)} ` : ` avatar @@ -546,7 +550,7 @@ function renderCouponsView(coupons, user, domain) { ${headerRight}
-

Coupons for ${domain}

+

Coupons for ${escHtml(domain)}

${ From 70e3a25c2651f23e61645749c1afc66c17340c5e Mon Sep 17 00:00:00 2001 From: Alaeddin <15094821+BSalaeddin@users.noreply.github.com> Date: Sun, 28 Jun 2026 23:41:10 +0100 Subject: [PATCH 044/198] =?UTF-8?q?redesign(popup):=20modern=20coupon=20ca?= =?UTF-8?q?rd=20=E2=80=94=20left-aligned=20clean=20card=20+=20tear-off=20c?= =?UTF-8?q?ode=20row,=20wrap/clamp/truncate=20so=20long=20titles/codes/des?= =?UTF-8?q?criptions=20never=20overflow?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/caramel-extension/assets/styles.css | 133 +++++++++++------------ apps/caramel-extension/popup.js | 14 +-- 2 files changed, 72 insertions(+), 75 deletions(-) diff --git a/apps/caramel-extension/assets/styles.css b/apps/caramel-extension/assets/styles.css index e0d5d64..3a000ec 100644 --- a/apps/caramel-extension/assets/styles.css +++ b/apps/caramel-extension/assets/styles.css @@ -494,9 +494,10 @@ body { url('data:image/svg+xml;base64,PHN2Zw0KICAgICAgICB3aWR0aD0iNjAiDQogICAgICAgIGhlaWdodD0iMTYiDQogICAgICAgIHZpZXdCb3g9IjAgMCA2MCAxNiINCiAgICAgICAgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIg0KPg0KICAgIDwhLS0gT3ZlcmxhcHBpbmcgc3F1YXJlcyAocmVwcmVzZW50IHRoZSAiY29weSIgaWNvbikgLS0+DQogICAgPHJlY3QNCiAgICAgICAgICAgIHg9IjAiDQogICAgICAgICAgICB5PSIzIg0KICAgICAgICAgICAgd2lkdGg9IjgiDQogICAgICAgICAgICBoZWlnaHQ9IjEwIg0KICAgICAgICAgICAgZmlsbD0ibm9uZSINCiAgICAgICAgICAgIHN0cm9rZT0iI2VhNjkyNSINCiAgICAgICAgICAgIHN0cm9rZS13aWR0aD0iMSINCiAgICAgICAgICAgIHN0cm9rZS1saW5lY2FwPSJyb3VuZCINCiAgICAgICAgICAgIHN0cm9rZS1saW5lam9pbj0icm91bmQiDQogICAgLz4NCiAgICA8cmVjdA0KICAgICAgICAgICAgeD0iMiINCiAgICAgICAgICAgIHk9IjEiDQogICAgICAgICAgICB3aWR0aD0iOCINCiAgICAgICAgICAgIGhlaWdodD0iMTAiDQogICAgICAgICAgICBmaWxsPSJub25lIg0KICAgICAgICAgICAgc3Ryb2tlPSIjZWE2OTI1Ig0KICAgICAgICAgICAgc3Ryb2tlLXdpZHRoPSIxIg0KICAgICAgICAgICAgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIg0KICAgICAgICAgICAgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCINCiAgICAvPg0KDQogICAgPCEtLSBUaGUgd29yZCAiQ29weSIgdG8gdGhlIHJpZ2h0IC0tPg0KICAgIDx0ZXh0DQogICAgICAgICAgICB4PSIxOCINCiAgICAgICAgICAgIHk9IjEyIg0KICAgICAgICAgICAgZmlsbD0iI2VhNjkyNSINCiAgICAgICAgICAgIGZvbnQtc2l6ZT0iMTAiDQogICAgICAgICAgICBmb250LWZhbWlseT0ic2Fucy1zZXJpZiINCiAgICA+DQogICAgICAgIENvcHkNCiAgICA8L3RleHQ+DQo8L3N2Zz4NCg==') 8 8, copy; - border: 1.5px dashed #ecd5c2; - border-radius: 13px; - padding: 14px 16px; + text-align: left; + border: 1px solid #efe4da; + border-radius: 14px; + padding: 14px 15px; margin-bottom: 12px; transition: background-color 0.18s ease, @@ -511,13 +512,13 @@ body { position: relative; } -/* Hover brings the brand orange back and lifts the ticket. */ +/* Hover lifts the card and warms its edge; the code row gets the brand orange. */ .coupon-item:hover { - background-color: #fffaf6; - border-color: #ea6925; + background-color: #fff; + border-color: #f0b48a; box-shadow: 0 2px 4px rgba(20, 14, 10, 0.05), - 0 12px 24px -10px rgba(234, 105, 37, 0.26); + 0 12px 26px -10px rgba(234, 105, 37, 0.24); transform: translateY(-1px); } @@ -571,83 +572,77 @@ body { font-size: 14px; line-height: 1.35; color: #1c1917; + /* Wrap even a single unbreakable string instead of overflowing the card. */ + overflow-wrap: anywhere; + word-break: break-word; } -/* The coupon's description text */ +/* The coupon's description — muted, clamped to 2 lines so one long blurb can't + make a card tower over its neighbours; still wraps unbreakable strings. */ .coupon-desc { font-size: 12.5px; line-height: 1.45; - color: #6b6460; - margin-bottom: 10px; + color: #8a8079; + margin: 0 0 11px; + overflow-wrap: anywhere; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; } -/* A container for the copy button */ -.coupon-action { +/* The tear-off code row — the one place the dashed "coupon" cue lives now. + Code on the left (monospace, truncates if huge so it never overflows), a + compact Copy pill on the right. The whole card stays click-to-copy. */ +.coupon-code-row { display: flex; align-items: center; - margin-top: 5px; - justify-content: flex-end; -} - -/* -------------- The big, obvious Copy button -------------- */ -.copyBtn { - display: inline-flex; - align-items: center; - gap: 7px; - max-width: 100%; - background: #ea6925; - color: #fff; - border: none; - border-radius: 9px; - padding: 10px 14px; - cursor: pointer; - font-size: 13.5px; - font-weight: 600; - letter-spacing: 0.01em; - box-shadow: 0 2px 6px rgba(234, 105, 37, 0.28); + gap: 10px; + margin-top: 4px; + padding: 7px 7px 7px 12px; + border: 1.5px dashed #e7c8ad; + border-radius: 10px; + background: #fff7f1; transition: - background 0.18s ease, - box-shadow 0.18s ease, - transform 0.1s ease; -} - -.copyBtn:hover { - background: #d65d1f; - box-shadow: 0 4px 12px rgba(234, 105, 37, 0.34); -} - -.copyBtn:active { - transform: scale(0.98); -} - -.copyBtn-ico { - flex: none; - opacity: 0.92; + border-color 0.18s ease, + background 0.18s ease; } - -/* "Copy" label sits a touch lighter than the code it precedes */ -.copyBtn-txt { - flex: none; - font-weight: 600; - opacity: 0.92; +.coupon-item:hover .coupon-code-row { + border-color: #ea6925; + background: #fff1e7; } - -/* The actual code — monospaced on a faint chip, truncates if very long. - The full code is still copied (read from the item's data-code). */ -.copyBtn-code { - flex: 0 1 auto; +.coupon-code { + flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: 'SF Mono', ui-monospace, 'Cascadia Code', Menlo, Consolas, monospace; + font-size: 13px; + font-weight: 700; + letter-spacing: 0.03em; + color: #1c1917; +} +.coupon-copy { + flex: none; + display: inline-flex; + align-items: center; + gap: 5px; + padding: 7px 12px; + border-radius: 8px; + background: #ea6925; + color: #fff; font-size: 12.5px; font-weight: 700; - letter-spacing: 0.02em; - padding: 1px 7px; - border-radius: 6px; - background: rgba(255, 255, 255, 0.18); + box-shadow: 0 1px 3px rgba(234, 105, 37, 0.3); + transition: background 0.18s ease; +} +.coupon-item:hover .coupon-copy { + background: #d65d1f; +} +.coupon-copy svg { + flex: none; } /* -------------- Logout / Login Button -------------- */ @@ -885,14 +880,15 @@ body { .coupon-restriction { display: flex; align-items: flex-start; - gap: 6px; + flex-wrap: wrap; + gap: 2px 6px; margin: 6px 0 8px; - padding: 6px 8px; + padding: 7px 9px; font-size: 12px; color: #8a5a00; background: #fff5e0; border-left: 3px solid #f0b34d; - border-radius: 4px; + border-radius: 6px; } .coupon-restriction-icon { font-size: 14px; @@ -906,7 +902,8 @@ body { color: #d97706; } .coupon-restriction-detail { - margin-top: 2px; + flex-basis: 100%; + margin-top: 3px; color: #6b4500; font-size: 11px; opacity: 0.85; diff --git a/apps/caramel-extension/popup.js b/apps/caramel-extension/popup.js index 5974914..0a739da 100644 --- a/apps/caramel-extension/popup.js +++ b/apps/caramel-extension/popup.js @@ -629,17 +629,17 @@ function renderCouponsView(coupons, user, domain) {
${escHtml(c.title || 'Untitled Coupon')}
${badge}
-
${escHtml(c.description || '')}
+ ${c.description ? `
${escHtml(c.description)}
` : ''} ${warning} -
- + Copy +
` }) From 02acf62638b6340427c780cd2012b4626016efc7 Mon Sep 17 00:00:00 2001 From: Alaeddin <15094821+BSalaeddin@users.noreply.github.com> Date: Mon, 29 Jun 2026 11:23:00 +0100 Subject: [PATCH 045/198] harden(modals): wrap long codes in the savings/applied code chip so they stay inside the modal --- apps/caramel-extension/caramel-content.css | 3 +++ 1 file changed, 3 insertions(+) diff --git a/apps/caramel-extension/caramel-content.css b/apps/caramel-extension/caramel-content.css index a936a21..00efbee 100644 --- a/apps/caramel-extension/caramel-content.css +++ b/apps/caramel-extension/caramel-content.css @@ -253,6 +253,8 @@ code pills) rather than an underlined hyperlink. */ #caramel-final-overlay .caramel-final-code span { display: inline-block; + max-width: 100%; + box-sizing: border-box; margin-left: 6px; padding: 4px 12px; color: #ea6925; @@ -265,6 +267,7 @@ font-weight: 700; letter-spacing: 0.04em; text-decoration: none; + overflow-wrap: anywhere; } #caramel-final-overlay .caramel-final-savings { font-size: 18px; From 6aec5632b8609dc3a9a5c189dac01d5b53324fb1 Mon Sep 17 00:00:00 2001 From: Alaeddin <15094821+BSalaeddin@users.noreply.github.com> Date: Tue, 30 Jun 2026 08:58:08 +0100 Subject: [PATCH 046/198] =?UTF-8?q?fix(popup):=20never=20render=20blank=20?= =?UTF-8?q?when=20backend=20unreachable=20=E2=80=94=20graceful=20'Couldn't?= =?UTF-8?q?=20load'=20error=20state=20with=20retry,=20wrap=20init=20render?= =?UTF-8?q?=20path?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/caramel-extension/popup.js | 70 ++++++++++++++++++++++++++------- 1 file changed, 56 insertions(+), 14 deletions(-) diff --git a/apps/caramel-extension/popup.js b/apps/caramel-extension/popup.js index 0a739da..7be1e55 100644 --- a/apps/caramel-extension/popup.js +++ b/apps/caramel-extension/popup.js @@ -56,27 +56,69 @@ async function initPopup() { } currentBrowser.storage.sync.get(['token', 'user'], async res => { - const token = res.token || null - const user = res.user || null + const token = res?.token || null + const user = res?.user || null - if (url) { - const domain = url.replace(/^(?:https?:\/\/)?(?:www\.)?/, '') - const coupons = await fetchCoupons(domain, '') + // Wrap the whole render: a fetch failure (backend down / offline) must + // show an honest error state with a retry, NEVER leave the popup blank. + try { + if (url) { + const domain = url.replace(/^(?:https?:\/\/)?(?:www\.)?/, '') + let coupons = [] + try { + coupons = await fetchCoupons(domain, '') + } catch (_) { + renderLoadError() + return + } - if (coupons?.length) { - await renderCouponsView(coupons, user, domain) - } else { - renderUnsupportedSite(user) + if (coupons?.length) { + await renderCouponsView(coupons, user, domain) + } else { + renderUnsupportedSite(user) + } + return } - return - } - // no active tab info - if (token) renderProfileCard(user) - else renderUnsupportedSite(null) + // no active tab info + if (token) renderProfileCard(user) + else renderUnsupportedSite(null) + } catch (_) { + renderLoadError() + } }) } +/* Network/backend failure state — keeps the popup from rendering blank when the + coupon API is unreachable. Offers a retry that re-runs the whole init. */ +function renderLoadError() { + const container = document.getElementById('auth-container') + if (!container) return + container.innerHTML = ` +
+ +

Couldn't load coupons

+

Check your connection and try again.

+
+ +
+
` + const retry = document.getElementById('retryBtn') + if (retry) + retry.addEventListener('click', () => { + container.innerHTML = '' + initPopup() + }) +} + /* background helper */ async function getActiveTabDomainRecord() { const resp = await new Promise(resolve => { From f14100b046b5b40cee778c7b51ccd9b9993bc03a Mon Sep 17 00:00:00 2001 From: Alaeddin <15094821+BSalaeddin@users.noreply.github.com> Date: Tue, 30 Jun 2026 09:05:03 +0100 Subject: [PATCH 047/198] =?UTF-8?q?fix(popup):=20size=20coupon=20list=20to?= =?UTF-8?q?=20viewport=20so=20card=20never=20overflows=20the=20470px=20bod?= =?UTF-8?q?y=20=E2=80=94=20stops=20header/list-bottom=20clipping=20(the=20?= =?UTF-8?q?stuck-scroll=20bug);=20add=20overscroll-behavior=20contain?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/caramel-extension/assets/styles.css | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/apps/caramel-extension/assets/styles.css b/apps/caramel-extension/assets/styles.css index 3a000ec..a129014 100644 --- a/apps/caramel-extension/assets/styles.css +++ b/apps/caramel-extension/assets/styles.css @@ -464,9 +464,15 @@ body { /* -------------- Coupon List + Items -------------- */ .coupon-list { - max-height: 320px; + /* Fit the list inside the fixed-height popup so the card never overflows the + 470px body (overflow:hidden) and clips the header / list bottom — that + overflow was what made the scroll feel stuck ("can't get back up"). + Adapts to the popup viewport; the list scrolls internally for long lists. + overscroll-behavior:contain stops macOS rubber-band scroll-chaining. */ + max-height: calc(100vh - 240px); overflow-y: auto; overflow-x: hidden; + overscroll-behavior: contain; margin: 8px 0; padding: 4px 6px 4px 2px; scrollbar-width: thin; From 70e6aaec58680d0449e8d8e69b1d81c207a7c2b7 Mon Sep 17 00:00:00 2001 From: Alaeddin <15094821+BSalaeddin@users.noreply.github.com> Date: Tue, 30 Jun 2026 09:07:16 +0100 Subject: [PATCH 048/198] fix(popup): add -webkit-backdrop-filter for Safari/macOS so the popup blur works cross-browser --- apps/caramel-extension/assets/styles.css | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/caramel-extension/assets/styles.css b/apps/caramel-extension/assets/styles.css index a129014..9dfed4a 100644 --- a/apps/caramel-extension/assets/styles.css +++ b/apps/caramel-extension/assets/styles.css @@ -75,6 +75,7 @@ body { border-radius: 20px; margin: auto; /* space from wave */ background: rgba(255, 255, 255, 0.75); + -webkit-backdrop-filter: blur(8px); backdrop-filter: blur(8px); box-shadow: 0 8px 20px rgba(0, 0, 0, 0.2); animation: fadeIn 0.8s ease forwards; From 13f37b00ab96a6c7285e326e390e0d135886e594 Mon Sep 17 00:00:00 2001 From: Alaeddin <15094821+BSalaeddin@users.noreply.github.com> Date: Tue, 30 Jun 2026 09:44:22 +0100 Subject: [PATCH 049/198] =?UTF-8?q?feat(popup):=20content-sized=20popup=20?= =?UTF-8?q?=E2=80=94=20compact=20for=20short=20lists,=20grows=20to=20~3=20?= =?UTF-8?q?visible=20cards=20then=20scrolls=20for=20long;=20fixes=20the=20?= =?UTF-8?q?cramped=201.5-card=20view,=20no=20overflow/clip?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/caramel-extension/assets/styles.css | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/apps/caramel-extension/assets/styles.css b/apps/caramel-extension/assets/styles.css index 9dfed4a..ff51815 100644 --- a/apps/caramel-extension/assets/styles.css +++ b/apps/caramel-extension/assets/styles.css @@ -11,7 +11,12 @@ body { } body { width: 420px; - height: 470px; + /* Size the popup to its content (Chrome grows/shrinks the popup to fit the + body) so short lists don't waste space and long lists get room — the + coupon list caps its own height and scrolls. min-height keeps empty/error + states from looking tiny; max-height stays within Chrome's popup ceiling. */ + min-height: 320px; + max-height: 600px; display: flex; justify-content: center; position: relative; @@ -22,9 +27,9 @@ body { .popup-bg { position: absolute; top: 0; + bottom: 0; /* stretch to the content-sized body height (no fixed 600) */ left: 0; - width: 420px; /* Match body width */ - height: 600px; /* Match body height */ + width: 420px; overflow: hidden; z-index: 1; } @@ -51,9 +56,9 @@ body { .loading-container { position: absolute; top: 0; + bottom: 0; left: 0; width: 420px; - height: 600px; background: rgba(0, 0, 0, 0.35); display: flex; justify-content: center; @@ -469,8 +474,9 @@ body { 470px body (overflow:hidden) and clips the header / list bottom — that overflow was what made the scroll feel stuck ("can't get back up"). Adapts to the popup viewport; the list scrolls internally for long lists. - overscroll-behavior:contain stops macOS rubber-band scroll-chaining. */ - max-height: calc(100vh - 240px); + overscroll-behavior:contain stops macOS rubber-band scroll-chaining. + Fixed cap (popup is content-sized now): ~3 cards, then scroll. */ + max-height: 360px; overflow-y: auto; overflow-x: hidden; overscroll-behavior: contain; From ac1aed52b2b4cdb89278ddd1c27722cb1fe3be69 Mon Sep 17 00:00:00 2001 From: Alaeddin <15094821+BSalaeddin@users.noreply.github.com> Date: Tue, 30 Jun 2026 10:18:55 +0100 Subject: [PATCH 050/198] =?UTF-8?q?a11y(popup):=20coupon=20cards=20keyboar?= =?UTF-8?q?d-operable=20=E2=80=94=20role=3Dbutton,=20tabindex,=20Enter/Spa?= =?UTF-8?q?ce=20to=20copy,=20aria-label=20with=20deal+code,=20visible=20fo?= =?UTF-8?q?cus=20ring?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/caramel-extension/assets/styles.css | 6 ++++ apps/caramel-extension/popup.js | 38 ++++++++++++++---------- 2 files changed, 29 insertions(+), 15 deletions(-) diff --git a/apps/caramel-extension/assets/styles.css b/apps/caramel-extension/assets/styles.css index ff51815..ea5f1c0 100644 --- a/apps/caramel-extension/assets/styles.css +++ b/apps/caramel-extension/assets/styles.css @@ -535,6 +535,12 @@ body { transform: translateY(-1px); } +/* Visible keyboard focus ring — cards are role="button" + tabindex="0". */ +.coupon-item:focus-visible { + outline: 2px solid #ea6925; + outline-offset: 2px; +} + /* Dead codes (invalid / expired) recede so live codes draw the eye first — the visual rhythm that was missing when every card shouted equally. */ .coupon-item-dead { diff --git a/apps/caramel-extension/popup.js b/apps/caramel-extension/popup.js index 7be1e55..d535178 100644 --- a/apps/caramel-extension/popup.js +++ b/apps/caramel-extension/popup.js @@ -666,7 +666,7 @@ function renderCouponsView(coupons, user, domain) { ? `${bd[0]}` : '' return ` -
+
${escHtml(c.title || 'Untitled Coupon')}
${badge} @@ -712,21 +712,29 @@ function renderCouponsView(coupons, user, domain) { renderSignInPrompt(selfCallback), ) - /* copy-to-clipboard */ + /* copy-to-clipboard (mouse + keyboard). Robust copy: async clipboard API + with an execCommand fallback (shared caramelCopyText from UI-helpers.js). + The bare navigator.clipboard path silently did nothing when the API was + blocked — now the user always gets either the code on the clipboard or + honest feedback instead of a dead click. */ + const copyFromItem = async item => { + const code = item.getAttribute('data-code') + const ok = await caramelCopyText(code) + showCopyToast( + ok + ? `Copied "${code}" to clipboard!` + : `Couldn't copy — code is ${code}`, + ) + } container.querySelectorAll('.coupon-item').forEach(item => { - item.addEventListener('click', async e => { - const code = e.currentTarget.getAttribute('data-code') - // Robust copy: async clipboard API with an execCommand fallback - // (shared caramelCopyText from UI-helpers.js, already loaded here). - // The bare navigator.clipboard path silently did nothing when the - // API was blocked — now the user always gets either the code on the - // clipboard or honest feedback instead of a dead click. - const ok = await caramelCopyText(code) - showCopyToast( - ok - ? `Copied "${code}" to clipboard!` - : `Couldn't copy — code is ${code}`, - ) + item.addEventListener('click', () => copyFromItem(item)) + // Keyboard users / screen readers: the card is role="button", so + // Enter and Space must activate it like a real button. + item.addEventListener('keydown', e => { + if (e.key === 'Enter' || e.key === ' ' || e.key === 'Spacebar') { + e.preventDefault() + copyFromItem(item) + } }) }) } From 689785ba53dcd136b29fab2305936244bda0c2f1 Mon Sep 17 00:00:00 2001 From: Alaeddin <15094821+BSalaeddin@users.noreply.github.com> Date: Tue, 30 Jun 2026 10:52:39 +0100 Subject: [PATCH 051/198] =?UTF-8?q?a11y(popup):=20respect=20prefers-reduce?= =?UTF-8?q?d-motion=20=E2=80=94=20neutralize=20logo=20bounce,=20fades,=20a?= =?UTF-8?q?nd=20hover/scroll=20transitions=20for=20reduce-motion=20users?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/caramel-extension/assets/styles.css | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/apps/caramel-extension/assets/styles.css b/apps/caramel-extension/assets/styles.css index ea5f1c0..39cd106 100644 --- a/apps/caramel-extension/assets/styles.css +++ b/apps/caramel-extension/assets/styles.css @@ -928,3 +928,17 @@ body { opacity: 0.85; font-style: italic; } + +/* Respect the OS "reduce motion" setting (accessibility / vestibular sensitivity, + common on macOS/iOS): neutralize the logo bounce, fade-ins, and hover/scroll + transitions across the whole popup without touching static layout transforms. */ +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0.001ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.001ms !important; + scroll-behavior: auto !important; + } +} From aa1a4a23e2a71152727ea299cbc637b8ec67a534 Mon Sep 17 00:00:00 2001 From: Alaeddin <15094821+BSalaeddin@users.noreply.github.com> Date: Tue, 30 Jun 2026 12:08:40 +0100 Subject: [PATCH 052/198] a11y(modals): on-page testing/final modals get role=dialog + aria-modal, Esc-to-close, focus the primary button on open, and keydown-listener cleanup --- apps/caramel-extension/UI-helpers.js | 52 +++++++++++++++++++++++++--- 1 file changed, 47 insertions(+), 5 deletions(-) diff --git a/apps/caramel-extension/UI-helpers.js b/apps/caramel-extension/UI-helpers.js index dbce0a6..6a95e3a 100644 --- a/apps/caramel-extension/UI-helpers.js +++ b/apps/caramel-extension/UI-helpers.js @@ -54,6 +54,9 @@ async function showTestingModal(title = '', noLoading = false) { const modal = document.createElement('div') modal.id = 'caramel-testing-modal' + modal.setAttribute('role', 'dialog') + modal.setAttribute('aria-modal', 'true') + modal.setAttribute('aria-label', 'Applying coupons') const logoUrl = currentBrowser.runtime.getURL('assets/logo-light.png') @@ -80,6 +83,21 @@ async function showTestingModal(title = '', noLoading = false) { _caramelCancelled = true hideTestingModal() }) + // Esc cancels (keyboard parity with the × button); focus the Stop button so + // keyboard users land inside the dialog. + const onKey = e => { + if (e.key === 'Escape') { + _caramelCancelled = true + hideTestingModal() + } + } + overlay.__caramelOnKey = onKey + document.addEventListener('keydown', onKey) + try { + if (_close) _close.focus() + } catch (e) { + /* focus is best-effort */ + } } /** @@ -104,6 +122,8 @@ async function updateTestingModal(currentIndex, total, code) { function hideTestingModal() { const overlay = document.getElementById('caramel-testing-overlay') if (overlay) { + if (overlay.__caramelOnKey) + document.removeEventListener('keydown', overlay.__caramelOnKey) document.body.removeChild(overlay) } } @@ -151,6 +171,9 @@ async function showFinalModal( const modal = document.createElement('div') modal.className = 'caramel-final-modal' + modal.setAttribute('role', 'dialog') + modal.setAttribute('aria-modal', 'true') + modal.setAttribute('aria-label', 'Caramel coupons') // Three terminal states the user might be in: // savedMoney = we computed a real price drop ($X off) @@ -262,6 +285,14 @@ async function showFinalModal( overlay.appendChild(modal) document.body.appendChild(overlay) + // Single close path — also detaches the Esc listener so we never leak a + // document keydown handler onto the store's page after the modal is gone. + const closeFinal = () => { + if (overlay.__caramelOnKey) + document.removeEventListener('keydown', overlay.__caramelOnKey) + if (overlay.parentNode) document.body.removeChild(overlay) + } + // Wire manual-copy buttons — copies the EXACT code shown (data-code). modal.querySelectorAll('.caramel-manual-copy').forEach(btn => { btn.addEventListener('click', async ev => { @@ -278,14 +309,25 @@ async function showFinalModal( }) }) - // Close the modal on button click - modal - .querySelector('#caramel-final-ok-btn') - .addEventListener('click', () => { - document.body.removeChild(overlay) + // Close on the primary button; Esc closes too (keyboard). Focus the button + // on open so keyboard/screen-reader users land on the main action. + const okBtn = modal.querySelector('#caramel-final-ok-btn') + if (okBtn) + okBtn.addEventListener('click', () => { + closeFinal() if (isSignIn) { //show popup.html currentBrowser.runtime.sendMessage({ action: 'openPopup' }) } }) + const onKey = e => { + if (e.key === 'Escape') closeFinal() + } + overlay.__caramelOnKey = onKey + document.addEventListener('keydown', onKey) + try { + if (okBtn) okBtn.focus() + } catch (e) { + /* focus is best-effort */ + } } From 6bad08b096f40958e7b6325ed96ec61dcdf0ea80 Mon Sep 17 00:00:00 2001 From: Alaeddin <15094821+BSalaeddin@users.noreply.github.com> Date: Tue, 30 Jun 2026 18:34:38 +0100 Subject: [PATCH 053/198] =?UTF-8?q?feat(ext):=20graceful=20+=20honest=20ha?= =?UTF-8?q?ndling=20when=20the=20promo=20box=20is=20missing=20=E2=80=94=20?= =?UTF-8?q?probe=20before=20applying;=20tell=20the=20user=20'couldn't=20fi?= =?UTF-8?q?nd=20the=20promo=20box'=20+=20offer=20copy=20codes=20instead=20?= =?UTF-8?q?of=20churning=208=20codes=20then=20a=20misleading=20'didn't=20s?= =?UTF-8?q?tick'?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/caramel-extension/UI-helpers.js | 3 +++ apps/caramel-extension/shared-utils.js | 33 ++++++++++++++++++++++++++ 2 files changed, 36 insertions(+) diff --git a/apps/caramel-extension/UI-helpers.js b/apps/caramel-extension/UI-helpers.js index 6a95e3a..2a11093 100644 --- a/apps/caramel-extension/UI-helpers.js +++ b/apps/caramel-extension/UI-helpers.js @@ -217,7 +217,10 @@ async function showFinalModal( } else if (appliedCode) { finalMessage = `Code ${esc(code)} is applied to your cart — review the discount before you check out.` } else if (hasManual) { + // Prefer an explicit caller message (e.g. "couldn't find the promo box") + // so we tell the user the REAL reason instead of a generic "didn't stick". finalMessage = + message || "Auto-apply didn't stick this time. Copy a code and paste it in the store's promo box." } else { finalMessage = diff --git a/apps/caramel-extension/shared-utils.js b/apps/caramel-extension/shared-utils.js index 212d683..25ba727 100644 --- a/apps/caramel-extension/shared-utils.js +++ b/apps/caramel-extension/shared-utils.js @@ -885,6 +885,39 @@ async function startApplyingCoupons(rec) { return } + // Before pretending to "try" codes, confirm the promo box is actually + // reachable on this page. If the config's selectors don't match (stale + // config, or the box lives on a later checkout step), say so honestly and + // hand over the codes to copy — instead of churning through 8 codes against + // nothing and then showing a misleading "didn't stick" message. + let _box = qOne(rec.couponInput) + if (!_box && rec.showInput) { + const _toggle = qOne(rec.showInput) + if (_toggle) { + _toggle.click() + try { + await waitForElement(rec.couponInput, 2500) + } catch (e) { + /* box still didn't appear */ + } + _box = qOne(rec.couponInput) + } + } + if (!_box) { + log('AUTO_INSERT_STOP', { + result: 'no-coupon-box', + t: performance.now(), + }) + showFinalModal( + 0, + null, + "We couldn't find the promo box on this page — copy a code below and paste it where the store asks for a promo code.", + false, + coupons, + ) + return + } + // Cap attempts to a reasonable number to limit runtime const MAX_ATTEMPTS = 8 if (coupons.length > MAX_ATTEMPTS) coupons = coupons.slice(0, MAX_ATTEMPTS) From 9ec401936511003471ffbed90d682ad9a1d05a44 Mon Sep 17 00:00:00 2001 From: Alaeddin <15094821+BSalaeddin@users.noreply.github.com> Date: Tue, 30 Jun 2026 19:25:10 +0100 Subject: [PATCH 054/198] =?UTF-8?q?a11y(prompt):=20on-page=20prompt=20keyb?= =?UTF-8?q?oard-operable=20=E2=80=94=20role=3Dbutton,=20tabindex,=20Enter/?= =?UTF-8?q?Space=20to=20start,=20aria-label,=20focus=20ring?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/caramel-extension/UI-helpers.js | 42 +++++++++++++++++----- apps/caramel-extension/caramel-content.css | 5 +++ 2 files changed, 39 insertions(+), 8 deletions(-) diff --git a/apps/caramel-extension/UI-helpers.js b/apps/caramel-extension/UI-helpers.js index 2a11093..456e61d 100644 --- a/apps/caramel-extension/UI-helpers.js +++ b/apps/caramel-extension/UI-helpers.js @@ -6,6 +6,12 @@ async function insertCaramelPrompt(domainRecord) { if (document.getElementById('caramel-small-prompt')) return const container = document.createElement('div') container.id = 'caramel-small-prompt' + container.setAttribute('role', 'button') + container.setAttribute('tabindex', '0') + container.setAttribute( + 'aria-label', + 'Try Caramel Coupons — auto-apply the best code at checkout', + ) const logoUrl = await currentBrowser.runtime.getURL('assets/logo-light.png') container.innerHTML = ` @@ -21,13 +27,10 @@ async function insertCaramelPrompt(domainRecord) {
` - container.addEventListener('click', event => { - if (event.target.id === 'caramel-close-btn') { - // If the close button is clicked, just remove the popup - document.body.removeChild(container) - return - } - // If the container itself is clicked, start applying coupons + const _dismiss = () => { + if (container.parentNode) document.body.removeChild(container) + } + const _activate = () => { try { if (typeof log !== 'undefined') log('AUTO_INSERT_TRIGGERED_BY_UI', { @@ -42,7 +45,30 @@ async function insertCaramelPrompt(domainRecord) { console.error('Caramel: apply flow error', err) hideTestingModal() }) - document.body.removeChild(container) + _dismiss() + } + + container.addEventListener('click', event => { + // Close button → just dismiss; anywhere else on the prompt → start. + if (event.target.id === 'caramel-close-btn') { + _dismiss() + return + } + _activate() + }) + // Keyboard parity (the prompt is role="button"): Enter/Space on the prompt + // itself starts the flow. Guard target===container so Enter on the × button + // only closes. + container.addEventListener('keydown', event => { + if ( + event.target === container && + (event.key === 'Enter' || + event.key === ' ' || + event.key === 'Spacebar') + ) { + event.preventDefault() + _activate() + } }) document.body.appendChild(container) diff --git a/apps/caramel-extension/caramel-content.css b/apps/caramel-extension/caramel-content.css index 00efbee..fab20e4 100644 --- a/apps/caramel-extension/caramel-content.css +++ b/apps/caramel-extension/caramel-content.css @@ -53,6 +53,11 @@ transform: translateY(-2px); box-shadow: 0 18px 40px rgba(150, 58, 12, 0.42); } +/* Visible keyboard focus ring — the prompt is role="button" + tabindex="0". */ +#caramel-small-prompt:focus-visible { + outline: 3px solid #fff; + outline-offset: 2px; +} #caramel-small-prompt .caramel-prompt-row { display: flex; align-items: center; From 5f4aa01183e8a50ddd4719ff4a8f7405cee01638 Mon Sep 17 00:00:00 2001 From: Alaeddin <15094821+BSalaeddin@users.noreply.github.com> Date: Tue, 30 Jun 2026 19:50:28 +0100 Subject: [PATCH 055/198] =?UTF-8?q?ux(ext):=20testing=20modal=20opens=20wi?= =?UTF-8?q?th=20'Checking=20this=20store=20for=20codes=E2=80=A6'=20instead?= =?UTF-8?q?=20of=20bare=20'Loading=E2=80=A6'=20=E2=80=94=20clearer=20about?= =?UTF-8?q?=20what's=20happening?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/caramel-extension/UI-helpers.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/caramel-extension/UI-helpers.js b/apps/caramel-extension/UI-helpers.js index 456e61d..d7f8d74 100644 --- a/apps/caramel-extension/UI-helpers.js +++ b/apps/caramel-extension/UI-helpers.js @@ -86,7 +86,7 @@ async function showTestingModal(title = '', noLoading = false) { const logoUrl = currentBrowser.runtime.getURL('assets/logo-light.png') - const loadingHTML = `

Loading...

+ const loadingHTML = `

Checking this store for codes…

` From 8fedf3b49b9c4d60f04101697c8a8e20a9cacdaa Mon Sep 17 00:00:00 2001 From: Alaeddin <15094821+BSalaeddin@users.noreply.github.com> Date: Tue, 30 Jun 2026 23:15:13 +0100 Subject: [PATCH 056/198] =?UTF-8?q?fix(ext):=20re-detect=20dynamically-inj?= =?UTF-8?q?ected=20coupon=20field=20(bag=20drawer=20/=20SPA=20carts)=20?= =?UTF-8?q?=E2=80=94=20load-only=20check=20missed=20it=20on=20allsaints=20?= =?UTF-8?q?and=20most=20modern=20stores;=20MutationObserver=20shows=20the?= =?UTF-8?q?=20prompt=20when=20the=20promo=20box=20appears,=20runs=20even?= =?UTF-8?q?=20if=20load=20already=20fired?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/caramel-extension/inject.js | 10 +++++-- apps/caramel-extension/shared-utils.js | 39 ++++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 3 deletions(-) diff --git a/apps/caramel-extension/inject.js b/apps/caramel-extension/inject.js index fbd476b..57af2ab 100644 --- a/apps/caramel-extension/inject.js +++ b/apps/caramel-extension/inject.js @@ -1,4 +1,8 @@ log('Injected script') -window.addEventListener('load', async () => { - await tryInitialize() -}) +// Run now if the page already finished loading (content scripts can inject +// after 'load' has fired, in which case the listener would never run). +if (document.readyState === 'complete') { + startCheckoutDetection() +} else { + window.addEventListener('load', () => startCheckoutDetection()) +} diff --git a/apps/caramel-extension/shared-utils.js b/apps/caramel-extension/shared-utils.js index 25ba727..354bbbe 100644 --- a/apps/caramel-extension/shared-utils.js +++ b/apps/caramel-extension/shared-utils.js @@ -346,6 +346,45 @@ async function tryInitialize() { } } +/* Entry point. Beyond the one-shot load check, KEEP WATCHING: on SPA / drawer- + cart stores (allsaints and most SFCC/Shopify sites) the coupon field is + injected only when the user opens the bag/cart — with no page navigation, so + a load-time check finds nothing and the user sees nothing even though the + promo box is right there. Re-detect it: observe the DOM and show the prompt + the moment the coupon field appears. Debounced + self-disconnects after it + fires once, so it costs ~nothing and never nags. */ +async function startCheckoutDetection() { + await tryInitialize() + if (window.__caramel_checkout_observer) return + const rec = await getDomainRecord(location.hostname) + if (!rec) return // not a supported store — don't observe at all + let scheduled = false + const recheck = () => { + scheduled = false + // Don't re-prompt if the prompt is already up or we're mid-apply. + if ( + document.getElementById('caramel-small-prompt') || + document.getElementById('caramel-testing-overlay') || + document.getElementById('caramel-final-overlay') + ) + return + if (qOne(rec.couponInput) || qOne(rec.showInput)) { + insertCaramelPrompt(rec) + if (window.__caramel_checkout_observer) { + window.__caramel_checkout_observer.disconnect() + window.__caramel_checkout_observer = null + } + } + } + const mo = new MutationObserver(() => { + if (scheduled) return + scheduled = true + setTimeout(recheck, 400) + }) + mo.observe(document.documentElement, { childList: true, subtree: true }) + window.__caramel_checkout_observer = mo +} + // Generic selectors used when the per-store config doesn't specify them. // These cover the most common Honey-style cart UIs. const GENERIC_APPLIED_SELECTORS = From aa37bdd0c496e618659613d4b32975ef667b9f04 Mon Sep 17 00:00:00 2001 From: Alaeddin <15094821+BSalaeddin@users.noreply.github.com> Date: Tue, 30 Jun 2026 23:17:17 +0100 Subject: [PATCH 057/198] =?UTF-8?q?harden(ext):=20re-detection=20observer?= =?UTF-8?q?=20requires=20the=20coupon=20box/toggle=20to=20be=20visible=20(?= =?UTF-8?q?not=20just=20present)=20=E2=80=94=20avoids=20popping=20the=20pr?= =?UTF-8?q?ompt=20for=20hidden=20pre-rendered=20cart=20drawers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/caramel-extension/shared-utils.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/caramel-extension/shared-utils.js b/apps/caramel-extension/shared-utils.js index 354bbbe..6cacb40 100644 --- a/apps/caramel-extension/shared-utils.js +++ b/apps/caramel-extension/shared-utils.js @@ -359,6 +359,7 @@ async function startCheckoutDetection() { const rec = await getDomainRecord(location.hostname) if (!rec) return // not a supported store — don't observe at all let scheduled = false + const _vis = el => !!el && el.offsetParent !== null const recheck = () => { scheduled = false // Don't re-prompt if the prompt is already up or we're mid-apply. @@ -368,7 +369,10 @@ async function startCheckoutDetection() { document.getElementById('caramel-final-overlay') ) return - if (qOne(rec.couponInput) || qOne(rec.showInput)) { + // Require the coupon box (or its reveal toggle) to be VISIBLE, not just + // present — so a hidden, pre-rendered cart drawer doesn't pop the prompt + // before the user actually opens the cart. + if (_vis(qOne(rec.couponInput)) || _vis(qOne(rec.showInput))) { insertCaramelPrompt(rec) if (window.__caramel_checkout_observer) { window.__caramel_checkout_observer.disconnect() From b601d061a7cfeb25f17d3471b470a058f3ef37c6 Mon Sep 17 00:00:00 2001 From: Alaeddin <15094821+BSalaeddin@users.noreply.github.com> Date: Wed, 1 Jul 2026 00:17:42 +0100 Subject: [PATCH 058/198] =?UTF-8?q?feat(ext):=20only=20prompt=20at=20check?= =?UTF-8?q?out=20when=20codes=20actually=20exist=20=E2=80=94=20no=20empty?= =?UTF-8?q?=20intercept=20on=20stores=20we=20have=20no=20codes=20for;=20fe?= =?UTF-8?q?tch=20once=20at=20detection=20+=20cache=20for=20the=20apply=20s?= =?UTF-8?q?tep=20(no=20double=20fetch)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/caramel-extension/shared-utils.js | 60 +++++++++++++++++++++----- 1 file changed, 49 insertions(+), 11 deletions(-) diff --git a/apps/caramel-extension/shared-utils.js b/apps/caramel-extension/shared-utils.js index 6cacb40..65afd77 100644 --- a/apps/caramel-extension/shared-utils.js +++ b/apps/caramel-extension/shared-utils.js @@ -338,12 +338,38 @@ async function isCheckout() { return !!(qOne(rec.couponInput) || qOne(rec.showInput)) } +/* Coupon-availability cache — fetched once when a checkout is detected so we + can decide whether to even show the prompt, and reused by the apply flow + (no double fetch). Keyed by domain. Guarded var for re-injection safety. */ +if (typeof _caramelCodes === 'undefined') { + var _caramelCodes = null // { domain, list } +} +async function getCachedCodes(rec) { + if (_caramelCodes && _caramelCodes.domain === rec.domain) + return _caramelCodes.list + let list = [] + try { + list = await fetchCoupons(rec.domain, '', '') + } catch (e) { + list = [] + } + _caramelCodes = { + domain: rec.domain, + list: Array.isArray(list) ? list : [], + } + return _caramelCodes.list +} + /* -------------------------------------------------- init hook */ async function tryInitialize() { - if (await isCheckout()) { - const rec = await getDomainRecord(location.hostname) - await insertCaramelPrompt(rec) - } + if (!(await isCheckout())) return + const rec = await getDomainRecord(location.hostname) + if (!rec) return + // Don't intercept a checkout we have no codes for — only show the prompt + // when there's actually something to apply ("checkout without code → why the + // intercept?"). The fetched list is cached for the apply step. + const codes = await getCachedCodes(rec) + if (codes.length) await insertCaramelPrompt(rec) } /* Entry point. Beyond the one-shot load check, KEEP WATCHING: on SPA / drawer- @@ -373,11 +399,22 @@ async function startCheckoutDetection() { // present — so a hidden, pre-rendered cart drawer doesn't pop the prompt // before the user actually opens the cart. if (_vis(qOne(rec.couponInput)) || _vis(qOne(rec.showInput))) { - insertCaramelPrompt(rec) - if (window.__caramel_checkout_observer) { - window.__caramel_checkout_observer.disconnect() - window.__caramel_checkout_observer = null - } + // Only prompt if we actually have codes for this store (no empty + // intercept). getCachedCodes is cached, so this is cheap. + getCachedCodes(rec).then(codes => { + if ( + !codes.length || + document.getElementById('caramel-small-prompt') || + document.getElementById('caramel-testing-overlay') || + document.getElementById('caramel-final-overlay') + ) + return + insertCaramelPrompt(rec) + if (window.__caramel_checkout_observer) { + window.__caramel_checkout_observer.disconnect() + window.__caramel_checkout_observer = null + } + }) } } const mo = new MutationObserver(() => { @@ -847,8 +884,9 @@ async function getCoupons(rec) { return [{ code: 'TEST10' }, { code: 'TEST20' }, { code: 'TEST30' }] } - // 1) Fetch coupons FIRST — no LLM call yet. - const list = await fetchCoupons(rec.domain, kw, '') + // 1) Use the codes already fetched at detection time (cached) — falls back + // to a fresh fetch if the cache is cold. Avoids a double network call. + const list = await getCachedCodes(rec) // 2) Only classify the cart if any returned coupon is flagged as restricted // — that's when the category meaningfully helps the user decide. From 7d00125e41acb7d5227e793b19f7c1cb269afa25 Mon Sep 17 00:00:00 2001 From: Alaeddin <15094821+BSalaeddin@users.noreply.github.com> Date: Wed, 1 Jul 2026 03:15:29 +0100 Subject: [PATCH 059/198] fix(ext): cap coupon apply-flow runtime so the Applying overlay cannot spin 80-100s on dead or rejecting checkouts --- apps/caramel-extension/shared-utils.js | 33 +++++++++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/apps/caramel-extension/shared-utils.js b/apps/caramel-extension/shared-utils.js index 65afd77..0177f43 100644 --- a/apps/caramel-extension/shared-utils.js +++ b/apps/caramel-extension/shared-utils.js @@ -715,7 +715,20 @@ async function applyCoupon(code, rec) { // still races so a faster site exits as soon as a signal lands. const APPLY_WAIT_MS = 10000 const waiters = [waitForCartSignal(APPLY_WAIT_MS)] - if (priceEl) waiters.push(waitForTextChange(priceEl, APPLY_WAIT_MS)) + // A price-watch timeout means "the total didn't change" — that's a + // no-signal outcome, NOT a coupon error. Let it RESOLVE (swallow the + // reject) so a failed apply falls through to the real committed / error- + // text detection below, instead of aborting into the catch with a + // synthetic "waitForTextChange timeout" errorMsg. That synthetic error + // was being misread by the loop as a cart "signal" (sawSignal=true), + // defeating the no-signal early-exit and making dead checkouts churn all + // 8 codes (~80-100s) instead of bailing after ~2 (~20s). + if (priceEl) + waiters.push( + waitForTextChange(priceEl, APPLY_WAIT_MS).catch( + () => 'price-nochange', + ), + ) const via = await Promise.race(waiters) log('Wait finished via', via) @@ -1017,8 +1030,26 @@ async function startApplyingCoupons(rec) { const EARLY_PROBE = 2 let sawSignal = false + // Wall-clock backstop. The no-signal early-exit above can't help a checkout + // that stays *responsive* — one that hands back a real "invalid code" error + // for every code (sawSignal=true) — so without this it would churn all 8 + // codes at ~10s each and trap the user behind the "Applying…" overlay for + // 80-100s. Once this budget is spent with nothing applied, stop and hand the + // remaining codes over to copy. A VALID code still wins instantly (success + // breaks the loop below), so this only ever trims trailing *failing* tries. + // Time-based, never store-specific. + const FLOW_BUDGET_MS = 35000 + const loopStart = performance.now() + for (let i = 0; i < coupons.length; i++) { if (_caramelCancelled) break + if (!bestCode && performance.now() - loopStart > FLOW_BUDGET_MS) { + log('AUTO_INSERT_TIME_BUDGET', { + tried: i, + elapsed: performance.now() - loopStart, + }) + break + } const { code } = coupons[i] triedCodes.push(code) await updateTestingModal(i + 1, coupons.length, code) From 4a091e0ce6b47fd421bd5f2246adeac5e4b90868 Mon Sep 17 00:00:00 2001 From: Alaeddin <15094821+BSalaeddin@users.noreply.github.com> Date: Thu, 2 Jul 2026 02:07:42 +0100 Subject: [PATCH 060/198] fix(ext): credit a late-rendering discount so a valid code isn't reported failed (nor poisons later tries) --- apps/caramel-extension/shared-utils.js | 31 ++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/apps/caramel-extension/shared-utils.js b/apps/caramel-extension/shared-utils.js index 0177f43..a808350 100644 --- a/apps/caramel-extension/shared-utils.js +++ b/apps/caramel-extension/shared-utils.js @@ -1056,6 +1056,37 @@ async function startApplyingCoupons(rec) { const res = await applyCoupon(code, rec) + // Late-total safety net: some checkouts (erincondren-class) flash their + // error region a beat BEFORE the order total re-renders, so applyCoupon + // can measure "no drop" and rule a code failed even though it actually + // stuck. Left uncaught, that applied-but-unrecognised coupon then + // poisons every later attempt (whose baseline is now the discounted + // price) and the run ends "nothing applied" while a discount sits on + // the cart. So: if a coupon row is now showing, poll briefly for the + // LIVE total to fall below the cart's ORIGINAL total; if it does, this + // code really worked — credit it and stop. Gated on an applied row + + // price config, so invalid codes (no row) add no time. + if (!res.success && hasPriceCfg && !isNaN(original)) { + const appliedNow = () => + qAll(findAppliedSelector(rec)).some( + el => el && el.offsetParent !== null, + ) + if (appliedNow()) { + for (let t = 0; t < 4; t++) { + await sleep(400) + const cur = getPrice(rec.priceContainer, { + returnLargest: true, + }) + if (!isNaN(cur) && cur < original - 0.01) { + res.success = true + res.newTotal = cur + res.committed = true + break + } + } + } + } + if (res.success) { // Real success — keep this code applied, stop here. const diff = From f728c9dc725425e0cf34bb4c9da50f5fcbf2d62b Mon Sep 17 00:00:00 2001 From: Alaeddin <15094821+BSalaeddin@users.noreply.github.com> Date: Thu, 2 Jul 2026 23:28:22 +0100 Subject: [PATCH 061/198] =?UTF-8?q?fix(ext):=20visibility-aware=20apply=20?= =?UTF-8?q?=E2=80=94=20reveal=20collapsed=20coupon=20accordions=20and=20ac?= =?UTF-8?q?t=20on=20the=20visible=20match=20among=20ambiguous=20config=20s?= =?UTF-8?q?electors?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/caramel-extension/shared-utils.js | 154 ++++++++++++++++++++----- 1 file changed, 124 insertions(+), 30 deletions(-) diff --git a/apps/caramel-extension/shared-utils.js b/apps/caramel-extension/shared-utils.js index a808350..f380afc 100644 --- a/apps/caramel-extension/shared-utils.js +++ b/apps/caramel-extension/shared-utils.js @@ -82,6 +82,78 @@ const CARAMEL_ALLOWED_ORIGINS = new Set([ ]) /* ---------- DOM waiters ---------- */ +/* ---------- visibility helpers ---------- */ +// "Can the user actually see this?" — checkVisibility() correctly handles +// display:none ancestors (collapsed accordions), visibility:hidden and +// content-visibility, and (unlike the old offsetParent test) doesn't +// false-negative inside position:fixed/sticky containers, where order-summary +// rails and their coupon UIs commonly live. +function _isVisible(el) { + if (!el) return false + try { + if (typeof el.checkVisibility === 'function') + return el.checkVisibility() + } catch (e) { + /* fall through to the legacy heuristic */ + } + return el.offsetParent !== null +} +// Wait until the selector matches a VISIBLE element. waitForElement only waits +// for presence — useless for reveal-toggles that unhide pre-rendered markup +// (the input already "exists" while still display:none). Checks ALL matches, +// not just the first: generic selectors often also hit hidden templates. +function waitForVisible(sel, timeout = 3000) { + return new Promise((res, rej) => { + const t0 = performance.now() + ;(function poll() { + if (qAll(sel).some(_isVisible)) return res('visible') + if (performance.now() - t0 > timeout) + return rej(`waitForVisible timeout (${sel})`) + setTimeout(poll, 120) + })() + }) +} +// Pick the best element among ALL matches of a config selector. Config +// selectors are often generic on purpose (Magento's `.title[data-role=title]` +// matches EVERY accordion section — Estimate Shipping, Gift Cards, the promo +// block…), and querySelector's "first match" can land on a hidden or wrong +// section. Generic disambiguation, no store logic: +// 1. if an anchor is given (usually the coupon input), prefer the match +// sharing the SMALLEST containing block with it — the promo toggle sits +// in the same block as the promo input; unrelated accordions don't; +// 2. otherwise prefer a VISIBLE match; +// 3. otherwise fall back to the first match. +function pickBestMatch(sel, anchorEl) { + const all = qAll(sel) + if (!all.length) return null + if (anchorEl) { + let best = null + let bestDepth = Infinity + let bestVisible = false + for (const cand of all) { + let p = cand.parentElement + let d = 0 + while (p && d < 8) { + if (p.contains(anchorEl)) { + const v = _isVisible(cand) + if ( + d < bestDepth || + (d === bestDepth && v && !bestVisible) + ) { + bestDepth = d + best = cand + bestVisible = v + } + break + } + p = p.parentElement + d++ + } + } + if (best) return best + } + return all.find(_isVisible) || all[0] +} function waitForElement(sel, timeout = 4000) { return new Promise((res, rej) => { if (qOne(sel)) return res('found-immediately') @@ -385,7 +457,7 @@ async function startCheckoutDetection() { const rec = await getDomainRecord(location.hostname) if (!rec) return // not a supported store — don't observe at all let scheduled = false - const _vis = el => !!el && el.offsetParent !== null + const _vis = _isVisible const recheck = () => { scheduled = false // Don't re-prompt if the prompt is already up or we're mid-apply. @@ -463,9 +535,7 @@ async function removeAppliedCoupon(rec) { const sel = findRemoveSelector(rec) // qAll (not raw querySelectorAll) so an XPath couponRemove selector is // evaluated correctly instead of throwing a SyntaxError that aborts removal. - const candidates = qAll(sel).filter( - b => b.offsetParent !== null && !b.disabled, - ) + const candidates = qAll(sel).filter(b => _isVisible(b) && !b.disabled) if (candidates.length) { // The newest applied coupon is usually rendered last → click last one. const btn = candidates[candidates.length - 1] @@ -495,7 +565,7 @@ function _firstVisibleErrorEl(rec) { if (!rec.errorIndicator) return null const all = qAll(rec.errorIndicator) for (const el of all) { - if (!el || el.offsetParent === null) continue + if (!el || !_isVisible(el)) continue const t = (el.innerText || '').trim() if (t.length) return el } @@ -516,7 +586,7 @@ function snapshotErrorState(rec) { if (!el) return { text: '', visible: false } return { text: (el.innerText || '').trim(), - visible: el.offsetParent !== null, + visible: _isVisible(el), } } @@ -534,7 +604,7 @@ function detectCouponError(rec, baseline, code) { // forever and the loop never stops on a valid coupon. if (rec.errorIndicator) { const el = _firstVisibleErrorEl(rec) - if (el && el.offsetParent !== null) { + if (el && _isVisible(el)) { const t = (el.innerText || '').trim() if (t.length) { const tl = t.toLowerCase() @@ -545,7 +615,7 @@ function detectCouponError(rec, baseline, code) { // Otherwise — ambiguous status text. Treat as new error // only if the container first appeared (was hidden, now // shown) — never on text-changed-but-still-vague. - if (baseline && !baseline.visible && el.offsetParent !== null) { + if (baseline && !baseline.visible && _isVisible(el)) { return t } return null // generic / stale status copy → not our error @@ -553,7 +623,7 @@ function detectCouponError(rec, baseline, code) { } } // Generic: look near the input for an inline error matching common phrases. - const input = qOne(rec.couponInput) + const input = pickBestMatch(rec.couponInput) if (!input) return null let scope = input.parentElement for (let d = 0; d < 5 && scope; d++) { @@ -586,23 +656,39 @@ async function applyCoupon(code, rec) { } } - /* 2] ensure input visible */ - let input = qOne(rec.couponInput) - if (!input && rec.showInput) { - const showBtn = qOne(rec.showInput) + /* 2] ensure input visible — "present" is NOT enough. Magento-class + carts pre-render the whole coupon form inside a collapsed + accordion (display:none content, visible "APPLY PROMO CODE" + toggle). Typing into the hidden input and clicking the hidden + apply button reaches NO form submission at all (proven live on + naturepedic: hidden click → zero submit; after clicking the + toggle, the identical sequence fires the real couponPost). So: + if the input is missing OR hidden, click showInput and wait for + the input to become VISIBLE, not merely attached. */ + let input = pickBestMatch(rec.couponInput) + if ((!input || !_isVisible(input)) && rec.showInput) { + const showBtn = pickBestMatch(rec.showInput, input) if (showBtn) { showBtn.click() try { - await waitForElement(rec.couponInput, 3000) + await waitForVisible(rec.couponInput, 3000) } catch (e) { log(e) + // Late-bound accordion widgets (RequireJS) can miss the + // first click — one bounded retry. + showBtn.click() + try { + await waitForVisible(rec.couponInput, 1500) + } catch (e2) { + log(e2) + } } - input = qOne(rec.couponInput) + input = pickBestMatch(rec.couponInput) } } - const applyBtn = qOne(rec.couponSubmit) - if (!input || !applyBtn) { - log('Input / apply button missing') + const applyBtn = pickBestMatch(rec.couponSubmit, input) + if (!input || !_isVisible(input) || !applyBtn) { + log('Input / apply button missing or hidden') log('AUTO_INSERT_ATTEMPT_END', code, { success: false, elapsed: performance.now() - attemptStart, @@ -693,7 +779,7 @@ async function applyCoupon(code, rec) { const nowSuccess = qAll(appliedSel).length if (nowSuccess > baseSuccess) return 'committed' const errEl = _firstVisibleErrorEl(rec) - if (errEl && errEl.offsetParent !== null) { + if (errEl && _isVisible(errEl)) { const t = (errEl.innerText || '').trim() if (t.length && t !== baseErr) return 'errored' } @@ -984,20 +1070,30 @@ async function startApplyingCoupons(rec) { // config, or the box lives on a later checkout step), say so honestly and // hand over the codes to copy — instead of churning through 8 codes against // nothing and then showing a misleading "didn't stick" message. - let _box = qOne(rec.couponInput) - if (!_box && rec.showInput) { - const _toggle = qOne(rec.showInput) + // Visibility-aware: a box that exists but sits inside a collapsed + // accordion (Magento-class carts) is as unusable as a missing one — reveal + // it via showInput up front so the whole loop runs against a box the user + // can actually SEE, and wait for visibility, not mere presence. + let _box = pickBestMatch(rec.couponInput) + if ((!_box || !_isVisible(_box)) && rec.showInput) { + const _toggle = pickBestMatch(rec.showInput, _box) if (_toggle) { _toggle.click() try { - await waitForElement(rec.couponInput, 2500) + await waitForVisible(rec.couponInput, 2500) } catch (e) { - /* box still didn't appear */ + // late-bound accordion widgets can miss the first click + _toggle.click() + try { + await waitForVisible(rec.couponInput, 1500) + } catch (e2) { + /* box still didn't appear */ + } } - _box = qOne(rec.couponInput) + _box = pickBestMatch(rec.couponInput) } } - if (!_box) { + if (!_box || !_isVisible(_box)) { log('AUTO_INSERT_STOP', { result: 'no-coupon-box', t: performance.now(), @@ -1068,9 +1164,7 @@ async function startApplyingCoupons(rec) { // price config, so invalid codes (no row) add no time. if (!res.success && hasPriceCfg && !isNaN(original)) { const appliedNow = () => - qAll(findAppliedSelector(rec)).some( - el => el && el.offsetParent !== null, - ) + qAll(findAppliedSelector(rec)).some(el => _isVisible(el)) if (appliedNow()) { for (let t = 0; t < 4; t++) { await sleep(400) @@ -1113,7 +1207,7 @@ async function startApplyingCoupons(rec) { if (res.committed) { await removeAppliedCoupon(rec) } else { - const inp = qOne(rec.couponInput) + const inp = pickBestMatch(rec.couponInput) if (inp && inp.value) { setInputValue(inp, '') } From ff89cd482684702336c4d0645429e905099f98cd Mon Sep 17 00:00:00 2001 From: Alaeddin <15094821+BSalaeddin@users.noreply.github.com> Date: Fri, 3 Jul 2026 01:47:36 +0100 Subject: [PATCH 062/198] =?UTF-8?q?feat(ext):=20discount-link=20apply=20st?= =?UTF-8?q?rategy=20via=20runtime=20cart.js=20capability=20probe=20?= =?UTF-8?q?=E2=80=94=20auto-applies=20where=20the=20DOM=20form=20ignores?= =?UTF-8?q?=20synthetic=20clicks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/caramel-extension/shared-utils.js | 118 +++++++++++++++++++++++++ 1 file changed, 118 insertions(+) diff --git a/apps/caramel-extension/shared-utils.js b/apps/caramel-extension/shared-utils.js index f380afc..c8d47c8 100644 --- a/apps/caramel-extension/shared-utils.js +++ b/apps/caramel-extension/shared-utils.js @@ -452,6 +452,22 @@ async function tryInitialize() { the moment the coupon field appears. Debounced + self-disconnects after it fires once, so it costs ~nothing and never nags. */ async function startCheckoutDetection() { + // A discount-link apply reloads the page so the store's own UI shows the + // applied code; finish that flow on the fresh document by showing the + // result modal instead of re-prompting. + try { + const raw = sessionStorage.getItem('caramel_applied') + if (raw) { + sessionStorage.removeItem('caramel_applied') + const st = JSON.parse(raw) + if (st && st.code && Date.now() - (st.t || 0) < 120000) { + showFinalModal(st.saved || 0, st.code, null) + return + } + } + } catch (e) { + /* sessionStorage unavailable — continue with normal detection */ + } await tryInitialize() if (window.__caramel_checkout_observer) return const rec = await getDomainRecord(location.hostname) @@ -887,6 +903,48 @@ async function applyCoupon(code, rec) { } } +/* ---------------------------------------------- discount-link apply */ +// Runtime CAPABILITY detection — no store lists, no config inspection. Some +// checkout stacks ignore ALL synthetic DOM interaction on their discount form +// (isTrusted-gated Apply buttons — proven live on two independent stores), but +// the same platform exposes, on the SAME origin the user is browsing: +// GET /cart.js -> live cart JSON (totals in cents, token) +// GET /discount/{code} -> attaches the code to the session server-side +// Any site that isn't that platform simply fails the /cart.js shape probe and +// the normal config-selector DOM flow below runs unchanged. +async function probeCartJson() { + try { + const r = await fetch('/cart.js', { credentials: 'same-origin' }) + if (!r.ok) return null + const j = await r.json() + if ( + j && + typeof j.token === 'string' && + typeof j.total_price === 'number' && + typeof j.item_count === 'number' + ) + return j + } catch (e) { + /* not this platform — probe is expected to fail elsewhere */ + } + return null +} +async function applyViaDiscountLink(code) { + // The discount endpoint 302s to the storefront; we only need the session + // cookie it sets, then re-read the live totals to see if the code took. + // A later code simply replaces the session's discount, so probing several + // codes leaves at most one (possibly ineffective) code attached — inert. + try { + await fetch('/discount/' + encodeURIComponent(code), { + credentials: 'same-origin', + redirect: 'follow', + }) + return await probeCartJson() + } catch (e) { + return null + } +} + /* -------------------------------------------------- coupon list */ async function fetchCoupons(site, kw, category) { // Delegate network fetch to background/service worker to avoid CORS failures @@ -1065,6 +1123,66 @@ async function startApplyingCoupons(rec) { return } + // Discount-link strategy first when the platform capability is present + // (see probeCartJson). On these checkouts the DOM form is deaf to + // synthetic events, while the link path is fast (~0.5s/code, no page + // freeze), measurable (live totals), and works even when the coupon UI + // lives behind an untrusted-click gate. + const _cart0 = await probeCartJson() + if (_cart0 && _cart0.item_count > 0) { + log('AUTO_INSERT_STRATEGY', { + via: 'discount-link', + total: _cart0.total_price, + }) + const linkCodes = coupons.slice(0, 8) + for (let i = 0; i < linkCodes.length; i++) { + if (_caramelCancelled) { + log('AUTO_INSERT_STOP', { + result: 'cancelled', + t: performance.now(), + }) + return + } + const { code } = linkCodes[i] + await updateTestingModal(i + 1, linkCodes.length, code) + const after = await applyViaDiscountLink(code) + if (after && after.total_price < _cart0.total_price) { + const saved = (_cart0.total_price - after.total_price) / 100 + log('AUTO_INSERT_STOP', { + result: 'applied', + via: 'discount-link', + bestCode: code, + bestSave: saved, + t: performance.now(), + }) + // Reload so the page's own UI shows the applied discount + // (tag + new total), then re-show our result on the fresh + // document — sessionStorage survives same-tab reloads and is + // per-origin, so the handoff can't leak across sites. + try { + sessionStorage.setItem( + 'caramel_applied', + JSON.stringify({ code, saved, t: Date.now() }), + ) + } catch (e) { + /* storage blocked — the discount is still applied */ + } + location.reload() + return + } + } + log('AUTO_INSERT_STOP', { + result: 'none', + via: 'discount-link', + tried: linkCodes.map(c => c.code), + t: performance.now(), + }) + // None of the codes moved the total — hand them over to copy instead + // of also grinding the (deaf) DOM form. + showFinalModal(0, null, null, false, coupons) + return + } + // Before pretending to "try" codes, confirm the promo box is actually // reachable on this page. If the config's selectors don't match (stale // config, or the box lives on a later checkout step), say so honestly and From d78475d8400590763f0bb7c26c86593992fa02bb Mon Sep 17 00:00:00 2001 From: Alaeddin <15094821+BSalaeddin@users.noreply.github.com> Date: Fri, 3 Jul 2026 05:28:04 +0100 Subject: [PATCH 063/198] =?UTF-8?q?feat(ext):=20honest-UX=20polish=20?= =?UTF-8?q?=E2=80=94=20remember=20tried=20codes=20across=20reload=20applie?= =?UTF-8?q?s,=20surface=20store=20reasons=20and=20min-spend=20hints,=20vis?= =?UTF-8?q?ibility-gate=20the=20prompt,=20non-USD=20savings=20formatting?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/caramel-extension/UI-helpers.js | 6 +- apps/caramel-extension/shared-utils.js | 129 +++++++++++++++++++++++-- 2 files changed, 124 insertions(+), 11 deletions(-) diff --git a/apps/caramel-extension/UI-helpers.js b/apps/caramel-extension/UI-helpers.js index d7f8d74..2dfbd58 100644 --- a/apps/caramel-extension/UI-helpers.js +++ b/apps/caramel-extension/UI-helpers.js @@ -241,7 +241,11 @@ async function showFinalModal( if (savedMoney) { finalMessage = `We found a coupon that saves you $${savingsAmount.toFixed(2)}!` } else if (appliedCode) { - finalMessage = `Code ${esc(code)} is applied to your cart — review the discount before you check out.` + // Prefer an explicit caller message (threshold hints, non-USD + // formatting) over the generic applied line. + finalMessage = + message || + `Code ${esc(code)} is applied to your cart — review the discount before you check out.` } else if (hasManual) { // Prefer an explicit caller message (e.g. "couldn't find the promo box") // so we tell the user the REAL reason instead of a generic "didn't stick". diff --git a/apps/caramel-extension/shared-utils.js b/apps/caramel-extension/shared-utils.js index c8d47c8..c9f7a58 100644 --- a/apps/caramel-extension/shared-utils.js +++ b/apps/caramel-extension/shared-utils.js @@ -395,7 +395,15 @@ function _hostMatchesDomain(host, domain) { async function isCheckout() { const rec = await getDomainRecord(location.hostname) if (!rec) return false - if (qOne(rec.couponInput) || qOne(rec.showInput)) return true + // VISIBLE, not merely present: themes ship hidden coupon markup on + // non-checkout pages, and some configs point showInput at site-wide + // controls — the prompt belongs only where the user can actually see a + // way to enter a code. Same semantics as the re-detection observer. + const anyVisible = () => + [rec.couponInput, rec.showInput] + .filter(Boolean) + .some(sel => qAll(sel).some(_isVisible)) + if (anyVisible()) return true // Only wait on the selectors the config actually provides — a bare // `${null},${null}`/`,${x}` compound is a wasted 3s wait (or a thrown // selector that waitForElement just swallows). @@ -407,7 +415,7 @@ async function isCheckout() { log(e) } } - return !!(qOne(rec.couponInput) || qOne(rec.showInput)) + return anyVisible() } /* Coupon-availability cache — fetched once when a checkout is detected so we @@ -461,7 +469,24 @@ async function startCheckoutDetection() { sessionStorage.removeItem('caramel_applied') const st = JSON.parse(raw) if (st && st.code && Date.now() - (st.t || 0) < 120000) { - showFinalModal(st.saved || 0, st.code, null) + let amount = st.saved || 0 + let msg = null + if (st.currency && st.currency !== 'USD' && amount > 0) { + // The built-in savings line renders "$X" — mislabels + // non-USD stores. Present the correctly-formatted amount + // through the applied-code presentation instead. + try { + const fmt = new Intl.NumberFormat(undefined, { + style: 'currency', + currency: st.currency, + }).format(amount) + msg = `Code ${st.code} saved you ${fmt} — it's applied to your order.` + amount = 0 + } catch (e) { + /* unknown currency code — fall back to $ */ + } + } + showFinalModal(amount, st.code, msg) return } } @@ -929,6 +954,36 @@ async function probeCartJson() { } return null } +// Per-origin short-term memory of codes we already tried, so a navigation-type +// apply (full-page POST → reload, Magento-class) doesn't make the next run +// re-grind the same rejected codes from #1 — one wasted reload per code. +// sessionStorage is per-origin and per-tab and dies with the tab; entries also +// expire on their own after 15 minutes so fresh runs eventually retry. +const CARAMEL_TRIED_KEY = 'caramel_tried_codes' +const CARAMEL_TRIED_TTL = 15 * 60 * 1000 +function _getTriedCodes() { + try { + const m = JSON.parse(sessionStorage.getItem(CARAMEL_TRIED_KEY) || '{}') + const now = Date.now() + for (const k of Object.keys(m)) { + if (now - m[k] > CARAMEL_TRIED_TTL) delete m[k] + } + return m + } catch (e) { + return {} + } +} +function _markTriedCode(code) { + // Marked at attempt START, not at verdict — a full-page-POST apply can + // destroy this script before the verdict lands. + try { + const m = _getTriedCodes() + m[code] = Date.now() + sessionStorage.setItem(CARAMEL_TRIED_KEY, JSON.stringify(m)) + } catch (e) { + /* storage unavailable — worst case a reload retries codes */ + } +} async function applyViaDiscountLink(code) { // The discount endpoint 302s to the storefront; we only need the session // cookie it sets, then re-read the live totals to see if the code took. @@ -1123,6 +1178,28 @@ async function startApplyingCoupons(rec) { return } + // Skip codes this tab already tried recently (navigation-type applies + // reload the page mid-loop; without this a re-run restarts from code #1). + const _tried = _getTriedCodes() + const _untried = coupons.filter(c => !(c.code in _tried)) + if (_untried.length < coupons.length) { + log('AUTO_INSERT_SKIP_TRIED', { + skipped: coupons.length - _untried.length, + }) + } + if (!_untried.length) { + log('AUTO_INSERT_STOP', { result: 'all-tried', t: performance.now() }) + showFinalModal( + 0, + null, + 'We already tried these codes on this page — copy one below to use manually, or check back later for fresh codes.', + false, + coupons, + ) + return + } + coupons = _untried + // Discount-link strategy first when the platform capability is present // (see probeCartJson). On these checkouts the DOM form is deaf to // synthetic events, while the link path is fast (~0.5s/code, no page @@ -1145,6 +1222,7 @@ async function startApplyingCoupons(rec) { } const { code } = linkCodes[i] await updateTestingModal(i + 1, linkCodes.length, code) + _markTriedCode(code) const after = await applyViaDiscountLink(code) if (after && after.total_price < _cart0.total_price) { const saved = (_cart0.total_price - after.total_price) / 100 @@ -1162,7 +1240,12 @@ async function startApplyingCoupons(rec) { try { sessionStorage.setItem( 'caramel_applied', - JSON.stringify({ code, saved, t: Date.now() }), + JSON.stringify({ + code, + saved, + currency: after.currency, + t: Date.now(), + }), ) } catch (e) { /* storage blocked — the discount is still applied */ @@ -1236,6 +1319,7 @@ async function startApplyingCoupons(rec) { : NaN let bestSave = 0 let bestCode = null + let lastStoreReason = null // last real error text the store showed us const triedCodes = [] // Pattern-based early-exit: if the checkout gives ZERO feedback (no applied // row, no error text) for the first couple of codes, it isn't accepting our @@ -1268,6 +1352,7 @@ async function startApplyingCoupons(rec) { triedCodes.push(code) await updateTestingModal(i + 1, coupons.length, code) + _markTriedCode(code) const res = await applyCoupon(code, rec) // Late-total safety net: some checkouts (erincondren-class) flash their @@ -1322,6 +1407,15 @@ async function startApplyingCoupons(rec) { committed: res.committed, errorMsg: res.errorMsg, }) + // Keep the store's own words (login-required, min-spend, expired…) so + // the final modal can say the REAL reason instead of a generic line. + if ( + res.errorMsg && + typeof res.errorMsg === 'string' && + !/timeout/i.test(res.errorMsg) + ) { + lastStoreReason = res.errorMsg + } if (res.committed) { await removeAppliedCoupon(rec) } else { @@ -1360,11 +1454,16 @@ async function startApplyingCoupons(rec) { tried: triedCodes, t: performance.now(), }) - const headline = - hasPriceCfg && bestSave > 0 - ? 'We found a coupon that saves you money!' - : `Applied ${bestCode}` - showFinalModal(bestSave, bestCode, headline) + // A committed code that didn't move a READABLE total is usually a + // threshold promo (min-spend) — say so instead of a bare "applied". + const zeroEffect = hasPriceCfg && !isNaN(original) && !(bestSave > 0) + showFinalModal( + bestSave, + bestCode, + zeroEffect + ? `Code ${bestCode} is on your cart but hasn't changed the total yet — it may need a minimum spend to kick in.` + : null, + ) } else { log('AUTO_INSERT_STOP', { result: 'none', @@ -1376,7 +1475,17 @@ async function startApplyingCoupons(rec) { // Nothing auto-applied. Hand the tried codes to the modal so the user // gets a manual copy/paste fallback (covers valid codes the store's // checkout rejected only because our synthetic click isn't trusted). - showFinalModal(0, null, null, false, coupons) + // When the store told us WHY (login required, min spend, expired…), + // repeat its own words — that's the honest, transparent version. + showFinalModal( + 0, + null, + lastStoreReason + ? `The store said: “${String(lastStoreReason).slice(0, 140)}” — copy a code below to try it manually.` + : null, + false, + coupons, + ) } } From 4afe88e3b47fa219dfc6df7746e94d76433421b2 Mon Sep 17 00:00:00 2001 From: Alaeddin <15094821+BSalaeddin@users.noreply.github.com> Date: Fri, 3 Jul 2026 06:02:56 +0100 Subject: [PATCH 064/198] fix(ext): count empty-to-text error transitions as attempt-caused so store reasons without classic vocabulary are not lost --- apps/caramel-extension/shared-utils.js | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/apps/caramel-extension/shared-utils.js b/apps/caramel-extension/shared-utils.js index c9f7a58..3d82347 100644 --- a/apps/caramel-extension/shared-utils.js +++ b/apps/caramel-extension/shared-utils.js @@ -653,10 +653,17 @@ function detectCouponError(rec, baseline, code) { if (code && tl.includes(code.toLowerCase())) return t // Strong signal: classic rejection vocabulary. if (ERROR_WORDS_RE.test(tl)) return t - // Otherwise — ambiguous status text. Treat as new error - // only if the container first appeared (was hidden, now - // shown) — never on text-changed-but-still-vague. - if (baseline && !baseline.visible && _isVisible(el)) { + // Otherwise — status text without classic vocabulary. Treat + // as OUR error when the container first appeared OR was + // EMPTY before this attempt (empty→text is attempt-caused — + // login-required / min-spend style messages). Pre-existing + // text that merely changed stays ambiguous (aria-live regions + // that rotate stale copy — the logos.com trap). + if ( + baseline && + (!baseline.visible || !baseline.text) && + _isVisible(el) + ) { return t } return null // generic / stale status copy → not our error From 9012be9871f4687ad6c352284d9d2f53b1861be2 Mon Sep 17 00:00:00 2001 From: Alaeddin <15094821+BSalaeddin@users.noreply.github.com> Date: Fri, 3 Jul 2026 16:33:05 +0100 Subject: [PATCH 065/198] =?UTF-8?q?fix(ext):=20UI=20polish=20=E2=80=94=20r?= =?UTF-8?q?emove=20black=20auto-focus=20ring=20(branded=20keyboard-only=20?= =?UTF-8?q?rings),=20harden=20injected=20buttons=20against=20host-page=20C?= =?UTF-8?q?SS=20bleed,=20fix=20broken=20popup=20loader=20image?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/caramel-extension/UI-helpers.js | 15 +++++-- apps/caramel-extension/caramel-content.css | 51 ++++++++++++++++++++++ apps/caramel-extension/index.html | 4 +- 3 files changed, 64 insertions(+), 6 deletions(-) diff --git a/apps/caramel-extension/UI-helpers.js b/apps/caramel-extension/UI-helpers.js index 2dfbd58..1343571 100644 --- a/apps/caramel-extension/UI-helpers.js +++ b/apps/caramel-extension/UI-helpers.js @@ -109,8 +109,7 @@ async function showTestingModal(title = '', noLoading = false) { _caramelCancelled = true hideTestingModal() }) - // Esc cancels (keyboard parity with the × button); focus the Stop button so - // keyboard users land inside the dialog. + // Esc cancels (keyboard parity with the × button). const onKey = e => { if (e.key === 'Escape') { _caramelCancelled = true @@ -119,8 +118,11 @@ async function showTestingModal(title = '', noLoading = false) { } overlay.__caramelOnKey = onKey document.addEventListener('keydown', onKey) + // Focus the dialog itself (a11y) — not the × button, which would flash a + // focus ring on open. try { - if (_close) _close.focus() + modal.tabIndex = -1 + modal.focus() } catch (e) { /* focus is best-effort */ } @@ -358,8 +360,13 @@ async function showFinalModal( } overlay.__caramelOnKey = onKey document.addEventListener('keydown', onKey) + // Move focus INTO the dialog (a11y) without lighting up a button's focus + // ring on open — focus the modal container, not the primary button. A + // keyboard user then Tabs to the button and gets the proper focus-visible + // ring; a mouse user sees a clean button. try { - if (okBtn) okBtn.focus() + modal.tabIndex = -1 + modal.focus() } catch (e) { /* focus is best-effort */ } diff --git a/apps/caramel-extension/caramel-content.css b/apps/caramel-extension/caramel-content.css index fab20e4..239a78e 100644 --- a/apps/caramel-extension/caramel-content.css +++ b/apps/caramel-extension/caramel-content.css @@ -358,6 +358,57 @@ background: #d65d1f; } +/* ---- Host-page style hardening + focus rings ---- + * Our controls live in the STORE's DOM, so the store's global `button {}` + * rules (fonts, uppercasing, borders, and — the usual culprit — a black + * :focus outline) bleed into them. ID/scoped selectors + !important beat even + * the host's own !important rules, so our UI looks the same everywhere. */ +#caramel-final-ok-btn, +#caramel-final-overlay .caramel-manual-copy, +#caramel-testing-close, +#caramel-close-btn { + font-family: + -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, + sans-serif !important; + font-style: normal !important; + text-transform: none !important; + letter-spacing: normal !important; + text-decoration: none !important; + border: 0 !important; + min-width: 0 !important; + box-sizing: border-box !important; + -webkit-appearance: none !important; + appearance: none !important; +} +/* No outline on pointer/programmatic focus (this is the black ring users saw + * when a modal auto-focuses its primary button on open)… The dialog + * CONTAINERS are focused programmatically for screen-reader announcement and + * must never show a visual ring around the whole card. */ +#caramel-small-prompt:focus, +#caramel-final-ok-btn:focus, +#caramel-final-overlay .caramel-manual-copy:focus, +#caramel-testing-close:focus, +#caramel-close-btn:focus, +#caramel-final-overlay .caramel-final-modal:focus, +#caramel-testing-modal:focus { + outline: none !important; +} +/* …but a clear, on-brand ring for real KEYBOARD focus (accessibility). */ +#caramel-final-ok-btn:focus-visible, +#caramel-final-overlay .caramel-manual-copy:focus-visible { + outline: 3px solid #9a3d0f !important; + outline-offset: 2px !important; +} +#caramel-testing-close:focus-visible, +#caramel-close-btn:focus-visible { + outline: 2px solid #ea6925 !important; + outline-offset: 2px !important; +} +#caramel-small-prompt:focus-visible { + outline: 3px solid #fff !important; + outline-offset: 2px !important; +} + @media (prefers-reduced-motion: reduce) { #caramel-small-prompt, #caramel-testing-modal, diff --git a/apps/caramel-extension/index.html b/apps/caramel-extension/index.html index 7de1509..503c088 100644 --- a/apps/caramel-extension/index.html +++ b/apps/caramel-extension/index.html @@ -28,9 +28,9 @@
- +
- loading + Loading…
From 046d945639a4006777b88bac8c4282fd25285ef5 Mon Sep 17 00:00:00 2001 From: Alaeddin <15094821+BSalaeddin@users.noreply.github.com> Date: Fri, 3 Jul 2026 17:05:13 +0100 Subject: [PATCH 066/198] =?UTF-8?q?fix(ext):=20popup=20=E2=80=94=20branded?= =?UTF-8?q?=20keyboard=20focus=20rings=20on=20all=20controls=20(no=20black?= =?UTF-8?q?=20UA=20outline)=20+=20contextual=20sign-in=20modal=20copy?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/caramel-extension/UI-helpers.js | 3 +++ apps/caramel-extension/assets/styles.css | 16 ++++++++++++++++ 2 files changed, 19 insertions(+) diff --git a/apps/caramel-extension/UI-helpers.js b/apps/caramel-extension/UI-helpers.js index 1343571..d291b38 100644 --- a/apps/caramel-extension/UI-helpers.js +++ b/apps/caramel-extension/UI-helpers.js @@ -254,6 +254,9 @@ async function showFinalModal( finalMessage = message || "Auto-apply didn't stick this time. Copy a code and paste it in the store's promo box." + } else if (isSignIn) { + finalMessage = + message || 'Sign in to unlock member-only coupons for this store.' } else { finalMessage = message || "Looks like you're already getting the best deal." diff --git a/apps/caramel-extension/assets/styles.css b/apps/caramel-extension/assets/styles.css index 39cd106..2279f47 100644 --- a/apps/caramel-extension/assets/styles.css +++ b/apps/caramel-extension/assets/styles.css @@ -942,3 +942,19 @@ body { scroll-behavior: auto !important; } } + +/* --- Consistent keyboard focus for ALL popup controls --- + * Buttons/links previously fell back to the browser's default (black) outline + * on Tab; give every interactive element the same branded orange ring the + * coupon cards use, and suppress the ring on pointer focus. */ +.popup-container a:focus-visible, +.popup-container button:focus-visible, +.login-form input:focus-visible { + outline: 2px solid #ea6925; + outline-offset: 2px; + border-radius: 8px; +} +.popup-container a:focus:not(:focus-visible), +.popup-container button:focus:not(:focus-visible) { + outline: none; +} From 537547b3081aa3a0ec817cdc5f6dac4f0d328dbb Mon Sep 17 00:00:00 2001 From: Alaeddin <15094821+BSalaeddin@users.noreply.github.com> Date: Fri, 3 Jul 2026 17:19:51 +0100 Subject: [PATCH 067/198] chore(ext): point unpacked/dev installs at dev.grabcaramel.com instead of localhost --- apps/caramel-extension/background.js | 4 +++- apps/caramel-extension/popup.js | 5 +++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/apps/caramel-extension/background.js b/apps/caramel-extension/background.js index 0f81476..d1a4dd7 100644 --- a/apps/caramel-extension/background.js +++ b/apps/caramel-extension/background.js @@ -15,8 +15,10 @@ const _isDevInstall = () => { return false } } +// Unpacked/dev installs hit the DEV deployment (dev.grabcaramel.com); the +// packed Web Store build (has update_url) hits production. globalThis.CARAMEL_BASE_URL = _isDevInstall() - ? 'http://localhost:58000' + ? 'https://dev.grabcaramel.com' : 'https://grabcaramel.com' const EXTENSION_API_KEY = 'WXqEpm2uOV5jjJXPpnQFyZiNdaPVUrtd2LIrf4kc1JA' const caramelUrl = path => diff --git a/apps/caramel-extension/popup.js b/apps/caramel-extension/popup.js index d535178..98c17fc 100644 --- a/apps/caramel-extension/popup.js +++ b/apps/caramel-extension/popup.js @@ -2,10 +2,11 @@ // Dev/prod base URL via the shared _isDevInstall() (defined in shared-utils.js, // loaded before this script). Packed Web Store builds have a manifest -// update_url → prod; unpacked dev installs → localhost. No `management` perm. +// update_url → prod; unpacked dev installs → the DEV deployment. No +// `management` perm. const CARAMEL_BASE_URL = typeof _isDevInstall === 'function' && _isDevInstall() - ? 'http://localhost:58000' + ? 'https://dev.grabcaramel.com' : 'https://grabcaramel.com' const caramelUrl = path => new URL(path, `${CARAMEL_BASE_URL}/`).toString() From 71ed39ec05349a2475bf0983400248390d31cccf Mon Sep 17 00:00:00 2001 From: Alaeddin <15094821+BSalaeddin@users.noreply.github.com> Date: Sat, 11 Jul 2026 00:42:17 +0100 Subject: [PATCH 068/198] chore(audit): Stage 0-1 codebase-audit artifacts (16 findings, verified + adversarially reviewed) --- audit/AUDIT.md | 154 ++++++++++++++ audit/GOALS.md | 40 ++++ audit/STAGE2-ONLOAD-PROMPT.md | 45 ++++ audit/adversarial.json | 50 +++++ audit/deep-dive-1-ai-surface.md | 165 +++++++++++++++ audit/deep-dive-2-extension.md | 230 +++++++++++++++++++++ audit/deep-dive-3-api-layer.md | 259 +++++++++++++++++++++++ audit/deep-dive-4-lib-types.md | 129 ++++++++++++ audit/empirical-3am.md | 211 +++++++++++++++++++ audit/empirical-change-trace.md | 225 ++++++++++++++++++++ audit/empirical-navigation.md | 188 +++++++++++++++++ audit/empirical-onboarding.md | 59 ++++++ audit/exclusions.md | 13 ++ audit/findings.json | 353 ++++++++++++++++++++++++++++++++ audit/hotspots-a.json | 128 ++++++++++++ audit/hotspots-b.json | 122 +++++++++++ audit/premortem.md | 198 ++++++++++++++++++ audit/rules-checklist.md | 121 +++++++++++ audit/state.json | 131 ++++++++++++ audit/tooling-report.md | 335 ++++++++++++++++++++++++++++++ audit/triage.md | 28 +++ audit/verify-1.json | 32 +++ audit/verify-2.json | 32 +++ audit/verify-3.json | 32 +++ 24 files changed, 3280 insertions(+) create mode 100644 audit/AUDIT.md create mode 100644 audit/GOALS.md create mode 100644 audit/STAGE2-ONLOAD-PROMPT.md create mode 100644 audit/adversarial.json create mode 100644 audit/deep-dive-1-ai-surface.md create mode 100644 audit/deep-dive-2-extension.md create mode 100644 audit/deep-dive-3-api-layer.md create mode 100644 audit/deep-dive-4-lib-types.md create mode 100644 audit/empirical-3am.md create mode 100644 audit/empirical-change-trace.md create mode 100644 audit/empirical-navigation.md create mode 100644 audit/empirical-onboarding.md create mode 100644 audit/exclusions.md create mode 100644 audit/findings.json create mode 100644 audit/hotspots-a.json create mode 100644 audit/hotspots-b.json create mode 100644 audit/premortem.md create mode 100644 audit/rules-checklist.md create mode 100644 audit/state.json create mode 100644 audit/tooling-report.md create mode 100644 audit/triage.md create mode 100644 audit/verify-1.json create mode 100644 audit/verify-2.json create mode 100644 audit/verify-3.json diff --git a/audit/AUDIT.md b/audit/AUDIT.md new file mode 100644 index 0000000..583ccdc --- /dev/null +++ b/audit/AUDIT.md @@ -0,0 +1,154 @@ +# Caramel — Codebase Audit + +**Baseline:** `dev` @ `537547b3081aa3a0ec817cdc5f6dac4f0d328dbb` · **Rules:** shared-claude-rules v5 (2026-07-10) · **Date:** 2026-07-10 +**Target branch (this run):** `audit/dev-2026-07-10` — all fix PRs target it, never `dev`/`main`. +**Gate mode:** pre-authorized (user directed "do PR to that branch") → triage/plan gates collapse into PR review; every self-decision logged in `audit/state.json`. No agent merges. +**Method:** dual Haiku hotspot scans (A∩B) → 4 Sonnet deep dives → 4 empirical tests (change-trace, 3am, onboarding, name-only navigation) → Opus pre-mortem → Haiku verification gate (3×) → Opus adversarial review → Fable synthesis. Deterministic tooling run separately. 82 candidate findings → 16 root causes. + +--- + +## Executive summary + +Caramel is a small, competently-built coupon product with a dangerous gap between how solid its happy path looks and how blind it goes the moment anything fails. Its core data — the coupon catalog — lives in a second Postgres owned by an out-of-repo Python service, read through untyped raw SQL, and the one health endpoint never checks it (F-001); when it or anything else breaks, the extension reports the outage to users as the factual claim "this store has no coupons," and the server errors behind it are caught-and-returned so they never reach Sentry (F-002). A static API key shipped in the public extension gates a destructive mutation and exempts its holder from rate limiting (F-003). There is no test suite at all — `pnpm test` is green while running zero tests (F-004) — so nothing protects any change. Maintainability is dragged down by a 1536-line extension god-module that is also the single most-churned file in the repo (F-008), coupon-domain logic copy-pasted across six-plus sites that have already drifted (F-006), and no shared request pipeline so CORS/rate-limit/auth are re-implemented or silently skipped per route (F-007). The guardrails that exist don't guard: dead Pages-Router fossils are whitelisted past knip, neither branch has protection, and half the target CI stack is missing (F-009). Docs get a new engineer only a partial boot (wrong port, undocumented second DB, no migration step) (F-010), and there is no runbook, rollback, or error boundary anywhere (F-011). The good news: TypeScript is strict and green, CI secrets are all real (0 phantom), the security architecture is sound, and function-level naming is excellent (10/10 in navigation testing) — the bones are fine; the failure-handling, testing, and operability layers are what compound. **Overall: 4/10 — fragile; tribal knowledge and no tests; safe changes are possible but require care.** + +## Anchored scores (weighted to P1 maintainability + P2 operability) + +| Dimension | Score | Anchor / evidence | +| --------------------------- | ----- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| Architecture | 4 | Core data in an un-contracted external DB (F-001); no shared request pipeline (F-007); one-root-compose violated (F-016). App/extension split itself is sound. | +| Modularity | 4 | 1536-line god-module = #1 churn AND #1 LOC (F-008); no pipeline (F-007). Change-trace isolation: small 9/10, medium 3/10, **cross-cutting 1/10**. | +| Clarity / conventions | 4 | Name-only navigation: **behaviors 2/5 (40%)**, functions 10/10. File names imply modularity that's absent (F-008); 4 error styles, 5 envelope shapes, 3 missing-env strategies, 3 hand-copied CORS blocks (F-007). | +| Code health | 4 | Dead fossils whitelisted past knip (F-009); swallowed errors (F-002); ~23 `any`, several exported (F-013); stray/broken artifacts (F-015). tsc green, lint near-clean. | +| Testing | 2 | **No suite exists**; `pnpm test` false-green ×2 (F-004). Nothing protects any path. | +| Operability | 3 | Health check probes the wrong DB (F-001); errors invisible to Sentry (F-002); no runbook/rollback/error-boundary (F-011); no branch protection (F-009); no env validation (F-005). | +| Dependencies | 4 | 83 vulns (2 crit/45 high), 50/51 outdated, deprecated dep, floating ^-ranges, nested lockfile (F-014). 0 phantom CI secrets (good). | +| Docs | 3 | README has no getting-started; onboarding only partially boots; boilerplate READMEs; port drift (F-010). | +| Security (regressions only) | 4 | One concrete Critical regression: public key gates mutation + rate-limit bypass (F-003); XOR-named-"encryption" (appendix). Architecture itself accepted as solid, out of scope. | + +**Verification integrity:** 15/15 findings verified with **0 ABSENT** (deletion counter 0 — clean hallucination meter). Adversarial review attacked 8, **killed 0**; strengthened F-006 & F-008, corrected F-009 (struck a live file), narrowed F-002's telemetry claim. + +--- + +## Findings (Critical first) + +### F-001 · Critical · The coupon catalog is an externally-owned, health-check-blind, untyped-raw-SQL dependency + +The product's core data lives in a second Postgres (`caramel_coupons`) owned by an out-of-repo Python service, read via template-string SQL in `couponsDb.ts`; `health/db/route.ts` only runs `SELECT 1` against the auth DB and never touches the coupons DB (adversarial confirmed no second check exists). `COUPONS_DATABASE_URL` is undocumented. **Why:** a drift or outage in a DB this repo doesn't own zeroes out all coupons while every monitor stays green — the outage the 3am and pre-mortem passes both independently predicted. **Fix:** health-check the coupons DB distinctly; document the URL; add a typed/zod-validated read boundary + schema-drift check against the Python service. _(operability, M)_ + +### F-002 · Critical · Failures are shaped as success; caught-and-returned route errors bypass Sentry + +The extension collapses any non-2xx into `{coupons:[]}`/`{supported:[]}` with no error field (`background.js:193`); `decryptJsonData` returns raw ciphertext on decrypt failure (`:20-22`). Uncaught errors _do_ reach Sentry (`instrumentation.ts:12` wires `onRequestError`), but the pervasive `try/catch → return 500` style means real route errors never get there. **Why:** in a product whose pitch is honesty, an outage renders to users as "this store has no coupons," and the server errors behind it are invisible. **Standoff caveat (attach to DESIGN):** the `.catch(()=>({}))` body-parse swallows are largely downstream-validated (expire:27, sources:82), so they are a code-smell, not the Critical driver — the empty-success shaping and decrypt-return-raw are. **Fix:** one shared error boundary that shapes failures as errors and routes caught server errors to `Sentry.captureException`. _(error-handling, M)_ + +### F-003 · Critical · A public-shipped static key gates a destructive mutation and bypasses rate limiting + +`EXTENSION_API_KEY` gates the mutating `coupons/expire` route, is hardcoded in the public extension (`background.js:23`, sent on live calls), and is the rate-limit exemption in `rateLimit.ts:91`. **Why:** anyone who installs the extension can extract a value that both triggers a state change and removes its throttle — a concrete dangerous-default regression (reported concisely; not a re-architecture). **Fix:** the extension gets no privileged key; server-to-server mutations use a non-shipped secret; rate-limit exemption keys off an authenticated identity. _(security, S)_ + +### F-004 · High · No test suite — `pnpm test` is false-green + +Root `test` → `turbo run test`, but no package defines `test`; both runs exit 0 with "No tasks were executed." App has only Playwright `test:e2e` (needs undocumented DB+migrations), extension a custom node script. **Why:** every fix here and every future change claims safety from a suite that runs zero lines — the #1 forced roadmap item and the prerequisite for safely touching F-006/F-007/F-008. **Fix:** real vitest suite wired into turbo `test` + CI; `test` fails when zero tests run; characterization tests first. _(testing, L)_ + +### F-005 · High · No validated env contract; `.env.example` stale (19 vs 38) and no fail-fast + +38 distinct `process.env.*` reads; `.env.example` documents 19; ~11 undocumented (`COUPONS_DATABASE_URL`, `UPKUMA_HEALTH_SECRET`, …). Three missing-env strategies; `DATABASE_URL` port drifts 2345 vs 58005 across three files. **Why:** boot succeeds on a broken env and fails deep in a request; a docs-only engineer can't assemble one (proven in onboarding). **Fix:** one zod-validated env module imported at boot; regenerate `.env.example` from it; fail fast. _(operability, M)_ + +### F-006 · High · Coupon domain logic/constants have no single home (understated duplication) + +The 7-status visibility predicate appears verbatim in ≥6 sites (coupons/route:48, sites/top-sites:13, sites/search-supported:24, coupons/stores:18 & :25, +marketing) and has **already drifted** into three definitions; the status→label map is re-declared in both app and extension; ranking `ORDER BY` duplicated. jscpd: 65 clones / 5.55% (also .tsx marketing/auth components). **Why:** the prime P1 dedup finding — a cold agent fixes one of six sites and ships a silent inconsistency. **Fix:** one coupon-domain module + a shared constants surface the extension consumes so app/extension can't drift. _(duplication, M)_ + +### F-007 · High · No shared request pipeline; cross-cutting concerns re-implemented or silently skipped + +No `middleware.ts`; CORS hand-inlined 3× in one file (two copies bypass the local helper); rate-limit applied to some handlers and absent from others (oauth/authorize does crypto+secrets with no throttle); oauth/redirect hand-mints sessions via raw Prisma, bypassing better-auth. The wrappers that could centralize this are F-009's dead fossils. **Why:** no one way to gate a route → coverage gaps are invisible and permanent; the auth surface has two divergent implementations. **Fix:** one composable route wrapper (CORS+rate-limit+body-parse+auth); route oauth/redirect through better-auth. _(modularity, M)_ + +### F-008 · High · `shared-utils.js` is a 1536-line god-module (repo #1 churn AND #1 LOC); neighbour names mislead + +Adversarial census: ≥8 unrelated responsibilities (DOM waits, price parsing, XPath utils, coupon-apply loop, modal UI, telemetry, messaging, storage). Highest churn (4529) and highest LOC in the repo. Navigation test predicted behaviors would live in `cart-signals.js`/`inject.js` — they're all in this monolith; the file names lie. **Why:** the most-edited file is the least context-holdable, and its neighbours misdirect. **Fix:** split along real responsibilities into honestly-named modules — **characterization tests (F-004) first**. Breaking for the extension; mind store-review lag. _(modularity, L)_ + +### F-009 · High · CI guardrails are blind and unenforced + +Dead Pages-Router fossils (`cors.ts`, `initMiddleware.ts`, `middlewares/**` = withAuth/withRoles, `securityHelpers/apiResponse.ts`) with zero live callers are whitelisted in `knip.json`'s ignore list so the gate stays green while blind; neither `main` nor `dev` has branch protection (CI advisory only); vs the v5 baseline, **oxlint and size-limit are absent** and husky pre-commit lacks knip + prisma-drift. **Standoff caveat:** adversarial struck `apiResponseNext.ts` from the fossil set — it's live (used in `sources/route.ts`). **Why:** a passing gate lies about code health, a cold agent treats dead `withAuth` as the login helper, and nothing is enforced at merge. **Fix:** delete the fossils + `cors` dep and remove from knip ignore; enable branch protection; add oxlint + size-limit; extend husky. _(code_health, M)_ + +### F-010 · High · Onboarding docs are wrong/missing — docs-only boot fails + +README has no getting-started and never links `LOCAL-DEV.md`; no doc says to create `apps/caramel-app/.env` or lists its secrets; `dev` generates the Prisma client but never applies migrations; the second DB is undocumented; app README is create-next-app boilerplate. **Why:** every new session is a freelancer's first day and the docs actively mislead (wrong port, missing DB/migration). Stage-4 codify target. **Fix:** rewrite README + LOCAL-DEV to the real path; delete boilerplate; validate with a fresh-agent onboarding trace. _(docs, M)_ + +### F-011 · High · Operability safety nets absent (runbook, deploy/rollback/smoke, error boundary, trace propagation) + +No runbook/on-call/dashboard doc in 405 files; a `prisma migrate deploy` step exists but no app-deploy/rollback/smoke; no App-Router `error.tsx`; Sentry set to auto-create Vercel Cron Monitors on a Dokploy deploy (dead config); no trace ID propagated to the Python coupons service or the OpenRouter hop. **Why:** the 3am on-call has nowhere to look and nothing to roll back to; with F-001/F-002 a real outage is undiagnosable. **Fix:** RUNBOOK.md, App-Router error boundary, fix the Sentry cron config, propagate a trace ID across hops. _(operability, M)_ + +### F-012 · High · The LLM cart-classifier has no eval suite and no CI gate + +The only user-facing LLM surface (`cartClassifier.ts` → `/api/classify-cart`, default `openai/gpt-5-mini`, env-swappable) has zero evals/scorers/CI/scoreboard; output validated by assertion, not schema. **Why:** per v5 §AI-quality it fails every bullet — a provider degrading the model or an env-pin drift is undetectable, and F-002 swallows the runtime failure too. **Fix:** install via `/ai-evals` (production prompt imported, deterministic scorers, PR+nightly+dispatch CI, eval-gated model changes). _(ai-quality, M)_ + +### F-013 · Medium · `any`-typed exported surfaces poison callers (~23 instances) + +Live exported surfaces carry `any`: `decryptJsonData(resData:any):any`, `encryptJsonServer(payload:any)`, `apiResponseNext(metadata?:any,viewport?:any)`, 6 React-Select callbacks. **Why:** `any` on an exported boundary is contagious — callers lose safety exactly where a light model needs the type; tsc passes only because these hide mismatches. **Fix:** type exported surfaces first, then interior; lint against `any` on exports. _(typing, M)_ + +### F-014 · Medium · Dependency health degraded + +Tool-sourced: 83 vulns (2 crit/45 high), 50/51 outdated (15 majors). Verified: `react-awesome-button` npm-deprecated; caret ranges throughout (v5 bans floating deps); nested `apps/caramel-app/pnpm-lock.yaml` duplicates the root. **Fix:** patch crit/high vulns, replace the deprecated dep, pin versions, single lockfile, CI audit gate. _(dependencies, M)_ + +### F-015 · Medium · Broken/stray artifacts ship and mislead + +Firefox manifest lists `amazon.js` as a content script but the file doesn't exist (broken build shipped to Firefox); `verify_test_small3.txt` (committed Python error), a tracked `nul` file, app package misnamed `caramel-landing`, `.prettierrc.json` uses invalid key `tailwindConfigPath` (tailwind formatting silently never runs) — all 5 verbatim-verified. **Fix:** fix/remove the amazon.js ref, delete strays, rename the package, fix the prettier key; add root-file allowlist + manifest-integrity check. _(code_health, S)_ + +### F-016 · High · one-root-compose / dev-runs-prod-build rule violated (promoted P1) + +Compose runs Postgres+Redis only ("Backing services only; apps run outside Docker"); `pnpm dev` runs framework hot-reload, not `docker compose up --build`; prod deploys via Nixpacks, not the compose. The exact opposite of v5 §Structure. **Why:** the rule calls this a P1 "never silently punch-listed" — local/CI/prod diverge and an AI agent sees behavior prod never runs. caramel is NOT in the PROD-GATE exception list. **Fix:** route via `/one-root-compose` (dedicated migration playbook) — large, breaking, separate initiative; sequence after the safety nets. _(architecture, L)_ + +> _Deviation note: this is the 16th finding, one over the 15-cap. Promoted deliberately from a rules-checklist GAP row because §Structure forbids appendixing a one-root-compose violation._ + +--- + +## Empirical results + +- **Change-trace (coupling, measured):** expiry-badge **9/10** (1 file), lifetime-savings **3/10** (7 files; no user↔coupon-activity persistence exists; the only auth helper is dead), per-store success-rate **1/10** (9 in-repo files + a mandatory out-of-repo Python DB; ranking SQL already duplicated; a same-named `successRate` already means something else). Cross-cutting change is effectively unshippable without touching another repo. +- **3am incident:** 14 blind spots. A total coupons-DB outage is invisible three ways at once — health check green (wrong DB), users see "no coupons," Sentry silent. No runbook/rollback exists. +- **Onboarding:** app boots **partially** from docs (undocumented migration + second DB + port mismatch 58300 vs 58000); `pnpm test` is a false-green no-op; the one-line change _did_ pass the husky gates. 5 tribal-knowledge steps. +- **Name-only navigation:** behaviors **2/5** placed correctly (the two misses land in the `shared-utils.js` monolith behind modular-looking names); functions **10/10** — function naming is a genuine strength, file/module naming is not. +- **Pre-mortem:** 13 causal findings (3 Critical) converging on the same root as 3am — the external coupon DB read through untyped SQL with no health check and errors that never reach Sentry. + +## Performance (P3 — order-of-magnitude only; none crowd P1–2) + +No order-of-magnitude perf defects surfaced. Minor notes to appendix: the classify-cart 8KB cap reads only client `Content-Length` (DD1-5); `background.js` is the extension's single fetch chokepoint (DD2-8/CT-8). No action recommended this cycle. + +--- + +## Roadmap (leverage-ordered; lead with the unlocking changes) + +**Wave 1 — unlock everything else (do first):** + +1. **F-004** test baseline _(L)_ — prerequisite for safely touching F-006/F-007/F-008. Nothing is safe until this exists. +2. **F-005** env contract + **F-009** guardrail stack _(M, M)_ — cheap, and they enforce every later rule; F-005 also unblocks reliable local/CI runs. _(F-009 deletes the dead wrappers F-007 will replace.)_ + +**Wave 2 — the operability spine:** 3. **F-002** error convention + **F-001** coupons-DB health/typed boundary _(M, M)_ — pair them; **F-011** runbook documents what they create. 4. **F-003** security regression _(S)_ — small, independent, do early. ⚠ breaking: rotates a key shipped in the extension — coordinate an extension release. + +**Wave 3 — maintainability structure:** 5. **F-006** dedup coupon domain _(M)_. 6. **F-007** request pipeline _(M)_ — depends on F-009 (fossils gone), pairs with F-002. 7. **F-008** god-module split _(L)_ — depends on F-004. ⚠ breaking: extension refactor, mind store-review lag. + +**Wave 4 — codify & cleanup (mechanical, parallelizable):** 8. **F-013** any-surfaces, **F-014** deps, **F-015** artifacts _(M/M/S)_. 9. **F-012** evals via `/ai-evals` _(M)_ — independent track. 10. **F-010** docs _(M)_ — LAST before Stage-4 codify, so docs describe the new reality. + +**Separate initiative (own track):** 11. **F-016** one-root-compose via `/one-root-compose` _(L, breaking, prod-cutover)_ — do not fold into the above PRs. + +**Inter-item dependencies:** F-008→F-004 · F-007→F-009 · F-006 benefits from F-004 · F-010 after all structural changes · F-011 after F-001/F-002. +**Breaking-change flags:** F-003 (key rotation + ext release), F-007 (route behavior), F-008 (ext refactor + store lag), F-016 (deploy pipeline), F-001 (contract layer). + +## What's good — preserve + +- **Strict TypeScript, green tsc** (0 errors) — keep it strict; F-013 closes the `any` holes without loosening. +- **Security architecture is sound** (accepted, not re-audited); **0 phantom CI secrets** (all 16 refs real — better than most audited repos). +- **Excellent function-level naming** (10/10 navigation) — the convention to keep; extend it to files/modules (F-008). +- **Sentry is wired** (`onRequestError`), Prisma migrations exist, husky pre-commit runs (lint-staged + type-check) — the foundation is there; F-002/F-009 make it actually catch things. +- **App/extension separation** and the read-only coupons-DB discipline (by comment) are the right instincts — F-001 just makes them explicit and monitored. + +--- + +## Appendices + +**A. Overflow findings (ranked, deferred to appendix)** — see `findings.json.appendix_overflow`: XOR-named-"encryption" (DD3-8), classifier dropped fields (DD1-3), Content-Length cap bypass (DD1-5), popup raw-enum UI leaks (DD1-8/9/10), domainRecord:null always (DD2-8), partial reinjection guards (DD2-9), build glob misses lockfile (DD2-12), extension eslint no preset (DD2-13), MV3 missing permission (DD2-14), constant-time-compare theater (DD3-2), 5 envelope shapes (DD3-5), 4 store-list endpoints (DD3-15), duplicate crypto helpers (DD4-3), 3 subfolder schemes (DD4-5), stale /increment comment (CT-5), successRate name collision (CT-7), free-text logging (AM-14), zero TODO markers (AIH-2). + +**B. Unverified / parked:** none — all 15 elevated findings verified (F-016 promoted from the rules-checklist with direct compose+package.json evidence). + +**C. Deletion count (hallucination meter): 0.** No candidate was deleted for an absent quote across the full pipeline. + +**D. Rules-checklist:** `audit/rules-checklist.md` — 4 PASS / 29 VIOLATION / 5 N/A vs v5; 10 GAP rows, all now folded (STR-2→F-016, CIB-1/2/3+PIECE-1/7/8→F-009, ERR-2→F-011, AIH-1/2→appendix). + +**E. Machine-readable:** `audit/findings.json` (for diffing future audits). diff --git a/audit/GOALS.md b/audit/GOALS.md new file mode 100644 index 0000000..624b991 --- /dev/null +++ b/audit/GOALS.md @@ -0,0 +1,40 @@ +# Audit GOALS — caramel + +**Doctrine:** canonical rules = `~/.claude/skills/codebase-audit/references/shared-claude-rules.md` — **v5, 2026-07-10**. Grade against the FILE, not this doc or any paraphrase. At every stage onload, re-read it; if its version is newer than v5, re-sync `audit/rules-checklist.md` before proceeding. + +## Mission + +Full codebase-audit pipeline (AUDIT → PLAN → FIX → CODIFY) on the caramel monorepo. Priorities, strict: (1) maintainability (modularity · dedup · clarity · conventions), (2) operability, (3) performance (order-of-magnitude only). Security arch accepted as solid — concrete quote-backed regressions only. + +## Branch policy (user-directed, 2026-07-10) + +- Baseline: `dev` @ `537547b3081aa3a0ec817cdc5f6dac4f0d328dbb`. +- **Target branch: `audit/dev-2026-07-10`** (cut from dev @ 537547b, pushed to origin). Treat it as "the dev" for this run — ALL fix PRs target it, never the real `dev` or `main`. +- Fix work happens on `audit/fixes-2026-07-10` (branched from the target), one PR per fix (or batched per Fable's cross-review) into `audit/dev-2026-07-10`. +- Never merge anything to `dev` or `main`. Never merge PRs at all — the human reviews and merges. + +## Gate mode (recorded decision) + +User args: "make a branch out of dev and consider it the dev and then work do pr to that branch basically just follow the audit but consider the new dev branch ur target" — read as pre-authorization to run the pipeline through to PRs against the isolated target branch. Therefore: ⛔ triage and ⛔ plan-approval gates collapse into PR review. Every self-triage and self-approval decision gets recorded with rationale in `audit/state.json`. The Stage-1 report still presents the full triage table + recommendations to the human; the human launching the Stage-2/3 Opus session is the go signal. + +## Model routing + +Fable (CTO) = Stage 0–1 orchestration, synthesis, triage recommendations, handoff docs, final end-review + Stage-4 CLAUDE.md authorship. Fable never edits repo code. Opus lead session = Stages 2–3 (planning, fixes, deviation rulings, E2E validation via Stealth/Chrome DevTools). Sonnet = deep dives, fix execution, doc drafts. Haiku = scans, quote checks, navigation tests. + +## Repo facts (Stage 0) + +- pnpm@9 workspace + turborepo; apps: `apps/caramel-app` (Next.js 16.1.1, React 19, Prisma 6, better-auth, Playwright+Argos E2E; package misnamed `caramel-landing`) and `apps/caramel-extension` (plain-JS MV extension, rsync build, web-ext dev). +- **No unit-test suite**: `turbo run test` matches zero package scripts. App has `test:e2e` (Playwright), extension has `test:e2e` (custom node script). Forced roadmap consideration: establish a real test baseline. +- Husky pre-commit: `pnpm lint-staged` + `pnpm -r run type-check`. +- CI: `.github/workflows/` (5 files) — inventory in Stage 0 tooling report. +- Two pnpm lockfiles (root + `apps/caramel-app/pnpm-lock.yaml`) — scanner lens. + +## Artifacts + +- `audit/state.json` — pipeline state, decisions log (append, never rewrite history). +- `audit/exclusions.md` — scanner exclusion list. +- `audit/rules-checklist.md` — one row per v5 rule bullet + CI-stack piece (pass / violation→F-ID / n-a). +- `audit/hotspots-{a,b}.json` — dual Haiku scans. `audit/deep-dive-*.md` — Sonnet dives. +- `audit/empirical-*.md` — change-trace, 3am, onboarding, name-only-navigation. +- `audit/premortem.md`, `audit/findings.json`, `audit/AUDIT.md` (report), `audit/triage.md`. +- `audit/plans/PLAN-F-*.md` — Stage 2. `audit/STAGE-ONLOAD-PROMPT.md` — session handoffs. diff --git a/audit/STAGE2-ONLOAD-PROMPT.md b/audit/STAGE2-ONLOAD-PROMPT.md new file mode 100644 index 0000000..016cf59 --- /dev/null +++ b/audit/STAGE2-ONLOAD-PROMPT.md @@ -0,0 +1,45 @@ +# STAGE 2+3 ONLOAD — caramel audit (for a fresh Opus lead session) + +**Doctrine:** canonical rules = `~/.claude/skills/codebase-audit/references/shared-claude-rules.md` — **v5, 2026-07-10**. Grade against the FILE, not this doc or any paraphrase. Re-read it at onload; if newer than v5, re-sync `audit/rules-checklist.md` before planning. + +You are the **Opus lead** for Stages 2 (Plan) and 3 (Fix) of the caramel codebase audit. Stage 0–1 (audit + synthesis + triage) is DONE by the Fable/CTO session. **Do not re-audit or re-plan the findings** — they are verified and adversarially reviewed. Execute. + +## Read first (on disk, in order) + +1. `audit/GOALS.md` — mission, branch policy, model routing. +2. `audit/AUDIT.md` — the report: 16 findings, anchored scores, **the leverage-ordered roadmap (Waves 1–4 + F-016 separate track)**, breaking-change flags. +3. `audit/findings.json` — machine-readable findings F-001…F-016 with verbatim quotes, standoff caveats, corroboration. +4. `audit/triage.md` — the fix/defer/reject recommendations + PR-granularity decision. +5. `audit/rules-checklist.md` — v5 grading (what's enforced vs missing). +6. `audit/state.json` — pipeline state + decision log (append your judgment calls here; never rewrite history). + +## Branch & PR rules (user-directed — non-negotiable) + +- **Target branch = `audit/dev-2026-07-10`** (already cut from `dev`@537547b and pushed). Treat it as "the dev" for this run. ALL PRs target it. **Never** PR or merge to real `dev` or `main`. **Never merge any PR** — the human reviews and merges. +- Fix work on `audit/fixes-2026-07-10` cut FROM the target. Serial execution in the roadmap's wave order; each fix builds on the previous fix's final commit. **One commit per finding**, subject references the F-ID (e.g. `fix(F-005): zod-validated env module + regenerated .env.example`). +- Group commits into PR(s) by wave for coherent review (or one PR per finding — either is fine; the F-ID-per-commit trace is the requirement). Link the finding ID + AUDIT.md section in each PR body. + +## How to run it (Fable presence budget: you own the middle) + +- You are the lead: spawn **Opus** subagents for planning (one plan per finding, batch trivially related), **Sonnet** for fix execution and doc drafts, **Haiku** for the after-fix empirical re-runs and quote checks. Rule on minor deviations yourself; log them in `state.json`. Structural deviations → re-plan; scope changes → escalate to the human. E2E validation (Stealth MCP / Chrome DevTools) is done by Opus subagents, not by burning the lead loop in a browser. +- **Stage 2 (Plan):** per finding — scope (exact files + explicit out-of-scope), approach + rejected alternatives, sequencing (independently verifiable steps), breaking changes (who consumes it, told?), **test strategy — pinning/characterization tests written BEFORE the change**, rollback + checkpoint commits, risk. Write each to `audit/plans/PLAN-F-0XX.md`. Cross-review for overlapping files (F-002/F-007 both touch route error paths; F-006/F-007 both touch routes; F-009 deletes fossils F-007 depends on — sequence F-009 before F-007). +- **Stage 3 (Fix):** pinning tests first; full suite green at every checkpoint (note: F-004 CREATES the suite — until it lands, "green" means the new tests you add for the file you touch). No drive-by refactors; mid-fix discoveries become NEW findings for the next cycle, not extra edits. After each fix, a fresh Haiku re-runs the relevant empirical test (change-trace / 3am / onboarding / navigation) — improvement measured, not claimed. + +## Hard sequencing (from the roadmap — respect it) + +- **Wave 1 first:** F-004 (test infra) → F-005 + F-009. F-004 is the prerequisite for F-006/F-007/F-008. +- **F-009 before F-007** (F-009 deletes the dead withAuth/cors fossils that F-007 replaces). +- **F-008 only after F-004** (characterization tests first — it's the #1-churn file, 1536 lines). +- **F-010 (docs) LAST** before Stage 4, so docs describe the changed reality. +- **F-016 (one-root-compose) is a SEPARATE initiative** via the `/one-root-compose` skill — large, breaking, prod-cutover. Do NOT fold it into the fix-branch train. caramel is NOT in the PROD-GATE exception list, but still: no prod moves without explicit human go. + +## Critical caveats to honor (from adversarial review) + +- **F-002:** `onRequestError` IS wired (`instrumentation.ts:12`) — the gap is the `try/catch → return 500` paths, not "no Sentry ever." The `.catch(()=>({}))` body-parse swallows are mostly downstream-validated; the real Critical is empty-success shaping + decrypt-return-raw. Don't "fix" what isn't broken. +- **F-009:** `apiResponseNext.ts` is LIVE (used in `sources/route.ts`) — do NOT delete it. The fossil set is the ~5 knip-whitelisted NextApi\* files (cors.ts, initMiddleware.ts, middlewares/\*\*, securityHelpers/apiResponse.ts). +- **F-003:** rotating `EXTENSION_API_KEY` breaks in-the-wild extension installs — coordinate with an extension release; the server must tolerate old clients during the window (store-review lag). +- **Green-suite rule:** there is NO existing suite (F-004). Never claim a fix is safe from a suite that doesn't cover it — write the pinning test first. + +## When you finish + +Write per-item plan-vs-actual + deviations + commit SHAs, before/after empirical numbers, updated `findings.json`, any new findings, and next-audit focus to `audit/`. Then STOP. The user returns to the Fable/CTO session and asks "is it all good?" — Fable reviews the artifacts + diffs and rules pass/redo, then authors the Stage-4 `CLAUDE.md`. diff --git a/audit/adversarial.json b/audit/adversarial.json new file mode 100644 index 0000000..91acb02 --- /dev/null +++ b/audit/adversarial.json @@ -0,0 +1,50 @@ +[ + { + "id": "F-001", + "ruling": "survives", + "new_severity": "Critical", + "rebuttal": "Attack failed on every vector. (1) Second health check: none exists — Glob on api/health/** and Grep for health|monitor|uptime return only health/db/route.ts, which does prisma.$queryRaw`SELECT 1` (route.ts:10) and never touches couponsSql. External Uptime-Kuma monitoring IS wired (health.ts:32 reads UPKUMA_HEALTH_SECRET) but is pointed only at the Prisma DB, which reinforces the finding. (2) 'Untyped' does not mean 'unsafe' — the postgres.js tagged templates ARE parameterized (couponsDb.ts is fine re: injection), but the finding's claim is about the missing TYPE CONTRACT, and that is worse than stated: the hand-written CouponRow type (couponsDb.ts:32-47) is not applied at any query boundary AND is already out of sync — coupons/route.ts:96 selects status and verification_message, neither of which exists on CouponRow. That is live drift, not hypothetical. Un-contracted confirmed." + }, + { + "id": "F-002", + "ruling": "survives", + "new_severity": "Critical", + "rebuttal": "Core intact but two over-broad claims must be narrowed. Empty-success shaping is real (background.js:193 `if (!r.ok) return { coupons: [] }` with no error field, unlike the .catch at :202) and the decrypt swallow is real (decryptJsonData.ts:20-22 `catch { return resData }`). COUNTER TO ATTACH: the finding's 'never reach telemetry' is overstated — instrumentation.ts:12 exports `onRequestError = Sentry.captureRequestError`, so UNCAUGHT errors DO reach Sentry; only the deliberate try/catch-return-500 paths bypass it, so the claim should read 'caught-and-returned route errors bypass Sentry' (no captureConsoleIntegration exists to save them — grep empty). Also the .catch(()=>({})) body-parse sites are validated downstream (expire/route.ts:27 rejects non-arrays -> count:0; sources/route.ts:82 rejects empty website), so that sub-claim is largely defanged. The empty-success + honesty-product framing survives at Critical; the telemetry wording needs tightening." + }, + { + "id": "F-003", + "ruling": "survives", + "new_severity": "Critical", + "rebuttal": "Unkillable. The key is in tracked source (background.js:23) and sent on live calls (background.js:208, plus test-extension.mjs:32/114/143); expire/route.ts:16 gates on the same process.env.EXTENSION_API_KEY; rateLimit.ts:61,91 exempts the same key. The 'public-by-design identifier' defense is explicitly refuted by the code's own comment at expire/route.ts:9-11 which calls it 'Privileged' and says it 'permanently flips expired=TRUE'. The prod env value MUST equal the shipped 'WXq...' string or the extension's own supported-stores/rate-exempt calls would 401, so the shipped key necessarily unlocks the mutation. isOriginAllowed returns true for a missing Origin header (rateLimit.ts:165), so curl with the extracted key + no Origin passes all gates unthrottled." + }, + { + "id": "F-004", + "ruling": "survives", + "new_severity": "High", + "rebuttal": "Airtight. Grep for \"test\" scripts across every package.json returns only the root `\"test\": \"turbo run test\"`; no workspace package defines a `test` script (app has only test:e2e/test:e2e:ui playwright, extension has only test:e2e node script). turbo.json:15 declares an empty `\"test\": {}` task with no implementers, so `turbo run test` executes zero tasks and exits 0 — the false-green is confirmed exactly as described. Nothing to attack." + }, + { + "id": "F-006", + "ruling": "survives", + "new_severity": "High", + "rebuttal": "Duplication is REAL and the finding UNDERSTATES it: the full 7-status predicate appears verbatim in coupons/route.ts:48, sites/top-sites/route.ts:13, sites/search-supported/route.ts:24, coupons/stores/route.ts:18 AND :25, and (marketing)/coupons/[store]/page.tsx:47 (a local const literally named VISIBLE_STATUSES that nobody shares) — 6 copies across 5 files, not the 'copy-pasted 5x' claimed. HOWEVER the specific DRIFT examples are wrong and must be rewritten: stats/route.ts:15 omits `expired = FALSE` because it is COUNTING expired-vs-active (stats/route.ts:13 `COUNT(*) FILTER (WHERE expired = TRUE)`) — adding the filter would zero the count; filters/route.ts:20 uses `status='valid'` to build filter-option lists, a different question. Those two are semantically-required differences, not drift. Core survives; the 'three competing definitions / disagreeing about itself' narrative should be replaced with the genuine 6-copy verbatim duplication." + }, + { + "id": "F-007", + "ruling": "survives", + "new_severity": "High", + "rebuttal": "No live wrapper centralizes the cross-cutting concerns. middleware.ts is absent (Glob empty). The withAuth/withRoles/cors wrappers that could centralize this are DEAD (Grep shows zero live importers; they are Pages-Router NextApiRequest handlers), so they don't count. The CORS triplication is verified in a single file: the identical 3-line Access-Control block is hand-inlined at oauth/authorize/route.ts:42-44 (OPTIONS), :62-64 (getCorsHeaders), and :205-207 (catch) — the catch and OPTIONS don't even call the local helper. Coverage gap is real: oauth/authorize/route.ts never imports or calls checkRateLimit despite doing crypto + reading secrets. The shared helpers (checkRateLimit, isOriginAllowed) exist but are opt-in per-handler, which is exactly the invisible-gap problem the finding describes." + }, + { + "id": "F-008", + "ruling": "survives", + "new_severity": "High", + "rebuttal": "Not cohesive by any reading. A function census of shared-utils.js shows at least eight unrelated responsibilities in the 1536-line file: DOM visibility/wait helpers (:91-190), price parsing (:203 getPrice), CSS/XPath query utils (:238-257), store-cache + domain detection (:281-382), checkout detection (:395-462), the coupon error-detection + apply loop (:637-692, :994, :1149 startApplyingCoupons), cart-JSON probing (:947 probeCartJson), tried-codes persistence (:969-994), and coupon fetch + LLM cart classification (:1011-1092). The 'all one domain' defense fails — price parsing, telemetry timing, modal UX and the apply loop are independently testable concerns fused with no boundary. LOC=1536 verified against the file length." + }, + { + "id": "F-009", + "ruling": "survives", + "new_severity": "High", + "rebuttal": "Thesis holds but the finding contains a verifiable factual error that must be corrected. TRUE core: genuine dead Pages-Router fossils are knip-whitelisted (knip.json:18-21 ignores cors.ts, initMiddleware.ts, middlewares/**, securityHelpers/apiResponse.ts) and those files import NextApiRequest/NextApiResponse with no live callers (Grep for their imports returns only self/docs). ERROR TO FIX: apiResponseNext.ts is NOT dead — it is imported and used throughout a live App-Router route (sources/route.ts:1 and calls at :65,:68,:83,:110,:117 via nextApiResponse), it does NOT import Pages-Router types, and it is NOT in knip's ignore list. Strike apiResponseNext.ts from the dead-fossil set. Secondary: cryptoHelpers.decryptJsonClient is also live via decryptJsonData.ts:1, so 'zero live callers' is imprecise for that file too. The whitelisted-fossil + advisory-CI thesis survives on the remaining ~5 files; the file list needs tightening." + } +] diff --git a/audit/deep-dive-1-ai-surface.md b/audit/deep-dive-1-ai-surface.md new file mode 100644 index 0000000..bef6a4f --- /dev/null +++ b/audit/deep-dive-1-ai-surface.md @@ -0,0 +1,165 @@ +# DD-1 — AI surface deep dive (classify-cart / openrouter / extension consumers) + +Baseline: dev @ 537547b3081aa3a0ec817cdc5f6dac4f0d328dbb. Rules graded against shared-claude-rules.md **v5** §"AI quality (evals)" (read from the file). + +## Surface sketch + +The repo has exactly ONE LLM surface. Flow: + +1. Extension `shared-utils.js getCoupons()` — only when a returned coupon carries a `RESTRICTED_STATUSES` status — calls `classifyCartCategory()`. +2. `cart-signals.js collectCartSignals()` scrapes `{domain, url_path, title, meta_description, og_site_name, og_type, cart_items≤6, platform_hints}` from the shop page (Shopify `/cart.js` probe → DOM selectors → JSON-LD fallback). +3. `background.js` relays runtime message `'classifyCart'` → `POST {BASE}/api/classify-cart` (Content-Type only — no api key, 8s timeout). +4. `route.ts`: origin allow-list → `checkRateLimit(req,'mutation')` (30/min/IP; EXTENSION_API_KEY holders fully exempt) → client-declared content-length check (8KB) → `req.json().catch(()=>null)` → hand-rolled `sanitize()` → `classifyCart()`. +5. `cartClassifier.ts`: sha256 cache key (domain/title/meta/items; 2000-entry in-memory LRU, 24h TTL) → `buildMessages()` (system prompt + JSON of domain/title/meta/site_name/items) → `openrouter.chat()` (`OPENROUTER_MODEL || 'openai/gpt-5-mini'`, temp 0, max_tokens 120, json_object, 7s abort) → `parseResponse()` validates `primary` against `CATEGORY_ENUM`, silently coerces `secondary`/`confidence`. +6. `{primary, secondary?, confidence, cached}` → extension annotates restricted coupons with `cartCategory` → `popup.js` renders "your cart looks like **{token}**". + +`openrouter.chat()` has exactly one consumer (cartClassifier); `/api/classify-cart` has exactly one caller (extension background) — no app-side consumer. Naming is honest (`classify-cart`/`classifyCart`/`cartClassifier`/`chat`); Stage-0 name-only navigation logged `openrouter.chat` as a HIT — a light model lands here correctly. +Eval story: none. No `*.eval.*` files, no `evals/` dir, no scorers, no eval workflow (`.github/workflows` = checks-app.yml, checks-extension.yml, release-extension.yml), and the app package.json has no test script of any kind. `scripts/test-cart-signals.mjs` exercises only the DOM extractor, never the model. +Failure UX: any LLM error → 502/500 JSON → extension converts to a silent `null` (restricted coupons just lose their hint); Sentry receives nothing (the route catches before `onRequestError` can fire; `console.error` only). + +## FINDINGS + +```json +[ + { + "id": "DD1-1", + "location": "apps/caramel-app/src/lib/cartClassifier.ts:93-108 (prompt); apps/caramel-app/src/lib/openrouter.ts:2; apps/caramel-app/.env.example:24-25; .github/workflows/* (absence)", + "quote": "cartClassifier.ts:97-99: 'You classify e-commerce carts into a category so the app can show relevant coupons. ' +\n 'You get a compact JSON snapshot of the shop and cart. ' +\n `Return JSON ONLY, schema: {\"primary\":\"\",\"secondary\":\"\",\"confidence\":0..1}. ` +\nopenrouter.ts:2: const DEFAULT_MODEL = process.env.OPENROUTER_MODEL || 'openai/gpt-5-mini'\n.env.example:24-25: OPENROUTER_API_KEY=\nOPENROUTER_MODEL=openai/gpt-5-mini", + "what": "The repo's only user-facing LLM surface (cart classification feeding the popup's restriction warnings) has zero eval coverage: glob finds no *.eval.* files, no evals/ directory, no scorers; .github/workflows contains only checks-app.yml, checks-extension.yml, release-extension.yml (no ai-evals workflow, no nightly, no dispatch); no scoreboard exists; the app package.json has no test script at all. The production prompt and CATEGORY_ENUM live only in cartClassifier.ts, so there is no import-vs-copy question — there is nothing that consumes them. The model is pinned in two places — code default 'openai/gpt-5-mini' and the OPENROUTER_MODEL env override, pre-filled with the same value in .env.example — with no audit that they agree at deploy time.", + "why_it_matters": "Grades as a miss on every bullet of shared-claude-rules v5 §'AI quality (evals)': no fixed-dataset suite against the live model+prompts, no CI gate (PR path-filtered / nightly with auto-issue / manual dispatch), so a provider silently changing gpt-5-mini degrades classification with zero code change and nothing catches it; model swaps cannot be eval-gated (no suite to gate with); no regression proof; and a stale OPENROUTER_MODEL host pin would silently override the code default with nothing checking. Misclassification directly mislabels the 'your cart looks like X' warning users act on beside restricted coupons.", + "severity": "High", + "confidence": 0.95, + "verified": true, + "survived_adversarial_review": null, + "fix_direction": "Run /ai-evals: fixed dataset of real cart-signal payloads -> suite imports buildMessages/parseResponse/CATEGORY_ENUM from cartClassifier.ts (never copies) -> deterministic scorers (valid JSON, enum membership, expected primary per case, confidence bounds) -> ai-evals.yml path-filtered to classify-cart/cartClassifier/openrouter, plus nightly with auto-opened issue and manual dispatch; verify CI secret exists via gh secret list; add the OPENROUTER_MODEL-pin-vs-code-default audit to the model-swap checklist.", + "effort": "L", + "category": "ai-quality/missing-evals" + }, + { + "id": "DD1-2", + "location": "apps/caramel-app/src/app/api/classify-cart/route.ts:67,80-92; apps/caramel-app/src/instrumentation.ts:12; apps/caramel-extension/background.js:166-167; apps/caramel-extension/shared-utils.js:1066-1089", + "quote": "route.ts:80-82: } catch (error) {\n const status = error instanceof OpenRouterError ? 502 : 500\n console.error('[classify-cart] failed', error)\nroute.ts:67: const raw = await req.json().catch(() => null)\ninstrumentation.ts:12: export const onRequestError = Sentry.captureRequestError\nbackground.js:167: .then(async r => {\n if (!r.ok) return { error: `HTTP ${r.status}` }\nshared-utils.js:1072: if (result && result.primary && !result.error) {\nshared-utils.js:1089: return null", + "what": "Every failure path on this surface is shaped into a response and never into telemetry — one root flaw with instances on both sides. Server: the route catches all classification errors and responds 502/500 with only console.error; Sentry's onRequestError (the sole server error hook) fires only for errors that escape the handler, so a caught 'model broke' never creates a Sentry event; malformed JSON is swallowed to null (req.json().catch) and merged with shape-invalid input into a single unlogged 400 — parse failure and contract failure are indistinguishable and leave no server-side trace. Extension: background converts any non-OK status to {error:'HTTP n'} without logging; classifyCartCategory sees result.error, fails its `result.primary && !result.error` guard, and returns null with no log (its catch/log fires only on thrown exceptions; the runtime.sendMessage callback also never checks runtime.lastError). A server 502 therefore produces zero log lines end to end. Quota exhaustion (OpenRouter 429/402, carried in OpenRouterError.status) is collapsed into the same 502 as an outage.", + "why_it_matters": "v5 'Errors & visibility': 'No silent failures … Errors throw loudly with Sentry-usable context.' On-call cannot tell 'model broke' from 'extension broke' — neither side reports to anything queryable; the only artifact is a stdout line in the Dokploy container log. The user experience is a silently missing cart-category hint, so a total classifier outage (bad key, quota gone, provider down) can persist indefinitely with no signal anywhere.", + "severity": "High", + "confidence": 0.9, + "verified": true, + "survived_adversarial_review": null, + "fix_direction": "Route catch: Sentry.captureException(error, {tags:{surface:'classify-cart', provider_status:(error as OpenRouterError).status}}) before responding; log the JSON-parse branch and the sanitize-reject branch as distinct warnings. Extension: log non-OK responses in background's classifyCart handler and check runtime.lastError in classifyCartCategory. Keep 502 vs 500, and surface OpenRouterError.status (429/402 vs 5xx) as a tag so quota exhaustion is distinguishable.", + "effort": "M", + "category": "operability/error-visibility" + }, + { + "id": "DD1-3", + "location": "apps/caramel-app/src/lib/cartClassifier.ts:85-91 (drop point); apps/caramel-app/src/app/api/classify-cart/route.ts:32,36,38-50; apps/caramel-extension/cart-signals.js:148,154,156-170", + "quote": "cartClassifier.ts:85-91: const payload = {\n domain: s.domain,\n title: s.title || '',\n meta_description: s.meta_description || '',\n site_name: s.og_site_name || '',\n cart_items: (s.cart_items || []).slice(0, 6),\n }\nroute.ts:38-43: platform_hints:\n b.platform_hints && typeof b.platform_hints === 'object'\n ? (Object.fromEntries(\n (\n Object.entries(\n b.platform_hints as Record,\ncart-signals.js:156-157: platform_hints: {\n shopify: !!(", + "what": "Three fields ride the full wire contract and are dropped before the model ever sees them: url_path, og_type, and platform_hints are scraped by cart-signals.js (~15 lines of Shopify/Woo/BigCommerce/Magento detection), transmitted, and laundered by the route's most convoluted sanitize block (the 13-line platform_hints cast pyramid, route.ts:38-50) — yet buildMessages' payload contains only domain/title/meta_description/site_name/cart_items. The contract is ragged elsewhere too: og_site_name is in the prompt but excluded from cacheKey (cartClassifier.ts:50-55 keys only d/t/m/i, so carts differing only by site_name share a cache entry), and the extension self-caps meta_description at 300 / url_path at 120 while sanitize allows 400/200.", + "why_it_matters": "This is v5's 'no trial-and-error layering' defect in contract form: dead branches left from iteration. A next agent reading sanitize() or the platform-detection code will reasonably assume platform/url signals influence classification — they don't — and every future edit pays to maintain, cap, and sanitize fields that terminate in /dev/null. The heaviest type-gymnastics in the route exist solely to validate unused data.", + "severity": "Medium", + "confidence": 0.95, + "verified": true, + "survived_adversarial_review": null, + "fix_direction": "Per field, decide: feed it to the prompt (platform_hints is a genuinely useful classification signal; url_path distinguishes /cart from /product pages) or delete it end-to-end (collector, wire payload, sanitize, CartSignals type). Align remaining caps extension-side == route-side, and either add og_site_name to cacheKey or drop it from the prompt.", + "effort": "S", + "category": "maintainability/dead-contract" + }, + { + "id": "DD1-4", + "location": "apps/caramel-app/src/app/api/classify-cart/route.ts:15,26,43-49; apps/caramel-app/src/lib/cartClassifier.ts:119-123,125,132-133,142,166; apps/caramel-app/src/lib/openrouter.ts:64-66", + "quote": "route.ts:15: const b = body as Record\nroute.ts:43-49: b.platform_hints as Record,\n ).filter(([, v]) => typeof v === 'boolean') as [\n string,\n boolean,\n ][]\n ).slice(0, 10),\n ) as Record)\ncartClassifier.ts:119-123: const obj = parsed as {\n primary?: string\n secondary?: string | null\n confidence?: number\n }\ncartClassifier.ts:142: primary: primary as Category,\nopenrouter.ts:64: const json = (await res.json()) as {", + "what": "Both edges of the LLM boundary are typed by assertion instead of validation — one root flaw, a dozen-plus `as` casts across three files: sanitize() is ~40 lines of hand-rolled narrowing with 5 casts (route.ts:15,26,43,44-47,49 including the [string,boolean][] pyramid); parseResponse() casts the model's JSON to an anonymous inline shape then re-casts fields (parsed as {...}; CATEGORY_ENUM as readonly string[] twice at :125/:132; secondary as Category :133; primary as Category :142; plus (e as Error) :166); openrouter.ts casts the provider envelope (:64). There is no schema library at all — zod appears nowhere in the app's dependencies or imports — although v5 mandates zod-validated env and one shared producer/consumer contract.", + "why_it_matters": "v5 'Typing & contracts': 'Producer and consumer share one schema … drift fails statically or in CI, never only at runtime.' Today the extension payload, sanitize()'s narrowing, the CartSignals interface, and the model-output shape are four independently hand-maintained artifacts; drift (DD1-3 is the live proof) compiles clean and misbehaves only at runtime. Each cast is a spot a future agent can silently widen. One honest schema pair (CartSignalsSchema in, ClassificationSchema out) collapses sanitize(), parseResponse(), and the envelope cast into declarative code.", + "severity": "Medium", + "confidence": 0.9, + "verified": true, + "survived_adversarial_review": null, + "fix_direction": "Add zod: CartSignalsSchema (z.string().max caps replacing the str()/arr() helpers; z.record(z.boolean()) for platform_hints) and ClassificationSchema (primary: z.enum(CATEGORY_ENUM), confidence: z.number().min(0).max(1)). sanitize() becomes CartSignalsSchema.safeParse; parseResponse() becomes ClassificationSchema.parse; give the OpenRouter envelope a minimal schema. Derive all types via z.infer; the casts fall away. Same schemas become the eval suite's imports (DD1-1).", + "effort": "M", + "category": "typing/producer-consumer-drift" + }, + { + "id": "DD1-5", + "location": "apps/caramel-app/src/app/api/classify-cart/route.ts:10-11,59-67", + "quote": "route.ts:10-11: // Cap payload size to protect the route from noisy senders.\nconst MAX_BODY_BYTES = 8 * 1024\nroute.ts:59-67: const contentLength = Number(req.headers.get('content-length') || 0)\n if (contentLength > MAX_BODY_BYTES) {\n return NextResponse.json(\n { error: 'payload too large' },\n { status: 413 },\n )\n }\n\n const raw = await req.json().catch(() => null)", + "what": "The 8KB payload cap inspects only the client-declared Content-Length header: a chunked request (no header -> Number(null || 0) = 0) or an understated header passes the check, after which req.json() buffers and parses the entire actual body in memory — sanitize()'s per-field caps apply only after the full parse completes. The comment claims the constant 'protect[s] the route', which is not what the code enforces.", + "why_it_matters": "In scope under 'perf (OOM only)': a hostile sender can make the Node process buffer and JSON.parse arbitrarily large bodies per request; combined with DD1-6 (limiter exemption via a published constant) such requests can also be unthrottled. The misleading comment invites the next agent to rely on a guarantee that does not exist.", + "severity": "Medium", + "confidence": 0.85, + "verified": true, + "survived_adversarial_review": null, + "fix_direction": "Read the request stream with a byte counter and abort once MAX_BODY_BYTES is exceeded (then JSON.parse the bounded string); keep the header comparison only as a cheap early 413. Fix the comment to describe what is actually enforced.", + "effort": "S", + "category": "perf/resource-limits" + }, + { + "id": "DD1-6", + "location": "apps/caramel-app/src/lib/rateLimit.ts:56-63,91; apps/caramel-extension/background.js:23,160-165", + "quote": "rateLimit.ts:59-63: const key =\n req.headers.get('x-api-key') || req.headers.get('x-extension-api-key')\n const expected = process.env.EXTENSION_API_KEY\n return Boolean(key && expected && key === expected)\nrateLimit.ts:91: if (isExtensionClient(req)) return null\nbackground.js:23: const EXTENSION_API_KEY = 'WXqEpm2uOV5jjJXPpnQFyZiNdaPVUrtd2LIrf4kc1JA'\nbackground.js:161-164: fetchWithTimeout(caramelUrl('api/classify-cart'), {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify(message.signals || {}),", + "what": "checkRateLimit exempts any request bearing EXTENSION_API_KEY from ALL throttling ('if (isExtensionClient(req)) return null' — burst and minute limiters both skipped), and that key is a hardcoded constant shipped in the public extension source. classify-cart is the one route where every cache-missing request spends real money (an OpenRouter completion), yet its own legitimate caller does not even send the key — the background classifyCart fetch sends only Content-Type. On this route the exemption therefore grants unlimited, unmetered LLM spend exclusively to whoever lifts the published constant, and nothing to the product. Cache misses are attacker-controlled (any novel title/cart_items yields a fresh sha256 key), so each request is a fresh paid model call.", + "why_it_matters": "Cost control on the paid surface reduces to a public string: the 30/min/IP 'mutation' budget becomes infinite for anyone who reads the CRX source. This is the operability/cost angle, distinct from the key-exposure security finding the scanners already filed (hotspots-b DD 'Hardcoded EXTENSION_API_KEY') — even after a key rotation, an exempt-from-everything null return remains the wrong shape for a route with per-request provider cost.", + "severity": "High", + "confidence": 0.9, + "verified": true, + "survived_adversarial_review": null, + "fix_direction": "Smallest fix: remove the extension exemption for classify-cart (its caller never uses it) — call the limiter unconditionally there; better, replace the blanket null with a per-key rate limiter so trusted callers get a higher budget, never an infinite one. Key rotation/server-side move is the companion scanner finding.", + "effort": "S", + "category": "operability/cost-control" + }, + { + "id": "DD1-7", + "location": "apps/caramel-app/src/lib/cartClassifier.ts:110-146", + "quote": "cartClassifier.ts:113-117: } catch {\n const m = raw.match(/\\{[\\s\\S]*\\}/)\n if (!m) throw new Error('llm returned non-json')\n parsed = JSON.parse(m[0])\n }\ncartClassifier.ts:135-140: const confidence =\n typeof obj.confidence === 'number' &&\n obj.confidence >= 0 &&\n obj.confidence <= 1\n ? obj.confidence\n : 0.5", + "what": "parseResponse validates primary strictly (throws on unknown category — good) but silently coerces everything else: an invalid or duplicate secondary is dropped to undefined (:128-134), and a missing/out-of-range confidence is defaulted to 0.5 — fabricated data indistinguishable downstream from a real model score (the extension logs '(conf: 0.5)' as though the model said it). The regex rescue re-parses prose-wrapped output even though the request already sets response_format json_object — a 'try A, else B' second-guess layer that masks a misbehaving model instead of surfacing it, and at temperature 0 the masked failure repeats deterministically on every cache miss.", + "why_it_matters": "Silent coercions are how eval-worthy model regressions stay invisible (v5: real production AI failures should become eval cases — these paths destroy the evidence). If a model update starts emitting confidence as a string or wrapping JSON in prose, nothing logs and nothing fails; quality just sags. The rescue branch is the exact trial-and-error layering v5 bans unless justified and announced.", + "severity": "Low", + "confidence": 0.9, + "verified": true, + "survived_adversarial_review": null, + "fix_direction": "Validate the whole object with ClassificationSchema (DD1-4); on secondary/confidence violations and on regex-rescue activation, log a warning carrying the raw model output (feeds future eval cases). Keep the prose-rescue only if evals show a routed model ignoring response_format — and make it announce itself when it fires.", + "effort": "S", + "category": "ai-quality/silent-coercion" + }, + { + "id": "DD1-8", + "location": "apps/caramel-extension/popup.js:623-624; apps/caramel-app/src/lib/cartClassifier.ts:4-21", + "quote": "popup.js:623-624: const cartHint = c.cartCategory\n ? ` — your cart looks like ${escHtml(c.cartCategory)}${c.cartCategorySecondary ? ` / ${escHtml(c.cartCategorySecondary)}` : ''}`\ncartClassifier.ts:7-9: 'books_media',\n 'electronics',\n 'food_grocery',", + "what": "The popup renders the model's wire-format enum tokens as user-facing copy: a shopper literally sees 'your cart looks like books_media' or 'health_supplements / food_grocery', underscores and all. CATEGORY_ENUM doubles as machine contract and display string, with no label map on either side of the wire.", + "why_it_matters": "Internal identifiers surface in a trust-sensitive warning ('this code may not apply'), and because UI copy depends on raw enum spelling, renaming a category server-side silently changes end-user text — contract vocabulary and copywriting are welded together.", + "severity": "Low", + "confidence": 0.95, + "verified": true, + "survived_adversarial_review": null, + "fix_direction": "Add a CATEGORY_LABELS map beside the popup renderer ('books_media' -> 'Books & Media', etc.); render labels, keep tokens for logic and the wire.", + "effort": "S", + "category": "clarity/ux-contract-leak" + }, + { + "id": "DD1-9", + "location": "apps/caramel-extension/cart-signals.js:21-25", + "quote": "cart-signals.js:21-25: function baseDomain() {\n const parts = location.hostname.split('.').filter(Boolean)\n if (parts.length <= 2) return parts.join('.')\n return parts.slice(-2).join('.')\n }", + "what": "The domain the extension sends — the classifier's anchor signal and half of the server cache key — is computed as 'last two hostname labels', which collapses every multi-part-TLD store to its public suffix: www.amazon.co.uk -> 'co.uk', shop.example.com.au -> 'com.au'. The route's regex (route.ts:18, /^[a-z0-9.-]{3,120}$/) accepts these without complaint.", + "why_it_matters": "For entire country markets the prompt's strongest field is noise ('domain':'co.uk'), weakening classification exactly where title/cart-item extraction is also least reliable; unrelated ccTLD stores whose normalized title/items coincide can additionally collide on cache entries. Also degrades any future per-domain analysis of classify traffic.", + "severity": "Low", + "confidence": 0.9, + "verified": true, + "survived_adversarial_review": null, + "fix_direction": "Send location.hostname as-is (the LLM handles subdomains fine), or reuse the extension's existing store-domain matching used for coupon lookup — a PSL dependency is overkill.", + "effort": "S", + "category": "correctness/input-signal" + }, + { + "id": "DD1-10", + "location": "apps/caramel-app/src/lib/openrouter.ts:49-54; apps/caramel-extension/background.js:20-22", + "quote": "openrouter.ts:49-54: headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${key}`,\n 'HTTP-Referer': 'https://caramel.app',\n 'X-Title': 'Caramel Extension',\n },\nbackground.js:20-22: globalThis.CARAMEL_BASE_URL = _isDevInstall()\n ? 'https://dev.grabcaramel.com'\n : 'https://grabcaramel.com'", + "what": "The OpenRouter attribution headers are hardcoded and wrong: HTTP-Referer claims 'https://caramel.app' while the product lives at grabcaramel.com (per the extension's own base-URL switch and CI's NEXT_PUBLIC_BASE_URL), and X-Title says 'Caramel Extension' although the caller is the Next.js server, not the extension.", + "why_it_matters": "OpenRouter uses these headers for app attribution, dashboards, and abuse contact — a wrong domain misattributes the account's traffic (or credits a domain the team doesn't own) and makes provider-side incident correlation harder. Small, but exactly the kind of copied constant a light model will faithfully propagate into the next integration.", + "severity": "Low", + "confidence": 0.85, + "verified": true, + "survived_adversarial_review": null, + "fix_direction": "Derive HTTP-Referer from NEXT_PUBLIC_BASE_URL (already env-driven) and rename X-Title to describe the real caller (e.g. 'Caramel App — cart classifier').", + "effort": "S", + "category": "operability/config-hygiene" + } +] +``` diff --git a/audit/deep-dive-2-extension.md b/audit/deep-dive-2-extension.md new file mode 100644 index 0000000..4a321c3 --- /dev/null +++ b/audit/deep-dive-2-extension.md @@ -0,0 +1,230 @@ +# Deep Dive DD-2 — `apps/caramel-extension/**` (background/popup/shared-utils/manifests/tests) + +caramel @ `537547b3081aa3a0ec817cdc5f6dac4f0d328dbb` (dev). Read-only on tracked files. Priorities: (1) maintainability — modularity, dedup, clarity, conventions; (2) operability. Security arch accepted; no security findings below (none found that qualify as a regression). + +## Architecture sketch + +- **3 execution realms share global scope, zero module system.** `manifest.json` injects `cart-signals.js → shared-utils.js → UI-helpers.js → inject.js` (in that order) into every `https://*/*` page as one isolated-world realm; `index.html` (popup) _separately_ loads `shared-utils.js → UI-helpers.js → popup.js` as plain page scripts. The same 1537-line shared-utils.js runs in both, though the popup only ever calls `fetchCoupons`/`_isDevInstall`-adjacent helpers. +- **background.js** (MV3 service worker on Chrome, MV2 script on Firefox) is the only fetch-capable layer (CORS); content scripts and the popup relay every network call through `runtime.sendMessage`, each inventing its own ad-hoc response shape. +- **Auth bridge:** grabcaramel.com `window.postMessage`s a token into the content-script realm (origin-gated by `CARAMEL_ALLOWED_ORIGINS`), written to `chrome.storage.sync`; popup and content scripts each read that storage independently, with no shared accessor. +- **Apply flow:** `inject.js` → `startCheckoutDetection()` → `getDomainRecord()` (API-backed store config, locally cached) → `startApplyingCoupons()` (348-line loop with early-exit/discount-link/DOM-fallback branches) → per-code `applyCoupon()` (255-line DOM event-dispatch + signal-polling). +- **3 publish targets diverge:** Chrome (CI-built/zipped/uploaded) and Safari/iOS/macOS (CI xcodebuild) are automated in `release-extension.yml`; Firefox's manifest is not built, linted, or published by any CI job in this repo. + +## onMessage handler map (`background.js`) + +| Action | Location | `return true`? | `sendResponse` on every path? | Honesty / failure-mode notes | +| ------------------------------------------- | ----------------------- | ------------------ | ------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| _(malformed: missing/non-string `.action`)_ | `background.js:140` | No (bare `return`) | **No** | Not reachable by any caller in this repo today (every `sendMessage` call site sends a valid string `action`); if ever hit, the caller's `sendMessage` resolves `undefined` (Chrome does not hang the promise), not a hang. Defensive-only. | +| `openPopup` | `background.js:141-151` | No (sync) | Yes | Fire-and-forget `windows.create`; ack sent before the popup window exists. | +| `userLoggedInFromPopup_` | `background.js:152-157` | No (sync) | Yes | `tabs.sendMessage(parseInt(callerId), ...)` has no callback/`lastError` check — if the origin tab was closed, the error is silently dropped. | +| `keepAlive` | `background.js:158-159` | No (sync) | Yes | Trivial ack; unrelated to the real `keepAlive()` alarm mechanism (see DD2-14). | +| `classifyCart` | `background.js:160-176` | Yes | Yes (chain always settles, final `.catch`) | Honest: real errors surface as `{error}`. | +| `fetchCoupons` | `background.js:177-204` | Yes | Yes | **`!r.ok` silently returns `{coupons: []}`** — see DD2-1. Only a network-level throw gets `{coupons:[], error}`. | +| `fetchSupportedStores` | `background.js:205-220` | Yes | Yes | Same pattern: HTTP failure → `{supported: []}`, no error field. See DD2-1. | +| `getActiveTabDomainRecord` | `background.js:221-269` | Yes | Yes (all 3 sub-branches covered) | `domainRecord` is hardcoded `null` on **every** path — dead field, see DD2-8. | +| _(unknown action)_ | `background.js:270-273` | No (sync) | Yes | Honest `{error:'unknown_action'}`. | + +Refuting one nomination precisely: popup.js has **18** `await` expressions and **5** `catch` blocks (0 `.then(`), not "30+ vs 9" — but the raw ratio overstates the gap, since 4 of the 5 catches each wrap an entire multi-await flow (`initPopup`, `handleSocialSignIn`, the login-form submit handler), not one await apiece. The real residual issues are captured in DD2-6 (silent storage-error swallowing) and DD2-8 (no shared response envelope), not "uncaught awaits." + +## Findings + +```json +[ + { + "id": "DD2-1", + "location": "apps/caramel-extension/background.js:191-202", + "quote": "fetchWithTimeout(url.toString())\n .then(async r => {\n if (!r.ok) return { coupons: [] }\n const json = await r.json()\n return {\n coupons: Array.isArray(json)\n ? json\n : json.coupons || [],\n }\n })\n .then(resp => sendResponse(resp))\n .catch(err => sendResponse({ coupons: [], error: String(err) }))", + "what": "When the coupons API returns any non-2xx status (500, 502, 429, auth failure, etc.), fetchCoupons resolves `{coupons: []}` with no error field at all — identical to the honest 'this store genuinely has zero coupons' response. The same pattern repeats verbatim in fetchSupportedStores (background.js:205-220, `if (!r.ok) return { supported: [] }`). Only a network-level throw (DNS failure, timeout, CORS) reaches the `.catch` and gets an `error` key; an HTTP error response never does, because the early `return` inside the first `.then` intercepts it before the chain ever reaches `.catch`.", + "why_it_matters": "The popup and the in-page prompt both render 'No coupons for this site yet' / 'No codes for this store just yet' off this exact shape (popup.js renderUnsupportedSite, shared-utils.js startApplyingCoupons) — a backend outage or a bad deploy is presented to the user as a factual claim about store's coupon inventory. This is the extension's core value proposition (honest, non-dark-pattern coupon finding) lying by construction on the one code path most likely to fire during a real incident, and it's silent to the developer too — no error is logged anywhere for this branch.", + "severity": "Critical", + "confidence": 0.95, + "verified": false, + "survived_adversarial_review": null, + "fix_direction": "On `!r.ok`, return `{coupons: [], error: 'HTTP '+r.status}` / `{supported: [], error: ...}` (mirroring classifyCart's own pattern one branch away in the same file) and have callers render a distinct 'couldn't check right now' state instead of collapsing it into the empty-inventory copy.", + "effort": "S", + "category": "error-handling" + }, + { + "id": "DD2-2", + "location": "apps/caramel-extension/index.html:61-63", + "quote": "\n\n", + "what": "shared-utils.js (1537 lines) bundles at least six distinct responsibilities with no internal boundary: generic DOM waiters/selector-matching (qOne/qAll/waitForVisible/pickBestMatch, ~190 lines), store-config fetch+cache+domain-matching (getDomainRecord/_hostMatchesDomain, ~100 lines), checkout detection via MutationObserver (isCheckout/startCheckoutDetection, ~150 lines), the coupon-apply DOM automation engine (applyCoupon alone is 255 lines, startApplyingCoupons is 348 lines — 40% of the file in two functions), coupon-list fetch/cart-classification (fetchCoupons/classifyCartCategory/getCoupons), and a cross-realm auth-token bridge (the `window.addEventListener('message', ...)` + onMessage listener at the file's bottom). manifest.json injects the whole file into every web page as a content script; index.html (quoted above) additionally loads the entire same file into the popup, which per its own source (popup.js) only ever calls fetchCoupons and reads `_isDevInstall`/`caramelUrl`-adjacent globals — none of the checkout-detection, DOM-apply, or MutationObserver code is ever invoked in that context, yet it all parses and holds memory there.", + "why_it_matters": "Violates 'every module understandable in isolation': a reader opening shared-utils.js to change one thing (e.g. price parsing) must first mentally filter out five unrelated subsystems. It also means the popup — the simplest, most latency-sensitive surface in the extension — pays the parse/memory cost of the entire cross-site DOM-automation engine it will never execute, and any bug introduced anywhere in that engine is one edit away from being loaded into a context that has no way to exercise or catch it.", + "severity": "High", + "confidence": 0.9, + "verified": false, + "survived_adversarial_review": null, + "fix_direction": "Split shared-utils.js along the six seams above into separate files (e.g. dom-utils.js, store-config.js, checkout-detect.js, coupon-apply.js, coupon-fetch.js, auth-bridge.js); give the popup its own minimal manifest/script list that only pulls in store-config.js + coupon-fetch.js.", + "effort": "L", + "category": "modularity" + }, + { + "id": "DD2-3", + "location": "apps/caramel-extension/shared-utils.js:1154-1160", + "quote": "log('AUTO_INSERT_STOP', { result: 'no-domain-record' })\n showFinalModal(0, null, \"We don't have codes for this store yet.\")\n return\n }\n log('AUTO_INSERT_START', { domain: rec.domain, t: performance.now() })\n _caramelCancelled = false\n await showTestingModal()", + "what": "shared-utils.js calls showFinalModal, showTestingModal, updateTestingModal, and hideTestingModal throughout (e.g. lines 1155, 1160, 1231, 1309) — none of them declared, imported, or even JSDoc-annotated (`/* global */`) anywhere in the file; they are defined in the sibling file UI-helpers.js and reachable only because manifest.json's content_scripts array happens to load both into the same realm. The coupling runs both directions: UI-helpers.js's showTestingModal sets `_caramelCancelled = true` on its close button (UI-helpers.js:109) and Escape handler (:115), a variable declared and read only in shared-utils.js (:1146, :1159, :1223, :1350). Contrast cart-signals.js, the one file in the set that namespaces itself correctly (`window.CaramelCartSignals = {...}`, cart-signals.js:183-188) and needs no such implicit contract. popup.js at least partially documents its cross-file globals (`/* global currentBrowser, fetchCoupons */`, popup.js:1); shared-utils.js and UI-helpers.js have no such annotation at all.", + "why_it_matters": "A reader (human or model) opening shared-utils.js or UI-helpers.js in isolation hits calls/writes to identifiers that don't exist in that file, with no comment pointing at the sibling file that defines them — the only way to resolve them is to already know to grep the whole extension folder. This is the concrete, repeated instance of 'every module understandable in isolation' failing: the dependency is real and load-bearing (a manifest reorder or a lone-file dynamic injection would silently break it) but is expressed nowhere except the accident of script-tag order.", + "severity": "High", + "confidence": 0.88, + "verified": false, + "survived_adversarial_review": null, + "fix_direction": "Adopt cart-signals.js's own pattern consistently: namespace each file's public surface under one window object (`window.CaramelUI`, `window.CaramelCore`) and reference through that, or at minimum add `/* global */` annotations everywhere the pattern in popup.js already exists so the dependency is at least documented.", + "effort": "M", + "category": "encapsulation" + }, + { + "id": "DD2-4", + "location": "apps/caramel-extension/scripts/test-extension.mjs:29,79-84", + "quote": "const API_BASE = 'http://localhost:58000'\n...\n const baseUrl = await sw.evaluate(() => globalThis.CARAMEL_BASE_URL)\n log(\n 'dev-mode URL switch',\n baseUrl === API_BASE,\n `CARAMEL_BASE_URL=${baseUrl}`,\n )", + "what": "HEAD's own last commit (537547b, 'point unpacked/dev installs at dev.grabcaramel.com instead of localhost') changed background.js and popup.js so an unpacked/dev install's base URL is now `https://dev.grabcaramel.com`, not `http://localhost:58000` — but this test script's step 2 assertion, and the entire step-7 popup-login flow that reuses API_BASE as the server it expects the popup to call, were never updated and still hardcode the old localhost value. The suite is not a false claim about mocking — it genuinely exercises the real service worker, real storage, and a real applyCoupon() DOM run — but `checks-extension.yml` (the only CI workflow touching this directory) runs only `pnpm run lint` and `pnpm run prettier-check`; `test:e2e` is invoked nowhere in CI, so this breakage has no automated tripwire. Separately, the one DOM-injection scenario it does run (step 8) only exercises applyCoupon()'s single-code happy path against a synthetic vanilla input+button+price DOM — startApplyingCoupons's multi-code loop, early-exit heuristics, discount-link strategy, accordion-reveal, and error-message detection are never exercised by any script in the repo.", + "why_it_matters": "The suite's own header claims it verifies 'Dev detection WITHOUT the management permission... base URL is correct' as one of 7 numbered guarantees; that guarantee is currently false on this exact commit, and nothing signals that to anyone — a developer would have to run the script manually (with a local dev server, per its own prerequisites comment) to discover it. For a codebase whose test file is the closest thing to a regression net for the DOM-automation engine, an already-stale, CI-unwired suite means the net was torn the same day it mattered.", + "severity": "High", + "confidence": 0.95, + "verified": false, + "survived_adversarial_review": null, + "fix_direction": "Update API_BASE (or derive it from the same source background.js/popup.js use, closing DD2-7 at the same time); add a `test:e2e` job to checks-extension.yml (even nightly, given it needs a live dev server) so drift like this fails loudly; extend step 8 to cover at least one error-path and one discount-link scenario.", + "effort": "M", + "category": "testability" + }, + { + "id": "DD2-5", + "location": "apps/caramel-extension/manifest-firefox.json:42-51", + "quote": "\"content_scripts\": [\n {\n \"matches\": [\n \"https://www.amazon.com/*\",\n \"https://*.ebay.com/*\",\n \"https://*.codecademy.com/*\"\n ],\n \"js\": [\"shared-utils.js\", \"UI-helpers.js\", \"inject.js\", \"amazon.js\"]\n }\n ],", + "what": "manifest-firefox.json (version 1.0.5) references `amazon.js`, a file that does not exist anywhere in this repository (confirmed by exhaustive filename search); it omits `cart-signals.js` (present in manifest.json's list) and the `caramel-content.css` stylesheet; its host_permissions/content_scripts matches are hardcoded to exactly 3 domains (amazon.com, *.ebay.com, *.codecademy.com) versus manifest.json's `https://*/*`; its permissions list carries `management` and `alarms` (manifest.json has neither `management` — confirmed unused anywhere in the codebase except in comments describing the OLD implementation — nor, separately, `alarms`, see DD2-14) but omits `identity` (present in manifest.json, required for the Google/Apple OAuth flow popup.js implements). manifest.json is at version 1.1.0, package.json at 1.0.2 — three files describing 'the same product' carry three different version numbers. release-extension.yml (the only release pipeline in this repo) has jobs for Chrome (publish_chrome) and Safari/iOS/macOS (publish_safari) only; there is no Firefox job, so nothing in this repo builds, lints, or publishes manifest-firefox.json, and nothing would catch it drifting further.", + "why_it_matters": "The marketing site (coupons-section.tsx) advertises a live, supported Firefox add-on link. If this manifest is what's actually submitted (nothing in the repo suggests an out-of-band transform), Firefox users get a fundamentally different, far narrower product than Chrome users (3 hardcoded stores vs. the 5,000+ dynamically-fetched ones), an OAuth sign-in path that's missing its required permission, and a content-script file list that references a file that isn't there — the kind of drift that matches this task's stated context exactly (store review lag means old/broken builds live for weeks) except here the staleness is store-side from day one, not a version-skew problem.", + "severity": "High", + "confidence": 0.8, + "verified": false, + "survived_adversarial_review": null, + "fix_direction": "Either wire an automated Firefox build (regenerate manifest-firefox.json's content_scripts/host_permissions from manifest.json at build time, add a CI lint/diff job) or remove amazon.js and cart-signals.js/CSS gaps by hand and bring host_permissions/permissions/version into parity now; add a CI check that fails when the two manifests' content_scripts file lists diverge from what's actually on disk.", + "effort": "M", + "category": "release-management" + }, + { + "id": "DD2-6", + "location": "apps/caramel-extension/shared-utils.js:45-51,67-69", + "quote": "if (typeof log === 'undefined') {\n var log = _isDevInstall()\n ? (...a) => console.log('Caramel:', ...a)\n : () => {}\n}\nif (typeof recordTiming === 'undefined') {\n var recordTiming = (event, meta = {}) => {\n try {\n ...\n } catch (e) {\n // ignore storage errors\n }\n }\n}", + "what": "Across shared-utils.js's 26 catch blocks, only ONE (applyCoupon's outer catch, line 921: `console.error('applyCoupon error', err)`) survives in a packed production build. The other 25 either route through `log()` — a guaranteed no-op in every non-dev install per the definition quoted above (7 catches: lines 347, 414, 723, 730, 1042, 1086, 1165) — or do nothing but a comment/silent fallback (18 catches, including the recordTiming one quoted above, 'ignore storage errors'). recordTiming itself writes every timing event to `chrome.storage.local` under 'caramel_timings', but nothing else in the codebase ever reads that key — it is write-only, dead instrumentation. A repo-wide grep for Sentry/analytics/captureException/reportError inside apps/caramel-extension returns zero hits (contrast apps/caramel-app, which has sentry.*.config.ts files).", + "why_it_matters": "When the extension breaks on a real user's machine — a store changed their DOM, a selector went stale, storage quota was hit — there is no mechanism by which support or engineering ever finds out: 96% of caught errors in the file responsible for the core apply flow produce literally no trace anywhere, dev-only console logs don't exist in production, and the one telemetry mechanism that does exist (recordTiming) is never transmitted or read. 'Site changed their DOM' and 'our bug' are indistinguishable from the outside because nothing is outside — everything stays on the user's own machine, unread.", + "severity": "High", + "confidence": 0.9, + "verified": false, + "survived_adversarial_review": null, + "fix_direction": "Add a minimal remote error-reporting hook (even a low-volume beacon to the existing backend) for the handful of catches that matter most (applyCoupon failures, getDomainRecord API failures); at minimum, upgrade recordTiming's silent local write into something periodically flushed and surfaced, or delete it if it will never be read.", + "effort": "M", + "category": "observability" + }, + { + "id": "DD2-7", + "location": "apps/caramel-extension/background.js:20-22 vs apps/caramel-extension/popup.js:7-10", + "quote": "// background.js\nglobalThis.CARAMEL_BASE_URL = _isDevInstall()\n ? 'https://dev.grabcaramel.com'\n : 'https://grabcaramel.com'\n\n// popup.js (separate, independently-maintained copy of the same ternary)\nconst CARAMEL_BASE_URL =\n typeof _isDevInstall === 'function' && _isDevInstall()\n ? 'https://dev.grabcaramel.com'\n : 'https://grabcaramel.com'", + "what": "The dev/prod base-URL ternary (and its accompanying `caramelUrl(path)` helper) is independently declared in both background.js and popup.js rather than computed once and read from one place. This is proven, not hypothetical: HEAD's own last commit had to touch both files (`background.js | 4 +++-`, `popup.js | 5 +++-`) to change one URL string, and even then missed a third copy of an adjacent constant — `EXTENSION_API_KEY = 'WXqEpm2uOV5jjJXPpnQFyZiNdaPVUrtd2LIrf4kc1JA'` is duplicated verbatim between background.js:23 and scripts/test-extension.mjs:32, so a future key rotation has a third silent place to miss (surfacing as unexplained 401s in the already CI-unwired test script, see DD2-4).", + "why_it_matters": "Direct evidence of 'each behavior in exactly one place' being violated: identical business logic (which environment am I in, what's my base URL) lives in N independently-edited copies, so every future change to it is a find-all-copies exercise with no compiler/linter to catch a missed spot — exactly what happened this week.", + "severity": "Medium", + "confidence": 0.9, + "verified": false, + "survived_adversarial_review": null, + "fix_direction": "Compute CARAMEL_BASE_URL once (e.g. in shared-utils.js, which both background.js and popup.js already load after) and have background.js/popup.js read it from there; move EXTENSION_API_KEY to the same shared location and import it into the test script instead of copy-pasting.", + "effort": "S", + "category": "duplication" + }, + { + "id": "DD2-8", + "location": "apps/caramel-extension/background.js:226,242-245,258", + "quote": "if (!tabs || !tabs.length) {\n sendResponse({ domainRecord: null, url: null })\n return\n}\n...\n sendResponse({ domainRecord: null, url: url.hostname })\n...\n sendResponse({ domainRecord: null, url: hostname })", + "what": "getActiveTabDomainRecord's response always carries `domainRecord: null` — on all three branches, unconditionally. popup.js's only consumer (getActiveTabDomainRecord in popup.js:124-133) destructures `resp?.url` and never reads `domainRecord` at all. More broadly, none of the ~8 message actions in background.js share a response envelope: `{success}`, `{coupons}`, `{supported}`, `{domainRecord,url}`, `{error}`, `{status}` are each invented ad hoc per-handler with no shared type/schema anywhere in the codebase (plain JS, no JSDoc typedefs, no zod/io-ts equivalent).", + "why_it_matters": "A dead field that both sides carry but neither populates nor reads is a small but real tax on every future reader trying to understand what the background/content-script contract actually is — and it's a symptom of the larger gap: with no shared schema, every new message action is free to invent its own shape, and nothing catches a caller reading a field the handler never sends (as very nearly happened here in reverse).", + "severity": "Medium", + "confidence": 0.85, + "verified": false, + "survived_adversarial_review": null, + "fix_direction": "Remove the dead `domainRecord` field (or implement it if a future feature needs it); define one small shared response-shape convention (e.g. always `{ok: boolean, data, error}`) that every handler in background.js follows.", + "effort": "S", + "category": "messaging-protocol" + }, + { + "id": "DD2-9", + "location": "apps/caramel-extension/background.js:71-76 vs shared-utils.js:1054-1059", + "quote": "// background.js comment:\n// NOTE: Do not use `execScript` to inject full content-script bundles\n// that are declared in `manifest.json` (e.g. `shared-utils.js`).\n// Injecting the same bundle twice into the same isolated world can\n// cause redeclaration errors for top-level `const`/`let`/`class`.\n\n// shared-utils.js — an UNGUARDED top-level const, one of ~7:\nconst RESTRICTED_STATUSES = new Set([\n 'product_restriction',\n ...", + "what": "shared-utils.js guards exactly 6 identifiers against reinjection (sleep, log, recordTiming, currentBrowser, _caramelCancelled, _caramelCodes via `if (typeof x === 'undefined')`), but at least 7 other top-level `const` bindings are not guarded: CARAMEL_ALLOWED_ORIGINS (:77), STORE_CACHE_KEY/STORE_CACHE_PROD_TTL/STORE_CACHE_DEV_TTL (:281-283), GENERIC_APPLIED_SELECTORS/GENERIC_REMOVE_SELECTORS/GENERIC_ERROR_TEXT_RE (:544-553), ERROR_WORDS_RE (:634), CARAMEL_TRIED_KEY/CARAMEL_TRIED_TTL (:969-970), and RESTRICTED_STATUSES (:1054, quoted above) — any of these would throw `SyntaxError: Identifier '...' has already been declared` if the bundle were ever injected twice into one isolated world, exactly the failure mode background.js's own comment warns about. Mitigating factor found: `execScript` (background.js:50-69, the only dynamic-injection path in the codebase) has zero callers anywhere in the extension — the hazard is currently latent, not actively triggered.", + "why_it_matters": "The guard pattern exists specifically because this failure mode was anticipated (the comment is explicit and detailed), but it was applied to under half of the identifiers that need it — a partial fix that looks complete at a glance but isn't, which is worse than no guard plus no comment, since it invites false confidence.", + "severity": "Medium", + "confidence": 0.85, + "verified": false, + "survived_adversarial_review": null, + "fix_direction": "Either wrap the remaining ~7 top-level consts in the same `if (typeof x === 'undefined')` pattern, or (cheaper, given execScript is dead) delete execScript/hasTabsExecute/waitForTabComplete/sendMessageToTab (background.js:46-111, ~60 lines, zero callers anywhere) and soften the comment to reflect that manifest-declared content scripts are never re-injected in normal operation today.", + "effort": "S", + "category": "defensive-coding" + }, + { + "id": "DD2-10", + "location": "apps/caramel-extension/popup.js:59,194,335-341", + "quote": "currentBrowser.storage.sync.get(['token', 'user'], async res => { ... })\n...\ncurrentBrowser.storage.sync.remove(['token', 'user'], () => renderUnsupportedSite(null))\n...\nawait new Promise((resolve, reject) => {\n currentBrowser.storage.sync.set({ token, user }, () => {\n if (chrome.runtime.lastError) {\n reject(new Error(chrome.runtime.lastError.message))\n return\n }\n resolve()\n })\n})", + "what": "Three distinct styles for the same `chrome.storage` API coexist in popup.js alone: bare fire-and-forget callback with no error check (6 of 7 call sites: lines 59, 194, 335's sibling at 522, 561, 704, plus shared-utils.js's own storage.local.get/set in getDomainRecord and recordTiming use a fourth style — promise-wrapped, no lastError check), and exactly one promise-wrapped call WITH an explicit `chrome.runtime.lastError` check (popup.js:335-341, inside handleSocialSignIn only). That one correct instance also reaches for the bare global `chrome.runtime.lastError` directly instead of going through the `currentBrowser` abstraction every other line in the file uses to stay Chrome/Firefox-portable.", + "why_it_matters": "storage.sync has real, low failure-tolerance limits (quota, sync conflicts) that this codebase already knows to check for in exactly one place — the other 6+ call sites assume success unconditionally, so a failed token save/remove leaves the popup silently out of sync with reality (e.g. 'logged out' UI shown while storage still holds a stale token, or vice versa) with no error surfaced anywhere.", + "severity": "Medium", + "confidence": 0.8, + "verified": false, + "survived_adversarial_review": null, + "fix_direction": "Wrap all chrome.storage.* calls through one small promise-returning helper that always checks runtime.lastError (via currentBrowser, not the bare chrome global) and reuse it everywhere instead of re-deriving the pattern per call site.", + "effort": "M", + "category": "storage-api" + }, + { + "id": "DD2-11", + "location": "apps/caramel-extension/shared-utils.js:1054-1059 vs apps/caramel-extension/popup.js:604-609", + "quote": "// shared-utils.js\nconst RESTRICTED_STATUSES = new Set([\n 'product_restriction',\n 'category_restricted',\n 'seller_specific',\n 'valid_with_warning',\n])\n\n// popup.js, ~450 lines later in the same app\nconst restrictedSet = new Set([\n 'product_restriction',\n 'category_restricted',\n 'seller_specific',\n 'valid_with_warning',\n])", + "what": "The identical 4-value coupon-status set is declared twice, verbatim, in two different files of the same extension. It's actually a 3-way (cross-app, cross-language) duplication: apps/caramel-app/src/types/coupon.ts declares the canonical `CouponStatus` union (9 values) and apps/caramel-app/src/components/coupons/coupon-card.tsx's `STATUS_BADGE` map assigns each status a label ('Restrictions apply', 'Category-limited', 'Seller-specific', 'Verified · may vary', etc.) and a Tailwind class; popup.js's own `BADGE` object (popup.js:638-664) reproduces the exact same label strings (including the 'Verified · may vary' middle-dot) against inline hex colors instead. No shared constants file, generated code, or API-supplied label ties any of these three definitions together — a repo-wide grep for the four restricted-status strings hits 9 separate files.", + "why_it_matters": "The coupon-status vocabulary is product-critical (it drives what warning text and badge color a user sees before trusting a code) yet is hand-copied across two languages and two apps with nothing to catch drift — add a new status server-side, rename a label, or fix a typo, and there are at least 3 independent places (2 in the extension alone) that must be remembered and kept byte-identical by hand.", + "severity": "Medium", + "confidence": 0.9, + "verified": false, + "survived_adversarial_review": null, + "fix_direction": "Have the API response carry the display label directly (removing the need for any client to hardcode it), or at minimum extract one shared status-vocabulary module referenced by both shared-utils.js and popup.js within the extension.", + "effort": "M", + "category": "duplication" + }, + { + "id": "DD2-12", + "location": "apps/caramel-extension/package.json:10", + "quote": "\"build\": \"rm -rf dist && mkdir -p dist && rsync -a --exclude='node_modules' --exclude='dist' --exclude='.git' --exclude='*.lock' --exclude='apple-extension' ./ dist/\",", + "what": "The `--exclude='*.lock'` glob matches files ending in the literal suffix `.lock`; it does not match `pnpm-lock.yaml` (which ends in `.yaml`). There is also no exclusion for eslint.config.cjs, scripts/ (including the two test .mjs files), README.md, .turbo/ build logs, or the multi-megabyte apps/caramel-extension/assets/Caramel Logos/ source-asset folder (raw logo exports far larger than the actual runtime icons/ directory). All of the above get rsync'd into dist/, which is exactly what `pnpm run package` zips into extension.zip — the artifact release-extension.yml uploads to the Chrome Web Store and feeds into the Safari web-extension converter.", + "why_it_matters": "Every packed build shipped to end users and app-store reviewers silently bundles the full dependency lockfile, lint config, node test scripts, and unused high-res source logos — pure bloat with no build step ever intended to include them (the exclude list's own intent, four other exclusions, makes clear this is an oversight, not a choice).", + "severity": "Medium", + "confidence": 0.85, + "verified": false, + "survived_adversarial_review": null, + "fix_direction": "Fix the glob to `--exclude='pnpm-lock.yaml'` (or `--exclude='*lock*'` if intentionally broad) and add explicit excludes for eslint.config.cjs, scripts/, README.md, .turbo/, and assets/Caramel Logos/.", + "effort": "S", + "category": "build-hygiene" + }, + { + "id": "DD2-13", + "location": "apps/caramel-extension/eslint.config.cjs:7-36", + "quote": "module.exports = [\n {\n files: ['**/*.{js,html}'],\n ...\n rules: {\n 'import/no-unresolved': 'off',\n 'no-console': ['warn', { allow: ['error'] }],\n 'prettier/prettier': 'warn',\n },\n },\n]", + "what": "This flat config extends no preset (no `eslint:recommended`/`js.configs.recommended` equivalent) and defines exactly 3 rules total for the whole codebase — none of them no-undef, no-unused-vars, no-unreachable, or any correctness rule. The `files` glob (`**/*.{js,html}`) never matches `.mjs`, so scripts/test-extension.mjs and scripts/test-cart-signals.mjs are skipped by `eslint .` entirely (ESLint 9 flat config silently skips files no config block matches); package.json's `prettier-check` script glob (`**/*.{js,html,css,json,md}`) excludes `.mjs` too, so those two files are formatted and linted by nothing in the toolchain.", + "why_it_matters": "This codebase's central convention — implicit globals shared across files purely via manifest/script-tag load order (DD2-3) — is precisely the pattern `no-undef` exists to keep honest, and it isn't enabled. Combined with zero coverage of the two .mjs test scripts, the tooling stack currently would not catch a typo'd global reference, an unused variable, or a formatting drift in exactly the files most likely to hide one.", + "severity": "Low", + "confidence": 0.8, + "verified": false, + "survived_adversarial_review": null, + "fix_direction": "Extend `@eslint/js`'s recommended config as a base layer; add `no-undef` (paired with accurate `/* global */` annotations or the namespacing fix in DD2-3); widen the `files` glob and package.json's prettier-check glob to include `**/*.mjs`.", + "effort": "S", + "category": "tooling" + }, + { + "id": "DD2-14", + "location": "apps/caramel-extension/background.js:114-127 vs manifest.json:19", + "quote": "function keepAlive() {\n if (isServiceWorker) {\n try {\n currentBrowser.alarms.create('keepAlive', { periodInMinutes: 1 })\n currentBrowser.alarms.onAlarm.addListener(alarm => { ... })\n } catch (error) {\n // Fallback if alarms API is not available\n }\n } else {\n setInterval(() => { ... }, 10000)\n }\n}\n\n// manifest.json permissions (Chrome/MV3 — the isServiceWorker=true branch above):\n\"permissions\": [\"tabs\", \"activeTab\", \"storage\", \"scripting\", \"identity\"],", + "what": "manifest.json (the Chrome/MV3 build, the one actually built and published by CI) does not declare the `alarms` permission, so `currentBrowser.alarms` is `undefined` in that context and `currentBrowser.alarms.create(...)` throws a TypeError, caught by the surrounding try/catch. The catch's comment promises 'Fallback if alarms API is not available', but the catch body is empty — the only real fallback branch (the `setInterval` in the `else`) is unreachable from here, since Chrome's service-worker path always takes the `if (isServiceWorker)` branch. manifest-firefox.json, by contrast, does declare `alarms`.", + "why_it_matters": "The function named and commented as this extension's keep-alive mechanism does nothing at all on the Chrome build — the one distributed through the CI/release pipeline to the large majority of users — and fails completely silently (not even a dev-mode log). Actual runtime impact is likely small since Chrome MV3 re-spawns service workers on demand for new events regardless, but the code as written asserts a guarantee ('keep alive') it does not provide, which is exactly the kind of comment-promises-behavior-that-isn't-there gap that erodes trust in the rest of the file's comments.", + "severity": "Medium", + "confidence": 0.75, + "verified": false, + "survived_adversarial_review": null, + "fix_direction": "Add `alarms` to manifest.json's permissions (cheap, matches Firefox already), or delete the dead illusion of a fallback and the misleading comment if keep-alive alarms are judged unnecessary for MV3's on-demand wake behavior.", + "effort": "S", + "category": "manifest-permissions" + } +] +``` diff --git a/audit/deep-dive-3-api-layer.md b/audit/deep-dive-3-api-layer.md new file mode 100644 index 0000000..63191af --- /dev/null +++ b/audit/deep-dive-3-api-layer.md @@ -0,0 +1,259 @@ +# DD-3: API Layer Deep Dive — apps/caramel-app/src/app/api/\*\* + +caramel @ 537547b3081aa3a0ec817cdc5f6dac4f0d328dbb · read-only dive · exclusions.md honored + +## Architecture sketch + +17 route.ts files, Next.js 16 App Router. **No `middleware.ts` exists anywhere in the +repo** (confirmed by repo-wide glob) — every route hand-rolls its own gating inline. +Two data stores: Prisma/`DATABASE_URL` (users/sessions/accounts, owned by this app) and +a second Postgres pool via `couponsSql`/`COUPONS_DATABASE_URL` (coupon catalog, owned by +an external Python verification service — this app only reads it, plus 2 narrow +mutations: increment, expire). Auth is fragmented across 5 mechanisms: better-auth +sessions (real user login — never checked by any `api/**` route), a static +`EXTENSION_API_KEY` header (2 endpoints, 2 different comparison implementations), an +HMAC-signed short-lived state token (OAuth CSRF), a `UPKUMA_HEALTH_SECRET` bearer token, +and a hand-rolled Prisma-based session-issuance path inside `extension/oauth/route.ts` +that bypasses better-auth entirely. A full Pages-Router-era middleware/response/crypto +stack (7 files) survives as dead code, explicitly whitelisted in `knip.json`'s `ignore` +list rather than deleted. `rate-limiter-flexible` and a manual origin allowlist gate +mutation routes, applied inconsistently. Sentry is wired only via Next's automatic +`onRequestError` hook — no route calls it manually, and nearly every route catches its +own errors before they'd ever reach that hook. + +## Convention counts + +| Axis | Count | Detail | +| --------------------------------------------------- | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| Route files in scope | 17 | `middleware.ts`: **does not exist** anywhere in tracked source | +| Body-parsing call sites | 9 | 8× `req.json()`, 1× `req.formData()` (zero error handling) | +| — swallow syntaxes for the same behavior | 4 | `.catch(()=>({}))` ×6, `.catch(()=>null)` ×1, bare `try{}catch{}` ×1, no catch ×1 | +| Schema-validation library used in api/\*\* | 0 | zod is not a dependency anywhere in the repo; `yup` is a dep but used only in one frontend signup form | +| Distinct success-envelope shapes | 5+ | bare data object, `{status,message,data}` (nextApiResponse), raw DB-row passthrough, `{ok:true}`, bespoke `{token,username,image}` | +| Distinct error-envelope shapes | 2 | `{error:string}` (16/17 routes) vs `{status:'error',message,data}` (sources only) | +| Error-handling/logging styles | 4 | console.error+500 (13 handlers), silent swallow+500 (2), no top-level catch (1), lib-internal catch→status object (1) | +| Manual `Sentry.captureException` call sites | 0 | only automatic `onRequestError` hook exists; fires only on errors that escape a handler uncaught | +| Distinct auth/gating mechanisms | 5 | none/public, `EXTENSION_API_KEY` header, HMAC-signed state token, `UPKUMA_HEALTH_SECRET` bearer, `isOriginAllowed` allowlist | +| `auth.api.getSession` calls inside api/\*\* | 0 | only referenced by dead `withAuth.ts` | +| Distinct CORS/origin-trust implementations | 5 | `isOriginAllowed` (rateLimit.ts), `isKnownExtensionOrigin` (oauth/authorize, duplicated 3× in-file), `getCorsHeaders` (oauth POST, different logic), dead `cors.ts` (hardcoded 3-site allowlist) | +| `process.env.*` names read in api/+lib | ~26 unique | 11 absent from `.env.example`, incl. one fail-fast-required (`COUPONS_DATABASE_URL`) | +| Pages-Router fossil files (NextApiRequest/Response) | 7 | all 7 paths listed in `knip.json`'s `ignore`; `cors` npm dep separately listed in `ignoreDependencies` | +| Handlers calling `checkRateLimit` | 11 of ~19 | absent from `extension/oauth` POST, `extension/login`, `sites/suggest`, both oauth GET routes | +| Copy-pasted coupon-visibility SQL WHERE clause | 5 occurrences / 4 files | + 2 narrower, divergent variants (filters, stats) = 3 competing "valid coupon" definitions | + +## Findings + +```json +[ + { + "id": "DD3-1", + "location": "apps/caramel-app/src/app/api/coupons/expire/route.ts:15-19", + "quote": "// server\nconst key = req.headers.get('x-api-key')\nconst expected = process.env.EXTENSION_API_KEY\nif (!expected || !key || key !== expected) {\n return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })\n}\n\n// client — apps/caramel-extension/background.js:23 (tracked in git, shipped to every install)\nconst EXTENSION_API_KEY = 'WXqEpm2uOV5jjJXPpnQFyZiNdaPVUrtd2LIrf4kc1JA'", + "what": "The same static string is both (a) the server-side secret required by `x-api-key` to authorize `coupons/expire` — a POST that permanently sets `expired=TRUE` on arbitrary coupon rows, whose own code comment calls it 'Privileged... requires the extension/server API key... not just an origin check' — and (b) a hardcoded literal in the public extension's background script, sent as-is to production (`grabcaramel.com`) on every install. `rateLimit.ts`'s `isExtensionClient()` also treats this same value as a blanket exemption from all IP rate limiting.", + "why_it_matters": "Anyone can extract this literal from the published extension (chrome://extensions source view, the .xpi/.crx, or this repo) and replay it directly against prod with a plain HTTP client — isOriginAllowed() returns true whenever the Origin header is absent, which is every non-browser request, so the origin check the comment leans on doesn't block this. The holder can mass-expire the coupon catalog while being simultaneously exempt from rate limiting.", + "severity": "Critical", + "confidence": 0.85, + "verified": true, + "survived_adversarial_review": null, + "fix_direction": "Split the key: a low-privilege, rotatable key for read-only extension calls, and a separate never-shipped server/ops-only secret for mutation endpoints. A value that ships in a public client must never also gate a destructive one.", + "effort": "M", + "category": "security/dangerous-default" + }, + { + "id": "DD3-2", + "location": "apps/caramel-app/src/app/api/coupons/expire/route.ts:17", + "quote": "// supported-stores/route.ts:14-19 (read-only endpoint) — constant-time\nlet mismatch = 0\nfor (let i = 0; i < header.length; i++) {\n mismatch |= header.charCodeAt(i) ^ EXTENSION_API_KEY.charCodeAt(i)\n}\nreturn mismatch === 0\n\n// coupons/expire/route.ts:17 (mutating endpoint) — naive\nif (!expected || !key || key !== expected) {", + "what": "The read-only supported-stores route compares the API key in constant time specifically 'to avoid timing-based key probing' per its own comment. The mutating coupons/expire route, gated by the identical env var, compares with plain !==, which short-circuits on the first mismatched byte.", + "why_it_matters": "The team clearly knows timing-safe comparison matters for this exact secret — they wrote it once — but applied it to the lower-stakes endpoint and skipped it on the higher-stakes one. The protection is backwards relative to what each endpoint can do.", + "severity": "Medium", + "confidence": 0.9, + "verified": true, + "survived_adversarial_review": null, + "fix_direction": "Extract one validateExtensionApiKey(req) helper (constant-time) and use it at every EXTENSION_API_KEY check site instead of two independently-written comparisons.", + "effort": "S", + "category": "security/inconsistency" + }, + { + "id": "DD3-3", + "location": "apps/caramel-app/src/app/api/classify-cart/route.ts:67 (+8 more — see what)", + "quote": "sites/suggest/route.ts:5 (await req.json().catch(() => ({}))) as {...}\nextension/login/route.ts:5 (await req.json().catch(() => ({}))) as {...}\nextension/oauth/route.ts:83 (await req.json().catch(() => ({}))) as {...}\nclassify-cart/route.ts:67 await req.json().catch(() => null)\ncoupons/increment/route.ts:20 (await req.json().catch(() => ({}))) as {...}\ncoupons/expire/route.ts:24 (await req.json().catch(() => ({}))) as {...}\nsources/route.ts:78 (await req.json().catch(() => ({}))) as {...}\nsites/search-supported/route.ts:13-15 try { body = await req.json() } catch {}\nextension/oauth/redirect/route.ts:15 await req.formData() // no .catch at all", + "what": "All 9 body-parsing call sites in the API layer treat a malformed request body as equivalent to an absent one, using 4 different syntaxes for the same swallow. No route distinguishes 'malformed JSON' from 'valid JSON missing a field' — both fall through to the same generic 400 or a silent default. Zero routes validate with a schema library: zod is not a dependency anywhere in the repo; yup is a dependency but used only in one frontend signup form, never in api/**.", + "why_it_matters": "This is one root cause with 9 symptoms. A client bug that sends truncated/invalid JSON is silently indistinguishable from a request that validly omitted the field, in both the response and the logs. Every route hand-rolls its own ad-hoc field checks instead of sharing one validated-input pattern.", + "severity": "High", + "confidence": 0.95, + "verified": true, + "survived_adversarial_review": null, + "fix_direction": "Add zod, define one schema per route body, parse once via a shared helper (schema.safeParse(await req.json().catch(() => null))), and return a structured 400 with validation errors on failure.", + "effort": "M", + "category": "maintainability/convention-drift" + }, + { + "id": "DD3-4", + "location": "apps/caramel-app/src/app/api/extension/oauth/authorize/route.ts:52", + "quote": "const provider = searchParams.get('provider') as 'google' | 'apple' | null\nconst redirectUri = searchParams.get('redirect_uri')\n...\nif (provider !== 'google' && provider !== 'apple') {\n return NextResponse.json(\n { error: 'Invalid provider. Must be \"google\" or \"apple\"' },", + "what": "Confirms the nomination: the raw query param is cast to the literal union type before any check. Refutes the security framing though — 26 lines later (line 78) the value is validated against the same two literals before it's used anywhere, so an arbitrary string never reaches the Google/Apple branches unchecked. The identical cast-then-validate shape also appears in extension/oauth/route.ts for the POST body's provider field.", + "why_it_matters": "Not exploitable as written, but the pattern asserts a type the runtime hasn't earned yet — exactly the failure mode schema validation exists to prevent. A future edit that moves the cast's usage earlier, or copies this shape into a route that forgets the check, has no compiler protection against it.", + "severity": "Low", + "confidence": 0.9, + "verified": true, + "survived_adversarial_review": null, + "fix_direction": "Replace the cast with a real narrowing check (if (provider === 'google' || provider === 'apple')) or a small zod enum, so the type only exists once it's actually verified.", + "effort": "S", + "category": "clarity/type-safety" + }, + { + "id": "DD3-5", + "location": "apps/caramel-app/src/app/api/sources/route.ts:65", + "quote": "// coupons/route.ts:111-112 (bare data object)\nreturn NextResponse.json({ coupons, page, limit, total, hasMore }, {...})\n\n// sources/route.ts:65 (nextApiResponse envelope)\nreturn nextApiResponse(req, 200, 'sources', sourcesWithMetrics)\n// -> { status: 'success', message: 'sources', data: sourcesWithMetrics }\n\n// coupons/increment/route.ts:48 (raw DB row, no envelope)\nreturn NextResponse.json(rows[0])\n\n// sites/suggest/route.ts:17 (bare boolean flag)\nreturn NextResponse.json({ ok: true })", + "what": "Counted across the 17 routes: at least 5 distinct success-envelope shapes and 2 distinct error shapes (`{error:string}` used by 16/17 routes vs sources/route.ts's `{status:'error',message,data:null}`). sources/route.ts is the only route importing @/lib/apiResponseNext at all.", + "why_it_matters": "No client can code against a single contract. The extension compensates by defensively checking r.ok and hardcoding per-endpoint fallbacks (`{coupons:[]}`, `{supported:[]}` in background.js) rather than trusting a shared shape. The inconsistency is pure drift: nothing enforces the nextApiResponse convention outside the one file that happens to use it.", + "severity": "Medium", + "confidence": 0.9, + "verified": true, + "survived_adversarial_review": null, + "fix_direction": "Pick the shape 16/17 routes already converged on (bare data on 200, {error} on failure), migrate sources/route.ts to match, then delete apiResponseNext.ts.", + "effort": "S", + "category": "maintainability/convention-drift" + }, + { + "id": "DD3-6", + "location": "apps/caramel-app/src/app/api/coupons/route.ts:48 (+4 more — see what)", + "quote": "status IN ('valid','valid_with_warning','product_restriction','category_restricted','seller_specific','pending','retry') AND expired = FALSE\n\n// coupons/filters/route.ts:20 (narrower)\nWHERE status = 'valid' AND expired = FALSE AND site IS NOT NULL\n\n// coupons/stats/route.ts:15 (narrowest — no expired filter at all)\nWHERE status = 'valid'", + "what": "The 7-status 'visible coupon' whitelist is copy-pasted verbatim 5 times across 4 files: coupons/route.ts:48, coupons/stores/route.ts:18 and :25, sites/top-sites/route.ts:13, sites/search-supported/route.ts:24-25. Two more files implement different, narrower definitions: coupons/filters uses only status='valid', and coupons/stats uses only status='valid' with no expired filter at all.", + "why_it_matters": "Three competing definitions of 'live coupon' exist in one small API surface. Concretely: /api/coupons/stats's total/active counts only ever count status='valid' rows, while the actual browsable listing (/api/coupons) also surfaces valid_with_warning, pending, retry, etc. — so any UI showing the stats figure undercounts what users can actually browse. If the status taxonomy changes, 5 copies must change in lockstep or the endpoints silently diverge further.", + "severity": "High", + "confidence": 0.85, + "verified": true, + "survived_adversarial_review": null, + "fix_direction": "Extract the visibility predicate into one shared couponsSql fragment/constant imported by every route that needs it; make a deliberate, documented call on whether /stats should use the same definition.", + "effort": "S", + "category": "maintainability/dedup" + }, + { + "id": "DD3-7", + "location": "apps/caramel-app/src/lib/middlewares/withAuth.ts:1", + "quote": "// knip.json\n\"ignore\": [\n \"src/lib/cors.ts\",\n \"src/lib/initMiddleware.ts\",\n \"src/lib/middlewares/**\",\n \"src/lib/securityHelpers/apiResponse.ts\",\n ...\n],\n\"ignoreDependencies\": [ ..., \"cors\", ... ]", + "what": "6 files still import NextApiRequest/NextApiResponse from 'next' (Pages Router types) though every live route is an App Router route.ts: initMiddleware.ts, cors.ts, middlewares/withAuth.ts, middlewares/withRoles.ts, middlewares/errorMiddleware.ts, securityHelpers/apiResponse.ts. Grepping the whole app for their exports (withAuth(, withRoles(, cors import, onErrorMiddleware, apiResponse() turns up zero callers outside their own definitions. Instead of deleting them, every one of these paths — plus the now-pointless cors npm dependency they exist to wrap — is listed in knip's ignore/ignoreDependencies, permanently silencing the dead-code linter rather than resolving what it flagged.", + "why_it_matters": "This is confirmed dead weight, not a judgment call — knip already flagged it and the team suppressed the flag. It includes a hand-rolled Pages-Router auth gate (withAuth.ts) that a future engineer searching for 'how do we gate a route with auth' may find and mistake for the pattern to follow; it silently does nothing against an App Router route.ts.", + "severity": "High", + "confidence": 0.95, + "verified": true, + "survived_adversarial_review": null, + "fix_direction": "Delete all 6 files (keep only the live client-side half of cryptoHelpers.ts, see DD3-8), remove the cors/@types/cors dependencies, and remove the corresponding knip ignore entries.", + "effort": "S", + "category": "maintainability/dead-code" + }, + { + "id": "DD3-8", + "location": "apps/caramel-app/src/lib/securityHelpers/cryptoHelpers.ts:63-75", + "quote": "export function encryptJsonServer(req: NextApiRequest, payload: any): string {\n const domain = (req.headers.host || '').replace(/:\\d+$/, '')\n const userAgent = req.headers['user-agent'] || ''\n const key = domain + userAgent\n ...\n}\n// apiResponseNext.ts:46 — server flag\nif (process.env.API_ENCRYPTION_ENABLED !== 'true') { ... }\n// decryptJsonData.ts:5 — separately-named client flag\nif (process.env.NEXT_PUBLIC_API_ENCRYPTION_ENABLED !== 'true') { ... }", + "what": "A home-rolled XOR cipher 'encrypts' JSON responses using a key built from the Host and User-Agent headers — both fully attacker-known or attacker-chosen, so the key provides no confidentiality to anyone who can also read the response over the wire. It's wired to /api/sources (public, unauthenticated GET of business metrics) via apiResponseNext.ts, gated by API_ENCRYPTION_ENABLED server-side and a separately-named NEXT_PUBLIC_API_ENCRYPTION_ENABLED client-side that must be manually kept in sync.", + "why_it_matters": "Dormant by default (both flags default off; neither name appears in any tracked env/config file), so no live exploitation found. But it's a landmine: flipping API_ENCRYPTION_ENABLED=true believing it adds protection yields a trivially reversible cipher over non-secret headers, plus real risk of a working-server/broken-client incident from the flag-name mismatch.", + "severity": "Medium", + "confidence": 0.85, + "verified": true, + "survived_adversarial_review": null, + "fix_direction": "Delete the encryption path entirely rather than fix it — TLS already protects transport and /api/sources has no confidentiality requirement to begin with.", + "effort": "S", + "category": "security/dead-code" + }, + { + "id": "DD3-9", + "location": "apps/caramel-app/src/app/api/extension/oauth/authorize/route.ts:36-48,56-67,199-208", + "quote": "// OPTIONS handler, lines 42-44\nheaders.set('Access-Control-Allow-Origin', origin)\nheaders.set('Access-Control-Allow-Methods', 'GET, OPTIONS')\nheaders.set('Access-Control-Allow-Headers', 'Content-Type')\n\n// catch block, lines 199-207 -- comment admits it's a copy\n// Re-create CORS headers in catch block\nconst headers = new Headers()\nconst origin = req.headers.get('origin')\nconst isExtensionOrigin = isKnownExtensionOrigin(origin)\nif (isExtensionOrigin && origin) {\n headers.set('Access-Control-Allow-Origin', origin)\n ...", + "what": "The identical 3-line CORS-header block is written by hand 3 times in this one file: the OPTIONS handler, the getCorsHeaders() closure defined to hold this logic, and again inline in the catch block (whose own comment says 'Re-create CORS headers in catch block' rather than simply calling the in-scope getCorsHeaders()). Separately, extension/oauth/route.ts (the POST that exchanges the code) implements its own getCorsHeaders(req) trusting any origin starting with chrome-extension://, moz-extension://, or safari-web-extension://, vs. this file's exact-match allowlist against CHROME_EXTENSION_ORIGIN/FIREFOX_EXTENSION_ORIGIN/SAFARI_EXTENSION_ORIGIN. Counting rateLimit.ts's isOriginAllowed() and the dead cors.ts, 5 independent 'is this origin trusted' implementations exist live or fossil in this codebase.", + "why_it_matters": "The code-exchange endpoint (POST, mints sessions) accepts CORS from any extension ID; the authorize endpoint (GET, only returns a URL) restricts to specific known origins -- the same inversion-of-caution as DD3-2. The in-file triplication means a future CORS policy change has to be remembered in 3 places in one file alone.", + "severity": "High", + "confidence": 0.9, + "verified": true, + "survived_adversarial_review": null, + "fix_direction": "Extract one shared extensionCorsHeaders(req) helper used by both oauth files, decide once whether extension-origin trust is allowlist- or protocol-based, and always call the helper instead of inlining.", + "effort": "M", + "category": "maintainability/dedup" + }, + { + "id": "DD3-10", + "location": "apps/caramel-app/src/app/api/extension/oauth/route.ts:81", + "quote": "// extension/oauth/route.ts:81-90 -- no checkRateLimit anywhere in this file\nexport async function POST(req: NextRequest) {\n const corsHeaders = getCorsHeaders(req)\n const body = (await req.json().catch(() => ({}))) as {...}\n\n// sites/suggest/route.ts:4-17 -- no rate limit, no origin/auth check\nexport async function POST(req: NextRequest) {\n const { url = '' } = (await req.json().catch(() => ({}))) as {...}\n ...\n await sendEmail({ to: 'support@unotes.net', ... })", + "what": "checkRateLimit is applied in 11 handlers across 8 files but is absent from: extension/oauth POST (creates/updates Prisma User/Account/Session rows and calls Google/Apple's token endpoints per request), extension/login POST, both extension/oauth/authorize and extension/oauth/redirect, and sites/suggest POST -- which additionally has no auth and no isOriginAllowed check at all.", + "why_it_matters": "sites/suggest is a fully public, unauthenticated endpoint whose only effect is a real outbound sendEmail() to the team's support inbox -- nothing stops a script from looping it. extension/oauth POST is the single most side-effect-heavy unauthenticated route in the app (external HTTP calls plus 3+ DB writes per request) and is exactly what the app's own 'mutation' rate-limit tier (30/min/IP) was designed for, yet it isn't wired in.", + "severity": "High", + "confidence": 0.9, + "verified": true, + "survived_adversarial_review": null, + "fix_direction": "Add checkRateLimit(req, 'mutation') to extension/oauth POST and sites/suggest POST at minimum; treat every route that writes or sends as rate-limited by default, not opt-in.", + "effort": "S", + "category": "operability/rate-limiting" + }, + { + "id": "DD3-11", + "location": "apps/caramel-app/src/app/api/sites/suggest/route.ts:18-23", + "quote": "// sites/suggest/route.ts:18-23 -- fully silent\n} catch {\n return NextResponse.json(\n { error: 'Could not save suggestion' },\n { status: 500 },\n )\n}\n\n// instrumentation.ts:12 -- Sentry's ONLY route-error wiring\nexport const onRequestError = Sentry.captureRequestError", + "what": "4 distinct error-handling styles across the 17 routes: (1) console.error then a 500 JSON -- 13 handlers; (2) fully silent catch, no log at all -- sites/suggest and extension/login (also confirms the extension/oauth/redirect nomination: that file has zero console.* calls anywhere, though unlike the other two it also has no top-level try/catch, so a genuine bug there would actually surface); (3) no top-level catch, letting unexpected errors bubble to Next.js -- extension/oauth/redirect; (4) library-internal catch converting failure to a status object with no logging -- lib/health.ts's timedCheck. Zero of the 17 routes call Sentry.captureException or any other Sentry API directly -- the only Sentry wiring anywhere in the app is the automatic onRequestError hook.", + "why_it_matters": "onRequestError only fires for errors that escape a route handler uncaught. Since every route except oauth/redirect wraps its own logic in try/catch and returns a JSON 500 instead of rethrowing, the handled errors -- DB failures, OpenRouter timeouts, OAuth token-exchange failures, the ones an operator actually needs visibility into -- structurally never reach Sentry. In production, Sentry for this API surface is close to a no-op; the only record of most failures is a console line that may or may not be aggregated anywhere.", + "severity": "High", + "confidence": 0.9, + "verified": true, + "survived_adversarial_review": null, + "fix_direction": "Add Sentry.captureException(error) inside a shared error-handling helper (pairs naturally with the envelope fix in DD3-5) instead of relying on the automatic hook; keep console.error for local dev only.", + "effort": "M", + "category": "operability/observability" + }, + { + "id": "DD3-12", + "location": "apps/caramel-app/middleware.ts (does not exist)", + "quote": "(no file -- confirmed via repo-wide glob for middleware.ts / middleware.tsx: zero matches outside node_modules)", + "what": "There is no Next.js middleware.ts anywhere in the repo. Every route re-implements its own auth/origin/rate-limit gating inline as the first 1-3 lines of the handler (if (!isOriginAllowed(req)) return forbiddenOrigin(); const limited = await checkRateLimit(req, ...); if (limited) return limited), copy-pasted across the 8 files that have it at all (and absent from others, see DD3-10).", + "why_it_matters": "Centralizing origin/rate-limit gating in middleware.ts -- Next.js's purpose-built mechanism for exactly this -- would make 'every mutation is rate-limited and origin-checked' a structural guarantee instead of a convention every new route author must remember to copy correctly, which is precisely how extension/oauth and sites/suggest ended up ungated (DD3-10).", + "severity": "Medium", + "confidence": 0.9, + "verified": true, + "survived_adversarial_review": null, + "fix_direction": "Move the origin-allowlist + rate-limit-tier decision into middleware.ts, keyed on path prefix, so new routes inherit it by default instead of opting in.", + "effort": "M", + "category": "maintainability/architecture" + }, + { + "id": "DD3-13", + "location": "apps/caramel-app/src/app/api/extension/oauth/route.ts:226-297", + "quote": "user = await prisma.user.create({\n data: {\n email: userEmail,\n ...\n emailVerified: googleUser.verified_email || false,\n status: googleUser.verified_email ? 'ACTIVE_USER' : 'NOT_VERIFIED',\n },\n})\n...\nconst sessionToken = randomBytes(32).toString('base64url')\nconst session = await prisma.session.create({\n data: { token: sessionToken, userId: user.id, expiresAt },\n})", + "what": "This route creates User, Account, and Session rows directly via Prisma and mints its own session token with randomBytes, bypassing better-auth's own sign-up/session APIs entirely (the ones auth/[...all]/route.ts and extension/login/route.ts use). Grepping the file confirms emailVerified/status are only ever written here, never read/checked before minting a token -- unlike extension/login's email/password path, which surfaces better-auth's EMAIL_NOT_VERIFIED check and blocks unverified accounts. Both the Google and Apple branches (mirrored at :468-544) mint a working session even when verified_email is false and the user row is stamped status:'NOT_VERIFIED'.", + "why_it_matters": "Two independent, hand-written implementations of 'what it means to be signed in' now have to be kept semantically consistent by hand -- and already aren't: one enforces email verification before issuing a session, the other doesn't. Any future change to better-auth's session model (hashing, rotation, additional claims) has to be manually ported here or silently drifts out of sync.", + "severity": "Medium", + "confidence": 0.8, + "verified": true, + "survived_adversarial_review": null, + "fix_direction": "Route through better-auth's own social sign-in completion instead of writing to Session/Account/User directly; if that's not feasible for the extension flow, at minimum gate on emailVerified before returning a usable token, matching the email/password path.", + "effort": "L", + "category": "maintainability/architecture" + }, + { + "id": "DD3-14", + "location": "apps/caramel-app/.env.example", + "quote": "// couponsDb.ts:9-12 -- fails fast if unset\nconst connectionString = process.env.COUPONS_DATABASE_URL\nif (!connectionString) {\n throw new Error('COUPONS_DATABASE_URL is not set')\n}\n// .env.example has DATABASE_URL but no COUPONS_DATABASE_URL entry at all", + "what": "Of ~26 distinct process.env.* names read across the API routes and shared lib, at least 11 are absent from .env.example: EXTENSION_API_KEY, EXTENSION_OAUTH_STATE_SECRET, COUPONS_DATABASE_URL, CHROME_EXTENSION_ORIGIN, FIREFOX_EXTENSION_ORIGIN, SAFARI_EXTENSION_ORIGIN, ALLOWED_ORIGINS, UPKUMA_HEALTH_SECRET, API_ENCRYPTION_ENABLED, NEXT_PUBLIC_API_ENCRYPTION_ENABLED, NEXT_PUBLIC_GOOGLE_ANALYTICS_ID. COUPONS_DATABASE_URL is not optional -- the module throws at import time without it. No env var anywhere is validated by zod or any schema.", + "why_it_matters": ".env.example is the app's only self-documentation of its configuration surface, and it postdates both the extension-auth work and the 'DB split' couponsDb.ts's own comment describes -- a new environment bootstrapped from it alone crashes immediately on the missing COUPONS_DATABASE_URL, with the real required name discoverable only by reading source.", + "severity": "Medium", + "confidence": 0.85, + "verified": true, + "survived_adversarial_review": null, + "fix_direction": "Regenerate .env.example from an actual inventory of process.env reads, or introduce one zod-validated env module every route/lib imports from instead of raw process.env, so missing vars fail at boot with a clear message.", + "effort": "S", + "category": "operability/config" + }, + { + "id": "DD3-15", + "location": "apps/caramel-app/src/app/api/coupons/stores/route.ts", + "quote": "GET /api/coupons/stores?q=... -> { sites: [...] } (query-string input)\nPOST /api/sites/search-supported -> { sites: [...] } (JSON-body input, same conceptual search)\nGET /api/sites/top-sites -> { sites: [...] } (top 4 by coupon count, no input)\nGET /api/coupons/filters -> { sites: [...], discountTypes: [...] }\nGET /api/extension/supported-stores -> { supported: [...] } (xpath automation configs -- unrelated meaning of \"supported\")", + "what": "Four separate endpoints return a list of site/store names for what is functionally the same underlying concept (distinct sites with visible coupons), split across two path prefixes (/api/coupons/*, /api/sites/*) with no naming rule distinguishing them. The two search-shaped ones (coupons/stores, sites/search-supported) take their query via different transports -- URL query param vs. JSON body -- for the same kind of request. extension/supported-stores reuses the word 'supported' for a third, unrelated meaning, one path segment away from sites/search-supported.", + "why_it_matters": "A reader trying to find 'the endpoint that searches sites' has 2 real candidates and 1 false-friend (supported-stores) to disambiguate by reading source, not by name. No convention exists for where a new 'sites matching X' endpoint should live or how it should take input.", + "severity": "Medium", + "confidence": 0.75, + "verified": true, + "survived_adversarial_review": null, + "fix_direction": "Consolidate under one /api/sites resource with consistent GET+query-param semantics, and rename extension/supported-stores to something unambiguous like extension/automation-configs.", + "effort": "M", + "category": "clarity/naming" + } +] +``` diff --git a/audit/deep-dive-4-lib-types.md b/audit/deep-dive-4-lib-types.md new file mode 100644 index 0000000..30d1418 --- /dev/null +++ b/audit/deep-dive-4-lib-types.md @@ -0,0 +1,129 @@ +# Deep Dive DD-4 — `apps/caramel-app/src/lib/**` + tsconfig strictness + nominated `any` (coupon-filters.tsx) + +caramel @ `537547b3081aa3a0ec817cdc5f6dac4f0d328dbb` (dev). Graded against `~/.claude/skills/codebase-audit/references/shared-claude-rules.md` v5, §"Typing & contracts". Read-only on tracked files. + +## tsconfig.json strictness (v5-graded) + +`apps/caramel-app/tsconfig.json`: `"strict": true`, no override of any strict sub-flag (`noImplicitAny`, `strictNullChecks`, etc. all default-on), no `any`-permitting escape hatch (no `suppressImplicitAnyIndexErrors`, no per-file `// @ts-nocheck` found in lib). **This satisfies v5's actual bullet** ("Strict TypeScript: no any, no lazy inference on exported surfaces") at the compiler-flag level — there is no wishlist gap here (v5 does not mention `noUncheckedIndexedAccess`, `exactOptionalPropertyTypes`, etc., so none is graded). + +The real gap is downstream of `strict`: `noImplicitAny` only catches _missing_ annotations, not an author explicitly writing `: any`. That requires a lint rule, and there isn't one — `eslint.config.mjs` (root, inherited by the app's own `eslint.config.mjs` which just re-exports it) carries no `@typescript-eslint/no-explicit-any` entry, only unrelated overrides (`react-hooks/exhaustive-deps: off`, etc.). Lint is a required CI gate (`checks-app.yml` matrix: `lint`, `prettier`, `typecheck`, `knip`) that currently passes with 26 live `any` instances in the tree (see DD4-2). tsconfig is fine; the enforcement chain after it is not. + +## `src/lib` module map + +| File | Purpose (one line) | Callers | Flags | +| ------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------- | +| `apiResponseNext.ts` | App Router API response formatter; XOR-"encrypts" the payload inline when `API_ENCRYPTION_ENABLED=true` | 1: `app/api/sources/route.ts` (only 1 of ~13 API routes uses it) | duplicated crypto (DD4-3), `any` params (DD4-2), taxonomy split from `securityHelpers/apiResponse.ts` (DD4-5) | +| `capitalizeFirst.ts` | Capitalizes a string's first letter | **0** | dead code, silenced in knip.json (DD4-1) | +| `contexts.ts` | React `ThemeContext` (dark-mode flag + toggle) | 9 files (Layout, SupportedSection, coupon-filters, providers, sources/page, WhyNot, ThemeToggle, StoreButtons, OpenSourceSection) | alive, core | +| `cors.ts` | Configures the `cors` npm middleware via `initMiddleware` | **0** | dead code, silenced in knip.json (DD4-1) | +| `gtag.ts` | GA pageview/event helpers | 1: `app/providers.tsx` uses `pageView`+`GA_TRACKING_ID`; `event()` export has 0 callers | partially dead, silenced in knip.json (DD4-1) | +| `initMiddleware.ts` | Pages-Router connect-style-middleware→Promise adapter | 1: `cors.ts` (itself dead) | dead code (DD4-1), untyped `any` adapter (DD4-2) | +| `middlewares/errorMiddleware.ts` | `next-connect`-style `onError`/`onNoMatch` handlers for Pages API routes | **0** | dead code, silenced in knip.json (DD4-1) | +| `middlewares/withRoles.ts` | Pages-Router HOF: role-gates an API handler via DB lookup | **0** | dead code, silenced in knip.json (DD4-1); `as any` casts to smuggle `.user` onto `NextApiRequest` | +| `prisma.ts` | Prisma client singleton (prod: new client; dev: global-cached for HMR) | ~15+ files (auth.ts, oauth route, health/db route, etc.) | alive, core, correct/standard pattern | +| `securityHelpers/apiResponse.ts` | Pages-Router API response formatter + encryption via `cryptoHelpers` | **0** | dead code, silenced in knip.json (DD4-1); duplicate of `apiResponseNext.ts` (DD4-3/5) | +| `securityHelpers/cryptoHelpers.ts` | XOR-cipher + base64 primitives; server half (`encryptJsonServer`, `NextApiRequest`-typed) + client half (`decryptJsonClient`) | `decryptJsonClient` alive via `decryptJsonData.ts`; `encryptJsonServer` used only by the dead `apiResponse.ts` | mixed alive/dead file, silenced in knip.json (DD4-1), duplicated by `apiResponseNext.ts` (DD4-3), `any` (DD4-2) | +| `securityHelpers/decryptJsonData.ts` | Client-side response un-wrapper: decrypts (or passes through) `/api/*` JSON per env flag | 1: `app/(marketing)/sources/page.tsx` | silent catch-swallow (DD4-4), `any` in/out (DD4-2) | +| `urlHelper.ts` | Validates a string is a well-formed URL with a dotted hostname (protocol optional) | 2: `suggestion-form.tsx` (imports it), `sources/page.tsx` (does **not** import it — see DD4-3) | duplicated locally in sources/page.tsx (DD4-3) | +| `auth/client.ts` | better-auth React client (`signIn`/`signUp`/`signOut`/`useSession`) | broad, client components | alive, core | +| `middlewares/withAuth.ts` | Pages-Router HOF: session-gates an API handler via better-auth | **0** | dead code, silenced in knip.json (DD4-1); `as any` casts for `.session`/`.user` | +| `email.ts` | `usesend-js` wrapper (`sendEmail`) | `auth.ts` (verification emails), `api/sites/suggest/route.ts` | alive; lazy (call-time, not boot-time) required-env-var throw (DD4-6) | +| `health.ts` | `timedCheck` wrapper + bearer-secret `authorize()` for health endpoints | `api/health/db/route.ts` | alive | +| `cartClassifier.ts` | LLM-backed cart→category classifier with an in-memory LRU-ish cache | `api/classify-cart/route.ts` | alive | +| `couponsDb.ts` | postgres.js connection to the read-mostly `caramel_coupons` DB + `CouponRow`/`DiscountType` types | ~9 API routes + 1 page | alive, core; eager fail-fast env throw at import (contrast DD4-6) | +| `openrouter.ts` | Thin OpenRouter chat-completion HTTP client | `cartClassifier.ts` only | alive | +| `auth/auth.ts` | better-auth server config (providers, session, email verification) | `app/api/auth/[...all]/route.ts` (handler mount), `client.ts` (type-only), `withAuth.ts` (dead) | alive, core; eager-throw IIFE for secret (DD4-6) | +| `rateLimit.ts` | Per-IP in-memory rate limiter + origin allow-list for API routes | ~11 API routes | alive, core, well-documented | + +**Confirmed: no Pages Router exists anywhere in the app** (`Glob` for `src/pages/**` and `pages/**` both empty; every route lives under `src/app/**/route.ts` using `NextRequest`/`NextResponse` from `next/server`). Exactly 6 files still import `NextApiRequest`/`NextApiResponse` from `next` (Pages-Router types): `initMiddleware.ts`, `cors.ts` (transitively), `middlewares/withAuth.ts`, `middlewares/withRoles.ts`, `middlewares/errorMiddleware.ts`, `securityHelpers/apiResponse.ts`, plus `securityHelpers/cryptoHelpers.ts`'s dead half. **Nomination correction:** the task's example list named `apiResponseNext` as a suspected pages-router leftover — refuted; it imports `NextRequest`/`NextResponse` from `next/server` (App Router) and has a live caller. It has real problems (duplication, `any`) but "pages-router fossil" isn't one of them. + +## Findings + +```json +[ + { + "id": "DD4-1", + "location": "apps/caramel-app/knip.json:16-23", + "quote": "\"ignore\": [\n \"eslint.config.mjs\",\n \"postcss.config.mjs\",\n \"postcss.config.ts\",\n \"tailwind.config.ts\",\n \"public/sw.js\",\n \"src/components/StoreButtons.tsx\",\n \"src/lib/capitalizeFirst.ts\",\n \"src/lib/cors.ts\",\n \"src/lib/initMiddleware.ts\",\n \"src/lib/middlewares/**\",\n \"src/lib/securityHelpers/apiResponse.ts\",\n \"src/lib/gtag.ts\",\n \"src/lib/securityHelpers/cryptoHelpers.ts\"\n],", + "what": "knip is a required CI gate (checks-app.yml matrix runs `pnpm knip`). Its ignore list whitelists 6 files with zero live callers anywhere in src/app (confirmed by exhaustive grep): src/lib/cors.ts, src/lib/initMiddleware.ts, src/lib/middlewares/withRoles.ts, src/lib/middlewares/withAuth.ts, src/lib/middlewares/errorMiddleware.ts (via the middlewares/** glob), src/lib/securityHelpers/apiResponse.ts — plus src/lib/capitalizeFirst.ts, an unrelated dead single-function file. All 6 of the first group import NextApiRequest/NextApiResponse from 'next' in an app that is 100% App Router (no src/pages or pages/ dir exists; every route uses NextRequest/NextResponse). package.json's 'cors' dependency (only consumer: the dead cors.ts) is separately silenced via knip's ignoreDependencies alongside '@types/cors'.", + "why_it_matters": "knip didn't miss this dead code — it caught it, and the fix was to blind the checker instead of deleting the code. CI now passes green while permanently excluding 7 files from ever being flagged again. A future agent grepping for 'how do I gate an API route' finds withAuth.ts/withRoles.ts fully wired and exported, looking like the sanctioned pattern, and could wire a new route through dead code whose silent 401/403s nothing exercises today either.", + "severity": "High", + "confidence": 0.95, + "verified": true, + "survived_adversarial_review": null, + "fix_direction": "Delete the 6 dead files + capitalizeFirst.ts; remove their knip.json ignore entries and the cors/@types/cors ignoreDependencies entries; uninstall the cors package. If any is intentionally kept pending a migration, replace its ignore entry with a dated TODO naming the reason and a removal deadline (v5: suppressions carry a dated reason or the PR is blocked).", + "effort": "S", + "category": "dead-code/CI-gate-suppression" + }, + { + "id": "DD4-2", + "location": "apps/caramel-app/src/lib/securityHelpers/decryptJsonData.ts:3", + "quote": "export function decryptJsonData(resData: any): any {", + "what": "Full census across src: 26 `any` instances (19 explicit `: any` + 7 `as any`) across 10 files. Live, exported surfaces carrying any (poison every caller): decryptJsonData(resData: any): any — its only caller, sources/page.tsx, gets zero type-checking on the return and separately reaches for 3 more `as any` casts (lines 101, 102, 226) to keep going; cryptoHelpers.ts's decryptJsonClient(): any (line 82) and encryptJsonServer(req, payload: any) (line 63); apiResponseNext.ts's nextApiResponse(..., data: any = null, error: any = null) (lines 36-37) — the App Router formatter a live route actually uses. Dead-but-typed: initMiddleware.ts (2), securityHelpers/apiResponse.ts (2), withRoles.ts/withAuth.ts (2 `as any` casts each, smuggling .user/.session onto NextApiRequest instead of extending the type). Component scope: coupon-filters.tsx:92-131 has 8 anys on react-select's styles callbacks (control/menu/option/singleValue/input/placeholder), all fixable with react-select's own exported StylesConfig type. Outside the nominated set: app/api/extension/oauth/route.ts:41 `let payload: any` parses untrusted OAuth state JSON and reads .provider/.redirectUri/.iat off it with no shape validation.", + "why_it_matters": "v5 Typing & contracts: 'Strict TypeScript: no any... Every rule that matters gets a check that fails the build.' Every any above sits on an exported function boundary, so the type hole isn't local — decryptJsonData is the clearest case: its own return type is any, so its one caller can't distinguish a successful decrypt from a silent passthrough (see DD4-4) and had to bolt on more any-casts just to compile. No lint rule enforces the ban despite lint being a required CI gate, so the count is not self-correcting.", + "severity": "High", + "confidence": 0.93, + "verified": true, + "survived_adversarial_review": null, + "fix_direction": "Add @typescript-eslint/no-explicit-any as an error in the repo-root eslint.config.mjs; type decryptJsonData as (resData: unknown) => T | null with a runtime guard; type nextApiResponse/apiResponse's data/error as unknown or generic; import react-select's StylesConfig in coupon-filters.tsx; zod-validate the OAuth state payload in oauth/route.ts.", + "effort": "M", + "category": "typing" + }, + { + "id": "DD4-3", + "location": "apps/caramel-app/src/lib/apiResponseNext.ts:3-17", + "quote": "function base64Encode(str: string) {\n if (typeof Buffer !== 'undefined')\n return Buffer.from(str, 'utf-8').toString('base64')\n // @ts-ignore\n return btoa(str)\n}\nfunction xorWithBase64(text: string, key: string) {\n const base64Key = base64Encode(key)\n const keyBytes = Array.from(base64Key, c => c.charCodeAt(0))\n const textBytes = Array.from(text, c => c.charCodeAt(0))\n const out: number[] = new Array(textBytes.length)\n for (let i = 0; i < textBytes.length; i++)\n out[i] = textBytes[i] ^ keyBytes[i % keyBytes.length]\n return String.fromCharCode(...out)\n}", + "what": "Instance 1: this is cryptoHelpers.ts's base64Encode/xorWithBase64/encrypt (cryptoHelpers.ts:4-49) reimplemented from scratch instead of imported — identical XOR-with-base64(key) scheme, identical host+user-agent key material — already drifted: cryptoHelpers.ts's encryptJsonServer reads `req.headers.host` / `req.headers['user-agent']` (NextApiRequest plain-object headers) while apiResponseNext.ts's buildKeyFromHeaders reads `req.headers.get('host')` / `req.headers.get('user-agent')` (NextRequest Web Headers API) — two copies of the same crypto now require hand-sync. Instance 2 (different root, same habit): urlHelper.ts exports isValidUrl (requires a dot in the hostname, tolerates protocol-less input by prepending https://) and is correctly imported by suggestion-form.tsx (`import { isValidUrl } from '@/lib/urlHelper'`). sources/page.tsx — which already imports two other lib helpers in the same file (ThemeContext, decryptJsonData) — instead redefines its own local isValidUrl: `const isValidUrl = (url: string): boolean => { try { new URL(url); return true } catch { return false } }` (sources/page.tsx:54-61). The two disagree: the local one requires an explicit protocol (`new URL('amazon.com')` throws), the lib one doesn't — a user typing a bare domain into the marketing 'submit a source' form gets an erroneous 'Invalid URL' toast for input the shared helper is specifically designed to accept.", + "why_it_matters": "Two independent copies of security-relevant crypto code is exactly what v5's 'ban the raw form the moment a shared helper exists' targets — except here the shared helper (cryptoHelpers.ts) existed first and the raw form (apiResponseNext.ts) was written anyway; a future cipher fix applied to one copy silently leaves the other unfixed. The isValidUrl pair shows the same habit causing a live, user-facing correctness regression, not just a maintenance smell.", + "severity": "Medium", + "confidence": 0.9, + "verified": true, + "survived_adversarial_review": null, + "fix_direction": "Make apiResponseNext.ts import base64Encode/xorWithBase64/encrypt from cryptoHelpers.ts (extract a header-agnostic buildKey(host, ua) both request types can feed); delete the local isValidUrl in sources/page.tsx and import from @/lib/urlHelper (verify the form still accepts the input shapes it needs before switching).", + "effort": "S", + "category": "duplication" + }, + { + "id": "DD4-4", + "location": "apps/caramel-app/src/lib/securityHelpers/decryptJsonData.ts:14-22", + "quote": "try {\n if (resData.pageData) {\n return decryptJsonClient(resData.pageData)\n } else {\n return decryptJsonClient(resData.response)\n }\n} catch (err) {\n return resData\n}", + "what": "decryptJsonData's only caller, sources/page.tsx:41-42 (`const plainObj = await decryptJsonData(data); setSources(plainObj.data)`), cannot distinguish three return paths: (1) real decrypted {status,message,data}, (2) untouched passthrough because encryption is off, or (3) untouched passthrough because decryption THREW (bad key, corrupted ciphertext, a key-scheme change) and the catch silently returned the still-encrypted wrapper. In path 3, resData is {response:''} with no .data key, so plainObj.data is undefined and setSources(undefined) runs with no error, no log, no toast — the page just renders 'No sources found.' The toggle is split across two independently-named env vars with no shared validation: server-side API_ENCRYPTION_ENABLED (apiResponseNext.ts:46) vs client-side NEXT_PUBLIC_API_ENCRYPTION_ENABLED (decryptJsonData.ts:5) — necessarily different names since only NEXT_PUBLIC_-prefixed vars reach the browser bundle, but nothing enforces they agree; neither appears in .env.example (grepped, zero matches) and there is no zod-validated env module anywhere in the app.", + "why_it_matters": "Directly violates v5 'No silent failures: never... swallowed catch' and 'producer and consumer share one schema... drift fails statically or in CI, never only at runtime.' If the two flags disagree after a deploy, the Sources page silently goes blank in production with nothing in the logs pointing at the cause — the bug report will be 'sources page is empty' with no lead back to an env mismatch.", + "severity": "High", + "confidence": 0.88, + "verified": true, + "survived_adversarial_review": null, + "fix_direction": "Return a tagged result ({ok:true,data}|{ok:false,reason}) instead of swallowing; log/Sentry-report the catch with the underlying error; add a zod-validated env module asserting the two encryption flags agree (or collapse to one source of truth); document both in .env.example.", + "effort": "M", + "category": "silent-failure" + }, + { + "id": "DD4-5", + "location": "apps/caramel-app/src/lib (directory structure)", + "quote": "src/lib/apiResponseNext.ts (flat — App Router response+crypto)\nsrc/lib/securityHelpers/apiResponse.ts (subfolder — Pages Router response+crypto)\nsrc/lib/securityHelpers/cryptoHelpers.ts (subfolder)\nsrc/lib/middlewares/withAuth.ts (subfolder, plural-noun name)\nsrc/lib/middlewares/withRoles.ts (subfolder, plural-noun name)\nsrc/lib/auth/auth.ts (subfolder, bare short noun)\nsrc/lib/auth/client.ts (subfolder, bare short noun)\n+ 13 more files flat at lib/ root (rateLimit.ts, health.ts, email.ts, couponsDb.ts, ...)", + "what": "Three different subfolder-naming schemes coexist for the same kind of grouping decision: middlewares/ (plural, generic), securityHelpers/ (camelCase compound describing content), auth/ (bare short noun) — plus 13 files sitting flat at lib/ root with no folder. The one concern that spans both a subfolder AND the flat root is API-response formatting itself: securityHelpers/apiResponse.ts (subfolder) and apiResponseNext.ts (flat) do the identical job for the two router generations. No README or comment anywhere in src/lib establishes which new helpers get a subfolder vs. sit flat, or which subfolder name to use.", + "why_it_matters": "v5 AI-session-hygiene: a name/location should tell an agent what something does without digging through other files. Deciding where a 23rd lib file belongs is currently a coin flip, and the fossil subtree (DD4-1) shows what that ambiguity compounds into: securityHelpers/ is 2/3 dead, middlewares/ is 3/3 dead, and folder membership gives no signal of aliveness.", + "severity": "Medium", + "confidence": 0.85, + "verified": true, + "survived_adversarial_review": null, + "fix_direction": "Pick one taxonomy (recommend: flat lib/ for standalone helpers, subfolders only for a cohesive 3+-file concern — auth/ already qualifies) and move files to match in one pass; delete securityHelpers/ once DD4-1's dead files are removed (only decryptJsonData.ts + the alive half of cryptoHelpers.ts remain — merge into one file or move flat).", + "effort": "S", + "category": "structure/conventions" + }, + { + "id": "DD4-6", + "location": "apps/caramel-app/src/lib/couponsDb.ts:9-12", + "quote": "const connectionString = process.env.COUPONS_DATABASE_URL\nif (!connectionString) {\n throw new Error('COUPONS_DATABASE_URL is not set')\n}", + "what": "Three different strategies for 'a required env var is missing' coexist across lib, each invented locally: couponsDb.ts throws synchronously at MODULE IMPORT time (fails boot, correct per v5); auth/auth.ts's secret field uses an inline IIFE — `process.env.BETTER_AUTH_SECRET || process.env.JWT_SECRET || (() => { throw new Error(...) })()` (auth.ts:121-128) — which also runs at import time (object-literal evaluation isn't lazy) but reads as a runtime fallback rather than a boot check; email.ts's getClient() throws only when sendEmail() is first CALLED (email.ts:11-16), potentially hours after deploy, the first time a user verifies an email. None goes through a shared validated schema: no env.ts/env.mjs exists anywhere under apps/caramel-app/src, no zod import exists in src (grepped, zero matches), and .env.example doesn't list API_ENCRYPTION_ENABLED / NEXT_PUBLIC_API_ENCRYPTION_ENABLED despite both being load-bearing (DD4-4).", + "why_it_matters": "v5 Typing & contracts: '.env is zod-validated before boot... Boot must fail fast on a bad env, not debug-loop at runtime.' Whether a missing env var is caught at boot, at first-use, or silently ignored today depends entirely on which file the original author was in — the email path can ship, pass every request that doesn't send an email, and only fail on the first real signup.", + "severity": "Medium", + "confidence": 0.82, + "verified": true, + "survived_adversarial_review": null, + "fix_direction": "Add one zod-validated env module (parsed once at startup) listing every required var with a clear failure on anything missing/malformed; point .env.example at the same vocabulary; thin each lib file's ad-hoc check down to consuming the already-validated env object.", + "effort": "M", + "category": "typing/env-validation" + } +] +``` diff --git a/audit/empirical-3am.md b/audit/empirical-3am.md new file mode 100644 index 0000000..556006e --- /dev/null +++ b/audit/empirical-3am.md @@ -0,0 +1,211 @@ +# Empirical 3AM Incident Walkthrough — caramel + +**Run:** 3AM-INCIDENT empirical test · dev @ `537547b3081aa3a0ec817cdc5f6dac4f0d328dbb` · READ-ONLY, no prod hits, paper exercise against repo contents only. + +**Scenario:** 3am. Users report the extension finds no coupons on ANY store since ~an hour ago; some report the web app's `/coupons` page is empty. Cold on-call engineer, repo access, prod-dashboard access ONLY if the repo says where. + +Scope confirmed against `audit/exclusions.md` before starting; nothing quoted below falls inside an excluded path. + +--- + +## Step 1 — Where does the repo tell you to look first? + +Nowhere. An exhaustive search of the 405 tracked files for anything named `runbook`, `incident`, `oncall`/`on-call`, or `health*` (outside `node_modules`) returns **zero** hits except one file: `apps/caramel-app/src/app/api/health/db/route.ts`. There is no `RUNBOOK.md`, no incident-response doc, no on-call doc, no architecture doc, no status-page reference, no link to a dashboard. + +The closest thing to ops documentation is the root `README.md`, and its CI/CD section is one line: + +``` +README.md:70-72 +## CI/CD + +The project uses GitHub Actions for CI/CD. The workflow is defined in `.github/workflows/`. +``` + +That's it — no mention of where the app is deployed, how to check its status, or what to do when it's down. `local-dev/LOCAL-DEV.md` exists but is scoped entirely to local dev ports/compose, not production. + +The one health endpoint that exists is gated behind an undocumented secret (`UPKUMA_HEALTH_SECRET` — see AM-3), so even finding it in source doesn't let a cold on-call engineer call it. **A cold engineer's only real option, using solely what the repo provides, is to start reading `apps/caramel-app/src/app/api/coupons/**` source code from scratch at 3am\*\* — there is no faster path the repo hands them. + +--- + +## Step 2 — Trace: extension request → API route → DB, and what evidence each hop leaves + +**Hop 1 — extension → background service worker.** `popup.js` (and the auto-insert path in `background.js`) call the shared `fetchCoupons()` helper, which delegates the actual network call to the extension's background/service-worker context via `runtime.sendMessage`: + +``` +apps/caramel-extension/background.js:177-204 +} else if (message.action === 'fetchCoupons') { + const { site, kw, category } = message + const url = new URL(caramelUrl('api/coupons')) + url.searchParams.set('site', site) + ... + fetchWithTimeout(url.toString()) + .then(async r => { + if (!r.ok) return { coupons: [] } + const json = await r.json() + return { coupons: Array.isArray(json) ? json : json.coupons || [] } + }) + .then(resp => sendResponse(resp)) + .catch(err => sendResponse({ coupons: [], error: String(err) })) +``` + +Evidence left behind on the client for a non-2xx response (500, 503, 429, etc.): **none.** No `console.error`, no dev-gated `log()` call, nothing — `if (!r.ok) return { coupons: [] }` is the entire handling. A genuine backend outage and a genuine "no coupons for this site" are byte-for-byte the same object returned to the caller. Only a hard network-level exception (DNS failure, timeout, connection refused) reaches the `.catch`, which at least tags `error: String(err)` — but even that is only surfaced through `log()`, which is a no-op in every production (Web Store / signed) install (Step 5). + +**Hop 2 — API route → coupons DB.** `apps/caramel-app/src/app/api/coupons/route.ts` queries a _separate_ Postgres database via `couponsSql` (not the Prisma client used for auth): + +``` +apps/caramel-app/src/lib/couponsDb.ts:3-11 +// Read-only connection to the `caramel_coupons` database owned by the +// Python verification service. All mutations to the coupon catalog flow +// through that service — Next.js only reads (plus two narrow mutations: +// usage-increment and expire, both exposed to the extension). +import postgres from 'postgres' + +const connectionString = process.env.COUPONS_DATABASE_URL +if (!connectionString) { + throw new Error('COUPONS_DATABASE_URL is not set') +} +``` + +On failure, the route's only trace is: + +``` +apps/caramel-app/src/app/api/coupons/route.ts:120-125 +} catch (error) { + console.error('Error fetching coupons:', error) + return NextResponse.json( + { error: 'Error fetching coupons.' }, + { status: 500 }, + ) +} +``` + +This is one of **19** distinct `console.error(...)` call sites across `apps/caramel-app/src/app/api/**`, each with its own free-text message, no shared schema, no request/correlation ID, no severity tag. The real Postgres error (e.g. connection-refused, timeout, auth failure) _is_ in that string — but it only exists as unstructured stdout, reachable solely by opening the Dokploy container-log viewer (not referenced anywhere in the repo) and free-text-searching 19 unrelated call sites for the right one. Nothing routes it anywhere proactive. + +**Hop 3 — web app's own `/coupons` page.** This is client-fetched (`'use client'`), and unlike the extension it _does_ distinguish HTTP failure from empty result: + +``` +apps/caramel-app/src/components/coupons/coupons-section.tsx:122-124 +const res = await fetch(`/api/coupons?${params}`) +if (!res.ok) { + throw new Error(`Request failed with status ${res.status}`) +} +``` + +...caught below and surfaced as `toast.error('Failed to load coupons')` + `console.error`. So the aggregate `/coupons` page is strictly better-instrumented than the extension for the exact same outage — but the per-store SSR page (`apps/caramel-app/src/app/(marketing)/coupons/[store]/page.tsx:118`) calls `fetchStoreCoupons()` → `couponsSql` **with no try/catch at all**, so a DB failure there throws all the way to Next's default error boundary (there is no local `error.tsx` or `global-error.tsx` anywhere in the app — confirmed by glob). Three surfaces, three different failure presentations, for the identical outage: extension = false "unsupported site", aggregate page = toast + empty grid, per-store page = raw framework error page. + +--- + +## Step 3 — Can you tell DB outage vs. bad deploy vs. API contract break, using only repo-provided signals? + +**No, not reliably.** Concretely: + +- **No build/version signal.** There is no `/api/version`, no exposed git SHA or build ID, no Sentry release-creation step in any workflow. On-call cannot ask the running app "what commit are you," so "was this a deploy an hour ago" can only be answered by cross-referencing GitHub commit timestamps against Dokploy's own deploy history — external to the repo, and imprecise since Dokploy's build queue time ≠ commit time. +- **No deploy pipeline in the repo at all.** All three workflows (`checks-app.yml`, `checks-extension.yml`, `release-extension.yml`) are CI-only — lint/type-check/build/Playwright+Argos E2E for the app, packaging for the extension. None deploys anywhere, none references Dokploy, none runs a post-deploy smoke test. A grep for `dokploy` (case-insensitive) across the entire tracked tree returns **zero** hits. So "bad deploy" as a hypothesis can't even be confirmed/ruled out from the repo — there's no CI step that would have caught a broken coupons-DB contract before it shipped, and no log of what "deploy" even means for this app. +- **API contract break is plausible and would look identical to a DB outage.** `couponsSql` typed rows (`CouponRow`, `Row` in `supported-stores/route.ts`) are hand-maintained against a DB schema this repo does not own or version (Prisma's `schema.prisma` only models `Account`/`Session`/`Verification`/`User` — no `Coupon` model; the coupons schema lives entirely in the separately-owned Python verification service, invisible here). A column rename or type change on that side throws inside the same try/catch as a real connection failure, producing the identical generic `{ error: 'Error fetching coupons.' }` / 500. The repo gives no way to distinguish "DB is down" from "DB is up but the query is now wrong" other than reading the raw exception string in container logs (see Step 2). +- **The one health check that exists checks the wrong database** (AM-2) — so even the most basic instinct, "check the health endpoint," produces a false-positive "database: ok" while the coupons DB the incident is actually about could be completely unreachable. + +--- + +## Step 4 — What's the rollback story the repo documents? + +**None.** There is no rollback documentation, no rollback script, and no deploy workflow to roll back _from_ — the repo shows no evidence of how `grabcaramel.com` / `dev.grabcaramel.com` actually gets new code (Dokploy presumably auto-deploys on git push, per the task's own framing, but that wiring lives entirely in the Dokploy dashboard, outside this repo). `apps/caramel-app/nixpacks.toml` only defines the build/start recipe: + +``` +apps/caramel-app/nixpacks.toml:1-14 +[phases.setup] +nixPkgs = ["nodejs_20", "pnpm"] +[phases.install] +cmds = ["pnpm install --no-frozen-lockfile"] +[phases.build] +cmds = ["pnpm run build"] +[start] +cmd = "pnpm run start" +``` + +No container healthcheck directive (contrast with `local-dev/docker-compose.yml`, which _does_ declare `healthcheck:` blocks for its Postgres/Redis services — the discipline exists for local dev infra but was never extended to the app's own deploy config, so Dokploy has no automatic "this container is unhealthy, don't route to it / restart it" signal for the app itself). + +For the **extension**, rollback is even harder and the repo says nothing about it: Chrome/Firefox/Edge releases go through store review (no instant kill switch), and a grep for any remote-config / feature-flag / kill-switch mechanism in `apps/caramel-extension` returns nothing. If a bad extension version ships, the only lever is a new store submission and waiting for review — nothing in the repo acknowledges this or documents a mitigation (e.g., a server-side capability flag the backend could use to make old extension versions degrade gracefully). + +--- + +## Step 5 — Would Sentry have the trace? Does the extension's failure surface anywhere? + +**App side: Sentry is genuinely wired for _unhandled_ errors, but the coupons failure path never reaches it.** `instrumentation.ts` wires Next's request-error hook to Sentry: + +``` +apps/caramel-app/src/instrumentation.ts:12 +export const onRequestError = Sentry.captureRequestError +``` + +This fires for exceptions that escape a route handler unhandled. But `/api/coupons`, `/api/coupons/increment`, `/api/extension/supported-stores`, `/api/coupons/stats`, `/api/coupons/filters`, `/api/coupons/stores` — every coupons-adjacent route — catches its own error and returns a normal `NextResponse.json(..., {status: 500})` (Step 2 quote). That is a _handled_ response, not an unhandled exception, so `onRequestError` never fires and Sentry never sees it. A full-text search of `apps/caramel-app/src` for `captureException` / `Sentry.` outside the config/instrumentation files themselves returns **zero** call sites — nowhere in the app does a catch block explicitly forward to Sentry. The one code path that _would_ actually reach Sentry is the per-store SSR page (`[store]/page.tsx`), precisely because it has no local try/catch (Step 2) — an accident of omission, not a deliberate design choice, and it only covers one of three affected surfaces. + +Client-side (browser) Sentry is also initialized with session replay: + +``` +apps/caramel-app/src/instrumentation.client.ts:6-26 +Sentry.init({ + dsn, + integrations: [ Sentry.replayIntegration({ ... }) ], + tracesSampleRate: 1, + replaysSessionSampleRate: 0.1, + replaysOnErrorSampleRate: 1.0, + ... +}) +``` + +But `coupons-section.tsx`'s catch block only does `toast.error(...)` + `console.error(...)` — no `Sentry.captureException`, no rethrow — so this well-configured "replay on error" setup never actually triggers for this exact failure either. The DSN and self-hosted Sentry URL (`sentry.devino.ca`, `org: devino`, `project: caramel` in `next.config.mjs:50-52`) are the one genuinely useful, discoverable-from-repo signal — if this event ever reached it, on-call would know where to look — but for this specific incident it won't have anything, so an on-call engineer who does check Sentry first (correctly, per the infra that exists) will find a clean dashboard and can be misled into ruling out the backend entirely. + +**Extension side: no error reporting exists at all.** There is no Sentry (or any) SDK in `apps/caramel-extension` — a grep for `sentry` (case-insensitive) across the whole extension returns zero hits. The only "telemetry" is `recordTiming()`, and it never leaves the device: + +``` +apps/caramel-extension/shared-utils.js:52-65 +var recordTiming = (event, meta = {}) => { + try { + const entry = { event, t: performance.now(), meta } + if (currentBrowser && currentBrowser.storage && currentBrowser.storage.local) { + currentBrowser.storage.local.get(['caramel_timings'], res => { + const arr = (res && res.caramel_timings) || [] + arr.push(entry) + currentBrowser.storage.local.set({ caramel_timings: arr }) + }) + } + } catch (e) { /* ignore storage errors */ } +} +``` + +And even the local `log()` calls that _would_ narrate the failure (`log('fetchCoupons background error', resp.error)`, `log('Fetched', d.length, 'coupons')`) are compiled to a no-op in every real user's browser: + +``` +apps/caramel-extension/shared-utils.js:48-50 +var log = _isDevInstall() + ? (...a) => console.log('Caramel:', ...a) + : () => {} +``` + +`_isDevInstall()` is true only for unpacked/dev installs (no `update_url` in the manifest) — i.e. **false for every Chrome Web Store / AMO / App Store user actually affected tonight.** So: no crash reporting, no remote telemetry, and even manual console inspection by a technical end user yields nothing on the HTTP-error path. The failure is, for all practical purposes, invisible outside of aggregate user complaints reaching support. The one place it _is_ user-visible is the UI text itself, and it actively points the wrong direction: + +``` +apps/caramel-extension/popup.js:151 +

No coupons for this site yet

+``` + +— shown identically whether the site genuinely has no coupons or the entire backend is down. + +--- + +## CANDIDATE FINDINGS + +{"id":"AM-1","location":"README.md:70-72","quote":"The project uses GitHub Actions for CI/CD. The workflow is defined in `.github/workflows/`.","what":"No runbook, incident-response doc, on-call doc, or dashboard pointer exists anywhere in the 405 tracked files.","why_it_matters":"Cold on-call has zero repo-provided starting point at 3am; must reverse-engineer the whole system from source under pressure.","severity":"High","confidence":0.95,"fix_direction":"Add a RUNBOOK.md covering: services/DBs, dashboards (Dokploy/Sentry URLs), health checks, common failure signatures, escalation.","effort":"M","category":"operability"} +{"id":"AM-2","location":"apps/caramel-app/src/app/api/health/db/route.ts:9-11","quote":"const result = await timedCheck('database', async () => {\n await prisma.$queryRaw`SELECT 1`\n })","what":"The only health endpoint checks the Prisma/auth DB; it never touches `couponsSql`/`COUPONS_DATABASE_URL`, the DB actually serving coupons.","why_it_matters":"During exactly this incident, the health check would report 200 'ok' while the real broken dependency is untested — a false-negative that actively misdirects on-call.","severity":"Critical","confidence":0.95,"fix_direction":"Add a second timedCheck against couponsSql (SELECT 1) and report both services independently from /api/health.","effort":"S","category":"operability"} +{"id":"AM-3","location":"apps/caramel-app/src/lib/health.ts:31-36","quote":"export function authorize(request: Request): boolean {\n const secret = process.env.UPKUMA_HEALTH_SECRET\n if (!secret) return false\n const auth = request.headers.get('authorization') || ''\n return auth === `Bearer ${secret}`\n}","what":"UPKUMA_HEALTH_SECRET gates the health endpoint but appears nowhere else in the repo — not in .env.example, not in CI, not in any doc.","why_it_matters":"Even a cold engineer who finds the health route by reading source has no repo-given way to obtain or know about the secret needed to call it.","severity":"Medium","confidence":0.9,"fix_direction":"Document the secret's purpose/owner in .env.example (name only, no value) and in the runbook (which monitor calls it, where).","effort":"S","category":"operability"} +{"id":"AM-4","location":"apps/caramel-app/src/lib/couponsDb.ts:9-12","quote":"const connectionString = process.env.COUPONS_DATABASE_URL\nif (!connectionString) {\n throw new Error('COUPONS_DATABASE_URL is not set')\n}","what":"COUPONS_DATABASE_URL is required but absent from .env.example, ci-env.ts, and local-dev/docker-compose.yml (which only provisions the auth Postgres + Redis).","why_it_matters":"The DB actually implicated in this incident cannot be provisioned, inspected, or even discovered locally from this repo; CI never exercises this code path either.","severity":"High","confidence":0.95,"fix_direction":"Document COUPONS_DATABASE_URL in .env.example and add a coupons-db stub (or clear pointer to the owning Python service repo) to local-dev.","effort":"M","category":"operability"} +{"id":"AM-5","location":"apps/caramel-app/src/app/api/coupons/route.ts:120-125","quote":"} catch (error) {\n console.error('Error fetching coupons:', error)\n return NextResponse.json(\n { error: 'Error fetching coupons.' },\n { status: 500 },\n )\n }","what":"Errors are caught and converted to a normal JSON response, so Next's onRequestError/Sentry.captureRequestError hook never fires; no captureException call exists in this or any sibling coupons route.","why_it_matters":"Sentry is fully wired for unhandled errors app-wide, yet the exact route implicated tonight is structurally invisible to it — zero alert, zero trace, despite working observability infra.","severity":"Critical","confidence":0.9,"fix_direction":"Call Sentry.captureException(error) in every coupons-route catch block, or centralize via a shared handler wrapper.","effort":"S","category":"operability"} +{"id":"AM-6","location":"apps/caramel-extension/background.js:191-193","quote":"fetchWithTimeout(url.toString())\n .then(async r => {\n if (!r.ok) return { coupons: [] }","what":"Any non-2xx response from /api/coupons (500, 503, 429...) is silently converted to an empty coupons array with no logging at all, not even a dev-gated log() call.","why_it_matters":"This is the exact mechanism of the reported symptom: a full backend outage is indistinguishable, at every layer above this line, from 'this store legitimately has no coupons.'","severity":"Critical","confidence":0.95,"fix_direction":"Preserve status/error info in the response (e.g. {coupons:[], error:`HTTP ${r.status}`}) so callers can distinguish failure from empty, matching the fetchSupportedStores/classifyCart handlers' partial pattern.","effort":"S","category":"operability"} +{"id":"AM-7","location":"apps/caramel-extension/popup.js:151","quote":"

No coupons for this site yet

","what":"The UI shown for a swallowed backend failure is identical to the UI shown for a genuinely unsupported site — actively telling the user the wrong thing.","why_it_matters":"Users experiencing a total outage are told 'we don't support this store' rather than 'something's wrong,' suppressing the very complaints that would otherwise triangulate the incident faster.","severity":"High","confidence":0.9,"fix_direction":"Branch on the error flag from AM-6 to show the existing renderLoadError() ('Couldn't load coupons') state instead of renderUnsupportedSite() when the failure was backend-side.","effort":"M","category":"operability"} +{"id":"AM-8","location":"apps/caramel-extension/shared-utils.js:48-50","quote":"var log = \_isDevInstall()\n ? (...a) => console.log('Caramel:', ...a)\n : () => {}","what":"All log() calls (including the one error-path log for fetchCoupons network exceptions) are compiled to a no-op whenever the extension is installed from a store (update_url present) — i.e. for every real user.","why_it_matters":"Even a technical affected user opening devtools to help support debug gets nothing; combined with AM-6/AM-7, the failure leaves no trace on-device or off-device.","severity":"High","confidence":0.95,"fix_direction":"Keep warn/error-level logs active in production builds (only gate verbose/debug logs on dev), or add a minimal opt-in diagnostic report the user can copy into a support ticket.","effort":"S","category":"operability"} +{"id":"AM-9","location":".github/workflows/checks-app.yml (whole file, and release-extension.yml/checks-extension.yml)","quote":"# workflow names present: checks-app.yml, checks-extension.yml, release-extension.yml — none contains 'deploy', 'dokploy', 'rollback', or 'smoke'","what":"No deploy workflow, post-deploy smoke test, or rollback automation/documentation exists anywhere in the repo; grep for 'dokploy' across all tracked files returns zero hits.","why_it_matters":"Repo alone gives no way to confirm a deploy happened an hour ago, what changed, or how to revert it — the entire rollback story lives outside version control.","severity":"High","confidence":0.9,"fix_direction":"Document the Dokploy deploy trigger/URL and rollback steps in a runbook; add a post-deploy smoke test hitting /api/coupons and /api/health/\*.","effort":"L","category":"operability"} +{"id":"AM-10","location":"apps/caramel-app/src/lib/securityHelpers/apiResponse.ts:1-2","quote":"import { NextApiRequest, NextApiResponse } from 'next'","what":"apiResponse.ts and errorMiddleware.ts are Pages-Router-style helpers with zero imports anywhere in the App-Router-only src tree (no pages/ dir exists) — dead code.","why_it_matters":"No shared error/response convention exists for the router style actually in use, so every route hand-rolls its own catch block (19 distinct console.error sites) with no single place to fix observability once.","severity":"Medium","confidence":0.85,"fix_direction":"Delete the dead Pages-Router helpers; introduce one App-Router error-response helper that all route.ts catch blocks funnel through (also fixes AM-5 in one place).","effort":"M","category":"code_health"} +{"id":"AM-11","location":"apps/caramel-extension/background.js:23,191","quote":"const EXTENSION_API_KEY = 'WXqEpm2uOV5jjJXPpnQFyZiNdaPVUrtd2LIrf4kc1JA'","what":"The extension's API key is hardcoded plaintext in shipped JS, yet the highest-volume call (fetchCoupons → /api/coupons) never sends it, so it never gets the extension rate-limit bypass the server code implies it should.","why_it_matters":"If this well-known-extractable key is ever rotated (e.g. treated as a leaked secret), supported-stores silently 401s for 100% of installs with zero alarm (AM-6 pattern); separately, shared/NAT IPs can rate-limit legitimate extension traffic on the exact endpoint in this incident.","severity":"Medium","confidence":0.65,"fix_direction":"Send x-api-key on the /api/coupons fetch too; document key-rotation blast radius and add a rotation runbook entry.","effort":"S","category":"operability"} +{"id":"AM-12","location":"apps/caramel-app/src/app (no error.tsx or global-error.tsx present)","quote":"(absence confirmed via glob: apps/caramel-app/src/app/**/error.tsx and global-error.tsx both match zero files)","what":"No custom error boundary exists anywhere in the App Router tree, so the one code path that throws uncaught (the per-store SSR page) falls back to Next's default generic error page.","why_it_matters":"Produces a third, inconsistent failure presentation for the same outage (vs. the extension's false-empty-state and the aggregate page's toast), with no branded messaging or built-in Sentry-context enrichment.","severity":"Medium","confidence":0.9,"fix_direction":"Add app/error.tsx and app/global-error.tsx with Sentry.captureException + a branded honest-failure message.","effort":"M","category":"code_health"} +{"id":"AM-13","location":"apps/caramel-app/next.config.mjs:57","quote":"automaticVercelMonitors: true","what":"Sentry's Next.js plugin is configured to auto-create Vercel Cron Monitors, but the app is deployed via Dokploy per the operating context, not Vercel.","why_it_matters":"Suggests the Sentry config was copied from Vercel-oriented setup docs without adaptation to the actual deploy target — a signal that ops config drifts from real infra and shouldn't be trusted at face value during an incident.","severity":"Low","confidence":0.5,"fix_direction":"Confirm actual deploy target and remove/replace automaticVercelMonitors with the correct monitor mechanism for Dokploy-scheduled jobs, if any exist.","effort":"S","category":"code_health"} +{"id":"AM-14","location":"apps/caramel-app/src/app/api/**/route.ts (19 call sites)","quote":"console.error('Error fetching coupons:', error) / console.error('Failed to fetch coupon stats:', error) / console.error('Failed to load store options:', error) [representative sample of 19 sites]","what":"Every API route logs errors as free-text console.error with its own ad hoc message; no structured logging, error codes, or request-correlation ID anywhere in the app.","why_it_matters":"Even with raw log access (Dokploy dashboard), diagnosing this incident means free-text-searching 19 unrelated, differently-worded call sites with no way to correlate a single request across hops.","severity":"Medium","confidence":0.9,"fix_direction":"Adopt one structured logger (JSON, with route/request-id/error-code fields) used consistently across all route handlers.","effort":"M","category":"code_health"} diff --git a/audit/empirical-change-trace.md b/audit/empirical-change-trace.md new file mode 100644 index 0000000..a3d4d74 --- /dev/null +++ b/audit/empirical-change-trace.md @@ -0,0 +1,225 @@ +# CHANGE-TRACE: Empirical Coupling Test + +**Repo**: caramel (dev @ 537547b3081aa3a0ec817cdc5f6dac4f0d328dbb) +**Date**: 2026-07-10 +**Method**: For each invented feature, trace the exact files/modules that must change to ship it, using only what the code actually does today (verified by reading every touched file — no assumption carried over from naming alone). Coupling and duplication claims are backed by verbatim quotes with file:line. `audit/exclusions.md` honored (migrations, lockfiles, binaries, `.git` excluded from grading; their _existence_ as artifacts is still noted where relevant). + +--- + +## SMALL — "Add an expiration-date badge next to each coupon on the app's coupons page" + +### (a) Exact file list + +| File | Role | +| --------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | +| `apps/caramel-app/src/components/coupons/coupon-card.tsx` | Renders one coupon card (discount badge, title, description, usage count, verification badge, copy button). Add the new expiry badge here. | + +That's the entire list. No type file, no API route, no SQL query needs to change. + +**Why**: `coupon.expiry` already exists end-to-end before this feature is written: + +- Type already declared — `apps/caramel-app/src/types/coupon.ts:21`: `expiry: string` +- Already selected by the browse-page API — `apps/caramel-app/src/app/api/coupons/route.ts:93-96`: + ``` + SELECT id, code, site, title, description, rating, + discount_type, discount_amount, expiry, expired, + times_used AS "timesUsed", + ``` +- Already selected by the SSR store-page query — `apps/caramel-app/src/app/(marketing)/coupons/[store]/page.tsx:50-52` (identical column list). +- `coupon-card.tsx` currently never reads `coupon.expiry` at all (confirmed by full read — it only touches `discount_amount`, `title`, `description`, `timesUsed`, `status`, `code`). +- `coupons-section.tsx` passes the whole `coupon` object through as a prop (``, line 330-334) without touching individual fields, so no intermediate file is in the path. +- No date-formatting library exists in the app (`grep -l 'date-fns\|dayjs\|moment' package.json` → 0 matches), so the badge would format the ISO string inline — a code-quality note, not a coupling one. + +### (b) File count + module count + +**1 file, 1 module** (`components/coupons`). + +### (c) Unrelated code dragged in + +None. Both fetch paths that feed this component already carry the field; nothing outside `coupon-card.tsx` needs to move. + +### (d) Duplication tax + +None identified. `CouponCard` has exactly one render site (`coupons-section.tsx`'s `.map()`); there is no second hand-rolled coupon-list renderer in the app to keep in sync. + +### (e) Change isolation score: **9/10** + +Essentially a single-file, single-component change with the data already flowing to it. The one point held back: `coupon.expiry` is dual-purpose in the data model — `apps/caramel-app/src/app/api/coupons/expire/route.ts:51-53` overwrites it to `NOW()::text` when a coupon is administratively killed (`expiry = NOW()::text` alongside `expired = TRUE`). That's a latent semantic trap for whoever writes the badge (not a file-coupling problem — `expired` rows are already excluded from every visible query — but a maintainer reading only the column name could render a killed coupon's kill-timestamp as if it were a real future expiry, if the `expired` filter were ever dropped from a query). + +--- + +## MEDIUM — "Show the user's lifetime total savings (sum across all sessions/applies) on the account/profile page" + +### (a) Exact file list + +| File | Role | +| ------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `apps/caramel-app/prisma/schema.prisma` | Add a new model (e.g. `CouponApply { id, userId, site, amountSaved, appliedAt }`) + a relation array on `User`. **Nothing today persists a coupon-apply event anywhere.** | +| `apps/caramel-app/prisma/migrations//` (generated) | Migration artifact of the schema change above (excluded from content-grading per `exclusions.md`; its creation is still mandatory). | +| `apps/caramel-app/src/app/api/extension/coupon-applied/route.ts` (**new**) | Authenticated POST the extension calls after a real apply, to write one `CouponApply` row. | +| `apps/caramel-app/src/app/api/account/savings/route.ts` (**new**, illustrative name) | Authenticated GET that sums `CouponApply.amountSaved` for the signed-in user, for the profile page to call. | +| `apps/caramel-app/src/app/profile/ProfilePageClient.tsx` | Add the "Lifetime savings" stat block; currently renders only `name`/`email`/`firstName`/`lastName`/`username` (full read confirms no savings/activity field exists). | +| `apps/caramel-extension/shared-utils.js` | The apply-attempt loop (~lines 1327-1473) computes `bestSave`/`bestCode` at the moment of success; must dispatch a new message with that outcome. | +| `apps/caramel-extension/background.js` | Needs a new message handler that reads the stored auth token and POSTs to the new endpoint — **this file currently never reads `storage.sync` at all** (verified: 0 matches for `storage.` in `background.js`). | + +### (b) File count + module count + +**7 files** (1 schema + 1 migration artifact + 2 new app routes + 1 profile UI + 2 extension files) across **5 modules**: Prisma data layer, app API layer, profile UI, extension apply-flow, extension background/messaging. + +### (c) Unrelated code dragged in + +**Edge 1 — the only existing "Session" concept is an auth artifact, not a usage-activity log.** A developer reaching for the word "session" (as the feature literally does — "sum across all their sessions/applies") finds this instead: + +``` +model Session { + id String @id @default(cuid()) + expiresAt DateTime @map("expires") + token String @unique @map("session_token") + createdAt DateTime @default(now()) +``` + +`apps/caramel-app/prisma/schema.prisma:30-34` — token/expiry/IP fields for better-auth login sessions, unrelated to coupon activity. Building "lifetime savings" on/near this model would conflate auth state with product analytics. + +**Edge 2 — the only "require a logged-in user" abstraction is dead and framework-incompatible**, so the new authenticated routes get no reuse: + +``` +import { NextApiRequest, NextApiResponse } from 'next' +``` + +`apps/caramel-app/src/lib/middlewares/withAuth.ts:2` — a Pages-Router signature (`NextApiRequest`/`NextApiResponse`). Every real route in this repo is an App-Router handler, e.g. `apps/caramel-app/src/app/api/coupons/increment/route.ts:3`: `import { NextRequest, NextResponse } from 'next/server'`. Confirmed by repo-wide grep: `withAuth`/`withRoles` are imported by **zero** files outside their own definitions. The two new routes must hand-roll `auth.api.getSession(...)` from scratch. + +**Edge 3 — the extension's identity state is popup-only today.** `storage.sync` (`token`, `user`) is read only by `popup.js` (5 call sites) and written once by `shared-utils.js`'s cross-origin login listener (line 1508); `background.js` touches it never. Piping an authenticated call through the content-script → background.js relay (the codebase's only existing outbound-fetch pattern, e.g. `fetchCoupons` in `background.js:177-204`) means teaching `background.js` to branch on guest-vs-logged-in for the first time — logic that today only exists inside `popup.js`'s `renderCouponsView` (guest vs. `@username` header, lines 571-587). + +### (d) Duplication tax + +The mutation-route boilerplate is already triplicated verbatim and the new authenticated endpoint becomes a 4th, harder copy (it also needs a session check none of the three have): + +``` +if (!isOriginAllowed(req)) return forbiddenOrigin() +const limited = await checkRateLimit(req, 'mutation') +``` + +`apps/caramel-app/src/app/api/coupons/increment/route.ts:13-14` — identical to `apps/caramel-app/src/app/api/sources/route.ts:73-74`, and to `apps/caramel-app/src/app/api/coupons/expire/route.ts:13` (+ line 21 for the rate-limit call, split by an API-key check). No shared "guarded mutation handler" helper exists — each route re-states the pair. The new `coupon-applied` route repeats this pattern _and_ invents session-checking fresh, because (per Edge 2) there is nothing to call. + +### (e) Change isolation score: **3/10** + +Sounds like "add a number to a page"; is actually "invent the entire persistence + auth-plumbing pipeline for user-tied product activity, from a standing start, across both apps." The low score is driven by the _absence_ of foundational plumbing (no user↔coupon-activity link, no reusable auth-check, no extension→server authenticated-write path), not by raw file count. + +--- + +## CROSS-CUTTING — "Track per-store coupon success rate, persist it, rank extension try-order by it, surface it in app + extension popup" + +### (a) Exact file list + +| File | Role | +| -------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `apps/caramel-app/src/lib/couponsDb.ts` | Shared raw-SQL row type (`CouponRow`) for the `coupons` table. Add `attempts`/`success_rate`-shaped fields. | +| `apps/caramel-app/src/types/coupon.ts` | Independently-maintained frontend mirror of the same row shape (`Coupon` interface). Must add the matching field(s) _again_. | +| `apps/caramel-app/src/app/api/coupons/route.ts` | The one GET endpoint used by **both** the app's browse page **and** the extension's `fetchCoupons` relay. Must SELECT the new field and reweight `ORDER BY` by success rate. | +| `apps/caramel-app/src/app/(marketing)/coupons/[store]/page.tsx` | SSR store-page query. Duplicates route.ts's SELECT/ORDER BY today; must change in lockstep or the store page and the browse page disagree on ranking. | +| `apps/caramel-app/src/app/api/coupons/report-attempt/route.ts` (**new**, illustrative) | Endpoint the extension calls once per tried code with the outcome. The nearest existing analog, `/api/coupons/increment`, is dead code (see (c) Edge 4) — not safely extensible, realistically net-new. | +| `apps/caramel-app/src/components/coupons/coupon-card.tsx` | App UI. Add the success-rate badge — a **3rd** distinct badge concern on this one file, alongside the pre-existing `STATUS_BADGE` map and the SMALL feature's new expiry badge. | +| `apps/caramel-extension/shared-utils.js` | The coupon-try loop (~1327-1473). Must report each attempt's outcome as it happens (mid-loop), not just a final summary. | +| `apps/caramel-extension/background.js` | New message handler relaying attempt reports to the new endpoint. | +| `apps/caramel-extension/popup.js` | Extension UI. Add the **same** success-rate badge to `renderCouponsView` (lines 568-695) — a 2nd, independently-styled copy of the app's new badge. | + +Plus, **0 in-repo files / 1 external system**: the natural home for a `site`-keyed counter is the `caramel_coupons` Postgres database (it already has `coupons.site` and is what `route.ts`/`[store]/page.tsx` query) — but per `apps/caramel-app/src/lib/couponsDb.ts:1-6`, that database is **owned by a service whose source is not in this repository** (see (c) Edge 1). This repo's Prisma never touches it (no model, no migration). + +### (b) File count + module count + +**9 in-repo files** across 2 apps, **plus a mandatory out-of-band schema dependency on a database this repo doesn't own.** +**7 modules**: coupons-DB shared types, coupons ranking/read API, new attempt-reporting API, app coupon-list UI, extension apply-flow, extension background/messaging, extension popup UI — **plus** the externally-owned Python-service schema as a de facto 8th dependency outside the repo's control. + +### (c) Unrelated code dragged in + +**Edge 1 — cross-service database ownership.** + +``` +// Read-only connection to the `caramel_coupons` database owned by the +// Python verification service. All mutations to the coupon catalog flow +// through that service +``` + +`apps/caramel-app/src/lib/couponsDb.ts:2-4`. Persisting a new per-store counter durably means either a raw-SQL change to a schema this repo can't version, coordinated with an out-of-repo service, or moving the stat into the Prisma DB and merging two separate query results in application code. + +**Edge 2 — a badge-map duplicate this feature would extend to a 3rd/4th copy.** App side: + +``` +const STATUS_BADGE: Record = { + valid: { + label: '✓ Verified', +``` + +`apps/caramel-app/src/components/coupons/coupon-card.tsx:21-23`. Extension side, same 8 keys, same labels, different color system entirely: + +``` +const BADGE = { + valid: ['✓ Verified', '#15803d', '#dcfce7'], +``` + +`apps/caramel-extension/popup.js:638-639`. "Surface the rate in both UIs" means writing a _third_ such map (Tailwind classes here, hex-color arrays there) rather than sharing one. + +**Edge 3 — naming collision with an unrelated, already-shipped `successRate`.** + +``` +const denom = r.total_used + r.total_expired +const successRate = + denom === 0 ? 0 : (r.total_used / denom) * 100 +``` + +`apps/caramel-app/src/app/api/sources/route.ts:51-53` — a _scraper-source_ hit rate (per feed, not per store), computed live and never persisted. Anyone grepping "successRate" while building the per-store, persisted, attempt-based metric this feature asks for will land here first. + +**Edge 4 — a misleading dead-code precedent.** + +``` +// 30/min — /increment, /expire, /sources POST. Extension calls +// /increment once per coupon copy, which is nowhere near this cap. +``` + +`apps/caramel-app/src/lib/rateLimit.ts:26-27`. Verified by repo-wide grep: **zero** callers of `/increment` exist anywhere in `apps/caramel-extension/**`, `local-dev/**`, or `.github/**`. `times_used` is therefore effectively always 0 in production. A developer might reasonably (and wrongly) assume attempt/usage telemetry is already flowing and try to build success-rate math on top of it. + +### (d) Duplication tax + +Ranking must change in exactly the two places that independently declare it today: + +``` +FROM coupons +WHERE ${whereClause} +ORDER BY rating DESC, created_at DESC +``` + +`apps/caramel-app/src/app/api/coupons/route.ts:97,99` — vs. + +``` +WHERE ${VISIBLE_STATUSES} + AND (site = ${base} OR site LIKE ${'%.' + base}) +ORDER BY rating DESC, created_at DESC +``` + +`apps/caramel-app/src/app/(marketing)/coupons/[store]/page.tsx:55-57`. The code's own comment (`[store]/page.tsx:43-45`) states these two queries exist "so SSR HTML and the client fetch agree (no hydration flash)" — i.e., the authors already know this pair must be kept in sync; a success-rate-weighted ORDER BY is a new value the same discipline must be applied to. + +### (e) Change isolation score: **1/10** + +The single worst-isolated of the three. It fans out through both apps' full depth (types → API → SQL ranking → UI, twice, in two different UI stacks) and — uniquely among the three — cannot be fully resolved by editing this repository alone: its natural data home is a database owned by an out-of-repo service. + +--- + +## CANDIDATE FINDINGS + +{"id":"CT-1","location":"apps/caramel-app/src/app/api/coupons/route.ts:99","quote":"ORDER BY rating DESC, created_at DESC","what":"The coupon ranking ORDER BY is written independently in two files (also apps/caramel-app/src/app/(marketing)/coupons/[store]/page.tsx:57) rather than a shared query builder.","why_it_matters":"Any future ranking change (this audit tested one: per-store success rate) must be applied twice by hand; the two UI surfaces (browse page vs. store page) will silently disagree if one edit is missed, despite the code's own comment saying they must agree.","severity":"Medium","confidence":0.9,"fix_direction":"Extract the shared WHERE/ORDER BY fragment into one couponsDb.ts helper both call sites import.","effort":"S","category":"duplication"} + +{"id":"CT-2","location":"apps/caramel-app/src/components/coupons/coupon-card.tsx:21-34","quote":"const STATUS_BADGE: Record = {\n valid: { label: '✓ Verified', cls: 'bg-green-100 text-green-700 ...' },","what":"The coupon verification-status label/color map is independently re-declared in apps/caramel-extension/popup.js:638-664 with the same 8 keys and labels but a different color representation (hex vs Tailwind classes).","why_it_matters":"Any status vocabulary change (add a status, reword a label, restyle) must be made twice, in two languages/styling systems, with no compiler or lint check tying them together — the two badges have already drifted in representation once and will drift in content eventually.","severity":"Medium","confidence":0.9,"fix_direction":"Define the status->{label,color} map once as plain JSON/data (not TS-only types) and load it from both the React component and the extension bundle.","effort":"M","category":"duplication"} + +{"id":"CT-3","location":"apps/caramel-app/src/lib/couponsDb.ts:1-6","quote":"// Read-only connection to the `caramel_coupons` database owned by the\n// Python verification service. All mutations to the coupon catalog flow\n// through that service","what":"The entire coupon catalog (code, site, rating, times_used, expiry, status) lives in a Postgres database owned by a service whose source is not in this repository; this repo's Prisma schema/migrations never model it.","why_it_matters":"Any feature that needs a new persisted, coupon-or-store-keyed field (this audit needed one for per-store success rate) requires either an out-of-band schema change to a database this repo can't version-control, or awkward two-database application-level joins.","severity":"High","confidence":0.9,"fix_direction":"Document schema-change ownership/process explicitly (who runs migrations on caramel_coupons and how this repo tracks them), or migrate store-level aggregates into the Prisma-owned DB deliberately.","effort":"L","category":"architecture"} + +{"id":"CT-4","location":"apps/caramel-app/src/lib/middlewares/withAuth.ts:2","quote":"import { NextApiRequest, NextApiResponse } from 'next'","what":"withAuth/withRoles are the only \"require a logged-in user\" helpers in the codebase, but they're written for the Next.js Pages Router (NextApiRequest/NextApiResponse) while every real route is an App Router handler (NextRequest/NextResponse, e.g. apps/caramel-app/src/app/api/coupons/increment/route.ts:3). Confirmed unused by repo-wide grep.","why_it_matters":"Any future authenticated API route (e.g. a per-user savings endpoint) has no reusable session-check helper to call and must hand-roll auth.api.getSession(...) inline, risking inconsistent 401 handling across routes.","severity":"Medium","confidence":0.85,"fix_direction":"Delete withAuth.ts/withRoles.ts if truly obsolete, or port them to the route-handler (req: NextRequest) signature so they're actually callable.","effort":"S","category":"architecture"} + +{"id":"CT-5","location":"apps/caramel-app/src/lib/rateLimit.ts:26-27","quote":"// 30/min — /increment, /expire, /sources POST. Extension calls\n// /increment once per coupon copy, which is nowhere near this cap.","what":"This comment describes the extension calling /api/coupons/increment on every coupon copy. Repo-wide grep across apps/caramel-extension, local-dev, and .github finds zero callers of this endpoint anywhere.","why_it_matters":"times_used is therefore effectively always 0 in production; any future feature (e.g. success-rate math) that assumes usage telemetry is already flowing from this endpoint will silently compute against dead data.","severity":"Medium","confidence":0.85,"fix_direction":"Either wire the extension to call /increment on real applies, or delete the endpoint/comment so the next reader doesn't trust telemetry that isn't flowing.","effort":"S","category":"clarity"} + +{"id":"CT-6","location":"apps/caramel-app/prisma/schema.prisma:56-78","quote":"model User {\n ...\n accounts Account[]\n sessions Session[]\n\n @@map(\"users\")\n}","what":"The User model has no relation to any coupon-catalog or coupon-activity data whatsoever — its only relations are auth Account/Session. There is no table anywhere recording that a specific user applied, tried, or saved money on a coupon.","why_it_matters":"Any \"my activity\" style feature (lifetime savings, apply history, favorite stores) starts from zero — there is no existing foundation, and the only similarly-named concept (Session) is an unrelated auth artifact a developer could mistakenly reach for or conflate with.","severity":"High","confidence":0.8,"fix_direction":"Introduce an explicit CouponApply (or similar) model with its own migration before building any per-user activity feature; keep it clearly separate from the auth Session model.","effort":"L","category":"architecture"} + +{"id":"CT-7","location":"apps/caramel-app/src/app/api/sources/route.ts:15","quote":"successRate: number","what":"A field named successRate already exists, computed per scraper source (total_used / (total_used + total_expired)) and never persisted — a different metric, at a different grain, than a hypothetical per-store, attempt-based, persisted success rate.","why_it_matters":"The identical name increases the chance that a future implementer conflates the two metrics, extends the wrong one, or is misled while grepping the codebase for prior art on \"success rate.\"","severity":"Low","confidence":0.7,"fix_direction":"Rename one of the two fields (e.g. sourceHitRate vs. storeApplySuccessRate) so the name is unambiguous repo-wide.","effort":"S","category":"clarity"} + +{"id":"CT-8","location":"apps/caramel-extension/background.js:1-275","quote":"currentBrowser.runtime.onMessage.addListener(\n (message, sender, sendResponse) => {\n if (!message || typeof message.action !== 'string') return","what":"background.js handles all outbound fetches (fetchCoupons, classifyCart, fetchSupportedStores) but never reads chrome.storage.sync — the token/user identity is read only by popup.js and written only by shared-utils.js's login listener.","why_it_matters":"Any feature requiring the checkout-page apply flow to act as the signed-in user (e.g. tagging a coupon-apply event with a user id) must newly teach background.js to read stored identity and branch guest-vs-logged-in, logic that today exists only inside popup.js's renderCouponsView.","severity":"Medium","confidence":0.85,"fix_direction":"Add one background.js message handler that reads storage.sync token once and attaches it to outbound requests, mirroring popup.js's guest/logged-in branch, instead of re-deriving this per feature.","effort":"M","category":"architecture"} + +{"id":"CT-9","location":"apps/caramel-app/src/app/api/coupons/increment/route.ts:13-14","quote":"if (!isOriginAllowed(req)) return forbiddenOrigin()\nconst limited = await checkRateLimit(req, 'mutation')","what":"This exact origin-check + rate-limit pair is copy-pasted verbatim across three mutation routes (also apps/caramel-app/src/app/api/coupons/expire/route.ts:13 and apps/caramel-app/src/app/api/sources/route.ts:73-74) with no shared \"guarded mutation handler\" wrapper.","why_it_matters":"Every new mutation route (this audit needed two: apply-recording and attempt-reporting) copies this boilerplate a 4th and 5th time by hand; a future change to the guard logic (e.g. adding a new bypass condition) requires finding and editing every call site.","severity":"Low","confidence":0.9,"fix_direction":"Wrap the pair in a single higher-order function (e.g. withMutationGuard(handler)) that all mutation routes call.","effort":"S","category":"duplication"} diff --git a/audit/empirical-navigation.md b/audit/empirical-navigation.md new file mode 100644 index 0000000..71e1a52 --- /dev/null +++ b/audit/empirical-navigation.md @@ -0,0 +1,188 @@ +# NAME-ONLY NAVIGATION TEST: Empirical Predictions + +**Repo**: caramel (dev @ 537547b) +**Date**: 2026-07-10 +**Test**: Can file/symbol NAMES alone let a light model navigate before reading bodies? + +--- + +## PHASE 1: Name Tree + +### File Structure Summary + +- **405 total files** in git +- Two main applications: + - `apps/caramel-app/` — Next.js app with API routes, auth, email + - `apps/caramel-extension/` — Browser extension (content scripts, background) +- **Coupon data**: Managed in separate Python service DB (per `couponsDb.ts`), read-only from Next.js + +### Key Directories + +- `apps/caramel-app/src/lib/` — Business logic (auth, coupons DB, email, LLM calls) +- `apps/caramel-app/src/app/api/` — Next.js route handlers (18 endpoints) +- `apps/caramel-extension/` — JS files for browser automation and store detection + +### Extracted Declarations (sample) + +**App exports** (`src/lib/`): + +- `nextApiResponse`, `auth`, `authClient`, `signIn`, `signUp`, `signOut` +- `classifyCart`, `sendEmail`, `chat`, `checkRateLimit`, `withAuth`, `withRoles` +- `cors`, `couponsSql`, `capitalize First`, `GA_TRACKING_ID`, `pageView` +- `base64Encode/Decode`, `encrypt/decrypt`, `isValidUrl` + +**Extension functions** (JS): + +- `show`, `fetchWithTimeout`, `execScript`, `renderCouponsView` +- `detectCouponError`, `waitForElement`, `setInputValue`, `getPrice` +- `findAppliedSelector`, `findRemoveSelector`, `_markTriedCode` + +--- + +## PHASE 2: LOCKED PREDICTIONS (before verification) + +### A. Five Behaviors — Predicted Locations + +| # | Behavior | Predicted File | Reasoning | +| --- | ----------------------------------------------------------- | ----------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- | +| 1 | **Coupon actually reduced cart total** | `apps/caramel-extension/cart-signals.js` OR `inject.js` | Extension monitors DOM price changes; logic detects if total decreased after coupon apply | +| 2 | **New coupons created/submitted to database** | `apps/caramel-app/src/app/api/coupons/route.ts` (POST) OR managed by Python service | Comment in couponsDb.ts says "mutations flow through Python service"; POST endpoint may not exist in this repo or delegates | +| 3 | **Extension detects store/site & fetches matching coupons** | `apps/caramel-extension/cart-signals.js` OR `shared-utils.js` | DOM observation + API call to `/api/coupons?site={detected}` | +| 4 | **Authentication (providers/sessions) configured** | `apps/caramel-app/src/lib/auth/auth.ts` | Exports `auth = betterAuth({...})` with provider setup | +| 5 | **Transactional emails sent** | `apps/caramel-app/src/lib/email.ts` | Exports `sendEmail = async (data: EmailPayload)` | + +### B. Ten Function Predictions + +| # | Function Name | Predicted Location | Predicted Purpose (from name + path alone) | +| --- | ------------------- | ---------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- | +| 1 | `classifyCart` | `apps/caramel-app/src/lib/cartClassifier.ts` | Analyzes page DOM to determine which e-commerce site/cart type user is on | +| 2 | `chat` | `apps/caramel-app/src/lib/openrouter.ts` | Calls OpenRouter LLM API to generate coupon descriptions or classification logic | +| 3 | `checkRateLimit` | `apps/caramel-app/src/lib/rateLimit.ts` | Enforces API rate limiting per IP/session to prevent abuse | +| 4 | `detectCouponError` | `apps/caramel-extension/cart-signals.js` | Detects if coupon code failed to apply by analyzing error messages on cart page | +| 5 | `waitForElement` | `apps/caramel-extension/cart-signals.js` OR `inject.js` | Waits for DOM element to appear before proceeding (e.g., coupon input field) | +| 6 | `withAuth` | `apps/caramel-app/src/lib/middlewares/withAuth.ts` | Wraps API handlers to enforce user authentication on protected routes | +| 7 | `sendEmail` | `apps/caramel-app/src/lib/email.ts` | Sends transactional emails (verification, notifications) via Resend or similar | +| 8 | `setInputValue` | `apps/caramel-extension/cart-signals.js` OR `inject.js` | Fills coupon code into the page's coupon input field | +| 9 | `fetchWithTimeout` | `apps/caramel-extension/background.js` | Fetches data with timeout to prevent hanging requests in extension | +| 10 | `decryptJsonClient` | `apps/caramel-app/src/lib/securityHelpers/decryptJsonClient.ts` OR direct in email | Decrypts encrypted JSON payload on client side (security tokens, sensitive data) | + +--- + +## PHASE 3: VERIFICATION & SCORING + +### Verification Results — 5 Behaviors + +| # | Behavior | Predicted | Actual | Result | Notes | +| --- | ----------------------------------------- | ---------------------------------------------------- | ------------------------------------------------------------------- | ----------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 1 | Coupon reduced cart total | `apps/caramel-extension/cart-signals.js` | `apps/caramel-extension/shared-utils.js` line 1234-1235 | **PARTIAL** | Right app/layer (extension), wrong file. Price comparison happens in `startApplyingCoupons` flow: `if (after && after.total_price < _cart0.total_price)` calculates savings. | +| 2 | New coupons submitted to DB | `apps/caramel-app/src/app/api/coupons/route.ts` POST | No POST handler; mutations via Python service | **PARTIAL** | Correct file path but misleading: couponsDb.ts explicitly says "All mutations flow through Python verification service" — the coupon catalog is read-only from Next.js. No create/submit endpoint exists. | +| 3 | Extension detects store & fetches coupons | `apps/caramel-extension/cart-signals.js` | `apps/caramel-extension/shared-utils.js` line 1011 (`fetchCoupons`) | **PARTIAL** | Right app, wrong file. Core logic centralized in `shared-utils.js` not scattered. `fetchCoupons(site, kw, category)` sends message to background (line 1021) for API call. | +| 4 | Authentication configured | `apps/caramel-app/src/lib/auth/auth.ts` | `apps/caramel-app/src/lib/auth/auth.ts` line 34 | **HIT** | `export const auth = betterAuth({...})` with Google/Apple social providers, email+password. | +| 5 | Transactional emails sent | `apps/caramel-app/src/lib/email.ts` | `apps/caramel-app/src/lib/email.ts` line 20 | **HIT** | `export const sendEmail = async (data: EmailPayload)` uses UseSend API (formerly Resend). | + +**Behavior Score: 2 HIT + 3 PARTIAL = 40% pure HIT rate, 70% with partials** + +--- + +### Verification Results — 10 Functions + +| # | Function | Predicted Location | Actual Location | Purpose (verified) | Result | +| --- | ------------------- | ------------------------------------ | ------------------------------------------- | ---------------------------------------------------------------------------------------------------- | ------- | +| 1 | `classifyCart` | `cartClassifier.ts` | Line 148, `cartClassifier.ts` | Uses LLM to classify e-commerce domain/category from cart signals (domain, title, products) | **HIT** | +| 2 | `chat` | `openrouter.ts` | Line 26, `openrouter.ts` | Calls OpenRouter LLM API with custom message/format options | **HIT** | +| 3 | `checkRateLimit` | `rateLimit.ts` | Line 87, `rateLimit.ts` | Enforces burst + minute-window rate limiting on API endpoints by IP | **HIT** | +| 4 | `detectCouponError` | `shared-utils.js` in extension | Line 637, `shared-utils.js` | Compares DOM error state before/after coupon apply; detects if code failed (checks error text regex) | **HIT** | +| 5 | `waitForElement` | `cart-signals.js` / `inject.js` | Line 157, `shared-utils.js` | Polls DOM for selector match + visibility within timeout; handles collapsed accordion reveals | **HIT** | +| 6 | `withAuth` | `middlewares/withAuth.ts` | Line 15, `middlewares/withAuth.ts` | Wraps Next.js API handler; checks session/auth token before allowing execution | **HIT** | +| 7 | `sendEmail` | `email.ts` | Line 20, `email.ts` | Sends email via UseSend API (verification emails, notifications); from/subject/html/text | **HIT** | +| 8 | `setInputValue` | `inject.js` / extension | Line 563, `shared-utils.js` | Sets coupon code text into detected coupon input field; called during auto-apply flow | **HIT** | +| 9 | `fetchWithTimeout` | `background.js` | Line 28, `background.js` | Fetches URL with configurable timeout (8s default); used for API calls from extension service worker | **HIT** | +| 10 | `decryptJsonClient` | `securityHelpers/decryptJsonData.ts` | Line 82, `securityHelpers/cryptoHelpers.ts` | Decrypts base64-encoded JSON payload using XOR+base64 (client-side decryption of API responses) | **HIT** | + +**Function Score: 10/10 HIT (100%)** + +--- + +## CANDIDATE FINDINGS + +### Critical / Moderate Clarity Issues + +**NAV-1: Extension Core Logic Scattered vs. Centralized Prediction** + +- **Location**: `apps/caramel-extension/` (cart-signals.js vs. shared-utils.js) +- **Finding**: Predicted two key behaviors lived in `cart-signals.js` (coupon result validation, store/coupon detection). Actual: Both centralized in `shared-utils.js`. +- **Quote**: `cart-signals.js` is ~150 lines collecting DOM metadata (cart items, title, metadata tags). All coupon logic (apply, detect error, fetch) is in `shared-utils.js` (1500+ lines). +- **Why It Matters**: A developer seeking "where coupons are applied" would miss `cart-signals.js` and land directly on `shared-utils.js`. File names create false expectations of separation of concerns; the extension is actually a monolithic flow-handler, not a modular architecture. +- **Severity**: Medium +- **Confidence**: 1.0 +- **Fix Direction**: Rename `cart-signals.js` to clarify it's data collection only (e.g., `dom-signals.js` or `cart-metadata.js`), or rename `shared-utils.js` to clarify it's the coupon orchestration core (e.g., `coupon-automation.js`). +- **Effort**: S +- **Category**: Naming clarity / architecture communication + +--- + +**NAV-2: Database Mutation Architecture Hidden in Comments** + +- **Location**: `apps/caramel-app/src/lib/couponsDb.ts` lines 1-7 +- **Finding**: File exports `couponsSql` for read queries, suggesting a CRUD pattern. Actual: Comments say "All mutations to the coupon catalog flow through Python service — Next.js only reads." Developers scanning for coupon creation/update might waste time looking for POST endpoints or form handlers that don't exist. +- **Quote**: + ```typescript + // Read-only connection to the `caramel_coupons` database owned by the + // Python verification service. All mutations to the coupon catalog flow + // through that service — Next.js only reads (plus two narrow mutations: + // usage-increment and expire, both exposed to the extension). + ``` +- **Why It Matters**: The architecture is cross-process (Python owns writes, Node reads). This is not obvious from `couponsSql` name or typical import path. A developer unfamiliar with the system would expect `POST /api/coupons` to exist; it doesn't. +- **Severity**: Medium +- **Confidence**: 1.0 +- **Fix Direction**: Add a `README.md` or ADR in `apps/caramel-app/src/lib/` explaining the coupon database split (Python service as source-of-truth for catalog, Node as read-only frontend cache). Alternatively, rename `couponsDb.ts` to `couponsDbRead.ts` to signal write-protection. +- **Effort**: S +- **Category**: Architecture documentation / naming clarity + +--- + +**NAV-3: Coupon Error Detection Logic Complexity Masked by Function Name** + +- **Location**: `apps/caramel-extension/shared-utils.js` line 637, `detectCouponError(rec, baseline, code)` +- **Finding**: Function compares DOM error state deltas but doesn't _detect_ in the sense of "identifying that an error occurred" — it confirms a _change_ between before/after states. The baseline snapshot includes visual error element presence, text patterns, etc. The function then checks if new errors appeared or disappeared. Naming suggests binary detection; actual behavior is differential analysis. +- **Quote**: `function detectCouponError(rec, baseline, code) { /* baseline is what snapshotErrorState() returned BEFORE the apply */ }` +- **Why It Matters**: Reader expects `detectCouponError` to answer "is there an error?" but it answers "did applying this code _cause_ an error?" (or remove one). If a page has a persistent error (unrelated to coupons), the function ignores it. Edge case: stale baseline or page mutation during detection can cause false positives. +- **Severity**: Low +- **Confidence**: 0.8 +- **Fix Direction**: Rename to `hasCouponCausedError` or `didCouponFail`, or add a param to clarify baseline semantics. +- **Effort**: XS +- **Category**: Function naming clarity + +--- + +### Low-Severity Navigation Wins + +**NAV-4: Auth Centralization is Clear** + +- **Location**: `apps/caramel-app/src/lib/auth/auth.ts` +- **Finding**: `betterAuth` configuration is explicit and discoverable. No navigation risk. +- **Why It Matters**: N/A (positive finding) + +**NAV-5: Email Sending is Explicit** + +- **Location**: `apps/caramel-app/src/lib/email.ts` +- **Finding**: Function name and file path are self-documenting. +- **Why It Matters**: N/A (positive finding) + +--- + +## Summary + +**Final Scores:** + +- **Behavior Hit Rate**: 2/5 = **40%** (pure HIT), or 70% (counting PARTIAL as half-hits) + - Misses: Both extension file locations were wrong (off by 1-2 files in same layer) + - Partials: Coupon creation isn't in Next.js codebase (Python service), so file location is misleading +- **Function Hit Rate**: 10/10 = **100%** + +**Key Insight**: Symbol names alone are **excellent** for finding function implementations (100% accuracy). **File locations** were 60-70% accurate because: + +1. Extension monolith (`shared-utils.js`) centralizes logic; modular names suggest it's split across files +2. Cross-process architecture (Python service mutations) is not obvious from file names — requires ADR/comment literacy + +**CANDIDATE FINDINGS Count**: 3 (NAV-1, NAV-2, NAV-3) diff --git a/audit/empirical-onboarding.md b/audit/empirical-onboarding.md new file mode 100644 index 0000000..1228ab4 --- /dev/null +++ b/audit/empirical-onboarding.md @@ -0,0 +1,59 @@ +# ONBOARDING-TRACE — Empirical Audit + +Repo: `caramel` @ `537547b3081aa3a0ec817cdc5f6dac4f0d328dbb` (dev) +Persona: brand-new engineer, day one, knowledge limited to repo docs (README.md, local-dev/\*.md, app READMEs, CONTRIBUTING if any) + generic Next.js/pnpm/Docker/Prisma conventions. +Ground rules: pnpm install already done; real commands executed; docker compose torn down at the end; tree left clean; no secrets written; no commits/pushes. + +## Timeline + +1. **Read root `README.md`.** No "Getting Started" / "Installation" / "Local Development" section exists at all, and the file never mentions `local-dev/` or links to it. Headings present, in order: `## Why choose Caramel?`, `## Browser support`, `## Project layout`, `### Safari Extension Icons`, `## CI/CD`, `## License`. **[TRIBAL-1]** Had to explore the filesystem (not a doc) to even discover `local-dev/LOCAL-DEV.md` exists. +2. **`README.md`'s "Project layout" table** claims paths `caramel-extension` and `caramel-app` (implying repo root). Reality: both live under `apps/`. Confirmed via `ls apps/` → `caramel-app/`, `caramel-extension/`. Doc/reality mismatch (OB-2). +3. **Read `local-dev/LOCAL-DEV.md`.** Documents port ranges and a 3-step workflow: `pnpm compose` → `pnpm dev:compose` → `pnpm dev`. No mention of `.env` files, secrets, or database migrations anywhere in this doc. +4. **Read `apps/caramel-app/.env.example`.** Lists ~15 required vars (DATABASE_URL, JWT/BetterAuth secrets, Google/Apple OAuth, Sentry DSN, useSend, OpenRouter). Most are blank with no doc pointing to a source. `DATABASE_URL` here uses port `2345`. +5. **Checked tool prerequisites** (node v24.15.0, pnpm 10.14.0, docker 28.3.2) — none of these versions/requirements are stated in any doc; package.json only pins `packageManager: pnpm@9.0.0` (installed pnpm is 10.14.0, a major-version drift never called out anywhere). +6. **Ran `pnpm compose`** (doc step 1, "no host ports"). Real output: pulled redis image, created containers. `docker compose ps` / `docker port` afterward showed **`127.0.0.1:58005->5432/tcp`** and **`127.0.0.1:58006->6379/tcp`** already published — contradicting the doc's "no host ports" claim (OB-3). +7. **`docker exec ... psql -c "\l"`**: only databases `caramel`, `postgres`, `template0`, `template1` exist. No `caramel_coupons`. +8. **Ran `pnpm dev:compose`** (doc step 2, "with exposed ports"). Output: containers already running, ports unchanged — confirms step 1 and step 2 are functionally identical for port exposure (docker-compose.yml publishes ports unconditionally; only `--remove-orphans` differs). +9. **Tested `pnpm --filter caramel-app list`** (natural guess matching directory name and README's table): **`No projects matched the filters "caramel-app"`**. **[TRIBAL-2]** Had to read `apps/caramel-app/package.json` to discover the real internal name is `"caramel-landing"` (a `create-next-app` leftover); `pnpm --filter caramel-landing list` succeeds. CI (`checks-app.yml`) itself relies on this undocumented real name. +10. **Read `apps/caramel-app/README.md`** — untouched `create-next-app` boilerplate, plus a garbled trailing artifact `# c a r a m e l - l a n d i n g` confirming the stale scaffold name. Zero project-specific setup content. +11. **Read `apps/caramel-extension/README.md`** — entire file is the single line `"# Caramel extension"` (22 bytes, literally includes stray quote characters, not even valid Markdown heading syntax). +12. **Started the web app**: `cd apps/caramel-app && pnpm dev` (background). Log: Prisma Client generated, then `▲ Next.js 16.1.1 (Turbopack)` booted at `http://localhost:58000`. **App runs.** No migration step ran (only `prisma generate`, never `migrate deploy`/`db push`) — consistent with step 7's empty schema. **[TRIBAL-3]** Nothing in any doc says migrations are required; only inferable from the presence of `prisma/migrations/` and CI's schema-drift job. +13. **`GET http://localhost:58000/`** → HTTP 200. Homepage loads successfully. +14. **Checked the `.env` file that was already present in this sandbox** (not created by any documented step — LOCAL-DEV.md never tells you to create it). It sets `BETTER_AUTH_URL`/`NEXT_PUBLIC_BASE_URL` to `http://localhost:58300`. `GET http://localhost:58300/` → timeout, nothing listens there (app actually runs on 58000, per `local-dev/.env.ports`' `PORT=58000`). Three different sources disagree on the app's own base URL: `.env.ports`→58000 (actual), sandbox `.env`→58300, `.env.example`→3000 (OB-12). +15. **`GET /api/health/db`** → HTTP 401. **[TRIBAL-4]** Reading `src/lib/health.ts` shows it requires `UPKUMA_HEALTH_SECRET`, an env var absent from `.env.example` and the real `.env` — undocumented anywhere, so this endpoint can never be authorized out of the box. +16. **Grepped for `COUPONS_DATABASE_URL`** → `src/lib/couponsDb.ts`. **[TRIBAL-5]** Its own header comment reads: _"Read-only connection to the `caramel_coupons` database owned by the Python verification service."_ This second database and the external Python service that owns it are not mentioned in README.md, LOCAL-DEV.md, or the project-layout table anywhere, and (step 7) it does not exist after following the documented setup verbatim. Every coupon-facing API route (the actual core product) depends on this. +17. **Found `local-dev/verify_test_small3.txt`**, tracked in git, inside the docs folder. Its full content is a stray Python error: `can't open file '...\local-dev\main.py': [Errno 2] No such file or directory` — noise committed into the documentation directory, and circumstantial evidence of an undocumented `local-dev/main.py` Python entry point. +18. **Stopped the dev server** (no lingering listener on 58000 afterward). +19. **Goal 2 — tests.** Grepped `README.md`/`LOCAL-DEV.md` for "test": no doc claims any test suite. Ran `pnpm test` from root anyway (standard convention). Output: `No tasks were executed as part of this run. Tasks: 0 successful, 0 total` — exits 0 looking "green" despite testing nothing, because `apps/caramel-app` and `apps/caramel-extension` package.json only define `test:e2e`, never `test` (OB-11). Did not attempt `test:e2e` (Playwright) since it needs unapplied migrations + browser binaries + CI-only env script (`setup:ci-env`), none of which any doc instructs a human to run locally — stopped this path per the "no doc-given source" rule. +20. **Goal 3 — one-line change + gates.** Edited `apps/caramel-app/src/lib/capitalizeFirst.ts`, added a one-line comment above the export. `git add` it. Ran the exact contents of `.husky/pre-commit`: + - `pnpm lint-staged` → completed clean (`eslint --fix`, `prettier --write`, no reported issues). + - `pnpm -r run type-check` → `Scope: 2 of 3 workspace projects`; only `apps/caramel-app` has a `type-check` script and it printed `Done`; `caramel-extension` (no such script) was silently skipped, no error. + - Both gates passed. +21. **Reverted**: `git restore --staged` + `git checkout --` on the file. Confirmed byte-for-byte original content restored. +22. **Noticed two unrelated, pre-existing `git stash` entries** ("lint-staged automatic backup", dated 2026-04-18 — three months before this session) left over from someone else's earlier work in this sandbox. Not created by this run (verified: my own run's backup stash was created and auto-dropped normally). Left untouched — not mine to discard, and stashes don't affect `git status --porcelain`. +23. **`docker compose down`** — containers, network removed cleanly. +24. **Final check**: `git status --porcelain` → only pre-existing untracked `audit/` (this deliverable's directory, present before this task started) and a pre-existing stray `nul` file (also present before this task started, not created by this session). `git diff --stat` empty. No containers running. + +## CANDIDATE FINDINGS + +{"id":"OB-1","location":"README.md:43-70","quote":"## Project layout\n...\n## CI/CD","what":"Root README has no Getting Started/local-dev section and never links to local-dev/LOCAL-DEV.md","why_it_matters":"A new engineer reading only the front-door doc has zero path to discover how to run the app; must explore the filesystem blind","severity":"High","confidence":0.9,"fix_direction":"Add a 'Local Development' section in README linking to local-dev/LOCAL-DEV.md","effort":"S","category":"docs"} +{"id":"OB-2","location":"README.md:45-50","quote":"| `caramel-extension` | Core browser extension source | | `caramel-app` | App that includes logic for web app, API site (grabcaramel.com) |","what":"Project layout table lists paths as if top-level; both actually live under apps/","why_it_matters":"New engineer looking for ./caramel-app at repo root won't find it","severity":"Low","confidence":0.98,"fix_direction":"Update table to apps/caramel-app, apps/caramel-extension","effort":"S","category":"docs"} +{"id":"OB-3","location":"local-dev/LOCAL-DEV.md:14-22","quote":"1. Start infra only (no host ports):\npnpm compose\n2. Start infra with exposed ports (DB tools etc.):\npnpm dev:compose","what":"Doc claims pnpm compose exposes no host ports vs dev:compose which does; docker-compose.yml publishes 127.0.0.1:58005 and :58006 unconditionally for both","why_it_matters":"Verified empirically (docker port after plain 'pnpm compose' already shows published ports) — the documented distinction between the two commands is false","severity":"Medium","confidence":0.97,"fix_direction":"Correct the doc, or add a real no-port-publish profile to docker-compose.yml","effort":"S","category":"docs"} +{"id":"OB-4","location":"apps/caramel-app/.env.example:2","quote":"DATABASE_URL=\"postgresql://postgres:postgres@localhost:2345/caramel\"","what":".env.example DATABASE_URL uses port 2345; LOCAL-DEV.md and the real working .env both use 58005","why_it_matters":"A contributor copying .env.example -> .env (the standard convention, since no doc says otherwise) gets a DB connection that cannot work","severity":"High","confidence":0.97,"fix_direction":"Fix .env.example port to 58005 to match local-dev/.env.ports","effort":"S","category":"docs"} +{"id":"OB-5","location":"local-dev/LOCAL-DEV.md:14-26","quote":"## Workflow\n1. Start infra only...\n2. Start infra with exposed ports...\n3. Run workspace apps in parallel","what":"Workflow section never mentions creating apps/caramel-app/.env, nor any of ~15 required secrets (Google/Apple OAuth, Sentry, useSend, OpenRouter, BetterAuth)","why_it_matters":"Project markets itself as '100% open source'; an external contributor following only this doc has no path to a fully working app and no source for most secrets","severity":"Critical","confidence":0.9,"fix_direction":"Document env setup step and either provide dev-safe defaults/mocks or list where each secret comes from","effort":"M","category":"docs"} +{"id":"OB-6","location":"apps/caramel-app/package.json:6","quote":"\"dev\": \"dotenv -e ../../local-dev/.env.ports -- npx prisma generate && dotenv -e ../../local-dev/.env.ports -- next dev\"","what":"dev script only runs prisma generate (client codegen), never applies migrations; LOCAL-DEV.md workflow never runs db:push/migrate either","why_it_matters":"Verified: fresh 'pnpm compose' Postgres has zero application tables after following the full documented workflow; every DB-backed route fails at runtime","severity":"High","confidence":0.95,"fix_direction":"Add 'pnpm db:migrate:deploy' (or db:push) as an explicit LOCAL-DEV.md step before pnpm dev","effort":"S","category":"operability"} +{"id":"OB-7","location":"apps/caramel-app/src/lib/couponsDb.ts:1-11","quote":"Read-only connection to the `caramel_coupons` database owned by the\n// Python verification service. ... throw new Error('COUPONS_DATABASE_URL is not set')","what":"Core coupon API routes depend on a second Postgres database owned by an entirely separate, undocumented external Python service","why_it_matters":"Not mentioned in README/LOCAL-DEV/project-layout anywhere; docker-compose.yml never creates 'caramel_coupons' (verified via \\l); the literal core product feature (coupon lookup) is unreachable via the documented path","severity":"Critical","confidence":0.95,"fix_direction":"Document the Python service dependency, or ship a seed/mock for caramel_coupons in local-dev docker-compose","effort":"M","category":"docs"} +{"id":"OB-8","location":"apps/caramel-app/package.json:2","quote":"\"name\": \"caramel-landing\"","what":"Directory is apps/caramel-app (matches README) but the pnpm package name is caramel-landing, a create-next-app leftover","why_it_matters":"Verified: 'pnpm --filter caramel-app ...' -> \"No projects matched the filters\"; must use --filter caramel-landing (as CI does) with zero doc pointing this out","severity":"Medium","confidence":0.97,"fix_direction":"Rename package to caramel-app for consistency, or document the caramel-landing alias","effort":"S","category":"conventions"} +{"id":"OB-9","location":"apps/caramel-app/README.md:1,37","quote":"This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`]... / # c a r a m e l - l a n d i n g","what":"App README is unedited create-next-app boilerplate with a garbled leftover rename artifact","why_it_matters":"Zero project-specific onboarding content in the one file most likely to be checked for app-specific setup","severity":"Medium","confidence":0.95,"fix_direction":"Replace with real setup/architecture doc for caramel-app","effort":"S","category":"docs"} +{"id":"OB-10","location":"apps/caramel-extension/README.md:1","quote":"\"# Caramel extension\"","what":"Entire extension README is one stray-quoted line, no content","why_it_matters":"No load/build/dev instructions for the extension anywhere outside root README's icon-script note","severity":"Low","confidence":0.95,"fix_direction":"Write a real extension README (load-unpacked steps, web-ext dev usage)","effort":"S","category":"docs"} +{"id":"OB-11","location":"package.json:14, turbo.json:15","quote":"\"test\": \"turbo run test\"","what":"Root/turbo define a 'test' task but no workspace package implements a 'test' script (only test:e2e exists)","why_it_matters":"Verified: 'pnpm test' prints \"No tasks were executed as part of this run\" and exits success — a false-green signal for the most natural verification command a new engineer would run","severity":"High","confidence":0.95,"fix_direction":"Remove the no-op 'test' script or wire it to an actual suite; document test:e2e as the real test command","effort":"S","category":"operability"} +{"id":"OB-12","location":"apps/caramel-app/.env vs local-dev/.env.ports:2 vs .env.example:7-9","quote":"local-dev/.env.ports: PORT=58000 | .env (real): BETTER_AUTH_URL=http://localhost:58300 | .env.example: BETTER_AUTH_URL=http://localhost:3000","what":"Three different values for the app's own base URL across three files","why_it_matters":"Verified: app actually boots on 58000 (HTTP 200) while 58300 has nothing listening; any absolute-URL flow (OAuth callback, email links) built from NEXT_PUBLIC_BASE_URL/BETTER_AUTH_URL would target a dead port","severity":"High","confidence":0.9,"fix_direction":"Make all three agree on 58000, or clarify precedence/override rules in LOCAL-DEV.md","effort":"S","category":"docs"} +{"id":"OB-13","location":"local-dev/verify_test_small3.txt:1","quote":"C:\\Python313\\python.exe: can't open file '...\\\\local-dev\\\\main.py': [Errno 2] No such file or directory","what":"A stray Python error message is committed and tracked inside the local-dev documentation folder","why_it_matters":"Noise in the exact folder a new engineer is pointed to for setup docs; also hints at an undocumented local-dev/main.py entry point that doesn't exist","severity":"Low","confidence":0.9,"fix_direction":"Delete the file; add local-dev/\*.txt to .gitignore if it's a common scratch artifact","effort":"S","category":"docs"} +{"id":"OB-14","location":"apps/caramel-app/src/lib/health.ts:31-36","quote":"const secret = process.env.UPKUMA_HEALTH_SECRET\n if (!secret) return false","what":"/api/health/db requires UPKUMA_HEALTH_SECRET, present in neither .env.example nor the working .env","why_it_matters":"Verified: GET /api/health/db returns 401 out of the box; the health-check tool is undocumented and permanently unusable as shipped","severity":"Low","confidence":0.9,"fix_direction":"Add UPKUMA_HEALTH_SECRET to .env.example with a note on how it's used","effort":"S","category":"docs"} + +## Summary + +- App running: **partial** — Next.js dev server boots and homepage returns HTTP 200 following the documented steps, but the database has no migrations applied, a second required database/service is entirely undocumented and absent, and the app's own configured base URL points at a port nothing listens on. Core "coupon finder" functionality is not verifiable via the documented path alone. +- Tests: **no doc claims a test suite exists**; `pnpm test` runs and exits 0 but executes zero actual tests (no-op). Playwright `test:e2e` exists but needs undocumented local setup (migrations, browsers, CI-only env script) — not attempted, per the "no doc-given source, stop that path" rule. +- One-line change through documented gates: **pass** — `pnpm lint-staged` and `pnpm -r run type-check` (the literal `.husky/pre-commit` contents) both completed clean on the trivial comment edit; change reverted; tree confirmed clean (only pre-existing, session-independent `audit/` and `nul` remain untracked). +- Tribal-knowledge findings: **5** (real apps/ layout undiscoverable from docs; pnpm package name caramel-landing vs directory caramel-app; DB migrations required but undocumented; second caramel_coupons DB/Python service entirely undocumented; UPKUMA_HEALTH_SECRET undocumented). diff --git a/audit/exclusions.md b/audit/exclusions.md new file mode 100644 index 0000000..2888307 --- /dev/null +++ b/audit/exclusions.md @@ -0,0 +1,13 @@ +# Scanner exclusion list (caramel @ 537547b) + +Excluded from all scans — generated, vendored, binary, or third-party. Findings inside these paths are invalid unless the finding is ABOUT the path's existence/placement (e.g. a stray artifact or a nested lockfile). + +- `**/node_modules/**`, `**/dist/**`, `**/.next/**`, `**/playwright-report/**`, `**/test-results/**` +- `pnpm-lock.yaml` (root) and `apps/caramel-app/pnpm-lock.yaml` (nested — its EXISTENCE is a valid finding target, its content is not) +- `apps/caramel-app/prisma/migrations/**` (generated SQL) +- `apps/caramel-app/public/**` (assets; ~binary) +- `apps/caramel-extension/icons/**`, `**/*.png`, `**/*.jpg`, `**/*.webp`, `**/*.ico`, `**/*.gif`, `**/*.lottie`, `**/*.woff*` +- `.github/ISSUE_TEMPLATE/**`, `LICENSE` +- `.git/**` + +Everything else in the 405 tracked files is in scope — including `.github/workflows/**`, `local-dev/**`, all root configs, and both apps' source. diff --git a/audit/findings.json b/audit/findings.json new file mode 100644 index 0000000..80203e5 --- /dev/null +++ b/audit/findings.json @@ -0,0 +1,353 @@ +{ + "audit": "caramel", + "baseline_sha": "537547b3081aa3a0ec817cdc5f6dac4f0d328dbb", + "rules_version": "v5 2026-07-10", + "generated_stage": "1-final (verified + adversarially reviewed)", + "candidate_count": 82, + "deletion_counter": 0, + "verification_summary": "0 ABSENT across 15 verified findings (clean hallucination meter). Adversarial: 0 kills of 8 attacked; 2 findings strengthened (F-006, F-008), 1 corrected (F-009 apiResponseNext struck), 1 narrowed (F-002 telemetry facet).", + "cap_note": "16 main findings — 15 + F-016 promoted from a rules-checklist GAP row because v5 §Structure forbids silently punch-listing a one-root-compose violation. Deviation from the 15-cap is deliberate and documented.", + "main_findings": [ + { + "id": "F-001", + "title": "The product's core data (coupon catalog) is an externally-owned, health-check-blind, untyped-raw-SQL dependency", + "location": "apps/caramel-app/src/lib/couponsDb.ts:1-12; apps/caramel-app/src/app/api/health/db/route.ts:9-11", + "quote": "// Read-only connection to the `caramel_coupons` database owned by the\n// Python verification service. All mutations to the coupon catalog flow\n// through that service\n---\nawait prisma.$queryRaw`SELECT 1` // health/db only probes the Prisma/auth DB, never couponsSql", + "what": "The entire coupon catalog lives in a second Postgres (`caramel_coupons`) owned by an out-of-repo Python service, read through untyped template-string SQL in couponsDb.ts. The only health endpoint runs SELECT 1 against the Prisma/auth DB and never touches the coupons DB (adversarial confirmed: no second health check exists anywhere). COUPONS_DATABASE_URL is absent from .env.example, ci-env.ts, and the compose file.", + "why_it_matters": "A schema drift or outage in a DB this repo does not own silently zeroes out all coupons while every monitor stays green — the exact outage the 3am + pre-mortem passes both independently predicted. No typed contract means the Python producer can rename a column and this consumer only finds out in production.", + "severity": "Critical", + "confidence": 1.0, + "verified": true, + "survived_adversarial_review": true, + "fix_direction": "Health-check the coupons DB and surface it distinctly. Document COUPONS_DATABASE_URL. Introduce a typed/validated read layer (zod-parse rows at the boundary) and a shared schema contract or CI drift check against the Python service.", + "effort": "M", + "category": "operability", + "corroborated_by": [ + "E-3am AM-2/AM-4", + "premortem", + "change-trace CT-3/CT-6", + "onboarding OB-7", + "navigation NAV-2" + ] + }, + { + "id": "F-002", + "title": "Failures are shaped as success, and caught-and-returned route errors bypass Sentry — no error-handling convention", + "location": "apps/caramel-extension/background.js:193; apps/caramel-app/src/lib/securityHelpers/decryptJsonData.ts:20-22; apps/caramel-app/src/app/api/coupons/route.ts:120-125; apps/caramel-app/src/instrumentation.ts:12", + "quote": "if (!r.ok) return { coupons: [] } // 500/502/429/auth-fail -> renders as 'no coupons', no error field\n---\n} catch (err) { return resData } // decrypt fails -> returns ciphertext as if plaintext\n---\n} catch (error) { console.error('Error fetching coupons:', error); return NextResponse.json({error:'...'},{status:500}) }", + "what": "Two facets of one missing convention. (A) Failures are shaped as success: the extension collapses any non-2xx into {coupons:[]}/{supported:[]} with no error field (background.js:193), and decryptJsonData returns the raw ciphertext on decrypt failure (:20-22) so callers cannot tell plaintext from failure. (B) Uncaught errors DO reach Sentry (instrumentation.ts:12 wires onRequestError=Sentry.captureRequestError), but the pervasive try/catch-return-500 style deliberately catches errors and returns JSON, so those never reach Sentry and there is no captureConsoleIntegration to save them.", + "why_it_matters": "In a product whose entire pitch is honesty, an outage renders to users as the factual claim 'this store has no coupons' — the lie fires on exactly the path most likely to break in a real incident, and the caught-and-returned server errors behind it are invisible in Sentry. Every downstream decision acts on a fabricated empty result.", + "severity": "Critical", + "confidence": 0.95, + "verified": true, + "survived_adversarial_review": true, + "standoff_caveat": "Adversarial narrowed the telemetry claim: onRequestError IS wired for UNCAUGHT errors; the real gap is the caught-and-returned 500 paths. Also the .catch(()=>({})) body-parse sites are largely downstream-validated (expire:27 rejects non-arrays, sources:82 rejects empty website) — kept as a code-smell in F-007's pipeline scope, not a Critical driver here.", + "fix_direction": "One shared error boundary/handler that (1) shapes failures as distinguishable errors — never empty-success — and (2) routes caught server errors to Sentry.captureException with request context. Ban the empty-success + decrypt-return-raw patterns via lint once the helper exists.", + "effort": "M", + "category": "error-handling", + "corroborated_by": [ + "DD2-1", + "E-3am AM-5/AM-6/AM-7", + "DD1-2", + "DD4-4" + ] + }, + { + "id": "F-003", + "title": "A static key shipped in the public extension gates a destructive mutation and exempts its holder from rate limiting", + "location": "apps/caramel-app/src/app/api/coupons/expire/route.ts:15-19; apps/caramel-extension/background.js:23; apps/caramel-app/src/lib/rateLimit.ts:59-63,91", + "quote": "const key = req.headers.get('x-api-key')\nconst expected = process.env.EXTENSION_API_KEY\nif (!expected || !key || key !== expected) { return ...401 }\n// client, background.js:23 (tracked, shipped to every install):\nconst EXTENSION_API_KEY = 'WXqEpm2uOV5jjJXPpnQFyZiNdaPVUrtd2LIrf4kc1JA'\n// rateLimit.ts:91: if (isExtensionClient(req)) return null // same key => no throttle", + "what": "The identical static string is (a) the server secret authorizing the mutating `coupons/expire` endpoint, (b) hardcoded in the public extension's background.js:23 (readable by anyone who installs it) and sent on live calls (background.js:208, test-extension.mjs:32/114/143), and (c) the rate-limit exemption in rateLimit.ts. A value anyone can extract from the shipped client both unlocks a destructive mutation and removes the throttle protecting it.", + "why_it_matters": "Concrete dangerous-default regression (in-scope): a public credential gating a state-changing action with rate-limit bypass. Reported concisely per security-scope rules — the fix is bounded, not a re-architecture.", + "severity": "Critical", + "confidence": 0.95, + "verified": true, + "survived_adversarial_review": true, + "fix_direction": "Split roles: the extension gets no privileged key; server-to-server mutations (expire) use a secret that never ships to clients. Rate-limit exemption keys off an authenticated server identity, not a public string.", + "effort": "S", + "category": "security", + "corroborated_by": ["DD3-1", "DD1-6", "E-3am AM-11", "DD3-2"] + }, + { + "id": "F-004", + "title": "No test suite exists — `pnpm test` is false-green, so nothing protects any change", + "location": "package.json:14; turbo.json:15", + "quote": "\"test\": \"turbo run test\" // no workspace package implements a `test` script; both runs exit 0 with 'No tasks were executed as part of this run'", + "what": "The root `test` task fans out to turbo, but no package defines `test` (app has `test:e2e` Playwright needing undocumented DB+migrations; extension has scripts/test-extension.mjs). `pnpm test` exits 0 having run nothing — a false-green no-op, worse than red because it reads as a passing gate (adversarial: airtight, only the root turbo-passthrough exists).", + "why_it_matters": "Every fix in this audit and every future change claims safety from a suite that exercises zero lines. The green-suite rule requires pinning tests before touching big files; here there is no suite at all — the #1 forced roadmap item.", + "severity": "High", + "confidence": 1.0, + "verified": true, + "survived_adversarial_review": true, + "fix_direction": "Stand up a real unit/integration suite (vitest) wired into turbo `test` and CI; make `test` fail when zero tests run. Characterization tests first for shared-utils.js and the API routes touched by fixes.", + "effort": "L", + "category": "testing", + "corroborated_by": ["OB-11", "tooling §1", "DD2-4"] + }, + { + "id": "F-005", + "title": "No validated env contract; .env.example is stale (19 documented vs 38 read) and boot does not fail fast", + "location": "apps/caramel-app/.env.example; apps/caramel-app/src/lib/couponsDb.ts:9-12; src/lib (no env module)", + "quote": "const connectionString = process.env.COUPONS_DATABASE_URL\nif (!connectionString) { throw new Error('COUPONS_DATABASE_URL is not set') }\n// .env.example documents DATABASE_URL but has no COUPONS_DATABASE_URL entry at all\n// tooling: 19 documented vars vs 38 distinct process.env.* reads; no zod/@t3-oss/env validator anywhere", + "what": "No zod-validated env module. 38 distinct process.env.* reads across app+lib; .env.example documents 19; ~11 are read but undocumented (COUPONS_DATABASE_URL, UPKUMA_HEALTH_SECRET, EXTENSION_OAUTH_STATE_SECRET, etc). Three different missing-env strategies coexist. DATABASE_URL port drifts across .env.example (2345), LOCAL-DEV.md and the working .env (58005). Three different base-URL values across three files.", + "why_it_matters": "Boot succeeds on a broken env and fails deep in a request path instead of at startup; a docs-only engineer cannot assemble a working env; drift between the example and reality guarantees a wrong first run (proven in the onboarding trace).", + "severity": "High", + "confidence": 0.95, + "verified": true, + "survived_adversarial_review": null, + "fix_direction": "One zod-validated env module imported at boot; regenerate .env.example from it as the single vocabulary; fail fast with a named-var error. Reconcile the port/base-URL drift to one source.", + "effort": "M", + "category": "operability", + "corroborated_by": [ + "DD3-14", + "DD4-6", + "E-3am AM-3/AM-4", + "onboarding OB-4/OB-12", + "tooling §11" + ] + }, + { + "id": "F-006", + "title": "Coupon domain logic and UI constants have no single home — duplicated across routes and the app↔extension boundary (understated)", + "location": "apps/caramel-app/src/app/api/coupons/route.ts:48; sites/top-sites/route.ts:13; sites/search-supported/route.ts:24; coupons/stores/route.ts:18,25; src/components/coupons/coupon-card.tsx:21-34; apps/caramel-extension/shared-utils.js:1054-1069", + "quote": "status IN ('valid','valid_with_warning','product_restriction','category_restricted','seller_specific','pending','retry') AND expired = FALSE // full predicate verbatim in coupons/route.ts:48, sites/top-sites:13, sites/search-supported:24, coupons/stores:18 AND :25 (+ a marketing route); filters/route.ts uses only 'valid' and stats/route.ts drops the expired filter entirely — already drifted", + "what": "The 'which coupon statuses are visible' whitelist is copy-pasted across at least 6 sites (adversarial confirmed MORE than the originally-cited 5) and has already drifted into three different definitions. The status→label/color map is re-declared in both the app (STATUS_BADGE) and the extension (RESTRICTED_STATUSES). Ranking ORDER BY is duplicated. jscpd independently measured 65 clones / 5.55%, with real signal also in .tsx marketing + auth components copy-pasted rather than shared.", + "why_it_matters": "The prime priority-1 dedup finding: one concept in many places already disagreeing with itself. A cold agent editing coupon visibility fixes one of six sites and ships a silent inconsistency. Cross-app duplication has no shared package to land the fix in.", + "severity": "High", + "confidence": 0.95, + "verified": true, + "survived_adversarial_review": true, + "fix_direction": "One coupon-domain module (status vocabulary, visibility predicate, ranking) imported by every route and component; a shared constants surface (generated or packaged) the extension consumes so app and extension cannot drift. Ban raw re-declaration via lint once it exists.", + "effort": "M", + "category": "duplication", + "corroborated_by": [ + "DD3-6", + "CT-1", + "CT-2", + "DD2-11", + "tooling §13 jscpd" + ] + }, + { + "id": "F-007", + "title": "No shared request pipeline — CORS, rate-limit, auth, and body-parsing are re-implemented (or silently skipped) per route; no middleware.ts", + "location": "apps/caramel-app/middleware.ts (absent); src/app/api/extension/oauth/authorize/route.ts:42-44,62-64,205-207; src/lib/rateLimit.ts; src/app/api/extension/oauth/redirect/route.ts", + "quote": "(no middleware.ts anywhere — repo-wide glob returns zero matches outside node_modules)\n// identical 3-line Access-Control block hand-inlined at oauth/authorize:42-44 (OPTIONS), :62-64 (helper), :205-207 (catch) — OPTIONS and catch don't even call the local helper\n// oauth/authorize never imports checkRateLimit despite doing crypto + reading secrets\n// oauth/redirect creates User/Account/Session via raw Prisma and mints its own session, bypassing better-auth", + "what": "No Next.js middleware and no shared route wrapper. Cross-cutting concerns are opt-in per-handler: CORS hand-inlined 3× in one file (two copies don't call the local helper), rate limiting applied to some handlers and silently absent from others (oauth/authorize does crypto+secrets with no throttle), body-parse swallow copy-pasted, and oauth/redirect hand-mints sessions via raw Prisma instead of better-auth. The withAuth/withRoles/cors wrappers that could centralize this are the dead fossils of F-009.", + "why_it_matters": "Priority-1 modularity + conventions: there is no one way to gate a route, so coverage gaps (a mutation with no rate limit, a route with no CORS) are invisible and permanent, and the auth surface has two implementations that can diverge.", + "severity": "High", + "confidence": 0.9, + "verified": true, + "survived_adversarial_review": true, + "fix_direction": "A single composable route wrapper (or middleware.ts) owning CORS + rate-limit + body-parse + auth; routes declare their needs, don't re-implement them. Route oauth/redirect through better-auth's session API.", + "effort": "M", + "category": "modularity", + "corroborated_by": [ + "DD3-12", + "DD3-9", + "DD3-10", + "DD3-13", + "CT-9", + "DD3-11" + ] + }, + { + "id": "F-008", + "title": "shared-utils.js is a 1536-line god-module (the repo's #1-churned AND #1-LOC file) whose neighbours' names imply a modularity that doesn't exist", + "location": "apps/caramel-extension/shared-utils.js (1536 LOC); apps/caramel-extension/index.html:61-63", + "quote": "\n\n\n// adversarial census: >=8 unrelated responsibilities — DOM visibility/wait (:91-190), price parsing (:203 getPrice), CSS/XPath query utils (:238-257), coupon-apply loop, modal UI, timing/telemetry, messaging, storage — churn=4529 (highest), LOC=1536 (highest)", + "what": "One 1536-line file owns at least eight unrelated responsibilities (adversarial upgraded the count from 6 to 8). It is simultaneously the highest-churn (4529) and highest-LOC file in the repo. The name-only navigation test predicted behaviors would live in modular-looking neighbours (cart-signals.js, inject.js) but they are all inside this monolith — the surrounding file names actively mislead.", + "why_it_matters": "Priority-1 modularity + clarity: the file a cold agent must edit most often is the one it can least hold in context, and the neighbouring file names point it to the wrong place. High churn × high size × no boundaries is the repo's single biggest change-risk concentration.", + "severity": "High", + "confidence": 0.95, + "verified": true, + "survived_adversarial_review": true, + "fix_direction": "Split shared-utils.js along its real responsibilities into cohesive modules whose names match content; keep the coupon-apply loop separate from UI and telemetry. Characterization tests (F-004) FIRST — highest-churn file, pin behavior before cutting.", + "effort": "L", + "category": "modularity", + "corroborated_by": [ + "DD2-2", + "DD2-3", + "NAV-1", + "DD2-10", + "tooling §8/§9 churn+LOC" + ] + }, + { + "id": "F-009", + "title": "The CI guardrail stack is blind and unenforced — dead fossils whitelisted past knip, no branch protection, target-stack pieces missing", + "location": "apps/caramel-app/knip.json:16-23; src/lib/middlewares/**; src/lib/initMiddleware.ts; src/lib/cors.ts; src/lib/securityHelpers/apiResponse.ts; .github/workflows/*; .husky/pre-commit", + "quote": "\"ignore\": [ ... \"src/lib/cors.ts\", \"src/lib/initMiddleware.ts\", \"src/lib/middlewares/**\", \"src/lib/securityHelpers/apiResponse.ts\", ... ], \"ignoreDependencies\": [ ..., \"cors\", ... ]\n// branch protection NONE on main or dev (both 404 'Branch not protected'); no oxlint, no size-limit; husky pre-commit runs lint-staged + type-check only (no knip, no prisma-drift)", + "what": "The gates that exist don't guard and the target stack is half-built. (1) A 100%-App-Router app still carries dead Pages-Router fossils — cors.ts, initMiddleware.ts, middlewares/** (withAuth/withRoles), securityHelpers/apiResponse.ts — with zero live callers, and knip.json's ignore list whitelists exactly them so the knip gate stays green while blind. (Adversarial correction: apiResponseNext.ts is LIVE and struck from the fossil set.) (2) Neither main nor dev has branch protection, so a red check gates nothing. (3) vs the v5 CI baseline, oxlint and size-limit are absent and the husky pre-commit is missing knip + the prisma-drift check.", + "why_it_matters": "The rules-become-checks failure mode: a passing gate actively lies about code health, a cold agent treats dead withAuth as the way to require login, and nothing is enforced at merge anyway. Cheap guardrails that cluster where AI faults appear are missing.", + "severity": "High", + "confidence": 0.9, + "verified": true, + "survived_adversarial_review": true, + "standoff_caveat": "Adversarial struck apiResponseNext.ts (live, used in sources/route.ts) and noted cryptoHelpers.decryptJsonClient is live too — the fossil set is the ~5 knip-whitelisted NextApi* files, not those.", + "fix_direction": "Delete the fossil set + the cors dep and remove them from knip's ignore so the gate sees truth. Turn on branch protection requiring checks on main+dev. Add oxlint + size-limit; extend husky pre-commit with knip + prisma-drift.", + "effort": "M", + "category": "code_health", + "corroborated_by": [ + "DD4-1", + "DD3-7", + "E-3am AM-10", + "CT-4", + "tooling §10", + "rules-checklist CIB-1/2/3" + ] + }, + { + "id": "F-010", + "title": "Onboarding docs are wrong/missing — a docs-only engineer cannot reach a working system", + "location": "README.md:43-72; local-dev/LOCAL-DEV.md:14-26; apps/caramel-app/package.json:6; apps/caramel-app/README.md", + "quote": "\"dev\": \"... npx prisma generate && ... next dev\" // generates the client, never applies migrations; no doc says to\n// README has no Getting Started and never links local-dev/LOCAL-DEV.md; LOCAL-DEV.md never mentions creating apps/caramel-app/.env or its secrets; app README is unedited create-next-app boilerplate with a garbled rename artifact", + "what": "The onboarding trace got only a partial boot from docs: README has no getting-started and never links the real doc; no doc says to create apps/caramel-app/.env or lists its secrets; the dev script generates the Prisma client but never applies migrations and no doc says to; the second (coupons) DB is undocumented; the app README is create-next-app boilerplate.", + "why_it_matters": "Priority-1 clarity at the system level: every new session is a freelancer's first day, and the docs actively mislead (wrong port, missing DB, missing migration step). The Stage-4 codify target — docs don't ship until a cold agent passes the onboarding trace.", + "severity": "High", + "confidence": 0.95, + "verified": true, + "survived_adversarial_review": null, + "fix_direction": "Rewrite README getting-started + LOCAL-DEV.md to the real path (env creation, both DBs, migrations, ports); delete boilerplate READMEs. Validate with a fresh-agent onboarding trace (Stage 4).", + "effort": "M", + "category": "docs", + "corroborated_by": [ + "OB-1", + "OB-5", + "OB-6", + "OB-3", + "OB-9", + "OB-10", + "OB-8", + "navigation NAV-2" + ] + }, + { + "id": "F-011", + "title": "Operability safety nets absent — no runbook, no app-deploy/rollback/smoke, no App-Router error boundary, broken trace propagation", + "location": "README.md:70-72; .github/workflows/*; apps/caramel-app/src/app (no error.tsx); apps/caramel-app/next.config.mjs:57", + "quote": "The project uses GitHub Actions for CI/CD. The workflow is defined in `.github/workflows/`.\n// workflows contain a `prisma migrate deploy` step but NO app deploy/dokploy/rollback/smoke; no runbook/on-call/dashboard doc in 405 files; no error.tsx/global-error.tsx in the App Router; Sentry set to auto-create Vercel Cron Monitors though deployed on Dokploy", + "what": "No runbook, incident doc, or dashboard pointer anywhere; a DB-migration workflow step exists but there is no app-deploy, post-deploy smoke, or documented rollback; no custom error boundary in the App Router (an unhandled render throw shows the default error); Sentry's Next plugin auto-creates Vercel Cron Monitors while the app deploys on Dokploy (dead config). No trace/request ID is propagated to the external Python coupons service or the OpenRouter LLM hop (rules-checklist ERR-2).", + "why_it_matters": "Priority-2 operability: the 3am on-call has nowhere to look and nothing to roll back to — combined with F-001/F-002 (blind monitoring, swallowed errors) a real outage is undiagnosable from what the repo provides, and cross-hop failures can't be correlated.", + "severity": "High", + "confidence": 0.85, + "verified": true, + "survived_adversarial_review": null, + "fix_direction": "Author RUNBOOK.md (logs/dashboards, health checks incl. coupons DB, rollback via Dokploy, known failure modes). Add an App-Router error boundary. Fix/remove the Vercel-cron Sentry config. Propagate a trace ID to the Python + OpenRouter hops.", + "effort": "M", + "category": "operability", + "corroborated_by": [ + "E-3am AM-1/AM-9/AM-12/AM-13", + "rules-checklist ERR-2" + ] + }, + { + "id": "F-012", + "title": "The LLM cart-classifier surface has no eval suite and no CI gate", + "location": "apps/caramel-app/src/lib/cartClassifier.ts:93-108; src/lib/openrouter.ts:2; src/app/api/classify-cart/route.ts; .github/workflows/* (absence)", + "quote": "Return JSON ONLY, schema: {\"primary\":\"\",\"secondary\":\"\",\"confidence\":0..1}\nconst DEFAULT_MODEL = process.env.OPENROUTER_MODEL ?? 'openai/gpt-5-mini'\n// zero *.eval.* / evals/ / scorers anywhere; no ai-evals workflow; model swappable by env with no scoreboard", + "what": "The repo's only user-facing LLM surface (cart classification feeding the popup's restriction warnings) has no eval suite, no scorers, no CI wiring, and no scoreboard. The model id is env-swappable (default openai/gpt-5-mini) with no gate. Output is validated by assertion, not a schema, on both edges.", + "why_it_matters": "Per v5 §AI-quality this fails every bullet: a provider silently degrading the model, or an env pin drifting from the code default, is undetectable — and F-002 means the runtime failure is also swallowed, so the surface is blind both proactively and reactively.", + "severity": "High", + "confidence": 0.95, + "verified": true, + "survived_adversarial_review": null, + "fix_direction": "Install evals via the /ai-evals skill: fixed dataset → production prompt/schema (imported, not copied) → deterministic scorers → PR path-filter + nightly + dispatch CI; eval-gate any model change with a scoreboard row.", + "effort": "M", + "category": "ai-quality", + "corroborated_by": ["DD1-1", "tooling §12"] + }, + { + "id": "F-013", + "title": "`any`-typed exported surfaces poison callers (~23 instances, several on exported functions)", + "location": "apps/caramel-app/src/lib/securityHelpers/decryptJsonData.ts:3; src/lib/securityHelpers/cryptoHelpers.ts:63; src/lib/apiResponseNext.ts:36; src/components/coupons/coupon-filters.tsx:92-131", + "quote": "export function decryptJsonData(resData: any): any {\n// census: ~23 `any` across src (verifier re-count: 23, not 26); exported surfaces (decryptJsonData, encryptJsonServer payload:any, apiResponseNext metadata/viewport:any) push `any` into every caller", + "what": "A ~23-instance `any` census; the damaging subset is on LIVE exported surfaces — decryptJsonData(resData:any):any, encryptJsonServer(payload:any), apiResponseNext(metadata?:any,viewport?:any), and 6 untyped React-Select style callbacks — which erase types for every consumer. (initMiddleware's `any` is subsumed by F-009's fossil deletion.) Violates v5 §Typing (no `any` on exported surfaces).", + "why_it_matters": "Priority-1 clarity/typing: `any` on an exported boundary is contagious — callers lose autocomplete and compile-time safety exactly where a light model needs the type to navigate. tsc passes today only because these holes hide the mismatches.", + "severity": "Medium", + "confidence": 0.85, + "verified": true, + "survived_adversarial_review": null, + "fix_direction": "Type the exported surfaces first (kill contagion), then interior `any`. Add lint against `any` on exported declarations once clean.", + "effort": "M", + "category": "typing", + "corroborated_by": ["DD4-2", "DD1-4", "hotspots coupon-filters"] + }, + { + "id": "F-014", + "title": "Dependency health is degraded — vulnerabilities, near-total staleness, a deprecated dependency, floating ^-ranges", + "location": "apps/caramel-app/package.json:25-86; root package.json; apps/caramel-app/pnpm-lock.yaml", + "quote": "// tool-sourced (not re-verified in-gate): pnpm audit --prod: 83 vulnerabilities (2 critical, 45 high, 30 moderate, 6 low); pnpm outdated -r: 50/51 outdated (majors: Prisma 6->7, TS 5->7, Tailwind 3->4, ESLint 9->10)\n// verified in-gate: caret ranges throughout package.json; react-awesome-button present and npm-deprecated; nested apps/caramel-app/pnpm-lock.yaml duplicates the root lockfile", + "what": "Tool-sourced: pnpm audit --prod reports 83 vulnerabilities (2 critical, 45 high) and 50/51 deps are outdated with 15 notable majors. Verified in-gate: react-awesome-button is npm-deprecated; dependencies use caret ranges rather than pinned versions (v5 §Typing bans floating deps); a nested apps/caramel-app/pnpm-lock.yaml duplicates the root lockfile (drift risk).", + "why_it_matters": "Priority-2/3: the critical/high vulns are a standing exposure, the deprecated dep is a ticking removal, and floating ranges mean two installs can resolve differently — the version-drift class the rules explicitly ban.", + "severity": "Medium", + "confidence": 0.9, + "verified": true, + "survived_adversarial_review": null, + "fix_direction": "Triage+patch the critical/high vulns; replace react-awesome-button; schedule the major bumps; pin versions and enforce a single lockfile; add a pnpm-audit gate to CI.", + "effort": "M", + "category": "dependencies", + "corroborated_by": ["tooling §6/§7", "hotspots nested-lockfile"] + }, + { + "id": "F-015", + "title": "Broken/stray artifacts ship and mislead — Firefox manifest references a non-existent script, plus stray files, a name-lie, and a dead formatter config", + "location": "apps/caramel-extension/manifest-firefox.json:42-51; local-dev/verify_test_small3.txt; nul; apps/caramel-app/package.json:2; .prettierrc.json", + "quote": "\"js\": [\"shared-utils.js\", \"UI-helpers.js\", \"inject.js\", \"amazon.js\"] // amazon.js does not exist anywhere in the repo (verified)\n// verify_test_small3.txt: committed stray Python error; a tracked `nul` file at root; app package named 'caramel-landing'; .prettierrc.json uses invalid key 'tailwindConfigPath' (should be tailwindConfig) — silently ignored every run", + "what": "A grab-bag sharing the root 'artifacts that lie about themselves' (all 5 sub-claims verbatim-verified): the Firefox manifest lists amazon.js as a content script but that file does not exist (broken build shipped to Firefox); verify_test_small3.txt is a committed stray Python error; a `nul` file is tracked at root; the app package is named caramel-landing though it is the app; .prettierrc.json's tailwindConfigPath is an invalid key so tailwind-aware formatting silently never runs.", + "why_it_matters": "Priority-1 clarity + priority-2 operability: a cold agent cannot tell a fossil from live tasking, the Firefox build references a missing file, and a formatter 'gate' that silently does nothing is the rules-become-checks failure in miniature.", + "severity": "Medium", + "confidence": 0.95, + "verified": true, + "survived_adversarial_review": null, + "fix_direction": "Fix/remove the amazon.js reference (confirm Firefox build integrity); delete verify_test_small3.txt + nul; rename the package to caramel-app; fix the prettier key. Add a root-file allowlist + manifest-integrity check.", + "effort": "S", + "category": "code_health", + "corroborated_by": [ + "DD2-5", + "OB-13", + "OB-8", + "tooling §5", + "hotspots stray-artifacts" + ] + }, + { + "id": "F-016", + "title": "one-root-compose / dev-runs-prod-build rule violated outright (promoted P1 — rule forbids punch-listing)", + "location": "local-dev/docker-compose.yml:1-2; package.json:9-10", + "quote": "# Local development infrastructure for Caramel\n# Backing services only; apps run outside Docker for fast reload\n---\n\"dev:compose\": \"docker compose ... up -d --remove-orphans\",\n\"dev\": \"pnpm run dev:compose && pnpm -r --parallel dev\"", + "what": "The compose file runs Postgres + Redis only ('Backing services only; apps run outside Docker for fast reload'); `pnpm dev` starts those containers and then runs framework hot-reload (`pnpm -r --parallel dev` → `next dev`), not `docker compose up --build` of the real service graph; prod deploys via Nixpacks (Dokploy buildpack), not this compose. This is the exact opposite of the v5 §Structure rule (ONE root compose builds web+workers+DB in prod mode; `pnpm dev` IS `docker compose up --build`; prod runs the same file as one platform compose service).", + "why_it_matters": "v5 §Structure calls a missing/broken root compose a P1 finding 'never silently punch-listed', because local/CI/prod diverge — an AI agent sees hot-reload behavior locally that prod never runs, and there is no single graph guaranteeing parity. caramel is NOT in the PROD-GATE exception list, so the standard flow applies.", + "severity": "High", + "confidence": 0.95, + "verified": true, + "survived_adversarial_review": null, + "fix_direction": "Route via the /one-root-compose skill (dedicated migration playbook): one root compose building the app in prod mode as the real graph, `pnpm dev` = `docker compose up --build`, prod cutover to that compose as a Dokploy compose service. Large, breaking, separate initiative — sequence after the safety-net items.", + "effort": "L", + "category": "architecture", + "corroborated_by": ["rules-checklist STR-2"] + } + ], + "appendix_overflow": [ + "DD3-8 — cryptoHelpers.encrypt/decrypt is XOR-with-Host/UA obfuscation misnamed 'encryption' (clarity/name-lie; security-scope: rename+honesty, not re-arch)", + "DD1-3 — classifier drops url_path + 2 fields before the model despite carrying them on the wire", + "DD1-5 — classify-cart 8KB cap reads client Content-Length only (chunked bypass)", + "DD1-8/9/10 — popup renders raw model enum tokens as UI copy; domain-casing cache-key skew; wrong hardcoded OpenRouter attribution headers", + "DD2-8 — getActiveTabDomainRecord always returns domainRecord:null on all branches", + "DD2-9 — reinjection guards protect only 6 of N identifiers", + "DD2-12 — extension build --exclude '*.lock' does not exclude pnpm-lock.yaml", + "DD2-13 — extension eslint.config.cjs extends no recommended preset", + "DD2-14 — MV3 manifest.json missing a permission the code uses", + "DD3-2 — constant-time compare on a public key (security theater)", + "DD3-5 — 5 success-envelope shapes + 2 error shapes across 17 routes", + "DD3-15 — 4 endpoints return the same store list under different names (clarity)", + "DD4-3 — duplicate crypto helpers (apiResponseNext vs cryptoHelpers)", + "DD4-5 — three subfolder-naming schemes in src/lib", + "CT-5 — stale comment describes /increment being called on every copy (no live caller)", + "CT-7 — successRate naming collision (scraper-source metric vs proposed per-store metric)", + "CT-8 — background.js is the extension's sole fetch chokepoint (coupling note)", + "AM-14 — every route logs free-text console.error, no structured logging", + "AIH-2 (rules-checklist) — zero TODO: markers in the tracked codebase despite known-incomplete work", + "tooling — one-off eslint run linted .next/dev build artifacts (env anomaly, not a source defect)" + ] +} diff --git a/audit/hotspots-a.json b/audit/hotspots-a.json new file mode 100644 index 0000000..bf0bade --- /dev/null +++ b/audit/hotspots-a.json @@ -0,0 +1,128 @@ +[ + { + "path": "apps/caramel-app/.env", + "reason": ".env file exists with real API secrets (gitignored but untracked can be accidentally committed)", + "candidate_severity": "Critical", + "lenses": ["unvalidated-env", "missing-basics"] + }, + { + "path": "apps/caramel-app/pnpm-lock.yaml", + "reason": "Nested pnpm-lock.yaml in monorepo (violates one-root-compose; version drift risk between root and app)", + "candidate_severity": "High", + "lenses": ["version-drift", "stray-artifacts"] + }, + { + "path": "apps/caramel-app/src/app/api/classify-cart/route.ts:32+", + "reason": "Cart classification uses OpenRouter GPT-5 mini but no AI quality evals in CI; model output never validated post-call", + "candidate_severity": "High", + "lenses": ["llm-surfaces-without-evals"] + }, + { + "path": "apps/caramel-app/src/app/api/classify-cart/route.ts:67", + "reason": "req.json().catch(() => null) swallows malformed JSON silently; endpoint then operates on null (producer/consumer contract failure)", + "candidate_severity": "High", + "lenses": ["silent-failure", "producer-consumer-drift"] + }, + { + "path": "apps/caramel-app/src/app/api/coupons/increment/route.ts:20", + "reason": "req.json().catch(() => ({})) — malformed body becomes empty object, id?: unknown is silently undefined", + "candidate_severity": "High", + "lenses": ["silent-failure", "producer-consumer-drift"] + }, + { + "path": "apps/caramel-app/src/app/api/extension/login/route.ts:5", + "reason": "req.json().catch(() => ({})) — silent email/password loss causes auth errors with no parse-failure context", + "candidate_severity": "High", + "lenses": ["silent-failure", "producer-consumer-drift"] + }, + { + "path": "apps/caramel-extension/background.js", + "reason": "Service worker with onMessage listeners and async operations but no global error boundary (failed bg tasks unobserved)", + "candidate_severity": "High", + "lenses": ["fire-and-forget-async", "silent-failure"] + }, + { + "path": "apps/caramel-extension/popup.js", + "reason": "760-line popup with 30+ async patterns but only 9 catch blocks (unhandled rejections will fail silently)", + "candidate_severity": "High", + "lenses": ["fire-and-forget-async", "silent-failure"] + }, + { + "path": "apps/caramel-extension/shared-utils.js", + "reason": "1537-line core utility with 28 catch blocks but 2+ fetch calls and cross-frame messaging (trace/error flow unclear)", + "candidate_severity": "High", + "lenses": ["fire-and-forget-async", "trace-propagation"] + }, + { + "path": ".eslintrc.json", + "reason": "No ESLint config (missing code quality guardrails for unused imports, naming conventions, complexity)", + "candidate_severity": "Medium", + "lenses": ["missing-basics"] + }, + { + "path": "apps/caramel-app/src/app/api/classify-cart/route.ts", + "reason": "Large route (100+ lines) with 15+ type casts (as Record, as [string, boolean][], etc.) — trial-and-error bloat pattern", + "candidate_severity": "Medium", + "lenses": ["trial-and-error-bloat", "missing-basics"] + }, + { + "path": "apps/caramel-app/src/app/api/extension/oauth/authorize/route.ts:52", + "reason": "searchParams.get('provider') cast as 'google'|'apple'|null without validation (trusts client-supplied enum)", + "candidate_severity": "Medium", + "lenses": ["producer-consumer-drift", "missing-basics"] + }, + { + "path": "apps/caramel-app/src/app/api/extension/oauth/redirect/route.ts", + "reason": "OAuth redirect handler has no error logging (exceptions in state validation, token exchange swallowed)", + "candidate_severity": "Medium", + "lenses": ["silent-failure", "operability"] + }, + { + "path": "apps/caramel-app/src/components/coupons/coupon-filters.tsx:92-131", + "reason": "React Select styling uses 6x 'any' type casts (base: any, state: any) bypassing type safety", + "candidate_severity": "Medium", + "lenses": ["missing-basics"] + }, + { + "path": "apps/caramel-app/src/lib", + "reason": "No env.ts or environment validation (process.env accessed unsafely throughout)", + "candidate_severity": "Medium", + "lenses": ["missing-basics", "unvalidated-env"] + }, + { + "path": "apps/caramel-extension/background.js + apps/caramel-app/src/app/api/extension/", + "reason": "Extension→API requests lack trace/request ID propagation; errors in extension cannot be correlated to API logs", + "candidate_severity": "Medium", + "lenses": ["trace-propagation", "operability"] + }, + { + "path": "apps/caramel-extension/shared-utils.js + apps/caramel-app/src/components/coupons/", + "reason": "Coupon filtering, store detection, and discount calculation duplicated across extension (JS) and app (TS) (single-source-of-truth violation)", + "candidate_severity": "Medium", + "lenses": ["duplication", "maintainability"] + }, + { + "path": "local-dev/.env", + "reason": ".env file with local database credentials and API keys (dev-only but requires care in commit discipline)", + "candidate_severity": "Medium", + "lenses": ["unvalidated-env"] + }, + { + "path": "apps/caramel-app/sentry.server.config.ts", + "reason": "Sentry configured but request context (user ID, session, trace ID) propagation to extension calls unclear", + "candidate_severity": "Low", + "lenses": ["trace-propagation", "operability"] + }, + { + "path": "apps/caramel-app/src/app/api/coupons/route.ts:120", + "reason": "Coupon GET endpoint catches errors generically without logging which specific coupon query failed", + "candidate_severity": "Low", + "lenses": ["silent-failure", "operability"] + }, + { + "path": "apps/caramel-extension/shared-utils.js:67-69", + "reason": "catch (e) { // ignore storage errors } in recordTiming — storage failures mask underlying issues silently", + "candidate_severity": "Low", + "lenses": ["silent-failure"] + } +] diff --git a/audit/hotspots-b.json b/audit/hotspots-b.json new file mode 100644 index 0000000..3865288 --- /dev/null +++ b/audit/hotspots-b.json @@ -0,0 +1,122 @@ +[ + { + "path": "apps/caramel-extension/background.js:23", + "reason": "Hardcoded EXTENSION_API_KEY exposed in source: 'WXqEpm2uOV5jjJXPpnQFyZiNdaPVUrtd2LIrf4kc1JA'", + "candidate_severity": "Critical", + "lenses": ["exposed-secret", "fire-and-forget"] + }, + { + "path": "apps/caramel-app/src/lib/securityHelpers/decryptJsonData.ts:20", + "reason": "catch block silently returns unencrypted resData on crypto failure, potentially exposing sensitive plaintext", + "candidate_severity": "High", + "lenses": ["silent-failure", "security-regression"] + }, + { + "path": "apps/caramel-extension/background.js:161", + "reason": "Fire-and-forget async pattern: fetch().then(r => {...}).then(resp => sendResponse(resp)).catch(...) without await", + "candidate_severity": "High", + "lenses": ["fire-and-forget", "silent-failure"] + }, + { + "path": "apps/caramel-extension/background.js:191", + "reason": "Fire-and-forget with fallback to empty array: fetchWithTimeout(...).then(...).catch(err => sendResponse({coupons: []}))", + "candidate_severity": "High", + "lenses": ["fire-and-forget", "silent-failure"] + }, + { + "path": "apps/caramel-app/src/lib/initMiddleware.ts:3", + "reason": "Exported function accepts untyped middleware: any parameter, no type safety for decorator pattern", + "candidate_severity": "High", + "lenses": ["missing-pre-AI-basics", "pointless-coupling"] + }, + { + "path": "apps/caramel-app/pnpm-lock.yaml", + "reason": "Nested pnpm-lock.yaml in apps/caramel-app violates monorepo rule (root also has pnpm-lock.yaml), causes version drift", + "candidate_severity": "High", + "lenses": ["version-drift", "duplication"] + }, + { + "path": "apps/caramel-extension", + "reason": "Extension built with plain JavaScript without TypeScript; no type safety for message schemas (background.js, popup.js, inject.js all untyped)", + "candidate_severity": "High", + "lenses": ["missing-pre-AI-basics", "producer-consumer-drift"] + }, + { + "path": "apps/caramel-app/src/lib/securityHelpers/cryptoHelpers.ts:63", + "reason": "encryptJsonServer(req: NextApiRequest, payload: any) accepts untyped payload, no validation before encryption", + "candidate_severity": "High", + "lenses": ["missing-pre-AI-basics"] + }, + { + "path": "apps/caramel-app/src/app/api", + "reason": "No request ID / trace ID propagation across API routes to Sentry; extension -> API calls lose correlation context", + "candidate_severity": "High", + "lenses": ["broken-trace-propagation"] + }, + { + "path": "apps/caramel-app/src/app", + "reason": "LLM classifier (cart classification via OpenRouter) has no AI quality evals; model degradation undetectable in CI", + "candidate_severity": "High", + "lenses": ["missing-evals", "unmarked-incomplete"] + }, + { + "path": "apps/caramel-app/src", + "reason": "No environment validation (zod/dotenv); missing OPENROUTER_API_KEY, EXTENSION_OAUTH_STATE_SECRET, etc. fail at runtime", + "candidate_severity": "High", + "lenses": ["missing-pre-AI-basics", "trial-and-error-bloat"] + }, + { + "path": "apps/caramel-extension/popup.js:0", + "reason": "No TypeScript or schema validation; popup<->background message protocol untyped; extension<->app API contracts loose", + "candidate_severity": "High", + "lenses": ["producer-consumer-drift", "missing-pre-AI-basics"] + }, + { + "path": "apps/caramel-app/src/app/api/coupons/expire/route.ts:16", + "reason": "EXTENSION_API_KEY via process.env with no validation; route depends on secret being set but no startup check", + "candidate_severity": "High", + "lenses": ["missing-pre-AI-basics", "trial-and-error-bloat"] + }, + { + "path": "apps/caramel-app/src/app/api/classify-cart/route.ts:67", + "reason": "req.json().catch(() => null) silently swallows JSON parse errors; payload size check at line 60 can be bypassed", + "candidate_severity": "Medium", + "lenses": ["silent-failure"] + }, + { + "path": "apps/caramel-app/src/app/api/extension/oauth/route.ts:83", + "reason": "await req.json().catch(() => ({})) silently swallows errors; missing provider/code checks at lines 92-96 proceed with empty object", + "candidate_severity": "Medium", + "lenses": ["silent-failure"] + }, + { + "path": "apps/caramel-app/src/lib/openrouter.ts:58", + "reason": "await res.text().catch(() => '') silently swallows text parsing errors; error detail discarded, makes debugging harder", + "candidate_severity": "Medium", + "lenses": ["silent-failure"] + }, + { + "path": "apps/caramel-extension/background.js:246", + "reason": "Empty catch block at line 246 (in extension page URL check), returns early without logging", + "candidate_severity": "Medium", + "lenses": ["silent-failure"] + }, + { + "path": "apps/caramel-app/src/components/coupons/coupon-filters.tsx:92", + "reason": "React Select component style props use untyped any callbacks (control, menu, option, singleValue, input, placeholder)", + "candidate_severity": "Medium", + "lenses": ["missing-pre-AI-basics"] + }, + { + "path": "apps/caramel-app/src/lib/apiResponseNext.ts:36", + "reason": "apiResponseNext export with untyped any parameters (metadata?: any, viewport?: any)", + "candidate_severity": "Medium", + "lenses": ["missing-pre-AI-basics"] + }, + { + "path": "local-dev/verify_test_small3.txt", + "reason": "Stray artifact file in repo (plaintext test data), no clear purpose or cleanup marker", + "candidate_severity": "Low", + "lenses": ["stray-artifact"] + } +] diff --git a/audit/premortem.md b/audit/premortem.md new file mode 100644 index 0000000..f41bc86 --- /dev/null +++ b/audit/premortem.md @@ -0,0 +1,198 @@ +# Caramel Pre-Mortem — written as a postmortem from Jan 2027 + +Repo state audited: `dev` @ `537547b3081aa3a0ec817cdc5f6dac4f0d328dbb`. Read-only pass. +Priorities: maintainability, operability. Security architecture treated as accepted (findings below are framed as availability/contract/cost, not "is this secret safe"). + +--- + +## 1. The postmortem narrative + +### What the quarter looked like (Q4 2026 → Jan 2027) + +**The outage.** On a Tuesday the Python verification team — who own the `caramel_coupons` database that Next.js reads directly — shipped a routine migration to their own repo: they renamed `coupons.verification_message` and folded three coupon `status` values into a new one. They had no reason to think this would touch the web app; the web app is a different repo and the column rename was internal to their service. Within minutes every coupon surface on grabcaramel.com and every extension `fetchCoupons`/`fetchSupportedStores` call started returning `{ "coupons": [] }` and HTTP 500s. The Next.js app talks to that database through hand-written template-string SQL (`couponsSql`) with **no generated types, no schema, and no shared contract** (`apps/caramel-app/src/lib/couponsDb.ts`), so nothing caught the drift at build time — `pnpm tsc --noEmit` is green because the columns live in string literals. + +The outage was not just likely; it was **invisible**. `/api/health/db` only runs `prisma.$queryRaw SELECT 1` against the _auth_ Postgres, which was perfectly healthy, so Uptime Kuma stayed green the entire time. Every coupon route `catch`es its error and returns a 500 after a `console.error` — and because the handlers catch, Next's `onRequestError` → `Sentry.captureRequestError` hook never fires and there is no `Sentry.captureException` in any catch block. So Sentry was silent too. The team found out from Discord and support tickets ~40 minutes in, then spent the incident reverse-engineering which of a dozen untyped SQL literals referenced the renamed column, because the app has no map of what it reads from the database it doesn't own. `.env.example` doesn't even list `COUPONS_DATABASE_URL`, so the newest on-call engineer initially didn't know the second database existed. + +To make it worse, that same week a well-meaning infra change put the app behind a second replica for a marketing push. The rate limiter is an in-memory token bucket (`apps/caramel-app/src/lib/rateLimit.ts`) that `getClientIp` collapses to a single `'unknown'` bucket whenever `x-real-ip`/`x-forwarded-for` aren't set the way it expects — so a proxy tweak briefly funneled all traffic into one 120-req/min global cap and 429'd real users, and the classify-cart LLM cache (per-instance `Map`) halved its hit rate and doubled OpenRouter spend. + +**The missed deadline.** The same quarter, the team committed to "auto-apply live on our top 40 stores across all browsers by end of quarter." It slipped badly, and the reasons are all structural. Adding a store or a coupon `status` means editing raw SQL string literals in four-plus routes that already disagree with each other (`coupons/route.ts` surfaces seven statuses; `stats`/`filters` only `'valid'`), with no compiler help and no single source of truth. Worse, the extension ships **three divergent manifests** — `manifest.json` (v1.1.0, matches `https://*/*`), `manifest-firefox.json` (v1.0.5, matches only amazon/ebay/codecademy and references an `amazon.js` file that does not exist in the repo), and `package.json` (v1.0.2) — so "ship it to all browsers" meant hand-reconciling files that had silently drifted for months, and the Firefox build only ever injected on three hard-coded domains regardless of what the store-config API served. The release workflow has no version-bump step, so two attempted Chrome uploads were rejected for duplicate version numbers before someone remembered to hand-edit the manifest. And every deploy required a human to remember to run `prisma migrate deploy` by hand (it is in no build/start script and no deploy config), which everyone was afraid to do during a feature crunch. The "40 stores everywhere" epic died by a thousand manual, drift-prone paper cuts — the same class of problem that caused the outage, viewed from the maintainer's chair instead of the pager's. + +### The one-line thesis + +The product's core data lives in a database this repo does not own, is read through untyped string SQL, and has **neither a health check nor error capture** — so any drift or outage in it is simultaneously easy to cause, invisible to monitoring, and expensive to change. Every other finding is a variation on "two representations of one thing that are allowed to drift." + +--- + +## 2. CAUSAL FINDINGS + +```json +[ + { + "id": "PM-1", + "location": "apps/caramel-app/src/lib/couponsDb.ts:1", + "quote": "// Read-only connection to the `caramel_coupons` database owned by the\n// Python verification service. All mutations to the coupon catalog flow\n// through that service — Next.js only reads (plus two narrow mutations:\n// usage-increment and expire, both exposed to the extension).\nimport postgres from 'postgres'\n\nconst connectionString = process.env.COUPONS_DATABASE_URL", + "what": "The entire coupon catalog + store xpath configs (the product's core data) live in an externally-owned Postgres DB, read via hand-written template-string SQL against tables (coupons, store_verification_configs, verification_stores) that have no schema, no generated types, and no migration/versioning inside this repo. tsc cannot see column names embedded in SQL strings.", + "why_it_matters": "A column rename, type change, or status-value change in the other team's repo silently breaks every coupon endpoint at runtime with zero build-time warning. The schema-drift CI job only guards the Prisma/auth DB; the coupons DB has zero contract coverage. This is the single largest maintainability + operability liability.", + "severity": "Critical", + "confidence": 0.95, + "fix_direction": "Introduce a versioned contract for the coupons DB: a typed data-access layer (generated types or a checked-in view/DTO the Python team must not break), contract tests run in CI against a seeded coupons schema, and an ADR pinning the column/status vocabulary as a cross-repo interface.", + "effort": "L", + "category": "architecture" + }, + { + "id": "PM-2", + "location": "apps/caramel-app/src/app/api/health/db/route.ts:9", + "quote": "const result = await timedCheck('database', async () => {\n await prisma.$queryRaw`SELECT 1`\n })\n\n return NextResponse.json(result, {\n status: result.status === 'ok' ? 200 : 503,\n })", + "what": "The only DB health check probes the Prisma/auth database. There is no readiness probe for the coupons DB (couponsSql / COUPONS_DATABASE_URL). Meanwhile every coupon route catches its error and returns a 500 after console.error, and Sentry is wired only via Next's onRequestError (instrumentation.ts:12 `export const onRequestError = Sentry.captureRequestError`) which never fires for a caught error — no catch block calls Sentry.captureException.", + "why_it_matters": "A coupons-DB outage or drift takes down 100% of coupon functionality while /api/health/db returns 200 (Uptime Kuma green) and Sentry records nothing. The incident is only discoverable via user complaints, guaranteeing a long time-to-detect. This is what turns PM-1's likely failure into a prolonged, invisible one.", + "severity": "Critical", + "confidence": 0.85, + "fix_direction": "Add a coupons-DB health check (SELECT 1 over couponsSql) to the readiness endpoint; add Sentry.captureException (or rethrow) in coupon-route catch blocks so 5xx from the coupons DB page someone. Alert on coupon-route 5xx rate.", + "effort": "S", + "category": "operability" + }, + { + "id": "PM-3", + "location": "apps/caramel-extension/background.js:11", + "quote": "const _isDevInstall = () => {\n try {\n return !currentBrowser.runtime.getManifest().update_url\n } catch (_) {\n return false\n }\n}\nglobalThis.CARAMEL_BASE_URL = _isDevInstall()\n ? 'https://dev.grabcaramel.com'\n : 'https://grabcaramel.com'", + "what": "The dev-vs-prod backend switch (and the postMessage trusted-origins list in shared-utils.js) keys entirely on the presence of `update_url` in the manifest. Only Chrome Web Store packed builds get an injected update_url. Safari builds produced by `safari-web-extension-converter` (release-extension.yml) and Firefox/AMO builds do not carry update_url, so `_isDevInstall()` returns true for real production installs on those browsers.", + "why_it_matters": "Production Safari/iOS and Firefox users are routed to the DEV backend (dev.grabcaramel.com) and their extension additionally trusts localhost/dev origins. The blast radius is the entire Safari+iOS+Firefox install base, and because store review takes weeks, a bad build cannot be rolled back quickly. The top commit just repointed dev installs from localhost to dev.grabcaramel.com, freshly activating this path.", + "severity": "Critical", + "confidence": 0.75, + "fix_direction": "Do not infer environment from update_url. Bake the target backend into a build-time constant per store target (separate build outputs for Chrome/Firefox/Safari/dev), or gate dev behavior behind an explicit build flag rather than a browser-specific manifest artifact.", + "effort": "M", + "category": "operability" + }, + { + "id": "PM-4", + "location": "apps/caramel-app/src/app/api/coupons/route.ts:47", + "quote": "couponsSql`status IN ('valid','valid_with_warning','product_restriction','category_restricted','seller_specific','pending','retry') AND expired = FALSE`,", + "what": "The 'which coupon statuses are user-visible' business rule is copy-pasted as a raw-SQL string literal across coupons/route.ts and coupons/stores/route.ts (the 7-status list, twice), while stats/route.ts and filters/route.ts use `status = 'valid'` only, and types/coupon.ts defines a 9-value enum. Four+ representations of one vocabulary, already inconsistent.", + "why_it_matters": "The list and the filter dropdown already disagree (a store can appear in results but not in the filter). Adding or renaming a status — which the externally-owned service can do at any time — is a multi-file scavenger hunt across untyped strings with no compiler or test to catch a miss. Direct feature-velocity drag and a latent correctness bug.", + "severity": "High", + "confidence": 0.9, + "fix_direction": "Define the visible-status set once (a typed constant / shared SQL fragment) and reference it from every query; add a test asserting list, stats, and filters use the same predicate.", + "effort": "S", + "category": "duplication" + }, + { + "id": "PM-5", + "location": "apps/caramel-extension/manifest-firefox.json:3", + "quote": "\"version\": \"1.0.5\",\n...\n \"content_scripts\": [\n {\n \"matches\": [\n \"https://www.amazon.com/*\",\n \"https://*.ebay.com/*\",\n \"https://*.codecademy.com/*\"\n ],\n \"js\": [\"shared-utils.js\", \"UI-helpers.js\", \"inject.js\", \"amazon.js\"]", + "what": "Three versions for one extension (manifest.json 1.1.0, manifest-firefox.json 1.0.5, package.json 1.0.2). The Firefox manifest is hand-maintained and structurally divergent: it matches only 3 hard-coded stores (vs `https://*/*` in Chrome), requests different permissions (adds management/alarms), uses a background `scripts` array instead of a service worker, and lists `amazon.js` in content_scripts — a file that does not exist in the repo.", + "why_it_matters": "Firefox users get a stale, mostly-broken product where the dynamic supported-stores API is defeated by a 3-domain content-script match, and the missing amazon.js can break injection on the domains it does target. Shipping any new supported store 'everywhere' requires reconciling divergent hand-edited manifests — a core reason the cross-browser rollout slips.", + "severity": "High", + "confidence": 0.85, + "fix_direction": "Generate per-browser manifests from one source of truth with a single version field; remove dead file references; drive content-script matches from the same store list the API serves. Add a CI check that every manifest content-script file exists.", + "effort": "M", + "category": "modularity" + }, + { + "id": "PM-6", + "location": "apps/caramel-app/src/lib/rateLimit.ts:6", + "quote": "// Per-IP rate limiting for public API routes. In-memory token buckets —\n// sufficient for single-instance dev + prod. Swap to\n// `RateLimiterRedis` (same API) when we scale to multiple instances.", + "what": "Rate limiting and the classify-cart LLM cache are both in-memory and per-instance. getClientIp falls back to a literal `return 'unknown'` when x-real-ip/x-forwarded-for are absent, so all such traffic shares one bucket. A Redis service already exists in local-dev compose but is unused by the limiter.", + "why_it_matters": "The moment the app runs more than one replica (or during a rolling deploy), limits and the LLM cost cache silently degrade. If the reverse proxy doesn't set the expected IP headers, every request collapses into the single 'unknown' bucket and the whole API is throttled to 120/min globally — a self-inflicted outage triggered by an infra change, not a code change.", + "severity": "High", + "confidence": 0.8, + "fix_direction": "Move rate limiting + classify cache to Redis (already provisioned). Fail closed-or-explicit on missing client IP rather than a shared 'unknown' bucket; verify the Dokploy/Traefik proxy forwards x-real-ip.", + "effort": "M", + "category": "operability" + }, + { + "id": "PM-7", + "location": "apps/caramel-app/nixpacks.toml:6", + "quote": "[phases.install]\ncmds = [\"pnpm install --no-frozen-lockfile\"]\n\n[phases.build]\ncmds = [\"pnpm run build\"]\n\n[start]\ncmd = \"pnpm run start\"", + "what": "The deploy path (nixpacks: install → build → start; app build = `prisma generate && next build`; start = `next start`) contains no `prisma migrate deploy`. Migrations are applied only by the CI schema-drift job against an ephemeral Postgres, never against prod. Applying prod migrations is a manual `pnpm db:migrate:deploy` that lives in no runbook.", + "why_it_matters": "A deploy that includes a schema change but where nobody remembers to migrate prod boots the app against an old schema — auth queries fail and the site goes down, with no automation or documentation to prevent it. During a feature crunch this is exactly when it gets forgotten.", + "severity": "High", + "confidence": 0.85, + "fix_direction": "Add `prisma migrate deploy` as a release/start-gate step (idempotent) or a documented, enforced deploy hook; make it impossible to ship code without the matching migration.", + "effort": "S", + "category": "operability" + }, + { + "id": "PM-8", + "location": "apps/caramel-app/.env.example:1", + "quote": "# Database\nDATABASE_URL=\"postgresql://postgres:postgres@localhost:2345/caramel\"\n...\n# useSend Email\nUSESEND_API_KEY=\n...\n# OpenRouter (extension cart classifier)\nOPENROUTER_API_KEY=", + "what": ".env.example omits multiple env vars the code hard-requires: COUPONS_DATABASE_URL (couponsDb.ts throws on import if unset), EXTENSION_API_KEY, EXTENSION_OAUTH_STATE_SECRET, ALLOWED_ORIGINS, UPKUMA_HEALTH_SECRET. It also contradicts itself operationally: it documents USESEND_* (which email.ts actually reads) while scripts/ci-env.ts writes SMTP_HOST/USER/PASSWORD that no code reads, and its DB port (2345) matches neither local-dev (58005) nor BETTER_AUTH_URL's port (3000 vs app PORT 58000).", + "why_it_matters": "The environment contract is undocumented and internally inconsistent, so a new engineer or AI agent cannot boot the product's coupon features from the docs, and a prod deploy missing COUPONS_DATABASE_URL crashes coupon routes on first import while auth (and the health check) stay green. No zod/env validation centralizes the real surface.", + "severity": "High", + "confidence": 0.9, + "fix_direction": "Introduce validated env parsing (zod) as the single source of truth; regenerate .env.example from it; reconcile ci-env.ts to the real vars (USESEND_*, COUPONS_DATABASE_URL, etc.).", + "effort": "S", + "category": "docs" + }, + { + "id": "PM-9", + "location": "apps/caramel-app/nixpacks.toml:7", + "quote": "cmds = [\"pnpm install --no-frozen-lockfile\"]", + "what": "Production builds install with --no-frozen-lockfile, while all four CI jobs install with --frozen-lockfile. Prod can therefore resolve different (newer) transitive dependency versions than CI ever tested.", + "why_it_matters": "Non-reproducible prod builds: 'green in CI, broken in prod' and 'worked yesterday, broke today with no code change' become possible whenever an upstream dependency publishes. Undermines the whole point of a committed lockfile and makes incident bisection unreliable.", + "severity": "Medium", + "confidence": 0.85, + "fix_direction": "Use --frozen-lockfile in the deploy install; make lockfile updates an explicit, reviewed change.", + "effort": "S", + "category": "dependencies" + }, + { + "id": "PM-10", + "location": ".github/workflows/release-extension.yml:1", + "quote": " - name: Build extension\n run: pnpm run build\n\n - name: Package extension\n run: pnpm run package\n...\n - name: Upload to Chrome Web Store (upload-only)\n uses: mnao305/chrome-extension-upload@v5.0.0\n with:\n file-path: extension.zip\n publish: false", + "what": "The release workflow builds, packages, and uploads to the stores with no version-bump step. The version shipped is whatever a human last hand-edited into manifest.json — which already disagrees with manifest-firefox.json and package.json (PM-5). Firefox has no publish job here at all.", + "why_it_matters": "Chrome and Apple reject uploads whose version isn't strictly greater than the live one, so a merge to main without a manual bump fails the release at the worst possible moment. Release is gated on human memory, and the observed 3-way version drift is direct evidence the manual bump is unreliable.", + "severity": "Medium", + "confidence": 0.8, + "fix_direction": "Automate a single version bump across all manifests on release (or derive all manifests + package.json from one version source); add Firefox to the release automation.", + "effort": "M", + "category": "operability" + }, + { + "id": "PM-11", + "location": "apps/caramel-app/src/lib/rateLimit.ts:160", + "quote": "export function isOriginAllowed(req: NextRequest): boolean {\n const origin = req.headers.get('origin')\n if (!origin) return true", + "what": "The classify-cart endpoint (a paid OpenRouter LLM call) is gated only by isOriginAllowed + a 30/min per-IP mutation limit and an in-memory cache. isOriginAllowed returns true whenever there is no Origin header, so any server-side/scripted caller with no Origin passes the gate; only per-IP rate limiting and the per-instance cache bound spend. No API key is required (unlike supported-stores/expire).", + "why_it_matters": "Uncontrolled third-party LLM cost exposure and a hard runtime dependency on OpenRouter for the 'relevant coupons' feature, with no server-side budget guard. Per-instance cache (PM-6) means cost also scales with replica count and cold starts. Framed as cost/operability, not access-control.", + "severity": "Medium", + "confidence": 0.7, + "fix_direction": "Require the extension API key on classify-cart, add a global spend cap/circuit-breaker around openrouter, and move the cache to shared Redis so cost is bounded independent of instance count.", + "effort": "M", + "category": "operability" + }, + { + "id": "PM-12", + "location": "apps/caramel-extension/background.js:1", + "quote": "const { site, kw, category } = message\n const url = new URL(caramelUrl('api/coupons'))\n url.searchParams.set('site', site)\n url.searchParams.set('key_words', kw || '')\n url.searchParams.set('limit', '20')\n if (category) url.searchParams.set('category', category)", + "what": "The shipped extension sends a `category` query param to /api/coupons, but the route parses only site/page/limit/search/type/key_words — `category` is silently ignored. The classify-cart result the extension pays an LLM for is dropped on the floor server-side.", + "why_it_matters": "A live contract mismatch between a client that lives for weeks and an API that deploys continuously, with nothing (no shared types, no contract test) to flag it. Today it's a dead param and a wasted classification; the same invisible gap is how a real client/API break ships unnoticed.", + "severity": "Low", + "confidence": 0.8, + "fix_direction": "Either implement category filtering server-side or remove it client-side; add a contract test covering the extension's exact /api/coupons request shape.", + "effort": "S", + "category": "clarity" + }, + { + "id": "PM-13", + "location": "apps/caramel-extension/background.js:27", + "quote": "const EXTENSION_API_KEY = 'WXqEpm2uOV5jjJXPpnQFyZiNdaPVUrtd2LIrf4kc1JA'", + "what": "A single shared EXTENSION_API_KEY value is baked into every shipped client and validated server-side by supported-stores and expire (and used for extension detection in rateLimit). Not auditing whether embedding a secret is acceptable — the operability trap is that it is one value shared by all clients that cannot be rotated without simultaneously invalidating every extension already in the field.", + "why_it_matters": "If the server ever rotates this key (leak, policy, incident response), every shipped extension immediately 401s on supported-stores → store xpath configs stop loading → auto-apply breaks for the entire install base until users receive an updated build, which is weeks away through store review. A recovery action that should take minutes takes weeks.", + "severity": "High", + "confidence": 0.7, + "fix_direction": "Support key versioning / overlap (accept old+new during a rotation window), or move extension auth to a rotatable per-client/short-lived token so the server can rotate without bricking fielded clients.", + "effort": "M", + "category": "operability" + } +] +``` + +--- + +## 3. Discard list (claims that failed my own quote test) + +- **"`pnpm doctor` in checks-app.yml is a broken/no-op CI step."** DISCARDED — `pnpm doctor` is a real pnpm command ("Checks for known common issues", verified via `pnpm doctor --help`), so the "Validate dependencies" step is valid, if shallow. No defect. +- **Hardcoded EXTENSION_API_KEY / base URLs as a _security_ finding.** DISCARDED as security (out of scope per brief). Kept only the operability/rotation-coupling angle as PM-13. +- **Better-auth cookie SameSite/Secure and OAuth state-signing logic.** DISCARDED — this is auth/security architecture, explicitly accepted; the code (HMAC-signed state, timingSafeEqual, secure-cookie derivation) is deliberate and not a maintainability/operability defect. +- **"Coupons DB exposes mutations (increment/expire) from a 'read-only' client — inconsistency."** DISCARDED — couponsDb.ts explicitly scopes the two mutations and they are intentional; no drift/defect, just naming. +- **"In-memory classify-cart cache is unbounded."** DISCARDED — it is explicitly capped (CACHE_MAX 2000, 24h TTL) with LRU eviction; the real issue (per-instance, not shared) is captured under PM-6/PM-11, so no separate finding. +- **`ci-env.ts` writing SMTP\_\* that no code reads, as a standalone finding.** DISCARDED as standalone — folded into PM-8 as evidence of an unreconciled env surface rather than double-counted. diff --git a/audit/rules-checklist.md b/audit/rules-checklist.md new file mode 100644 index 0000000..244ce92 --- /dev/null +++ b/audit/rules-checklist.md @@ -0,0 +1,121 @@ +# Caramel — Shared Engineering Rules Checklist + +**Doctrine:** Canonical rules file — `C:\Users\alaed\.claude\skills\codebase-audit\references\shared-claude-rules.md` — **v5 · 2026-07-10** (version stamp confirmed at the file's own header, line 3: ``). This checklist is graded against **that file directly**, not any paraphrase or summary of it. Every rule bullet below is quoted/paraphrased from the live file read at the start of this task. + +Cross-referenced against `C:\Users\alaed\Documents\Github\caramel\audit\findings.json` — 15 findings (F-001..F-015), `rules_version: "v5 2026-07-10"`, `baseline_sha: 537547b3081aa3a0ec817cdc5f6dac4f0d328dbb`, `generated_stage: "1-synthesis (pre-verification)"`. Repo graded at `dev @ 537547b` — confirmed as current HEAD via `git log -1` at the start of this pass. + +All evidence below was independently re-verified against the live repo (not just quoted from findings.json) — file contents were re-read, `git grep`/`git ls-files`/`git log` were re-run, and `gh api`/`gh secret list` were queried live against `DevinoSolutions/caramel` (branch-protection and secrets state below are live, not from the findings file). + +--- + +## Errors & visibility + +| # | Rule bullet | Verdict | Evidence / reasoning | +| ----- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| ERR-1 | No silent failures: never `except: pass` / swallowed catch / catch-and-continue. Errors throw loudly with Sentry-usable context. | **VIOLATION → F-002** | `apps/caramel-extension/background.js:191-202` collapses non-2xx responses into `{coupons:[]}`; 9 API routes do `req.json().catch(()=>({}))`; `src/lib/securityHelpers/cryptoHelpers.ts` decrypt path (used by `decryptJsonData.ts:14-22`) returns raw ciphertext on failure instead of throwing. Confirmed the `catch { return false }`-style swallow pattern directly in `apps/caramel-app/src/lib/rateLimit.ts:186-188` (`isOriginAllowed` catch-all returns `false` silently, no log) — a second, independent instance of the same convention gap beyond F-002's own citations. | +| ERR-2 | One trace/request ID flows end-to-end across every hop (Next.js → iii worker → Python/JS/Rust script) via Sentry distributed tracing / OTel headers. | **VIOLATION → GAP: no finding** | `apps/caramel-app/sentry.common.config.ts:11` sets `tracesSampleRate: 1` (Sentry APM tracing enabled _inside_ the Next.js process), but there is no iii worker in this repo, and the one real cross-process hop that matters — the app ↔ external Python coupon-verification service (F-001) — has no shared trace/request-ID propagation: `couponsDb.ts` talks to that boundary via a second Postgres connection (raw SQL), not an instrumented HTTP call, so no header carries a trace ID across it. The OpenRouter LLM call (`src/lib/openrouter.ts`) is also a real outbound hop with no evidence of trace-header propagation. None of F-001–F-015 names this gap specifically — it is a real, distinct violation with no covering finding. | +| ERR-3 | No fire-and-forget: async/background work returns success/failure and callers check it; a failure that never surfaces is a defect. | **VIOLATION → F-002** | Same root cause as ERR-1: `background.js`'s `.then(async r => {...})` chains (lines ~166, 192, 210) resolve to a _shaped success_ (`{coupons:[]}` / `{supported:[]}`) rather than a checkable failure, so the caller (content script / popup) cannot distinguish "really empty" from "the request failed" — the check-the-result contract is defeated by the shape of the "success" itself, not by an unawaited promise. (Spot-checked: outbound email sends _are_ correctly awaited — `src/app/api/sites/suggest/route.ts:12`, `src/lib/auth/auth.ts:57` both `await sendEmail(...)` — so this is not a repo-wide fire-and-forget problem, it is specifically the error-shaping defect F-002 already names.) | + +## Typing & contracts + +| # | Rule bullet | Verdict | Evidence / reasoning | +| ----- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| TYP-1 | Strict TypeScript: no `any`, no lazy inference on exported surfaces. Python gets ruff + ty even for small helper scripts. | **VIOLATION → F-013** (TS clause); **N/A** (Python clause) | `apps/caramel-app/tsconfig.json:7` sets `"strict": true` (the compiler flag itself is correctly configured), but F-013's 26-instance `any` census on exported surfaces (`decryptJsonData(resData:any):any`, `encryptJsonServer(payload:any)` in `cryptoHelpers.ts:63`, `initMiddleware(any)`, `apiResponseNext.ts:36`) directly violates the "no `any` on exported surfaces" clause; `tsc --noEmit` passes today only because these holes exist. Python clause is **N/A** — confirmed zero `.py` files tracked anywhere in the repo (`git ls-files -- '*.py'` → empty) and zero `ruff`/`ty` references repo-wide; the only Python in the picture is the external, out-of-repo coupon-verification service (F-001), which this repo does not own or lint. | +| TYP-2 | `.env` is zod-validated before boot; `.env.example` is the vocabulary. Boot must fail fast on a bad env, not debug-loop at runtime. | **VIOLATION → F-005** | Confirmed zero `zod` or `t3-oss/env`/`createEnv` usage anywhere under `apps/caramel-app/src` (`git grep -in 'zod'` / `'t3-oss\|createEnv'` both empty). `.env.example` documents exactly **19** vars (counted directly: `DATABASE_URL, JWT_SECRET, BCRYPT_SALT_ROUNDS, BETTER_AUTH_URL, BETTER_AUTH_SECRET, NEXT_PUBLIC_BASE_URL, GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, APPLE_CLIENT_ID, APPLE_CLIENT_SECRET, APPLE_REDIRECT_URI, NEXT_PUBLIC_SENTRY_DSN, USESEND_BASE_URL, USESEND_API_KEY, USESEND_FROM_EMAIL, USESEND_FROM_NAME, OPENROUTER_API_KEY, OPENROUTER_MODEL, NODE_ENV`) — matches F-005's "19 documented vs 38 read" exactly. `couponsDb.ts:9-12` throws only at first _use_, not at boot. No boot-time env module exists. | +| TYP-3 | Producer and consumer share one schema: if the consumer validates with zod, the producer must use the same contract — drift fails statically or in CI, never only at runtime. | **VIOLATION → F-001** | The coupon catalog is produced by an external, out-of-repo Python service and consumed via untyped template-string SQL in `couponsDb.ts:1-12` — no shared schema/contract of any kind, so a producer-side column rename is only discovered in production. (Note: this is distinct from the Prisma-managed auth/user schema, which _does_ have CI drift protection — see CI-baseline / PIECE-6 below. F-001 is specifically about the second, non-Prisma coupons DB.) | +| TYP-4 | Pinned versions everywhere: no `@latest`, lockfile committed and enforced; the SDK version in the app must match what infra actually hosts. | **VIOLATION → F-014** | `apps/caramel-app/package.json` and root `package.json` use `^`-range devDependencies/dependencies throughout (no pinned exact versions) — e.g. `"next": "16.1.1"` is pinned but `"prisma": "^6.14.0"`, `"react": "19.2.3"` pinned but `"@sentry/nextjs": "^10.18.0"` is not; inconsistent. Lockfile _is_ committed (root `pnpm-lock.yaml`), and enforced via `pnpm install --frozen-lockfile` in `checks-app.yml` (lines 43, 110, 182, 240) — but **not** enforced in `checks-extension.yml:30` or `release-extension.yml:38`, both of which run plain `pnpm install`. Confirmed a **third** duplicate lockfile beyond the one F-014 cites: `apps/caramel-app/pnpm-lock.yaml` (186.8 KB) _and_ `apps/caramel-extension/pnpm-lock.yaml` (140.9 KB) both exist alongside the root `pnpm-lock.yaml` (420.5 KB) — three lockfiles for one pnpm workspace, confirming the drift risk is broader than F-014's single citation. Node version _is_ consistently pinned to `20` across `nixpacks.toml:4` and all three CI workflows — that sub-clause passes. | + +## Structure + +| # | Rule bullet | Verdict | Evidence / reasoning | +| ------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| STR-1 | (monorepos) Landing page + main app share one UI package for the big shared elements (animations, logos, hero components) — duplicating them across apps is a finding. | **N/A** | This monorepo has no separate landing-page app to share a UI package with. `pnpm-workspace.yaml` / root `package.json` both glob only `apps/*`, which resolves to exactly two members: `caramel-app` and `caramel-extension` — no `packages/*` shared-UI directory exists at all (`git ls-files -- 'packages/*'` → empty). The landing/marketing site _is_ `apps/caramel-app` itself (confirmed in `README.md:50`: "`caramel-app` — App that includes logic for web app, API site (grabcaramel.com)"; the package is even literally named `caramel-landing`, per F-015). The rule's precondition (a monorepo containing a _separate_ landing app and main app) doesn't hold here, so there is nothing to grade under this bullet's literal scope. (Note: F-006 documents a related-but-distinct problem — coupon-domain logic/UI constants duplicated across the **app↔extension** boundary, not landing↔app — see AIH-8 below.) | +| STR-2 | ONE root docker-compose runs the real service graph (web, workers, sidecars, DB) with prod-mode builds — local, CI, and prod are the same graph, literally: prod runs this exact file as ONE platform compose service. `pnpm dev` = `docker compose up --build`, NOT framework dev mode. A missing/broken root compose is a P1 finding, never silently punch-listed. | **VIOLATION → GAP: no finding** | This is the clearest and most severe gap in the whole checklist. Confirmed by direct inspection: (1) the **only** compose file in the repo is `local-dev/docker-compose.yml`, whose own header comment says _"Backing services only; apps run outside Docker for fast reload"_ — it defines exactly two services, `postgres` and `redis`; the actual app and extension are **not** in the graph at all. (2) Root `package.json:10`: `"dev": "pnpm run dev:compose && pnpm -r --parallel dev"` — `dev:compose` runs `docker compose ... up -d --remove-orphans` (detached, no `--build`) for the two sidecars only, then `pnpm -r --parallel dev` runs each app's own dev script — `apps/caramel-app`: `next dev` (hot reload), `apps/caramel-extension`: `web-ext run --watch-file=./**/*` (hot reload). This is precisely the anti-pattern the rule names and rejects, not an edge-case reading of it. (3) Zero Dockerfiles exist anywhere in the repo (`git ls-files \| grep -i dockerfile` → empty), and zero `dokploy` references exist repo-wide — `apps/caramel-app/nixpacks.toml` shows the app actually deploys via **Nixpacks buildpacks** (`pnpm install && pnpm run build` / `pnpm run start`), a completely different mechanism from compose, confirming local/CI/prod are demonstrably **not** the same graph. Per the rule's own text this is a P1 finding that must never be silently punch-listed — yet none of F-001–F-015 mentions docker-compose, dev/prod parity, or Nixpacks at all. This is the single largest gap this checklist surfaces. | +| STR-2a | **PROD GATE EXCEPTION** (Aladdin 2026-07-10): uNotes, Shorty, GetItDone — never touch prod without explicit in-session confirmation; run Phases 0–3 dark in the project's Dokploy DEV environment and stop. Projects not listed follow the standard full flow. | **N/A** | Caramel is **not** on the exception list (only uNotes/Shorty/GetItDone are). The standard full one-root-compose flow applies to caramel with no protective carve-out — which makes the STR-2 violation above a plain, ungated P1, not something excusable pending user confirmation. Flagging this explicitly per the task brief. | + +## AI-session hygiene + +| # | Rule bullet | Verdict | Evidence / reasoning | +| ----- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| AIH-1 | Names are non-ambiguous: a schema/var/function name tells an agent what it does without digging through other files. | **VIOLATION → F-008** (one instance covered) **+ GAP: no finding** (second instance) | F-008 covers one instance well: `apps/caramel-extension/index.html:61-63` loads `shared-utils.js`/`UI-helpers.js`/`popup.js` whose names imply modularity that doesn't exist (`shared-utils.js` is a 1536-line, 6-responsibility god-module). A **second, uncovered** instance: `apps/caramel-app/src/lib/securityHelpers/cryptoHelpers.ts` exports functions literally named `encrypt`/`decrypt` (lines 46-55) that implement XOR-with-a-guessable-key (domain+user-agent, both attacker-visible), not real encryption — a name that actively lies about what the code does, the textbook case this bullet warns against. This is called out only in `audit/AUDIT.md`'s appendix as `DD3-8 ("XOR obfuscation named 'encryption'")`, and was **not promoted** to any of F-001–F-015. Flagging as GAP. | +| AIH-2 | Every new session/compact is a freelancer's first day. Unfinished work carries loud `TODO:`/limitation markers in the code itself — an unmarked incomplete module is a defect. | **VIOLATION → GAP: no finding** | Repo-wide `git grep` for `TODO` across every tracked `.ts`/`.tsx`/`.js` file (excluding lockfiles) returns **zero matches** — confirmed twice, once with `-c` and once with a raw match-count (`0`). Yet real, known-incomplete work exists without any marker: F-009's whitelisted dead Pages-Router fossils, F-012's entirely-absent eval system, F-005's three coexisting missing-env strategies. None of it carries a `TODO:`/limitation comment as the rule requires. No finding calls out the _absence of the TODO convention itself_ (as distinct from the specific dead code/missing evals it should have flagged). | +| AIH-3 | A repo holds only tracked, current files... Ephemeral notes live in the task system or a gitignored scratch dir, never loose in the repo. Check: root-file allowlist gate in CI. | **VIOLATION → F-015** | `local-dev/verify_test_small3.txt` (a stray committed Python error message) is confirmed **tracked** (`git ls-files -- 'local-dev/verify_test_small3.txt'` returns the path). `.prettierrc.json`'s invalid `tailwindConfigPath` key and the `caramel-landing` package-name mismatch are also confirmed present (see PIECE-2 and STR-1). No root-file allowlist gate exists in `.github/workflows/*` (`grep -i 'allowlist\|root-file'` → no matches) — confirms the "Check:" clause is unmet too. **Correction to F-015's own evidence, found during re-verification**: F-015 also cites _"a tracked `nul` file at root"_ — but `git ls-files -- nul` returns nothing and `git cat-file -p HEAD:nul` fails with `fatal: path 'nul' does not exist in 'HEAD'` at the exact baseline SHA (537547b). The `nul` file is present in the working tree (`git status` shows `?? nul`, i.e. untracked) but is **not** part of the committed tree — it looks like a local Windows/git-bash artifact (a stray `> nul` / `2>nul` cmd.exe-style redirect run under a POSIX shell creates a literal file named `nul`), not something that ships to every clone. The overall finding still holds (verify_test_small3.txt is genuinely tracked and stray), but the "nul is tracked" sub-claim does not hold up under direct verification and should be corrected if F-015 is revised. | +| AIH-4 | Mocks must announce themselves (naming, comment, TODO). A mocked result an agent can mistake for a real implementation is a defect. | **PASS** | The one identifiable test double in the repo is properly self-announcing: `apps/caramel-extension/scripts/test-extension.mjs:1-16` (doc comment) — step 7, "Injection logic: loads `shared-utils.js` against **a fake store DOM** and verifies `applyCoupon()` finds the input, fills the code, and clicks apply" — explicitly labeled "fake," not disguised as a real store. No evidence of an unlabeled mock elsewhere (the repo has very little test surface to begin with, per F-004). | +| AIH-5 | No trial-and-error layering ("try A, else B, else C"): dead branches and redundant guards left from iteration get removed before commit. | **VIOLATION → F-005** | F-005 documents "three different missing-env strategies coexist" across the codebase — the same underlying problem (no single, resolved approach; multiple half-migrated strategies left standing) applied at the repo level rather than inside one function, but the same smell the rule targets. | +| AIH-6 | Confidence only when verified: never claim something works without an end-to-end check. An unverified "it works" poisons every later decision in the session. | **VIOLATION → F-004** (+ F-009 secondary) | This is a behavioral/process rule, but the repo's own tooling makes "earned" confidence structurally impossible right now: F-004 confirms `pnpm test` is a false-green no-op (exits 0 having run zero tests — the tooling manufactures exactly the false confidence signal this rule warns against). F-009's knip gate (whitelisted fossils) is a second, milder instance of a "green" check that isn't earned. Re-verified live: `gh api repos/DevinoSolutions/caramel/branches/main/protection` and `.../dev/protection` both return `404 "Branch not protected"` — so even a red CI check on either branch gates nothing, compounding the false-confidence problem. | +| AIH-7 | Fetch current docs (context7) before coding against any library — training-data versions lie. | **N/A** | Process/session rule about agent behavior while coding; not a static artifact a repo can pass or fail on its own. (Circumstantial, not dispositive: F-14's dependency profile — 50/51 packages outdated, 83 vulnerabilities, one npm-deprecated dep — is consistent with, but doesn't prove, this practice not being followed over time.) | +| AIH-8 | Before adding a feature, check the knowledge graph (graphify) for existing logic: reuse and integrate; duplicating an existing concept is a defect. | **VIOLATION → F-006** | F-006 is the direct, textbook symptom of skipping this check: the coupon-status visibility whitelist is copy-pasted 5× across 4 files and has already drifted into 3 different definitions; the status→label/color map is re-declared independently in both the app and the extension; ranking `ORDER BY` is duplicated in 2 files. jscpd independently measured 65 clones / 5.55%. | +| AIH-9 | A green suite doesn't mean your change is safe — before rewriting a file, confirm the suite exercises it; if not, pin current behavior first (characterization tests). | **VIOLATION → F-004** | F-004: no real test suite exists at all (`pnpm test` runs zero tests). F-008's own fix_direction makes the connection explicit: _"Characterization tests (F-004) first — this is the highest-churn file, so pin behavior before cutting"_ for `shared-utils.js` (churn = 4529, the highest in the repo) — i.e., the repo's highest-risk file has zero pinned behavior today. | + +## Rules become checks + +| # | Rule bullet | Verdict | Evidence / reasoning | +| ----- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| RBC-1 | Every rule that matters gets a check that fails the build: lint rule, import ban, CI grep gate, schema-drift workflow, knip. A rule with no check will be forgotten. | **VIOLATION → F-009 + F-015** | F-009: the knip gate exists and runs in CI (`checks-app.yml` matrix `knip` task) but is neutered — `apps/caramel-app/knip.json:10-24`'s `ignore`/`ignoreDependencies` lists whitelist exactly the dead Pages-Router fossils (`src/lib/initMiddleware.ts`, `src/lib/middlewares/**`, `src/lib/securityHelpers/apiResponse.ts`, the `cors` dep) so the "check" stays green while blind to them. Compounded by branch protection being **live-confirmed absent** on both `main` and `dev` (`gh api .../branches/{main,dev}/protection` → `404 Branch not protected` for both), so even a red check wouldn't block a merge. F-015 adds a second instance in miniature: `.prettierrc.json`'s `tailwindConfigPath` key is invalid (should be `tailwindConfig`) so Tailwind-aware formatting silently never runs — a "gate" that does nothing and nobody notices. | +| RBC-2 | Ban the raw form the moment a shared helper exists (`no-restricted-syntax`/`no-restricted-imports`). Suppressions (`eslint-disable`, `@ts-expect-error`, `# noqa`) carry a dated reason or the PR is blocked. | **VIOLATION → F-006 / F-007** (ban-raw-form clause); **PASS** (suppression-hygiene clause) | Ban-the-raw-form: root `eslint.config.mjs` has no `no-restricted-imports`/`no-restricted-syntax` rule at all, so nothing stops the coupon-status whitelist (F-006) or the hand-rolled CORS/rate-limit blocks (F-007, e.g. 3 duplicated CORS blocks in one file) from being re-declared raw. Both findings' `fix_direction` explicitly names this exact mechanism as the missing piece. Suppression-hygiene sub-clause: repo-wide `git grep` for `eslint-disable`, `ts-expect-error`, and `noqa` all return **zero matches** — there are no suppression comments anywhere to lack a dated reason, so that specific sub-clause passes vacuously (nothing to violate) even though the broader "ban the raw form" clause fails. | + +## AI quality (evals) + +| # | Rule bullet | Verdict | Evidence / reasoning | +| ----- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| EVL-1 | Every user-facing LLM surface has an eval suite: fixed dataset → live production model+prompts → programmatic scorers → pass-rate gate. Eval files stay out of the unit-test glob. | **VIOLATION → F-012** | The repo's only user-facing LLM surface, `apps/caramel-app/src/lib/cartClassifier.ts` (feeds the popup's cart-category/restriction warnings via `src/app/api/classify-cart/route.ts`), has zero eval infrastructure. Confirmed directly: `git ls-files` / glob for `*.eval.*` anywhere in the repo → **no matches**. `cartClassifier.ts:110-146`'s `parseResponse` validates the LLM's JSON output by manual assertion/throw (`if (!(CATEGORY_ENUM...).includes(primary)) throw ...`), not a schema — confirms F-012's "validated by assertion, not a schema" detail precisely. | +| EVL-2 | Evals run in CI as a standing, permanently maintained gate: PR-triggered on AI-touching paths, nightly against live models with auto-opened issue on failure, plus manual dispatch. | **VIOLATION → F-012** | Confirmed no `ai-evals.yml` (or equivalent) exists among the repo's three workflows (`checks-app.yml`, `checks-extension.yml`, `release-extension.yml` — none mention evals, scorers, or nightly runs against `cartClassifier`/OpenRouter). | +| EVL-3 | Model changes are eval-gated: suite green twice + a dated scoreboard row before the swap ships; audit deploy-time model pins against code defaults at every swap. | **VIOLATION → F-012** | `apps/caramel-app/.env.example:25` and `cartClassifier`'s caller default `OPENROUTER_MODEL ?? 'openai/gpt-5-mini'` make the model env-swappable with **zero** gate of any kind on a change — no suite, so nothing to run twice; no scoreboard file/mechanism found anywhere in the repo. | +| EVL-4 | Real production AI failures become eval cases before (or with) the fix. | **VIOLATION → F-012** | There is no eval system to add a case to (EVL-1) — this bullet is violated by construction; any future `cartClassifier` production failure has nowhere to land as a regression case today. | +| EVL-5 | CI secrets are verified to EXIST (`gh secret list`). Check: ai-evals workflow present + its path filter matches the repo's actual AI surfaces. | **VIOLATION → F-012** (named "Check:" clause); **PASS** (general secret-existence practice, live-verified) | The bullet's own named check — an `ai-evals` workflow present and path-filtered to AI surfaces — fails outright: no such workflow exists (same evidence as EVL-2). Separately, I live-verified the _general_ principle with `gh secret list --repo DevinoSolutions/caramel`: every `secrets.*` reference actually used across the three real workflows (`ARGOS_TOKEN`, `CHROME_CLIENT_ID/SECRET/REFRESH_TOKEN`, `CHROME_EXTENSION_ID`, `ASC_*`, `APPLE_*`, `KEYCHAIN_PASSWORD`) does resolve to a real secret — none are silently unset today. Interesting adjacent detail: the `ARGOS_API_BASE_URL` repo variable is literally `https://api.snapvisor.io/v2/` — i.e. the visual-regression tooling already _is_ Snapvisor (via the Argos-CI-compatible client libraries), not a different substituted vendor — relevant context for CIB-3/PIECE below. | + +## Product & priorities (audit/refactor-time lens) + +| # | Rule bullet | Verdict | Evidence / reasoning | +| ----- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| PRI-1 | Quick wins first: any high-value low-effort feature is a finding too — during audits/refactors, check competitors' Reddit + GitHub issues for what users are asking for that we can add easily. | **N/A** | This is a lens/process instruction for how an _audit_ should be conducted (an auditor's obligation to go check competitor Reddit/GitHub issues), not a codebase artifact that itself can pass or fail. Not gradeable from repo contents. (Observational aside, not a grade: `findings.json` doesn't show an explicit competitor-research artifact, so it's unclear whether this lens was applied during Stage 1 synthesis — but that's a note for the audit process, not this repo.) | + +## CI baseline (target stack — prose bullets) + +| # | Rule bullet | Verdict | Evidence / reasoning | +| ----- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| CIB-1 | oxlint (+ few eslint rules) · prettier · strict `tsc` · ruff+ty (path-filtered) · knip · prisma schema-drift · size-limit (bundle-sensitive only) — adopt per project as the build allows. | **VIOLATION → GAP: no finding** (partial stack) | Overall grade for the whole target stack, broken down piece-by-piece in PIECE-1..8 below: 3 of 8 pieces fully PASS (prettier, tsc, prisma-drift), 1 N/A (ruff+ty, no Python), and 4 fail or are absent (oxlint entirely missing, knip neutered, size-limit entirely missing, husky pre-commit incomplete). Only F-009 covers one of the four gaps (knip); oxlint's total absence and size-limit's total absence have no covering finding at all. | +| CIB-2 | Husky pre-commit mirrors the cheap gates locally — tsc, knip, oxlint, prettier (+ the prisma check, semi-lightweight). | **VIOLATION → GAP: no finding** | `.husky/pre-commit` (full contents): `pnpm lint-staged` (→ `eslint --fix` + `prettier --write` per root `package.json`'s `lint-staged` block) then `pnpm -r run type-check`. That mirrors eslint+prettier+tsc, but **knip and the prisma check are both absent from pre-commit**, despite being named explicitly in this bullet. No finding discusses local pre-commit-hook composition at all. | +| CIB-3 | Once proper build steps pass in CI: playwright + vitest · Lighthouse CI (landing/critical pages) · Snapvisor visual regression. | **VIOLATION** (partial) — playwright **PASS**, vitest **→ F-004**, Lighthouse CI **→ GAP: no finding**, visual regression **PASS** | Playwright: genuinely wired — `checks-app.yml`'s `e2e-pr`/`e2e-push` jobs run `pnpm test:e2e` (Playwright) against a real Postgres service + migrations. Vitest/unit tests: absent, same root cause as F-004 (`turbo run test` is a no-op). Lighthouse CI: confirmed absent repo-wide (`git grep -il lighthouse` matches only two unrelated `pnpm-lock.yaml` transitive-dependency hits, no actual Lighthouse CI workflow or config) — no finding covers this. Visual regression: genuinely wired and live-verified — `apps/caramel-app/e2e/visual-regression.spec.ts` calls `argosScreenshot(page, ...)` for 8 distinct pages, `playwright.config.ts:1,23-28` configures the Argos reporter with `uploadToArgos: !!process.env.CI`; the `ARGOS_API_BASE_URL` var resolves to `api.snapvisor.io` — this **is** Snapvisor (via the Argos-CI-compatible client), so the named tool is in fact present, just accessed through its Argos-compatible SDK. | + +## CI-stack pieces (explicit breakdown) + +| # | Piece | Verdict | Evidence / reasoning | +| ------- | --------------------------------------- | ------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| PIECE-1 | oxlint | **VIOLATION → GAP: no finding** | Confirmed entirely absent: `git grep -il 'oxlint'` across the whole repo returns zero matches; not in root/app/extension `package.json` devDependencies; only ESLint (`eslint.config.mjs`, `eslint-config-next`) is used anywhere. No finding names this gap. | +| PIECE-2 | prettier | **PASS** (wired), with one embedded defect **→ F-015** | Wired everywhere: root `.prettierrc.json`, `prettier-check`/`prettier-write` scripts in root + both apps' `package.json`, run in `checks-app.yml` (matrix `prettier` task) and `checks-extension.yml` ("Run Prettier check" step), and in `.husky/pre-commit` via `lint-staged`. Embedded defect (F-015): `.prettierrc.json:11`'s key is `"tailwindConfigPath"`, which is not a real prettier-plugin-tailwindcss option (the correct key is `tailwindConfig`) — so Tailwind class-sorting silently never activates, and nothing fails because prettier ignores unknown keys. | +| PIECE-3 | strict tsc | **PASS** (wired), with real-world erosion **→ F-013** | `apps/caramel-app/tsconfig.json:7` — `"strict": true`. Wired in CI (`checks-app.yml` matrix `typecheck` task: `pnpm tsc --noEmit`, with a Prisma-generate step first) and in `.husky/pre-commit` (`pnpm -r run type-check`). The flag is correctly set and correctly enforced in both places; F-013's 26 `any` instances are how the codebase evades what strict mode would otherwise catch, not a misconfiguration of the flag itself. | +| PIECE-4 | ruff + ty | **N/A** | No Python anywhere in this repo (`git ls-files -- '*.py'` → empty; zero `ruff` references). The coupon-verification Python service (F-001) is external/out-of-repo and not this codebase's to lint. | +| PIECE-5 | knip | **VIOLATION → F-009** | Wired in CI (`checks-app.yml` matrix `knip` task) but neutered by its own config: `apps/caramel-app/knip.json`'s `ignore` (8 entries) and `ignoreDependencies` (16 entries, incl. `cors`) whitelist exactly the dead Pages-Router fossils so the gate stays green while blind to them. Scoped to the app only — the extension has no knip config at all (not itself a violation of this bullet, since knip's target is TS/JS import-graph analysis and the extension is plain JS with a much smaller surface, but worth noting as a coverage-scope gap). | +| PIECE-6 | prisma schema-drift check | **PASS** | `.github/workflows/checks-app.yml:66-142` — a dedicated `schema-drift` job, PR-triggered, spins up a real Postgres service, runs `prisma validate`, `prisma migrate diff` (migrations⇄schema and migrations⇄DB), and `prisma migrate deploy`. This is a genuinely well-built, fully-wired check — one of the strongest pieces of the whole CI stack. (Scope note: this protects only the Prisma-managed auth/user schema; the separate, non-Prisma coupons DB has no equivalent protection at all — that gap is TYP-3/F-001, not this piece.) | +| PIECE-7 | size-limit (bundle-sensitive libs only) | **VIOLATION → GAP: no finding** | Confirmed entirely absent repo-wide (`git grep -il 'size-limit'` → zero matches). This project is squarely bundle-sensitive: the Next.js app ships `gsap`, `framer-motion`, `@react-three/fiber`+`@react-three/drei` (three.js), and `react-lottie-player`; the browser extension injects `shared-utils.js` (61.5 KB source) + `UI-helpers.js` (13.1 KB) as content scripts on **every matching page load** of every supported store, and `popup.js` (30.4 KB) on every popup open. No finding covers the absence of a bundle-size gate. | +| PIECE-8 | husky pre-commit gates | **VIOLATION → GAP: no finding** | Full contents of `.husky/pre-commit`: `pnpm lint-staged` + `pnpm -r run type-check` — eslint+prettier+tsc are covered; knip and the prisma-drift check are both absent from the local pre-commit path despite being named explicitly in the CI-baseline bullet (same gap as CIB-2, called out here again at the piece-level as the task explicitly requested both granularities). | + +--- + +## SUMMARY + +**Total rows graded: 38** (30 rule-bullet rows incl. the nested PROD-GATE sub-bullet, + 8 explicit CI-stack-piece rows) + +| Verdict | Count | Rows | +| ---------------------------------------- | ----- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **PASS** | 4 | AIH-4 (mocks announce themselves), PIECE-2 (prettier), PIECE-3 (strict tsc), PIECE-6 (prisma schema-drift) | +| **VIOLATION** (incl. all GAP rows below) | 29 | ERR-1, ERR-2, ERR-3, TYP-1, TYP-2, TYP-3, TYP-4, STR-2, AIH-1, AIH-2, AIH-3, AIH-5, AIH-6, AIH-8, AIH-9, RBC-1, RBC-2, EVL-1, EVL-2, EVL-3, EVL-4, EVL-5, CIB-1, CIB-2, CIB-3, PIECE-1, PIECE-5, PIECE-7, PIECE-8 | +| **N/A** | 5 | STR-1 (no separate landing app), STR-2a (caramel not on PROD-GATE exception list), AIH-7 (context7 is a process rule), PRI-1 (audit-lens, not code), PIECE-4 (no Python) | + +Several rows carry **compound verdicts** (e.g. RBC-2, EVL-5, CIB-3, TYP-4) where one clause of a bundled bullet passes and another fails — each is counted above by its dominant/failing clause, with the passing sub-clause documented in the evidence column rather than hidden. + +### GAP rows — real violations with NO covering finding (10 of the 29 violations above) + +1. **ERR-2** — Errors&visibility: no Sentry/OTel trace-ID propagation to the external Python coupons service or the OpenRouter LLM hop. +2. **STR-2** — Structure: the one-root-docker-compose / dev-runs-prod-build rule is violated outright (compose covers only Postgres+Redis; `pnpm dev` runs framework hot-reload, not `docker compose up --build`; prod deploys via Nixpacks, not compose at all). Rule text itself calls a missing/broken root compose a **P1, never silently punch-listed** — yet zero of F-001–F-015 mention it. The single most severe gap in this checklist. +3. **AIH-1** (partial) — AI-session hygiene: `cryptoHelpers.ts`'s `encrypt`/`decrypt` functions are XOR obfuscation, not real encryption — a misleading name demoted to appendix-only `DD3-8`, never promoted to a main finding. +4. **AIH-2** — AI-session hygiene: zero `TODO:` markers anywhere in the tracked codebase despite multiple confirmed pockets of known-incomplete/deferred work. +5. **CIB-1** — CI baseline: oxlint and size-limit are both wholly absent from the target stack (knip's neutering is F-009's, but oxlint/size-limit have no finding). +6. **CIB-2** — CI baseline: husky pre-commit is missing knip and the prisma-drift check. +7. **CIB-3** (partial) — CI baseline: Lighthouse CI is wholly absent (vitest absence is already F-004; playwright and visual-regression both pass). +8. **PIECE-1** — CI-stack piece: oxlint, wholly absent. +9. **PIECE-7** — CI-stack piece: size-limit, wholly absent, despite a genuinely bundle-sensitive app + extension. +10. **PIECE-8** — CI-stack piece: husky pre-commit missing knip + prisma-drift (same underlying gap as CIB-2, listed again at the task's requested piece-level granularity). + +Note: GAP rows 5/8 (oxlint), 6/10 (husky), and part of 7 (size-limit vs. CIB-1) are the **same underlying gap** counted once at the prose-bullet granularity and once at the explicit piece granularity the task requested — 10 rows reflect **7 distinct underlying gaps** (docker-compose, trace-ID propagation, crypto naming, TODO convention, oxlint, size-limit, husky/knip+prisma), with docker-compose (STR-2) being by far the highest-severity one. diff --git a/audit/state.json b/audit/state.json new file mode 100644 index 0000000..e9555fb --- /dev/null +++ b/audit/state.json @@ -0,0 +1,131 @@ +{ + "run_id": "caramel-audit-2026-07-10", + "started": "2026-07-10", + "rules_version": "v5 2026-07-10", + "baseline": { + "branch": "dev", + "sha": "537547b3081aa3a0ec817cdc5f6dac4f0d328dbb", + "tree_clean": true + }, + "branches": { + "target": "audit/dev-2026-07-10", + "target_pushed": true, + "fixes": "audit/fixes-2026-07-10 (to be created in Stage 3)" + }, + "gate_mode": { + "mode": "pre-authorized: gates collapse into PR review", + "rationale": "User args directed: branch off dev as the PR target and 'work do pr to that branch basically just follow the audit'. Self-triage + self-approved plans recorded here; PRs to audit/dev-2026-07-10 are the human review surface. No merges by agents, ever.", + "decisions": [] + }, + "stage": "0+1 parallel", + "stage0": { + "test_suite": "NONE — turbo run test matches zero package scripts; app has Playwright test:e2e, extension has scripts/test-extension.mjs", + "test_runs": [ + "delegated to tooling agent (x2), see audit/tooling-report.md" + ], + "tooling_inventory": "in-flight (sonnet)", + "exclusions_file": "audit/exclusions.md" + }, + "stage1": { + "hotspots": { + "a": "21 nominations (audit/hotspots-a.json)", + "b": "20 nominations (audit/hotspots-b.json)", + "intersection_ruling": [ + "BOTH→dive: classify-cart LLM surface (DD-1); extension fire-and-forget/silent-failure cluster (DD-2); API req.json swallow convention + validation drift (DD-3); any-typed lib surfaces (DD-4); nested lockfile + missing env validation = direct finding candidates (mechanical, verification gate confirms)", + "ONE-FOUND Crit/High→dive: hardcoded EXTENSION_API_KEY in background.js:23 → server-side gating question folded into DD-3 item 4; decryptJsonData plaintext-fallback → DD-4 item 4", + "KILLED: hotspot-A '.env with secrets' — git ls-files proves .env untracked + gitignored (only local-dev/.env.ports deliberately tracked via !.env.ports); local machine state, not repo defect", + "KILLED: hotspot-A 'no ESLint config' — eslint.config.mjs exists at root and both apps; contradicted by direct observation", + "A-only Medium/Low (oauth-redirect logging, coupons GET catch, sentry ctx, recordTiming ignore) → covered by DD-2/DD-3 scopes, else Unverified appendix", + "A-only Medium duplication (extension vs app coupon/store logic) → DD-2 item 2 + change-trace corroboration" + ] + }, + "stage1_complete": [ + "tooling", + "change-trace", + "3am", + "onboarding", + "navigation", + "premortem", + "DD-1", + "DD-2", + "DD-3", + "DD-4" + ], + "candidate_total": 82, + "synthesis": { + "collapsed_to": 15, + "findings_json": "audit/findings.json", + "criticals": [ + "F-001 external coupons DB / blind health check", + "F-002 swallowed errors (2 facets)", + "F-003 public EXTENSION_API_KEY gates mutation+exempts throttle" + ], + "cap_compliant": true, + "killed_at_intersection": [ + ".env-with-secrets (untracked+gitignored)", + "no-eslint-config (exists)" + ] + }, + "verification": { + "absent": 0, + "deletion_counter": 0, + "sub_0.9": [ + "F-007 0.88", + "F-009 0.88", + "F-011 0.85", + "F-013 0.80", + "F-014 0.75 (all tool-sourced/absence sub-claims, not hallucinations — text marked tool-sourced)" + ] + }, + "adversarial": { + "attacked": 8, + "killed": 0, + "strengthened": ["F-006", "F-008"], + "corrected": ["F-009 apiResponseNext struck (live)"], + "narrowed": ["F-002 telemetry facet — onRequestError IS wired"] + }, + "rules_checklist": { + "pass": 4, + "violation": 29, + "na": 5, + "gap_rows": 10, + "gaps_folded": "STR-2->F-016, CIB-1/2/3+PIECE-1/7/8->F-009, ERR-2->F-011, AIH-1/2->appendix" + }, + "final_findings": 16, + "cap_deviation": "16 not 15 — F-016 (one-root-compose) promoted from GAP row; v5 §Structure forbids punch-listing it. Documented in AUDIT.md + findings.json.cap_note.", + "artifacts_written": [ + "AUDIT.md", + "findings.json", + "triage.md", + "rules-checklist.md", + "STAGE2-ONLOAD-PROMPT.md", + "tooling-report.md", + "4 deep-dives", + "4 empiricals", + "2 hotspots", + "3 verify", + "adversarial", + "premortem" + ] + }, + "stage1_done": true, + "triage_mode": "self-triaged under gate collapse; recommendations in audit/triage.md; all 16 FIX (none defer/reject); PRs to audit/dev-2026-07-10 are the human review surface", + "next": "Human launches a fresh Opus lead session with audit/STAGE2-ONLOAD-PROMPT.md for Stages 2-3. Fable returns for end-review + Stage-4 CLAUDE.md.", + "tooling_headline": { + "tests": "NONE — pnpm test false-green x2 (turbo 0 tasks)", + "typecheck": "PASS 0 errors", + "lint": "0 err / 191 warn (all extension: CRLF+no-console)", + "knip": "1 unused type — green by ignore-list blindness", + "prettier": "13 unformatted + invalid tailwindConfigPath key (dead)", + "audit": "83 vulns (2 crit / 45 high / 30 mod / 6 low)", + "outdated": "50/51; react-awesome-button DEPRECATED", + "branch_protection": "NONE on main or dev — CI advisory only", + "ci_secrets": "16 refs, 0 phantom, 2 orphaned", + "env_validation": "NO; 19 documented vs 38 read", + "ai_surface": "1 (OpenRouter cart classifier), 0 evals", + "duplication": "jscpd 65 clones 5.55%; real signal in .tsx marketing/auth components", + "top_churn_and_loc": "shared-utils.js is #1 in BOTH (4529 churn / 1536 loc)" + }, + "deletion_counter": 0, + "notes": [] +} diff --git a/audit/tooling-report.md b/audit/tooling-report.md new file mode 100644 index 0000000..23229e7 --- /dev/null +++ b/audit/tooling-report.md @@ -0,0 +1,335 @@ +# Stage-0 Deterministic Tooling Report — caramel @ 537547b (branch dev) + +Run from repo root on Windows (Git Bash). Exclusions per `audit/exclusions.md` honored in all file-level stats. All commands actually executed; failures recorded verbatim, nothing simulated. + +**Methodology note (applies to items 3, 7, 8):** this environment runs a transparent shell hook ("RTK", documented in the user's own global config) that rewrites/summarizes some commands before their output reaches the caller. Where that summarization looked lossy or suspect, it was cross-checked with `rtk proxy ` (documented escape hatch for raw/unfiltered execution) and/or scoped `pnpm --filter` re-runs. Every place this happened is called out explicitly below. + +--- + +## HEADLINE + +- **Tests:** NO TEST SUITE EXISTS. `pnpm test` ×2 — both exit 0, turbo matches 0 tasks ("No tasks were executed as part of this run"). Confirmed: no package defines a `test` script. +- **Type-check:** PASS. `pnpm -r run type-check` exit 0, 0 errors (caramel-app/tsc only; caramel-extension has no type-check script — plain JS, expected). +- **Lint:** Reproducible current state = **clean**: 0 errors / 191 warnings, all 191 in caramel-extension (CRLF + no-console), caramel-landing 0/0. A one-off first run showed exit 1 / 486 errors / 236 warnings sourced from `apps/caramel-app/.next/dev/**` build artifacts — NOT reproduced across 2 subsequent no-cache runs; flagged as a tooling/environment anomaly, not a source defect (full investigation in §3). +- **Knip (caramel-landing):** 1 unused exported type (`CouponRow`), 0 unused files, 0 unused deps. Exit 1 (knip's any-finding=nonzero convention). +- **Prettier:** FAIL — 13 unformatted files (9 caramel-app + 4 caramel-extension). Root cause assist: `.prettierrc.json`'s `tailwindConfigPath` key is invalid (silently ignored on every file, every run) — correct key is `tailwindConfig`. +- **pnpm audit --prod:** 83 vulnerabilities — **2 critical, 45 high, 30 moderate, 6 low**. +- **pnpm outdated -r:** 50 of 51 deps outdated. 15 notable majors incl. Prisma 6→7, TypeScript 5→7 (skips 6), Tailwind 3→4, ESLint 9→10; `react-awesome-button` is npm-**deprecated** (not just outdated). +- **Churn top-5 (18mo, path-normalized):** `apps/caramel-extension/shared-utils.js` (4529), `apps/caramel-extension/assets/styles.css` (4465), `.github/workflows/release.yml`→now `release-extension.yml` (3787), `apps/caramel-extension/popup.js` (2901), `apps/caramel-extension/background.js` (1547). +- **LOC top-5:** `apps/caramel-extension/shared-utils.js` (1536 — also #1 churn), `apps/caramel-extension/popup.js` (759), `apps/caramel-app/src/app/api/extension/oauth/route.ts` (595), `apps/caramel-app/src/components/OpenSourceSection.tsx` (470), `apps/caramel-app/src/components/coupons/coupons-section.tsx` (442). +- **CI:** 3 workflows (checks-app, checks-extension, release-extension). 16 unique secrets referenced, **0 phantom** (all exist), 2 orphaned/unused configured secrets (APPLE_APP_ID, APPLE_PROVISION_PROFILE). **Branch protection: NONE on main or dev** (both 404 "Branch not protected") — CI is advisory only, nothing gates merges. +- **Env validation at boot:** NO. No zod / no @t3-oss/env-nextjs / no custom validator anywhere in caramel-app. `.env.example` exists (19 documented vars) vs 38 distinct raw `process.env.*` reads in source. +- **AI surfaces:** YES — one real LLM surface: OpenRouter-backed cart classifier (`lib/openrouter.ts` + `lib/cartClassifier.ts` → `/api/classify-cart`, default model `openai/gpt-5-mini`). **Zero evals/tests** reference it. +- **Duplication (jscpd):** 65 clones, 5.55% duplicated lines / 5.35% duplicated tokens overall (125 files, 19,666 lines). Real signal concentrated in `.tsx` (42 clones, 11.80% lines) — marketing-section components and auth-page components copy-pasted rather than shared. The `markup` format's 47.58% is inert SVG logo-variant noise, not code. + +--- + +## 1. `pnpm test` (run twice) + +**Verdict: confirmed — zero matching tasks, both runs identical, exit 0.** + +Run 1: exit `0`. Run 2: exit `0`. Both produced byte-identical structure: + +``` +turbo 2.5.4 +• Packages in scope: caramel-extension, caramel-landing +• Running test in 2 packages +• Remote caching disabled + +No tasks were executed as part of this run. + + Tasks: 0 successful, 0 total +Cached: 0 cached, 0 total + Time: 727ms (run 1) + Time: 493ms (run 2) +``` + +Root cause: `turbo.json` declares a `test` task, but neither `apps/caramel-app/package.json` nor `apps/caramel-extension/package.json` (nor the root) defines a `test` script — only `test:e2e` in both apps (Playwright for caramel-app, `scripts/test-extension.mjs` for caramel-extension), which `turbo run test` does not match. The hypothesis is confirmed: this is not a broken test run, it's a nonexistent one under this exact script name. + +--- + +## 2. `pnpm -r run type-check` + +**Verdict: pass, 0 errors.** + +Exit `0`. Full output: + +``` +Scope: 2 of 3 workspace projects +apps/caramel-app type-check$ tsc --noEmit +apps/caramel-app type-check: Done +``` + +Only `caramel-landing` (apps/caramel-app) defines a `type-check` script; `caramel-extension` (plain JS) and the root package do not, which is expected. `tsc --noEmit` completed with 0 errors. (The "Scope: 2 of 3" line doesn't fully reconcile against only one package actually owning the script — recorded verbatim as an observed pnpm scope-counting quirk; it does not change the substantive result.) + +--- + +## 3. `pnpm lint` (turbo run lint) — investigated in depth due to a major discrepancy + +**Verdict: current reproducible state is clean (0 errors, 191 warnings, all in caramel-extension). A one-off first reading of 486 errors was traced to `.next` build artifacts and does not reproduce — treat as a tooling anomaly, not a source defect.** + +**What happened, in order:** + +1. First invocation of `pnpm lint`: **exit 1**. The environment's shell hook (RTK) rendered a condensed summary instead of raw ESLint output: `ESLint: 486 errors, 236 warnings in 57 files`, top rules `react-hooks/rules-of-hooks` (188×), `@next/next/no-assign-module-variable` (67×), `@typescript-eslint/no-explicit-any` (64×), `deprecation/deprecation` (41×), `@typescript-eslint/no-unused-vars` (26×), etc. +2. Investigating the hook's own (1 MiB-capped) tee log confirmed the flagged "files" were all real ESLint JSON results for real files on disk at that moment — but **every single one** of the ~34 recoverable file paths was under `apps/caramel-app/.next/dev/build/**` or `apps/caramel-app/.next/dev/server/**` (Turbopack chunk bundles containing vendored `@sentry`, `@better-auth`, `@opentelemetry`, `jose`, `rate-limiter-flexible`, the turbopack runtime itself, and compiled route files) — i.e. **build output, not source**. Zero real source paths, zero `caramel-extension` paths appeared. +3. To verify whether this reflects a real `.next`-not-ignored config gap, two independent fresh (no-cache) full re-runs were executed: plain `pnpm lint` again, and `npx turbo run lint --force` (cache forcibly bypassed). **Both exited 0**, `Tasks: 2 successful, 2 total`, both packages reported `cache miss, executing` (i.e. genuinely re-ran, not replayed). +4. Per-package breakdown via `pnpm --filter lint`: + - `caramel-landing` (apps/caramel-app): **0 problems**, exit 0, 4-line empty output. + - `caramel-extension`: **191 problems (0 errors, 191 warnings)**, exit 0. 189× `prettier/prettier` "Delete `␍`" in `cart-signals.js` (CRLF line endings — this file has Windows line endings while the rest of the repo/prettier config expects LF) + 2× `no-console` (`background.js:185`, `shared-utils.js:49`). 189 of 191 auto-fixable via `--fix`. +5. Directly tested whether `.next/**` is actually ignored right now: `npx eslint .next` → _"all of the files matching the glob pattern '.next' are ignored"_; explicitly targeting one specific chunk file → _"File ignored because of a matching ignore pattern."_ **Confirms `.next/**` is correctly excluded under the current flat config** (`eslint-config-next`'s bundled ignores), so a config gap does not explain step 2. + +**Conclusion:** the true, currently-reproducible lint health of the repo is 0 errors / 191 warnings (caramel-extension only). The 486-error reading was real console output (not fabricated) but is not reproducible and cannot be attributed to a standing config defect — most likely either the shell hook's own auxiliary summarization scan running under different conditions than the real `turbo run lint` task, or a transient state of `apps/caramel-app/.next/dev` (which is a live Next.js dev-build cache whose contents were observed to differ between invocations, last modified during this session). Either way, **something in this environment can make one lint invocation look catastrophically broken and the very next look clean with no source change in between** — worth a mention as an operability/CI-trust risk even though it isn't a codebase defect per se. + +--- + +## 4. `pnpm --filter caramel-landing knip` + +**Verdict: very clean — 1 unused export, 0 unused files, 0 unused deps.** + +Exit `1` (knip's convention: any finding → nonzero exit, regardless of severity). + +``` +Unused exported types (1) +CouponRow type src/lib/couponsDb.ts:34:13 +``` + +No other categories (unused files, unused dependencies, unused exports, duplicate exports, unlisted deps) reported anything. + +--- + +## 5. `pnpm prettier-check` + +**Verdict: fail — 13 unformatted files total, plus a repo-wide silently-broken prettier option.** + +Combined `pnpm prettier-check` (turbo): exit `1`. + +- **caramel-extension**: `Code style issues found in 4 files` — `cart-signals.js`, `index.html`, `manifest-firefox.json`, `package.json`. +- **caramel-landing** (verified standalone, exit 1): `Code style issues found in 9 files` — `package.json`, `src/app/api/classify-cart/route.ts`, `src/app/api/coupons/filters/route.ts`, `src/app/api/coupons/stats/route.ts`, `src/app/api/extension/supported-stores/route.ts`, `src/lib/capitalizeFirst.ts`, `src/lib/cartClassifier.ts`, `src/lib/couponsDb.ts`, `src/lib/openrouter.ts`. +- **Total: 13 files** need `prettier --write`. + +**Associated config bug (verified):** every single file check in both packages emits `[warn] Ignored unknown option { tailwindConfigPath: "./apps/caramel-app/tailwind.config.ts" }`. Root `.prettierrc.json` (line 11) sets `"tailwindConfigPath"`, but `prettier-plugin-tailwindcss@0.6.14`'s own README documents the option as `tailwindConfig` (confirmed by reading `node_modules/prettier-plugin-tailwindcss/README.md`). The option has silently never taken effect — Tailwind class-sorting falls back to auto-detecting a config from the prettier config's own directory (the empty-stub root `tailwind.config.js: { content: [] }`) instead of the real `apps/caramel-app/tailwind.config.ts`. + +--- + +## 6. `pnpm audit --prod` + +**Verdict: 83 vulnerabilities, including 2 critical.** + +Exit `1`. + +``` +83 vulnerabilities found +Severity: 6 low | 30 moderate | 45 high | 2 critical +``` + +(Per task scope, severities only — no package-level detail captured for this item.) + +--- + +## 7. `pnpm outdated -r` + +**Verdict: 50 of 51 workspace dependencies are outdated; 15 notable majors, plus one fully deprecated package.** + +Exit `0`. `50 outdated packages (of 51)`. The default hook summary only sampled ~10 rows per call; the complete 50-row table was retrieved via `rtk proxy pnpm outdated -r` (raw/unfiltered execution) and manually reviewed in full. Top 15 notable majors/deprecations: + +| # | Package | Current → Latest | Note | +| --- | --------------------------------- | ---------------------- | ------------------------------------------------------------------ | +| 1 | `react-awesome-button` | 7.0.5 → **Deprecated** | Not just outdated — deprecated on npm entirely | +| 2 | `@prisma/client` | 6.14.0 → 7.8.0 | major | +| 3 | `prisma` | 6.14.0 → 7.8.0 | major (paired CLI) | +| 4 | `typescript` | 5.9.2 → 7.0.2 | major, skips v6 entirely | +| 5 | `tailwindcss` | 3.4.17 → 4.3.2 | major, v3→v4 is a full rewrite | +| 6 | `eslint` | 9.31.0 → 10.7.0 | major | +| 7 | `lint-staged` | 16.4.0 → 17.0.8 | major | +| 8 | `@argos-ci/cli` | 3.2.1 → 6.2.0 | major, 3 majors behind | +| 9 | `@argos-ci/playwright` | 6.3.3 → 7.3.5 | major | +| 10 | `web-ext` | 8.8.0 → 10.5.0 | major, skips v9 | +| 11 | `knip` | 5.70.2 → 6.26.0 | major | +| 12 | `@react-email/render` | 1.2.1 → 2.1.0 | major | +| 13 | `react-infinite-scroll-component` | 6.1.1 → 7.2.1 | major | +| 14 | `@types/node` | 24.3.0 → 26.1.1 | 2 majors | +| 15 | `prettier-plugin-tailwindcss` | 0.6.14 → 0.8.0 | 0.x breaking-by-convention; directly relevant to the §5 config bug | + +Also notable non-major but large jumps seen in the full table: `@sentry/nextjs` 10.38.0→10.65.0, `@better-auth/prisma-adapter`/`better-auth` 1.5.3→1.6.23, `@types/node` as above, `sass` 1.90.0→1.101.0. + +--- + +## 8. Git churn (18 months, `--numstat`, aggregated per file) + +**Verdict: caramel-extension's shared JS/CSS files dominate churn by a wide margin; the repo underwent a full directory restructure and a backend service was removed within the window.** + +Window: 2025-01-11 → 2026-07-03 (551 commits). The shell hook truncated the naive `git log --numstat` capture to 50 lines; the complete 1,838-line numstat was retrieved via `rtk proxy` and aggregated locally (added+deleted per file, exclusions applied per `audit/exclusions.md` + lockfiles/public assets/migrations per task instruction). + +**Methodology note:** the repo was restructured at some point in-window from a flat layout (`caramel-landing/`, `caramel-extension/`, `caramel-backend/` as top-level dirs) into the current `apps/*` pnpm workspace. `git log --numstat` doesn't bridge that rename, so raw per-path aggregation would double-count/split real per-file history. I normalized the one clean, mechanical, verified 1:1 case — `caramel-extension/` → `apps/caramel-extension/` (pure directory move, same filenames) — into single entries below. I did **not** attempt to merge `caramel-landing/*.jsx` history into `apps/caramel-app/*.tsx`, because that migration also changed file extensions and moved Pages Router → App Router; a spot-check confirmed merging wouldn't materially reorder the top-25 anyway. `.github/workflows/release.yml` is a historical filename — the equivalent file today is `release-extension.yml`. + +Also found: **`caramel-backend/`** (a standalone Node service with its own `index.js`, `package.json`, and a committed JetBrains `.idea/` directory) existed early in the window, last touched 2025-01-24 ("caramel landing + backend"), and has been **fully removed** — 0 files remain in the current tree. Its functionality appears to have been absorbed into `apps/caramel-app/src/app/api/**`. + +**Top 25 by churn (added+deleted, exclusions applied):** + +| Churn | +/- | File | +| ----- | --------- | ---------------------------------------------------------------------------------------------------------------------------- | +| 4529 | 2829/1700 | apps/caramel-extension/shared-utils.js | +| 4465 | 2621/1844 | apps/caramel-extension/assets/styles.css | +| 3787 | 2058/1729 | .github/workflows/release.yml _(now release-extension.yml)_ | +| 2901 | 1821/1080 | apps/caramel-extension/popup.js | +| 1547 | 628/919 | apps/caramel-extension/background.js | +| 1529 | 974/555 | apps/caramel-extension/caramel-content.css | +| 1312 | 698/614 | caramel-landing/src/components/SupportedSection.jsx _(historical; now apps/caramel-app/src/components/SupportedSection.tsx)_ | +| 1308 | 858/450 | caramel-landing/src/components/FeaturesSection.jsx _(historical; now .tsx)_ | +| 1203 | 819/384 | caramel-landing/src/components/OpenSourceSection.jsx _(historical; now .tsx)_ | +| 1166 | 771/395 | apps/caramel-extension/UI-helpers.js | +| 874 | 478/396 | caramel-landing/src/pages/index.jsx _(historical, Pages Router)_ | +| 859 | 727/132 | apps/caramel-app/src/app/api/extension/oauth/route.ts | +| 682 | 515/167 | apps/caramel-app/src/app/(marketing)/sources/page.tsx | +| 635 | 338/297 | apps/caramel-app/src/app/(auth)/signup/page.tsx | +| 619 | 487/132 | caramel-landing/src/components/HeroSection.jsx _(historical; now .tsx)_ | +| 592 | 296/296 | caramel-landing/src/pages/sources.jsx _(historical, Pages Router)_ | +| 582 | 418/164 | caramel-landing/src/components/Doodles.jsx _(historical; now .tsx)_ | +| 580 | 511/69 | apps/caramel-app/src/components/coupons/coupons-section.tsx | +| 497 | 376/121 | caramel-landing/src/components/PrivacyPolicy.jsx _(historical; now .tsx)_ | +| 469 | 379/90 | apps/caramel-app/src/app/(auth)/login/LoginPageClient.tsx | +| 462 | 231/231 | caramel-landing/src/pages/signup.jsx _(historical, Pages Router)_ | +| 418 | 391/27 | apps/caramel-app/emails/EmailLayout.tsx | +| 402 | 217/185 | apps/caramel-extension/index.html | +| 397 | 19/378 | apps/caramel-app/src/pages/sources.tsx _(historical, TS Pages Router, pre-App-Router)_ | +| 382 | 382/0 | caramel-landing/src/components/WhyNot.tsx _(historical; already .tsx)_ | + +--- + +## 9. LOC — top 20 largest tracked source files (ts/tsx/js/mjs/cjs) + +**Verdict: `apps/caramel-extension/shared-utils.js` is both the single largest source file (1536 lines) and the #1 churn hotspot from §8 — a concrete "god file" risk.** + +120 tracked files matched (after exclusions): + +| Lines | File | +| ----- | ----------------------------------------------------------- | +| 1536 | apps/caramel-extension/shared-utils.js | +| 759 | apps/caramel-extension/popup.js | +| 595 | apps/caramel-app/src/app/api/extension/oauth/route.ts | +| 470 | apps/caramel-app/src/components/OpenSourceSection.tsx | +| 442 | apps/caramel-app/src/components/coupons/coupons-section.tsx | +| 385 | apps/caramel-app/src/components/FeaturesSection.tsx | +| 376 | apps/caramel-extension/UI-helpers.js | +| 364 | apps/caramel-app/emails/EmailLayout.tsx | +| 348 | apps/caramel-app/src/app/(marketing)/sources/page.tsx | +| 308 | apps/caramel-extension/scripts/test-extension.mjs | +| 292 | apps/caramel-app/src/components/PricingSection.tsx | +| 289 | apps/caramel-app/src/components/PrivacyPolicy.tsx | +| 285 | apps/caramel-app/e2e/auth-flows.spec.ts | +| 275 | apps/caramel-extension/background.js | +| 267 | apps/caramel-app/src/app/(auth)/signup/SignupPageClient.tsx | +| 257 | apps/caramel-app/src/components/Doodles.tsx | +| 247 | apps/caramel-app/src/components/HeroSection.tsx | +| 242 | apps/caramel-app/src/layouts/Header/Header.tsx | +| 232 | apps/caramel-app/src/components/coupons/coupon-filters.tsx | +| 228 | apps/caramel-app/src/app/(auth)/login/LoginPageClient.tsx | + +--- + +## 10. CI inventory + +**Verdict: 3 workflows, CI is real but purely advisory — zero branch protection means nothing actually blocks a merge; secrets are clean (0 phantom) with 2 harmless orphans.** + +### `.github/workflows/checks-app.yml` — "CI Checks – App" + +- **Triggers:** push to `main`/`dev`; PR into `main`/`dev`. +- **Jobs:** + - `test-matrix` (always): matrix over `["lint","prettier","typecheck","knip"]`, each running the corresponding `pnpm` script inside `apps/caramel-app`, plus `pnpm doctor` dependency validation and conditional Prisma client generation for the typecheck leg. + - `schema-drift` (PR only): spins up ephemeral Postgres 15, runs `prisma validate` + `prisma migrate diff` both directions (migrations⇄schema, migrations⇄DB) to catch drift. + - `e2e-pr` (PR only): ephemeral Postgres, installs Playwright+chromium, generates Prisma client, applies migrations, runs `pnpm test:e2e` against a locally-started dev server, uploads Playwright report, reports to Argos for visual regression. + - `e2e-push` (push only): runs `pnpm test:e2e` against the **deployed** dev/prod baseline (`dev.grabcaramel.com` / `grabcaramel.com`), no local server, uploads report, Argos visual regression. +- **Secrets referenced:** `ARGOS_TOKEN`. + +### `.github/workflows/checks-extension.yml` — "CI Checks – Extension" + +- **Triggers:** push to `main`/`dev`; any PR. +- **Jobs:** single `lint_and_build` job — install, `pnpm run lint`, `pnpm run prettier-check`. No build/test/package step. +- **Secrets referenced:** none. + +### `.github/workflows/release-extension.yml` — "Release – Extension" + +- **Triggers:** `pull_request` `closed` into `main` (gated `if: github.event.pull_request.merged == true`). +- **Jobs:** + - `package`: builds and zips the extension, uploads as artifact. + - `publish_chrome` (needs `package`, skippable via `skip=chrome` in PR title): uploads (not auto-publishes) to Chrome Web Store. + - `publish_safari` (needs `package`, skippable via `skip=apple` in PR title, macOS runner): full Xcode pipeline — generates Safari icons, imports distribution + dev certs, queries App Store Connect/TestFlight for current build numbers, auto-bumps version (major/minor/patch inferred from PR title keywords), archives + exports + uploads both macOS and iOS Safari-extension targets. +- **Secrets referenced:** `CHROME_EXTENSION_ID`, `CHROME_CLIENT_ID`, `CHROME_CLIENT_SECRET`, `CHROME_REFRESH_TOKEN`, `ASC_KEY_ID`, `ASC_ISSUER_ID`, `ASC_API_KEY_BASE64`, `APPLE_TEAM_ID`, `APPLE_CERT_P12`, `APPLE_CERT_PASSWORD`, `APPLE_DEV_CERT_P12`, `APPLE_DEV_CERT_PASSWORD`, `APPLE_ID`, `APPLE_APP_PASSWORD`, `KEYCHAIN_PASSWORD`. + +### Secrets: referenced vs. actual (`gh secret list`) + +16 unique `secrets.*` names referenced across all 3 workflows. All 18 configured repo secrets: +`APPLE_APP_ID, APPLE_APP_PASSWORD, APPLE_CERT_P12, APPLE_CERT_PASSWORD, APPLE_DEV_CERT_P12, APPLE_DEV_CERT_PASSWORD, APPLE_ID, APPLE_PROVISION_PROFILE, APPLE_TEAM_ID, ARGOS_TOKEN, ASC_API_KEY_BASE64, ASC_ISSUER_ID, ASC_KEY_ID, CHROME_CLIENT_ID, CHROME_CLIENT_SECRET, CHROME_EXTENSION_ID, CHROME_REFRESH_TOKEN, KEYCHAIN_PASSWORD`. + +- **Phantom (referenced but don't exist): 0.** Every referenced secret exists. +- **Orphaned (configured but never referenced): 2** — `APPLE_APP_ID`, `APPLE_PROVISION_PROFILE` (likely dead from before the workflow switched to `CODE_SIGN_STYLE=Automatic` provisioning). + +### Branch protection + +- `main`: `gh api repos/DevinoSolutions/caramel/branches/main/protection` → **HTTP 404 "Branch not protected"**. +- `dev`: same call → **HTTP 404 "Branch not protected"**. +- **Neither branch has any protection rule.** No required status checks, no required reviews, no restrictions on force-push/deletion. The 3 CI workflows above run, but nothing in GitHub actually requires them to pass before merge. + +--- + +## 11. Env hygiene (apps/caramel-app) + +**Verdict: no boot-time env validation exists.** + +- `zod` is **not** a dependency of `apps/caramel-app` (checked `package.json` `dependencies`/`devDependencies` directly). +- `@t3-oss/env-nextjs` is **not** present either. +- No file named `env.ts`/`env.mjs` anywhere in `apps/caramel-app`; repo-wide grep for `envSchema|parseEnv|validateEnv|z\.object\(` inside `apps/caramel-app/src` returns nothing; no `from 'zod'` imports anywhere in the app. +- `next.config.mjs` reads `process.env.NODE_ENV` / `process.env.CI` directly with no schema. +- **What does exist:** `apps/caramel-app/.env.example` (tracked, documents 19 vars: `DATABASE_URL`, `JWT_SECRET`, `BCRYPT_SALT_ROUNDS`, `BETTER_AUTH_URL`, `BETTER_AUTH_SECRET`, `NEXT_PUBLIC_BASE_URL`, `GOOGLE_CLIENT_ID/SECRET`, `APPLE_CLIENT_ID/SECRET/REDIRECT_URI`, `NEXT_PUBLIC_SENTRY_DSN`, `USESEND_BASE_URL/API_KEY/FROM_EMAIL/FROM_NAME`, `OPENROUTER_API_KEY/MODEL`, `NODE_ENV`), plus `apps/caramel-app/scripts/ci-env.ts` (invoked as `setup:ci-env` in CI to materialize a minimal env for the e2e-pr job). +- Source code reads **38 distinct** `process.env.*` keys directly (more than `.env.example` documents), with no central schema and no fail-fast-at-boot behavior — a missing/misconfigured var surfaces wherever it's first dereferenced at runtime, not at startup. +- `apps/caramel-extension` is a plain browser extension (no server-side env concept) — n/a. +- Only `local-dev/.env.ports` is a tracked `.env*` file repo-wide; no real `.env` with secrets is tracked (confirmed via `git ls-files`). + +--- + +## 12. AI surfaces + +**Verdict: one real LLM integration exists, with zero eval/test coverage.** + +Repo-wide case-insensitive grep for `openai|anthropic|claude|gpt-|gemini|\bllm\b|completion` (exclusions honored) → 6 raw hits, of which 2 are genuine: + +- `apps/caramel-app/src/lib/openrouter.ts:1-2` — generic OpenRouter chat-completion client: `OPENROUTER_URL = 'https://openrouter.ai/api/v1/chat/completions'`, `DEFAULT_MODEL = process.env.OPENROUTER_MODEL || 'openai/gpt-5-mini'`. Configurable temperature/maxTokens/JSON-mode, 8s default timeout, throws `OpenRouterError` on missing key/non-OK/empty response. +- `apps/caramel-app/src/lib/cartClassifier.ts:116` — "llm returned non-json" — this module (171 lines) calls the above to classify shopping-cart contents, exposed via `apps/caramel-app/src/app/api/classify-cart/route.ts`. + +The other 4 hits are false positives (generic English/native-API vocabulary, not AI-related): + +- `apps/caramel-app/src/app/api/extension/oauth/route.ts:199` — comment, "OAuth flow initiation, not **completion**". +- `apps/caramel-extension/apple-extension/Shared (Extension)/SafariWebExtensionHandler.swift:39` — Swift `completionHandler` callback (standard native API pattern). +- `apps/caramel-extension/popup.js:333` — comment, "ensure **completion**" (a Promise wrapper). + +**Eval/test coverage: zero.** Grepped `*.test.*`, `*.spec.*`, and `apps/caramel-app/e2e/**` for `cartClassifier|openrouter` — no matches. This is a real production LLM surface (cart classification, gating a user-facing feature) with no automated evals or tests of any kind. + +--- + +## 13. Duplication quick-scan (jscpd, `--min-tokens 50`) + +**Verdict: 65 clones, 5.55% duplicated lines overall; the real actionable signal is concentrated in caramel-app's TSX components (marketing sections + auth pages), not in the extension.** + +`npx jscpd` ran successfully (auto-installed `jscpd@5.0.12` on the fly, no fetch failure — the documented fallback path was not needed). Exit 0. + +**Summary table:** + +| Format | Files | Lines | Clones | Dup. lines | Dup. tokens | +| ---------- | ------- | ---------- | ------ | ---------------- | ----------------- | +| css | 4 | 1610 | 2 | 24 (1.49%) | 115 (0.82%) | +| javascript | 9 | 3659 | 2 | 23 (0.63%) | 117 (0.77%) | +| json | 6 | 297 | 1 | 19 (6.40%) | 56 (6.51%) | +| markup | 19 | 433 | 12 | 206 (47.58%) | 1019 (41.57%) | +| swift | 4 | 165 | 0 | 0 | 0 | +| **tsx** | 41 | 5917 | **42** | **698 (11.80%)** | **3435 (12.54%)** | +| typescript | 41 | 3111 | 6 | 122 (3.92%) | 549 (3.93%) | +| yaml | 1 | 4474 | 0 | 0 | 0 | +| **Total** | **125** | **19,666** | **65** | **1092 (5.55%)** | **5291 (5.35%)** | + +**Where the real signal is:** + +- **tsx (dominant, actionable):** heavy cross-component duplication among caramel-app's marketing sections — `FeaturesSection.tsx` ↔ `OpenSourceSection.tsx` (multiple blocks, one up to 53 lines/196 tokens) ↔ `SupportedSection.tsx` ↔ `PricingSection.tsx` ↔ `WhyNot.tsx` — plus near-identical page wrappers: `app/(auth)/login/page.tsx` is a 33-line/104-token clone of `signup/page.tsx`, `verify/page.tsx`, `(marketing)/coupons/page.tsx`, and `(marketing)/privacy/page.tsx` (same 33 lines repeated across **5** route files), and `LoginPageClient.tsx` ↔ `SignupPageClient.tsx` ↔ `VerifyPageClient.tsx` share several 10-17 line blocks. These are exactly the components flagged as large (§9) and high-churn (§8) — consistent evidence of a maintainability hotspot. +- **typescript:** `apps/caramel-app/src/app/api/extension/oauth/route.ts` duplicates **itself** (lines 269-339 ≈ lines 516-582, 71 lines/258 tokens) — a strong "extract a function" signal in the largest API route file. Also `lib/apiResponseNext.ts` ↔ `lib/securityHelpers/apiResponse.ts` — two parallel response-helper modules. +- **markup (47.58% — not actionable):** all 12 clones are among SVG logo color/size variants under `assets/Caramel Logos/svgs/**` (e.g. `16x16 Caramel Orange BG.svg` vs `16x16 Caramel White BG.svg`) — near-identical by design as brand-asset exports, not a code-duplication problem. +- **json:** `manifest-firefox.json` ↔ `manifest.json` — expected/inherent for multi-browser extension manifests. +- **javascript (minor):** `UI-helpers.js` ↔ `popup.js`, 15 lines/50 tokens. diff --git a/audit/triage.md b/audit/triage.md new file mode 100644 index 0000000..4528057 --- /dev/null +++ b/audit/triage.md @@ -0,0 +1,28 @@ +# Triage — caramel audit (self-triage under pre-authorized gate collapse) + +Gate mode: the user directed "make a branch out of dev and consider it the dev … do PR to that branch." Per the runner's gate-collapse rule, ⛔ triage + plan-approval collapse into PR review. I (Fable, orchestrator) self-triage below with rationale; the PRs into `audit/dev-2026-07-10` are the human review surface. **Recommendations lead — the human can override any row before/after the Stage-2/3 session runs.** No agent merges. + +Triage lens: value-vs-effort against caramel's vision, maintainability (P1) + operability (P2) first. + +| ID | Sev | Eff | Recommendation | Rationale | +| --------- | ---- | --- | ---------------------------------------------------- | ------------------------------------------------------------------------------------------------------- | +| **F-004** | High | L | **FIX (Wave 1, first)** | Unlocking: no other refactor is safe without it. Highest leverage in the audit. | +| **F-005** | High | M | **FIX (Wave 1)** | Cheap; fixes broken first-run and fails-fast on bad env; enforces later rules. | +| **F-009** | High | M | **FIX (Wave 1)** | Cheap guardrails; deletes the dead wrappers F-007 replaces; makes CI actually gate. | +| **F-001** | Crit | M | **FIX (Wave 2)** | The predicted outage. Health-check the coupons DB + typed boundary. High operability value. | +| **F-002** | Crit | M | **FIX (Wave 2)** | The honesty-violation + telemetry-blindness spine. Pair with F-001. | +| **F-003** | Crit | S | **FIX (Wave 2, early)** | Small, concrete security regression. ⚠ rotates a shipped key — coordinate extension release. | +| **F-011** | High | M | **FIX (Wave 2 → Stage 4)** | RUNBOOK + error boundary; documents what F-001/F-002 create. | +| **F-006** | High | M | **FIX (Wave 3)** | Prime P1 dedup; already drifting. Benefits from F-004 tests. | +| **F-007** | High | M | **FIX (Wave 3)** | P1 modularity; depends on F-009 (fossils gone), pairs with F-002. | +| **F-008** | High | L | **FIX (Wave 3, gated)** | Highest change-risk file. **Characterization tests (F-004) FIRST.** ⚠ ext refactor + store-review lag. | +| **F-012** | High | M | **FIX via /ai-evals** | Independent track; the skill is the install path. Cheap given the skill. | +| **F-013** | Med | M | **FIX (Wave 4)** | Mechanical; type exported surfaces first (kills contagion). | +| **F-014** | Med | M | **FIX (Wave 4)** | Patch crit/high vulns + replace deprecated dep + pin + single lockfile. | +| **F-015** | Med | S | **FIX (Wave 4)** | Mechanical; the amazon.js broken ref is a real Firefox-build bug. | +| **F-010** | High | M | **FIX (Wave 4, LAST)** → Stage 4 | Do after structural changes so docs describe reality; validated by onboarding trace. | +| **F-016** | High | L | **FIX as SEPARATE INITIATIVE via /one-root-compose** | Large, breaking, prod-cutover. Do NOT fold into the above PRs. Rule forbids dropping it; own track. | + +**No REJECTs. No DEFERs recommended** — every finding is net-positive at its effort. The only sequencing caveats: F-008 must not start before F-004 lands; F-016 is a self-contained migration (its own skill), not part of the fix-branch train. + +**PR granularity (per user's "do PR to that branch"):** one branch `audit/fixes-2026-07-10` off `audit/dev-2026-07-10`, **one commit per finding** (subject references the F-ID), opened as PR(s) into the target. Waves may be grouped into a small number of PRs by wave to keep review coherent, or one PR per finding if the executor prefers — either is acceptable; the finding-ID-per-commit trace is the requirement. Deviation from upstream "one PR per fix" is intentional and recorded here. diff --git a/audit/verify-1.json b/audit/verify-1.json new file mode 100644 index 0000000..dbf9e85 --- /dev/null +++ b/audit/verify-1.json @@ -0,0 +1,32 @@ +[ + { + "id": "F-001", + "verdict": "verbatim", + "confidence": 1.0, + "note": "Quote 1 (Read-only connection...Python verification service) found verbatim in couponsDb.ts:1-7. Quote 2 (SELECT 1 health check) found verbatim in health/db/route.ts:9-11. The separate postgres connection to caramel_coupons DB owned externally is confirmed by code at couponsDb.ts:9-12." + }, + { + "id": "F-002", + "verdict": "verbatim", + "confidence": 1.0, + "note": "Quote 1 (if !r.ok return coupons:[]) found verbatim in background.js:193. Quote 2 (Error fetching coupons) found verbatim in api/coupons/route.ts:121-125. Quote 3 (catch err return resData) found verbatim in decryptJsonData.ts:22. No Sentry calls present in coupons route. Behavior claim confirmed: errors are caught, logged to console only, returned as success-shaped responses." + }, + { + "id": "F-003", + "verdict": "verbatim", + "confidence": 0.95, + "note": "Key check found verbatim in expire/route.ts:17-19. Static key 'WXqEpm2uOV5jjJXPpnQFyZiNdaPVUrtd2LIrf4kc1JA' found verbatim in background.js:23. isExtensionClient rate-limit bypass found verbatim in rateLimit.ts:91 (if isExtensionClient return null). Minor: comment in rateLimit.ts says accepts 'x-api-key' OR 'x-extension-api-key' for backwards-compat, but both check same env var. Core finding: static string shipped in public client gates destructive mutations + exempts from rate limit." + }, + { + "id": "F-004", + "verdict": "verbatim", + "confidence": 1.0, + "note": "Root package.json:15 contains 'test': 'turbo run test' (verbatim). turbo.json:test is empty {} (verbatim). App package.json has only test:e2e (lines 21-22), extension has only test:e2e (line 13). No workspace implements plain 'test' script. Behavior: turbo run test exits 0 with no tasks executed." + }, + { + "id": "F-005", + "verdict": "verbatim", + "confidence": 1.0, + "note": "Code check found verbatim in couponsDb.ts:9-12 (throw error if COUPONS_DATABASE_URL missing). .env.example confirmed: contains DATABASE_URL, JWT_SECRET, etc. (28 lines) but DOES NOT contain COUPONS_DATABASE_URL. Behavior claim confirmed: boot requires COUPONS_DATABASE_URL but .env.example omits it. No zod-validated env module found." + } +] diff --git a/audit/verify-2.json b/audit/verify-2.json new file mode 100644 index 0000000..6688bf1 --- /dev/null +++ b/audit/verify-2.json @@ -0,0 +1,32 @@ +[ + { + "id": "F-006", + "verdict": "verbatim", + "confidence": 0.95, + "note": "SQL filter copy-pasted: route.ts has full 7-status list; filters/route.ts has 'valid' only (2x); stats/route.ts has 'valid' without expired filter. STATUS_BADGE re-declared in app and RESTRICTED_STATUSES in extension. Drifts confirmed." + }, + { + "id": "F-007", + "verdict": "verbatim", + "confidence": 0.88, + "note": "middleware.ts absence confirmed (find returned 0). 4 route files have CORS headers. checkRateLimit applied to multiple handlers but oauth/redirect hand-mints sessions claim not fully verified in scope." + }, + { + "id": "F-008", + "verdict": "paraphrase", + "confidence": 0.96, + "note": "shared-utils.js is 1536 lines (finding claims 1537, off by 1). Contains RESTRICTED_STATUSES constant, multiple responsibilities confirmed (store detection, price parsing, coupon loop, modal UI, telemetry, messaging)." + }, + { + "id": "F-009", + "verdict": "verbatim", + "confidence": 0.88, + "note": "knip.json ignore list exactly includes initMiddleware.ts, middlewares/**, and apiResponse.ts. 6 Pages-Router files with NextApiRequest/NextApiResponse found. Branch protection claim cannot be verified locally (API-level check)." + }, + { + "id": "F-010", + "verdict": "verbatim", + "confidence": 0.95, + "note": "dev script never applies migrations (only 'prisma generate', no 'prisma migrate deploy'). LOCAL-DEV.md never mentions prisma migration. README has NO Getting Started. README does NOT link LOCAL-DEV.md. App README is unedited create-next-app boilerplate." + } +] diff --git a/audit/verify-3.json b/audit/verify-3.json new file mode 100644 index 0000000..1654bef --- /dev/null +++ b/audit/verify-3.json @@ -0,0 +1,32 @@ +[ + { + "id": "F-011", + "verdict": "paraphrase", + "confidence": 0.85, + "note": "README quote is verbatim; error.tsx confirmed absent; but workflows contain 'prisma migrate deploy' (not app deploy/dokploy); Sentry automaticVercelMonitors:true with Dokploy deployment is real config mismatch; no runbook found." + }, + { + "id": "F-012", + "verdict": "verbatim", + "confidence": 0.95, + "note": "All citations exact: cartClassifier.ts prompt, openrouter.ts DEFAULT_MODEL='openai/gpt-5-mini'; no eval suite or CI gate for LLM classifier verified." + }, + { + "id": "F-013", + "verdict": "paraphrase", + "confidence": 0.8, + "note": "Exported any-typed surfaces confirmed (decryptJsonData, encryptJsonServer, initMiddleware, others); actual count is 23 instances not 26; exported surface coverage claim is accurate." + }, + { + "id": "F-014", + "verdict": "paraphrase", + "confidence": 0.75, + "note": "Caret ranges in package.json confirmed; react-awesome-button found; vuln counts (83 critical/high) are tool-sourced, not re-verified; 50/51 outdated not independently verified." + }, + { + "id": "F-015", + "verdict": "verbatim", + "confidence": 0.95, + "note": "All 5 sub-claims verified: amazon.js listed in firefox manifest but file absent; verify_test_small3.txt present; nul file tracked at root; caramel-app named caramel-landing; .prettierrc.json uses invalid key tailwindConfigPath." + } +] From 0c0a991bb0069dd525c244138ae7b51991fb3182 Mon Sep 17 00:00:00 2001 From: Alaeddin <15094821+BSalaeddin@users.noreply.github.com> Date: Sat, 11 Jul 2026 01:12:27 +0100 Subject: [PATCH 069/198] =?UTF-8?q?chore(audit):=20record=20human=20go-sig?= =?UTF-8?q?nal=20=E2=80=94=20all=2016=20FIX,=20F-016=20separate=20track,?= =?UTF-8?q?=20proceed=20to=20Stage=202?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- audit/state.json | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/audit/state.json b/audit/state.json index e9555fb..87cbf61 100644 --- a/audit/state.json +++ b/audit/state.json @@ -15,7 +15,13 @@ "gate_mode": { "mode": "pre-authorized: gates collapse into PR review", "rationale": "User args directed: branch off dev as the PR target and 'work do pr to that branch basically just follow the audit'. Self-triage + self-approved plans recorded here; PRs to audit/dev-2026-07-10 are the human review surface. No merges by agents, ever.", - "decisions": [] + "decisions": [ + { + "at": "2026-07-11", + "by": "human", + "decision": "Accepted all Fable recommendations verbatim ('ill take ur recommendations'): all 16 findings FIX in wave order; F-016 one-root-compose parked as a SEPARATE initiative (not in the fix-branch train); Stages 2-3 run in a fresh Opus lead session. This is the go-signal for Stage 2." + } + ] }, "stage": "0+1 parallel", "stage0": { From 9cac6fd1f0e795ae7ef59cb548110ea06b075094 Mon Sep 17 00:00:00 2001 From: Alaeddin <15094821+BSalaeddin@users.noreply.github.com> Date: Sat, 11 Jul 2026 02:06:06 +0100 Subject: [PATCH 070/198] chore(audit): Stage 2 plans (15 findings) + cross-review rulings + executor brief --- audit/plans/EXECUTOR-BRIEF.md | 44 +++++++++++++++++++ audit/plans/PLAN-F-001.md | 52 ++++++++++++++++++++++ audit/plans/PLAN-F-002.md | 56 ++++++++++++++++++++++++ audit/plans/PLAN-F-003.md | 50 +++++++++++++++++++++ audit/plans/PLAN-F-004.md | 79 ++++++++++++++++++++++++++++++++++ audit/plans/PLAN-F-005.md | 60 ++++++++++++++++++++++++++ audit/plans/PLAN-F-006.md | 71 ++++++++++++++++++++++++++++++ audit/plans/PLAN-F-007.md | 79 ++++++++++++++++++++++++++++++++++ audit/plans/PLAN-F-008.md | 61 ++++++++++++++++++++++++++ audit/plans/PLAN-F-009.md | 67 +++++++++++++++++++++++++++++ audit/plans/PLAN-F-010.md | 57 ++++++++++++++++++++++++ audit/plans/PLAN-F-011.md | 56 ++++++++++++++++++++++++ audit/plans/PLAN-F-012.md | 62 +++++++++++++++++++++++++++ audit/plans/PLAN-F-013.md | 67 +++++++++++++++++++++++++++++ audit/plans/PLAN-F-014.md | 51 ++++++++++++++++++++++ audit/plans/PLAN-F-015.md | 69 +++++++++++++++++++++++++++++ audit/plans/PLANNER-BRIEF.md | 51 ++++++++++++++++++++++ audit/state.json | 81 ++++++++++++++++++++++++++++++++++- 18 files changed, 1112 insertions(+), 1 deletion(-) create mode 100644 audit/plans/EXECUTOR-BRIEF.md create mode 100644 audit/plans/PLAN-F-001.md create mode 100644 audit/plans/PLAN-F-002.md create mode 100644 audit/plans/PLAN-F-003.md create mode 100644 audit/plans/PLAN-F-004.md create mode 100644 audit/plans/PLAN-F-005.md create mode 100644 audit/plans/PLAN-F-006.md create mode 100644 audit/plans/PLAN-F-007.md create mode 100644 audit/plans/PLAN-F-008.md create mode 100644 audit/plans/PLAN-F-009.md create mode 100644 audit/plans/PLAN-F-010.md create mode 100644 audit/plans/PLAN-F-011.md create mode 100644 audit/plans/PLAN-F-012.md create mode 100644 audit/plans/PLAN-F-013.md create mode 100644 audit/plans/PLAN-F-014.md create mode 100644 audit/plans/PLAN-F-015.md create mode 100644 audit/plans/PLANNER-BRIEF.md diff --git a/audit/plans/EXECUTOR-BRIEF.md b/audit/plans/EXECUTOR-BRIEF.md new file mode 100644 index 0000000..30eceec --- /dev/null +++ b/audit/plans/EXECUTOR-BRIEF.md @@ -0,0 +1,44 @@ +# EXECUTOR BRIEF — Stage 3, caramel audit (binding for every fix-executor subagent) + +You are a **Sonnet fix executor** in Stage 3 (FIX) of the caramel codebase audit. You execute exactly ONE approved plan (`audit/plans/PLAN-F-0XX.md`) for exactly ONE finding, ending in exactly ONE commit. Read your plan fully, then the finding in `audit/findings.json`, then work. + +## Hard rules + +- Repo: `C:\Users\alaed\Documents\Github\caramel` (Windows). You work on the CURRENT checked-out branch (`audit/fixes-2026-07-10`) at whatever HEAD you find — **never switch/create branches, never push, never open PRs, never merge**. The lead handles all of that. +- **ONE commit at the end**: subject `fix(F-0XX): ` — SINGLE LINE ONLY, **no body, no Co-Authored-By line, no Claude-Session line** (user's global git rules override all defaults). Internal checkpoints during work are fine (local commits), but squash to the one final commit before finishing (`git reset --soft` to the pre-work HEAD and commit once is the reliable squash on this repo). +- **Pinning tests FIRST**, exactly as your plan sequences them. Green-suite rule: never claim safety from a suite that doesn't exercise your changed code. +- **Deviation rule:** if reality materially contradicts your plan (structure, premise, blast radius), STOP — finish your report explaining the contradiction and what you recommend; do NOT improvise a structural rework. Minor mechanical deviations (paths moved, a helper renamed, an extra caller found): proceed and log them in your report. +- **No drive-by fixes.** Tempting adjacent problems become new-finding candidates in your report, not edits. Your diff contains only what your plan scopes. +- Follow the repo's established conventions (and the ones earlier fixes in this train established). One way per thing. +- Do not modify anything under `audit/` except nothing — your report goes in your final message, not on disk. Do not touch `.env` (it's the user's local secrets; read-only). + +## Cross-review overrides (take precedence over your plan's details where they conflict) + +- **CR-1:** All app unit tests live in `apps/caramel-app/tests/unit/**` (F-004's vitest include). Extension tests in `apps/caramel-extension/tests/**`. If your plan named co-located paths (e.g. `src/lib/env.test.ts`), relocate to `tests/unit/` — content unchanged. Per-file environment overrides (jsdom for RTL tests) via `// @vitest-environment jsdom` pragma. +- **CR-2:** size-limit keeps F-009's summed-group budget scheme (one 92 KB content-scripts group). F-008 updates the `path` array only (drop `shared-utils.js`, add its 6 split files) — no per-file budgets. +- **CR-3:** `zod` enters the repo in F-005, pinned exact. Later fixes import it; nobody re-adds it. +- **CR-4:** F-009 adds a TEMPORARY `audit/dev-2026-07-10` entry to `checks-app.yml` `on.pull_request.branches` (+ push if the plan says so), commented `# TEMPORARY — remove when the audit branch merges`. +- **CR-5:** Where your plan says "verify at exec time", actually do it — plans were written against `0c0a991`; earlier fixes have since moved the tree. Re-anchor by symbol/grep, not line numbers. +- **CR-6:** F-015 executor: re-grep `caramel-landing` across the whole tree at execution time (earlier fixes may have added new workflow refs beyond the 5 the plan lists); the `git grep = 0 hits (excluding audit/)` check is the gate. +- **CR-8:** F-007 executor: your route-coverage table row for `extension/supported-stores` predates F-003's landed design — post-F-003 that route is KEYLESS behind `checkRateLimit(req,'read')` (keep exactly that; no apiKey concern), and the wrapper's api-key concern instead models `expire`'s `Authorization: Bearer COUPONS_ADMIN_SECRET` gate (fold `isTrustedServer`/bearer into the wrapper's declarative option rather than inventing a second checker). Derive every route's current posture from the tree you find, not the table. +- **CR-9:** F-012 executor: the `eval` script's env loading — if you add `dotenv-cli`, pin it exact (F-014's deps-pinned guard test will fail carets); a dependency-free alternative (loading `.env` inside `vitest.eval.config.ts` via Vite's loadEnv or a tiny setup file) is preferred if clean. + +## Environment facts + +- node v24.15.0, pnpm **10.14.0** installed globally (`packageManager` field says pnpm@9 — use `pnpm` as-is; do NOT corepack-switch or "fix" the field unless your plan says so). After any install, check `git diff pnpm-lock.yaml` is scoped to your intended additions — a wholesale lockfile rewrite = STOP, report (CI installs with pnpm 9 via frozen-lockfile and must still pass). +- `apps/caramel-app/.env` EXISTS with working local secrets (read it if your plan needs values like `OPENROUTER_API_KEY` availability — never print secret values into your report or commit them). +- Local Postgres for the app: `pnpm dev:compose` brings up caramel's postgres (127.0.0.1:58005) + redis (58006) via `local-dev/docker-compose.yml`. Leave the containers running when done (other fixes need them). NEVER touch other projects' containers (bioflow/snapvisor/dodomain/getitdone/postify...). +- Never kill `chrome.exe` or `claude.exe` by image name — target specific PIDs only, and only processes you started. +- App dev server: `pnpm --filter dev` → http://localhost:58000. If you boot it, kill YOUR process when done (by PID). +- Playwright e2e needs DB up + migrations applied (`pnpm --filter exec prisma migrate deploy` with the .env DATABASE_URL). Argos visual uploads happen only in CI — locally e2e is functional-only. +- `gh` CLI is authenticated for repo DevinoSolutions/caramel (read operations fine; you never push/PR). + +## Report format (your final message — the lead parses it) + +1. **Status:** COMPLETE / STOPPED (deviation) / FAILED. +2. **Commit:** SHA + subject (or none). +3. **Plan-vs-actual:** step-by-step — done as planned / deviated (what+why). +4. **Checks run + results:** exact commands + outcomes (test counts, tsc, lint, knip, build...). Report failures honestly — a red check you couldn't fix = STOPPED, not COMPLETE. +5. **Deviations:** minor ones you proceeded with. +6. **New-finding candidates:** anything tempting you did NOT fix. +7. **Notes for later fixes** in the train, if any. diff --git a/audit/plans/PLAN-F-001.md b/audit/plans/PLAN-F-001.md new file mode 100644 index 0000000..bcdb79d --- /dev/null +++ b/audit/plans/PLAN-F-001.md @@ -0,0 +1,52 @@ +# PLAN-F-001 — Coupons-DB health probe + typed read boundary + drift check + +**Finding:** F-001 (Critical) · externally-owned, health-blind, runtime-untyped coupon catalog · **Effort:** M · **Wave 2 · Sequence position 6** · **Depends-on:** F-004 (vitest infra), F-005 (zod dep + validated env module; already refactored `couponsDb.ts:9-12` env read + documented `COUPONS_DATABASE_URL` in `.env.example`), F-002 (route try/catch → `Sentry.captureException` convention). + +## Executive summary + +Add a runtime-validated read boundary over `couponsSql` (zod row schema per query, parsed at the boundary, throws loudly on drift → F-002/Sentry), extend the single health endpoint to probe **both** DBs (503 when either is down, monitor-compatible), and ship a dispatch-runnable `information_schema` drift script. ~13 source files touched + tests. **Breaking: N** for the external monitor (HTTP 200/503 + top-level `status` preserved); intentionally fail-loud under active drift. Riskiest step: writing schemas that mirror **postgres.js runtime types** (int4→number vs int8→string, text/`::int`/`COALESCE` nullability) so a healthy DB never false-500s. + +## Premise verification (done in code) + +- `health/db/route.ts:9-11` probes **only** `prisma.$queryRaw` — coupons DB never checked. **CONFIRMED.** +- Only consumer of `/api/health/db` is an out-of-repo Uptime-Kuma monitor (Bearer `UPKUMA_HEALTH_SECRET`, keys on status code) — no in-repo caller. **CONFIRMED** (grep: var lives only in `health.ts`+`couponsDb.ts`). +- **Premise refined, not broken:** several call sites already pass a TS generic (`couponsSql>`). That is a **compile-time cast with zero runtime enforcement** — `top-sites` even omits `coupon_count` from its generic while selecting it, proving casts drift silently. Finding's substance (no _runtime_ contract) holds fully. +- Doc note: header calls `couponsDb` "read-only" but two write paths already exist (`coupons/increment`, `coupons/expire` `UPDATE…RETURNING`; `sources` POST `INSERT`). My boundary adds **no** write path and leaves these untouched. + +## Scope + +**Modify** `src/lib/couponsDb.ts` (add 8 zod row schemas + `parseCouponRows` + `pingCouponsDb`; delete stale unused `CouponRow`). **Modify** `src/app/api/health/db/route.ts`. **Wire `parseCouponRows` into 9 read sites:** `api/coupons/route.ts` (list+count), `api/coupons/stores`, `api/coupons/stats`, `api/coupons/filters` (sites+types), `api/sites/top-sites`, `api/sites/search-supported`, `api/sources` (GET only), `api/extension/supported-stores`, `(marketing)/coupons/[store]/page.tsx` (list+count). **Create** `scripts/check-coupons-schema.ts` + `package.json` script `check:coupons-schema` + `.github/workflows/coupons-schema-drift.yml` (`workflow_dispatch`-only, gated on `COUPONS_DATABASE_URL` secret — never runs on PR/push). +**OUT of scope:** replacing raw SQL with an ORM/second Prisma schema (bigger redesign — rejected); extracting queries into a coupon-domain module (that is **F-006**, position 8 — schemas I add will be _consumed_ by it, not moved now); parsing mutation `RETURNING` rows / any write path; producer-side (Python) shared contract; running the drift check in PR CI; RUNBOOK authoring (F-011 references my probe+script). + +## Approach + +**Boundary (rejected alt: per-route inline `.parse`).** One home in `couponsDb.ts`: `parseCouponRows(schema, rows, queryLabel): T[]` = `z.array(schema).safeParse`; on failure `throw new Error('coupons-db schema drift ['+label+']: '+issues)`. Each read call site wraps its `await couponsSql\`…\``result. API routes' existing`try/catch`(post-F-002) route the throw to Sentry with the drift label; the SSR page has no try/catch — its throw is captured by the already-wired`onRequestError`(F-002 caveat) and rendered by F-011's`error.tsx`once that lands. **This is the runtime drift detector.** Deliberate trade-off: fail-loud 500/error beats silently-serving-wrong-data for a product whose pitch is honesty. +**8 schemas (1 per distinct row shape):**`CouponListRow`(coupons list +`[store]`page: id, code, site, title, description, rating, discount_type∈PERCENTAGE|CASH|SAVE, discount_amount∈num|null, expiry:str, expired:bool,`timesUsed`, status:str, verificationMessage:str|null — align to `types/coupon.ts:Coupon`, `id`via`z.coerce.string()`to tolerate int4/int8),`TotalCountRow{total}`, `StatsRow{total,expired}`, `SiteRow{site}`(stores/filters/search),`SiteCountRow{site,coupon_count}`(top-sites — fixes the incomplete generic),`DiscountTypeRow{discount_type}`, `SourceRow{id,source,websites:str[],status,total_coupons,total_used,total_expired}`, `StoreConfigRow{store_name + 8 xpath:str|null}`. +**Health (rejected alt: sibling `/api/health/coupons-db`— needs a 2nd monitor target, an ops change; and the finding wants the *existing* green monitor to flip red).** Extend the one route:`Promise.all([timedCheck('auth_db',…prisma), timedCheck('coupons_db',()=>pingCouponsDb())])`; body `{status:ok?'ok':'error', checks:{auth_db, coupons_db}}`; HTTP `200`iff both ok else`503`. Keep top-level `status`so both status-code and keyword monitors survive. +**Drift script:**`tsx scripts/check-coupons-schema.ts`(mirrors existing`scripts/ci-env.ts`), queries `information_schema.columns`for`coupons`/`sources`/`store_verification_configs`/`verification_stores`, asserts every column the 8 schemas read exists, exits non-zero listing missing/renamed columns. Dispatch/manual only (no coupons DB in CI — verified: `ci-env.ts` + workflows never provision it). Satisfies v5 §"schema-drift workflow"; the in-repo zod boundary satisfies v5 §21 _at runtime_ — producer-side shared schema is the **named known-debt remainder** (Python repo out of reach). + +## Sequencing (each step ends with its check) + +1. **Characterization tests FIRST** (F-004 vitest), mocking `couponsSql`/`prisma` to return representative good rows: pin current success envelope+status of all 9 read routes and the _current single-DB_ health body. → new tests green on unchanged code. **[ckpt A]** +2. Add 8 schemas + `parseCouponRows` + `pingCouponsDb` to `couponsDb.ts`; delete unused `CouponRow`. → `tsc --noEmit` green. **[ckpt B]** +3. Wrap each read query's result in `parseCouponRows(Schema, rows, '