Commit Graph

11 Commits

Author SHA1 Message Date
JP
a8c959bfab Add a saved recipe to the collection or the wishlist so it can be rated
A recipe was previously a dead end: you could save one and see whether your bar
could make it, but there was no way to record that you had actually drunk it.

Promoting links rather than converts. A drink is created and Recipe.sourceDrinkId
is set to point at it, so the recipe survives and then renders on that drink's
page through the relation the drink detail view already reads. The wishlist path
creates a "try later" entry tagged source=recipe and leaves the recipe alone.

Both are idempotent. Promoting an already-linked recipe returns the existing
drink with a 200 rather than creating a second one, because pressing the button
twice is a double click and not a request for a duplicate. Creation and linking
happen in one transaction - a drink that existed but was not linked back would
show no recipe and would be promoted again on the next attempt.

The logic lives in src/lib/recipe-promotion.ts and is shared by the REST route
and the MCP tool, so the ownership check, the idempotency rule and the generated
description cannot drift apart.

Adding to drinks navigates straight to the rating page: being able to rate it is
the entire point, and an unrated cocktail is easy to lose in a list of 88. There
is no Toaster mounted in this app, so the wishlist path reports inline instead.

One MCP tool with a target parameter rather than two, since the tool list is
already at the point where selection quality suffers - 23 now. Gated on
drinks:write, not bar:write: it changes the collection, not the bar.

Verified: promote to drink then rate it, idempotency on both targets, the
recipe stays linked and undeleted, invalid target, unknown recipe, unauthenticated
access, read-only tokens, and that another user's recipe is refused identically
on both the REST route and the tool without confirming the row exists.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W1Ee4Mc1X1SX8HgYa52zu7
2026-08-09 21:30:40 +00:00
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
0ead1385f7 Add the owner admin area: invites, people, reset links
Creating an invite previously meant writing SQL by hand, which made the
whole feature unusable in practice. The owner can now create invites
with a use count and expiry, copy the link, show a QR code, and revoke.

QR codes are generated locally by node-qrcode as inline SVG. The CSP
forbids loading from any other origin, so an external generator was not
an option, and img-src 'self' already covers a same-origin SVG.

People lists everyone with what they have added and what their AI use
has cost over 30 days, with suspend, reactivate, per-user AI toggle and
delete. Deleting collects image keys and the minted gateway key id
before the cascade removes the rows, then cleans both up best-effort -
storage or the gateway being unavailable must not leave an account
half-deleted. Guards refuse to suspend or delete yourself or the last
owner.

Password reset links close the gap that came with keeping email and
password sign-in: with no email infrastructure, a member who forgets
their password had no way back in and the owner had no way to help. The
owner generates a single-use 24 hour link and delivers it the same way
as an invite. Issuing one invalidates any earlier unused reset, and the
reset endpoint answers identically for unknown, used and expired tokens
so it cannot be used to probe which exist.

The admin area 404s for members rather than 403ing, so its existence is
not advertised, and every /api/admin handler independently requires the
owner role.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 21:26:33 +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
a6cabc5178 Gate signup behind an invitation and revoke sessions on suspend
Registration was open to anyone who could reach the app. Signup now
requires an invite: /invite/<token> validates the link and parks the
token in an HttpOnly cookie, /join re-checks it and renders the form,
and the register route consumes a use inside the same transaction that
creates the user - so a duplicate email rolls the use back instead of
burning it. The token never reaches the client, so the form cannot forge
or replay one.

The jwt callback now revalidates the user on every call and returns null
when the account is missing or suspended, which clears the session
cookie. Every API route and server component already branches on
session?.user?.id, so this revokes access everywhere without editing any
of them. The try/catch around that lookup is load-bearing: Auth.js
treats a throw in this callback the same as a null return, so an
unguarded transient database error would sign out every user at once.

authorize() rejects non-ACTIVE users too, so a suspended account cannot
sign in again to mint a fresh token.

/register redirects to /join; it is linked from elsewhere and may be
bookmarked.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 20:59:52 +00:00
JP
593f68138c Add a camera option wherever a photo can be attached
The bar add-item form could only pick an existing file, so adding a
bottle meant taking a photo first and then hunting for it. The scan flow
already had a working camera; this reuses that CameraCapture component
rather than adding a second implementation.

The change is in DrinkImageUpload, which the bar form, the drink form
and the drink detail view all share, so all three gain the camera.

Falls back to a file input with capture="environment" when getUserMedia
is unavailable - it needs a secure context, so it is absent when the app
is reached over plain http on the LAN.

Also sets type="button" on CameraCapture's controls. They previously had
no type, which defaults to submit, so capturing a photo inside the bar
item form would have submitted the form.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 19:16:20 +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