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
35 changes: 35 additions & 0 deletions app/backend/src/handlers/transaction.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { Request, Response } from 'express';

interface SubmitTransactionRequest {
_transactionXdr: string; // underscore = intentionally unused
networkPassphrase?: string; // will be renamed with underscore
}

// POST /v1/transactions/submit
export const submitTransaction = (req: Request, res: Response) => {
// Mark unused networkPassphrase with underscore prefix
const { _transactionXdr, networkPassphrase: _networkPassphrase } =
req.body as SubmitTransactionRequest;

try {
const result = {
hash: 'stub-hash-' + Date.now(),
resultXdr: 'AAAAAAA=',
ledger: 1,
};
return res.status(200).json(result);
} catch (error: unknown) {
// Safe error message access
const detail = error instanceof Error ? error.message : String(error);
return res.status(502).json({
error: 'transaction_failed',
detail,
});
}
};

// GET /v1/transactions/:hash
export const getTransaction = (req: Request, res: Response) => {
const { hash } = req.params;
return res.status(404).json({ error: 'not_found', hash });
};
39 changes: 39 additions & 0 deletions app/backend/src/idempotency/error.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
export class IdempotencyError extends Error {
public readonly statusCode: number;

constructor(message: string, statusCode: number) {
super(message);
this.name = this.constructor.name;
this.statusCode = statusCode;
}
}

export class MissingKeyError extends IdempotencyError {
constructor() {
super('Missing required Idempotency-Key header for mutating requests', 400);
}
}

export class InvalidKeyFormatError extends IdempotencyError {
constructor(detail: string) {
super(`Invalid Idempotency-Key format: ${detail}`, 400);
}
}

export class FingerprintMismatchError extends IdempotencyError {
constructor() {
super(
'Request body fingerprint does not match the original request for this idempotency key',
409,
);
}
}

export class AlreadyProcessingError extends IdempotencyError {
constructor() {
super(
'A request with this idempotency key is already being processed',
409,
);
}
}
37 changes: 37 additions & 0 deletions app/backend/src/idempotency/fingerprint.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import crypto from 'crypto';

export class RequestFingerprint {
private readonly hash: string;

private constructor(hash: string) {
this.hash = hash;
}

public static fromBody(body: Record<string, unknown>): RequestFingerprint {
const sortedBody = RequestFingerprint.sortObjectKeys(body);
const bodyString = JSON.stringify(sortedBody);
const hash = crypto.createHash('sha256').update(bodyString).digest('hex');
return new RequestFingerprint(hash);
}

public asString(): string {
return this.hash;
}

private static sortObjectKeys(obj: unknown): unknown {
if (Array.isArray(obj)) {
// Use arrow function to avoid unbound method lint error
return obj.map(item => RequestFingerprint.sortObjectKeys(item));
}
if (obj !== null && typeof obj === 'object') {
const record = obj as Record<string, unknown>;
return Object.keys(record)
.sort()
.reduce((result: Record<string, unknown>, key: string) => {
result[key] = RequestFingerprint.sortObjectKeys(record[key]);
return result;
}, {});
}
return obj;
}
}
5 changes: 5 additions & 0 deletions app/backend/src/idempotency/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export { IdempotencyStore } from './store';
export { IdempotencyKey } from './key';
export { RequestFingerprint } from './fingerprint';
export { idempotencyMiddleware } from './middleware';
export * from './error';
42 changes: 42 additions & 0 deletions app/backend/src/idempotency/key.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { Request } from 'express';
import { InvalidKeyFormatError, MissingKeyError } from './error';

const MAX_KEY_LEN = 128;
const KEY_REGEX = /^[a-zA-Z0-9\-_.]+$/;

export class IdempotencyKey {
private readonly value: string;

private constructor(value: string) {
this.value = value;
}

public static fromHeaders(req: Request): IdempotencyKey {
const rawKey = req.headers['idempotency-key'] as string | undefined;

if (!rawKey) {
throw new MissingKeyError();
}

const trimmed = rawKey.trim();
if (trimmed.length === 0) {
throw new InvalidKeyFormatError('key must not be empty');
}
if (trimmed.length > MAX_KEY_LEN) {
throw new InvalidKeyFormatError(
`key exceeds maximum length of ${MAX_KEY_LEN}`,
);
}
if (!KEY_REGEX.test(trimmed)) {
throw new InvalidKeyFormatError(
'key may only contain ASCII alphanumeric characters, hyphens, underscores, and dots',
);
}

return new IdempotencyKey(trimmed);
}

public asString(): string {
return this.value;
}
}
76 changes: 76 additions & 0 deletions app/backend/src/idempotency/middleware.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { Request, Response, NextFunction } from 'express';
import { IdempotencyStore } from './store';
import { IdempotencyKey } from './key';
import { RequestFingerprint } from './fingerprint';
import {
IdempotencyError,
FingerprintMismatchError,
AlreadyProcessingError,
} from './error';

