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 .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,8 @@ AVIATIONSTACK_API_KEY= # optional — leave blank to use mock data
# OTEL_SDK_DISABLED=false

# ─── Server ────────────────────────────────────────────────────────────────────
# Port the API server listens on
# Port the API server listens on. Must be an integer from 1 through 65535;
# startup fails immediately when the configured value is outside this range.
PORT=3001

# Maximum listeners per policy status event emitter. Default: 1000.
Expand Down
19 changes: 18 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,11 @@ Supporting infrastructure:
- **PrismaService** — PostgreSQL integration for policy and oracle data storage
- **AuthModule** — Stellar wallet signature verification + JWT issuance
- **LoggingInterceptor** — Request/response duration logging
- **ThrottleGuard** — IP-based rate limiting (60 req/min)
- **UserThrottlerGuard** — anonymous callers use per-IP rate limiting (60 req/min);
authenticated callers use isolated per-wallet buckets with a 5× higher quota

Server configuration validates `PORT` during startup. It must be an integer in
the TCP port range `1`–`65535`; when omitted, the API uses port `3001`.
- **Vault bootstrap** — optional HashiCorp Vault KV loading before Nest validates env vars
- **OpenTelemetry** — optional distributed tracing when OTEL packages are installed/configured

Expand Down Expand Up @@ -424,6 +428,14 @@ For reference, successful responses use the complementary envelope:

Monetary values are returned as strings in 7-decimal fixed-point format matching Stellar asset precision (e.g. `"10.0000000"`).

### Premium-rate changes

Each product premium-rate template has a monotonically increasing version.
Changing `premiumRate` creates the next version; edits to unrelated product
fields do not. A policy records both the rate and template version in effect at
purchase time. Later product updates affect new quotes and policies only—an
existing policy's premium terms are never changed retroactively.

### BigInt serialization & API contract change (#671)

All `BigInt` values (such as oracle readings, large policy IDs, and 64-bit timestamps or token amounts) are centrally serialized as decimal strings across all JSON responses, SSE streams, logging paths, and error envelopes.
Expand Down Expand Up @@ -481,6 +493,11 @@ CONCURRENCY_TEST_DATABASE_URL=postgresql://.../parashield_test npx jest claims.c

