Commit Graph

8 Commits

Author SHA1 Message Date
JP
e77f605c9c Add OAuth 2.1 server so claude.ai and ChatGPT can connect natively
Phase A needed a token pasted into a header, which Anthropic documents as an
org-admin-scoped beta on claude.ai and is undocumented on ChatGPT. This makes
the app its own authorization server so both connect through their normal
"add a connector" flow, with a per-user consent screen.

The whole thing funnels into the existing verifyMcpToken: an issued access
token is an ordinary McpAccessToken row with source "oauth", so the resource
server gained no OAuth awareness and Phase A's verification path is unchanged.

Notes on the parts that fail quietly if got wrong:

- scopes_supported is published in the protected resource metadata. When a 401
  challenge carries no explicit scope Claude requests exactly what is
  advertised there, so omitting it silently makes every connection read-only.
- Loopback redirect URIs match with the port ignored. Claude Code registers
  http://localhost/callback and then redirects to an ephemeral port; exact
  matching would reject every native client. RFC 8252 7.3 requires this.
- CIMD is deliberately not advertised. Claude only selects it when the metadata
  carries client_id_metadata_document_supported, and supporting it would mean
  fetching a client-supplied URL server-side from a host that also reaches
  MinIO, Postgres and the gateway on the LAN. DCR costs nothing by comparison.
- The consent page validates client_id and redirect_uri before it will redirect
  anywhere, because an unvalidated redirect_uri is an open redirect.
- Authorization codes are consumed by one guarded updateMany, so a replay under
  concurrency cannot mint a second token. A PKCE mismatch burns the whole grant
  rather than just the code.
- Refresh tokens rotate and keep the previous hash; presenting it revokes the
  grant, since that is either theft or a client that cannot be trusted to hold
  state.

Also fixes the login page, which hardcoded callbackUrl and so dropped anyone
sent to sign in for the consent screen onto /dashboard instead. Same-origin
destinations only, absolute or relative - the middleware writes absolute.

"/.well-known" joins PUBLIC_ROUTES. It is a prefix match, so nothing else
should be served from there.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W1Ee4Mc1X1SX8HgYa52zu7
2026-08-09 05:33:48 +00:00
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
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
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
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
8a582bfa7f Security hardening for production readiness
- Add security headers (CSP, HSTS, X-Frame-Options, X-Content-Type-Options, etc.)
- Strengthen password requirements (10+ chars, mixed case, numbers)
- Increase shared list slug entropy from 4 to 16 bytes
- Add rate limiting to login, registration, upload, and restore endpoints
- Add file magic number validation for image uploads (JPEG, PNG, WebP, HEIC)
- Add CSV row limit (50k) to restore endpoint
- Update client-side registration form to match new password policy

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 12:55:16 -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