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>
request.url carries the app's internal bind address (0.0.0.0:3000)
because it runs behind a reverse proxy, so every invite link - valid or
not - redirected to an unreachable https://0.0.0.0:3000/join.
Uses NEXTAUTH_URL, which is the configured public origin and cannot be
influenced by a request header, falling back to forwarded headers.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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>
Both providers were registered but never usable: all four client
env vars are empty in production and no Account row has ever been
created. Email and password is the only path that has ever worked.
This also simplifies the invite gating that follows. With OAuth there
were two redemption paths, an invite token that had to survive the
provider round trip in a SameSite=Lax cookie, and an
OAuthAccountNotLinked dead end for anyone who signed up with a password
and later clicked a provider button. Now there is one path.
Account, Session and VerificationToken stay in the schema. They are
Auth.js's tables and dropping them would be a destructive migration for
no benefit.
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>
The SDK returns a Node IncomingMessage, not a web ReadableStream. undici
accepts it (verified against the live bucket, 21882 bytes), but the cast
claimed a type it never had. transformToByteArray is the documented API;
buffering is fine with uploads capped at 10MB.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The /minio-images rewrite proxied straight onto the MinIO bucket with no
authentication, and the bucket carries an anonymous read policy. Verified
from the public internet: GET /minio-images?list-type=2 returned a full
ListBucketResult naming all 33 objects, and any key then fetched 200. Every
menu scan, drink photo and bar photo was enumerable and downloadable by
anyone. Replaced with a route handler that requires a session and streams
via the existing getImage(). The URL shape is unchanged, so stored
imageUrls, uploadImage/getImageUrl and every <img> keep working.
Nothing uses next/image and it must stay that way here: the optimizer
fetches server-side without the session cookie.
Route protection was an allowlist that had to be updated by hand for each
new page, and had already been missed for /bar, /bartender and /recommend,
which rendered to logged-out visitors. /recipes was in the middleware
matcher but not the authorized() list, so it fell through too. Inverted
both to a denylist so new pages are private by default. /api stays out of
the matcher because authorized() answers with an HTML redirect and API
clients need the JSON 401 those routes already return.
Also fixes a cross-user write: POST /api/recipes took sourceDrinkId from
the client with no ownership check, and the drink detail page loaded its
recipes relation unfiltered, so one user could attach an arbitrary recipe
to another user's drink where it rendered permanently and the owner could
not delete it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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>
Bar items added by barcode stored the Open Food Facts image URL
directly. The CSP in next.config.mjs restricts img-src to our own
origin, so the browser blocked those and showed a broken image - the
picture was fine, we just could not display it.
Copy externally-hosted images into MinIO at lookup time and hand back a
/minio-images path instead. That fixes the class rather than the
instance: no CSP entry is needed per image source, the picture survives
the source deleting or reorganising it, and the user's browser never
has to talk to a third party to render their own bar.
Also fixes a latent bug this uncovered: imageUrl was validated with
z.string().url(), which rejects the relative /minio-images/... paths
that uploadImage returns, so saving an uploaded drink image would fail
validation. That matches production having zero drinks with an image.
Both schemas now accept either form.
CSP keeps two third-party entries for OAuth avatars, which the provider
hosts and we only ever receive as a URL at sign-in.
Existing rows were backfilled separately.
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>