Skip to content
2KSTechPublic

About

Partner Outreach Tracking for Nonprofits

Resources

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

 

History

1 Commit

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

JumpSeat

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.

Contents

Requirements

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.

Getting started (development)

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 application

All 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 dev

sqlite3 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.

Development vs production

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.

Running

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 image

Behind apache2: deploy/apache2/jumpseat.conf.example.

Docker

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/health

That 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 on 0.0.0.0 is 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 the ports: line, not an accident.
  • VITE_API_URL is compiled into the bundle at build time, so it comes from the args: block, not from .env. Changing it means docker compose build, not restart. The default — same origin — is what you want behind a proxy.
  • Behind TLS, set JUMPSEAT_COOKIE_SECURE=1 and JUMPSEAT_TRUST_PROXY together 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.

Production install — the order matters

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 build VITE_API_URL is 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, plus JUMPSEAT_API_PORT and any paths you are relocating (JUMPSEAT_DB_PATH, JUMPSEAT_BACKUP_DIR). Leave JUMPSEAT_SESSION_SECRET out.

  • 3. Start with the TLS-dependent pair OFF: JUMPSEAT_COOKIE_SECURE=0 and JUMPSEAT_TRUST_PROXY=0.

  • 4. Leave JUMPSEAT_BIND_HOST at 127.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. Set 0.0.0.0 only 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 -wal and -shm files 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, then curl -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 from Allowed:, fix .env now — not after the proxy is in the way.

  • 7. Load the vhost and sign in over HTTPS. Working? Then ProxyPreserveHost and the certificate are right.

  • 8. Now set JUMPSEAT_COOKIE_SECURE=1 and JUMPSEAT_TRUST_PROXY together, and restart. The banner should print no ⚠ lines at all. Sign in once more to confirm — the cookie is named __Host-jumpseat.sid when 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 Secure cookie is only set on a connection the server believes is HTTPS. Behind a proxy the server sees plain HTTP unless trust proxy is on and the proxy sends X-Forwarded-Proto: https (the example vhost sets it — keep that line). COOKIE_SECURE=1 with TRUST_PROXY=0 means 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 proxy off, 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.

Configuration

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.

The data model

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. notes is NOT NULL: a thread cannot move without a reason.
  • interaction and 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_contact is who was there from their side, interaction_user from yours; neither is the same question as interaction.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. users holds no credential: rows come from JUMPSEAT_ALLOWED_USERS at boot and from a verified sign-in, and are never deleted, because interaction_user references them and owner names people by username. is_active mirrors 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.

The database

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.

Backups

pnpm db:backup

Uses VACUUM INTO: safe against a live database, and it will not overwrite an existing snapshot. Also available as POST /api/admin/outreach/backup.

Tests

pnpm test          # 6 suites, 201 tests
pnpm lint

Both 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.

API

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

Security

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.

Data

The database holds third-party contact data. *.sqlite and data/ are gitignored and should stay that way.

License

MIT — see LICENSE.

About

Partner Outreach Tracking for Nonprofits

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages