The app runs on Supabase: hosted Postgres, Auth (email/password + Google/Discord OAuth), Storage (avatars/files), and Realtime (campaign dice rolls). Test env stays on localhost Postgres and never touches Supabase. Two projects exist: prod (the production credentials section, used by the chapterhouse Fly app) and dev (the development section, used by local dev and the chapterhouse-dev Fly app).
One-time project setup (dashboard):
- Create a project; note the project ref, region, and database password.
- Disable the Data API (Project Settings -> Data API). App tables live in
publicwith RLS off; a live PostgREST endpoint would expose them. - Auth: enable the Email provider (confirmations off for the personal app), add Google + Discord OAuth apps (callback
https://<ref>.supabase.co/auth/v1/callback), set Site URL and fill the redirect allow-list per project — prod:https://chapterhouse.tools/dashboard+https://www.chapterhouse.tools/dashboard(+https://chapterhouse.fly.dev/dashboardfallback); dev:https://dev.chapterhouse.tools/dashboard+http://localhost:3000/dashboard. A host missing from its project's allow-list fails OAuth round-trips with redirect_uri-not-allowed. - Storage: create a private bucket
charkeeper; create S3 access keys (Project Settings -> Storage). - The SPA reads the project URL and anon key at runtime from meta tags the Rails layout renders out of the encrypted credentials — nothing to bake into the JS for the web app.
supabaseConfig.jsis only a fallback for hosts without the Rails layout: before building a Tauri webview bundle, fill it with the target project's URL and anon key (and never commit the filled values). - Add credentials via
bin/rails credentials:edit:
development:
supabase:
url: https://<ref>.supabase.co
anon_key: <anon key>
service_role_key: <service role key>
db:
host: aws-<n>-<region>.pooler.supabase.com
port: 5432
database: postgres
username: chapter.<ref> # dedicated role, not postgres — see below
password: "<db password>"
storage:
endpoint: https://<ref>.storage.supabase.co/storage/v1/s3
region: <region>
access_key_id: <s3 key id>
secret_access_key: <s3 secret>
bucket: charkeeper
production: # same shape, pointing at the prod projectThe app connects as a dedicated chapter role, not postgres. On a fresh project, create it in the SQL editor before the first schema load (it then owns every table the load creates). Keep it least-privilege: no CREATEDB/CREATEROLE (a CREATEROLE role is a privilege-escalation primitive) and no BYPASSRLS (app tables have RLS off anyway):
CREATE ROLE chapter LOGIN PASSWORD '<db password>';
GRANT USAGE, CREATE ON SCHEMA public TO chapter;
GRANT USAGE ON SCHEMA extensions TO chapter;Database rules:
- Always connect through the session pooler (port 5432, user
chapter.<ref>). Never the transaction pooler (6543): GoodJob needs LISTEN/NOTIFY andwith_advisory_lockneeds session advisory locks. The directdb.<ref>.supabase.cohost is IPv6-only. - First load is
bin/rails db:schema:loadthenbin/rails db:seed— neverdb:migratefrom zero (159 migrations include data backfills), and neverdb:create/db:drop/db:resetagainst Supabase. - The dev Fly app's release step migrates the dev DB under RAILS_ENV=production, which stamps
ar_internal_metadata.environment = production. A later localdb:schema:loadagainst the dev project then raises ProtectedEnvironmentError; clear it first withbin/rails db:environment:set RAILS_ENV=development. - After running migrations in development, review the
db/schema.rbdiff: a dump from the Supabase catalog can pick upextensions.*/pg_graphql/supabase_vaultlines that break localhost test schema loads. The gate specspec/config/supabase_migration_gates_spec.rbcatches this.
Known gaps: the Cypress e2e login step used the removed password form and was never rewritten against Supabase. Browser coverage now lives in the Playwright eval lane instead (see E2E tests); spec/e2e is dormant upstream code.
This fork supports D&D only: dnd5 (2014 rules) and dnd2024. The other
upstream CharKeeper systems (pathfinder2, daggerheart, dc20, fate,
fallout, cosmere, cthulhu7) were removed; the
RemoveNonDndSystemsData migration deletes their rows on databases
that predate the removal.
Player and DM guides for the TLC provider live in docs/user-guide/
(creating a TLC character, rule warnings, homebrew, admin content).
The Leyfarers/TLC implementation plan lives at
docs/leyfarers-implementation-plan.md with digests in docs/ and source
references in docs/reference/. The architecture map is
docs/codebase-map.md; deferred work is tracked in docs/TODOS.md.
Reference PDFs are NOT in git (GitHub blocks LFS uploads on public forks);
download the Players Guide from the
reference-docs release
into docs/ (gitignored there).
The Leyfarer's Chronicle (TLC) is a new provider type (Tlc::Character, Tlc::Feat, Tlc::Spell, Tlc::Item) extending dnd2024 as a subclass of the existing decorator (TlcDecorator < Dnd2024Decorator). Content queries follow two patterns: STRICT scope (.tlc() returns only Tlc:: rows; used for TLC-only filters) and UNION scope (.tlc_content() returns both Dnd2024:: and Tlc:: rows, minus the 8-spell banned list; used for character spell/feat/item options). TLC mechanics are code; TLC content lives in db/data/tlc/*.json and is seeded by the idempotent rake tlc:seed.
Adding TLC content (species, subclasses, feats, spells, items) follows a three-step workflow:
-
Extract and author: Run
bin/extract-tlc-content(lands with ticket #14 B3) to generate author-friendly JSON from the Players Guide, review the diff, and editdb/data/tlc/{species_traits,feats,spells,items}.jsonby hand. -
Review and merge: Before committing JSON edits, run
rake tlc:seedin the shell to verify the content parses and no malformed modifiers or banned-spell auto-grants are present. The tool prints counts per file type and a count of unverified rows (rows carryingverified: falsefor later content-gate review). Anupsert_allwith a partial unique index ensures re-running the seed is safe; rows never duplicate. -
Verify and mark: Content arrives from extraction marked
verified: false. Seed it, then mark each unverified row after checking the underlying Players Guide rules and interactions match the modifiers. To mark a row verified: visit the adminbook (HTTP Basic, see CLAUDE.md), click the row's slug in the Feats/Spells/Items index, scroll to the Info textarea, edit the JSON to set"verified": true, and submit. Alternatively, query the console to find unverified rows:Tlc::Feat.where("info->>'verified' = ?", 'false')(orTlc::Spell/Tlc::Itemfor other types). Once all rows in a file are markedverified: true, the extraction phase for that file is done.
Modifier syntax errors (malformed Dentaku formulas, missing variables) surface at runtime when a character sheet evaluates the modifiers, not during rake tlc:seed. To triage:
- Reproduce the error on a character sheet using the affected trait/feat/spell/item.
- Note the exact error message and identify the row's
slugfield from the context (or narrow it down by reproduction). - Open
db/data/tlc/{type}.json, find the row by slug, and inspect themodifiersoreval_variablesJSONB fields for syntax errors or typos. - Fix the JSON and re-run
rake tlc:seedto reload the corrected content into the database (re-runs are safe due to the partial unique index). - Commit the fix.
Cache caveat: app/lib/platform_config.rb caches the TLC config (app/javascript/applications/CharKeeperApp/data/tlc.json) under a cache key derived from the config files' own contents (PlatformConfig::CONFIG_VERSION, a SHA of every data/*.json) for 3 days. Editing tlc.json changes the key, so a deploy picks the new config up even though production's redis cache survives deploys. The digest is computed at boot: in development, restart the server (or Rails.cache.clear) after editing tlc.json.
$ npm testnode --test over spec/javascript/*.test.js. Deterministic, local, free, no network, ~2s on a warm checkout: this is the lane that has to stay green on every commit. yarn install is the only setup; nothing is installed by hand at run time.
Both harnesses register the same module hooks (spec/javascript/support/jsxHooks.js), which compile the SPA's .jsx with the babel preset esbuild.config.js uses and redirect the pages/components/context/helpers barrels to support/stubs.js — the real barrels drag in the whole app, including the gitignored supabaseConfig.js. A test file imports exactly one harness:
- SSR (
support/jsxLoader.js) — compiles in SSR mode and renders throughrenderToString.stubs.js's field components record the props they are handed instead of drawing, which is enough to render a creation form and drive it the way a player does (tlcForm.test.js: species list, size default, the payload Save submits, the post-save reset, and no blank label against the realfetchDictionary, which is English-only and serves theendictionary even to a browser still holding a stale persisted locale).warningsBanner.test.jsmounts the realWarningsBannerthis way — direct render/dismiss cases plus a gate that the 2024 character sheet actually mounts it. - jsdom (
support/domHarness.js) — jsdom supplies the document and the hooks resolvesolid-jsto its client build (thebrowserexport condition, the same one esbuild picks), so components mount as real nodes andcreateEffectruns. That is the difference that matters: state arriving from afetchinside an effect is invisible to the SSR harness, because SSR makes effects a no-op. The lane still never touches the network — the request layer resolves tostubs.js, anddomHarness.jsmakes a realfetchthrow.
tlcRouting.test.js uses the jsdom harness to gate the two TLC surfaces that had no automated check at all:
pages/Navigation/CharactersTab.jsx— the platform picker routingtlctoTlcCharacterForm. Only evaluates after the characters fetch resolves.pages/Content/CharacterTab.jsx— theprovider === 'tlc'<Match>opening the interim Dnd5 sheet.character()is empty until its fetch resolves.
Delete either branch and npm test fails naming that file. Companion cases keep the dnd5/dnd2024 destinations distinct, so a branch that matched everything would not pass either.
The loader needs module.registerHooks and jsdom 30 requires ^22.22.2 || ^24.15.0 || >=26, hence .node-version 22.22.2.
What this lane still cannot reach. Layout and CSS (jsdom computes no geometry, so anything keyed off a measured size is a guess), Supabase auth, the Rails side of any request, and the built bundle as a browser actually runs it. Those belong to the eval lane below.
$ npx playwright install chromium # one-time, ~115MB, not needed for `npm test`
$ npm run eval:e2ePlaywright against the deployed dev instance (https://dev.chapterhouse.tools; override with QA_BASE_URL). spec/playwright/tlcSurfaces.spec.js signs in and walks the same two TLC surfaces the gate lane covers, but through the shipped bundle, the real API and real CSS: the platform picker opening the TLC creation form, and a TLC character opening its sheet. The second spec creates a throwaway character, opens it, and deletes it again in a finally, so the eval needs no seeded fixture. A leftover Eval Leyfarer <timestamp> row on the QA account means a cleanup failed.
This is an eval, not a gate. The house rule is two lanes with different budgets: gate tests are deterministic, local, free, under two seconds, and run on every commit; periodic evals are networked and slow, and run before ship and on a schedule. This suite needs a live deployment, a real Supabase login and a browser download, and the dev machine auto-stops to zero, so a cold run pays a start-up. Putting it in a pre-commit hook would make every commit depend on Fly's uptime. Run it before shipping anything that touches character creation or the sheet, and after any deploy to dev.
It also asserts on what is deployed, which lags main. A red eval can mean "dev has not been redeployed yet" rather than "the code broke"; check the deployed version before chasing a failure.
Credentials. QA_EMAIL and QA_PASSWORD come from .env.qa.local at the repo root, loaded by playwright.config.js through node's own process.loadEnvFile (no dotenv dependency). That file is gitignored and stays that way: this repo is public. A run with either missing fails immediately with a message naming the file. Failure traces land in test-results/ and capture the logged-in session, credentials typed into the login form included, so that directory is gitignored too. Never attach one to an issue.
Cypress (spec/e2e) is upstream's and is wired to nothing here: it appears in no package.json, its documented flow was a manual install-run-uninstall, and its one spec targets a Rails test server on port 5002 that this fork never starts. Kept in place only to keep subrepo merges with kortirso clean. It is not coverage.
There is none: no .github/ directory, so every check above runs when a human runs it. Recommendation, deliberately not implemented in #69 because workflow files are deploy-adjacent infrastructure:
- Add one GitHub Actions workflow running
npm teston push and pull request. It needs no secrets, no services and no browser, and it now covers the TLC routing branches. It is the cheapest real gate available. - Add
bundle exec rspecto the same workflow behind a Postgres service container and aRAILS_MASTER_KEYsecret. - Leave the Playwright eval out of pull-request CI. It needs the QA login and a live deployment; a nightly or manually dispatched job is the right home for it.