No description
  • TypeScript 94.9%
  • Nix 5.1%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
2026-09-10 10:53:24 -04:00
scripts allow sub path serving 2026-09-10 10:53:24 -04:00
src allow sub path serving 2026-09-10 10:53:24 -04:00
tests allow sub path serving 2026-09-10 10:53:24 -04:00
.dockerignore initial oneshot 2026-09-10 09:54:27 -04:00
.env.example initial oneshot 2026-09-10 09:54:27 -04:00
.envrc initial oneshot 2026-09-10 09:54:27 -04:00
.gitignore initial oneshot 2026-09-10 09:54:27 -04:00
flake.lock switch to latest unstable nixpkgs 2026-09-10 10:20:01 -04:00
flake.nix allow sub path serving 2026-09-10 10:53:24 -04:00
next.config.ts allow sub path serving 2026-09-10 10:53:24 -04:00
package-lock.json initial oneshot 2026-09-10 09:54:27 -04:00
package.json initial oneshot 2026-09-10 09:54:27 -04:00
playwright.config.ts allow sub path serving 2026-09-10 10:53:24 -04:00
README.md allow sub path serving 2026-09-10 10:53:24 -04:00
tsconfig.json initial oneshot 2026-09-10 09:54:27 -04:00

Auction Demo

Motorroom is a vehicle-auction demo built with Next.js 16.3.4, React 19.3.0, TypeScript 7.0.2, and Node.js 24. SQLite stores listings, bids, sessions, and a durable email outbox. A separate worker closes auctions and delivers notifications; no external queue or SQLite npm binding is required.

Browse searchable live/ended auctions, open details in a dialog or at /auctions/[id], and sign in to place bids. /my-bids tracks auctions you have bid on, including ones where you were outbid; authorized creators can list a car at /listings/new. Prices are USD, stored as integer cents. The visible grid refreshes every 30 seconds and an open detail dialog every 15 seconds—this is polling, not a WebSocket feed.

This is a demonstration, not a payment, escrow, or vehicle-transfer system. The seed inventory is illustrative. See src/lib/BACKEND.md for the full backend contract and security details.

Quick start

Use Node 24 (including its built-in node:sqlite) and npm. The Nix shell pins both through nixpkgs and also supplies Git and pkg-config:

nix develop
cp .env.example .env.local
npm ci
npm run dev

Open http://localhost:3000. Browsing does not need an identity provider or SMTP. Querying inventory seeds it once automatically; npm run seed is also available. Seed creates six vehicles owned by Demo Showroom (illustrative), not fake customers or bids. Subsequent seed runs do not reset auction deadlines.

For automatic shell activation, install direnv with nix-direnv and run direnv allow. .envrc enters the flake shell and exports .env.local for the worker and seed scripts as well as Next. Review .envrc before allowing it.

Next loads .env.local automatically. The npm worker/seed scripts also load it, using Node's --env-file-if-exists=.env.local; direnv is optional. In a second terminal, enter nix develop, then run:

npm run worker
# Or process just one batch:
npm run worker:once
# Optional explicit seed:
npm run seed

When invoking TypeScript files directly rather than through npm, pass --env-file=.env.local or export configuration yourself, for example node --env-file=.env.local --import tsx scripts/worker.ts --once. Exported variables take precedence over values loaded by Node from the file.

Commands

