Skip to content

Latest commit

 

History

History
473 lines (359 loc) · 25.3 KB

File metadata and controls

473 lines (359 loc) · 25.3 KB

Architecture and domain invariants

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.

Contents

System shape

                    ┌──────────────────────────────────────────┐
  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:

  1. Edgeauth, api-keys. Establish who is calling.
  2. Domainorganizations, issuers, payments, proofs, credentials, trusted-sources. Own product state.
  3. Infrastructurecommon, 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.

Modules

Each entry lists responsibilities, public interface, owned tables, and dependencies it must not take.

authsrc/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-keyssrc/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.

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

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

paymentssrc/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.

proofssrc/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.

credentialssrc/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.

webhookssrc/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.

jobssrc/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.

auditsrc/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.

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

common, config, database, health, trusted-sources

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.

Critical flows

Authentication

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.

Payment synchronization

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.

Proof issuance

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.

Verification

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.

Revocation

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

Anchoring

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.

Webhook delivery

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.

Domain invariants

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

Reviewer note

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.

Trust boundaries

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.

Protected data

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:

  1. Never log a raw secret. global-exception.filter.ts keeps internals out of responses; the same discipline applies to logs.
  2. Never return another tenant's data. Every multi-tenant query filters on organizationId.
  3. Never widen a public endpoint. GET /proofs/:id/verify is unauthenticated. Anything added to its response becomes public to anyone holding a proof id.
  4. Hash before indexing. A field needed for lookup but not display is stored hashed.

Transaction ownership

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.

Extension rules

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.

Verification

npm run lint
npm run test -- --runInBand
npm run build

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

Maintenance

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.