Commit Graph

9 Commits

Author SHA1 Message Date
JP
4fd339dabc Add MCP server so Claude and ChatGPT can read and write drink data
Exposes the collection over the Model Context Protocol at /api/mcp, with 22
tools covering drinks, ratings, bar inventory, recipes, wishlist and taste
preferences, plus search/fetch aliases for ChatGPT's deep-research mode.

Authentication is a bearer token, the app's first header-borne credential -
every other route derives identity from the NextAuth cookie, which a machine
client cannot present. /api/mcp sits under the middleware's /api exclusion so
it can answer a JSON 401 with an RFC 9728 WWW-Authenticate challenge instead
of an HTML redirect to /login.

Tokens are stored as a SHA-256 hash rather than plaintext like Invite.token
and PasswordReset.token. Those are single-use and short-lived; this one is
long-lived and grants read/write over a whole collection, and the nightly
pg_dump keeps 14 days of history. Not encrypt(), which is reversible AES and
right only for outbound keys we must replay; not bcrypt, which cannot be
indexed and would turn verification into a table scan per request.

verifyMcpToken joins User.status on every call, mirroring the jwt callback, so
suspending a member kills their MCP access immediately rather than leaving the
token as a documented way to outlive suspension. It fails closed on a database
error, deliberately unlike the jwt callback, which keeps the session because a
throw there would sign out every user at once.

No tool reaches the Switchboard gateway. Claude and ChatGPT are language
models already, so they can reason over a bar inventory without the app paying
to do it a second time, and a remote client looping a vision call is not a
failure mode worth having. Account deletion, restore, gateway keys, admin
routes and shared-list creation are excluded too.

The OAuth models ship now but are unused; the token endpoint will write the
same McpAccessToken rows, so adding it later touches no verification code.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W1Ee4Mc1X1SX8HgYa52zu7
2026-08-09 04:29:54 +00:00
JP
13793d43ca Make MenuItem.userId required
Backfilled from the parent scan (176/176 rows), so the column can now be
NOT NULL rather than relying on every writer remembering to set it. The
constraint was applied on the server alongside this, so the schema and
the database stay in step for the next db push.

Also rewrites two MenuScan rows that stored absolute
http://localhost:9000 URLs from before the app used the /minio-images
proxy. Those resolved against the viewer's own machine, so they have
always been broken images for anyone not running MinIO locally, and are
unreachable now that MinIO is bound to loopback.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 22:03:11 +00:00
JP
70db6314e3 Phase 5 hygiene: image ownership, real deletes, scan resilience
Images were readable by any signed-in user, not just their owner. Keys
are now namespaced per user in both shapes the app writes, the three
bar/ objects that predated that were migrated under the owner's prefix,
and the image route enforces the prefix - answering 404 rather than 403
so it does not confirm someone else's key exists. Barcode-mirrored
images now write under the user prefix too.

deleteImage existed but was never called, so deleting a drink, bar item
or scan removed the row and left the file in storage forever, still
fetchable. All three now clean up through one shared helper, after the
database work and best-effort, so a storage hiccup cannot block a delete
the user asked for or leave the row behind.

Menu scans are processed detached from the request, so every deploy
abandoned anything in flight and left the row PROCESSING forever with a
spinner that never resolved. They are now reaped lazily when a user
lists their scans - no scheduler needed. Concurrent extractions are
capped at two per process: each is a vision call with a four minute
timeout that costs real money, and nothing else bounded them.

MenuItem gains userId. Ownership was only ever transitive via scanId,
which held because nothing queries MenuItem directly, but left any
future direct query an IDOR with nothing to stop it.

Restore was correctly scoped but unbounded, so a crafted file could
create unlimited rows in one transaction against shared Postgres - self
harm with one user, denial of service with several. Capped, and imageUrl
from the CSV now goes through the same validation the API enforces
instead of reaching the column unchecked.

Members can delete their own account and data. Once the app holds other
people's history, including Rating.location, that is the minimum.

Registration rate limiting took the first x-forwarded-for hop, which the
client controls; it now takes the last, which our proxy appends.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 21:57:57 +00:00
JP
34afc497f4 Give members their own budget-capped gateway key
Members share the owner's AI budget, so each now gets a Switchboard key
minted on first use with a daily cap and a per-request ceiling. That
gives real spend limits and per-user attribution: the app's own rate
limiter lives in memory and resets on every deploy, so it could never be
a spend control. The gateway enforces the caps and answers 402
guardrail, which the error mapper already turns into a budget message.

Resolution order is the user's own key, then mint, then borrow the
owner's key for a single request if the gateway is unreachable - a
served request beats a hard failure, and the fallback logs loudly
because no per-user cap applies to it. The owner key is used only for
minting, never for inference.

API key management is now owner-only, enforced on GET, POST and DELETE.
GET matters as much as POST because it returns the masked key and the
gateway URL. Settings became a server component so the role is known
before first render: members never see the card and never issue the
request, rather than having it flash and disappear.

