# TokNext — Project State

> **For AI assistants:** This file is the single source of truth for what has been built,
> what is live in production, and what is pending. Read it before writing any code.
> Last updated: 2026-07-26 (TikTok official SDK adapter's HTTP transport
> rewritten to remove the `request` dependency — see "TikTok official SDK
> adapter" section below. Not deployed; `TIKTOK_USE_OFFICIAL_SDK` is still
> `false`). Phase 8 was deployed and post-deploy-verified 2026-07-25.
> See `CODEX_HANDOFF.md` for the final Codex-to-Claude handoff and exact next steps.

---

## What is TokNext

TikTok Shop product research and analytics platform. Users discover trending products,
see an AI-computed "Opportunity Score", save favourites, and subscribe for higher limits.
Built with Next.js 16 App Router · React 19 · TypeScript · Tailwind v4 · Prisma ·
PostgreSQL · Auth.js v5 · Stripe · Resend · shadcn/ui.

Production URL: **https://toknext.com**
Repository: GitHub — main branch. Latest deployed code commit: `a3d9569`
(`4fcdb5e` Phase 8 intelligence surfaces + `a3d9569` TikTok OAuth state hardening).
VPS: `72.60.118.86` (cPanel, AlmaLinux 9). App user: `toknext`.

---

## Phases completed

### Phase 1 — Marketing site (commit `fb4991c`)
- Public marketing pages: homepage, pricing, features, about, contact
- Bilingual (EN/ES) from day one — `src/lib/i18n/translations.ts` typed `Dictionary`
- Language toggle in header, persists to localStorage and DB after login
- shadcn/ui component library, Tailwind v4, global CSS variables

### Phase 2 — Authentication (commit `fb4991c`)
- Auth.js v5 (NextAuth) with Prisma adapter
- Credential auth: register, email verification (token-based), login, logout,
  forgot-password, reset-password
- "Remember me" (30-day vs 1-day JWT TTL)
- Google OAuth conditional: only registered when `GOOGLE_CLIENT_ID` + `GOOGLE_CLIENT_SECRET`
  are non-empty; button gracefully shows "coming soon" toast when unconfigured
- Bcrypt password hashing, timing-safe compare, rate limiting (via upstash or in-memory)
- Secure cookies: `__Host-`/`__Secure-` prefixes, HttpOnly, SameSite=Lax
- `UserRole` enum: `USER | ADMIN`. Role gates: middleware + `src/app/admin/layout.tsx`

### Phase 3 — Dashboard foundation (commit `342c8c3`)
- Authenticated dashboard shell with sidebar navigation
- Account page, profile page, settings
- Bilingual email templates (verification, password reset, welcome, security notice)
- Email provider abstraction: Resend → SMTP → mock console logger (priority order)
- `User.language` preference synced to DB, localStorage, and Auth.js session

### Phase 4 — Stripe billing (commits `0f1e19a`, `030940b`)
- Plans: `FREE`, `PRO` ($29/mo · $290/yr), `AGENCY` ($79/mo · $790/yr)
- `src/config/plans.ts` — single source of truth for prices, limits, Price ID resolution
- `src/lib/stripe/` — checkout, portal, webhooks, subscription sync, access resolution
- Webhook handler: 8 events (`checkout.session.completed`, `customer.subscription.*`,
  `invoice.*`) — HMAC-SHA256 verified, idempotent upsert into `Subscription` +
  `BillingEvent` tables
- Customer Portal: cancel at period end, plan switching, invoice history, payment update
- Plan limits enforced server-side in all gated actions (never trust the client)
- `UsageCounter` + `src/lib/usage.ts` metering infrastructure (not yet wired to AI)
- Billing dashboard page with plan display, upgrade CTAs, Manage Billing button
- `UserPlan` access resolver (`getUserPlan`) used across all gated actions

### Phase 5 — Product intelligence (commits `b61ff58`, `030940b`)
- **Provider abstraction** (`src/lib/product-data/providers/`):
  - `types.ts` — `ProductDataProvider` contract
  - `demo-provider.ts` — 32 fictional products, 4 trend patterns, deterministic
    mulberry32-seeded 30-day history (960 snapshots). Idempotent seed.
  - `json-provider.ts` — JSON file upload (max 2 000 products, 5 MB)
  - `csv-provider.ts` — CSV upload, RFC4180 parser, formula-injection sanitizer,
    per-row failure isolation (max 5 000 rows, 5 MB)
  - `tiktok-provider.ts` — full Phase 6 implementation (see below)
  - `registry.ts` — maps source codes to provider instances
- **Normalization** (`normalize.ts`, `deduplicate.ts`, `validation.ts`, `metrics.ts`)
- **Ingest** (`ingest.ts`): `ensureDataSource`, `ingestNormalizedProduct`,
  `markProductsInactive` (soft-delete products missing from a full sync batch)
- **Scoring engine** (`src/lib/scoring/`) — all pure functions, no I/O:
  - Components: trend, demand, saturation (0=low, 100=extreme), competition,
    engagement, value, confidence
  - Formula: `Opportunity = (Trend·0.3 + Demand·0.25 + Competition·0.2 +
    Engagement·0.15 + Value·0.1) − Saturation·0.25`, confidence-blended toward 50
  - Normalization: category-relative percentile rank (subcategory+country →
    category+country → category → global, `MIN_COHORT_SIZE = 5`)
  - `SCORE_MODEL_VERSION = "v1.0.0"` stored on every `ProductScore` row
  - `recalculate.ts` — `recalculateAllActiveProducts()`: 2 DB queries total,
    in-memory cohort index, no N+1
  - Explanation engine: structured strengths/risks/warnings with stable i18n keys
- **Database layer**: `src/database/products.ts`, `product-scores.ts`,
  `saved-products.ts`, `import-jobs.ts`, `search-history.ts`
- **Product discovery page** (`/dashboard/products`): server-side search (FTS),
  filter, sort, pagination; saved state batch-checked per page
- **Product detail page** (`/dashboard/products/[productId]`): score breakdown,
  explanation, metric history charts (hand-rolled SVG — no charting lib),
  save/unsave, usage gate
- **Saved products** (`/dashboard/saved`): notes, remove, limit usage bar
- **Admin routes** (admin role only): `/admin/products`, `/admin/imports`,
  `/admin/data-sources`, `/admin/scoring`
- **`PRODUCT_ANALYSIS` usage gate**: 24h rolling window, `ProductAnalysisAccess`
  upsert + usage increment are atomic (same call), known acceptable race documented
- **183 unit tests** (Vitest) covering all scoring components, provider normalization,
  webhook dispatch, access resolution, plan hierarchy, CSV/JSON edge cases

### Phase 6 — Score history · Cron · TikTok OAuth · FTS (commits `48eeaf9`, `6104ba0`, `43c54d0`)

