@bc-forge/indexer
v1.0.1
Published
Server indexer microservice for bc-forge
Readme
Indexer microservice
Supported Node, Prisma, Stellar SDK, and package ranges are in the compatibility matrix.
Quick start (Docker Compose)
The fastest way to run the indexer is the bundled Compose stack, which starts Postgres plus the indexer (API + background indexer) with one command:
docker compose upRun it from this indexer/ directory. The stack brings up:
| Service | What it does |
| ---------- | ------------------------------------------------------------------------ |
| postgres | Postgres 16 with a persistent postgres-data volume |
| indexer | applies migrations on start, seeds sample rows, then runs the indexer + API |
Required environment
Two variables are mandatory; compose fails fast with a clear error if they are
missing. Put them in indexer/.env (git-ignored) or export them in your shell:
CONTRACT_ID=C... # Soroban contract ID whose events get indexed
INDEXER_API_TOKEN=replace-with-a-long-random-secretOptional overrides (also settable in .env):
RPC_URL— Soroban RPC endpoint (defaults tohttps://soroban-testnet.stellar.org).PORT— host port for the API (defaults to3000).
Once the stack is up:
curl http://localhost:3000/health
# {"status":"ok"}
curl -H "Authorization: Bearer $INDEXER_API_TOKEN" http://localhost:3000/api/v1/statsHow migrations run in the container
On startup the indexer container runs:
npx prisma migrate deploy && npx prisma db seed && node dist/index.jsThis is the command: on the indexer service in docker-compose.yml:
prisma migrate deployapplies the committed migrations fromprisma/migrations/that have not run yet, in order. It never drafts new migrations and never resets data, which makes it the right command for start-up and for production databases. Do not runprisma migrate devagainst a database with data you care about —migrate devis a development command that may reset the database to reconcile drift.prisma db seedinserts a few clearly marked sample rows fromprisma/seed.ts(no secrets; ledger0andseed-txHashes).node dist/index.jsstarts the compiled server (the image's defaultCMD); Compose overrides the default entry sequence with the command above so migrations are guaranteed to run first.
To change the schema: edit prisma/schema.prisma, run
npx prisma migrate dev --name <change> against a disposable local database,
and commit the new folder under prisma/migrations/. The same SQL can be
applied outside Docker with npm run db:deploy (prisma migrate deploy).
Health and monitoring
GET /health remains the database readiness probe. GET /healthz returns
status, lastIndexedLedger, latestNetworkLedger, and lag for external
health and lag monitors. The default lag warning threshold is 100 ledgers and
can be changed with INDEXER_LAG_THRESHOLD.
Lag alerts are emitted as one-line JSON to stdout using the existing sanitized
logger. Alert monitors can match alert: "indexer_lag_threshold_exceeded"
and inspect lastIndexedLedger, latestNetworkLedger, lag, and threshold;
no paid alerting client or service integration is required.
API authentication
The indexer read API (/api/v1/mints, /api/v1/transfers, /api/v1/burns,
/api/v1/vault-deposits, /api/v1/wrapper-updates, /api/v1/vesting-claims,
/api/v1/split-distributions, /api/v1/holders, /api/v1/supply-history,
/api/v1/stats) is protected by a
shared secret. Set it in the environment (dotenv loads .env automatically)
and send it on every request as a bearer token:
# .env
INDEXER_API_TOKEN=replace-with-a-long-random-secretcurl -H "Authorization: Bearer $INDEXER_API_TOKEN" http://localhost:3000/api/v1/statsRequests with a missing or incorrect token receive HTTP 401 with
{ "error": "Unauthorized" }. The token value is never logged. GET /health
is registered outside the authenticated router and stays public so uptime
probes keep working.
Vault, wrapper, vesting, and split events
These families are decoded from the contract topics below and listed with the
same cursor pagination as /api/v1/mints. Each stored row has a unique
txHash.
| Family | Route | Contract topic | Data tuple |
| --- | --- | --- | --- |
| Vault deposits | GET /api/v1/vault-deposits | deposit | Yield vault: (caller, assets, shares). Wrapper: (caller, assets, shares, version); the trailing schema version is ignored. |
| Wrapper updates | GET /api/v1/wrapper-updates | wrap, unwrap | wrap: (caller, amount, wrapped_amount). unwrap: (caller, wrapped_amount, underlying_amount). |
| Vesting claims | GET /api/v1/vesting-claims | v_rel | (beneficiary, amount) |
| Split distributions | GET /api/v1/split-distributions | pyo_succ | (invoice_id, recipient, amount) |
An optional address filter matches caller on deposits and wrapper updates,
beneficiary on vesting claims, and recipient on split distributions.
Rate-limit events are not indexed
contracts/rate-limit (BcForgeRateLimit) does not publish contract events.
check_rate_limit, set_global_rate_limit, and set_address_rate_limit only
read and write instance storage. There is no topic to decode, so the indexer
does not store a rate-limit family and does not expose a list route for it.
Holder & supply aggregates
GET /api/v1/holders returns the current token holders with their balances,
derived from the indexed mint, transfer, and burn events at ingestion time.
GET /api/v1/supply-history returns timestamped supply points, one per
supply-changing event. Both use the same cursor pagination (limit, cursor)
and optional from_ledger filtering as the event-list routes.
Health/readiness probe
GET /health is a database readiness check. It runs a SELECT 1 through the
shared Prisma client on every request:
200 { "status": "ok" }— the database answered the ping.503 { "status": "error" }— the ping failed (wrongDATABASE_URL, database down, etc.). Hosting probes should treat this as unhealthy.
The route needs no bearer token. The response body is always a fixed literal, so no driver error text, database URL, or credential can leak to the client; failures are logged server-side with credentials scrubbed.
Docker
Building the Image
Build the production Docker container from the root directory:
docker build -f indexer/Dockerfile -t bc-forge-indexer .The build context must be the repository root (not indexer/) because
@bc-forge/indexer depends on the @bc-forge/sdk workspace, which is
resolved via the root lockfile rather than the npm registry.
For a multi-architecture build (linux/amd64 + linux/arm64 under one
manifest list, as published by .github/workflows/publish-indexer.yml):
docker buildx build --platform linux/amd64,linux/arm64 -f indexer/Dockerfile -t bc-forge-indexer .Verifying the published image signature
Images published for indexer-v* tags are signed with Cosign keyless signing.
No private signing key is created or stored. The signature certificate is
issued through GitHub Actions OIDC and is attached to the immutable image
digest in GHCR. Only the signing job and the SBOM attestation job in
.github/workflows/publish-indexer.yml have id-token: write.
Copy the
sha256:digest from the publish job summary (the same digest Buildx recorded for the smoked manifest).Set the release tag and image digest, replacing the examples below:
IMAGE=ghcr.io/bcpathway/bc-forge-indexer TAG=indexer-v1.2.3 DIGEST=sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdefVerify the signature against the workflow identity for that tag and the GitHub Actions OIDC issuer:
cosign verify \ --certificate-identity "https://github.com/BCPathway/bc-forge/.github/workflows/publish-indexer.yml@refs/tags/${TAG}" \ --certificate-oidc-issuer "https://token.actions.githubusercontent.com" \ "${IMAGE}@${DIGEST}"
Cosign prints the verified signature payload on success. Check that its image
digest matches DIGEST and that its certificate identity and issuer match the
values above. The tag is included in the identity check, while the signature
verification target is the immutable digest rather than a mutable tag.
Release SBOM
When an indexer-v* tag is pushed, the publish workflow generates a CycloneDX
SBOM from the immutable GHCR image digest rather than a mutable tag. It checks
that the SBOM lists operating-system and npm packages, rejects environment
metadata and credential-like values, and uploads
indexer-<tag>-sbom.cdx.json to the GitHub Release for that tag, creating the
release when one does not exist yet. Cosign then attests the SBOM to the same
digest with keyless GitHub OIDC. No private signing key is used, and pipeline
credentials are not written into the SBOM.
Using the same IMAGE, TAG, and DIGEST as the signature check above:
cosign verify-attestation \
--type cyclonedx \
--certificate-identity "https://github.com/BCPathway/bc-forge/.github/workflows/publish-indexer.yml@refs/tags/${TAG}" \
--certificate-oidc-issuer "https://token.actions.githubusercontent.com" \
"${IMAGE}@${DIGEST}"Running the Container
Run the image, passing the required environment variables:
docker run -p 3000:3000 \
-e DATABASE_URL="postgresql://user:password@host:5432/bc_forge?schema=public" \
-e CONTRACT_ID="CABC...XYZ" \
-e RPC_URL="https://soroban-testnet.stellar.org" \
-e INDEXER_API_TOKEN="your-secret-token" \
-e PORT=3000 \
bc-forge-indexer
docker runstarts the server only — it does not apply migrations or seed (that is the Compose stack's job via itscommand:). Runnpm run db:deploy && npm run db:seedagainst the target database first, or use the Compose quick start above.
Environment Variables
PORT— Port number the Express HTTP server listens on (defaults to3000).DATABASE_URL— Required at runtime. PostgreSQL database connection URL used by Prisma.CONTRACT_ID— Required at runtime. Soroban smart contract ID to index.RPC_URL— Soroban RPC endpoint URL (defaults tohttps://soroban-testnet.stellar.org).INDEXER_API_TOKEN— Bearer token used to authenticate requests to/api/v1/*endpoints.
Database Migrations
Schema migrations live in indexer/prisma/migrations/ and are applied with
prisma migrate deploy (npm run prisma:deploy). That is the command to use
against staging and production: it applies the committed migrations in order and
never prompts or resets data. npm run prisma:migrate (prisma migrate dev)
remains a local development command. Run npm run prisma:generate to refresh
the Prisma client after a schema change.
Migrations are not automatically applied when the container starts, so run
npm run prisma:deploy separately — or as a release step before starting the
container.
The aggregate tables Holder and SupplyPoint, which back
GET /api/v1/holders and GET /api/v1/supply-history, are created by
20260928193226_add_holder_and_supply_point. That migration only adds new
tables and touches no existing table, so it applies cleanly whether or not the
base schema init migration tracked in #1056 has been applied yet.
Prefer the one-command path? Use the Compose stack above, whose
indexerservice runsprisma migrate deploy(nevermigrate dev) on start. The committed migrations live underprisma/migrations/and apply to a fresh database withnpm run db:deploy.
Seeding
prisma/seed.ts inserts at most three sample rows, all clearly marked as
seed data (placeholder SEED_DATA_ADDRESS, amount 1, ledger 0,
seed- txHashes). It contains no secrets. Run it with npm run db:seed,
or automatically via prisma migrate reset / the Compose stack.
Release artifacts
Each published indexer release ships two artifacts built from the same commit
(.github/workflows/publish-indexer.yml):
| Artifact | Where | Purpose |
| --- | --- | --- |
| Container image | ghcr.io/bcpathway/bc-forge-indexer | runs the API and the background indexer |
| bc-forge-indexer-prisma-migrations-<version>.tar.gz | GitHub Release assets | the migrations that match that image |
<version> is the semantic version from the indexer-v<semver> release tag, taken from the same commit as the image. The
archive contains prisma/migrations/, prisma/schema.prisma, and
prisma/migration_lock.toml, so operators can apply exactly the migrations that
shipped with the image. The release notes record the archive name, its SHA-256
checksum, the image digest, and the commit they were built from.
The image is tagged with the immutable version (for example 1.2.3) and the
commit (sha-<commit>); latest moves only on stable releases, so a
1.2.3-rc.1 prerelease never takes it over.
Verify the migration archive
Download the archive and its .sha256 companion from the release into the same
directory, then check it:
sha256sum -c bc-forge-indexer-prisma-migrations-<version>.tar.gz.sha256 # Linux
shasum -a 256 -c bc-forge-indexer-prisma-migrations-<version>.tar.gz.sha256 # macOSA match prints OK. A mismatch means the download is corrupt or was modified —
do not apply its migrations.
Deploy, verify, and roll back an indexer release
Production deployments pin an immutable image digest, never latest or a
mutable tag, so the running image always matches a reviewed release:
IMAGE=ghcr.io/bcpathway/bc-forge-indexer
# Copy the digest from the release notes (never deploy :latest in production).
DIGEST=sha256:1f0c8f0b0d5a2c9e4b3a6d7f8e9a0b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a1. Back up the database
prisma migrate deploy is forward-only, so take a backup before every rollout
that carries a migration:
pg_dump --format=custom "$DATABASE_URL" > "bc-forge-$(date -u +%Y%m%dT%H%M%SZ).dump"2. Apply the migrations
The image does not migrate on start (CMD ["npm", "start"]), so run the deploy
from the same digest as an explicit step before starting the new container:
docker pull "${IMAGE}@${DIGEST}"
docker run --rm -e DATABASE_URL="$DATABASE_URL" \
"${IMAGE}@${DIGEST}" npx prisma migrate deploy3. Roll out the new digest
docker run -d --name bc-forge-indexer --restart unless-stopped -p 3000:3000 \
-e DATABASE_URL="$DATABASE_URL" \
-e CONTRACT_ID="$CONTRACT_ID" \
-e RPC_URL="$RPC_URL" \
-e INDEXER_API_TOKEN="$INDEXER_API_TOKEN" \
"${IMAGE}@${DIGEST}"4. Verify health
The two probes defined in Health and monitoring are the success criteria — a rollout is done only when both pass:
GET /healthreturns200 {"status":"ok"}. A503means the database is unreachable, which is a failed rollout.GET /healthzreturnslag(latestNetworkLedger - lastIndexedLedger). A healthy indexer keepslagbelowINDEXER_LAG_THRESHOLD(default100).
curl -fsS http://localhost:3000/health
curl -fsS http://localhost:3000/healthzIf either check fails, roll back before the indexer falls further behind.
When a migration makes an image rollback unsafe
Migrations are forward-only: rolling the image back does not undo them. Rolling back to the previous digest is safe only while that image still matches the schema left by the new image. A migration is not backward compatible — and therefore makes image rollback unsafe — when it
- drops or renames a table or column the previous image reads or writes,
- changes a column type, or adds a
NOT NULLcolumn, in a way the previous image cannot produce, - rewrites or deletes existing rows.
Purely additive migrations (new tables, nullable columns, indexes) leave the previous image working, so the old digest can simply be restarted. When a migration is not backward compatible, do not roll the image back against the migrated schema: restore the backup from step 1 and accept that writes made after the backup are lost.
5. Roll back
# 1. Stop the new container.
docker stop bc-forge-indexer && docker rm bc-forge-indexer
# 2. Backward-compatible migration: start the previous digest as-is.
docker run -d --name bc-forge-indexer \
-e DATABASE_URL="$DATABASE_URL" \
"${IMAGE}@${PREVIOUS_DIGEST}"
# 3. Non-backward-compatible migration: restore the pre-rollout dump first,
# then start the previous digest.
pg_restore --clean --if-exists --dbname "$DATABASE_URL" bc-forge-<timestamp>.dump
# 4. Re-run the step 4 health checks and keep the deployed digests on record so
# the previous digest is known during an incident.Production operations runbook
This section covers day-two operations: validating the environment before startup, graceful start and stop procedures, database backup and restore, cursor recovery after a restart, and how to interpret health probe responses.
Security note — Never commit real credentials. Use placeholder values in committed files (e.g.
replace-with-a-long-random-secret) and inject secrets at runtime through environment variables or a secrets manager. SeeSECURITY.mdand the Docker section above for guidance on passing secrets safely.
Environment validation
Run through this checklist before starting or restarting the service in any environment. A missing or malformed variable surfaces as a startup crash rather than a silent failure.
| Variable | Required | Check |
|---|---|---|
| CONTRACT_ID | Yes | Must begin with C (Stellar contract address format) |
| DATABASE_URL | Yes | Must be a valid postgresql:// connection string |
| INDEXER_API_TOKEN | Yes | Must be a long random secret — never a placeholder |
| RPC_URL | No | Defaults to https://soroban-testnet.stellar.org; set to a mainnet RPC for production |
| PORT | No | Defaults to 3000; must be an integer |
| INDEXER_LAG_THRESHOLD | No | Defaults to 100 ledgers; tune based on acceptable lag |
| INDEXER_RATE_LIMIT_MAX | No | Optional request cap; a non-integer value is ignored and the built-in default is used |
| INDEXER_RATE_LIMIT_WINDOW_MS | No | Optional rate-limit window in milliseconds |
Quick shell validation (substitute your actual secret names):
# Verify required variables are set and non-empty
: "${CONTRACT_ID:?CONTRACT_ID is required}"
: "${DATABASE_URL:?DATABASE_URL is required}"
: "${INDEXER_API_TOKEN:?INDEXER_API_TOKEN is required}"
# Confirm DATABASE_URL begins with the expected scheme
echo "$DATABASE_URL" | grep -q '^postgresql://' \
|| { echo "DATABASE_URL must start with postgresql://"; exit 1; }
# Confirm CONTRACT_ID looks like a Stellar contract address
echo "$CONTRACT_ID" | grep -qE '^C[A-Z2-7]{55}$' \
|| { echo "CONTRACT_ID does not look like a valid Stellar contract address"; exit 1; }Migration order
Production startup migrates before it serves traffic. Do not start the API against a database that has not applied the current Prisma migrations.
- Validate the environment in the section above. A missing
CONTRACT_IDcrashes startup withCONTRACT_ID environment variable is required. A missing or rejectedDATABASE_URLfails in Prisma before the loop starts.INDEXER_API_TOKENmust be a real secret; the API rejects callers that do not present it. - Apply migrations with
npx prisma migrate deploy --schema indexer/prisma/schema.prisma. Prisma runs each directory underindexer/prisma/migrationsin lexicographic order (the timestamp prefix). Do not rename or edit a migration that has already been applied. Do not useprisma migrate devorprisma migrate reseton a production database; reset drops data. - Start the server only after
migrate deployexits 0. The Compose stack does steps 2 and 3 itself (see How migrations run in the container). A standalonedocker rundoes not: run migrate deploy yourself, then start the container (see Database Migrations). - Seeding (
npm run db:seed) is not part of the production order. The seed file contains no secrets; do not point it at a production database.
A failed migrate deploy stops on the failed migration and leaves later
migrations unapplied. Keep the server down until deploy exits 0. Starting
without migrating fails requests that expect the new schema.
Starting the service
Compose stack (recommended for first-time setup)
# From the indexer/ directory
docker compose up -dCompose applies migrations (prisma migrate deploy) and seeds sample rows
before starting the server process, so the database is always ready when the
API accepts traffic.
Confirm the service is healthy:
curl http://localhost:3000/health
# Expected: {"status":"ok"}
curl http://localhost:3000/healthz
# Expected: {"status":"ok","lastIndexedLedger":<n>,"latestNetworkLedger":<n>,"lag":<n>}Standalone container (production)
Apply migrations first — the standalone docker run path does not run
prisma migrate deploy automatically:
# 1. Apply any pending migrations
DATABASE_URL="<your-db-url>" npx prisma migrate deploy --schema indexer/prisma/schema.prisma
# 2. Start the container
docker run -d --name bc-forge-indexer \
-p 3000:3000 \
-e DATABASE_URL="<your-db-url>" \
-e CONTRACT_ID="<your-contract-id>" \
-e RPC_URL="https://soroban-mainnet.stellar.org" \
-e INDEXER_API_TOKEN="<your-secret-token>" \
-e PORT=3000 \
bc-forge-indexerDo not interpolate secrets directly in shell scripts that are committed or logged. Prefer passing them via a secrets manager (e.g. AWS Secrets Manager, HashiCorp Vault) or Docker secrets and referencing the resolved value in the container environment.
Graceful shutdown
The server handles SIGTERM and SIGINT by flushing in-flight database
connections and exiting cleanly (see indexer/src/index.ts). Always prefer
a graceful stop over a hard kill.
# Compose — waits for the container to stop cleanly (default timeout: 10 s)
docker compose stop indexer
# Standalone container
docker stop bc-forge-indexer # sends SIGTERM, waits up to 10 s
docker stop -t 30 bc-forge-indexer # extend timeout if neededTo stop immediately (last resort — risks incomplete writes):
docker kill bc-forge-indexerAfter a hard kill, verify the last indexed ledger is consistent before restarting (see Cursor recovery below).
Database backup and restore
Backup
Use pg_dump to take a consistent logical backup. Run it against a replica
when possible to avoid load on the primary.
# Full logical backup (plain SQL)
pg_dump \
--no-password \
--format=custom \
--file="indexer-backup-$(date +%Y%m%d%H%M%S).dump" \
"$DATABASE_URL"
# Or using Docker if psql/pg_dump is not installed locally
docker run --rm \
-e PGPASSWORD="<db-password>" \
postgres:16 \
pg_dump --host <db-host> --port 5432 --username <db-user> \
--format=custom --dbname <db-name> \
> "indexer-backup-$(date +%Y%m%d%H%M%S).dump"Store backups outside the database host. Verify them periodically with a test restore to a throwaway database.
Restore
# Stop the indexer before restoring to prevent writes during the operation
docker compose stop indexer # or docker stop bc-forge-indexer
# Restore into an existing (empty) database
pg_restore \
--no-password \
--clean \
--if-exists \
--dbname "$DATABASE_URL" \
indexer-backup-<timestamp>.dump
# Re-apply any migrations that post-date the backup
DATABASE_URL="<your-db-url>" npx prisma migrate deploy \
--schema indexer/prisma/schema.prisma
# Restart the service
docker compose up -d # or docker start bc-forge-indexerAfter the restore, confirm /healthz reports a sensible lastIndexedLedger
before directing traffic back to the service.
Cursor recovery
The indexer tracks its position in the LastIndexedLedger table (a single row
with id = 1). If the service is restarted after a hard kill or an incomplete
write, it resumes from lastLedger.ledger + 1. No manual intervention is
needed in the normal case.
If the cursor row is missing or points to an incorrect ledger (e.g. after a partial restore), reset it manually:
# Connect to the database
psql "$DATABASE_URL"
-- Check current cursor
SELECT * FROM "LastIndexedLedger";
-- Reset to a known-good ledger (replace <ledger_number> with the desired value)
-- The indexer will re-index from <ledger_number> + 1 on next startup.
UPDATE "LastIndexedLedger"
SET ledger = <ledger_number>
WHERE id = 1;
-- If the row is missing entirely, insert it
INSERT INTO "LastIndexedLedger" (id, ledger, hash, "parentHash")
VALUES (1, <ledger_number>, '<ledger_hash>', '<parent_hash>');Re-indexing already-processed ledgers is safe: the indexer uses
skipDuplicates: trueonIndexedLedgerinserts and catches unique constraint violations (P2002) on event rows, so duplicate processing does not corrupt data.
After updating the cursor, restart the service:
docker compose restart indexer
# or
docker restart bc-forge-indexerHealth probe interpretation
GET /health
A database readiness check. It runs SELECT 1 through the shared Prisma
client.
| Response | Meaning | Action |
|---|---|---|
| 200 {"status":"ok"} | Database is reachable | No action needed |
| 503 {"status":"error"} | Database ping failed | Check DATABASE_URL, network connectivity, and database logs |
This endpoint requires no bearer token and is safe to call from external uptime monitors.
GET /healthz
An extended health and lag check. Returns:
{
"status": "ok",
"lastIndexedLedger": 12345678,
"latestNetworkLedger": 12345680,
"lag": 2
}| Field | Meaning |
|---|---|
| status | "ok" when the database is reachable; "error" otherwise |
| lastIndexedLedger | Last ledger fully persisted by the indexer |
| latestNetworkLedger | Most recent ledger reported by the Soroban RPC |
| lag | Difference between latestNetworkLedger and lastIndexedLedger |
Lag thresholds:
lagbelowINDEXER_LAG_THRESHOLD(default100) — normal operation.lagat or above the threshold — the indexer emits a structured warning log:
Monitor for this{"level":"warn","alert":"indexer_lag_threshold_exceeded","lastIndexedLedger":...,"latestNetworkLedger":...,"lag":...,"threshold":100}alertfield in your log aggregator. Common causes are RPC connectivity issues, database slowness, or a backlog after a restart.- A
lagof0after a fresh start may indicate theCONTRACT_IDis incorrect or the RPC endpoint is unreachable.
Log triage
All logs are emitted as one-line JSON to stdout. Key fields:
| level | msg / context | Suggested action |
|---|---|---|
| info | "starting contract indexer" | Normal startup |
| info | "indexing ledger range" | Normal operation |
| warn | alert: "indexer_lag_threshold_exceeded" | Investigate RPC or DB performance |
| error | "indexer ingestion error" | Check error field; service will retry in 5 s |
| error | "event processing error" | Check topic and error fields; non-duplicate errors are logged |
To stream logs in real time:
docker logs -f bc-forge-indexer # standalone container
docker compose logs -f indexer # Compose stackCross-references
- Docker image and Compose stack — Docker section above
- Migration commands — Database Migrations section above
- Secret handling and security policy —
SECURITY.md - Pause/unpause during an incident —
SECURITY.md— Incident Response - Admin key hygiene —
docs/ADMIN_KEYS.md