AiCall records one row per request from the gateway's own response
metadata, so member spend is queryable instead of a journald grep. The
write is fire-and-forget - tracking must never fail a working request.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 21:16:14 +00:00
JP
b3a0cc75eb Add roles and invite models
Additive schema only; no behaviour changes yet. Verified with prisma
migrate diff against production: two enums, three new tables, new User
columns and foreign keys, and no destructive statements.

Role lives on the User row rather than an OWNER_EMAIL env check, because
email is nullable and user-editable and so a poor thing to authorize
against. The jwt callback will need a per-request lookup for status
anyway, so reading role in the same query is free.

Invites are bearer tokens in a URL, shared out of band as a link or QR
code, because the app has no email capability. claimInvite consumes a
use with a single UPDATE guarded on usedCount < maxUses: Prisma cannot
compare two columns in a where clause, and one statement means one row
lock, so two people redeeming the last use cannot both succeed.

SearchCache finally gets its user relation. Every other user-owned model
cascades; without it, deleting a user left orphaned rows holding their
raw search queries. Verified zero orphans before adding the constraint.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 20:35:48 +00:00
JP
a0e1619072 Route all AI features through the Switchboard gateway
Replace the direct Anthropic and OpenAI integrations with a single
provider that talks to Switchboard, an OpenAI-compatible gateway that
routes each request to the best available model. The app no longer pins
a model id anywhere: it sends switchboard/auto and lets the gateway
choose, then logs which model answered and what it cost.

Routing levers are set per feature in src/lib/ai/routing.ts. Three of
those choices came from measuring against the live gateway:

- category and prefer_free are set explicitly on every request. An API
  key carries its own routing defaults, and anything left unset inherits
  them - drink prompts were being sent to a free coding model.
- Token budgets are generous because the router may pick a reasoning
  model, and reasoning tokens come out of the same max_tokens budget as
  the answer. At 512 tokens a request returned null content; at 4096 the
  same request returned correct JSON.
- No tier lever on text features. tier "cheap" pinned a slow reasoning
  model (42-180s, two timeouts and one truncated response in five
  trials) and tier "frontier" escalated as far as Opus at $0.02 a call,
  while unconstrained routing answered in about a second. Vision keeps
  "frontier", where the accuracy is worth a few tenths of a cent.

Gateway failures are mapped to actionable messages rather than passed
through: a 401 relayed as 401 would read as an expired session and
bounce the user to login, and a 429 would collide with the app's own
rate limiter.

Also collapses the key lookup that was duplicated across ten call sites
into getUserProvider(), which fixes a latent bug where a bare findFirst
with no ordering let different features pick different providers.

Existing claude/openai key rows are ignored at runtime and offered for
removal in Settings, so no migration is needed before deploying.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 16:41:00 +00:00
JP Scott
dc1ad4d0c0 Add recipes, images, AI photo ID, barcode scanning & ingredient matching
- Fuzzy ingredient matching for bar inventory against recipes
- AI photo identification API for bottles/labels (drink + bar context)
- Barcode scanner with photo toggle for My Bar
- Barcode scan + photo ID buttons on Add Drink form
- Auto-pull product images from Open Food Facts barcode lookup
- Recipes section on drink detail pages with bar availability
- Dedicated Recipes page in sidebar navigation
- Bar item image support (schema, upload, display)
- Drink detail image upload component
- MinIO image proxy through Next.js rewrites (fixes broken image links)
- Improved category mapping (energy drinks → Mixers, not Spirits)
- Re-process saved recipe ingredients against current bar inventory

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 22:26:17 -07:00
JP Scott
2ac2c4b2d4 Add My Bar, Bartender, Recommend features + drink images
- Drink Images: upload/display photos of bottles/cans on drink cards and detail pages
- My Bar: inventory tracker for spirits, liqueurs, mixers, bitters, garnishes, tools
- Bartender: AI-powered cocktail recipe generation, "what can I make" suggestions,
  saved recipes. Cross-references bar inventory for ingredient availability.
- Recommend: AI flavor profile analysis, personalized drink recommendations,
  "find similar" drinks based on highly-rated favorites
- Navigation: desktop sidebar with all 8 routes, mobile bottom nav with
  4 primary items + "More" popup menu
- New Prisma models: BarItem, Recipe, FlavorProfile
- Backup/restore updated to include bar items

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 18:28:02 -07:00
JP Scott
969bc9347a Initial commit: DrinkTracker full-stack app
Next.js 14 drink collection tracker with AI-powered search,
menu scanning, ratings, wishlist, sharing, and CSV backup/restore.

Features:
- Auth (credentials + OAuth ready)
- Drink collection with ratings and reviews
- AI search via Claude/OpenAI with search history
- Menu photo scanning with AI extraction
- Wishlist / Try Later system
- Public sharing via slug URLs
- CSV backup and restore (merge/replace modes)
- Docker Compose for Postgres + MinIO + dev server

Security: docker-compose files use env var interpolation
instead of hardcoded secrets.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 12:42:11 -07:00