**Threshold evaluation (#712):** oracle readings are 7-decimal fixed point (e.g. `324000000` = 32.4) and product thresholds are `Decimal(20,7)`. Trigger comparisons are done on exact scaled `BigInt` values (`src/common/utils/decimal.ts`) rather than `parseFloat`, so the last decimal place is never lost and a reading exactly equal to the threshold never triggers (strict `LessThan` / greater-than).

**Claim evaluation isolation:** the policy and its latest oracle reading are
loaded inside one PostgreSQL `REPEATABLE READ` transaction. Updates committed
after evaluation begins are intentionally deferred to the next evaluation, so
a decision never combines an old policy with a new oracle-key state.

---

## Project layout
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
-- Version product premium-rate templates and preserve the exact template used
-- by every policy. Existing policies are backfilled from their current product.
ALTER TABLE "Product"
ADD COLUMN "premiumRateVersion" INTEGER NOT NULL DEFAULT 1;

ALTER TABLE "Policy"
ADD COLUMN "premiumRate" INTEGER,
ADD COLUMN "premiumRateVersion" INTEGER;

UPDATE "Policy" AS policy
SET
"premiumRate" = product."premiumRate",
"premiumRateVersion" = product."premiumRateVersion"
FROM "Product" AS product
WHERE policy."productId" = product."id";

ALTER TABLE "Policy"
ALTER COLUMN "premiumRate" SET NOT NULL,
ALTER COLUMN "premiumRate" SET DEFAULT 0,
ALTER COLUMN "premiumRateVersion" SET NOT NULL,
ALTER COLUMN "premiumRateVersion" SET DEFAULT 1;
4 changes: 4 additions & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ model Policy {
policyholder String
coverageXlm Decimal @db.Decimal(20, 7)
premiumPaid Decimal @db.Decimal(20, 7)
// Immutable snapshot of the product's premium template at purchase time.
premiumRate Int @default(0)
premiumRateVersion Int @default(1)
oracleKey String
startTime DateTime
endTime DateTime
Expand Down Expand Up @@ -45,6 +48,7 @@ model Product {
coverageMin Decimal @db.Decimal(20, 7)
coverageMax Decimal @db.Decimal(20, 7)
premiumRate Int
premiumRateVersion Int @default(1)
maxDuration Int
status ProductStatus @default(ACTIVE)
policies Policy[]
Expand Down
7 changes: 7 additions & 0 deletions src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { WebhooksModule } from './common/webhooks/webhooks.module';
import { validateJwtSecret } from './auth/jwt-secret.validator';
import { validateAllSecrets } from './common/secrets/secret-validator';
import { OpenTelemetryShutdownHook } from './common/telemetry/opentelemetry';
import { validatePortEnv } from './common/utils/config.utils';

/**
* Validate loaded environment configuration at startup.
Expand All @@ -29,6 +30,12 @@ function validateConfig(config: Record<string, unknown>) {
const errors: string[] = [];
const logger = new Logger('ConfigValidation');

try {
config['PORT'] = validatePortEnv(config['PORT']);
} catch (err) {
errors.push((err as Error).message);
}

// #343 — not a hard requirement (a localhost fallback is fine for local
// dev), but the ThrottlerModule storage below falls back to it silently,
// which is easy to miss in a real deployment that meant to point at a
Expand Down
62 changes: 62 additions & 0 deletions src/claims/claim-evaluation-snapshot.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { Prisma } from '@prisma/client';
import { readClaimEvaluationSnapshot } from './claim-evaluation-snapshot';

describe('readClaimEvaluationSnapshot', () => {
it('reads policy and oracle state in one repeatable-read transaction', async () => {
const policy = { id: 'policy-1', oracleKey: 'rainfall:old' };
const reading = { id: 'reading-1', key: 'rainfall:old', value: 10n };
const transactionClient = {
policy: { findUnique: jest.fn().mockResolvedValue(policy) },
oracleReading: { findFirst: jest.fn().mockResolvedValue(reading) },
};
const prisma = {
$transaction: jest.fn(async (callback) => callback(transactionClient)),
} as any;

await expect(readClaimEvaluationSnapshot(prisma, policy.id)).resolves.toEqual({
policy,
reading,
});
expect(prisma.$transaction).toHaveBeenCalledWith(
expect.any(Function),
{ isolationLevel: Prisma.TransactionIsolationLevel.RepeatableRead },
);
expect(transactionClient.oracleReading.findFirst).toHaveBeenCalledWith({
where: { key: 'rainfall:old' },
orderBy: { submittedAt: 'desc' },
});
});

it('cannot mix a concurrent policy update with a newer oracle key', async () => {
const database = {
policy: { id: 'policy-1', oracleKey: 'rainfall:v1' },
readings: {
'rainfall:v1': { id: 'reading-v1', key: 'rainfall:v1' },
'rainfall:v2': { id: 'reading-v2', key: 'rainfall:v2' },
} as Record<string, object>,
};
const prisma = {
$transaction: jest.fn(async (callback) => {
// Capture the database state as PostgreSQL does at REPEATABLE READ.
const snapshot = structuredClone(database);
const tx = {
policy: {
findUnique: jest.fn(async () => {
database.policy.oracleKey = 'rainfall:v2'; // concurrent commit
return snapshot.policy;
}),
},
oracleReading: {
findFirst: jest.fn(async ({ where }) => snapshot.readings[where.key] ?? null),
},
};
return callback(tx);
}),
} as any;

const result = await readClaimEvaluationSnapshot(prisma, 'policy-1');
expect(result.policy?.oracleKey).toBe('rainfall:v1');
expect(result.reading?.key).toBe('rainfall:v1');
expect(database.policy.oracleKey).toBe('rainfall:v2');
});
});
25 changes: 25 additions & 0 deletions src/claims/claim-evaluation-snapshot.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { Prisma, PrismaClient } from '@prisma/client';

/**
* Read every mutable input needed to begin claim evaluation from one
* PostgreSQL REPEATABLE READ snapshot. A product/oracle update committed while
* this callback is running cannot be mixed with the policy state read first.
*/
export async function readClaimEvaluationSnapshot(
prisma: PrismaClient,
policyId: string,
) {
return prisma.$transaction(
async (tx) => {
const policy = await tx.policy.findUnique({ where: { id: policyId } });
if (!policy) return { policy: null, reading: null };

const reading = await tx.oracleReading.findFirst({
where: { key: policy.oracleKey },
orderBy: { submittedAt: 'desc' },
});
return { policy, reading };
},
{ isolationLevel: Prisma.TransactionIsolationLevel.RepeatableRead },
);
}
10 changes: 6 additions & 4 deletions src/claims/claims.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { ErrorCode } from '../common/errors/error-codes';
import { compareScaledToThreshold, parseDecimalScaled, parseFixedPointBigInt } from '../common/utils/decimal';
import { financialBatch, financialTransaction, isSerializationFailure } from '../prisma/financial-transaction';
import { PolicyStateCacheService, DerivedPolicyState } from './policy-state-cache.service';
import { readClaimEvaluationSnapshot } from './claim-evaluation-snapshot';

export type ClaimResult = 'Paid' | 'Rejected' | 'Expired' | 'AlreadyClaimed' | 'AlreadyProcessed' | 'PolicyNotActive' | 'PendingFinalPeriod';

Expand Down Expand Up @@ -109,8 +110,9 @@ export class ClaimsService {
async autoProcess(policyId: string, productsMap?: Map<string, ProductSummary>): Promise<ClaimResult> {
this.logger.log(`auto_process policy: ${policyId}`);

// Fetch policy from DB to verify it exists and is active
const policy = await this.prisma.policy.findUnique({ where: { id: policyId } });
// Policy and oracle input must come from the same database snapshot. At
// REPEATABLE READ, a concurrent update cannot produce a mixed decision.
const { policy, reading } = await readClaimEvaluationSnapshot(this.prisma, policyId);
if (!policy) {
this.logger.warn(`Policy ${policyId} not found in DB`);
return 'PolicyNotActive';
Expand Down Expand Up @@ -206,8 +208,8 @@ export class ClaimsService {
this.claimCountCache.delete(policy.policyholder);
this.logger.log(`Claim record created: id=${claim.id} policyId=${policyId}`);

// Fetch latest oracle reading for this policy's oracle key
const reading = await this.oracleService.getLatestReading(policy.oracleKey);
// Use the oracle row captured with the policy above; do not issue a second,
// independently committed read here.
if (!reading) {
this.logger.warn(`No oracle reading for key=${policy.oracleKey} — rejecting claim ${claim.id}`);
await financialBatch(this.prisma, `claim ${claim.id}`, () => [
Expand Down
77 changes: 77 additions & 0 deletions src/common/guards/user-throttler.guard.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { ExecutionContext } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { UserThrottlerGuard } from './user-throttler.guard';

class TestGuard extends UserThrottlerGuard {
public run(props: any) {
return this.handleRequest(props);
}
}

function contextFor(request: Record<string, unknown>): ExecutionContext {
return {
switchToHttp: () => ({
getRequest: () => request,
getResponse: () => ({ setHeader: jest.fn() }),
}),
getClass: () => class TestController {},
getHandler: () => function testHandler() {},
} as unknown as ExecutionContext;
}

describe('UserThrottlerGuard differentiated limits', () => {
const increment = jest.fn().mockResolvedValue({
totalHits: 1,
timeToExpire: 60,
isBlocked: false,
timeToBlockExpire: 0,
});
const jwtService = { verifyAsync: jest.fn() };
const guard = new TestGuard(
[{ name: 'default', ttl: 60_000, limit: 60 }],
{ increment } as any,
new Reflector(),
jwtService as any,
);

const requestProps = (request: Record<string, unknown>) => ({
context: contextFor(request),
limit: 60,
ttl: 60_000,
blockDuration: 60_000,
throttler: { name: 'default' },
getTracker: jest.fn(),
generateKey: jest.fn((_context, tracker) => `rate:${tracker}`),
});

beforeAll(async () => guard.onModuleInit());

beforeEach(() => jest.clearAllMocks());

it('keeps anonymous callers in the lower per-IP bucket', async () => {
await guard.run(requestProps({ ip: '203.0.113.10', headers: {} }));
expect(increment).toHaveBeenCalledWith(
'rate:203.0.113.10', 60_000, 60, 60_000, 'default',
);
});

it('gives authenticated callers a higher per-user limit', async () => {
await guard.run(requestProps({
ip: '203.0.113.10',
wallet: 'GALICE',
headers: {},
}));
expect(increment).toHaveBeenCalledWith(
'rate:user:GALICE', 60_000, 300, 60_000, 'default',
);
});

it('uses independent buckets for authenticated users sharing an IP', async () => {
await guard.run(requestProps({ ip: '203.0.113.10', wallet: 'GALICE', headers: {} }));
await guard.run(requestProps({ ip: '203.0.113.10', wallet: 'GBOB', headers: {} }));
expect(increment.mock.calls.map((call) => call[0])).toEqual([
'rate:user:GALICE',
'rate:user:GBOB',
]);
});
});
21 changes: 21 additions & 0 deletions src/common/guards/user-throttler.guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
ThrottlerStorage,
InjectThrottlerOptions,
InjectThrottlerStorage,
ThrottlerRequest,
} from '@nestjs/throttler';
import { JwtService } from '../../auth/jwt.service';

Expand All @@ -30,6 +31,8 @@ import { JwtService } from '../../auth/jwt.service';
*/
@Injectable()
export class UserThrottlerGuard extends ThrottlerGuard {
private static readonly AUTHENTICATED_LIMIT_MULTIPLIER = 5;

constructor(
@InjectThrottlerOptions() options: ThrottlerModuleOptions,
@InjectThrottlerStorage() storageService: ThrottlerStorage,
Expand Down Expand Up @@ -59,4 +62,22 @@ export class UserThrottlerGuard extends ThrottlerGuard {

return super.getTracker(req);
}

/**
* Anonymous requests retain the configured IP quota. Authenticated requests
* receive a separate, per-wallet bucket with a five-times-higher quota.
*/
protected async handleRequest(requestProps: ThrottlerRequest): Promise<boolean> {
const { req } = this.getRequestResponse(requestProps.context);
const tracker = await this.getTracker(req);
const authenticated = tracker.startsWith('user:');

return super.handleRequest({
...requestProps,
limit: authenticated
? requestProps.limit * UserThrottlerGuard.AUTHENTICATED_LIMIT_MULTIPLIER
: requestProps.limit,
getTracker: async () => tracker,
});
}
}
18 changes: 18 additions & 0 deletions src/common/utils/config.utils.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { validatePortEnv } from './config.utils';

describe('validatePortEnv', () => {
it.each([1, 3001, 65_535, '8080'] as const)('accepts valid port %p', (value) => {
expect(validatePortEnv(value)).toBe(Number(value));
});

it('uses the documented default when unset', () => {
expect(validatePortEnv(undefined)).toBe(3001);
});

it.each([0, -1, 65_536, '1.5', 'abc', '3000junk', Number.NaN, Number.POSITIVE_INFINITY])(
'fails fast for invalid port %p',
(value) => {
expect(() => validatePortEnv(value)).toThrow('PORT must be an integer between 1 and 65535');
},
);
});
9 changes: 9 additions & 0 deletions src/common/utils/config.utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,3 +73,12 @@ export function validateOptionalUrlEnv(value: string | undefined, name: string):
}
return validateUrlEnv(value, name);
}

/** Validate a TCP port and return its numeric value. */
export function validatePortEnv(value: unknown, name = 'PORT', defaultValue = 3001): number {
const candidate = value === undefined || value === null || value === '' ? defaultValue : Number(value);
if (!Number.isInteger(candidate) || candidate < 1 || candidate > 65_535) {
throw new Error(`${name} must be an integer between 1 and 65535`);
}
return candidate;
}
3 changes: 2 additions & 1 deletion src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import { json, urlencoded } from 'express';
import Redis from 'ioredis';
import { validateJwtSecret } from './auth/jwt-secret.validator';
import { validateAllSecrets } from './common/secrets/secret-validator';
import { validatePortEnv } from './common/utils/config.utils';

const REQUEST_BODY_LIMIT = '1mb';
const SERVER_TIMEOUT_MS = 30_000;
Expand Down Expand Up @@ -479,7 +480,7 @@ async function bootstrap() {

app.enableShutdownHooks();

const port = configService.get<string>('PORT') ?? 3001;
const port = validatePortEnv(configService.get('PORT'));
const server = await app.listen(port);
server.timeout = SERVER_TIMEOUT_MS;
logger.log(`Parashield API running on http://localhost:${port}/api/v1`);
Expand Down
Loading