Skip to content

One container, and real authentication - #6

Merged
ralyodio merged 2 commits into
mainfrom
feat/single-container-and-auth
Aug 11, 2026
Merged

ralyodio merged 2 commits into
mainfrom
feat/single-container-and-auth

Conversation

@ralyodio

Copy link
Copy Markdown
Contributor

Two changes, both requested.

One container

Deletes the three-service split. apps/server is now the only entrypoint — a single Bun process that:

  • serves the Hono API in-process on /api/v1 and /health/*
  • supervises the Next PWA as a loopback child and proxies everything else to it
  • runs the background loop (signal expiry, deletion jobs, session pruning)

One Dockerfile, one railway.json, one deploy, one log stream, one set of environment variables.

Next ships its own server with no supported way to mount it inside another Bun server, hence the child process. Only the supervisor binds a public port; the child is loopback-only.

The worker loop is single-instance by design — two copies would both claim the same pending deletion job — so railway.json pins numReplicas: 1 and records why. Scaling horizontally later means splitting the loop back out behind a lock.

Removed: docker/{api,web,worker}.Dockerfile, apps/{web,worker}/railway.json, apps/api/src/index.ts, apps/worker/src/index.ts.

Real authentication

The API previously accepted one shared bearer token. That is now the machine path only; humans get sessions.

  • argon2id hashing via Bun.password — no hand-rolled crypto
  • sessions stored as a SHA-256 hash of the cookie value, never the value, so a database leak yields no usable sessions
  • httpOnly, SameSite=Lax, Secure in production
  • identical response for unknown user and wrong password, so the endpoint cannot enumerate accounts
  • account lockout after repeated failures
  • workspace scoping derived from membership, not from client-supplied headers — previously any holder of the token could name any workspace
  • registration provisions an organization and first workspace, since a user without one cannot do anything here

The service token now additionally requires explicit scope headers and is compared in constant time.

The approval buttons were decorative

Zero onClick handlers. Approve, edit, skip and do-not-contact now call the API. A policy denial surfaces the gate that blocked it instead of failing silently, and do-not-contact confirms first, because a suppression tombstone deliberately outlives the prospect record.

CI

The docker matrix is replaced by one image job that boots the container and asserts the API is healthy, the Next child answers through the proxy, unauthenticated calls are 401, and register/login/me works end to end. Docker is not installed on the authoring machine, so this job is the only thing that has ever run these images.

Verification

289 tests (24 new for auth), typecheck clean, next build clean, prettier clean.

🤖 Generated with Claude Code

ralyodio and others added 2 commits August 11, 2026 13:52
Two changes you asked for.

SINGLE CONTAINER
Deletes the three-service split. apps/server is now the only entrypoint: one
Bun process that serves the Hono API in-process, supervises the Next PWA as a
loopback child and proxies to it, and runs the background loop. One
Dockerfile, one railway.json, one deploy, one log stream.

Next ships its own server with no supported way to mount it inside another
Bun server, hence the child process. Only the supervisor binds a public port.

The worker loop is single-instance by design — two copies would both claim
the same pending deletion job — so railway.json pins numReplicas to 1 and
says why.

AUTHENTICATION
Replaces the shared bearer token as the human path.

- argon2id password hashing via Bun.password
- sessions stored as a SHA-256 hash of the cookie, never the value, so a
  database leak yields no usable sessions
- httpOnly + SameSite=Lax cookies, Secure in production
- identical response for unknown user and wrong password, so the endpoint
  cannot enumerate accounts
- lockout after repeated failures
- workspace scoping derived from membership, not from client headers
- registration provisions an organization and first workspace, because a user
  without a workspace cannot do anything here

The service token survives for internal machine callers only, and now needs
explicit scope headers and a constant-time comparison.

Also wires the approval buttons, which were decorative: approve, edit, skip
and do-not-contact now call the API, and a policy denial shows the gate that
blocked it rather than failing silently.

289 tests, typecheck clean, PWA builds.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The container started against an unmigrated database and every write failed
with 'no such table: workspaces'. Serving against a schema that is not there
produces confusing 500s on every route, so the supervisor now applies pending
migrations before anything listens, and exits rather than starting if they
fail.

Boot-time migration is safe here specifically because the deployment is one
container pinned to one replica; the usual objection is replicas racing, which
cannot happen at numReplicas 1. RUN_MIGRATIONS=false opts out.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@ralyodio
ralyodio merged commit b28fd5c into main Aug 11, 2026
4 checks passed
@ralyodio
ralyodio deleted the feat/single-container-and-auth branch August 11, 2026 14:06
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant