Skip to content
Open
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
4 changes: 1 addition & 3 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ jobs:
echo "stellar-scaffold already installed. Clear cache to force reinstall."
fi
- name: Build with Scaffold and build client packages
shell: bash
run: STELLAR_SCAFFOLD_ENV=development stellar-scaffold build --build-clients 2>&1 | tee build_clients.log
- name: Install official Stellar CLI
run: |
Expand Down Expand Up @@ -113,6 +114,3 @@ jobs:
- name: Run Tests
working-directory: ./frontend
run: npm test --if-present

# Workflow run retention settings
retention-days: 30
8 changes: 4 additions & 4 deletions .github/workflows/secrets-check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,15 @@ on:
branches: ["main"]
paths:
- "k8s/**"
- ".github/workflows/secrets-check.yml"
- "scripts/check-k8s-secrets.sh"
pull_request:
branches: ["main"]
paths:
- "k8s/**"
- ".github/workflows/secrets-check.yml"
- "scripts/check-k8s-secrets.sh"

# Retention policy: Keep successful runs for 30 days, failed/cancelled for 7 days
jobs:
check-secrets-placeholders:
name: Verify no real secrets in k8s manifests
Expand All @@ -20,6 +23,3 @@ jobs:

- name: Check backend-secret.yaml for non-placeholder values
run: ./scripts/check-k8s-secrets.sh

# Workflow run retention settings
retention-days: 30
47 changes: 41 additions & 6 deletions backend/Dockerfile
Original file line number Diff line number Diff line change
@@ -1,24 +1,59 @@
FROM node:20-alpine AS builder
FROM node:20-alpine@sha256:fb4cd12c85ee03686f6af5362a0b0d56d50c58a04632e6c0fb8363f609372293 AS builder

WORKDIR /usr/src/app

COPY package*.json ./
RUN npm ci

COPY tsconfig.json ./
COPY tsconfig*.json ./
COPY src/ ./src/

RUN npm run build

FROM node:20-alpine AS production
FROM node:20-alpine@sha256:fb4cd12c85ee03686f6af5362a0b0d56d50c58a04632e6c0fb8363f609372293 AS production-deps

WORKDIR /usr/src/app

COPY package*.json ./
RUN npm ci --only=production
RUN addgroup -S payd && adduser -S -G payd payd \
&& chown payd:payd /usr/src/app

USER payd

COPY --chown=payd:payd package*.json ./
# These packages use lib/ or excel.js in Node; dist/ contains browser bundles.
# Remove them before committing the dependency layer, retaining all Node files
# and native bindings from the unchanged lockfile.
RUN npm ci --omit=dev \
&& rm -rf node_modules/exceljs/dist \
node_modules/@stellar/stellar-sdk/dist \
node_modules/@stellar/stellar-base/dist \
&& npm cache clean --force

COPY --from=builder /usr/src/app/dist ./dist
# Match the Alpine series used by the pinned Node image. Copy only its runtime
# binary and entrypoint so npm, Yarn and C/C++ headers stay in the build stages.
FROM alpine:3.23 AS production

WORKDIR /usr/src/app

RUN apk add --no-cache libstdc++ wget \
&& addgroup -S payd && adduser -S -G payd payd \
&& chown payd:payd /usr/src/app \
&& ln -s node /usr/local/bin/nodejs

COPY --from=production-deps /usr/local/bin/node /usr/local/bin/node
COPY --from=production-deps /usr/local/bin/docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
COPY --chown=payd:payd package*.json ./
COPY --from=production-deps --chown=payd:payd /usr/src/app/node_modules ./node_modules
COPY --from=builder --chown=payd:payd /usr/src/app/dist ./dist
# Public metadata served by dist/app.js; TypeScript does not copy this asset.
COPY --chown=payd:payd .well-known/stellar.toml ./.well-known/stellar.toml

USER payd

EXPOSE 3001

HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
CMD wget -qO- "http://127.0.0.1:${PORT:-3001}/health/live" || exit 1

ENTRYPOINT ["docker-entrypoint.sh"]
CMD ["node", "dist/index.js"]
6 changes: 5 additions & 1 deletion backend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -323,7 +323,7 @@ npm run lint
### Type Checking

```bash
npm run type-check
npm run typecheck
```

## Deployment
Expand All @@ -335,6 +335,10 @@ docker build -t payd-backend .
docker run -p 3001:3001 --env-file .env payd-backend
```

The runtime image runs as `payd` and starts `node dist/index.js`. It contains the Node runtime and production dependencies; npm, Yarn and compilation tools stay in the build stages. The image defines a HEALTHCHECK for `/health/live` and keeps dependency installation separate from source copying so source-only rebuilds reuse dependency layers.

See the [source-pinned Docker acceptance report](../docs/validation/docker-529-20261004/ACCEPTANCE.md) for the measured uncompressed image size, actual runtime UID, dependency-cache results, package execution checks and their limits.

### Environment Variables (Production)

```
Expand Down
3 changes: 2 additions & 1 deletion backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@
"main": "src/index.ts",
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc",
"build": "tsc -p tsconfig.build.json",
"typecheck": "tsc -p tsconfig.json --noEmit",
"start": "node dist/index.js",
"test": "jest",
"test:benchmark": "ts-node src/benchmarks/sds-vs-horizon.benchmark.ts",
Expand Down
2 changes: 1 addition & 1 deletion backend/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ app.use(
app.use(cors());

// Attach request ID to morgan logs for end-to-end traceability
morgan.token('request-id', (req) => (req as any).requestId || '-');
morgan.token<express.Request>('request-id', (req) => req.requestId || '-');
app.use(
morgan(
':method :url :status :res[content-length] - :response-time ms request-id=:request-id'
Expand Down
6 changes: 5 additions & 1 deletion backend/src/controllers/assetController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,11 @@ export class AssetController {
`SELECT COUNT(*) AS count FROM clawback_audit_logs ${whereClause}`,
params
);
const total = parseInt(countResult.rows[0].count, 10);
const countRow = countResult.rows[0];
if (!countRow) {
throw new Error('Clawback count query did not return a row');
}
const total = parseInt(countRow.count, 10);

// Paginated rows
const dataParams = [...params, limit, offset];
Expand Down
4 changes: 2 additions & 2 deletions backend/src/controllers/authController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -301,13 +301,13 @@ export class AuthController {
*/
static oauthCallback(req: express.Request, res: express.Response) {
const frontendUrl = process.env.FRONTEND_URL || 'http://localhost:5173';
const user = req.user as (express.User & { is_2fa_enabled?: boolean }) | undefined;
const user = req.user;

if (!user) {
return res.redirect(`${frontendUrl}/login?error=oauth_failed`);
}

if (user.is_2fa_enabled) {
if ('is_2fa_enabled' in user && user.is_2fa_enabled) {
const challengeToken = generateTwoFactorChallengeToken(user.id);
return res.redirect(
`${frontendUrl}/auth-callback?requires2fa=1&challengeToken=${encodeURIComponent(challengeToken)}`
Expand Down
2 changes: 1 addition & 1 deletion backend/src/controllers/cashFlowForecastController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ export class CashFlowForecastController {
if (!validation.success) {
res.status(400).json({
error: 'Invalid request parameters',
details: validation.error.errors,
details: validation.error.issues,
});
return;
}
Expand Down
2 changes: 2 additions & 0 deletions backend/src/db/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
// Keep legacy database imports on the application's shared connection pool.
export { default, pool, query } from '../config/database.js';
4 changes: 3 additions & 1 deletion backend/src/jobs/part49Jobs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,9 @@ async function runWithAdvisoryLock(
[lockId],
);

if (!rows[0].acquired) {
const lockResult = rows[0];
if (!lockResult) throw new Error('Advisory lock query returned no result');
if (!lockResult.acquired) {
logger.debug(`[${jobName}] Lock held by another pod — skipping`);
return;
}
Expand Down
9 changes: 5 additions & 4 deletions backend/src/middleware/advancedRateLimiting.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ export function advancedRateLimitMiddleware(options: AdvancedRateLimitOptions =
const bypassValid = await validateBypassToken(
bypassToken as string,
req.tenantId,
req.user?.id
req.user?.id.toString()
);
if (bypassValid) {
logger.info('Rate limit bypassed with valid token', {
Expand All @@ -79,7 +79,7 @@ export function advancedRateLimitMiddleware(options: AdvancedRateLimitOptions =
// Get dynamic limits if enabled
let effectiveTier = tier;
if (enableDynamicLimits && req.tenantId) {
const dynamicTier = await getDynamicRateLimit(req.tenantId, req.user?.id);
const dynamicTier = await getDynamicRateLimit(req.tenantId, req.user?.id.toString());
if (dynamicTier) {
effectiveTier = dynamicTier;
}
Expand All @@ -102,7 +102,7 @@ export function advancedRateLimitMiddleware(options: AdvancedRateLimitOptions =
identifier: clientIdentifier,
tier: effectiveTier,
organizationId: req.tenantId,
userId: req.user?.id,
userId: req.user?.id.toString(),
path: req.path,
method: req.method,
ipAddress: extractIpAddress(req),
Expand Down Expand Up @@ -198,7 +198,7 @@ export function tieredOrganizationRateLimit(options: Omit<AdvancedRateLimitOptio
export function endpointRateLimit(config: {
[endpoint: string]: { tier: RateLimitTierName; methods?: string[] };
}) {
return (req: Request, res: Response, next: NextFunction): void => {
return async (req: Request, res: Response, next: NextFunction): Promise<void> => {
for (const [pattern, rules] of Object.entries(config)) {
const regex = new RegExp(pattern);
if (regex.test(req.path)) {
Expand Down Expand Up @@ -388,6 +388,7 @@ function getSystemLoad(): number {
try {
const cpus = os.cpus().length;
const loadAvg = os.loadavg()[0]; // 1-minute load average
if (loadAvg === undefined || cpus === 0) return 0.5;
return Math.min(loadAvg / cpus, 1.0);
} catch {
return 0.5; // safe fallback
Expand Down
9 changes: 5 additions & 4 deletions backend/src/middleware/auditLogger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,9 +71,9 @@ export function auditLoggerMiddleware(options: AuditMiddlewareOptions = {}) {

try {
const auditEntry: AuditLogEntry = {
userId: req.user?.id,
userEmail: req.user?.email,
organizationId: req.tenantId || req.user?.organizationId,
userId: req.user?.id.toString(),
userEmail: req.user?.email ?? undefined,
organizationId: req.tenantId ?? req.user?.organizationId ?? undefined,
action: determineAction(req),
resource: determineResource(req),
resourceId: extractResourceId(req),
Expand Down Expand Up @@ -201,7 +201,8 @@ function determineResource(req: Request): string {
*/
function extractResourceId(req: Request): string | undefined {
// Check common ID patterns in params
return req.params.id || req.params.employeeId || req.params.organizationId || undefined;
const resourceId = req.params.id || req.params.employeeId || req.params.organizationId;
return typeof resourceId === 'string' ? resourceId : undefined;
}

/**
Expand Down
8 changes: 5 additions & 3 deletions backend/src/middleware/organizationRateLimiter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { Request, Response, NextFunction } from 'express';
import crypto from 'crypto';
import pool from '../db/index.js';
import logger from '../utils/logger.js';
import { parseRouteInteger } from '../utils/routeParams.js';

/**
* Rate limit tier configurations
Expand All @@ -16,7 +17,8 @@ export interface RateLimitTier {
/**
* Predefined rate limit tiers
*/
export const RATE_LIMIT_TIERS: Record<string, RateLimitTier> = {
export const RATE_LIMIT_TIERS: Record<string, RateLimitTier> &
Record<'free' | 'standard' | 'premium' | 'enterprise', RateLimitTier> = {
free: {
requestsPerMinute: 10,
requestsPerHour: 100,
Expand Down Expand Up @@ -83,7 +85,7 @@ function getOrganizationId(req: Request): number | null {
}

if (req.params.organizationId) {
return parseInt(req.params.organizationId, 10);
return parseRouteInteger(req.params.organizationId);
}

return null;
Expand Down Expand Up @@ -495,7 +497,7 @@ export async function revokeBypassToken(tokenPrefix: string): Promise<boolean> {
[tokenPrefix]
);

return result.rowCount > 0;
return (result.rowCount ?? 0) > 0;
}

/**
Expand Down
9 changes: 6 additions & 3 deletions backend/src/middleware/requestAuditLogger.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { Request, Response, NextFunction } from 'express';
import { v4 as uuidv4 } from 'uuid';
import { randomUUID as uuidv4 } from 'node:crypto';
import pool from '../db/index.js';
import logger from '../utils/logger.js';
import { parseRouteInteger } from '../utils/routeParams.js';

/**
* Configuration options for request audit logging
Expand Down Expand Up @@ -121,7 +122,7 @@ function getOrganizationId(req: Request): number | null {

// From route params
if (req.params.organizationId) {
return parseInt(req.params.organizationId, 10);
return parseRouteInteger(req.params.organizationId);
}

return null;
Expand Down Expand Up @@ -320,7 +321,9 @@ export function auditCriticalOperation(
const organizationId = getOrganizationId(req);
const userId = getUserId(req);
const resourceType = req.path.split('/')[2] || 'unknown'; // e.g., /api/employees/:id -> 'employees'
const resourceId = req.params.id || req.params[Object.keys(req.params)[0]];
const firstParam = Object.values(req.params)[0];
const resourceParam = req.params.id || firstParam;
const resourceId = typeof resourceParam === 'string' ? resourceParam : undefined;

// Capture before state if this is an update/delete
let beforeState: any = null;
Expand Down
4 changes: 2 additions & 2 deletions backend/src/middleware/requestId.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Request, Response, NextFunction } from 'express';
import { v4 as uuidv4 } from 'uuid';
import { randomUUID } from 'node:crypto';

const REQUEST_ID_HEADER = 'x-request-id';

Expand All @@ -13,7 +13,7 @@ declare global {

export function requestIdMiddleware(req: Request, res: Response, next: NextFunction): void {
const incomingId = req.headers[REQUEST_ID_HEADER];
const requestId = (Array.isArray(incomingId) ? incomingId[0] : incomingId) || uuidv4();
const requestId = (Array.isArray(incomingId) ? incomingId[0] : incomingId) || randomUUID();

req.requestId = requestId;
res.setHeader(REQUEST_ID_HEADER, requestId);
Expand Down
8 changes: 4 additions & 4 deletions backend/src/middleware/smartRateLimiter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,9 @@ export function smartRateLimitMiddleware(options: SmartRateLimitOptions = {}) {

const organizationId = req.tenantId || req.user?.organizationId;

if (!organizationId && organizationBased) {
// No organization context, use IP-based limiting
const clientIdentifier = identifier(req);
if (!organizationId) {
// Organization metrics require an organization; the general limiter
// handles requests that only have an IP address.
return next();
}

Expand Down Expand Up @@ -181,4 +181,4 @@ function defaultIdentifier(req: Request): string {
(req.headers['x-real-ip'] as string) ||
'unknown'
);
}
}
Loading