One line per decision, with the reason. Project-wide rules are enforced in openspec/config.yaml and CLAUDE.md; this file records why.
- 2026-10-10 — Backend is Spring Boot with Spring Security, not Express. Why: hands-on Spring Security experience securing image upload.
- 2026-10-10 — One origin at discosticker.cafe behind an nginx gateway; every service runs under docker compose. Why: the browser talks only to nginx, so there's no CORS to configure.
- 2026-10-10 — The frontend is a Next.js static export served by nginx. Why: it can later become a Next.js server container behind the same nginx without changing routes.
- 2026-10-10 — Postgres stores users, stickers and audit records.
- 2026-10-10 — Local object storage uses SeaweedFS's S3 endpoint, not MinIO; the app talks only to the S3 API, configured by endpoint, credentials and path-style setting. Why: MinIO's free images were pulled in September 2026, and using only the S3 API makes the move to S3 in production a configuration change.
- 2026-10-10 — The walking skeleton includes a bare Next.js static export served by nginx; page content and API calls come with the login change. Why: nginx routing is proven before OAuth, so a failed Discord redirect can't be a routing problem.
- 2026-10-10 — Toolchain: Maven wrapper, Java 25, Spring Boot 4.1.1, AWS SDK BOM 2.55.14, Next.js 16.4 and Node 24.
- 2026-10-10 — TypeScript is pinned to
~5.9.3until Next.js fully supports a newer major version. - 2026-10-10 — The Next.js export is built into the gateway image with
trailingSlash: trueand no fallback toindex.html. Why: one fewer container, and unknown paths return 404 instead of a 200 HTML page. - 2026-10-10 — A one-shot bucket-init container creates the bucket and must succeed before the api starts. Why: failures are loud, and the storage keys are checked before the app runs.
- 2026-10-10 — Spring uses
forward-headers-strategy=nativebehind nginx; the login change proves it end to end. Why: Tomcat then trusts forwarded headers only from internal proxy addresses. - 2026-10-10 — Production runs on a new Vultr instance, separate from the myriaco.re server, using the same docker compose stack.
- 2026-10-10 — This weekend, production storage is SeaweedFS on that server, reachable only on the internal network; AWS S3 replaces it later as a configuration change plus a data copy.
- 2026-10-10 — Production is a Vultr vc2-2c-4gb instance (2 vCPU, 4 GB, Debian 13) running the same compose stack plus
compose.prod.yaml; images build on the server from a clone of the public repo, with no registry or CI. - 2026-10-10 — Production and local nginx share one routes include;
compose.prod.yamlmounts the production server config from the checkout. Why: routes can't drift between local and production. - 2026-10-10 — Long-running services restart
unless-stopped; bucket-init stays one-shot. Why: the stack comes back after a reboot with no manual steps, without bucket-init restarting in a loop. - 2026-10-10 — nginx serves HTTPS with a Let's Encrypt certificate for
discosticker.cafeandwww.discosticker.cafe, issued and renewed by Debian's certbot through the running gateway (webroot), and registered without an email address. Why: Let's Encrypt no longer sends expiry emails, and renewal is automatic. - 2026-10-10 —
www.discosticker.caferedirects (301) tohttps://discosticker.cafe; the GoDaddywwwCNAME stays. - 2026-10-10 — The renewal hook reloads the gateway and is quiet on success. Why: certbot labels any stderr as an error, and its log is the only place a failed renewal shows up while there's no monitoring.
- 2026-10-11 — The visual theme is plain CSS: the brief's 12 color tokens as custom properties in
globals.css, with no component library. Why: a library like MUI injects styles at runtime, which would conflict with a future Content-Security-Policy, and it cost more time than the theme's box allowed. - 2026-10-11 — The palette is "Lavender café" (lavender and peach on cream, with deep plum text). Every text pairing passes WCAG AA (4.5:1, and 3:1 for non-text UI), and pairings that fail, such as muted text on lavender, are never used. Why: the theme stays readable for everyone.
- 2026-10-11 — The logo is an original café-cup sticker (no Discord logo or mascot), committed unchanged with its full SHA-256 checked, served once from
public/, and shown only through<img>, never inlined. It is also the browser icon, withfavicon.icokept as the fallback. Why: one hashed file, and an SVG shown through<img>can't run script. - 2026-10-11 — The home page's splash shows in every state, above the sign-in controls: "Your pics, sticker-sized." and "Upload a pic and get a link that shows up sticker-sized in Discord." The page names file types only in the upload form. Why: visitors see what the site does first, and types and limits keep one source,
/api/stickers/limits. - 2026-10-11 — The site is light-only (
color-scheme: light), with the leftover dark-mode CSS removed until dark mode comes next weekend. Why: the leftover CSS gave dark-mode visitors a half-dark page. - 2026-10-11 — The frontend makes no third-party requests: it uses a system font stack, and no font or CSS CDNs. Why: visitors aren't exposed to other sites, and a future Content-Security-Policy can stay strict.
- 2026-10-10 — Uploading requires Discord sign-in. Why: every upload can be audited and tied to an account that can be banned.
- 2026-10-10 — Login rejects banned accounts and Discord accounts less than 7 days old, before an authenticated session is created; the pre-login session holds only the OAuth state and is destroyed when a login is rejected. Why: it blocks banned users and throwaway accounts made just to upload.
- 2026-10-10 — Until admin ban-and-purge exists, bans are set by hand in the database, with no ban UI or ban audit events. Why: admin tooling is next weekend's work.
- 2026-10-10 — Uploads are typed from their content (magic bytes), size-limited at nginx and in Spring, dimension-checked before full decode, and re-encoded to PNG. Why: security is a core goal of the project, not polish.
- 2026-10-10 — Original upload bytes are never stored or served; the audit record keeps their SHA-256. Why: only the re-encoded image is ever served.
- 2026-10-11 — Uploads are limited to 10 MB. Why: it fits a typical phone photo, and every image is downscaled to sticker size anyway.
- 2026-10-11 — Each user can upload at most 20 stickers per hour; going over gets a clear "try again later" message, not an error page. Why: caps how fast one account can fill the disk.
- 2026-10-11 — Images are only ever shrunk, never scaled up: larger than the standard sticker size → downscaled to fit, keeping the aspect ratio; at or below it → dimensions unchanged. Every upload is still re-encoded to PNG. Why: stickers look consistent, and small images aren't blurred.
- 2026-10-11 — The storage bucket stays private and stickers are served only through Spring at
/s/{id}.png, with no presigned URLs. Why: presigned URLs expire and would break links pasted in Discord, storage isn't reachable from the internet, and one serving path lets ban-and-purge take a sticker down in one place. - 2026-10-11 — The upload form shows the accepted types, the size limit and the hourly limit, and those numbers match what the server enforces. Why: users learn the rules before hitting them.
- 2026-10-11 — The standard sticker size is 160 px: larger images are scaled down to fit a 160 × 160 box. Why: a paste test showed Discord draws pasted image links at their natural size, and 160 px matched a real sticker on desktop and phone; the trade-off is a slightly softer image than Discord's own 320 px stickers on high-DPI screens.
- 2026-10-11 — Bans are checked on every upload as well as at login, and re-checked under the user's lock just before the sticker is stored. Why: in-memory sessions last up to 8 hours, so a hand ban stops uploads immediately instead of at the next login.
- 2026-10-11 — The hourly limit counts accepted uploads only, checked under a per-user database row lock so parallel uploads can't race past it; failed attempts are audited but not capped this weekend. Why: a rejected upload stores nothing, and a cap on failed attempts moves to next weekend.
- 2026-10-11 — An upload is processed fully in memory first; then, under the user's lock, the ban and hourly count are re-checked, the PNG is stored, and the sticker and audit rows commit together. If a step fails, any stored PNG is deleted and a
failedaudit row is written outside the rolled-back transaction. Why: no orphaned files, no sticker without an audit row, and every outcome is audited. - 2026-10-11 — "10 MB" means 10 MiB (10,485,760 bytes). nginx accepts request bodies up to 11 MiB on the upload route only; every other route keeps nginx's 1 MiB default. Why: a file just over the limit still reaches Spring and gets the clear
too_largemessage, while bigger bodies stop at the gateway. - 2026-10-11 — nginx's request-body temp files and the api's
/tmpare memory-backed (tmpfs, 128 MiB each), and ImageIO's disk cache is off, so uploaded originals never touch disk. Known limit: this holds only while the server has no swapfile. Why: originals are never stored, not even as temp files. - 2026-10-11 — Images over 8,192 px on a side or 32 megapixels are rejected from their header, before decoding. At most 2 images decode at once; a third waits up to 10 seconds, then gets
busy. The api runs with-Xmx1g. Why: a small file that decodes to a huge image can't exhaust memory on a 4 GB server. - 2026-10-11 — CMYK and YCCK JPEGs are rejected, embedded color profiles are ignored, and EXIF orientation is applied; the output PNG holds only image data, with no metadata. Why: no location, device or capture details are ever published, and Java's built-in JPEG reader can't decode CMYK correctly.
- 2026-10-11 — Animated PNGs are accepted and kept as a still image, and the upload form says so. Why: animation comes with GIF support next weekend, and keeping the still image cost less time than rejecting them.
- 2026-10-11 — Stickers at
/s/{id}.pngare served by their own stateless security chain (no session, no cookies) withCache-Control: public, max-age=86400. Why: Discord and browsers can cache sticker images; the trade-off is that a removed sticker can stay in their caches for up to a day. - 2026-10-11 — The CSRF token is read only from the
X-XSRF-TOKENheader, never from the request body. Why: reading it from the body meant parsing multipart uploads before the user was known. - 2026-10-11 — The upload form gets its numbers from
GET /api/stickers/limits. Why: the numbers shown can't drift from what the server enforces. - 2026-10-11 — Upload errors reach the page only as fixed reason codes (
too_large,unsupported_type,dimensions_too_large,unreadable,unsupported_color,rate_limited,busy,invalid_request,banned,error), each mapped to a fixed message. Why: as with login, no server-supplied text is rendered in the page. - 2026-10-11 — Upload log lines hold only the outcome, the reason code and the time taken: no file names, Discord IDs or IP addresses. Why: container logs aren't rotated yet, so they keep the least data possible.
- 2026-10-11 — No real phone photo is committed as a test file; real-camera orientation is checked by hand in production instead. Why: its EXIF would publish the device model, software and capture time in the public repo.
- 2026-10-11 — Until owner delete and admin ban-and-purge exist, a sticker is taken down by hand with the runbook's procedure, which shows the sticker's link, owner and upload time and deletes nothing without an explicit
yes. Its upload audit row is kept, and the takedown itself isn't audited until ban-and-purge. Copies can stay in Discord's and browsers' caches for up to a day. Why: once others can upload, a ban alone doesn't remove what was already posted; the confirmation was added after the first production takedown removed the wrong test sticker. - 2026-10-10 — Public sticker IDs are 128-bit random values in base64url, and database keys never appear in URLs. Why: sticker links can't be guessed or enumerated.
- 2026-10-10 — Audit records cover logins and uploads, accepted and rejected. Why: a refused account or upload still leaves a trace.
- 2026-10-10 — Login audit rows are written only when Discord confirmed an identity (accepted, banned, too new); provider errors and missing or invalid OAuth state go to the application log only. Why: with no IPs and no rate limit, anyone could otherwise flood the audit table anonymously.
- 2026-10-10 — Login rejections reach the page only as fixed reason codes (
banned,too_new,error) mapped to fixed messages; the raw?login=value is never rendered. Why: rendering it would let a crafted link inject text into the page. - 2026-10-10 — Login uses separate dev and prod Discord applications with registration id
discord; the registered redirect URIs arehttp://localhost:8080/login/oauth2/code/discord(dev) andhttps://discosticker.cafe/login/oauth2/code/discord(prod). OnlyDISCORD_CLIENT_SECRETgoes insecrets.env, and only the api receives it. Why: the production client secret only ever exists on the server. - 2026-10-10 — Sessions are Tomcat's in-memory sessions this weekend: pre-login sessions expire after 10 minutes, signed-in sessions after 8 hours idle, and a redeploy signs everyone out. A hand ban takes effect at the user's next login, and for uploads immediately. Why: Postgres-backed sessions cost about 30 minutes this weekend; they move to next weekend.
- 2026-10-11 — Ban and account-age checks run in a custom
OAuth2UserService, before Spring Security creates a principal; a rejected login destroys its pre-login session. - 2026-10-11 — If the login audit write fails, the user is signed out instead of staying signed in. Why: "accepted" always means "audited".
- 2026-10-11 — Discord's OAuth error values are validated before they're logged. Why: a crafted callback URL can't inject fake log lines.
- 2026-10-11 — Login uses PKCE (S256), which Spring Security 7.1 sends by default; Discord accepts it, verified in production.
- 2026-10-11 — Discord's tokens are discarded after login; the database stores only the Discord ID and timestamps, and the display name lives only in the session. Why: keep the least data possible.
- 2026-10-11 — The session cookie is
__Host-SESSIONin production andSESSIONlocally: HttpOnly, SameSite=Lax, Secure over HTTPS, and sessions are tracked by cookie only, never in the URL. Why: a session ID can't leak through a link. - 2026-10-11 — The api returns Discord IDs as strings. Why: they exceed JavaScript's safe integer range and would be silently rounded.
- 2026-10-11 — The api denies by default: signed-out
/api/**calls get 401 JSON, never a redirect to a login page;/api/healthand/s/**are public. - 2026-10-11 — CSRF protection uses Spring Security's single-page-app mode (
csrf.spa()), and sign-out isPOST /api/logout. Why: another site can't sign a user out with a link. - 2026-10-11 — Production secrets are pasted once and checked by length without printing them. Why: that check caught a doubled paste during the login deploy.
- 2026-10-10 — Secrets live only in a gitignored
secrets.envand reach containers as environment variables through docker compose; a committedsecrets.env.examplelists the variable names. Why: the repo is public, and a secret pushed once stays in its history. - 2026-10-10 — Each service gets only the secrets it needs, through compose
${VAR:?}interpolation;env_file: secrets.envis never used. Why: least privilege, and a missing key stops startup. - 2026-10-10 —
secrets.envholds only secrets; the bucket name, region and endpoint live in compose orapplication.yml. - 2026-10-10 — Claude Code's file tools are denied reading and editing
secrets.env, andCLAUDE.mdforbids reading or printing it any other way. Why: anything Claude reads ends up in the session transcript. - 2026-10-10 — Claude Code never runs
docker compose configwithsecrets.env, ordocker inspecton services that hold secrets. Why: both print secret values into the transcript. - 2026-10-10 — In production,
secrets.envis created by hand on the server in the deploy checkout, owned by the deploy user with mode 600, with values generated on the server, or, when a third party issues them (Discord's client secret), typed in by hand on the server without echo (read -rs), never on the maintainer's machine; it is never committed, copied into an image, or handled by Claude Code, and Claude never connects to the server. Vault stays out of scope. Why: the secrets are generated where they're used, so they never travel or appear in a transcript. (Replaces the earlier "decided with hosting" entry.) - 2026-10-10 — Compose requires the storage keys, and a test proves anonymous and wrong-key requests are rejected. Why: SeaweedFS accepts anonymous requests when its keys are missing, and storage must fail closed.
- 2026-10-10 — SeaweedFS runs
weed mini4.48 with WebDAV, the admin UI, telemetry and bucket auto-creation off; its always-on admin server serves only/healthand/metrics, on the internal network. Why: fewer exposed services, and no buckets created by accident. - 2026-10-10 — Only the gateway publishes a port; Postgres, SeaweedFS and the api stay on the internal network.
- 2026-10-10 — The Vultr firewall allows 80 and 443 from anywhere (IPv4 and IPv6) and 22 only from the maintainer's home IPv4; key-only SSH stays on, with Vultr's web console as the fallback. Why: only the site is public, and password-guessing bots can't reach SSH at all.
- 2026-10-10 — ufw stays active on the server and allows 22, 80 and 443 for IPv4 and IPv6; the Vultr firewall is the perimeter and ufw is the host backstop. Why: Vultr's image shipped ufw active, and its IPv6 rules are what let IPv6 traffic reach
docker-proxy. - 2026-10-10 — SSH is key-only with root login disabled, set in a
00-hardening.confdrop-in. Thedeployuser is in thesudoanddockergroups: it runs Docker and$Proddirectly and usessudofor system changes. Why: membership indockeris root-equivalent, which is accepted because the only way in is key-only SSH asdeployfrom one IPv4 address. - 2026-10-10 — TLS 1.2 and 1.3 only; TLS by bare IP or for an unknown host is refused (
ssl_reject_handshake), and plain-HTTP requests for unknown hosts are closed; no OCSP stapling. Why: Let's Encrypt ended OCSP in 2025. - 2026-10-10 — The gateway sends four fixed security headers with
always: HSTSmax-age=300(no includeSubDomains or preload),X-Content-Type-Options: nosniff,Referrer-Policy: strict-origin-when-cross-origin,X-Frame-Options: DENY; no Content-Security-Policy this weekend. HSTS is raised once HTTPS has been stable. Why: a long HSTS max-age is hard to undo if something goes wrong. - 2026-10-10 — nginx is the only source of those security headers; the login change hides any copies the api sends (
proxy_hide_header). Why: Spring Security's default one-year HSTS would otherwise overridemax-age=300. - 2026-10-10 — Docker updates are manual: Docker's apt repository is not an allowed origin for unattended-upgrades. unattended-upgrades installs Debian's security and stable point-release updates (allowed origins: trixie
label=Debian, trixielabel=Debian-Security, trixie-security). Why: Debian stable updates are conservative fixes, and manual Docker updates avoid surprise daemon restarts.
- 2026-10-10 — Every change goes through OpenSpec: explore → propose → apply → archive.
- 2026-10-10 — Project rules live in
openspec/config.yamlandCLAUDE.md, and any rule change updates both files. - 2026-10-10 — Proposals state what's out of scope, designs list the threats considered, and every task ends with a
Verify:line. Why: security requirements become verified tasks, not just claims. - 2026-10-10 — The project context spells out what a Next.js static export can't do (SSR, API routes, middleware, image optimization). Why: otherwise Claude Code reaches for them by habit, and they fail at build time.
- 2026-10-10 — Work test-first where practical; every task states how it's verified.
- 2026-10-10 — Each feature or large effort gets its own branch, which may hold several OpenSpec changes, and merges to
masterby pull request. The initial commit and the two setup commits were the only direct pushes tomaster. - 2026-10-10 — The repo enforces LF line endings with
.gitattributes. Why: shell scripts with CRLF endings fail inside Linux containers. - 2026-10-10 — The repo is public on GitHub from the first commit. Why: this is a portfolio project.
- 2026-10-10 — Decisions are approved in the maintainer's PM session before they go in this file, and Claude Code edits it only when asked. If a change would contradict a recorded decision, Claude Code stops and asks.
- 2026-10-10 — Pull requests merge with a merge commit, not squash, and each change is archived on its branch before the merge. Why: the history keeps each change's trail from proposal to verified code.
- 2026-10-10 — Deploys run from the feature branch before it merges; the server switches to
masterafter the merge. - 2026-10-10 —
add-production-deployis archived as the first commit on the login branch. Why: its last task (switching the server tomaster) can only run after the merge. - 2026-10-10 — From the login change on, switching the server to
masterafter a merge is a routine step in the runbook's Redeploy section, not a task in each change, so every change archives on its branch before the merge. - 2026-10-10 — Server checkouts are verified against GitHub's branch hash, taken from the maintainer's machine, never the server's own
origin/*; after a branch switch, the gateway is recreated, not just reloaded. Why: a staleorigin/masteronce checked out pre-deploy files and silently broke the gateway's config mount. - 2026-10-11 — Before opening or merging a pull request, run
git status -sband confirm the branch isn't ahead of GitHub and has no uncommitted files. Why: PRs were merged before their last commit was pushed twice (login and upload), so a merge now waits until the maintainer pastesgit status -sboutput showing nothing ahead. - 2026-10-11 — Verify lines compare full hashes, never abbreviated ones. Why: an explore report once misquoted the logo's hash, which would have failed a correct copy.
- 2026-10-11 — A styling-only change is proven by a check that every original line of the page survives once class names and indentation are stripped. Why: "no behavior change" becomes a test, not a promise.
- 2026-10-10 — Target: a live, demo-able app at discosticker.cafe by Sunday night. Unfinished work moves to next weekend instead of shipping at lower quality.
- 2026-10-10 — Login and rejected-upload audit records stay in this weekend's scope; ban audit records move to next weekend with admin ban-and-purge.
- 2026-10-10 — The "My stickers" page and owner-only delete move to next weekend.
- 2026-10-10 — Uploads accept only PNG and JPEG this weekend; WebP moves to next weekend.
- 2026-10-10 — The walking skeleton deploys to discosticker.cafe as soon as it merges, before login. Why: hosting problems surface early instead of on Sunday night.
- 2026-10-10 — The deploy has no local rehearsal script and no swapfile; if the server build runs out of memory, the api and gateway are built one at a time. Why: both cost time without reducing real risk for this deploy.
- 2026-10-10 — The login change starts by hiding the api's security headers (
proxy_hide_header), before any OAuth work. - 2026-10-10 — The login change skips Postgres-backed sessions and a frontend test runner (Vitest) this weekend; the frontend checks stay type check plus build. Why: together they save about an hour, which keeps the 1 pm live-test round and upload's Sunday time realistic.
- 2026-10-11 — A visual theme change runs after upload goes live, time-boxed to about 1.5 hours and first to cut if upload runs long: a pastel Material-style theme, an original logo (no Discord logo or mascot), and a splash section on the home page. Why: a polished demo, without risking the core feature.
- 2026-10-11 — Upload goes live before the theme, and the second test round starts by 7 pm Sunday (planned for 6 pm) whether or not the theme is live. Why: testers should land on the themed site, but the theme can't delay testing.
- 2026-10-11 — Upload's lower-priority tests move to next weekend: extra EXIF and format fixtures, a gateway size-limit test, a test for a database failure after the file is stored, and a decode time-budget test. Why: they cover rarer cases, and the main paths are tested.
- 2026-10-11 — The theme's time box was extended to about 4:50 pm with the browser icon kept; a theme not deployed by 5:30 pm ships after the second test round. Why: a gateway restart never lands while testers are on the site.
- 2026-10-11 — The repo is licensed MIT. Why: a public repo without a license isn't open source.
- 2026-10-10 — Audit records don't store visitor IP addresses this weekend; the Discord account is the identity behind every audited action. nginx's access log still records visitor IPv4 addresses in Docker's container logs, which aren't rotated yet. Storing IPs, with the IPv6 visitor-address fix (IPv6 visitors currently appear as the Docker bridge address) and retention cleanup, moves to next weekend. Why: sign-in already ties every action to a bannable account, it stores less personal data, and it saves time for upload.
- 2026-10-10 — Next weekend: GIFs, importing server stickers, a report link, admin ban-and-purge, storing visitor IPs in audit records (with the IPv6 visitor-address fix and retention cleanup), the "My stickers" page with owner-only delete, WebP uploads, raising the HSTS max-age, a Content-Security-Policy, theme extras (animations, dark mode, extra pages), Postgres-backed sessions, a frontend test runner, nginx log rotation, upload's deferred tests, a cap on failed upload attempts,
</dev/nullon the takedown'sdocker compose execcalls, and an optional GitLab mirror.