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
3 changes: 2 additions & 1 deletion Readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -180,7 +180,8 @@ kyc_verifications
notifications
```

See [docs/DATABASE.md](docs/DATABASE.md) for complete schema.
See [docs/DB_WORKFLOW.md](docs/DB_WORKFLOW.md) for the local migration workflow,
entity-to-schema alignment notes, and transaction foreign-key ownership.

## 🧪 Testing
```bash
Expand Down
18 changes: 18 additions & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Security Policy

## Reporting a vulnerability

Please do not open public GitHub issues for security problems.

- Email the maintainers with the issue summary, affected area, reproduction steps,
and any suggested mitigation.
- Include whether the report affects authentication, payments, Soroban escrow
orchestration, or database integrity.
- If you need an acknowledgement, include a preferred contact address in the report.

## Operational checklist

- Configure `CORS_ALLOWED_ORIGINS` explicitly in production.
- Enable `TRUST_PROXY` only behind a trusted reverse proxy.
- Keep `HTTP_BODY_SIZE_LIMIT` small unless a route has a documented reason to exceed it.
- Store private keys only in environment variables; never commit them to the repository.
43 changes: 43 additions & 0 deletions docs/DB_WORKFLOW.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# Database Workflow

This repository uses TypeORM migrations against PostgreSQL. The canonical
schema starts with `src/migrations/1731513600000-InitialSchema.ts` and is
extended by follow-up migrations in timestamp order.

## Local setup

```bash
createdb stellarsettle_dev
export DATABASE_URL=postgresql://localhost:5432/stellarsettle_dev
export JWT_SECRET=dev-secret
export STELLAR_HORIZON_URL=https://horizon-testnet.stellar.org
export STELLAR_USDC_ASSET_CODE=USDC
export STELLAR_USDC_ASSET_ISSUER=GXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
export STELLAR_ESCROW_PUBLIC_KEY=GXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
npm install
npm run db:migrate
npm run type-check
npm run dev
```

## Entity alignment notes

- `User.stellarAddress`, `User.userType`, and `User.kycStatus` map to the
quoted camelCase columns created by the initial migration.
- `Investment.stellarOperationIndex` and `Transaction.investmentId` come from
`1731700000000-AddInvestmentPaymentVerification.ts`.
- `Transaction.invoiceId` is added by
`1731800000000-AddTransactionInvoiceAndInvestmentLinks.ts`.

## Transaction foreign-key ownership

| TransactionType | `invoice_id` | `investment_id` | Notes |
| --- | --- | --- | --- |
| `investment` | Set to the funded invoice | Set to the investment row | Funding an invoice through an investment |
| `payment` | Set to the repaid invoice | Nullable | Seller repayment or invoice settlement |
| `withdrawal` | Nullable | Nullable | User-level cash movement, not invoice-scoped |
| `refund` | Set when refund is invoice-specific | Set when refund reverses an investment | Populate whichever entity the refund reverses |

For existing environments, historical backfill can be done later by joining
transactions to investments through the stored `investment_id` and the linked
invoice on `investments.invoice_id`.
29 changes: 29 additions & 0 deletions docs/SOROBAN_ESCROW_ORCHESTRATION.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# Soroban Escrow Orchestration

This backend uses a wallet-signing model for Soroban escrow funding.

## Security model

- The server does **not** sign Soroban funding transactions.
- When `SOROBAN_ESCROW_ENABLED=true`, the backend prepares an XDR payload and
records a pending transaction row linked to the relevant investment and invoice.
- Final DB confirmation still comes from Horizon verification and/or the
reconciliation worker, not from the draft preparation call alone.

## Sequence