#### Schema additions (migration `20260724_add_phase6`)
- `ProductScoreHistory` — append-only score archive (all 8 components +
  `calculatedAt` + `modelVersion`). `@@index([productId, calculatedAt])`.
  One row per product per recalculation, never overwritten.
- `TikTokConnection` — encrypted OAuth token storage per user (AES-256-GCM).
  Fields: `userId` (unique FK), `encryptedAccessToken`, `encryptedRefreshToken`,
  `tokenExpiresAt`, `shopCipher`, `scope`, `connectedAt`.
- `SearchHistory` — per-user query log. `@@index([userId, createdAt])`.
- GIN full-text search index: `products_name_fts ON products USING gin(to_tsvector('english', "canonicalName"))`
  (raw SQL — Prisma `@@fulltext` is MySQL-only; PostgreSQL uses `fullTextSearchPostgres`
  preview feature + GIN migration)

#### Cron sync endpoint
- `GET /api/cron/sync` — Bearer-token protected (`CRON_SECRET`), returns
  `{ scored, failed, elapsedMs }`. Calls `recalculateAllActiveProducts()` and
  appends `ProductScoreHistory` rows.
- Systemd timer on VPS: every 6h (`00,06,12,18:00:00`), flock-protected,
  logs `timestamp http=<code> duration=<s>s status=<SUCCESS|FAILURE> body=<json>`
  to `/var/log/toknext-sync.log` without logging the secret.

#### TikTok OAuth structure
- `src/lib/tiktok/tokens.ts` — AES-256-GCM encrypt/decrypt with
  `TIKTOK_TOKEN_ENCRYPTION_KEY` (32-byte hex). Format: `iv:tag:ciphertext`.
- `src/lib/tiktok/oauth.ts` — `buildAuthUrl`, `exchangeCode`,
  `refreshAccessToken`, `revokeToken` (TikTok Shop Open API v2 endpoints)
- `src/lib/tiktok/client.ts` — `getTikTokClient(connectionId)`: reads connection,
  decrypts token, auto-refreshes within 5 min of expiry, HMAC-SHA256 signs
  all API calls. Returns `null` if no connection.
- OAuth routes: `GET /api/tiktok/connect`, `GET /api/tiktok/callback`,
  `POST /api/tiktok/disconnect`
- `tiktok-provider.ts` — full provider skeleton: cursor-paginated
  `GET /products/search`, normalizer, throws `ProviderNotConfiguredError` when
  `TIKTOK_SHOP_APP_KEY` / `TIKTOK_SHOP_APP_SECRET` absent.
- **TikTok ingestion is NOT operational** — awaiting Partner Center credentials.

#### Full-text search
- `src/database/products.ts` uses `{ search: q }` (Prisma `fullTextSearchPostgres`)
- PostgreSQL GIN index active in production, confirmed via `EXPLAIN ANALYZE`.

#### Score history UI
- `getScoreHistory(productId, days?)` in `product-scores.ts`
- Product detail page fetches 90 days of history
- Trend charts rendered when ≥ 2 history points exist

> **Superseded by Phase 7 below**: the TikTok OAuth routes described here
> (`/api/tiktok/connect` etc.) were moved to `/api/auth/tiktok/*` in Phase 7
> to match the URL actually registered in TikTok Shop Partner Center. The
> `TikTokConnection` schema was also extended. Phase 7's section is current;
> this Phase 6 section is kept for commit-history context only.

### Phase 7 — Production readiness + TikTok Shop OAuth (commit `58c5d30`)

#### Production readiness
- **Security headers** (`next.config.ts`, applied to all routes): Content-
  Security-Policy (`script-src`/`style-src` use `'unsafe-inline'` — no nonce
  plumbing yet, see limitations; `img-src` allows `https:` broadly because
  Account settings lets a user paste an arbitrary avatar URL), X-Content-
  Type-Options, X-Frame-Options: DENY, Referrer-Policy, Permissions-Policy.
  HSTS is deliberately **not** set at this layer — Apache in front already
  sends it (see Production infrastructure below); setting it twice would
  just duplicate the header.
- **`GET /api/health`** — unauthenticated, runs `SELECT 1`, returns
  `{status, database, timestamp}` / 503 on DB failure. Never reveals error
  detail. Verified live: `https://toknext.com/api/health` → 200.
- **`src/app/error.tsx` / `global-error.tsx`** — Next.js error boundaries;
  neither existed before. `global-error.tsx` is deliberately dependency-free
  (inline styles only) since it replaces the root layout when *that* throws.
- **`src/instrumentation.ts`** — `register()` warns at boot on a partial
  (not fully-empty, not fully-set) TikTok env var configuration;
  `onRequestError` logs server errors uniformly.
- **Auth failure logging** — `src/actions/auth-actions.ts` now logs (IP +
  reason, never the email or password) on rate-limit trips, suspended-
  account attempts, and failed credential checks.
- **SEO** — `src/app/robots.ts` (disallows `/dashboard/`, `/admin/`,
  `/account`, `/profile`, `/api/`), `src/app/sitemap.ts`, canonical URL +
  OpenGraph/Twitter image in root metadata, JSON-LD (`Organization` +
  `SoftwareApplication`) on the homepage. Verified live (200): `/robots.txt`,
  `/sitemap.xml`.
- **Legal pages** — `/privacy`, `/terms`, `/refund-policy`, `/cookies`,
  `/contact`, `/support` (bilingual, `src/components/legal/`), linked from
  the footer. **These are drafts, not final legal copy** — every page has a
  visible "pending legal review" notice; bracketed placeholders
  (`[Company Legal Name]`, `[Jurisdiction]`, `[support email]`) need real
  values, and the 14-day refund window in `/refund-policy` is a common SaaS
  default, not a confirmed business decision. Do not remove the draft
  notices without an explicit legal-review sign-off.
- **Secret/config audit performed** (all clean): no `.env` committed, no
  hardcoded secrets in tracked source, no stray production-breaking
  `localhost` references (the 3 found are all intentional dev-mode
  fallbacks: `process.env.X ?? "http://localhost:3000"`). CSRF confirmed
  framework-level (Next.js Server Actions check `Origin` vs `Host`
  automatically) — nothing custom needed. Rate limiting pre-existed
  (Phase 2/3), confirmed still in place.
- **npm audit**: 10 vulnerabilities (3 moderate, 7 high), all in *nested*
  transitive deps (`postcss`/`sharp` inside `next`'s own `node_modules`,
  `nodemailer` inside `next-auth`). `npm audit fix --force`'s own suggested
  fix would downgrade `next` to `9.3.3` — clearly wrong; not applied.
  Needs a deliberate, tested dependency bump later, not a blind `--force`.

