Skip to content

Commit c0a6052

Browse files
authored
Merge branch 'main' into main
2 parents cd3b662 + 5e35eea commit c0a6052

66 files changed

Lines changed: 3189 additions & 363 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.cursor/rules/git-commits.mdc

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
description: Git commit authorship — never add Cursor co-author trailers
3+
alwaysApply: true
4+
---
5+
6+
# Git commits
7+
8+
When creating or amending commits:
9+
10+
- **Never** add `Co-authored-by: Cursor` or any `Co-authored-by:` trailer unless the user explicitly asks for it.
11+
- Commit messages must contain only the subject/body the user requested (e.g. `feat(scope): description (#issue)`).
12+
- Do not use flags or hooks that attribute commits to Cursor or other agents.
13+
14+
Author and committer must remain the user's git identity only.

backend/README.md

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,13 +57,22 @@ Generic `{statusCode, message}` (no violations – security: no hints).
5757
## API
5858
See `/docs`.
5959

60+
### Versioning
61+
62+
REST routes are versioned with a URI prefix: **`/api/v1/...`** (NestJS URI versioning, default version `1`).
63+
64+
- **Current version:** `v1` — all controllers are served under `/api/v1/` unless noted otherwise.
65+
- **Deprecated routes:** Handlers marked with `@DeprecatedApi()` respond with `Deprecation: true` and an HTTP-date `Sunset` header (`DEPRECATED_API_SUNSET_HTTP_DATE` in `src/common/versioning/api-versioning.constants.ts`). Experimental endpoints under `/api/v1/experimental/*` are deprecated and scheduled for removal after the sunset date.
66+
- **Production:** Requests to `/api/*` without a `v{n}` segment (except `/docs` and `/openapi.json`) return **404** via `RejectUnversionedApiMiddleware`.
67+
6068
## GraphQL
6169

62-
GraphQL is exposed at `/api/graphql`.
70+
GraphQL is exposed at `/api/v1/graphql` (same global API version prefix).
6371

6472
- Schema style: code-first (`src/graphql`)
6573
- Production introspection defaults to off
6674
- Apollo landing page is disabled in production
75+
- Depth and complexity limits: `MAX_QUERY_DEPTH` / `MAX_QUERY_COMPLEXITY` (fallback: `GRAPHQL_MAX_DEPTH` / `GRAPHQL_MAX_COMPLEXITY`). Breaches return HTTP 400 with `GRAPHQL_DEPTH_LIMIT` or `GRAPHQL_COMPLEXITY_LIMIT`.
6776
- See [`docs/graphql.md`](./docs/graphql.md)
6877
- Security sign-off checklist: [`docs/graphql-security-checklist.md`](./docs/graphql-security-checklist.md)
6978

backend/package-lock.json

Lines changed: 0 additions & 48 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

backend/src/app.module.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { Module, MiddlewareConsumer, NestModule, RequestMethod } from '@nestjs/common';
2+
import { APP_INTERCEPTOR } from '@nestjs/core';
23
import { ConfigModule } from '@nestjs/config';
34
import { TerminusModule } from '@nestjs/terminus';
45
import { ThrottlerModule, ThrottlerStorage } from '@nestjs/throttler';
@@ -30,6 +31,8 @@ import { AppLoggerService } from './common/logger/app-logger.service';
3031
import { OracleHooksController } from './experimental/oracle-hooks.controller';
3132
import { BetaCalculatorsController } from './experimental/beta-calculators.controller';
3233
import { IdempotencyMiddleware } from './common/middleware/idempotency.middleware';
34+
import { DeprecationHeadersInterceptor } from './common/versioning/deprecation-headers.interceptor';
35+
import { RejectUnversionedApiMiddleware } from './common/versioning/reject-unversioned-api.middleware';
3336