Command Purpose
npm run dev Next development server
npm run build Production Next build
npm start Standard production server (next start)
npm run worker Recurring worker (node --env-file-if-exists=.env.local --import tsx scripts/worker.ts)
npm run worker:once One worker pass; suitable for a scheduler
npm run seed Idempotent illustrative inventory seed
npm test Synthetic tests (node --import tsx --test scripts/*.test.ts)
npm run typecheck TypeScript validation
npm run test:e2e Chromium UI/integration tests (isolated database)

Worker/seed npm scripts load .env.local when it exists and also accept exported configuration. Tests use isolated temporary databases and mocked identity/mail services. The Nix container intentionally uses node server.js, not npm start: Next's generated standalone server is the container entry point. For manually running standalone output, remember to copy public and .next/static too; the flake does this automatically.

Browser tests

Run npx playwright install chromium once inside the Nix shell, then npm run test:e2e. Playwright starts and stops its own server on port 3100 and uses a disposable database, never your application database. The suite covers same-page dialogs, keyboard focus, mobile layout, sign-in guards, authenticated bidding, bid history, and seller publication. See tests/README.md for details. Browser tests use synthetic server-side sessions; the separate backend tests exercise the OAuth protocol with mocked providers.

The UI uses Google Fonts with system-font fallbacks and externally hosted illustrative photos. Visitors’ browsers contact those hosts; no fonts or images are downloaded during the Nix/Next build. For an offline or privacy-sensitive deployment, self-host these assets.

Configuration

.env.example lists every application setting. Keep credentials out of Git, browser bundles, build arguments, and the Nix store. No application JWT/session secret or NEXT_PUBLIC_* secret is needed.

  • DATABASE_PATH defaults to ./data/auction.db. Web and worker must use the same file; an absolute path is safest. Containers default to /data/auction.db.
  • APP_URL is the externally visible origin, with no path, query, fragment, or credentials. Development can use http://localhost:3000. Production requires HTTPS, including when TLS terminates at a reverse proxy. Keep the proxy's host/origin configuration consistent with this URL and Next's Server Action origin protection.
  • Next's port is 3000. The image sets HOSTNAME=0.0.0.0 so it listens outside the container.

Hosting under a subpath

Next.js uses basePath, not an HTML <base> element. Set the public, non-secret NEXT_PUBLIC_BASE_PATH before starting development or building:

NEXT_PUBLIC_BASE_PATH=/auction-demo npm run dev
# Or for production:
NEXT_PUBLIC_BASE_PATH=/auction-demo npm run build
NEXT_PUBLIC_BASE_PATH=/auction-demo APP_URL=https://example.com npm start

The app is then served at https://example.com/auction-demo. Leave the variable unset or empty to serve at /. Use a leading slash, no trailing slash, and URL-safe path segments (nested paths such as /tools/auctions are supported).

  • This is a build-time setting. Rebuild when changing the path; a runtime environment variable cannot relocate an existing Next.js build. Give the separately run worker the same NEXT_PUBLIC_BASE_PATH so email links point to the app.
  • Keep APP_URL=https://example.com as the origin only. Register https://example.com/auction-demo/api/auth/callback with your OAuth provider.
  • Configure your reverse proxy to forward the entire prefixed path to Next.js without stripping the prefix, including /auction-demo/_next/* and /auction-demo/api/*.
  • Next Link and router navigation handle the prefix automatically. Use withBasePath from src/lib/paths.ts for plain anchors, form URLs, local public assets, and server-generated URLs; do not add it to Next links. Seller images remain external HTTPS URLs.
  • For Nix/container builds, set basePath in flake.nix before building the image. This embeds the path in the build and supplies it to the worker through the image environment. Merely setting it with docker run -e is not sufficient.

Authentication cookies remain host-wide (Path=/) to preserve the existing __Host- cookie protections. Subpath hosting does not isolate multiple instances' sessions on the same hostname; use separate hostnames for that.

  1. Register a confidential client with your provider and allow APP_URL + NEXT_PUBLIC_BASE_PATH + /api/auth/callback as the exact redirect URI, for example http://localhost:3000/api/auth/callback in development.
  2. Set OAUTH_ISSUER to the exact issuer identifier, OAUTH_CLIENT_ID, and OAUTH_CLIENT_SECRET. Leave the three explicit endpoint variables unset.
  3. Use OAUTH_SCOPES="openid profile email". The default client authentication is client_secret_basic; client_secret_post is also supported.
  4. Configure the provider to return a stable sub, an email, and boolean email_verified: true. An email-looking username is insufficient. If userinfo provides the email, that same userinfo response must attest to its verification.

Discovery must publish standard metadata including jwks_uri. The flow uses authorization code with query response mode (not form_post), S256 PKCE, state, validated ID-token claims and JWKS signatures, and matching userinfo/ID-token subjects. Sign-in begins at /api/auth/login. Logout is same-origin POST /api/auth/logout; it revokes the local session, not the provider's SSO session.

Remote provider endpoints must use HTTPS. OAUTH_ALLOW_INSECURE_HTTP=true permits only loopback HTTP endpoints, only outside production.

Generic OAuth: explicit endpoints, with constraints

An OAuth-only provider can be configured using all three of OAUTH_AUTHORIZATION_ENDPOINT, OAUTH_TOKEN_ENDPOINT, and OAUTH_USERINFO_ENDPOINT, plus OAUTH_ISSUER as a stable trusted provider identifier. Remove openid from OAUTH_SCOPES in this mode. This is not an alternate manual OIDC configuration; use discovery for OIDC.

The provider must support S256 PKCE and the configured confidential-client authentication method. Its bearer-authenticated JSON userinfo endpoint must directly return sub, email, boolean email_verified: true, optional name, and the creator claim. Redirects from userinfo are rejected. Access tokens are not decoded as identity.

“OAuth support” does not mean every provider works unchanged. GitHub-style numeric id profiles, separate email APIs, nonstandard token exchanges, or missing PKCE/verified-email attestations require a reviewed adapter.

Who can create listings?

Both LISTING_CREATOR_CLAIM and LISTING_CREATOR_VALUE must be set and match a provider-controlled claim. Never use a profile field a user can edit themselves.

  • Claim names are literal top-level keys, including namespaced URLs, not dot-paths.
  • Primitive strings, numbers, and booleans match through exact String(value) comparison; arrays match if any primitive element matches.
  • Examples: roles / listing_creator, or can_create_listings / true.
  • Missing configuration or a missing/mismatched claim denies creation. The server rechecks permission inside the write transaction.

Accounts are keyed by (issuer, sub), not email. Claims refresh on sign-in rather than continuously; provider revocation is not pushed into existing sessions. Changing the required value rechecks stored claims immediately; changing the claim name requires reauthentication. Restart the web process after changing provider configuration. Sessions expire after seven days and use opaque tokens stored only as hashes, with HttpOnly/SameSite cookies and Secure cookies on HTTPS.

Worker and SMTP

Run a worker alongside the web process. Each recurring pass closes due auctions, sends up to 20 queued messages, then sleeps 30 seconds. --once performs a single pass. SIGINT/SIGTERM stop the loop after the current pass.

Set SMTP_HOST and SMTP_FROM to enable sending. Without either, mail remains queued while auction closeout still runs. Supply SMTP_USER and SMTP_PASSWORD together, or omit both. Remote SMTP requires TLS:

  • SMTP_SECURE=false: STARTTLS, normally port 587.
  • SMTP_SECURE=true: implicit TLS, normally port 465; change SMTP_PORT accordingly.
  • Local plaintext SMTP catchers are allowed for development.

Closeout and email enqueueing occur in one transaction. Winner and verified seller notifications use the email addresses captured at closeout; illustrative sellers receive no mail. Unique outbox keys prevent duplicate enqueueing. Workers claim messages using leases; an abandoned claim expires after ten minutes. Failures retry indefinitely with exponential backoff from 30 seconds to 24 hours.

Delivery is at least once, not exactly once: SMTP acceptance followed by a crash before recording success can cause a resend. Stable Message-IDs help but cannot guarantee recipient deduplication. Monitor email_outbox.attempts, last_error, next_attempt_at, lease_until, and sent_at. There is no dead-letter UI. Real provider login and real SMTP acceptance must be verified separately from the mocked tests.

Reproducible Nix build

flake.nix follows nixos-unstable; flake.lock pins its exact revision and source hash for reproducible builds. The dev shell supports Apple Silicon macOS and ARM64/x86-64 Linux. Image and application packages are Linux-only. Intel macOS is not supported by current nixos-unstable.

The application is built with buildNpmPackage and importNpmLock + npmConfigHook. Each dependency is fetched using its package-lock.json integrity hash: there is no placeholder npmDepsHash or fake-hash replacement step. Commit package.json, the npm-generated package-lock.json, and the flake files together. When dependencies change, regenerate the npm lockfile with Node 24/npm and rebuild. Keep platform-specific optional dependencies in the lockfile so both Linux architectures can install Next/SWC, sharp, and esbuild correctly.

Prerequisites:

  • Nix with flakes enabled and a working daemon.
  • The committed package files and sources. next.config.ts already sets output: "standalone" and externalizes openid-client and nodemailer; the flake retains their runtime dependencies. It fails with a clear error if standalone output is missing.
  • A Linux builder for the desired architecture. On macOS, use a configured Linux VM/remote Nix builder; Docker Desktop by itself is not a Nix builder. No cross-compilation is assumed.
  • Initial network access for pinned nixpkgs, npm tarballs, and Nix binary substitutes. The actual Next build runs without network access: use local fonts/assets, not build-time remote downloads.

Validate every declared platform from either macOS or Linux (no Linux builder needed for evaluation):

nix flake check --all-systems --no-build
nix eval --raw .#packages.aarch64-linux.image.drvPath
nix eval --raw .#packages.x86_64-linux.image.drvPath

This validates derivations, not a successful Next build or a running container.

Build on Linux:

nix flake check --all-systems --no-build
nix build .#image
# Load the dockerTools archive; no Dockerfile or docker build is involved.
docker load < result

With a configured ARM64 Linux builder from macOS:

nix build .#packages.aarch64-linux.image
docker load < result

Use packages.x86_64-linux.image for an x86-64 builder/host. .#app on Linux builds only the standalone application tree. Nix includes the Node runtime, CA certificates, .next/static, optional public, the full node_modules (including tsx), scripts, tsconfig.json, and src/lib in the image. Keeping development dependencies is intentional so the same image can execute the TypeScript worker. The build patches npm's native Linux binaries with autoPatchelf before invoking Next and retains the referenced Nix runtime libraries. /app contains real application files rather than per-file Nix-store symlinks, so standalone's __dirname resolves to /app and its writable cache stays outside the immutable store.

Package evaluation requires the committed package.json and package-lock.json; the dev shell does not depend on them. importNpmLock reads their JSON directly from the flake source so a fresh no-build check does not depend on an already-materialized filtered source path. nix flake lock regenerates a missing flake lock from the configured input. Run nix flake update nixpkgs to advance the locked revision to the latest nixos-unstable. In a Git checkout Nix only sees tracked/staged files, so add new build inputs before building. Do not stage secrets. A path-based flake outside Git may copy the entire checkout to the Nix store before its build-source filter runs: build only from a clean, secret-free checkout.

.dockerignore is defense in depth for any future Docker build context; Nix does not read it. The flake separately restricts application build inputs. Build secrets are unnecessary; provide real configuration at runtime only.

Run web and worker from one image

Create a protected runtime env file outside the checkout, such as ../auction-demo.runtime.env. Use the names in .env.example, set APP_URL=https://auctions.example.com to your real public origin, and set DATABASE_PATH=/data/auction.db. Docker --env-file is not a shell/dotenv parser: write values without surrounding quotes, including OAUTH_SCOPES=openid profile email and SMTP_FROM=Auction Demo <auctions@example.com>. Restrict access to this file. Do not reuse the development HTTP origin in production.

docker volume create auction-data

docker run -d --name auction-web --restart unless-stopped --init \
  --env-file ../auction-demo.runtime.env \
  --mount type=volume,source=auction-data,target=/data \
  -p 127.0.0.1:3000:3000 \
  auction-demo:latest

docker run -d --name auction-worker --restart unless-stopped --init \
  --env-file ../auction-demo.runtime.env \
  --mount type=volume,source=auction-data,target=/data \
  auction-demo:latest node --import tsx scripts/worker.ts

Place an HTTPS reverse proxy in front of the loopback-bound web port. The worker publishes no port. The image has no fixed entrypoint, so the command after its name replaces the web command directly. For a scheduled one-pass worker:

docker run --rm --init \
  --env-file ../auction-demo.runtime.env \
  --mount type=volume,source=auction-data,target=/data \
  auction-demo:latest node --import tsx scripts/worker.ts --once

Volume permissions and SQLite concurrency

Both containers run nonroot as UID/GID 10001:10001. The image's /data is owned by that user with mode 0750; Docker initializes a new empty named volume with those permissions. The Next cache directory is also writable by that user. An existing volume or bind mount overrides image ownership: provision it for 10001:10001 before starting either process. On a Linux bind-mount host, for example:

sudo install -d -m 0750 -o 10001 -g 10001 /srv/auction-demo/data

Then replace both mounts with --mount type=bind,source=/srv/auction-demo/data,target=/data. Ensure existing database, WAL, and SHM files are writable by the same identity. Do not solve permissions with chmod 777 or by running the service as root.

SQLite uses WAL, foreign keys, a 10-second busy timeout, and BEGIN IMMEDIATE write transactions. Web and worker may run concurrently on one host, sharing the same persistent local directory and database path. Multiple workers can safely lease outbox jobs, but SQLite still serializes writers; this is not a multi-host database architecture. Keep transactions short and monitor lock contention before increasing process counts.

Do not use ephemeral serverless storage, separate per-container databases, or an unreviewed network filesystem/NFS volume. Mount the directory, not just the .db file, because SQLite needs adjacent WAL/SHM files. Back up using a coherent SQLite backup/snapshot mechanism, or stop all writers before copying the full database state; blindly copying a live main .db file is unsafe. Protect database files and backups as sensitive identity/session data. This demo has one initial schema; future schema changes need explicit migrations.