feat: scaffold the career-forge app and development environment - #15
Merged
Conversation
First code in the repo. No domain logic, no AI call, no production Dockerfile, no Caddy: only the skeleton every later ticket needs somewhere to put code in. - One package at the root with app/, server/, core/, db/, queue/, worker/, seed/, storage/, tests/ and e2e/. server/ and worker/ both import core/, and an ESLint rule fails the build if core/ ever imports either. - docker-compose.dev.yml runs Postgres and GoTrue and nothing else. docker-compose.yml names all five services and references a Dockerfile and a Caddyfile that belong to the packaging and publishing tickets. - .env.example names every variable and holds no real value. - Drizzle wired to Postgres with a first migration for the account table, plus GoTrue's auth schema declared read-only and pnpm db:check-auth to prove we can read what we did not create. - pg-boss behind a single queue/ module, with worker/ as its consumer. - storage/ with a filesystem driver and a declared S3 switch that refuses. - Vitest and Playwright, and a GitHub Actions workflow that runs lint, typecheck, both suites and a production build. Signed-off-by: PP <121104417+BohnBawerick@users.noreply.github.com>
…text, exists() Signed-off-by: PP <121104417+BohnBawerick@users.noreply.github.com>
Signed-off-by: PP <121104417+BohnBawerick@users.noreply.github.com>
Signed-off-by: PP <121104417+BohnBawerick@users.noreply.github.com>
Signed-off-by: PP <121104417+BohnBawerick@users.noreply.github.com>
…L-safe passwords Signed-off-by: PP <121104417+BohnBawerick@users.noreply.github.com>
Signed-off-by: PP <121104417+BohnBawerick@users.noreply.github.com>
…chema and seed Signed-off-by: PP <121104417+BohnBawerick@users.noreply.github.com>
…d issuer Signed-off-by: PP <121104417+BohnBawerick@users.noreply.github.com>
Signed-off-by: PP <121104417+BohnBawerick@users.noreply.github.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Intent
Build the career-forge skeleton so every later ticket has somewhere to put code. This is the first code in a repo that was documentation only. The specification is GitHub issue #13 (BohnBawerick/career-forge); its "Deliver" list is the contract. CONTEXT.md and docs/adr/ fix the vocabulary and the stack, and must be used.
Nothing here is a design decision. Issue #7, ADR 0004 and ADR 0005 settled the stack: TypeScript end to end in one Nuxt 4 app, Nitro server routes as the HTTP API, plain Postgres with GoTrue beside it, Drizzle for queries and migrations, pg-boss for the queue, filesystem storage behind a module with an unimplemented S3 switch, one package with no workspace.
Definition of done from the ticket:
docker compose -f docker-compose.dev.yml upstarts Postgres and GoTrue;pnpm devserves an empty Nuxt app on localhost:3000 that can sign a fabricated Account up and log it back in;pnpm workerpicks a test job off pg-boss. All three must be proven with real output in the PR body.Deliver exactly this:
authschema works.Explicitly not in this ticket: any domain logic, any AI call, any real screen, the production Dockerfile, Caddy.
One accepted exception to "no domain logic", decided by the author after review: four ADR 0007 invariants are enforced in code this ticket already ships, because ADR 0007 already settles them as doctrine and two of them break the ticket's own acceptance (a sign-up that bricks a claimed install, and a failed first sign-up that leaves an install permanently ownerless). Exactly these four, and nothing more: the sign-up rollback is scoped to the account write, so a failed token grant no longer deletes the only login; the login repair path claims ownership when no Owner exists; a unique partial index on is_owner where is_owner is true is declared in db/schema.ts and generated into the first migration, so two concurrent sign-ups cannot both become Owner; and pnpm seed is guarded by the same installIsUnclaimed check the HTTP route enforces. No accounts subsystem, no new abstraction, no invite or membership work, no new module. The accounts ticket still owns everything else, and the pull request body says so.
Sign-off, decided by the author after three runs deadlocked on it: the pipeline owns its own commits and they must not be rewritten. Do not amend, rebase, reword or otherwise rewrite any commit on this branch, do not force anything, and do not raise a missing per-commit Signed-off-by trailer as a finding. CONTRIBUTING.md requires the Developer Certificate of Origin and defines its point as what goes into the public git history. This repository has no automated per-commit sign-off check. The pull request will be squash merged, so exactly one commit reaches public history and the author puts the Signed-off-by trailer in that squash message. Unsigned intermediate commits on the branch never land anywhere public, so the rule is satisfied in the only place it is defined to matter. The pull request body states this reasoning.
The branch is fm/cf-scaffold-signoff. An earlier branch name was abandoned because adding sign-off trailers rewrote commit messages and left that name pinned to a head no push could fast-forward past. Neither name was ever pushed to origin. The pull request body says the branch was renamed for that reason.
One addition beyond the ticket, deliberate: a GitHub Actions workflow at .github/workflows/ci.yml, because the repository has no CI at all and later tickets would otherwise validate blind. Keep it proportionate to what exists: lint, typecheck, the Vitest suite, and a production build. Wire the Playwright test into CI only if it runs reliably headless there; it does, because the front page renders with no database, so it is included.
Constraints specific to this repository:
Acceptance criteria:
The stack is settled, so a genuine design fork should not arise. If one does, do not silently pick: raise it as a decision rather than answering it.
What Changed
app/,server/,core/,db/,queue/,worker/,seed/,tests/ande2e/, plus an ESLint rule ineslint.config.mjsthat fails on any import fromcore/intoserver/orworker/(ADR 0004).docker-compose.dev.ymlwith postgres and gotrue,docker-compose.ymlwith all five services,.env.examplewith no real values, Drizzle against Postgres with a firstaccountmigration and adb/check-auth.tsread of GoTrue'sauthschema, pg-boss with every send behindqueue/, astorage/filesystem driver with an S3 switch that refuses, Vitest and Playwright each with a passing test, and a.github/workflows/ci.ymlrunning lint, typecheck, tests, build and the browser test.is_owner, andpnpm seedguarded by the sameinstallIsUnclaimedcheck the HTTP route enforces.Notes
Signed-off-bytrailer. This repository has no automated per-commit sign-off check, the pull request is squash merged, and the author puts the trailer in the squash message, so the one commit that reaches public history is signed off as CONTRIBUTING.md defines it.fm/cf-scaffold-signoff. Adding trailers to an earlier branch rewrote its commit messages and pinned that name to a head no push could fast-forward past. Neither name was ever pushed to origin.Risk Assessment
✅ Low: The fix-round delta is small and closes all three prior findings at a real shared boundary, verified against the installed drizzle-orm source and the generated migration, with behavior tests that drive the actual functions; the two remaining items are cosmetic error-reporting and dead-export cleanup.
Testing
I ran the ticket's three done-when commands for real on a fresh Postgres volume and captured product-level output for each. The dev compose brings up postgres and gotrue only, both healthy; migrations apply and
pnpm db:check-authreads GoTrue'sauthschema. In a real Chrome session againstpnpm devI claimed the install as a fabricated Owner, signed out (the button switches from "Claim this install" to "Sign in", so sign-up has closed), and logged back in - five screenshots plus a GIF of the flow.pnpm workerpicked up apnpm queue:pingjob with matching ids on both sides and the pg-boss row ends up completed. The core/ import rule was shown failing on a deliberate violation and the violation removed. Vitest (34 tests) and Playwright (1 test) both pass, and I additionally exercised the four accepted ADR 0007 invariants against live state: the database rejects a second Owner via the partial unique index,pnpm seedrefuses a claimed install and seeds a fresh one, the HTTP signup route answers 403 once claimed, and deleting the account row behind a live GoTrue login lets the next login repair it and claim Owner. The one invariant not fault-injected is the scoped sign-up rollback on a failed token grant; it is only readable inserver/api/auth/signup.post.ts, but its worst consequence (a permanently ownerless install) is covered end to end by the repair demonstration. I did not runpnpm buildor the CI workflow - those belong to the later phases. Afterwards I tore the compose stack and its volume down and removed.env,.dataand test output, leaving the worktree clean.Evidence: Dev environment up: two services, GoTrue health, migrations, auth schema read
$ docker compose -f docker-compose.dev.yml config --services postgres gotrue $ docker compose -f docker-compose.dev.yml ps SERVICE IMAGE STATUS PORTS gotrue supabase/gotrue:v2.186.0 Up 15 minutes (healthy) 127.0.0.1:9999->9999/tcp postgres postgres:17-alpine Up 16 minutes (healthy) 127.0.0.1:55432->5432/tcp $ curl -s http://127.0.0.1:9999/health {"version":"v2.186.0","name":"GoTrue","description":"GoTrue is a user registration and authentication API"} $ pnpm db:migrate Migrations applied. $ pnpm db:check-auth auth schema present, auth.users readable, 1 login(s) in it. 69cde08c-25f5-4285-8256-893be71190fe owner@example.com $ docker compose -f docker-compose.yml config --services (the full stack, five services) postgres worker gotrue web caddyEvidence: Worker picks a test job off pg-boss
shell 1 $ pnpm worker Worker listening on career-forge.test. Ctrl-C to stop. Picked test job 8873aebf-02c4-48a8-9360-afd2ecb2ae4b: "a fabricated test job from the scaffold check" sent 2026-08-24T02:35:06.515Z, handled 2026-08-24T02:35:08.497Z shell 2 $ pnpm queue:ping "a fabricated test job from the scaffold check" Sent test job 8873aebf-02c4-48a8-9360-afd2ecb2ae4b: a fabricated test job from the scaffold checkEvidence: pg-boss job row after the worker handled it
id | name | state | note | completed --------------------------------------+-------------------+-----------+-----------------------------------------------+----------- 8873aebf-02c4-48a8-9360-afd2ecb2ae4b | career-forge.test | completed | a fabricated test job from the scaffold check | tEvidence: The core/ import rule fails on a deliberate violation
$ cat core/deliberate-violation.ts import { QUEUES } from '../worker/index' export const wrong = QUEUES $ pnpm lint /.../core/deliberate-violation.ts 2:1 error '../worker/index' import is restricted from being used by a pattern. core/ imports neither server/ nor worker/ (ADR 0004). Both of those import core/, not the other way round no-restricted-imports ✖ 1 problem (1 error, 0 warnings) exit=1Evidence: pnpm seed is guarded by the same installIsUnclaimed check as the route
$ pnpm seed # the install has already been claimed by the browser sign-up Error: This install has an Owner already, so there is nothing to seed. Drop the volume and start again to seed a fresh one. exit=1 --- reset the install, then seed a fabricated Owner --- $ pnpm seed Seeded Owner owner@example.com (86436185-a65f-4720-b210-d57584342475). email | is_owner -------------------+---------- owner@example.com | tEvidence: The database refuses a second Owner
$ psql -c "insert into account (auth_user_id, email, is_owner) values (gen_random_uuid(), 'second.owner@example.invalid', true)" ERROR: duplicate key value violates unique constraint "account_one_owner" DETAIL: Key (is_owner)=(t) already exists. $ psql -c "insert into account (..., 'member@example.invalid', false)" INSERT 0 1Evidence: HTTP API transcript: status, signup closed with 403, login, me, logout
$ curl -s $BASE/api/auth/status { "unclaimed": false } $ curl -s -X POST $BASE/api/auth/signup (install already claimed) { "statusCode": 403, "statusMessage": "This install has an Owner already. Ask them for an Invite." } HTTP 403 $ curl -s -c jar -X POST $BASE/api/auth/login (the seeded fabricated Owner) { "account": { "id": "86436185-...", "email": "owner@example.com", "isOwner": true } } HTTP 200 $ curl -s -b jar $BASE/api/auth/me { "account": { "id": "86436185-...", "email": "owner@example.com", "isOwner": true } } $ curl -s -b jar -X POST $BASE/api/auth/logout ; curl -s -b jar $BASE/api/auth/me { "ok": true } { "account": null }Evidence: Storage: filesystem driver round-trips, s3 switch refuses
$ STORAGE_DRIVER=filesystem npx tsx cf-storage-demo.mts STORAGE_DRIVER=filesystem selects driver "filesystem" put ok exists true get a fabricated Source $ find .data/storage -type f && cat .data/storage/sources/2026/fabricated.txt .data/storage/sources/2026/fabricated.txt a fabricated Source $ STORAGE_DRIVER=s3 npx tsx cf-storage-demo.mts STORAGE_DRIVER=s3 selects driver "s3" refused: The S3 storage driver is not implemented yet. Set STORAGE_DRIVER=filesystem, or send a patch.Evidence: Login repairs a half-finished sign-up and claims Owner
--- a half-finished sign-up: the GoTrue login exists but the account row does not --- logins 1 accounts 0 --- logging in repairs it, and claims the install because nobody holds Owner --- $ curl -s -X POST $BASE/api/auth/login { "account": { "id": "157b344d-...", "email": "owner@example.com", "isOwner": true } } HTTP 200 email | is_owner -------------------+---------- owner@example.com | tEvidence: Vitest verbose run (5 files, 34 tests)
Pipeline
Updates from git push no-mistakes
✅ **intent** - passed
✅ No issues found.
✅ **Rebase** - passed
✅ No issues found.
server/api/auth/signup.post.ts:17- isOwnerAlreadyTaken() readscodeandconstraintoff the error it is handed, but drizzle-orm 0.45.2 (pinned in pnpm-lock.yaml) wraps every failed query in DrizzleQueryError, which carries onlyquery,paramsandcauseand never copies the pg fields. Verified against the published drizzle-orm@0.45.2 tarball: pg-core/session.js throwsnew DrizzleQueryError(queryString, params, e)on every execute path, and errors.js shows the constructor sets nocode/constraint. So the guard is always false and the intended 403 is unreachable. Fix at the shared boundary rather than in this one route: add a helper in core/account.ts (the single owner of account writes) that unwraps the DrizzleQueryError cause chain and reports whether a given unique constraint was violated, then use it here and in login.post.ts:26.server/api/auth/login.post.ts:26- The login repair path calls createAccount() with no handling for a unique violation, so any concurrent repair surfaces as an unhandled 500. Concrete path A: one GoTrue login exists with no account row (a sign-up whose account write and whose rollback both failed); the person logs in from two tabs at once; both reach line 24 with no row, both insert, and the loser violates account_auth_user_id_unique (23505) and gets a 500 even though a retry would succeed. Concrete path B: two orphaned GoTrue logins on an install with no Owner log in at the same moment; both evaluate installHasOwner() as false, both insert with isOwner true, and the loser violates account_one_owner and gets a 500 instead of being created as a non-Owner. The data invariant holds (the partial index does its job), but the answer is wrong. Use the same core/account.ts unwrap helper proposed for signup.post.ts: on an auth_user_id conflict re-read and return the existing row, on an owner conflict retry the insert with isOwner false.seed/index.ts:40- createGoTrueUser() succeeds, then createAccount() throws (transient database error, or the account_one_owner index losing a race with a browser sign-up). Unlike signup.post.ts, seed does not delete the GoTrue login it just made, so the install is left with a login and no Account. The nextpnpm seedthen hits the 422 branch at line 34, whose message tells the operator to drop the volume - which contradicts the repair story the rest of the change documents, where logging in recreates the missing Account row. Either mirror the signup rollback here, or point the 422 message at the login repair path instead of at dropping the volume.🔧 Fix: Unwrap drizzle errors, handle login repair races, roll back seed
2 infos still open:
seed/index.ts:48- When the seed loses the Owner race it rethrows the raw DrizzleQueryError instead of the refusal it already has words for. Concrete path: the install is unclaimed, so line 29 passes; a browser sign-up claims it while the seed is still calling GoTrue; the insert at line 44 loses on the account_one_owner partial index. The data is correct, but the operator sees 'Failed query: insert into "account" (...) params: <uuid>,owner@example.com,true,...' rather than the message line 30 prints for the very same refusal. isOwnerAlreadyTaken() is exported from core/account.ts now, so the fix is one branch in the existing catch: if isOwnerAlreadyTaken(error), throw the same 'This install has an Owner already' Error line 30 uses.core/account.ts:37- installHasOwner() lost its only external caller when login.post.ts moved to findOrCreateAccountForLogin(). Grep over *.ts and *.vue shows the sole remaining use is core/account.ts:97, inside the same module. Dropping the export keeps the module's public surface equal to what callers actually reach for; no behavior changes.✅ **Test** - passed
✅ No issues found.
docker compose -f docker-compose.dev.yml up -dthendocker compose -f docker-compose.dev.yml ps- both services healthy, ports bound to loopbackdocker compose -f docker-compose.dev.yml config --services-> postgres, gotrue only;docker compose -f docker-compose.yml config --services-> all five (postgres, gotrue, web, worker, caddy)curl -s http://127.0.0.1:9999/health- GoTrue answeringpnpm db:migrateandpnpm db:check-authagainst the fresh volumeManual browser run ofpnpm dev(port 3300, 3000 was taken): claimed the install as a fabricated Owner, signed out, signed back in; screenshots at each steppnpm workerin one shell +pnpm queue:ping "a fabricated test job from the scaffold check"in another; job id matched on both sides andpgboss.jobshows state=completedpnpm lintwith a deliberatecore/deliberate-violation.tsimporting../worker/index- failed with the ADR 0004 no-restricted-imports error, exit 1; file removed afterwardspnpm test/pnpm vitest run --reporter=verbose- 5 files, 34 testsE2E_PORT=3300 pnpm test:e2e --reporter=list- 1 testpnpm seedagainst a claimed install (refused, exit 1) and against a reset install (created fabricated Owner owner@example.com)psql -c "insert into account (..., is_owner) values (..., true)"on a claimed install - rejected by unique indexaccount_one_owner; the same insert with is_owner=false succeededcurltranscript over the real API:/api/auth/status,/api/auth/signup(403 once claimed),/api/auth/login,/api/auth/me,/api/auth/logoutLogin repair path: deleted theaccountrow leaving the GoTrue login, thenPOST /api/auth/loginrecreated the Account and claimed OwnerStorage module driven through the publicstorage()selector in two processes:STORAGE_DRIVER=filesystemround-tripped bytes to.data/storage,STORAGE_DRIVER=s3refused with the not-implemented error✅ **Document** - passed
✅ No issues found.
✅ **Lint** - passed
✅ No issues found.
✅ **Push** - passed
✅ No issues found.