A partner outreach tracker. One screen listing every organization you're in contact with, sorted by the soonest action due, with a log of what actually happened. One Node process, one SQLite file, a handful of users.
Is: a shared worklist. Threads move through a pipeline; every move is recorded with who, when, and why.
Is not: multi-tenant, horizontally scaled, real-time, internationalized, email-sending, role-differentiated, or an API for third-party clients. Every one of those absences removes machinery.
- Requirements
- Getting started (development) — Keycloak client,
.env, run it - Development vs production — what actually differs
- Running — the commands, per mode
- Docker — the short path:
docker compose up -d - Production install — the numbered checklist; do these in order
- Configuration — every variable and its default
- The data model · The database · Backups
- Tests · API · Security · Data · License
Node 20+ and pnpm (corepack enable). One package.json, at the repository
root — there is no second manifest in server/, so every pnpm command in
this file is run from the root and covers the API and the frontend together.
Installing on a server? This section is the laptop path. The Keycloak client
and .env below apply either way, but for a real deployment follow
Production install from step 1 rather
than running pnpm dev.
JumpSeat does not hold passwords. Authentication is Keycloak's, and you need a realm and a client before the server will start.
1. In Keycloak, create a client in the realm holding your accounts:
| Setting | Value |
|---|---|
| Client authentication | On — this is what produces the secret |
| Direct access grants | On — JumpSeat exchanges the password itself |
| Standard flow | Off — JumpSeat never redirects to a Keycloak page |
| Service accounts | Off |
| Client scopes | include email and profile |
| Root URL · Home URL · Valid redirect URIs | leave empty — none are needed |
Nothing on the Keycloak side needs a JumpSeat URL. Standard flow is off, so no
browser is ever redirected to Keycloak and there is nothing to come back to;
JumpSeat calls two endpoints server-to-server (…/protocol/openid-connect/token
and …/certs) and that is the whole conversation. Home URL, if you set one, is
only a link in Keycloak's own account console.
profile is not optional: without it the token carries no preferred_username,
and sign-in fails with a 502 rather than a login error.
2. In .env — copy .env.template and fill in five values:
JUMPSEAT_KEYCLOAK_URL=https://id.example.org # no trailing slash, no /auth
JUMPSEAT_KEYCLOAK_REALM=jumpseat
JUMPSEAT_KEYCLOAK_CLIENT_ID=jumpseat
JUMPSEAT_KEYCLOAK_CLIENT_SECRET=... # from Credentials on that client
JUMPSEAT_ALLOWED_USERS=alice,bob # who may use THIS applicationAll five are required. The server exits naming the one that is missing rather than failing later on someone's first sign-in.
Leave JUMPSEAT_SESSION_SECRET out. It is generated for you — see
Configuration.
3. Run it — from the repository root, not from server/:
pnpm install --frozen-lockfile
pnpm db:init
pnpm devsqlite3 compiles a native addon during install. pnpm 10 blocks postinstall
scripts by default and package.json allowlists this one; if you see it skipped
anyway, fix that before going on — the symptom otherwise is a missing-module
error at first boot rather than an install failure.
Open http://localhost:5173 and sign in with a Keycloak username and password.
There is no setup screen and no first account to create: JUMPSEAT_ALLOWED_USERS
already says who may sign in, and a row is created for each of those names at
boot.
Two gates, in this order. The allowlist is checked first, in-process — a username that is not on it is refused before the password is sent anywhere, so JumpSeat cannot be used to grind credentials against your realm. Keycloak then says whether the password is right.
Adding or removing someone is an edit to JUMPSEAT_ALLOWED_USERS and a restart.
Removing a name blocks the next sign-in and stops them being offered as an
interaction attendee; their row and history are kept. There are no roles:
everyone who can sign in can do everything.
To use an existing database, copy it to data/outreach.sqlite before starting.
More differs between them than the command you type:
| Development | Production | |
|---|---|---|
| Processes | Two — API on :3000, Vite on :5173 |
One, serving the API and the UI on one port |
| Frontend | Vite serves src/, with hot reload |
Prebuilt dist/, served by Node |
pnpm build |
not needed | required — without dist/ every page is a 503 saying so |
/api |
Vite proxies it to the API | same origin, no proxy |
NODE_ENV |
unset | production — set by pnpm start and ecosystem.config.cjs, never from .env |
COOKIE_SECURE / TRUST_PROXY |
both 0 |
both set together, once TLS works |
| Database | ./data/outreach.sqlite |
wherever JUMPSEAT_DB_PATH points, owned by the service user |
NODE_ENV=production is what makes the server serve dist/; it is not what
builds it. pnpm build builds for production whatever NODE_ENV says.
The commands, per mode. For a first deployment use Production install instead — the order of the last two steps matters.
pnpm dev # development, two processes
pnpm build && pnpm start # production, one process
pnpm build && pm2 start ecosystem.config.cjs && pm2 save # under pm2
docker compose up -d # Docker; ./data is bind-mounted, no database in the imageBehind apache2: deploy/apache2/jumpseat.conf.example.
The shortest path to a running instance, and the one that skips most of the checklist below — no Node version to match, no pm2, no build step to remember.
cp .env.template .env # fill in the five Keycloak/allowlist values
mkdir -p data && sudo chown 1000:1000 data
docker compose up -d
curl -s localhost:3000/api/healthThat chown is not optional. The container runs as uid 1000, and the first
thing it does is create a SQLite database in ./data. A directory owned by your
own user fails with SQLITE_CANTOPEN; the server now says so in as many words,
but it is easier to avoid than to read about.
Three things worth knowing before you put it in front of anyone:
- The published port is bound to
127.0.0.1. Docker publishes ports by writing its own iptables chains, which bypass your INPUT rules entirely — a port published on0.0.0.0is on the internet whatever your firewall says. Loopback is the safe default and is what a reverse proxy on the same host needs. Reaching it from another machine is a deliberate edit to theports:line, not an accident. VITE_API_URLis compiled into the bundle at build time, so it comes from theargs:block, not from.env. Changing it meansdocker compose build, notrestart. The default — same origin — is what you want behind a proxy.- Behind TLS, set
JUMPSEAT_COOKIE_SECURE=1andJUMPSEAT_TRUST_PROXYtogether in.env, exactly as in the checklist below. Setting the first alone is refused at boot.
The database is never in the image. ./data is a bind mount, so the .sqlite
file stays an ordinary file you can copy, back up, or hand to pnpm db:import.
Behind a reverse proxy on a server, working through in this sequence. Steps 1–6 bring it up over plain HTTP on the loopback interface; only then do you turn on the two variables that depend on TLS actually working.
-
1. Install and build, from the repository root.
pnpm install --frozen-lockfile && pnpm buildVITE_API_URLis compiled into the bundle here, not read at runtime. The default (/api, same origin) is what you want behind a proxy; changing it later is a rebuild, not a restart. -
2. Write
.env— the five identity values, plusJUMPSEAT_API_PORTand any paths you are relocating (JUMPSEAT_DB_PATH,JUMPSEAT_BACKUP_DIR). LeaveJUMPSEAT_SESSION_SECRETout. -
3. Start with the TLS-dependent pair OFF:
JUMPSEAT_COOKIE_SECURE=0andJUMPSEAT_TRUST_PROXY=0. -
4. Leave
JUMPSEAT_BIND_HOSTat127.0.0.1. It is the bind address, not a firewall rule — with the default, the port does not answer from another machine however open the firewall is, which is the point when a proxy fronts the app. Set0.0.0.0only to test a direct connection, and put it back. -
5. Create the database as the user the service will run as.
pnpm db:init. SQLite in WAL mode writes-waland-shmfiles beside the database, so that user needs write permission on the directory, not only the file. A database initialised as root and served as someone else fails at first sign-in with a readonly error. -
6. Start it and check the boot banner.
pm2 start ecosystem.config.cjs && pm2 save, thencurl -s localhost:$JUMPSEAT_API_PORT/api/health→{"ok":true,…}. The banner prints the realm, the allowlist, and every roster row created, reactivated or deactivated at boot. If a colleague is missing fromAllowed:, fix.envnow — not after the proxy is in the way. -
7. Load the vhost and sign in over HTTPS. Working? Then
ProxyPreserveHostand the certificate are right. -
8. Now set
JUMPSEAT_COOKIE_SECURE=1andJUMPSEAT_TRUST_PROXYtogether, and restart. The banner should print no ⚠ lines at all. Sign in once more to confirm — the cookie is named__Host-jumpseat.sidwhen it works.`TRUST_PROXY` is a **hop count**, not a boolean: `1` for a reverse proxy on this host, **`2` when a CDN such as Cloudflare sits in front of that**. Too low and every rate-limit bucket and logged IP is the proxy's rather than the visitor's — count the entries in `X-Forwarded-For` if unsure.
Why those two are one step. They look like hardening you could do independently, and they are not:
- A
Securecookie is only set on a connection the server believes is HTTPS. Behind a proxy the server sees plain HTTP unlesstrust proxyis on and the proxy sendsX-Forwarded-Proto: https(the example vhost sets it — keep that line).COOKIE_SECURE=1withTRUST_PROXY=0means no cookie is ever stored, and the symptom is a login form that does nothing, with no error anywhere. - The login rate limiter buckets by client IP. With
trust proxyoff, every request appears to come from the proxy — one shared bucket, so ten bad passwords from one person locks out everyone.
As of 0.5.0 neither of those is silent any more. COOKIE_SECURE=1 with
TRUST_PROXY=0 refuses to boot in production, naming both variables; and a
sign-in that arrives over what the server considers plain HTTP returns a 500
with code: INSECURE_COOKIE and a log line saying what to change, rather than a
cheerful 200 that sets no cookie. If sign-in fails at step 8, read the log — it
tells you which of the two it is.
The five identity variables above are required; everything else has a working
default. Every variable is resolved in server/config.js, which is the only
module that reads the environment, and all but JUMPSEAT_SESSION_SECRET are
documented in .env.template — that one is deliberately
absent, because it is generated on first boot and belongs in a template only for
someone who manages keys externally. Unknown variables are ignored. NODE_ENV
comes from the runtime (npm script, Dockerfile, compose, pm2), not from .env.
| Variable | Default |
|---|---|
JUMPSEAT_KEYCLOAK_URL |
required |
JUMPSEAT_KEYCLOAK_REALM |
required |
JUMPSEAT_KEYCLOAK_CLIENT_ID |
required |
JUMPSEAT_KEYCLOAK_CLIENT_SECRET |
required |
JUMPSEAT_ALLOWED_USERS |
required — empty fails closed |
JUMPSEAT_KEYCLOAK_TIMEOUT_MS |
5000 |
JUMPSEAT_API_PORT |
3000 |
JUMPSEAT_WEB_PORT |
5173 — the Vite dev server, for the dev banner |
JUMPSEAT_BIND_HOST |
127.0.0.1 — the bind address, not a firewall rule; containers need 0.0.0.0 |
JUMPSEAT_TRUST_PROXY |
0 — a hop count: 1 behind a reverse proxy, 2 with a CDN in front of it. Set together with JUMPSEAT_COOKIE_SECURE, see above |
JUMPSEAT_DB_PATH |
./data/outreach.sqlite |
JUMPSEAT_BACKUP_DIR |
./data/backups |
JUMPSEAT_SESSION_SECRET |
leave unset — generated on first boot, see below |
JUMPSEAT_SESSION_IDLE_HOURS |
8 |
JUMPSEAT_SESSION_ABSOLUTE_HOURS |
168 |
JUMPSEAT_SESSION_SWEEP_MINUTES |
60 — housekeeping only; expiry is enforced on every request |
JUMPSEAT_LOGIN_RATE_MAX |
10 |
JUMPSEAT_RATE_WINDOW_MIN |
15 |
JUMPSEAT_COOKIE_SECURE |
0 |
JUMPSEAT_BODY_LIMIT |
256kb |
JUMPSEAT_STATIC_MAX_AGE |
1h |
VITE_API_URL |
/api — compiled into the bundle at pnpm build, not read at runtime |
JUMPSEAT_SESSION_SECRET: set nothing. This is the key that signs session
cookies, and there is nothing for you to generate or paste. On first boot the
server creates 32 random bytes, stores them in the app_state table inside your
SQLite file, and reuses them on every boot after that. Supply your own only if
you hold keys externally or run more than one instance against one database.
Two consequences worth knowing: changing it signs everyone out, which is the
intended behaviour of rotating a key; and deleting the database signs everyone
out too, because the key lived in it.
JUMPSEAT_BACKUP_DIR defaults to a backups/ directory beside the
database, so relocating JUMPSEAT_DB_PATH relocates snapshots with it unless
you set this explicitly.
One SQLite file. Sixteen tables, and the shape is the point:
partner_orgs·partner_contacts·partner_outreach— who they are, who you know there, and the thread you have going.partner_outreach_history— every status change with a note.notesisNOT NULL: a thread cannot move without a reason.interactionand its three join tables — a thing that happened or is going to. It links to as many threads as it touched, because one convening is one event and three conversations.interaction_contactis who was there from their side,interaction_userfrom yours; neither is the same question asinteraction.created_by, which is only who typed it in.- Five lookup tables — statuses, channels, organization types, relationship types, interaction kinds. These are the application's entire vocabulary, and none of it is written in the code: add a status in Admin and it appears immediately as a filter pill, a badge, and an option in every form.
users·sessions·app_state— the account mirror, server-side sessions, and the generated signing secret.usersholds no credential: rows come fromJUMPSEAT_ALLOWED_USERSat boot and from a verified sign-in, and are never deleted, becauseinteraction_userreferences them andownernames people by username.is_activemirrors allowlist membership.
Two axes that look like one and are not: org_type is what kind of organization
they are, relationship_type is what you want from them. A labor department
approached for expertise and one approached for funding are the same kind of
organization and completely different work.
owner and changed_by are TEXT usernames rather than foreign keys, on purpose:
an audit column should record the value that was true when the action happened,
not a pointer that follows a later rename.
schema.sql is the only definition. Nothing has shipped, so there is no
installed base and no upgrade path: change the schema, delete the .sqlite,
and run pnpm db:init.
pnpm db:backupUses VACUUM INTO: safe against a live database, and it will not overwrite an
existing snapshot. Also available as POST /api/admin/outreach/backup.
pnpm test # 6 suites, 201 tests
pnpm lintBoth run on every push, on Node 20 and 22 — see
.github/workflows/ci.yml. Neither needs a service,
a container, a network, or a secret.
The database is real; the realm is not. Every suite runs against an actual
SQLite file in a temp directory, seeded with a known dataset and thrown away
after — the SQL is most of what is worth testing and a mock cannot fail a
FOREIGN KEY. Keycloak is the one thing stubbed, because the alternative is a
suite that cannot run without a realm to sign in to. test/stubRealm.js answers
the same contract server/keycloak.js does and raises the same KeycloakError
class, and test/harness.js injects it into every app it builds, so no test can
reach the network by forgetting to.
Nothing in the suite is a fixed credential. Passwords, the client secret, the
session secret and the realm name are generated per run with
crypto.randomBytes, so no literal secret exists in this repository and no test
can come to depend on one. The stub realm's URL is generated under the reserved
.test TLD, so a stub that ever leaked into a real fetch would fail to
resolve rather than reach something.
| Suite | What it covers |
|---|---|
auth.test.js |
The token exchange through an injected fetch — which realm answers mean "wrong password", which mean "realm is down", and which mean "this token is unusable". Then the route: the allowlist gate, the second check against the name the realm resolves to, sessions, and the rate limiter |
config.test.js |
Every required variable missing and blank, the URL shape rules, the allowlist rules, and the endpoints derived from them |
users.test.js |
The read-only roster, session revoke, reconcileAllowlist (insert, reactivate, deactivate, never delete), and the sign-in mirror — last_login_at, and an email claim that is absent, malformed, or changed |
outreach.test.js interaction.test.js reference.test.js |
The tracker, the event log, and the lookup tables |
Two gates, tested separately, because they fail differently and a caller is
told different things: a username off the allowlist is a 403 refused
in-process, a bad password is a 401 that came back from the realm, and a realm
that cannot be reached is a 503 — never a 401, which would read as "wrong
password" and send someone to reset a password that was fine.
Known gap: keycloak.verify() — the id_token signature and claim checks —
is not covered. It resolves its key set through jose's own fetch, which
createKeycloak accepts no override for, so testing it would mean a network
call or an HTTP server in the suite; a mocked jose would test the mock. Worth a
test the day that function takes an injectable key set.
GET /api/health and POST /api/auth/login are unauthenticated. Everything
else requires a session.
| Method | Path |
|---|---|
GET |
/api/health |
POST |
/api/auth/login · /api/auth/logout |
GET |
/api/auth/me |
GET |
/api/admin/users — read-only roster |
POST |
/api/admin/users/:id/sessions/revoke |
GET |
/api/admin/outreach — ?status= ?owner= ?relationship_type= |
GET |
/api/admin/outreach/meta · /stats · /:id |
PUT |
/api/admin/outreach/:id · /:id/status · /contacts/:id/verify |
POST |
/api/admin/outreach · /api/admin/outreach/backup |
GET |
/api/admin/interactions — ?kind= ?state= ?orgId= ?outreachId= ?userId= ?from= ?to= ?limit= ?offset= |
GET |
/api/admin/interactions/options · /:id |
POST PUT DELETE |
/api/admin/interactions · /:id |
GET |
/api/admin/reference · /:table |
POST PUT DELETE |
/api/admin/reference/:table · /:table/:name |
No password ever reaches storage here. It is forwarded once, over TLS, to the Keycloak token endpoint and discarded. There is no hash column and no password-change endpoint.
The id_token that comes back is verified before a claim is read: signature
against the realm JWKS (refetched on an unknown kid, so key rotation needs no
restart), issuer, audience, azp, and expiry. A token that fails is a 502, never
a fallback to the unverified payload.
Sessions are rows the server can delete — logging out or revoking ends them
immediately, which a signed cookie carrying its own state cannot do. Two expiry
clocks: a rolling idle window and an absolute cap from login. /api/auth/login
is rate-limited per IP, which also stops it being used as a password oracle
against the realm.
Keycloak is consulted at sign-in and not again, so disabling an account there leaves an existing JumpSeat session alive until it expires. Sign Out Everywhere in Admin › Accounts closes it immediately.
Not yet present: helmet and a Content-Security-Policy. GET /api/health
reports the version and whether the database exists, unauthenticated.
The database holds third-party contact data. *.sqlite and data/ are
gitignored and should stay that way.
MIT — see LICENSE.