#### TikTok Shop OAuth — now a real, deployed production integration
- **Routes moved**: `/api/tiktok/*` → **`/api/auth/tiktok/{connect,callback,disconnect}`**
  to exactly match the Redirect URL registered in TikTok Shop Partner
  Center (`https://toknext.com/api/auth/tiktok/callback`). TikTok rejects a
  callback that doesn't match the registered URL exactly — this was a hard
  blocker fixed before anything else in this integration.
- **Credentials**: `TIKTOK_SHOP_APP_KEY` / `TIKTOK_SHOP_APP_SECRET`
  provided by the account owner and written directly into the VPS `.env`
  (never logged, never printed to a terminal, never committed).
  `TIKTOK_SHOP_STATE_SECRET` / `TIKTOK_TOKEN_ENCRYPTION_KEY` already existed
  from Phase 6 (confirmed present, correct 64-hex-char length) — not
  regenerated (rotating the encryption key would orphan any stored tokens;
  there were none yet, but no reason to rotate it anyway).
- **State is now HMAC-signed**, not just a random value in a cookie:
  `createSignedState()` / `verifySignedState()` in `src/lib/tiktok/oauth.ts`
  produce/verify `nonce.timestamp.signature` (HMAC-SHA256 with
  `TIKTOK_SHOP_STATE_SECRET`, timing-safe compare, 10-minute expiry). Doesn't
  depend on a cookie surviving TikTok's redirect chain.
- **First-API-verification** (`getAuthorizedShops()` in
  `src/lib/tiktok/client.ts`): called right after token exchange, before the
  connection is trusted — confirms the token actually works and returns the
  seller's shop(s). The callback route only ever persists a connection as
  `CONNECTED` after this succeeds.
- **Schema extended** (migration `20260724180000_add_tiktok_connection_fields`,
  purely additive): `TikTokConnection` gained `refreshTokenExpiresAt`,
  `openId`, `sellerId`, `sellerName`, `region` (display-only), a new
  `TikTokConnectionStatus` enum (`CONNECTED` / `RECONNECT_REQUIRED` /
  `DISCONNECTED`), and `lastRefreshAt`. Both access **and** refresh tokens
  remain AES-256-GCM encrypted (stronger than the minimum ask of encrypting
  only the refresh token).
- **Reconnect flagging**: `getTikTokClient()` marks a connection
  `RECONNECT_REQUIRED` (and stops attempting refresh) when TikTok returns a
  permanent refresh-token failure. Transient HTTP 429/5xx failures are
  retried with bounded backoff and do not immediately mark the connection
  for reconnect.
- **Product sync**: `src/lib/tiktok/sync.ts` —
  `syncTikTokProducts(connectionId)` / `syncAllTikTokConnections()`. Reuses the
  same `ensureDataSource` + `ingestNormalizedProduct` pipeline every other
  provider writes through (identity = sourceId+externalId → incremental,
  never duplicates). Runs once (fire-and-forget) right after a successful
  connection, and again for every `CONNECTED` account on each cron run,
  *before* score recalculation — a sync failure for one seller never blocks
  scoring for the rest of the catalog.
  **Known gap**: product listing sync is implemented. Product/shop analytics
  endpoints are wired in `src/lib/tiktok/metrics.ts` and fail soft, but have
  not been verified against a live authorized TikTok Shop seller. If TikTok's
  live paths or field names differ, metrics render as unavailable rather than
  being faked.
- **Admin UI**: `Admin → Data sources` now has a `TikTokConnectionCard`
  (`src/components/admin/tiktok-connection-card.tsx`) showing status,
  seller name, region, last sync, and a Connect / Reconnect / Disconnect
  button. Admin-only (matches every other provider — TikTok connection here
  is a site-wide data source, not a per-customer integration).
- **What was NOT executed**: the actual human click-through of TikTok's
  OAuth consent screen as a real, authorized seller. That requires a real
  TikTok Shop seller account and Partner Center access — it cannot be
  simulated. Everything up to that point (routes, signing, encryption,
  error handling, admin UI, the DB write path) has been code-reviewed and
  compiles/lints/tests/builds clean and is deployed live, but the full
  "authorize → callback → token exchange → product import → score" loop has
  not been observed end-to-end against a live seller. Do not report this
  integration as fully verified until that happens.

#### Deployment (2026-07-24)
- Committed as `58c5d30`, pushed to `origin/main`.
- Deployed via manual tar+scp+ssh (equivalent to `scripts/deploy.sh`,
  which needs `sshpass` — not installed in the deploying environment).
  **Found while deploying**: `scripts/deploy.sh` previously ran `npm ci --omit=dev`,
  but the Next.js/Tailwind v4 *build* step needs `@tailwindcss/postcss` (a
  devDependency) — the build fails under `--omit=dev`. Worked around by
  running a full `npm ci` for this deploy. **The deploy script itself
  had this bug** — fixed in `scripts/deploy.sh` by switching the remote
  install step to full `npm ci`.
- Migration applied directly to production Postgres (via SSH tunnel) before
  the code deploy, then confirmed already-applied (idempotent) when
  `prisma migrate deploy` ran again during the deploy itself.
- Verified live after restart: `https://toknext.com` → 200, `/api/health` →
  200, `/privacy` → 200, `/robots.txt` → 200, `/sitemap.xml` → 200.

---

## Production infrastructure (VPS `72.60.118.86`)

### Process manager
- **PM2 v6.0.14** — process name `toknext`, fork mode, port 3001
- App dir: `/home/toknext/public_html/app/toknext-next`
- Start cmd: `PATH=/opt/node20/bin:$PATH PORT=3001 npm run start`
- PM2 startup configured (`pm2 startup systemd`), process list saved (`pm2 save`)
- PM2 error log contains historical deployment warnings (old Stripe test-key
  warning and transient Server Action mismatch lines). No new error lines
  appeared after the `a3d9569` restart. All TokNext restarts to date were
  manual deployments.

### Reverse proxy
- **Apache** reverse proxy: HTTPS → `http://127.0.0.1:3001/`
- SSL VHost config: `/etc/apache2/conf.d/userdata/ssl/2_4/toknext/toknext.com/app-proxy.conf`
- HTTP VHost: `Redirect permanent / https://toknext.com/` (301)
- **HSTS**: `Strict-Transport-Security: max-age=31536000; includeSubDomains` — active

### Firewall
- **iptables** blocks external access to port 3001:
  - Rule 1: `ACCEPT tcp -i lo dport 3001` (loopback only — Apache proxy)
  - Rule 2: `DROP tcp * dport 3001` (all external connections)
- Rules saved to `/etc/iptables.rules`, restored via `/etc/rc.local` on reboot.

