Getting started
First-time setup, in order. Each step proves something works before the next one builds on it — if a step fails, stop there rather than pushing through.
Prerequisites
Rust, stable, edition 2021 toolchain (
rustup default stable).PostgreSQL 16 reachable somehow — a local install, or Docker (
docker run --rm -e POSTGRES_PASSWORD=postgres -p 5432:5432 postgres:16). Nothing here needs a specific host; every command below assumespsql/createdbare onPATHandDATABASE_URLpoints at a reachable server.just— the task runner this repo’s own commands go through (cargo install just, or your package manager).Node >= 26 and pnpm 11 — needed only for step 8 below (the admin console); every step before that is pure Rust/
psql.
Nothing else: every repo-owned check (the R1 lint, the R2 parity check, bootstrap-SQL/migration-drift gates) is cargo xtask <check> — see .xtask/ and AGENTS.md's own "Repo automation moved to xtask" section — so there is no separate Python or shell interpreter to install for any of them.
1. Clone and build
git clone https://github.com/vaam-apps/vsms.git
cd vsms
just checkjust check is cargo check --workspace --all-targets with the build-concurrency cap this repo’s own justfile sets. First build compiles the whole dependency tree including include_server_schema!'s expansion of sms-api — expect a few minutes the first time, seconds after.
2. Apply migrations to a scratch database
createdb vsms_check
export DATABASE_URL=postgres://localhost/vsms_check
cargo run -p sms-migrate
psql "$DATABASE_URL" -v ON_ERROR_STOP=1 -f ci/test-state-machine.sqlcargo run -p sms-migrate is the same binary a real deployment’s migrate step runs (backends/apps/sms-migrate) — ci/apply-migrations.sh, a shell wrapper that used to do this by shelling psql \i out per migration file, is gone (no bash script survives in this repo — see AGENTS.md's xtask section).
The last command asserts, against a real trigger, that legal state transitions succeed, illegal ones raise SM001, terminal states have no exits, and generated ids satisfy CrateStack’s format guard. It runs inside a transaction and rolls back — nothing it does is left behind. ALL ASSERTIONS PASSED is the expected last line.
3. Run the full check suite
just all-checksfmt, clippy with warnings as errors, the full offline test suite (no DATABASE_URL needed — this stays green without one on purpose), the R1 raw-sqlx lint, and the R2 state-machine parity check. This is everything CI runs, in CI’s order.
4. See what’s actually here
just routesNeeds no database — prints the full generated REST route table (100+ routes as of this writing) straight from the expanded schema. A fast way to get a feel for the surface area before reading code.
DATABASE_URL=postgres://localhost/vsms_check \
cargo test --workspace -- --ignoredRuns the live-Postgres suites against the scratch database from step 2 — these are the tests that prove behavior against a real trigger and a real claim loop, not just a type-checker. Safe to run repeatedly against the same database; every suite seeds its own uniquely-suffixed fixtures.
5. Run the gateway and worker for real
sms-gateway bootstrap chains everything this whole section walks through by hand into one idempotent call (production-readiness audit N2) — this section stays the long way on purpose, since the point here is understanding each individual step, not just running one command; see `docs/runbooks/deployment.adoc’s own step 3 for the one-liner once you already know what it’s doing.
Two one-time steps before either binary will start. First, the gateway’s two Ed25519 envelope keys (VS-3) — sms-gateway serve signs every answer with them and refuses to start without both. They are the only keys it needs; there is no token signing key (the OpenID Provider that had one was deleted in VS-4). Dev keys, kept out of git:
mkdir -p dev-keys
openssl genpkey -algorithm ed25519 -out dev-keys/envelope-current.pem
openssl genpkey -algorithm ed25519 -out dev-keys/envelope-next.pemSecond, sms-gateway serve also refuses to start until an active Provider row keyed orange_cm exists, and, since #62's routing rules engine, sms-worker's dispatch role needs at least one enabled Route pointing at it or it refuses every message with no matching route (a deliberate cutover from routing’s old "any active provider" placeholder — sms-gateway serve itself still only checks for the Provider row at startup, so a deployment missing only the Route half starts and reports healthy, then rejects everything it’s asked to send). No admin console exists yet to create either by hand, so run something that seeds both before starting either binary, not after. Two options: send_test_message below also creates both as a side effect (along with the App/AppClient/SenderId fixtures this walkthrough’s message needs anyway), or, if you only want the Provider/Route pair and nothing else, sms-gateway seed-dispatch (#148, renamed from seed-provider and extended to also seed the Route in #62) does exactly that — idempotent on both halves, no message/fixtures attached:
DATABASE_URL=postgres://localhost/vsms_check \
cargo run -p sms-gateway -- seed-dispatchThis walkthrough uses send_test_message below instead, since it needs the message/App/AppClient fixtures regardless and send_test_message already creates the Provider/Route pair along with them — no need to run both.
It also needs SMS_HASH_PEPPER — the server-held key behind msisdnHash/bodyHash (#134, sms_api::pepper). Required, minimum 32 bytes, and it must be the same value for every process in this walkthrough: a hash computed under one pepper never equals one computed under another, and nothing detects the mismatch — opt-out matching and dedupe just silently stop working. So export it once, here, rather than generating one per command:
export SMS_HASH_PEPPER="$(openssl rand -base64 48)"
DATABASE_URL=postgres://localhost/vsms_check \
cargo run -p sms-api --example send_test_message -- \
--to +237677123456 --sender-id VYMALO --body "Hello from vsms"Idempotent — safe to run again later to send a second message; it reuses the App/Provider/SenderId fixtures it created the first time rather than accumulating new ones.
Both binaries read a YAML configuration (VSC-1, docs/architecture.md section 9.4): the base file config/application.yml the repository ships, plus an overlay that says which provider they talk to. The committed profiles point at the compose stacks' containers (sms-fake-orange as a host name), so for a run on your own machine write a one-off overlay beside a copy of the base, outside the repository:
mkdir -p /tmp/vsms-config
cp config/application.yml /tmp/vsms-config/
cat > /tmp/vsms-config/application-local.yml <<'EOF'
providers:
- code: orange_cm
credentials:
client_id: placeholder
client_secret: placeholder
settings:
sender_number: "+2370000"
EOF
export SMS_CONFIG=/tmp/vsms-config/application.yml SMS_PROFILE=localThe base file reads the pepper as ${SMS_HASH_PEPPER}, which step 5 exported. Only the gateway loads it: the worker does not read security.* and starts without the variable, so leaving the export in its terminal is harmless and not needed. Now, in separate terminals:
# terminal 1 — the API server
DATABASE_URL=postgres://localhost/vsms_check \
SMS_ENVELOPE_SIGNING_KEY_FILE=dev-keys/envelope-current.pem \
SMS_ENVELOPE_NEXT_SIGNING_KEY_FILE=dev-keys/envelope-next.pem \
cargo run -p sms-gateway -- serve
# terminal 2 — dispatch, scheduler, and jobs
DATABASE_URL=postgres://localhost/vsms_check \
SMS_WORKER_ROLES=dispatch,scheduler,jobs \
cargo run --bin sms-workercargo run --bin sms-worker, not -p sms-worker — the package is named sms-worker-bin (the library crate backends/crates/sms-worker already owns the plain name), but the it produces is still called sms-worker, and --bin resolves by binary name across the whole workspace regardless of which package declares it.
Placeholder Orange credentials are enough to prove the whole pipeline moves the message you already sent through accepted → queued → routed: routing only needs an active Provider row and a matching, enabled Route (since #62; send_test_message seeds both), not a real Orange account. routed is where dispatch actually attempts a submission — with placeholder credentials that attempt fails outright (a real 401 from Orange’s own OAuth endpoint, since the orange_cm entry’s host.url defaults to the real https://api.orange.com), landing the message in failed with the rejection reason attached, never reaching submitted. That’s expected, and exactly the boundary docs/runbooks/36-handset-gate.adoc exists to cross with real credentials. Watch it with the psql command send_test_message printed:
id | state | provider_message_ref | state_reason
-----+--------+-----------------------+-------------------------------------------------------
... | failed | | oauth_401: token endpoint rejected credentials: ...6. See it reach delivered without a real Orange account
Everything above proves routing; it can’t prove delivery, because routed is exactly where a placeholder credential dies. backends/apps/sms-fake-orange closes that gap for a demo or local run — it’s a standalone process that impersonates Orange’s token and submit endpoints and independently POSTs a delivered DLR back, so sms-worker/sms-gateway need no code change to talk to it, only the orange_cm entry’s host.url pointed elsewhere. It is a development/demo tool, not a real provider — never point a production deployment at it (see the binary’s own module doc, backends/apps/sms-fake-orange/src/main.rs).
# terminal 3 — the fake, bound on its own port
cargo run -p sms-fake-orange-bin -- \
--bind-addr 127.0.0.1:8090 \
--dlr-endpoint http://127.0.0.1:8080/dlr/orange_cm \
--sender-number +2370000Then add host: {url: http://127.0.0.1:8090} to the orange_cm entry of /tmp/vsms-config/application-local.yml (still with the placeholder client_id/client_secret — the fake accepts any credentials by default), restart terminals 1 and 2 exactly as in step 5, and send a fresh message. Watch the same psql query this time land on delivered, and backends/apps/sms-fake-orange's own log show the submit it received and the DLR it posted back. --fault-mode seeded --seed <n> drives the same weighted failure mix backends/crates/sms-worker's chaos suite uses, for demoing the interesting paths instead of the happy one.
This still doesn’t close 36-handset-gate.adoc — nothing here proves Orange’s real DLR payload shape, or that a handset actually buzzes.
7. Register the composer’s key
Steps 1—5 prove the pipeline moves a message end to end, but sendMessage there runs through send_test_message's own in-process Procedures call, not over HTTP. Over HTTP every request is a signed envelope (VS-3), and the console’s composer sends through a service key of its own: an Ed25519 key you generate, whose private half never reaches vsms.
openssl genpkey -algorithm ed25519 -out console-composer.pem
chmod 0600 console-composer.pem
# The public half as register-client takes it: the raw 32 bytes, base64url.
PUBLIC_KEY="$(openssl pkey -in console-composer.pem -pubout -outform DER \
| tail -c 32 | base64 | tr '+/' '-_' | tr -d '=\n')"
# `SMS_HASH_PEPPER` is already exported from step 5, and must be the same value every other process here uses.
# (`register-client` itself reads no configuration; the export is for the processes that do.)
DATABASE_URL=postgres://localhost/vsms_check \
cargo run -p sms-gateway -- register-client \
--app <an App slug, e.g. from `psql ... -c 'select slug from app'`> \
--client-name console-composer --label "console composer" \
--scope sms:send --scope sms:read \
--public-key "$PUBLIC_KEY"register-client is idempotent (the key is identified by its RFC 9679 thumbprint) and gives the composer its own principal, appc_<slug>_console-composer. sms-gateway server-keys prints the gateway’s two public sealing keys, which the console pins:
cargo run -p sms-gateway -- server-keys --envelope-signing-key-file <current.pem> --envelope-next-signing-key-file <next.pem>(Use the same two PEM files serve was started with; the command needs no database.)
8. Run the admin console against it
Everything above proves the gateway/worker chain over HTTP; nothing in steps 1—7 actually starts the Next.js console.
Prerequisites: Node >= 26 and pnpm 11 (packageManager/engines in the root package.json pin the exact versions; corepack enable will resolve pnpm automatically), plus Rust with the wasm32-unknown-unknown target and wasm-bindgen’s command-line tool at the lockfile’s version (`just wasm-tools): the console signs in the browser, with the same crate the Node SDK uses compiled to WebAssembly.
# from the repo root — this is a pnpm workspace, not just frontends/apps/admin/
pnpm install
pnpm --filter admin run build:sealer # the browser's sealing module → frontends/apps/admin/public/sealer/Then create frontends/apps/admin/.env.local (gitignored — .env.example at the repo root is the template, and the console’s required keys are the ones listed in frontends/packages/env/src/index.ts):
SMS_API_URL=http://127.0.0.1:8080
# The gateway's two PUBLIC sealing keys, from `sms-gateway server-keys` above.
SMS_ENVELOPE_SERVER_KEY_CURRENT=...
SMS_ENVELOPE_SERVER_KEY_NEXT=...
# The composer's service key from step 7 (optional: every other screen works without it).
SMS_COMPOSER_KEY_FILE=/absolute/path/to/console-composer.pem
MESSAGE_STREAM_POLL_MS=2000
NEXT_PUBLIC_APP_NAME=vsms Admin Console
NODE_ENV=developmentThere is no login to configure: no client id, no session secret, no redirect URI. Create the account the console’s browser key will speak as:
DATABASE_URL=postgres://localhost/vsms_dev ./target/debug/sms-gateway provision-user \
--email you@example.com --display-name "Your Name" --role-key owner--role-key accepts any of §5.2’s built-in roles: the six seeded by 0002_bootstrap and pending (no permission), seeded by 0013_role_read_scope. (provision-user creates the account only: it holds no credential and prints none.)
pnpm --filter admin exec next dev -p 3100next dev does not read a PORT value out of .env.local when choosing which port to bind — that file is only loaded once the server process is already up. Pass -p explicitly (or export PORT in the shell that launches it) rather than relying on the env file.
Open http://localhost:3100/. The console makes a key in your browser and shows a QR. With no vaam admin phone to scan it, attach the key to the account you just made, using the k field of the QR (the page says where it is):
DATABASE_URL=postgres://localhost/vsms_dev ./target/debug/sms-gateway pair-console-key \
--user you@example.com --public-key "<the k field of the QR>"The page notices within a couple of seconds and opens on the dashboard. Send a message from the composer (http://localhost:3100/composer), and watch it move to delivered on http://localhost:3100/messages (polling, ~2s per MESSAGE_STREAM_POLL_MS above). docs/design/console-pairing.md is the design and what was and was not run against a real gateway.
Where to go next
docs/architecture.md— the full design: data model, provider abstraction, worker topology, security, compliance, and every framework constraint that shaped a decision.CONTRIBUTING.md— the three engineering rules (R1/R2/R3) the codebase is written against, and the schema-change workflow.AGENTS.md— current project status: what’s built, what’s still open, and every non-obvious thing found by actually running the toolchain rather than reading its docs.docs/runbooks/— other step-by-step procedures, including the real-handset acceptance gate.
Cleaning up
dropdb vsms_checkIf you started Postgres in Docker for this, stop that container too — nothing here needs it to stay running between sessions.