A maintained map of what each module owns, how the critical flows run, and which invariants hold — with the code and tests that enforce them.
The codebase accumulated auth, organizations, issuers, payments, proofs, credentials, anchoring, API keys, webhooks, and jobs without a single place recording ownership. This is that place. Every claim here points at a file, and every invariant points at the code that enforces it and the test that would fail if it stopped. Prose that cannot point at something is not an invariant; it is a hope.
- ADR index:
docs/adr/ - Module wiring:
src/app.module.ts - Data model:
prisma/schema.prisma
- System shape
- Modules
- Critical flows
- Domain invariants
- Trust boundaries
- Protected data
- Transaction ownership
- Extension rules
┌──────────────────────────────────────────┐
wallet ──────────▶│ auth session issue / revoke │
├──────────────────────────────────────────┤
integrator ──────▶│ api-keys scoped machine access │
├──────────────────────────────────────────┤
│ organizations ─▶ issuers │
│ payments ◀── stellar (Horizon) │
│ proofs ──▶ credentials │
│ │ │
│ ├──▶ jobs ──▶ Soroban contracts │
│ └──▶ webhooks ──▶ integrator URLs │
├──────────────────────────────────────────┤
│ audit health common database │
└──────────────────────────────────────────┘
Three layers, and the direction of dependency matters:
- Edge —
auth,api-keys. Establish who is calling. - Domain —
organizations,issuers,payments,proofs,credentials,trusted-sources. Own product state. - Infrastructure —
common,config,database,stellar,audit,jobs,webhooks,health. Serve the layers above.
Domain modules may depend on infrastructure. Infrastructure must not depend on
domain — a common guard that imported proofs would make the guard
untestable in isolation and couple every module to the proof lifecycle.
Each entry lists responsibilities, public interface, owned tables, and dependencies it must not take.
auth — src/auth/
Wallet-signature authentication and revocable sessions.
| Public interface | POST /auth/challenge, /auth/verify, /auth/logout, /auth/rotate, GET /auth/sessions |
| Owned tables | WalletChallenge, AuthSession |
| Key files | auth.service.ts, session.service.ts, auth-token.service.ts, cleanup.job.ts |
| Must not depend on | proofs, payments, credentials, webhooks |
Only a SHA-256 hash of the bearer token is stored. The raw token exists in the response body and nowhere else — not in the database, not in a log.
api-keys — src/api-keys/
Machine-to-machine credentials with explicit scopes.
| Public interface | /api-keys CRUD, integration-auth.controller.ts |
| Owned tables | ApiKey, ApiKeyScopeAssignment |
| Key files | api-key.service.ts, api-keys.controller.ts |
| Must not depend on | proofs, payments, credentials |
Keys are organization-scoped. Every lookup filters on organizationId
alongside the key id (api-key.service.ts:228),
so a valid key from one organization cannot address another's resources.
organizations — src/organizations/
Tenant boundary. Every multi-tenant resource hangs off an organization.
| Public interface | /organizations CRUD and membership |
| Owned tables | Organization |
| Key files | organizations.service.ts |
| Must not depend on | proofs, payments, credentials, jobs |
issuers — src/issuers/
Trusted attestation sources and their on-chain registry mirror.
| Public interface | /issuers CRUD, status transitions, registry sync |
| Owned tables | Issuer, Attestation |
| Key files | issuers.service.ts, issuer-registry.service.ts |
| Must not depend on | proofs, payments |
payments — src/payments/
Horizon synchronization and payment classification.
| Public interface | POST /payments/sync, GET /payments, PATCH /payments/:id/classification |
| Owned tables | Payment, SupportedAsset |
| Key files | payments.service.ts |
| Must not depend on | proofs, credentials, webhooks |
Payments are read by proofs but never written by it. Amounts are stored
encrypted; see Protected data.
proofs — src/proofs/
The core domain. Issuance, verification, revocation, and anchoring intent.
| Public interface | /proofs/minimum-income, /proofs/recurring-income, /proofs/payment-receipt, GET /proofs, /proofs/:id/verify, /proofs/:id/revoke |
| Owned tables | Proof, ProofClaim, AnchoringIntent, VerificationEvent |
| Key files | proofs.service.ts, contract-anchoring.service.ts |
| Must not depend on | auth internals, api-keys internals |
Reads payments, trusted-sources, and issuers; writes only its own tables.
credentials — src/credentials/
Deterministic canonicalization, hashing, and HMAC signing.
| Public interface | POST /credentials/verify |
| Owned tables | none — operates on Proof.signedPayload |
| Key files | credentials.service.ts, canonicalize.ts |
| Must not depend on | payments, webhooks, jobs |
Canonicalization must stay deterministic across versions: a credential signed
last month must verify today, so any change to
canonicalize.ts invalidates every
signature ever issued. Treat it as frozen.
The module is verification-only on purpose: it reads proofs and never writes
them, and issuance, revocation and any future export format stay in proofs,
which owns the signing key and the authenticated surface. See
ADR-0007.
webhooks — src/webhooks/
Signed outbound event delivery.
| Public interface | /webhooks CRUD, delivery replay |
| Owned tables | Webhook, WebhookDelivery |
| Key files | webhooks.service.ts, webhook-delivery.service.ts, webhook-signing.service.ts, webhook-ssrf-guard.ts |
| Must not depend on | proofs internals, payments internals |
The only module that makes outbound HTTP to customer-controlled URLs, which is why the SSRF guard lives here.
jobs — src/jobs/
Scheduled background work.
| Public interface | none — no controller |
| Owned tables | none; operates on AnchoringIntent |
| Key files | anchoring-worker.service.ts, anchoring-reconciler.service.ts |
| Must not depend on | HTTP request context |
Jobs have no request and therefore no user. Anything that reads
CurrentUser cannot be called from a job.
audit — src/audit/
Administrative action log and privacy-safe verification events.
| Owned tables | AuditLog, VerificationEventLog |
| Key files | verification-event.service.ts |
| Must not depend on | any domain module |
Written by many modules, reads none. That one-way dependency is what keeps it safe to call from anywhere.
stellar — src/stellar/
Horizon client and memo normalization. The only module that talks to Horizon.
| Key files | stellar.service.ts, memo-normalizer.ts |
| Must not depend on | any domain module |
Infrastructure and supporting domain. common holds guards,
decorators, interceptors, filters, and crypto helpers; it must not import any
domain module. database owns the Prisma lifecycle.
health probes dependencies.
trusted-sources owns the TrustedSource table.
POST /auth/challenge → WalletChallenge (nonce hash + expiry)
POST /auth/verify → verify SEP-53 signature → AuthSession (token hash)
every request → AuthGuard → hash bearer → load session → attach user
POST /auth/rotate → issue successor, link via rotatedToId
POST /auth/logout → set revokedAt
daily → CleanupJob deletes expired sessions
Enforcement: auth.guard.ts:24 rejects
a missing token, :45 an unknown user,
:49 an inactive account.
Tests: auth.service.spec.ts,
session.service.spec.ts,
auth.guard.spec.ts.
POST /payments/sync → Horizon page → normalize memo → classify → upsert Payment
Idempotent on Payment.operationId, which is @unique in
schema.prisma. Re-syncing the same window does not
duplicate rows, which is what makes a retry safe.
Tests: payments.service.spec.ts,
stellar.service.spec.ts,
memo-normalizer.spec.ts.
POST /proofs/minimum-income
→ load caller's payments (scoped by userId)
→ validate period and threshold
→ canonicalize claim → hash → HMAC sign
→ $transaction: Proof + ProofClaim + AnchoringIntent
→ webhook event
The transaction boundary is proofs.service.ts:214.
Proof, claim, and anchoring intent commit together or not at all — a proof
without its claim would verify against nothing.
Tests: proofs.service.spec.ts,
proofs.lifecycle.spec.ts,
payment-receipt.spec.ts.
GET /proofs/:id/verify (public — no auth)
→ load proof → check status, expiry, revocation
→ recompute canonical hash → compare HMAC
→ record VerificationEventLog (hashed metadata only)
Public by design: a relying party holding a credential must be able to check it without an account. That is why the response carries no payment detail and no wallet address — see Protected data.
Tests: credentials.service.spec.ts,
credentials.controller.spec.ts,
verification-event.service.spec.ts.
POST /proofs/:id/revoke
→ assert caller owns the proof
→ $transaction: status=REVOKED, revokedAt=now, AnchoringIntent(REVOKE)
→ webhook event
Revocation is terminal. Nothing sets a revoked proof back to ACTIVE, and
revokedAt outlives the credential so a verifier can distinguish "expired" from
"revoked".
every 10s AnchoringWorker: claim PENDING batch → submit → CONFIRMED | retry
every N AnchoringReconciler: reset intents stuck in PROCESSING
Bounded batches with exponential backoff and a permanent-failure cap
(anchoring-worker.service.ts). A
crashed worker leaves intents in PROCESSING; the reconciler is what unsticks
them, so both must run.
Tests: anchoring-worker.service.spec.ts,
anchoring-reconciler.service.spec.ts.
domain event → WebhookDelivery(PENDING) → SSRF guard → sign → POST → record
Tests: webhook-delivery.service.spec.ts,
webhook-signing.service.spec.ts,
webhook-ssrf-guard.spec.ts.
Each links to enforcing code and a test that fails if it regresses.
| # | Invariant | Enforced at | Tested by |
|---|---|---|---|
| I1 | A raw session token is never persisted; only its SHA-256 hash | session.service.ts, AuthSession.tokenHash |
session.service.spec.ts |
| I2 | A request without a valid bearer token cannot reach a protected route | auth.guard.ts:24 |
auth.guard.spec.ts |
| I3 | A suspended or revoked user cannot authenticate | auth.guard.ts:49 |
auth.guard.spec.ts |
| I4 | A wallet challenge is single-use and expires | WalletChallenge.usedAt, expiresAt |
auth.service.spec.ts |
| I5 | An API key is organization-scoped; a valid key cannot address another tenant | api-key.service.ts:228, :287, :300 |
api-key.service.spec.ts |
| I6 | An API key lacking a scope is refused with 403, distinct from 401 | api-key.guard.ts:21, scopes.guard.ts |
api-keys.controller.spec.ts |
| I7 | Every API key authentication failure returns an identical 401, regardless of cause | api-key.guard.ts:42, :49, :59, :73 |
api-key.service.spec.ts |
| I8 | Payment sync is idempotent on operationId |
Payment.operationId @unique in schema.prisma |
payments.service.spec.ts |
| I9 | A proof is issued only from payments the caller owns | proofs.service.ts:153, :542 |
proofs.service.spec.ts |
| I10 | Proof, claim, and anchoring intent commit atomically | proofs.service.ts:214, :446, :620 |
proofs.lifecycle.spec.ts |
| I11 | A proof can be read or revoked only by its owner | proofs.service.ts:331 |
proofs.service.spec.ts |
| I12 | Revocation is terminal; no path returns a revoked proof to ACTIVE |
proofs.service.ts:715 |
proofs.lifecycle.spec.ts |
| I13 | credentialHash is unique, so no two proofs share a credential |
Proof.credentialHash @unique in schema.prisma |
proofs.service.spec.ts |
| I14 | Canonicalization is deterministic; a credential signed earlier still verifies | canonicalize.ts |
credentials.service.spec.ts |
| I15 | Signature comparison is timing-safe | timing-safe.ts |
credentials.service.spec.ts |
| I16 | Payment amounts are encrypted at rest | protected-amount.ts |
payments.service.spec.ts |
| I17 | Verification events store hashed metadata, never raw identifiers | verification-event.service.ts |
verification-event.service.spec.ts |
| I18 | An anchoring intent is unique per proof and operation | AnchoringIntent @@unique([proofId, operation]) in schema.prisma |
anchoring-worker.service.spec.ts |
| I19 | Anchoring retries are bounded and back off | anchoring-worker.service.ts |
anchoring-worker.service.spec.ts |
| I20 | Intents stuck in PROCESSING are reclaimed |
anchoring-reconciler.service.ts |
anchoring-reconciler.service.spec.ts |
| I21 | Webhook targets are SSRF-checked before any request | webhook-ssrf-guard.ts |
webhook-ssrf-guard.spec.ts |
| I22 | Every webhook delivery is signed | webhook-signing.service.ts |
webhook-signing.service.spec.ts |
| I23 | Error responses never leak internals — no stack, no Prisma metadata | global-exception.filter.ts |
global-exception.filter.spec.ts |
| I24 | Every response carries a correlation ID | request-id.interceptor.ts |
request-id.interceptor.spec.ts |
| I25 | The health endpoint requires no auth and exposes no internals | health.controller.ts |
health.authorization.spec.ts |
I7 is easy to weaken by accident. Distinguishing "key not found" from "key revoked" in the response is a small usability gain and an enumeration oracle: a caller could probe which key ids exist. The guard returns the same message for all four causes, and it is worth keeping that way.
| Boundary | Crossing | Trusted? | Control |
|---|---|---|---|
| Client → API | HTTP requests | No | Validation pipe, guards, throttler |
| Wallet → auth | Signed challenge | No | SEP-53 verification, single-use nonce |
| Integrator → API | API key | No | Hashed lookup, scopes, org scoping |
| Horizon → payments | Ledger data | Partly | Public ledger, but normalized and classified before storage |
| API → Soroban | Anchoring | Partly | Contract enforces its own rules; backend cannot assume success |
| API → webhook URL | Outbound POST | No | SSRF guard; the URL is customer-controlled |
| API → database | Queries | Yes | Trusted; a compromised app is a compromised database |
The two worth dwelling on:
Webhook URLs are attacker-controlled input. A customer can point a webhook
at 169.254.169.254 or an internal host. The SSRF guard is what stops the
backend being used as a proxy into its own network.
Horizon data is public but not neutral. Memos are user-supplied. They are
normalized in memo-normalizer.ts before
anything downstream reads them.
| Class | Where | Handling |
|---|---|---|
| Session tokens | AuthSession.tokenHash |
SHA-256 only; raw token never stored |
| API keys | ApiKey.hash |
Hashed; prefix stored separately for lookup |
| Payment amounts | Payment |
AES-256-GCM at rest |
| Wallet addresses | User.walletHash |
Hashed for indexing |
| Webhook secrets | Webhook.secretEncrypted |
Encrypted |
| Credential payloads | Proof.signedPayload |
Signed; contains no raw payment history |
| Verification metadata | VerificationEventLog.metadataHash |
Hashed with a salt version |
Rules that must hold:
- Never log a raw secret.
global-exception.filter.tskeeps internals out of responses; the same discipline applies to logs. - Never return another tenant's data. Every multi-tenant query filters on
organizationId. - Never widen a public endpoint.
GET /proofs/:id/verifyis unauthenticated. Anything added to its response becomes public to anyone holding a proof id. - Hash before indexing. A field needed for lookup but not display is stored hashed.
Prisma transactions are used in exactly four places, and each is a deliberate atomicity boundary rather than a habit:
| Owner | Line | Why |
|---|---|---|
session.service.ts |
152 | Rotation must revoke the old session and create its successor together, or a rotation could leave two live sessions |
proofs.service.ts |
214, 446, 620 | Proof, claim, and anchoring intent must commit together — a proof without its claim verifies against nothing |
proofs.service.ts |
715 | Revocation must set status and enqueue the on-chain revocation together |
anchoring-worker.service.ts |
311 | Confirming an intent and recording the transaction hash on the proof must not diverge |
A module owns transactions over its own tables only. A transaction spanning two modules' tables means the boundary is in the wrong place.
Adding a module. Create src/<name>/, register it in
app.module.ts, add a section above with its owned
tables and forbidden dependencies, and add an ADR if it introduces a new
external dependency or crosses an existing boundary.
Adding a table. Name its owning module. Two modules writing one table is a boundary error, not a shortcut.
Adding an endpoint. State its auth requirement explicitly. A new unauthenticated endpoint needs an ADR — the surface area of what is public is a decision, not a detail.
Adding an invariant. Add a row above with enforcing code and a test. Without both it is documentation, and documentation does not fail a build.
Changing canonicalization. Do not. Every previously issued credential verifies against the current implementation, so a change invalidates them all. If it is genuinely necessary, it needs an ADR and a migration plan for existing credentials.
Changing a guard. Guards are the enforcement point for I2, I3, I5, I6, and I7. Changes need a test proving the rejection still happens and a security review.
npm run lint
npm run test -- --runInBand
npm run buildDocumentation links are checked by
src/docs-links.spec.ts, which fails when a
referenced path stops existing. A handbook that points at a moved file is worse
than one that points nowhere, because it reads as current.
Refresh when:
- a module is added, removed, or renamed;
- a table changes owner;
- an invariant is added, weakened, or newly tested;
- a trust boundary moves;
- a transaction boundary is added or removed;
- a decision listed in
docs/adr/is superseded.