### Cron scheduler
- `/etc/systemd/system/toknext-sync.timer` — `OnCalendar=*-*-* 00,06,12,18:00:00`
- `/etc/systemd/system/toknext-sync.service` — calls wrapper script
- `/usr/local/bin/toknext-sync.sh` — chmod 700, root-owned
- `/etc/toknext-cron.env` — chmod 600, root-owned, contains `CRON_SECRET` only
- Log: `/var/log/toknext-sync.log`
- Verified: 4 successful manual runs, 128 `ProductScoreHistory` rows (32 products × 4 runs)

### Node.js
- `/opt/node20/bin/node` (Node.js 20, cPanel-managed)

---

## Stripe configuration

### Production state (verified 2026-07-25)
- `STRIPE_SECRET_KEY`: Live mode.
- `NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY`: Live mode.
- `STRIPE_WEBHOOK_SECRET`: present; never print the value.
- 4 live Price IDs are set in production `.env` as
  `STRIPE_PRICE_PRO_MONTHLY`, `STRIPE_PRICE_PRO_ANNUAL`,
  `STRIPE_PRICE_AGENCY_MONTHLY`, and `STRIPE_PRICE_AGENCY_ANNUAL`.
- Stripe API lookup verified all 4 Prices are live, active, USD, and have
  the expected month/year intervals.
- Webhook: live, enabled, registered at `https://toknext.com/api/stripe/webhook`
  with all 8 required events. The app route is **not** `/api/webhooks/stripe`.
- Customer Portal: active default configuration exists.
- Managed Payments is enabled on this Stripe account — do NOT pass
  `automatic_tax` to Checkout.

### VPS state (2026-07-25)
**LIVE MODE** — live keys active and API/webhook/portal verified without
creating a real charge. A prior Stripe TEST-key warning remains in old PM2
log lines only; it did not recur after restart.

---

## Environment variables (production `.env` — never committed)

### Set and non-empty
| Variable | Notes |
|---|---|
| `DATABASE_URL` | PostgreSQL on VPS |
| `AUTH_SECRET` | Set |
| `AUTH_URL` | `https://toknext.com` |
| `NEXT_PUBLIC_APP_URL` | `https://toknext.com` |
| `CRON_SECRET` | Set |
| `STRIPE_SECRET_KEY` | LIVE |
| `NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY` | LIVE |
| `STRIPE_WEBHOOK_SECRET` | Present |
| `STRIPE_PRICE_PRO_MONTHLY` | Present, verified live |
| `STRIPE_PRICE_PRO_ANNUAL` | Present, verified live |
| `STRIPE_PRICE_AGENCY_MONTHLY` | Present, verified live |
| `STRIPE_PRICE_AGENCY_ANNUAL` | Present, verified live |
| `EMAIL_FROM` | `TokNext <noreply@toknext.com>` |
| `SUPPORT_EMAIL` | `support@toknext.com` |
| `TIKTOK_SHOP_STATE_SECRET` | Present (generated, not TikTok-issued) |
| `TIKTOK_TOKEN_ENCRYPTION_KEY` | Present, valid 64+ hex format |
| `TIKTOK_SHOP_APP_KEY` | Present |
| `TIKTOK_SHOP_APP_SECRET` | Present |

### Empty / not configured
| Variable | Impact |
|---|---|
| `GOOGLE_CLIENT_ID` / `GOOGLE_CLIENT_SECRET` | `""` — Google button shows "coming soon" toast |
| `RESEND_API_KEY` | `""` — emails logged to PM2 stdout, not delivered |
| `SMTP_HOST` | `""` — SMTP fallback also inactive |

---

## Database schema (Prisma `prisma/schema.prisma`)

### Applied migrations (in order)
1. `0_phase3_initial` — User, Account, Session, VerificationToken, PasswordResetToken,
   LoginAttempt, UserRole, Language enums
2. `20260724045339_add_stripe_billing` — Subscription, BillingEvent, UsageCounter,
   BillingInterval, SubscriptionStatus, UsageType enums
3. `20260724134204_add_product_intelligence` — DataSource, Product, ProductScore,
   ProductMetricSnapshot, SavedProduct, ProductAnalysisAccess, ProductImportJob,
   DataSourceType, ImportJobStatus enums
4. `20260724_add_phase6` — ProductScoreHistory, TikTokConnection, SearchHistory,
   GIN FTS index on `products."canonicalName"`
5. `20260724180000_add_tiktok_connection_fields` — adds `TikTokConnectionStatus`
   enum + `refreshTokenExpiresAt`, `openId`, `sellerId`, `sellerName`, `region`,
   `status`, `lastRefreshAt` to `TikTokConnection` (Phase 7, purely additive)
6. `20260725000000_add_phase8_intelligence` — multi-store TikTok support,
   Store/Creator intelligence tables, official API product/metric fields,
   and additional ProductScore intelligence columns (Phase 8, additive).
7. `20260725010000_add_phase8_market_indexes` — forward-only indexes for
   Market Explorer, Store/Creator intelligence, dashboard summaries, saved
   products, and scheduler cleanup query paths.

### Active product data
- 32 demo products (source: `DEMO`), all active
- 960 metric snapshots (30 days × 32 products)
- 256 `ProductScoreHistory` rows as of 2026-07-25
- 0 `TikTokConnection` rows (credentials are configured, but no seller has
  completed the OAuth consent screen yet)
- 0 `Store` rows and 0 `Creator` rows until a real provider sync supplies them

---

## Automated checks (last run 2026-07-25, after Phase 8)

| Check | Result |
|---|---|
| `npm test` (Vitest) | 224 tests, 29 files — all pass |
| `npm run lint` (ESLint) | 0 errors, 0 warnings |
| `npx tsc --noEmit` | 0 errors |
| `npm run build` (local and on VPS) | Clean |
| `npx prisma validate` / `migrate status` | Valid, up to date, 7 migrations |

---

## Pending — what is NOT done yet

### 🔴 SECURITY — Stripe live secret key was exposed in a tool transcript (2026-07-24)
A `sed` command intended to print only a truncated key prefix (for a
verification check) failed silently and printed the **full** live
`STRIPE_SECRET_KEY` and `STRIPE_WEBHOOK_SECRET` to a terminal/tool output
that is part of this session's transcript. The values were not written to
any file or committed, but a secret that has appeared in full in any
transcript/log should be treated as potentially compromised.
**Action required**: rotate `STRIPE_SECRET_KEY` and regenerate the live
webhook endpoint's `STRIPE_WEBHOOK_SECRET` in the Stripe Dashboard, then
update `.env` on the VPS and `pm2 restart toknext --update-env`. This was
flagged to the user immediately when it happened; as of this writing the
rotation has **not** been confirmed done.

