@snagset/server
v0.1.5
Published
The Snagset server: API, dashboard and widget in one package. Node and Postgres, or Vercel.
Readme
snagset — the server
One npm package: the API, the widget assets, the dashboard statics and the
migrations, with two entry points on the same createApp(deps) (LOCK L5/L7).
Run it
DATABASE_URL=postgres://… npx snagsetThat is the whole install. The bin migrates its own schema under an advisory lock, generates a signing key if it has none, and serves. An instance with no owner shows a setup screen; the first person to fill it in becomes the owner. Postgres is the only requirement — A9a, LOCK L6: no Redis, no S3, no queue, no extensions.
| Command | What it does |
|---|---|
| snagset | migrate, then serve (the default) |
| snagset migrate | migrate and exit — for a deploy step |
| snagset bootstrap | claim the instance headlessly, from SNAGSET_BOOTSTRAP_* |
| snagset origins <uid> | what may write to a project, and what has asked to |
| snagset allow-origin <uid> <origin> | approve one |
Exit codes: 2 bad config, 3 database unreachable, 4 a migration refused
(a checksum changed, or the database is ahead of this build).
First run
orgs is empty, so the instance is CLAIMABLE: every route but /healthz,
/readyz, /api/v1/meta and /api/v1/setup answers 409 instance_unclaimed,
and <data>/secret.key is the only thing written there — back it up.
409 and not 503 is deliberate (08 §3.4): a 503 tells every proxy in the path that a healthy instance is unhealthy, and a fresh container behind one would restart-loop before anyone read the URL.
Opening the URL and posting to /api/v1/setup creates the org, the owner, the
first project, its production and preview environments, Round 1, and a
session — in one transaction. The gate lifts without a restart.
Lost the URL? It is stored as a hash and cannot be re-printed. snagset
bootstrap mints a new one.
Two things an operator should know
The signing key. Generated to <data>/secret.key at mode 0600 on first
boot, or supplied as SNAGSET_SECRET_KEY. Back it up and treat it as a
password: without it, stored integration credentials are unrecoverable
ciphertext and every reviewer session is dead. On serverless it is required
— the filesystem is ephemeral, so a generated key would differ on every cold
start.
Row-level security may not be in force. Migration 0001 enables and FORCEs
RLS on all 31 tenanted tables. A role with the BYPASSRLS attribute overrides
all of it — and managed Postgres often grants it to the role in the connection
string it hands you (Neon's neondb_owner does, and cannot drop it from
itself). Boot says so, and /readyz reports it. Requests are still scoped by
org_id in the application, so this is a lost safety net rather than an open
door; to get it back, create a role without BYPASSRLS, grant it the tables,
and point DATABASE_URL at that. See D50.
Origins
A project starts with an empty allow-list, and nothing is ever allow-listed
automatically: Origin is attacker-controllable, so auto-allowing would be an
attacker-controlled header granting access. Origins that fetch /config are
recorded — snagset origins <uid> shows them — and snagset allow-origin
is the human decision (08 §3.5 D2).
Where the schema comes from
src/db/migrations/0001_init.sql is generated from
05 §3 — 32 tables, 15 enums, 58 indexes — plus
the RLS block this repo adds. pnpm gen:migration:check fails on drift, which
makes editing the spec the only way to change the database. Same for the
config registry against 08 §4.
Tests
pnpm test runs everything that needs no database. Set
SNAGSET_TEST_DATABASE_URL to also run the claim-flow tests — they drop and
recreate the public schema, which is why they refuse to read DATABASE_URL.