```mermaid
sequenceDiagram
participant Client
participant API
participant SorobanWrapper as Soroban Escrow Client
participant Horizon

Client->>API: fund investment
API->>SorobanWrapper: prepareInvestmentFunding(investment, invoice, investor)
SorobanWrapper-->>API: unsigned XDR draft
API->>API: persist pending transaction(invoice_id, investment_id)
API-->>Client: XDR for wallet signing
Client->>Horizon: submit signed transaction
API->>Horizon: verify/reconcile on confirmation
```
8 changes: 4 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
"test": "jest",
"lint": "eslint \"src/**/*.ts\"",
"type-check": "tsc --noEmit",
"db:migrate": "typeorm migration:run -d src/config/data-source.ts",
"db:migrate": "node -r ts-node/register ./node_modules/typeorm/cli.js migration:run -d src/config/data-source.ts",
"prepare": "husky"
},
"dependencies": {
Expand Down
121 changes: 119 additions & 2 deletions src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,19 +13,135 @@ export interface AppDependencies {
logger?: AppLogger;
metricsEnabled?: boolean;
metricsRegistry?: MetricsRegistry;
http?: {
trustProxy?: boolean | number | string;
corsAllowedOrigins?: string[];
corsAllowCredentials?: boolean;
bodySizeLimit?: string;
nodeEnv?: string;
};
requestLifecycleTracker?: RequestLifecycleTracker;
}

export interface RequestLifecycleTracker {
onRequestStart(): void;
onRequestEnd(): void;
waitForDrain(timeoutMs: number): Promise<boolean>;
}

function createCorsOptions({
allowedOrigins,
allowCredentials,
nodeEnv,
}: {
allowedOrigins: string[];
allowCredentials: boolean;
nodeEnv: string;
}): cors.CorsOptions {
return {
credentials: allowCredentials,
origin(origin, callback) {
if (!origin) {
callback(null, true);
return;
}

if (allowedOrigins.includes(origin)) {
callback(null, true);
return;
}

if (nodeEnv !== "production" && allowedOrigins.length === 0) {
callback(null, true);
return;
}

callback(null, false);
},
};
}

export function createRequestLifecycleTracker(): RequestLifecycleTracker {
let activeRequests = 0;
let drainResolvers: Array<(drained: boolean) => void> = [];

const resolveDrainIfIdle = () => {
if (activeRequests !== 0) {
return;
}

const resolvers = drainResolvers;
drainResolvers = [];
resolvers.forEach((resolve) => resolve(true));
};

return {
onRequestStart() {
activeRequests += 1;
},
onRequestEnd() {
activeRequests = Math.max(0, activeRequests - 1);
resolveDrainIfIdle();
},
waitForDrain(timeoutMs: number) {
if (activeRequests === 0) {
return Promise.resolve(true);
}

return new Promise((resolve) => {
const timeout = setTimeout(() => {
drainResolvers = drainResolvers.filter((item) => item !== resolve);
resolve(false);
}, timeoutMs);

drainResolvers.push((drained) => {
clearTimeout(timeout);
resolve(drained);
});
});
},
};
}

export function createApp({
authService,
logger: appLogger = logger,
metricsEnabled = true,
metricsRegistry = new MetricsRegistry(),
http,
requestLifecycleTracker = createRequestLifecycleTracker(),
}: AppDependencies) {
const app = express();
const corsAllowedOrigins = http?.corsAllowedOrigins ?? [];
const corsAllowCredentials = http?.corsAllowCredentials ?? true;
const bodySizeLimit = http?.bodySizeLimit ?? "1mb";
const trustProxy = http?.trustProxy ?? false;
const nodeEnv = http?.nodeEnv ?? process.env.NODE_ENV ?? "development";

app.set("trust proxy", trustProxy);
app.use(helmet());
app.use(cors());
app.use(express.json());
app.use(
cors(
createCorsOptions({
allowedOrigins: corsAllowedOrigins,
allowCredentials: corsAllowCredentials,
nodeEnv,
}),
),
);
app.use(express.json({ limit: bodySizeLimit }));
app.use((req, res, next) => {
requestLifecycleTracker.onRequestStart();
const finalize = () => {
res.off("finish", finalize);
res.off("close", finalize);
requestLifecycleTracker.onRequestEnd();
};

res.on("finish", finalize);
res.on("close", finalize);
next();
});
app.use(
createRequestObservabilityMiddleware({
logger: appLogger,
Expand Down Expand Up @@ -53,6 +169,7 @@ export function createApp({

app.use(notFoundMiddleware);
app.use(createErrorMiddleware(appLogger));
app.locals.requestLifecycleTracker = requestLifecycleTracker;

return app;
}
Loading
Loading