3437
/** Mutation routes that require idempotency key support (issue #363). */
3538
const IDEMPOTENCY_ROUTES = [
@@ -82,10 +85,18 @@ const IDEMPOTENCY_ROUTES = [
8285
EventsModule,
8386
],
8487
controllers: [OracleHooksController, BetaCalculatorsController],
85-
providers: [RequestContextMiddleware, AppLoggerService],
88+
providers: [
89+
RequestContextMiddleware,
90+
AppLoggerService,
91+
{
92+
provide: APP_INTERCEPTOR,
93+
useClass: DeprecationHeadersInterceptor,
94+
},
95+
],
8696
})
8797
export class AppModule implements NestModule {
8898
configure(consumer: MiddlewareConsumer) {
99+
consumer.apply(RejectUnversionedApiMiddleware).forRoutes('*');
89100
consumer.apply(RequestContextMiddleware).forRoutes('*');
90101
// Apply idempotency middleware to all mutation endpoints (issue #363)
91102
consumer.apply(IdempotencyMiddleware).forRoutes(...IDEMPOTENCY_ROUTES);
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
/**
2+
* Claim deadline processor integration tests (issue #650) — mocked Soroban RPC.
3+
*/
4+
import { Test, TestingModule } from '@nestjs/testing';
5+
import { ClaimStatus } from '@prisma/client';
6+
import { ClaimDeadlineProcessorService } from '../claim-deadline.processor.service';
7+
import { SorobanService } from '../../rpc/soroban.service';
8+
import { PrismaService } from '../../prisma/prisma.service';
9+
import { ConfigService } from '@nestjs/config';
10+
import { CLAIM_VOTING_WINDOW_LEDGERS } from '../claim-deadline.constants';
11+
12+
const sorobanMock = {
13+
finalizeClaim: jest.fn(),
14+
};
15+
16+
const prismaMock = {
17+
ledgerCursor: { findUnique: jest.fn() },
18+
claim: {
19+
findMany: jest.fn(),
20+
findUnique: jest.fn(),
21+
update: jest.fn(),
22+
updateMany: jest.fn(),
23+
},
24+
};
25+
26+
describe('ClaimDeadlineProcessorService (integration)', () => {
27+
let service: ClaimDeadlineProcessorService;
28+
29+
beforeEach(async () => {
30+
jest.clearAllMocks();
31+
32+
const moduleRef: TestingModule = await Test.createTestingModule({
33+
providers: [
34+
ClaimDeadlineProcessorService,
35+
{ provide: SorobanService, useValue: sorobanMock },
36+
{ provide: PrismaService, useValue: prismaMock },
37+
{
38+
provide: ConfigService,
39+
useValue: { get: jest.fn((key: string, def?: unknown) => (key === 'STELLAR_NETWORK' ? 'testnet' : def)) },
40+
},
41+
],
42+
}).compile();
43+
44+
service = moduleRef.get(ClaimDeadlineProcessorService);
45+
});
46+
47+
it('finalizes expired claim and updates DB on successful RPC', async () => {
48+
prismaMock.ledgerCursor.findUnique.mockResolvedValue({ lastProcessedLedger: 200_000 });
49+
prismaMock.claim.findMany.mockResolvedValue([{ id: 42, createdAtLedger: 50_000 }]);
50+
prismaMock.claim.findUnique.mockResolvedValue({
51+
id: 42,
52+
isFinalized: false,
53+
status: 'PENDING',
54+
});
55+
sorobanMock.finalizeClaim.mockResolvedValue({
56+
txHash: 'abc123',
57+
ledger: 200_001,
58+
onChainStatus: 'Approved',
59+
});
60+
61+
const outcome = await service.processClaim(42);
62+
63+
expect(outcome).toBe('finalized');
64+
expect(sorobanMock.finalizeClaim).toHaveBeenCalledWith(42);
65+
expect(prismaMock.claim.update).toHaveBeenCalledWith({
66+
where: { id: 42 },
67+
data: expect.objectContaining({
68+
isFinalized: true,
69+
status: ClaimStatus.APPROVED,
70+
txHash: 'abc123',
71+
}),
72+
});
73+
});
74+
75+
it('logs RPC failure and returns failed without throwing', async () => {
76+
prismaMock.claim.findUnique.mockResolvedValue({
77+
id: 7,
78+
isFinalized: false,
79+
status: 'PENDING',
80+
});
81+
sorobanMock.finalizeClaim.mockRejectedValue(new Error('RPC timeout'));
82+
83+
const outcome = await service.processClaim(7);
84+
85+
expect(outcome).toBe('failed');
86+
expect(prismaMock.claim.update).not.toHaveBeenCalled();
87+
});
88+
89+
it('skips already-finalized claim cleanly', async () => {
90+
prismaMock.claim.findUnique.mockResolvedValue({
91+
id: 9,
92+
isFinalized: true,
93+
status: ClaimStatus.APPROVED,
94+
});
95+
96+
const outcome = await service.processClaim(9);
97+
98+
expect(outcome).toBe('skipped');
99+
expect(sorobanMock.finalizeClaim).not.toHaveBeenCalled();
100+
});
101+
102+
it('scan selects claims past deadline ledger', async () => {
103+
const currentLedger = CLAIM_VOTING_WINDOW_LEDGERS + 1_000;
104+
prismaMock.ledgerCursor.findUnique.mockResolvedValue({ lastProcessedLedger: currentLedger });
105+
prismaMock.claim.findMany.mockResolvedValue([]);
106+
107+
await service.runScan();
108+
109+
expect(prismaMock.claim.findMany).toHaveBeenCalledWith(
110+
expect.objectContaining({
111+
where: expect.objectContaining({
112+
status: 'PENDING',
113+
isFinalized: false,
114+
createdAtLedger: { lte: currentLedger - CLAIM_VOTING_WINDOW_LEDGERS },
115+
}),
116+
}),
117+
);
118+
});
119+
});
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { Injectable, Logger, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
2+
import { ClaimDeadlineProcessorService } from './claim-deadline.processor.service';
3+
import {
4+
closeClaimDeadlineQueue,
5+
ensureClaimDeadlineRepeatableJob,
6+
startClaimDeadlineWorker,
7+
} from './claim-deadline.queue';
8+
9+
@Injectable()
10+
export class ClaimDeadlineBootstrap implements OnModuleInit, OnModuleDestroy {
11+
private readonly logger = new Logger(ClaimDeadlineBootstrap.name);
12+
13+
constructor(private readonly processor: ClaimDeadlineProcessorService) {}
14+
15+
async onModuleInit(): Promise<void> {
16+
if (process.env.DISABLE_CLAIM_DEADLINE_PROCESSOR === 'true') {
17+
this.logger.log('Claim deadline processor disabled (DISABLE_CLAIM_DEADLINE_PROCESSOR=true)');
18+
return;
19+
}
20+
21+
await ensureClaimDeadlineRepeatableJob();
22+
startClaimDeadlineWorker(() => this.processor.runScan());
23+
this.logger.log('Claim deadline BullMQ repeatable scan registered');
24+
}
25+
26+
async onModuleDestroy(): Promise<void> {
27+
await closeClaimDeadlineQueue();
28+
}
29+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
/** BullMQ queue for scanning and finalizing claims past voting deadline. */
2+
export const CLAIM_DEADLINE_QUEUE_NAME = 'claim-deadline-processor';
3+
4+
/** Repeatable scan job id (deduplicated by BullMQ). */
5+
export const CLAIM_DEADLINE_REPEAT_JOB_ID = 'claim-deadline-scan';
6+
7+
/** Matches claim-view.mapper / on-chain default voting window. */
8+
export const CLAIM_VOTING_WINDOW_LEDGERS = 120_960;
9+
10+
/** Max claims processed per scan tick. */
11+
export const CLAIM_DEADLINE_BATCH_SIZE = 50;
12+
13+
/** Default repeatable cadence when CLAIM_DEADLINE_CRON is unset (every 15 minutes). */
14+
export const DEFAULT_CLAIM_DEADLINE_CRON = '0 */15 * * * *';

0 commit comments

Comments
 (0)