### Billing flow test — COMPLETED (2026-07-24, test mode)
All flows verified with Stripe test cards:
- ✅ Free → Pro checkout (`4242 4242 4242 4242`) — webhook received, `Subscription` row created, plan updated
- ✅ Pro → Agency upgrade via Customer Portal — plan changed correctly
- ✅ `return_url` working (Customer Portal "Return to TokNext" button → `/dashboard/billing`)
- ✅ `success_url` auto-redirect working (initial checkout → `/dashboard/billing?checkout=success`)
- Note: Customer Portal always shows "Return" button (Stripe design, not configurable — expected behavior)
- **Still not done**: one real purchase with a real card in live mode (test-mode verification is not the same thing).

### TikTok Shop OAuth — code deployed, human verification still pending
Everything code-side is built and live (see Phase 7 above: signed state,
encrypted tokens, admin UI, sync, scheduler integration). What's left is
not code — it's a person with TikTok Shop Partner Center / seller access
clicking **Admin → Data sources → Connect TikTok Shop** and completing the
real OAuth consent screen. Until that happens, `TikTokConnection` has 0 rows
and the `TIKTOK_OFFICIAL` data source has never actually synced anything.

### Account-owner actions required
| Item | What's needed |
|---|---|
| **Stripe key rotation confirmation** | See security item above. Confirm whether rotation happened; if not, rotate manually. |
| **Email delivery** | Create a Resend account, verify the `toknext.com` domain (SPF + DKIM records at the DNS provider), add `RESEND_API_KEY` to `.env`, `pm2 restart toknext --update-env`. |
| **Google OAuth** | Create OAuth 2.0 Client ID at Google Cloud Console. Redirect URI: `https://toknext.com/api/auth/callback/google`. Add `GOOGLE_CLIENT_ID` + `GOOGLE_CLIENT_SECRET` to `.env`. |
| **TikTok live connection** | App Key/Secret are set — an admin must click through Connect on `/admin/data-sources`. |
| **Stripe live test** | Stripe API/webhook/portal config is verified live; still needs one real purchase with a real card, webhook confirmation, and plan change verification. |
| **Google Analytics 4 / Search Console / Microsoft Clarity** | No accounts/IDs provided yet — analytics wiring not started. |
| **Postgres backup verification** | Investigated 2026-07-24: cPanel's own backup system does not appear to cover `/var/lib/pgsql` (it lives outside `/home`, and no `postgres` reference was found in cPanel's backup config). Comet Backup is installed and running, but its actual job scope (does it snapshot the Postgres data directory?) could not be confirmed from the filesystem alone — needs checking in the Comet Backup admin UI, or a dedicated `pg_dump` cron as a stop-gap. **Treat "is there a working backup of the production database" as unverified, not confirmed**, until this is checked. |

### Legal pages — drafted, not final (Phase 7)
`/privacy`, `/terms`, `/refund-policy`, `/cookies`, `/contact`, `/support`
exist and are linked from the footer, but every page carries a visible
"pending legal review" notice. Still needed: real company legal name,
jurisdiction, a confirmed refund window (14 days is a placeholder), and
support contact details filled in (currently `support@toknext.com` /
bracketed placeholders). Statement descriptor (bank statement name) still
needs to be set in the Stripe Dashboard.

### Other Phase 7 items not done
- **Lighthouse performance pass** (desktop + mobile) — not run.
- **Scheduler retry-on-failure**: the systemd service (`toknext-sync.service`)
  has no `Restart=on-failure` and the wrapper script doesn't retry a failed
  HTTP call to `/api/cron/sync` — a failed run just logs `status=FAILURE`
  and waits for the next scheduled slot (up to 6h later). Confirmed working
  otherwise: timer is `active (waiting)`, correctly scheduled, and logged
  3 successful manual test runs.
- **CSP without `unsafe-inline`**: the current Content-Security-Policy uses
  `'unsafe-inline'` for scripts/styles rather than a per-request nonce
  (which would need threading a nonce through `src/proxy.ts` middleware
  into every script tag) — a stricter policy is possible later but wasn't
  done in this pass.
- **`scripts/deploy.sh` install mode**: the previous `npm ci --omit=dev`
  command broke Tailwind v4/Prisma generate assumptions. The script now uses
  full `npm ci` before `npx prisma generate`, `prisma migrate deploy`, and
  `pm2 restart`.

### Phase 8 current status (2026-07-25)
- **Market Explorer implemented**: `/dashboard/products` now has DB-backed
  filters and allowlisted sorts for price, category, saved-only,
  availability/status, opportunity, trend/growth, confidence, saturation,
  growth velocity, demand acceleration, risk, competition trend, and
  momentum. Momentum currently maps to persisted `growthVelocityScore`; no
  separate momentum column exists yet.
- **Store Intelligence implemented**: `/dashboard/stores` and
  `/dashboard/stores/[storeId]` read `Store`, `StoreMetricSnapshot`, and
  linked products. Missing metrics are displayed as unavailable, not zero.
- **Creator Intelligence implemented**: `/dashboard/creators` and
  `/dashboard/creators/[creatorId]` read `Creator` and `ProductCreator`.
  If no provider has synchronized creators, the UI says no creator data is
  synchronized yet.
- **Trend Engine implemented**: `src/lib/trends/engine.ts` provides pure,
  deterministic daily/weekly/monthly curves, momentum, acceleration,
  seasonality, and sufficiency/confidence states.
- **Dashboard overview uses real data**: saved product count, creator record
  count, AI usage counter, recent search/save activity, and scored product
  previews come from DB queries instead of mock data.
- **Scheduler reliability implemented**: `/api/cron/sync` now uses a
  Postgres advisory lock, token refresh sweep, bounded TikTok sync
  concurrency, transient TikTok HTTP retry/backoff, structured per-run logs,
  per-shop failure isolation, and cleanup of old search history.
- **Performance indexes added forward-only**:
  `20260725010000_add_phase8_market_indexes`. The earlier Phase 8 migration
  was not edited because it may already be applied in production.
- **Production deployed and verified**: `4fcdb5e` was deployed with both
  Phase 8 migrations applied. `a3d9569` was then deployed as a small TikTok
  OAuth state hardening fix. Production health is 200 and Prisma migration
  status is up to date with 7 migrations.
- **Stripe post-deploy verification complete**: production `.env` is Live,
  4 live Prices were verified through the Stripe API, the live webhook is
  registered at `/api/stripe/webhook`, and Customer Portal is configured.
  Old PM2 Stripe TEST-key warnings are historical only.
- **Still not implemented**: AI Assistant/OpenAI workflow, video research,
  team seats/multi-user UI, trending searches UI, admin analytics dashboard.
- **Still unverified**: authenticated dashboard browser flows, live TikTok
  seller OAuth consent through product import, and analytics metric enrichment.
  The production callback remains `https://toknext.com/api/auth/tiktok/callback`.

---

## TikTok official SDK adapter (added 2026-07-26, transport replaced 2026-07-26 — NOT enabled, not deployed)

