Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 18 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,23 @@ cd anchornet-backend
Install dependencies
npm install

Production persistence

Production requires PostgreSQL and a `DATABASE_URL`. Apply the checked-in
migrations before starting the API:

```sh
DATABASE_URL=postgres://user:password@host:5432/anchornet npm run migrate:up
DATABASE_URL=postgres://user:password@host:5432/anchornet npm start
```

The process checks the database and hydrates anchors, liquidity, and
settlements before it binds its HTTP port. It exits instead of serving with an
empty in-memory state when PostgreSQL is unavailable. Settlement reservations
are checked and inserted in one transaction with row locks, so concurrent API
instances cannot reserve the same liquidity twice. Development and Jest keep
the existing in-memory repositories when `DATABASE_URL` is omitted.

Run in development
npm run dev
Server runs at http://localhost:3001 by default. Set PORT to override.
Expand Down Expand Up @@ -319,4 +336,4 @@ Fork the repo and create a branch from main.
Install deps: npm install. Run tests: npm test; lint: npm run lint.
Open a pull request. CI runs lint, build, and tests on push/PR to main.
License
MIT
MIT
34 changes: 29 additions & 5 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,34 @@ Sensitive Data Redaction & Security Guarantees
Strict Redaction via Denylist: Any header, body parameter, or metadata stored in audit log entries is processed through redactSensitiveData().
Denylisted Fields: Secret-bearing keys such as x-api-key, authorization, cookie, set-cookie, token, access_token, refresh_token, secret, password, bearer, private_key, client_secret are matched case-insensitively and replaced with "[REDACTED]".
Preventing Plaintext Exposure: Under no circumstances should raw credentials or API keys be captured or retained in plaintext in the in-memory audit ring buffer or exposed via GET /api/v1/audit.
In-Memory Repositories & Future Persistence
Settlement, anchor, and liquidity data are held in process-local in-memory
repositories (src/repositories/*), all extending the shared
InMemoryRepository base class.
Durable PostgreSQL Persistence
In production, `DATABASE_URL` is required. `src/index.ts` reaches PostgreSQL,
loads all three aggregates, and only then constructs the HTTP app and binds a
port. The persistent repository facades preserve the existing synchronous
domain-service contract for reads while serializing accepted writes and
flushing them before graceful shutdown. Development and Jest retain the
in-memory repositories when no database URL is configured.

The migration in `migrations/001_initial_persistence.js` defines anchors,
liquidity entries, and settlements with foreign keys, numeric(78,0) amounts,
status/amount constraints, and query indexes. Amounts cross the database
boundary as strings and are converted to `bigint`; no financial value is
converted through JavaScript `number`.

Settlement opening is a dedicated database transaction. It locks every
liquidity row for the requested asset in stable anchor order, locks the
pending/executed settlement rows for that asset, calculates committed value,
and inserts the new pending row before committing. This prevents two API
instances from both observing the same remaining capacity. Execute/cancel
also use conditional transactional updates so a pending settlement can only
transition once.

In-Memory Repositories & Test Double
When `DATABASE_URL` is absent, settlement, anchor, and liquidity data are held
in process-local repositories (src/repositories/*), all extending the shared
InMemoryRepository base class. This keeps unit and HTTP tests deterministic
without requiring a live database; production cannot use this fallback because
configuration validation requires `DATABASE_URL` under `NODE_ENV=production`.

Idempotency cache (src/middleware/idempotency.ts) follows the same sequencing:
a process-wide `MemoryIdempotencyStore` (shared across mounts, hard-capped,
Expand Down Expand Up @@ -104,4 +128,4 @@ top-level settlement export so the two column constants cannot diverge.
CSV_COLUMNS (and, for settlements, to both the settlements route and the
nested anchors route) and to the expected-column lists in the tests. Treat a
failure in either guardrail as a real export regression, not as a test to
loosen.
loosen.
49 changes: 49 additions & 0 deletions migrations/001_initial_persistence.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
/* eslint-disable no-undef */
/** @param {import('node-pg-migrate').MigrationBuilder} pgm */
exports.up = (pgm) => {
pgm.createTable("anchors", {
id: { type: "text", primaryKey: true },
name: { type: "text", notNull: true },
registered_at: { type: "timestamptz", notNull: true },
active: { type: "boolean", notNull: true, default: true },
});

pgm.createTable("liquidity_entries", {
anchor: { type: "text", notNull: true, references: "anchors(id)", onDelete: "RESTRICT" },
asset: { type: "text", notNull: true },
amount: { type: "numeric(78,0)", notNull: true },
updated_at: { type: "timestamptz", notNull: true },
});
pgm.addConstraint("liquidity_entries", "liquidity_entries_pkey", {
primaryKey: ["anchor", "asset"],
});
pgm.addConstraint("liquidity_entries", "liquidity_amount_nonnegative", {
check: "amount >= 0",
});
pgm.createIndex("liquidity_entries", ["asset", "anchor"]);

pgm.createTable("settlements", {
id: { type: "bigserial", primaryKey: true },
anchor: { type: "text", notNull: true, references: "anchors(id)", onDelete: "RESTRICT" },
asset: { type: "text", notNull: true },
amount: { type: "numeric(78,0)", notNull: true },
fee: { type: "numeric(78,0)", notNull: true },
status: { type: "text", notNull: true, default: "pending" },
created_at: { type: "timestamptz", notNull: true },
cancel_reason: { type: "text" },
});
pgm.addConstraint("settlements", "settlement_amount_positive", { check: "amount > 0" });
pgm.addConstraint("settlements", "settlement_fee_nonnegative", { check: "fee >= 0" });
pgm.addConstraint("settlements", "settlement_status_valid", {
check: "status IN ('pending', 'executed', 'cancelled')",
});
pgm.createIndex("settlements", ["anchor", "id"]);
pgm.createIndex("settlements", ["asset", "id"]);
pgm.createIndex("settlements", ["status", "id"]);
};

exports.down = (pgm) => {
pgm.dropTable("settlements");
pgm.dropTable("liquidity_entries");
pgm.dropTable("anchors");
};
16 changes: 16 additions & 0 deletions migrations/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# AnchorNet database migrations

Migrations are applied with `node-pg-migrate` and are intentionally separate
from application startup. Deployments should run `npm run migrate:up` before
starting the API; the API then verifies connectivity and fails fast if the
database cannot be reached.

```sh
DATABASE_URL=postgres://anchornet:secret@localhost:5432/anchornet npm run migrate:up
```

Amounts use PostgreSQL `numeric(78,0)`. JavaScript converts those values to
`bigint` at the repository boundary, so values larger than
`Number.MAX_SAFE_INTEGER` are not rounded. Foreign keys prevent orphaned
liquidity and settlement records, and settlement opening locks the relevant
pool rows before checking capacity.
Loading
Loading