diff --git a/CHANGELOG.md b/CHANGELOG.md index 03dfd03..807f60c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ All notable changes to Epicure are documented here. This file is mirrored in-app at `/changelog` (and in the admin dashboard) via `apps/web/lib/changelog.ts` — update both together. +## 0.25.1 — 2026-07-14 09:40 + +### Fixed +- API key "Last used" only showed a date — now shows date and time. + ## 0.25.0 — 2026-07-14 09:36 ### Added diff --git a/HANDOFF.md b/HANDOFF.md deleted file mode 100644 index e415d14..0000000 --- a/HANDOFF.md +++ /dev/null @@ -1,127 +0,0 @@ -# Audit fixes handoff (2026-07-09) — for next session (Sonnet 5) - -A full 4-agent audit (bugs / UI-UX / backend consistency / feature gaps) was run, then fixes were fanned out to parallel agents. Session token limit killed 3 fix agents mid-flight. This file records: what's DONE, what's PARTIAL/UNVERIFIED, what's NOT STARTED. Nothing is committed. (Prior resolved backlog lives in TODO.md — don't confuse the two.) - -**First step for next session:** `pnpm typecheck && pnpm lint`, then verify the PARTIAL items below by reading `git diff` before doing anything else. - ---- - -## ✅ DONE (in working tree, uncommitted, reported complete by agents) - -### AI tier-quota bypass (critical — money leak) -- `apps/web/app/api/v1/ai/recipe-ideas/route.ts` — `generateObject` wrapped in `withAiQuota`. -- `apps/web/app/api/v1/ai/recipe-chat/route.ts` — `generateText` wrapped in `withAiQuota` (also maps provider errors). -- `apps/web/app/api/v1/ai/meal-plan/generate/route.ts` — `withAiQuota` added; recipe tier limit now charged per inserted draft recipe via `checkAndIncrementTierLimit(..., "recipe")` loop with refund (`incrementUsage(userId, "recipe", -charged)`) on `TierLimitError`; returns same 403 shape as `recipes/route.ts` POST. -- `apps/web/app/api/v1/recipes/[id]/nutrition/route.ts` — POST now author-only (was: any authed user could burn paid AI calls on public recipes), rate-limited (`rl:ai:${userId}`, 10/60), wrapped in `withAiQuota`. GET unchanged. - -### Webhooks (security + drift) -- `apps/web/lib/webhooks.ts` — SSRF fix: `dispatchWebhook` re-runs `validateWebhookUrl` per delivery (DNS re-resolution, private/link-local/loopback/metadata ranges rejected); `redirect: "manual"` on fetch (3xx = failed delivery); validation failure recorded as delivery failure (statusCode 0). Exported `WEBHOOK_EVENTS` as const array; `WebhookEvent` type derived from it. -- `apps/web/app/api/v1/webhooks/route.ts` + `[id]/route.ts` — drifted local `VALID_EVENTS` (4 events) removed; both use `z.enum(WEBHOOK_EVENTS)` (7 events). Users can now subscribe to `meal_plan.updated`, `shopping_list.completed`, `comment.added`. -- Leftover: `recipe.published` subscribable but never dispatched. Either dispatch on private→public transition in `recipes/[id]/route.ts` PUT, or drop the event. - -### DB schema + migration (generated, NOT applied) -- `packages/db/src/schema/recipes.ts` — indexes on `recipe_ingredients.recipe_id`, `recipe_steps.recipe_id`. -- `packages/db/src/schema/meal-planning.ts` — indexes on `meal_plans.user_id`, `meal_plan_entries.meal_plan_id`, `pantry_items.user_id`; UNIQUE `(user_id, week_start)` on meal_plans; UNIQUE `(meal_plan_id, day, meal_type)` on meal_plan_entries (verified intentional: entries route delete-then-insert + meal-planner.tsx `.find()` = one entry per slot). -- `packages/db/src/schema/social.ts` — `comments.parentId` now self-FK with `onDelete: "cascade"` (AnyPgColumn pattern). -- Migration generated: `packages/db/src/migrations/0023_medical_dark_beast.sql` (1 FK + 5 indexes + 2 unique indexes). -- ⚠️ **Before `pnpm db:migrate`, clean existing data or migration fails:** - 1. Dangling parents: `UPDATE comments SET parent_id = NULL WHERE parent_id IS NOT NULL AND parent_id NOT IN (SELECT id FROM comments);` - 2. Duplicate `(user_id, week_start)` meal_plans → merge/delete dupes. - 3. Duplicate `(meal_plan_id, day, meal_type)` entries → keep newest, delete rest. - -### Recipe route transactions -- `apps/web/app/api/v1/recipes/[id]/route.ts` PUT — snapshot/version-bump moved after Zod validation, inside the update transaction (invalid body no longer creates phantom snapshots). -- `apps/web/app/api/v1/recipes/[id]/versions/[versionId]/route.ts` — pre-restore snapshot moved inside the restore transaction. - ---- - -## ⚠️ PARTIAL / UNVERIFIED — agent died mid-work, verify diffs first - -1. **`recipes/[id]/route.ts` DELETE — S3 object cleanup.** Agent died MID-EDIT here ("Now bug B — DELETE with S3 cleanup"). File is modified — check whether DELETE now collects photo storage keys and calls `deleteObject` from `apps/web/lib/storage.ts` (which had zero callers). May be absent or half-written. Intended: gather keys from recipe photos + step photo columns (check `packages/db/src/schema/recipes.ts` for key vs full-URL columns), `deleteObject` per key in try/catch — storage failure must NOT fail the API response. -2. **Optimistic buttons — all 4 files edited, agent died before reporting. Review each diff:** - - `apps/web/components/social/favorite-button.tsx` — should be: optimistic flip, rollback + error toast (was: await-then-flip, silent fail). - - `apps/web/components/collections/collection-star-button.tsx` — same pattern. - - `apps/web/components/social/follow-button.tsx` — optimistic flip, keep existing error toast. - - `apps/web/components/meal-plan/shopping-list-view.tsx` — toggleItem: keep optimistic update, ADD rollback on `!res.ok`/throw with functional setState (rollback must not clobber other items' newer toggles). - - If toasts reference new i18n keys: neither `apps/web/messages/en.json` nor `fr.json` was modified — add keys or the UI breaks. -3. **`apps/web/components/shared/skeletons.tsx` — created, but ZERO loading.tsx/error.tsx/not-found.tsx files exist yet.** Agent died after the shared building blocks. Verify the file, then do item 5 below. - ---- - -## ❌ NOT STARTED — Wave 1 remainder - -4. **Upload presign size + storage quota** (`apps/web/app/api/v1/upload/presign/route.ts`, `apps/web/lib/storage.ts`): validates content-type only, no size limit; `"storage"` LimitKey in `lib/tiers.ts` is dead code. Fix: require `fileSize` (bytes) in Zod body, reject >10MB, check+increment storage tier usage (MB) before presigning. Also grep client components for the presign fetch and add `fileSize` there. -5. **Route-level UI states** (reuse `components/shared/skeletons.tsx`): - - `app/(app)/error.tsx` ("use client", reset() button), `app/(app)/not-found.tsx`, `app/(app)/loading.tsx` - - Per-route loading.tsx mirroring real layouts: recipes, feed, collections, meal-plan, recipes/[id], u/[username], search, explore, shopping-lists, pantry - - `app/admin/error.tsx` + `app/admin/loading.tsx` - - Root `app/error.tsx` + `app/not-found.tsx` (dependency-light; may render outside providers) - -## ❌ NOT STARTED — Wave 2 (planned batches, disjoint file sets) - -6. **Auth hardening:** - - No `middleware.ts` exists (CLAUDE.md claims otherwise). Add root middleware guarding `(app)` + `admin` (Better Auth session cookie). Unguarded pages today: `/recipes/new`, `/explore`, `/search`, `/settings/webhooks/docs`. - - `lib/api-auth.ts:76` — rate limit only on API-key branch of `requireSessionOrApiKey`; cookie-session branch (~103) skips it. Apply to both. - - `lib/tiers.ts:41` — `if (!tierDef) return;` = unknown/custom tier gets NO limits. Deny instead (or fall back to free limits). - - Admin role staleness: `lib/api-auth.ts:19` trusts `session.user.role` (5-min cookieCache, `auth/server.ts:78-81`). Make `requireAdmin` query DB role. - - `app/api/v1/meal-plans/[weekStart]/members/route.ts:106-118` — self-leave unreachable: plan lookup scoped to owner, members get 404, `isSelf` branch dead. Lookup must also match membership. - - `app/api/v1/conversations/[id]/messages/route.ts:26-31` — `asc + limit(200)`: conversations >200 messages lose all NEWER messages. Paginate (desc + cursor, reverse client-side); update `components/social/message-thread.tsx`. Also add `aria-label` to its icon-only send button (~112). - - `lib/rate-limit.ts:23` — off-by-one: allows limit+1 requests. - - `app/api/webhooks/stripe/route.ts` — no event-ID dedup (replay within tolerance re-runs tier update). -7. **Pagination:** - - `app/(app)/recipes/page.tsx:47` — findMany with NO limit, loads every recipe. - - `app/(app)/feed/page.tsx:46` — cap 40, no pagination; trending/for-you single batch. Also `components/feed/feed-page-content.tsx:86,112` — `.catch(() => {})` renders network failure as empty state; add error branch + retry. - - `app/(app)/u/[username]/page.tsx:55` — 24 recipes then dead end. - - `app/api/v1/recipes/[id]/comments/route.ts:28-43` — unbounded; paginate + update `components/social/comments-section.tsx` (also no error branch ~218; comment delete ~134 has no confirm). - - `app/api/v1/pantry/route.ts:17-22` — unbounded. - - `app/admin/users/page.tsx:28,36` — limit(100) no next-page; table lacks `overflow-x-auto`. Same for other admin tables. - - Reuse `total/limit/offset` pattern from `components/search/search-page-content.tsx:29-34`. -8. **Nav/theme/confirms:** - - `components/layout/nav.tsx:110` — `` hardcoded; render session user image. Add "View profile" link to dropdown. - - Dark mode wired (`components/providers.tsx:20`, `.dark` palette) but NO toggle. Add Sun/Moon in nav dropdown (`next-themes` setTheme). - - Confirm consistency: comment delete (`comments-section.tsx:134`) + pantry delete (`pantry-manager.tsx:55`) fire instantly; `recipes-grid.tsx:296`, `block-button.tsx:21`, `invites-manager.tsx:49` use `window.confirm`. Standardize on AlertDialog (pattern: `delete-recipe-button.tsx:62`). -9. **Forms/a11y/images/URLs:** - - `components/recipe/recipe-form.tsx` — no unsaved-changes guard; only title validated, empty ingredients/steps silently dropped (~:132,149,158). - - Canonical URL split: cards → `/recipes/[id]` (`recipe-card.tsx:42`, `recipes-grid.tsx:218`) vs `/r/[id]` (`feed-page-content.tsx:60`, `u/[username]/page.tsx:151`, `search-page-content.tsx:50`). Use `/recipes/[id]` in-app, keep `/r/[id]` for public share. - - 4+ recipe-card implementations (recipe-card.tsx; GridCard/ListRow/CompactRow in recipes-grid.tsx; inline in feed-page-content.tsx:33 + search-page-content.tsx:42; tiles in u/[username]/page.tsx:149) — consolidate where cheap. - - a11y: `aria-label` on 28 `size="icon"` buttons; meaningful `alt` on recipe images (`recipes/[id]/page.tsx:378`, `can-cook-content.tsx:39`, `expiring-soon-suggestions.tsx:39`, `cooked-it-review.tsx:149,209`); `alt` on AvatarImage everywhere; color-only signalling on difficulty badges + pantry expiry (`pantry-manager.tsx:96`) — add icon/text. - - Zero `next/image` (12 raw ``) — migrate grids/heroes, width/height set, remotePatterns for MinIO/S3 host in next.config. -10. **api-types/OpenAPI:** - - `packages/api-types` 100% orphaned — zero imports; every route inlines Zod. Wire recipes routes to it (align drift first) or delete the package + update CLAUDE.md. - - `apps/web/lib/openapi.ts` — ~20 of 90 routes documented; recipes list documented `page/limit` but real is `limit/offset` returning `{data, limit, offset}` (`recipes/route.ts:48-64`). - - Error shape: `ai-keys/route.ts:35` returns `{error: }` vs `{error: string}` elsewhere. -11. **Misc low:** - - `app/api/v1/search/route.ts:72` — escape `%`/`_` in ilike; `feed/trending/route.ts:6` — parseInt NaN reaches `.limit()`. - - `app/api/v1/shopping-lists/[id]/items/[itemId]/route.ts:17` — body cast without Zod. - - `app/api/v1/meal-plans/[weekStart]/entries/route.ts:48` — `recipeId` unvalidated → FK violation 500 (sibling shared route validates; copy it). - - ~~Recipe usage counter monthly + never decremented on delete~~ — **decided**: keep as-is, `maxRecipes` means "creations per month," deleting a recipe does not free up quota. - - `lib/ai/resolve-user-key.ts:18-23,44-62` — BYOK decrypt failures silently fall back to platform key; surface error. - - API keys: no expiry/scopes; all of a user's keys share one rate bucket (`api-auth.ts:79`). - -## Final phase -- Clean dev-DB dupes (see migration warning) → `pnpm db:migrate`. -- `pnpm typecheck && pnpm lint && pnpm build`, fix fallout. -- Smoke: favorite/star/follow, shopping toggle rollback (network off in devtools), AI 403 at quota, webhook to private IP rejected, recipe delete removes MinIO objects. - -## New features backlog (NOT fixes — not yet greenlit) -"S" items mostly wire existing orphaned infra: -1. Push+email for ALL notification types (S) — `createNotification` writes rows; only comments route calls `sendPushNotification` (`lib/push.ts`). -2. Personal recipe notes UI (S) — `recipeNotes` table exists (`recipes.ts:94`), zero API/UI. -3. Cooking history page (S/M) — `cookingHistory` written by `/cooked`, never read. Profile tab: counts, last-cooked, streaks. -4. Notifications inbox page (S) — API + bell exist. -5. Pantry-aware shopping lists (S). -6. Unit conversion from `users.unitPref` (M). -7. Nutrition daily diary (M). -8. Weekly digest email (M, needs cron). -9. Recipe fork/clone via `recipeVariations` (S). -10. GDPR data export (S/M). 11. Pantry barcode/photo scan (M). -12. "Cooked it" photo gallery (M). -13. Meal-plan generation targeting nutrition goals (M). - -## Gotchas (from project memory) -- Import drizzle operators (`eq, and, or, desc, sql, count`…) from `@epicure/db`, NEVER `drizzle-orm` directly in apps/web (dual-instance bug). -- shadcn Button: NO `asChild` prop — use `buttonVariants()` + ``. -- `session.user.tier` is `string` — cast `as "free" | "pro"` at call sites. -- `requireSessionOrApiKey` takes `NextRequest`; `requireSession` doesn't. -- Better Auth `changeEmail`/`changePassword`: call on `authClient` object, don't destructure. -- New user-facing strings go in BOTH `apps/web/messages/en.json` and `fr.json`. diff --git a/SECURITY_AUDIT.md b/SECURITY_AUDIT.md deleted file mode 100644 index 68370c4..0000000 --- a/SECURITY_AUDIT.md +++ /dev/null @@ -1,97 +0,0 @@ -# Security Audit — Epicure - -Date: 2026-07-12 -Scope: full app (`apps/web`, `packages/db`), 3 independent rounds, each own lens. -Bar: confidence ≥8/10 only. - ---- - -## Round 1 — Auth & Authorization - -### Finding 1: Stale cached role trusted for comment moderation — MEDIUM -- File: `apps/web/app/api/v1/comments/[id]/route.ts:33` -- Confidence: 8 -- Description: Route checks `session.user.role === "user"` directly. Role comes from Better Auth `cookieCache`, stale up to 5 min (`apps/web/lib/auth/server.ts:79-82`). `requireAdmin()` (`apps/web/lib/api-auth.ts:17-30`) already works around this by re-querying DB — this route doesn't. -- Exploit: Admin demotes abusive moderator/admin to `user`. Demoted user's existing session still passes stale-role check for up to 5 min → can keep deleting other users' comments via `DELETE /api/v1/comments/[id]`. -- Fix: Reuse fresh-DB-role pattern from `requireAdmin()` (or shared `requireRole()` helper) instead of trusting `session.user.role` directly. - -### Finding 2: Tier entitlement not re-verified against DB — LOW/MEDIUM -- Files: `apps/web/lib/auth/server.ts:79-82` (cookieCache) + all quota call sites (`api/v1/ai/generate/route.ts`, `ai/adapt/[id]/route.ts`, `ai/meal-plan/generate/route.ts`, `ai/batch-cook/generate/route.ts`, `apps/web/lib/tiers.ts:31`) -- Confidence: 8 -- Description: Same cookieCache staleness as Finding 1 applies to `session.user.tier`, but `checkAndIncrementTierLimit` never re-checks DB tier. -- Exploit: User downgraded pro→free (cancellation/chargeback). Stale session keeps pro-tier quota (AI calls, recipe count, storage) applying for up to 5 min. Bounded quota-bypass, not data breach. -- Fix: Fetch `users.tier` fresh in `checkAndIncrementTierLimit`, mirroring `requireAdmin`. - -**No other findings ≥8** — recipes/comments/ratings/meal-plans/shopping-lists/collections/admin routes/api-keys/webhooks/Better Auth config/proxy.ts/public share pages all reviewed clean. - ---- - -## Round 2 — Injection & Input Validation - -### Finding 1: SSRF via unfollowed redirect in AI URL-import — HIGH -- File: `apps/web/lib/ai/features/import-url.ts:22-28` -- Confidence: 9 -- Description: `POST /api/v1/ai/import-url` validates host via `validateWebhookUrl()` (blocks private/loopback/metadata ranges) but then `fetch(url, {...})` uses default `redirect: "follow"` — no restriction on where a 3xx response redirects to. `apps/web/lib/webhooks.ts:39-49` already hardens this with `redirect: "manual"` + comment explaining why; `import-url.ts` never got the same fix. -- Exploit: Attacker hosts public page returning `302 Location: http://169.254.169.254/latest/meta-data/...` (or internal service). Initial host passes validation; fetch follows redirect straight to internal/metadata address, response fed into AI prompt. -- Fix: Set `redirect: "manual"` in `import-url.ts`, treat 3xx as failure (mirror `webhooks.ts`). - -### Finding 2: DNS-rebinding TOCTOU in `validateWebhookUrl` — MEDIUM -- Files: `apps/web/lib/validate-webhook-url.ts:96-123`, consumed by `import-url.ts:22-25` -- Confidence: 8 -- Description: Validation does its own `dns.lookup`; the real `fetch()` re-resolves independently moments later. Attacker controlling authoritative DNS (low TTL) can return public IP for validation, private IP for fetch. `webhooks.ts` has a comment accepting this as residual risk; `import-url.ts` has no such mitigation and also lacks redirect protection (compounds with Finding 1). -- Fix: Resolve host once, validate resolved IP, connect directly to that IP (pin via custom `dns.lookup`/agent) instead of re-resolving in `fetch`. Apply to both `webhooks.ts` and `import-url.ts`. - -### Finding 3: IDOR into S3 `deleteObject` via unvalidated photo storage keys — HIGH -- Files: `apps/web/app/api/v1/recipes/route.ts:48-51`, `apps/web/app/api/v1/recipes/[id]/route.ts:45-48,211-231`, sink `apps/web/lib/storage.ts:40-42` -- Confidence: 8 -- Description: `photos[].key` validated only as non-empty string — no check that key was actually issued to this user/recipe by `/api/v1/upload/presign` (no prefix check against `recipes/{recipeId}/photos/` + `session.user.id`). Storage keys of public recipes are visible in rendered `` src on `apps/web/app/r/[id]/page.tsx:52-58`. On `PUT`, any photo key present in old list but absent from new list gets passed to `deleteObject()` with no ownership check. -- Exploit: Attacker views victim's public recipe, copies photo storage key from image URL. Calls `PUT /api/v1/recipes/{ownRecipeId}` with `photos: [{key: }]` (accepted, no ownership check), then `PUT` again without that key → diff logic treats it as "removed" → `deleteObject` permanently deletes victim's S3 object. No privilege required. -- Fix: Track storage keys server-side as belonging to a specific recipe/user (row created at presign time, or HMAC-signed key). Validate every submitted photo key was issued for this recipe/user before accepting into `recipePhotos` or before deletion eligibility. At minimum prefix-match against `recipes/{recipeId}/` + `session.user.id`, and never `deleteObject` a key not already present in that recipe's existing photo set. - -**No other findings ≥8** — all `sql\`...\`` usages parameterized correctly, no eval/exec usage, no raw filesystem path construction from user filenames elsewhere, Zod schemas otherwise bound ranges/enums/lengths properly. - ---- - -## Round 3 — Crypto, Secrets Management & Data Exposure - -**No findings ≥8.** Reviewed: `lib/auth/server.ts`, `lib/encrypt.ts`, `lib/gravatar.ts`, `lib/invites.ts`, `lib/api-auth.ts`, shopping-list public-share feature (`app/s/[id]/page.tsx`, `api/v1/shopping-lists/**`), profile-picture upload, `users/me/export`, api-keys/ai-keys, webhooks, Stripe/cron signature verification, broad sweep of routes joining/returning `users` rows. - -Notable good patterns confirmed: -- Share-link tokens use `crypto.randomUUID()` (122 bits), scoped queries never leak owner PII. -- User-row queries always project to narrow DTO before serializing (password/email never leak). -- All security-sensitive tokens use `crypto.randomBytes`/`randomUUID`; `Math.random()` only in non-security contexts. -- `lib/encrypt.ts`: AES-256-GCM + HKDF-SHA256, random IV per encryption, auth-tag verified. -- Webhook/cron secret checks use `crypto.timingSafeEqual` with length pre-check. -- No TLS-verification bypasses, no hardcoded secrets. -- `requireAdmin()` re-queries DB role rather than trusting session cache (see Round 1 Finding 1 — inconsistently applied elsewhere). - ---- - -## Summary - -| # | Severity | Category | Location | Confidence | -|---|----------|----------|----------|------------| -| R2-F1 | HIGH | SSRF (redirect) | `lib/ai/features/import-url.ts:22-28` | 9 | -| R2-F3 | HIGH | IDOR / arbitrary delete | `api/v1/recipes/[id]/route.ts:211-231` | 8 | -| R1-F1 | MEDIUM | Stale-role authz bypass | `api/v1/comments/[id]/route.ts:33` | 8 | -| R2-F2 | MEDIUM | SSRF (DNS rebinding) | `lib/validate-webhook-url.ts:96-123` | 8 | -| R1-F2 | LOW/MEDIUM | Stale-tier quota bypass | `lib/tiers.ts:31` + call sites | 8 | - -**Priority fix order**: R2-F1 (SSRF redirect) and R2-F3 (IDOR delete) first — both HIGH, both concrete exploit paths with no special privilege needed. Then R1-F1 (moderation bypass), R2-F2 (rebinding, compounds with F1), R1-F2 (quota bypass, low impact). - -**Status: all 5 fixed** (`safeFetch` w/ IP-pinning + manual-redirect in `lib/validate-webhook-url.ts`; `isOwnedRecipePhotoKey` in `lib/storage.ts` enforced on recipe create/update; fresh-DB role check in `comments/[id]/route.ts`; fresh-DB tier check in `lib/tiers.ts`). - ---- - -## Round 4 — Verification + fresh sweep - -Re-verified all 5 fixes above: all PASS, no gaps, no regressions (full `pnpm typecheck` + `pnpm test` clean, same 2 pre-existing unrelated test failures as baseline `main`). - -### Finding: IDOR on rating/review `photoKey` — same class as R2-F3, different endpoint — HIGH (fixed) -- File: `apps/web/app/api/v1/recipes/[id]/rate/route.ts:10,41,54` -- Confidence: 9 -- Description: `POST /api/v1/recipes/[id]/rate` accepted a client-supplied `photoKey` with no ownership check — the R2-F3 fix only covered `recipePhotos` (folder `photos/`), not review photos (folder `reviews/`), which use a different prefix format and weren't validated at all. -- Exploit: Attacker reads a victim's review-photo storage key off any public recipe/review response, submits it as `photoKey` when rating an unrelated recipe. When that recipe's author later deletes their recipe, `recipes/[id]/route.ts` DELETE collects all `ratings.photoKey` for the recipe and calls `deleteObject()` unconditionally — destroying the victim's photo in a completely different recipe. -- Fix applied: added `isOwnedReviewPhotoKey(key, recipeId, userId)` to `lib/storage.ts` (checks `recipes/{recipeId}/reviews/{userId}-` prefix), enforced in `rate/route.ts` before insert/update — rejects with 400 if the submitted key wasn't issued to this user for this recipe's reviews. - -No other new HIGH/MEDIUM findings survived this round. Checked clean: admin role checks, internal cron auth, public shopping-list share endpoints, all raw `sql` usage, other `deleteObject`/presign call sites. diff --git a/TODO.md b/TODO.md deleted file mode 100644 index 438de7f..0000000 --- a/TODO.md +++ /dev/null @@ -1,37 +0,0 @@ -# Known issues / backlog - -Findings from a codebase health scan (security, data integrity, tests, perf, cleanup). All items below are resolved as of this pass. - -## Resolved - -1. ~~**IDOR** — `collections/[id]/route.ts`~~ — investigated: the `PUT` handler's top-level ownership check (`existing = findFirst(id + userId)`) already gates the entire handler, including `removeRecipeId`/`addRecipeId`. Not actually vulnerable. No change made. -2. ~~**Missing transaction** — meal-plan generation~~ — wrapped the per-entry insert sequence in `db.transaction(...)`. -3. ~~**Missing indexes**~~ — added `userId` indexes to `collections`, `collectionMembers`, `cookingHistory`, `ratings`, `favorites`. -4. ~~**Zero test coverage** on `api/v1/admin/*` and `api/v1/webhooks/*`~~ — added Vitest coverage for all 7 route files (role checks, SSRF validation path, redelivery logic). -5. ~~SSRF gap — malformed IPv6~~ — replaced string-prefix heuristics with a proper IPv6 parser (handles `::` compression, IPv4-mapped addresses, fails closed on malformed input). Also found and fixed an identical duplicated bug in `lib/ai/features/import-url.ts`; consolidated both call sites onto the one fixed implementation. -6. ~~Race condition — `checkAndIncrementTierLimit`~~ — investigated: already atomic (single `INSERT ... ON CONFLICT DO UPDATE ... RETURNING`). The old racy `checkTierLimit` was dead code (zero callers) — deleted. -7. ~~N+1 / no pagination — collections list~~ — added `limit`/`offset` pagination, matching the `search` route's pattern. -8. ~~Dietary-tag search — missing index~~ — added a GIN index on `recipes.dietaryTags`; also switched the search filter from `->>` text extraction to `@>` containment so the index is actually used. -9. ~~Duplicated Zod schemas across AI features~~ — extracted shared `dietaryTagsSchema`/`ingredientSchema`/`stepSchema` into `lib/ai/features/recipe-schema.ts`. -10. ~~Stripe webhook stubbed~~ — implemented tier upgrade/downgrade; added `users.stripeCustomerId` to map `customer.subscription.deleted` events back to a user. -11. ~~No rate limit on `ai-keys`~~ — added `applyRateLimit` to the POST handler. -12. ~~`pnpm typecheck` documented but missing~~ — added the script to all three workspace packages; also fixed the pre-existing type errors it exposed (`packages/db` missing `@types/node`, two stale test mocks). -13. ~~Hardcoded `localhost:3001` fallback~~ — now throws a 500 with a clear message if `BETTER_AUTH_URL` is unset, instead of silently generating a broken link. - -# Feature ideas - -Brainstormed extensions building on existing infra (pantry match, meal planning, cooking mode w/ voice, tiers, AI generation, social/collections, print, version history). - -## Done - -1. **Expiry-aware pantry** — done. Pantry schema/UI already had `expiresAt` fully wired (date input, sort, expiry badges); added the missing piece — the canCook page now surfaces a "Use it up" badge on recipes that use soon-expiring pantry items and sorts them to the top. -2. **Shared meal plans/shopping lists** — done. Added `shoppingListMembers`/`mealPlanMembers` tables (viewer/editor roles, mirrors `collectionMembers`), share dialogs, and membership-checked API routes. Meal plans keep their owner-side weekly routes; shared access goes through new `/api/v1/meal-plans/shared/[mealPlanId]` routes since plans are addressed by `(userId, weekStart)`. -3. **PDF cookbook export** — done. `/print/collection/[id]` renders every recipe in a collection with `page-break-after` between them, reusing the existing print-page CSS; browser print-to-PDF produces the file, matching the existing single-recipe/meal-plan print pattern (no new PDF-rendering dependency). -4. **Recipe diff/compare view** — done. Added the `diff` package + `VersionDiffView`; version-history-button now has a "Compare with current" action next to Restore. -5. **Grocery delivery handoff** — done as a stub, per confirmed scope: shopping lists get a "Send to grocery delivery" button that always offers copy-as-text, plus a documented (not live) Instacart adapter gated behind `INSTACART_API_KEY`/`NEXT_PUBLIC_GROCERY_PROVIDER` — real activation needs an actual partner agreement. -6. **Personalized "for you" feed** — done. New `/api/v1/feed/for-you` ranks public recipes by tag/dietary-tag overlap with the user's favorited/highly-rated history (falls back to recency when there's no history yet); third feed tab added. -7. **PWA/offline mode** — done. The service worker and offline fallback already existed; added `manifest.json` + icons and wired them into the root layout metadata so the app is installable. Cache-first on `/cook` already makes previously-visited cooking-mode pages available offline. - -## Also fixed this pass - -- **Mobile responsiveness** — Recipes/Collections/Pantry/Meal Plan/Shopping Lists page headers now stack and wrap instead of clipping buttons off-screen on narrow viewports (`flex-col sm:flex-row` + `flex-wrap`). diff --git a/apps/web/components/settings/api-keys-manager.tsx b/apps/web/components/settings/api-keys-manager.tsx index 47d09fe..3f1965b 100644 --- a/apps/web/components/settings/api-keys-manager.tsx +++ b/apps/web/components/settings/api-keys-manager.tsx @@ -35,6 +35,16 @@ function formatDate(iso: string) { }); } +function formatDateTime(iso: string) { + return new Date(iso).toLocaleString(undefined, { + year: "numeric", + month: "short", + day: "numeric", + hour: "numeric", + minute: "2-digit", + }); +} + export function ApiKeysManager({ initialKeys }: { initialKeys: ApiKey[] }) { const t = useTranslations("settingsForm"); const [keys, setKeys] = useState(initialKeys); @@ -173,7 +183,7 @@ export function ApiKeysManager({ initialKeys }: { initialKeys: ApiKey[] }) { {t("createdOn", { date: formatDate(k.createdAt) })} · {k.lastUsedAt ? ( - {t("lastUsedOn", { date: formatDate(k.lastUsedAt) })} + {t("lastUsedOn", { date: formatDateTime(k.lastUsedAt) })} ) : ( {t("neverUsed")} )} diff --git a/apps/web/lib/changelog.ts b/apps/web/lib/changelog.ts index 5d7be45..679dc21 100644 --- a/apps/web/lib/changelog.ts +++ b/apps/web/lib/changelog.ts @@ -1,5 +1,5 @@ // Mirrors CHANGELOG.md at the repo root — update both together. -export const APP_VERSION = "0.25.0"; +export const APP_VERSION = "0.25.1"; export type ChangelogEntry = { version: string; @@ -11,6 +11,13 @@ export type ChangelogEntry = { }; export const CHANGELOG: ChangelogEntry[] = [ + { + version: "0.25.1", + date: "2026-07-14 09:40", + fixed: [ + "API key \"Last used\" only showed a date — now shows date and time.", + ], + }, { version: "0.25.0", date: "2026-07-14 09:36", diff --git a/apps/web/package.json b/apps/web/package.json index a87f0ab..cd10b49 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,6 +1,6 @@ { "name": "@epicure/web", - "version": "0.25.0", + "version": "0.25.1", "private": true, "scripts": { "dev": "next dev", diff --git a/package.json b/package.json index 7a08f89..53d48cf 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "epicure", - "version": "0.25.0", + "version": "0.25.1", "private": true, "scripts": { "dev": "pnpm --filter web dev",