Offline local development and acceptance

npm run qa:local runs the whole platform on your machine, offline, with synthetic seats and data, so a person or an agent with a browser can sign in as any role and walk every app without Auth0, a tenant, or production data.


The one command

From the repository root:

npm run qa:local                      # start, or report the stack that is already running
npm run qa:local -- --status          # print URLs, health and sign-in links
npm run qa:local -- --fresh           # stop, wipe and start again on freshly seeded data
npm run qa:local -- --stop            # stop every process of the stack
OptionDefaultEffect
--port-base N4310Main on N, Spell review on N+1, Allocation risk on N+2, identity provider on N+9. Also read from QA_LOCAL_PORT_BASE
--volume N60Number of synthetic #83 volume processes; 0 seeds none (0–5000)
--skip-buildoffSkip scripts/build-packages.mjs when the workspace packages are already built

The stack runs on the Node major that package.json pins (engines, Node 22). Started from another major, qa:local looks for a Node 22 install (Homebrew node@22, nvm, Volta, fnm) and runs every process on it; QA_LOCAL_NODE=/path/to/node chooses one explicitly. Node 26 cannot compile Main's pages under next dev, because it loads applications/main/tailwind.config.ts as ESM.

What it starts, in order, all on 127.0.0.1 (ports for the default base):

ProcessPortWhat it is
identity4319Loopback OAuth server with a seat picker (applications/main/src/local/identity-provider.ts)
main4310next dev for Main: backend API, generic frontend and operator console
workernoneThe step worker, one, on the same store and policy as Main
spell4311next dev for the Spell review frontend, proxying to Main
risk4312next dev for the Allocation risk frontend, proxying to Main

Before the web hosts start it builds the packages, writes the local access policy, directory and seats into .qa-local/run, starts the identity provider, runs the release step (the template publication npm run release runs) and seeds synthetic processes. The first start takes about 1.5 minutes, most of it next dev compiling. When everything is healthy it prints the seat picker URL and a direct sign-in link for every seat.

A start that is not reusing a healthy stack always wipes .qa-local/run and .qa-local/logs first. If any stage fails, every process already started is stopped and the error names the log to read.

PathContents
.qa-local/run/File store, uploaded files, access.json, directory.json, seats.json, the provider's signing key
.qa-local/logs/One <service>.log per stage and process: build-packages, prepare, identity, release, seed, main, worker, spell, risk
.qa-local/screenshots/Acceptance screenshots, from the checklists and npm run test:acceptance
.qa-local/state.jsonPorts, URLs and process ids of the running stack

.qa-local/ is gitignored.


Signing in

The seat picker lists every seat with one button per app; ★ marks the seat's home app. A direct link signs one seat into one app through the normal sign-in round trip:

http://127.0.0.1:4319/                                          seat picker
http://127.0.0.1:4319/sign-in?seat=<seat>&app=<main|spell|risk>[&returnUrl=/path]

For example, the EPL into Spell review: http://127.0.0.1:4319/sign-in?seat=spell-pm&app=spell. returnUrl must be a path on the target app. To switch seats, open another direct link: the chosen seat overrides the one the provider remembers.

The link goes to the app's login route, which redirects to the provider's authorize endpoint, which returns a code to the app's callback; the backend exchanges the code at the provider's token endpoint and verifies the token against the provider's JWKS. These are the endpoints the backend calls on Auth0, at the same paths:

http://127.0.0.1:4319/authorize
http://127.0.0.1:4319/oauth/token
http://127.0.0.1:4319/.well-known/jwks.json
http://127.0.0.1:4319/directory.json          stands in for the Auth0 Management API

Every hop is the production code path; only the person is chosen locally.

SeatPersonOrganizationSpell reviewAllocation riskMainHome
prime-memberPat PrimeGroveprime-memberprime—spell
prime-member-sparkSam SparkSparkprime-member——spell
sky-core-userCasey CoreSky Coresky-core-user——spell
soter-userSol SoterSoter Labssoter-user——spell
spell-pmErin EPLSoter Labsspell-pm——spell
spell-risk-reviewerBea LabsBA Labsspell-risk-reviewer——spell
spell-tech-coordinatorTess TechSoter Labsspell-tech-coordinator——spell
govops-pmGil GovOpsSoter Labs—govops-pm—risk
head-of-riskHal RiskBA Labs—head-of-risk—risk
sff-boardFern BoardSky Core—sff-board (read-only)—risk
soter-adminAda AdminSoter Labssoter-usergovops-pmoperator, plus reader on each volume appmain

