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 bucket carried an anonymous download policy, which is what made the
old unauthenticated proxy work. Now that images are served through a
session-gated route using credentials, anonymous access is unnecessary
and was the second half of the public exposure.
Applied on the running host; both compose files updated so bringing the
stack up elsewhere does not re-apply 'download'.
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>
sudoers grants drinkadmin NOPASSWD only as root, so every 'sudo -u
drinktracker' in the deploy prompted for a password. Restructured to
pipe one script per phase to 'sudo -n bash -s' and use su inside, which
needs no privilege change and cuts the SSH round-trips.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Docker in this unprivileged Proxmox LXC was broken two independent ways:
AppArmor could not load the docker-default profile, so the daemon could
not start any new container, and runc could not set the
net.ipv4.ip_unprivileged_port_start sysctl, which is why every service
needed network_mode: host. `docker build` was impossible outright since
Docker 29 removed the classic builder. The stack only survived because
the containers predated the breakage - a reboot would have left the app
down, and nothing could be redeployed.
Postgres, MinIO and the app now run natively under systemd. Deploys
build out-of-place into releases/<sha> and swap a symlink, so the build
happens while the old release keeps serving and downtime is the ~3s
restart rather than the ~3min build. Rollback is the same swap in
reverse with no rebuild.
Dockerfile and docker-compose.prod.yml are unchanged and still work on
a normal host; the compose app service gains `build: .` so it can come
up on a VPS that cannot reach the LAN-only Gitea registry.
Documents three traps found during the migration: rewrites() in
next.config.mjs is evaluated at build time so MINIO_ENDPOINT changes
silently do nothing, prisma migrate deploy would fail because
production has no _prisma_migrations table, and the image proxy relies
on the bucket's anonymous-download policy.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The remote shell runs as drinkadmin, which cannot enter /root even
though sudo can operate there, so 'cd $REMOTE_DIR && sudo docker ...'
failed at the cd. Pass the build context and compose file as paths
instead.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
.claude/ was ignored wholesale, which meant the deploy skill lived only
on one machine. Narrow the rule so .claude/skills/ is tracked while
settings.local.json stays local.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There is no CI, so a git push deploys nothing - the image has to be built
and the stack restarted separately. This captures that as one command
rather than a sequence to remember.
The image is built on the LXC and tagged with the registry name the
compose file already references, so compose finds it locally and never
pulls. That means no registry credentials are needed on either machine.
Also records the two things that cost the most time to work out: the
public hostname resolves to the reverse proxy rather than the container
(192.168.2.169), and the live stack runs from /root/drinktracker while
the checkouts under /home/drinkadmin are stale.
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>
network_mode: host avoids Docker creating separate network namespaces
which trigger sysctl writes blocked in LXC containers. All service
references updated from container names to localhost.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Push app image to jpscott84/drinktracker on Docker Hub
- docker-compose.prod.yml uses image instead of build
- install.sh pulls image instead of building from source
- Much faster deploys (no npm ci/build on target server)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Docker Compose reads ${VAR} interpolation from .env by default,
not from the env_file directive (which only sets container vars).
Added --env-file .env.production to all docker compose commands
so POSTGRES_USER, POSTGRES_PASSWORD, etc. are available for
compose file interpolation.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Automatically installs Docker via get.docker.com if not found
- Installs Docker Compose plugin if missing
- Installs OpenSSL and curl if missing
- Detects package manager (apt, dnf, yum, apk)
- Handles docker group permissions for current user
- Falls back to sudo for docker commands when needed
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- install.sh: Interactive setup script for Linux VPS/LXC deployment
- Checks prerequisites (Docker, Docker Compose, OpenSSL)
- Auto-generates all secrets (Postgres, MinIO, NextAuth, encryption)
- Creates .env.production with proper Docker service hostnames
- Builds and starts all services via docker-compose.prod.yml
- Health check loop with status reporting
- Idempotent (safe to re-run)
- docker-compose.prod.yml: Add migrate service
- One-shot container that runs prisma db push before app starts
- App depends on migrate completing successfully
- Override DATABASE_URL and MINIO_ENDPOINT for Docker networking
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>