An independent model reviewed the MCP and OAuth surfaces. All five findings
were verified against the code before changing anything; none were false
positives. audit.md is kept as the record of what was reviewed.
F1 (High) - refresh reuse detection forgot the token family. Only the current
hash and one predecessor lived on the grant, and each rotation overwrote the
predecessor. A thief who rotated a stolen token twice made the victim's
original unrecognisable: replaying it returned "unknown token" instead of
revoking the family, and the thief kept working. Refresh tokens are now rows in
OAuthRefreshToken, one per generation, retained for the life of the family and
consumed by a guarded update on usedAt - which also means two concurrent uses
of the same token can no longer both succeed. This corrects a claim I made when
the OAuth server shipped: reuse detection covered one generation, not the family.
F2 (Medium) - issueAccessToken wrote scopes back onto the grant, so redeeming a
stale authorization code redefined standing consent. Token issuance is not
consent; the consent endpoint is now the only writer. Codes are additionally
bound to a grant id and epoch, with a coversScopes check behind that.
F3 (Medium) - revocation was reversible. Reconnecting a disconnected app cleared
revokedAt and left credentials that had raced the revoke usable again. Every
approval now starts a clean epoch: the counter advances and prior access tokens,
refresh tokens and unconsumed codes are destroyed. Token writes are conditional
on the epoch they validated, so a revoke that wins a race aborts them. Refresh
also now requires offline_access to still be granted.
F4 (Medium) - loopback redirect matching compared only scheme, host and path,
silently accepting a differing query, fragment or userinfo. RFC 9700 2.1 wants
exact matching apart from the RFC 8252 port exception; that is what it does now.
F5 (Low) - get_collection_stats returned bar and recipe counts under
drinks:read. Gated on the caller actually holding bar:read.
Verified with regression tests for each: the two-rotation attack now revokes the
family, concurrent refresh yields exactly one winner, a pre-narrowing code is
refused, disconnect-reconnect leaves old credentials dead, and Claude Code's
ephemeral-port callback still works while query/userinfo/fragment variants are
rejected. Existing protections re-checked - code replay, PKCE mismatch, deny,
confidential-client rejection, and the MCP tools themselves.
Note for deploy: OAuthAuthCode gains a required grantId, so existing rows must
be cleared first. They are 60-second ephemeral codes.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W1Ee4Mc1X1SX8HgYa52zu7
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
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>
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>
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>
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>
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>
- 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>
- 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>
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>