soter-admin is also the platform operator: templates:read, database:read, database:write and user:impersonate. Every seat's email is <seat>@local.example.test and its user id is local|<seat>. Seats are defined in applications/main/src/local/seats.ts; role permissions come from the local deployment access policy written there, not from the token.


What is seeded

Every process is started and advanced through the normal application service, each step by the seat that owns it, so ownership, audit and permissions are what the running app would have produced. Source: applications/main/src/local/seed.ts; the result is in .qa-local/logs/seed.log.

Spell review, all for the second offered vote date (the cadence rule's, so a day rolling over cannot drop it):

ItemFiled byParked atActs next
Grove draft: raise the Grove ALM lineprime-members01_deal_registration (unsubmitted draft)prime-member
Grove: onboard synthetic RWA vaultprime-members05_validate_spell_item_requestspell-pm
Grove: adjust synthetic PSM parametersprime-members07a_pre_risk_review_writespell-risk-reviewer
Spark: rebalance synthetic allocationprime-member-sparks06_triage_and_prioritizespell-pm
Sky Core: rotate synthetic oraclesky-core-users05_validate_spell_item_requestspell-pm
Grove: synthetic cBEAM rate changeprime-members08_prepare_strategic_reviewspell-pm or spell-tech-coordinator
Spark: synthetic strategic decisionprime-member-sparks09_strategic_decisionspell-pm
Grove: synthetic technical scopeprime-members12_independent_technical_reviewspell-tech-coordinator

Allocation risk, all filed by prime-member for Grove:

DealParked at
Grove synthetic facility As01_deal_registration
Grove synthetic facility Bs02_nda_check
Grove synthetic facility Cs02_nda_check

Main (operations): one run, started by soter-admin, of every repository template whose first step waits for a person (the NFAT, onboarding, agent and Solana bridge templates). Templates whose first step is automated are not started.

Volume: the #83 list-performance seed sized for a laptop: 60 processes across three applications, volume-a (30), volume-b (18) and volume-c (12), at /apps/volume-a and so on in Main. --volume changes the total.


How it stays offline and safe

  • Loopback-only network. Every process runs with scripts/qa-local/loopback-only.cjs preloaded. A socket connection to anything but 127.0.0.1, localhost, ::1 or a local socket throws before a packet leaves the machine. This is a regression guard, not a sandbox for hostile code. The package build stage runs without it.
  • No developer credentials. Child processes get only PATH, HOME, TMPDIR, TERM, LANG, USER and SHELL from your shell. Every key defined in a .env* file (except .env.example) at the root, applications/main, applications/spell-review or applications/allocation-risk is set to the empty string, which Next and dotenv never override.
  • Local storage. STORAGE_DRIVER=file and MONGO_URL empty; data and uploads live under .qa-local/run.
  • Real tokens. The provider signs RS256 tokens with a key generated on this machine (.qa-local/run/identity-key.json, mode 600). The backend verifies signature, issuer, audience and organization through the normal path. The issuer is https://local.processos.invalid/: the reserved .invalid domain can never be a real tenant.
  • No Management API. Organization and identity questions are answered from the provider's synthetic directory (directory.json on the provider). Invitations are refused.

The backend accepts local identity only when PROCESS_LOCAL_IDENTITY_URL is set and every rule below holds (platform/backend/src/auth/local-identity.ts). Otherwise it throws LocalIdentityRefused; it never falls back to Auth0.

  1. PROCESS_LOCAL_IDENTITY_URL is a plain http loopback origin: host 127.0.0.1, localhost or ::1, no credentials, no path, query or fragment.
  2. No non-empty RAILWAY_* variable is present (RAILWAY_ENVIRONMENT, RAILWAY_PROJECT_ID and the other Railway names are also checked by name).
  3. None of VERCEL, VERCEL_ENV, FLY_APP_NAME, K_SERVICE, RENDER, DYNO, KUBERNETES_SERVICE_HOST, ECS_CONTAINER_METADATA_URI, ECS_CONTAINER_METADATA_URI_V4, AWS_LAMBDA_FUNCTION_NAME, GAE_SERVICE or WEBSITE_SITE_NAME is set.
  4. AUTH0_DOMAIN ends in .invalid.
  5. AUTH0_MGMT_CLIENT_ID and AUTH0_MGMT_CLIENT_SECRET are unset or empty.
  6. APP_BASE_URL, NEXT_PUBLIC_APP_BASE_URL and AUTH0_JWKS_URL, when set, are loopback URLs.
  7. Every origin in PROCESS_AUTH_MOUNTS and CORS_ALLOWED_ORIGINS, when set, is loopback.
  8. MONGO_URL, when set, is mongodb:// (not mongodb+srv://) with only loopback hosts.

The check runs at server startup (Next instrumentation), at host composition (web and worker), on every use (authorize, token exchange, key source, directory), and before the identity and seed stages of applications/main/scripts/local-acceptance.ts.


Agent acceptance checklists

One per app, each a numbered walk with the seat, the expected result, a screenshot name and what counts as a failure. An agent with a browser (Claude in Chrome or Playwright) follows them against a running stack.

Screenshots go to .qa-local/screenshots/<app>-<nn>-<name>.png.


Playwright smoke journeys

npm run test:acceptance

Runs applications/main/tests/acceptance/*.spec.ts with the acceptance Playwright configuration (playwright.acceptance.config.ts in applications/main) against a stack it first restarts with npm run qa:local -- --fresh, because the journeys change the seed. With a stack already running, node_modules/.bin/playwright test -c applications/main/playwright.acceptance.config.ts runs them without the restart. Its screenshots are named acceptance-<app>-<nn>-<name>.png, next to the checklists' <app>-<nn>-<name>.png. Screenshots land in .qa-local/screenshots/. It is not part of npm run verify; see verification.


Replacing the dev-login branch

The previous local workflow was the feat/dev-local-auth branch (PRs #50 and #35): a dev-login route taking ?role=…, run as two terminals (npm run dev and npm run job:step) because npm run dev:all broke it. Its failure modes, and why they do not apply here:

Old failureCauseNow
401 at middleware under dev:allThe JWKS URL was set at runtime inside a route handler; the middleware never saw itThe provider starts before the web host and its URL is in the environment at startup
Dev tokens rejected after the first requestnext dev cached the Auth0 JWKS URL from the first requestNo Auth0 URL is ever configured; the key source is the provider from the first request
Real Auth0 token exchange failed locallySocket Firewall's NODE_EXTRA_CA_CERTSNo Auth0 call and no TLS; the variable is not passed to the stack

The branch is not needed: sign in with a seat link instead of a role query.


Troubleshooting

SymptomCauseFix
port 4310 (main) is in useAnother server, or another worktree's stack, holds the portnpm run qa:local -- --port-base 4410; links and ports shift with it
<service> did not become healthy or <service> exitedThe named service failedRead .qa-local/logs/<service>.log
Local identity is refused: …One of the guard rules above failed; the message names itRun through npm run qa:local, which sets a permitted environment
seed failsA seeded step was refused.qa-local/logs/seed.log names the item and step
Data is in a state you did not expectEarlier walks changed itnpm run qa:local -- --fresh
Sign-in loops or a session is rejected after --freshThe signing key was regeneratedOpen the seat's direct link again
Odd compile errors or missing pages after switching branchStale next dev output--stop, delete .next in applications/main, applications/spell-review and applications/allocation-risk, start again
A run sits on an automated stepThe worker died.qa-local/logs/worker.log; --status shows it DOWN

Known gaps

  • The web hosts run next dev: the first load of each page compiles it, which can take several seconds per page.
  • Storage is the file store, not Mongo. A local Mongo must be a single-node replica set for template publication; see MongoDB replica set.
  • Spell items are seeded for the second offered vote date, so the current cycle view can be empty: pick the cycle that has items, or All cycles.
  • Allocation risk steps that need a file upload (from s02_nda_check on) are left for the walk.
  • Sessions do not survive --fresh: the run directory, including the signing key, is wiped.
  • The loopback guard covers Node socket connections only; it is not a sandbox.
  • The web hosts compile into each project's usual .next directory, so qa:local must not run at the same time as npm run dev, npm run build or npm run verify in the same checkout. Use a second worktree for either.
  • Keys from your .env files are set to the empty string, not unset, so code that defaults with ?? sees an empty value.