export function idempotencyMiddleware(store: IdempotencyStore) {
return async (req: Request, res: Response, next: NextFunction) => {
try {
// 1. Parse Key
const key = IdempotencyKey.fromHeaders(req);

// 2. Fingerprint Body
const fingerprint = RequestFingerprint.fromBody(req.body);

// 3. Check Store
const existingRecord = await store.tryAcquire(key, fingerprint);

if (!existingRecord) {
// First time: Intercept the response to cache it
const originalSend = res.send.bind(res);

res.send = (body: any) => {
const status = res.statusCode;
const recordStatus =
status >= 200 && status < 300 ? 'succeeded' : 'failed';
const bodyString =
typeof body === 'string' ? body : JSON.stringify(body);

// Fire and forget cache save (log on failure)
store
.complete(key, recordStatus, status, bodyString)
.catch(err =>
console.error(
`Failed to save idempotency record for key ${key.asString()}:`,
err,
),
);

return originalSend(body);
};

return next();
}

// Existing Record Found
if (existingRecord.requestFingerprint !== fingerprint.asString()) {
throw new FingerprintMismatchError();
}

if (existingRecord.status === 'processing') {
throw new AlreadyProcessingError();
}

// Replay cached response
res.setHeader('X-Idempotent-Replayed', 'true');
res.status(existingRecord.responseStatus ?? 500);

const bodyString = existingRecord.responseBody?.toString('utf-8') ?? '';
try {
res.json(JSON.parse(bodyString));
} catch {
res.send(bodyString);
}
} catch (error) {
if (error instanceof IdempotencyError) {
return res.status(error.statusCode).json({ error: error.message });
}
next(error);
}
};
}
77 changes: 77 additions & 0 deletions app/backend/src/idempotency/store.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { Pool } from 'pg';
import { IdempotencyKey } from './key';
import { RequestFingerprint } from './fingerprint';

export type RecordStatus = 'processing' | 'succeeded' | 'failed';

export interface IdempotencyRecord {
idempotencyKey: string;
requestFingerprint: string;
status: RecordStatus;
responseBody: Buffer | null;
responseStatus: number | null;
}

export class IdempotencyStore {
private pool: Pool;

constructor(pool: Pool) {
this.pool = pool;
}

public async tryAcquire(
key: IdempotencyKey,
fingerprint: RequestFingerprint,
): Promise<IdempotencyRecord | undefined> {
const insertResult = await this.pool.query(
`INSERT INTO idempotency_records (idempotency_key, request_fingerprint, status)
VALUES ($1, $2, 'processing')
ON CONFLICT (idempotency_key) DO NOTHING
RETURNING idempotency_key`,
[key.asString(), fingerprint.asString()],
);

if (insertResult.rows.length > 0) {
return undefined; // Fresh key — proceed!
}

// Key exists — fetch it
const { rows } = await this.pool.query(
`SELECT idempotency_key, request_fingerprint, status, response_body, response_status
FROM idempotency_records WHERE idempotency_key = $1`,
[key.asString()],
);

const row = rows[0];
return {
idempotencyKey: row.idempotency_key,
requestFingerprint: row.request_fingerprint,
status: row.status,
responseBody: row.response_body,
responseStatus: row.response_status,
};
}

public async complete(
key: IdempotencyKey,
status: RecordStatus,
responseStatus: number,
responseBody: string,
): Promise<void> {
await this.pool.query(
`UPDATE idempotency_records
SET status = $2, response_status = $3, response_body = $4, updated_at = now()
WHERE idempotency_key = $1`,
[key.asString(), status, responseStatus, Buffer.from(responseBody)],
);
}

public async cleanup(maxAgeHours: number): Promise<number> {
const result = await this.pool.query(
`DELETE FROM idempotency_records
WHERE created_at < now() - ($1::int || ' hours')::interval`,
[maxAgeHours],
);
return result.rowCount ?? 0;
}
}
Loading
Loading