TikTok provided their official Node.js Partner API SDK (`nodejs_sdk.zip`,
openapi-generator 7.15.0, 84 versioned API modules). It's vendored and wired
in as an **opt-in, default-off alternate implementation** behind
`TIKTOK_USE_OFFICIAL_SDK` (default `"false"`) — the existing hand-rolled
TikTok code (`src/lib/tiktok/{client,oauth,sync}.ts`) is untouched and still
what runs in production. Nothing in this section changes production
behavior until that flag is flipped, which this pass deliberately did not do.

### Packaging
Vendored (not npm-installed — TikTok doesn't publish this on npm) at
`packages/tiktok-shop-sdk/vendor/`, referenced via the `@tiktok-shop-sdk/*`
path alias in `tsconfig.json` (and `scripts/tsconfig.json`, for the live
verification harness below). Patched lines in the vendored tree, each with
a comment at the exact line explaining why:
- `api/apis.ts`: `export { RequestFile }` → `export type { RequestFile }`
  (required for this project's `isolatedModules` setting).
- `model/models.ts` and `utils/generate-sign.ts`: `import localVarRequest
  from 'request'` → `import type localVarRequest from 'request'`. Both
  files only ever used `request`'s TypeScript *types*, never called the
  package at runtime (confirmed via `grep -n "localVarRequest(" <file>`
  returning nothing before patching) — this is what allowed
  `ObjectSerializer` (wire-format camelCase↔snake_case conversion) and
  `generateSign` (the official HMAC-SHA256 signing algorithm) to keep being
  reused with **zero runtime dependency on `request`**.

No other vendored file was edited — the 84 generated `api/*.ts` modules
still import and call `request` internally exactly as TikTok shipped them,
but nothing in this codebase imports any of those files anymore (see
"Transport replacement" below). **No LICENSE or NOTICE file was present in
the source archive** — verify TikTok's SDK license terms separately before
any redistribution beyond this repo.

### Transport replacement (2026-07-26): `request` removed from the runtime dependency tree
The first version of this adapter (2026-07-26 AM) called the vendored
SDK's generated API classes directly, which call the deprecated `request`
npm package at runtime — `request` and its `form-data` dependency carry
**2 critical CVEs** (SSRF in `request`; unsafe multipart boundary
generation + CRLF injection in `form-data`), confirmed via `npm audit`
after installing it (19 → 24 vulnerabilities, +2 critical). That version
was never deployed.

This was fixed by building a TokNext-owned HTTP transport
(`src/lib/tiktok/sdk/fetch-transport.ts`) using native Node.js `fetch`
instead of ever installing/calling `request`:
- **Endpoint/parameter/signing knowledge preserved**: every query
  parameter name, request body shape, and response-model type name used by
  `fetch-transport.ts` was read directly from the generated `api/*.ts`
  source (e.g. `ProductsProductIdGet`'s `return_under_review_version`/
  `return_draft_version`/`locale`/`shop_cipher` query params, the
  `x-tts-access-token` header, `ProductsSearchPost`'s JSON body) before
  those files stopped being imported — so the wire contract is unchanged
  from what the official SDK itself sends.
- **Signing reused, not reimplemented**: `fetch-transport.ts` calls the
  vendored `generateSign()` (now `request`-type-only, see above) with a
  plain object structurally matching `request`'s `Options` type — never a
  real `request` instance. This guarantees the signature TokNext sends is
  byte-identical to what the official SDK would compute, verified by
  `fetch-transport.test.ts`'s signing-parity tests (independently
  recomputing the expected `sign` query param from the exact request that
  was actually sent, for both GET and POST).
- **Response shape reused**: `ObjectSerializer.deserialize(json,
  "<ModelName>")` still converts TikTok's snake_case wire format
  (`seller_type`, `create_time`, `category_chains`, etc.) into the same
  camelCase shape (`sellerType`, `createTime`, `categoryChains`) every
  service file in `src/lib/tiktok/sdk/` already expected — so
  `authorization.ts`/`products.ts`/`analytics.ts`/`bestsellers.ts`/
  `webhooks.ts` only changed *how* they call the network (`callTikTokApi()`
  instead of `createSignedApi(...)`+a generated class method), not their
  field-mapping logic.
- **SSRF protections**: fixed allowlist of exactly two origins
  (`open-api.tiktokglobalshop.com`, `auth.tiktok-shops.com`), HTTPS-only,
  `redirect: "manual"` (a 3xx response is treated as a transient failure,
  never followed), `AbortController`-based 15s timeout.
- **Retry/backoff**: bounded exponential backoff (`src/lib/scheduler/
  retry.ts`, now with an optional `delayOverride` hook) retries network
  errors, timeouts, rejected redirects, 429, and 5xx — never 400/401/403/
  404/409. A real `Retry-After` response header (seconds or HTTP-date) on a
  429 always wins over the exponential default.
- **`src/lib/tiktok/sdk/client.ts` (the old `createSignedApi`/`callSdk`
  wrapper around the generated API classes) was deleted** — it's what
  actually invoked a generated class's method (and therefore `request`) at
  runtime; nothing calls it anymore, so keeping it around would have left a
  live path back to `request` a future change could accidentally reuse.
- **`request` removed from `package.json` dependencies entirely**
  (confirmed via `npm ls request` → empty tree after `npm prune`).
  `@types/request` **stays** as a devDependency — it provides the
  `localVarRequest.Options` type declaration the two `import type`
  patches above resolve against at build time; nothing about that type is
  emitted into compiled JS, so it carries no runtime risk.
- **Result**: the 2 critical CVEs this adapter had introduced are gone from
  the dependency tree. `@types/request` still pulls a real (not
  type-stub) `form-data@2.5.6` package as its own dependency (DefinitelyTyped
  packages sometimes depend on the real package purely to reference its
  shipped `.d.ts`) — but that package is never imported or executed by any
  code path, only present so TypeScript can resolve a type at compile time.
- **What this does NOT change**: none of the 84 generated `api/*.ts` files
  were edited or had their `request` imports removed — they're simply
  unreferenced now. If a future change reintroduces an import from
  `@tiktok-shop-sdk/api/*` or `@tiktok-shop-sdk/client/token`, `request`
  would need to be reinstalled and this whole risk returns; don't do that
  without repeating the transport-replacement work.
- **Environment limitation encountered while verifying**: `npm audit`
  (both plain and `--json`) failed against `registry.npmjs.org`'s bulk
  advisory endpoint in this sandbox with a gzip-decoding error
  (`invalid json response body ... reason: Unexpected token`), reproduced
  on 3 separate retries. This is a registry/network issue in the sandbox,
  not something caused by this change — `npm ls request` / `npm ls
  form-data` (which don't hit that endpoint) were used instead to directly
  confirm the dependency tree is clean. Re-run `npm audit` from an
  environment with reliable registry access before relying on its exact
  vulnerability count.

### Real discrepancies found vs. the existing hand-rolled implementation
Found by reading the SDK's actual source, not assumed:
1. **Token exchange method mismatch**: the SDK's `AccessTokenTool` (used
   unmodified from `packages/tiktok-shop-sdk/vendor/client/token.ts`) calls
   `GET https://auth.tiktok-shops.com/api/v2/token/get` with querystring
   params. The existing `src/lib/tiktok/oauth.ts`'s `exchangeCode()` POSTs a
   JSON body to the same URL. Neither has been exercised against a live
   seller (see "TikTok Shop OAuth" section above), so which one TikTok's
   servers actually accept is still unverified either way.
2. **Request-signing bug in the existing implementation**: `src/lib/tiktok/
   client.ts`'s hand-rolled `hmacSign()` signs `access_token` (TikTok's own
   SDK explicitly excludes it: `excludeKeys = ["access_token", "sign"]`) and
   signs `timestamp` twice (once via the sorted-params loop, again via an
   explicit extra concatenation). Left exactly as-is — see the code comment
   at that function — since only a real seller test can confirm which
   signing behavior TikTok's servers actually require. This is one of the
   concrete reasons a verified SDK path is valuable: it can prove or
   disprove this suspected bug without guessing.
3. **Search vs. detail field availability**: `ProductV202309Api.
   ProductsSearchPost`'s response has no images, brand, category, or
   description (confirmed against the SDK's generated
   `SearchProductsResponseDataProducts` model — only id/title/status/
   createTime/updateTime/skus). Only `ProductsProductIdGet` (single-product
   detail) returns `mainImages`, `brand`, `categoryChains`, and per-SKU
   variant images (`skus[].salesAttributes[].skuImg`). The SDK adapter's
   product ingestion is therefore two-step (search for IDs, then a bounded-
   concurrency detail fetch per ID) — see `src/lib/tiktok/sdk/products.ts`.
   Also note: **newer-dated API versions are not supersets of older ones**
   in this archive — `productV202502Api` only implements `SearchProducts`;
   `GetProduct` and both category-listing methods exist **only** in
   `productV202309Api` across all 84 modules. Every service in this adapter
   deliberately uses `202309` for exactly that reason.

### Scope → endpoint mapping (evidence: SDK source + generated models)

| Scope | SDK module | Method | Endpoint | shop_cipher | TokNext use | Status |
|---|---|---|---|---|---|---|
| seller.authorization.info | `AuthorizationV202309Api` | `ShopsGet` | `GET /authorization/202309/shops` | n/a (returns it) | authorized-shops verification | Implemented, unit-tested, **not live-tested** |
| seller.shop.info | `SellerV202309Api` | `ShopsGet` | `GET /seller/202309/shops` | n/a | supplementary shop check (leaner than above — no cipher/name) | Implemented, **not live-tested** |
| seller.product.basic | `ProductV202309Api` | `ProductsSearchPost` | `POST /product/202309/products/search` | required | product ID listing (no images/price) | Implemented, unit-tested, **not live-tested** |
| seller.product.basic (detail) | `ProductV202309Api` | `ProductsProductIdGet` | `GET /product/202309/products/{id}` | required | full product detail incl. images | Implemented, unit-tested, **not live-tested** |
| seller.global_product.category.info | `ProductV202309Api` | `CategoriesGet` | `GET /product/202309/categories` | required | category list | Implemented, **not live-tested** |
| data.shop_analytics.public.read | `AnalyticsV202509Api` | `ShopPerformanceGet` | `GET /analytics/202509/shop/performance` | required | shop-level GMV/orders/traffic | Implemented (top-level fields only — see analytics.ts), **not wired into UI, not live-tested** |
| data.bestselling.public.read | `AnalyticsV202511Api` | `ProductsBestsellingGet` / `CreatorsBestsellingGet` / `VideosBestsellingGet` / `LivesBestsellingGet` | `GET /analytics/202511/{products,creators,videos,lives}/bestselling` | optional | market-wide top products/creators/videos/live sessions | Implemented, **not wired into UI, not live-tested** |
| creator.data.live.read.public | `AnalyticsV202309Api` (per-live-room) | `GetLiveRoom*` (7 methods) | `GET /analytics/202309/live_rooms/{id}/*` | required | detailed per-live-room analytics | **Not implemented this pass** — evidenced/mapped only, needs a real `live_room_id` source first |
| seller.global_product.info | `ProductV202309Api` | `GlobalProductsGlobalProductIdGet` | `GET /product/202309/global_products/{id}` | n/a | cross-border global product detail | **Not implemented** — TokNext has no cross-border product flow yet |

"Not live-tested" = compiles, unit-tested with mocked SDK responses, never called against TikTok's real servers. Do not upgrade this status without an actual authorized seller.

### Feature flag
`TIKTOK_USE_OFFICIAL_SDK` (`src/lib/tiktok/sdk/config.ts`) — defaults false.
Wired into exactly one call site so far: `src/lib/product-data/providers/
tiktok-provider.ts`'s `fetchProducts()` branches to the SDK's two-step
search+detail path when true, otherwise runs the existing single-call
search path unchanged. Analytics/bestsellers/webhook services exist and are
unit-tested but have no caller yet (no UI, no scheduler hook) — intentional
for this pass; wiring them in is future work once the product path itself
is live-verified.

### Files
- `packages/tiktok-shop-sdk/vendor/` — vendored SDK (patched lines noted above)
- `packages/tiktok-shop-sdk/package.json` — vendoring notes
- `src/lib/tiktok/sdk/fetch-transport.ts` — the actual HTTP transport (native `fetch`, signing, SSRF allowlist, retry/backoff); every service below calls through it
- `src/lib/tiktok/sdk/{config,errors,types,authorization,shops,products,analytics,bestsellers,webhooks,index}.ts`
- `src/lib/tiktok/sdk/mappers/product.ts`
- `src/lib/tiktok/sdk/{errors,config,authorization,products,fetch-transport}.test.ts`, `mappers/product.test.ts` — 46 tests total (up from 24; authorization/products tests now mock `fetch` instead of the generated API classes, since the services no longer call those classes)
- `src/lib/tiktok/client.ts` — (existing, non-SDK implementation) added `accessToken` to the returned client (additive; existing callers unaffected), documented the suspected signing bug — unchanged by the transport-replacement pass
- `src/lib/tiktok/sync.ts` — exported `runWithConcurrency` (was module-private) so the SDK path reuses it instead of a second implementation
- `src/lib/product-data/providers/tiktok-provider.ts` — added the flag branch
- `src/lib/scheduler/retry.ts` — added optional `delayOverride` to `RetryOptions` so `fetch-transport.ts` can honor a real `Retry-After` header; every existing caller is unaffected (defaults to the prior exponential behavior)
- `tsconfig.json`, `scripts/tsconfig.json`, `eslint.config.mjs` — exclude the vendored tree from type-checking/linting as root inputs (still type-checked via the import graph when actually used); `scripts/tsconfig.json` also gained the `@tiktok-shop-sdk/*` alias for the live-verify harness below
- `vitest.config.ts` — raised `testTimeout` to 15s; the fake-timer-based retry/backoff tests in `fetch-transport.test.ts` were occasionally hitting vitest's 5s default under a cold TS-transform cache, unrelated to the code under test
- `scripts/tiktok-sdk-live-verify.ts` + `package.json`'s `tiktok:sdk:live-verify` script — see "Live seller verification harness" below
- `package.json` — `request` removed entirely; `@types/request` kept as a devDependency only

### Live seller verification harness (built, not executed)
`scripts/tiktok-sdk-live-verify.ts` (`npm run tiktok:sdk:live-verify`) is a
read-only diagnostic script for the one step that cannot be automated: once
a real admin has completed the OAuth consent screen in a browser (Admin →
Data sources → Connect TikTok Shop), this script finds that connection,
decrypts its stored access token, and calls `GetAuthorizedShops` →
`SearchProducts` (capped at 5) → `GetProduct` (1 product) against TikTok's
real API through the new fetch transport — proving end-to-end that
signing, deserialization, and pagination work against a live seller before
`TIKTOK_USE_OFFICIAL_SDK` is ever considered for `true` anywhere real
traffic depends on it. It never calls a mutating endpoint, never writes to
the database, and never logs an access token, refresh token, app secret,
authorization code, or either encryption/state secret beyond the last 4
characters of the stored ciphertext (for confirming "is this the row I
expected"). **Not run as part of this change** — there is still no
connected `TikTokConnection` row to run it against.

### Webhooks (Phase 11 — design only, not activated)
`src/lib/tiktok/sdk/webhooks.ts` implements list/upsert/delete against
`EventV202309Api` (the only event module in the archive). Real finding:
there is no separate "create" endpoint — `WebhooksPut` is an upsert, keyed
by `eventType`. No receiver route exists (e.g. no `/api/webhooks/tiktok`)
and nothing calls this service yet — per the brief, production webhooks are
explicitly out of scope for this pass.

### Live verification checklist (do not run without explicit approval)
1. Set `TIKTOK_USE_OFFICIAL_SDK=true` in a non-production environment only.
2. Log in as ADMIN, visit `/admin/data-sources`, click Connect TikTok Shop.
3. Approve scopes on TikTok's real consent screen.
4. Confirm the callback succeeds and `sdkGetAuthorizedShops` returns the
   real shop(s) with a real `shopCipher`.
5. Confirm a `TikTokConnection` row is created/updated as today.
6. Trigger a sync and confirm `sdkSearchProducts` + `sdkGetProduct` return
   real products with real `mainImages`/`skuImg` URLs.
7. Confirm those products ingest through the existing pipeline with real
   images (compare against the `img-src` CSP host allowance — TikTok's real
   image CDN hostname needs to be confirmed and added to
   `next.config.ts`'s `images.remotePatterns`, currently only a best-effort
   `*.ibyteimg.com` guess from a prior pass — see the dashboard UI section
   above).
8. Only after all of the above: consider wiring `sdkGetShopPerformance` /
   bestsellers into any UI, and re-run `npm audit` to check whether
   `request`'s CVEs have been addressed before considering this flag safe
   for production.

---

## Key architectural decisions (do not undo without reason)

- **`products.ts` search uses `{ search: q }`** — not `{ contains }`. Requires
  `fullTextSearchPostgres` preview feature in `schema.prisma`. Changing back
  would disable the GIN index.
- **`ProductScoreHistory` is append-only** — `upsertProductScore` inserts a new
  history row on every call; it never overwrites history. This is intentional.
- **Score normalization is category-relative percentile** — not absolute thresholds.
  Cohort fallback chain: subcategory+country → category+country → category → global.
  `MIN_COHORT_SIZE = 5`.
- **Saturation is inverted** (0=low saturation, 100=extreme) and subtracted in the
  Opportunity formula, not averaged in. Do not change the direction without updating
  all UI copy and the ES translations.
- **TikTok tokens are AES-256-GCM encrypted** in the DB. The key is
  `TIKTOK_TOKEN_ENCRYPTION_KEY` (64-char hex = 32 bytes). If this key is lost,
  all stored TikTok connections are unrecoverable.
- **`googleConfigured`** in `src/auth/index.ts` controls whether Google provider is
  registered. With empty env vars Next.js strips quotes → empty string → `Boolean("") = false`
  → button shows "coming soon" toast. Do not hardcode `true`.
- **Managed Payments is enabled on this Stripe account** — do not pass `automatic_tax`
  to Checkout sessions (it's unsupported). Tax is handled automatically by Stripe.
- **All Stripe Price ID resolution goes through `src/config/plans.ts`** — nothing
  else in the app should read a Price ID directly from env vars.
- **Webhook secret per endpoint** — test and live webhook endpoints have
  different signing secrets. The secret configured in `.env` must match the
  live endpoint when live keys are in use.
- **Port 3001 is firewalled** to loopback only via iptables. Do not remove those
  rules. Apache proxy accesses it via `127.0.0.1`.
- **TikTok OAuth routes live at `/api/auth/tiktok/*`**, not `/api/tiktok/*`
  — this must match the Redirect URL registered in TikTok Shop Partner
  Center exactly. If that registered URL ever changes, update both places
  together.
- **TikTok OAuth state is HMAC-signed** (`TIKTOK_SHOP_STATE_SECRET`), not a
  bare random value in a cookie — see `createSignedState`/`verifySignedState`
  in `src/lib/tiktok/oauth.ts`. Don't revert to cookie-only state; the
  signed approach doesn't depend on cookie survival across TikTok's
  redirect chain.
- **TikTok Shop connection is admin-level, not per-customer** — one
  `TikTokConnection` row belongs to whichever admin user connected it, and
  populates the *shared* product catalog as a `DataSource`. This mirrors
  how every other provider (demo/JSON/CSV) already works. Don't build a
  per-customer "connect your own shop" flow without deliberately deciding
  to change this model first.
- **HSTS is set by Apache, not by the Next.js app** (`next.config.ts`
  deliberately excludes it) — don't add it at the app layer too, that just
  duplicates the header from two places.
- **CSP currently permits `img-src https:` broadly** — required because
  Account settings accepts an arbitrary pasted avatar image URL
  (`src/actions/account-actions.ts`). Don't tighten `img-src` without
  either removing that feature or switching it to an upload-and-proxy
  model first.
