Skip to content
Closed
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
126 changes: 126 additions & 0 deletions src/auditLog.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import { describe, it, beforeEach } from "node:test";
import assert from "node:assert";
import { AuditLog, redactSecrets } from "./auditLog.js";

await describe("AuditLog", async () => {
let auditLog: AuditLog;

await Promise.resolve(beforeEach(() => {
auditLog = new AuditLog();
}));

await it("records an entry for a mutation", () => {
const entry = auditLog.append({
actor: "tenant-1",
action: "charge.create",
targetId: "ch_123",
targetType: "charge",
after: { amount: 100, currency: "USD" },
});
assert.match(entry.id, /^audit_/);
assert.strictEqual(entry.actor, "tenant-1");
assert.strictEqual(entry.action, "charge.create");
assert.strictEqual(entry.targetId, "ch_123");
assert.strictEqual(auditLog.size(), 1);
});

await it("does not record an entry for a read", () => {
assert.strictEqual(auditLog.size(), 0);
});

await it("queries by target id", () => {
auditLog.append({
actor: "tenant-1",
action: "charge.create",
targetId: "ch_123",
targetType: "charge",
after: { amount: 100 },
});
auditLog.append({
actor: "tenant-1",
action: "charge.create",
targetId: "ch_456",
targetType: "charge",
after: { amount: 200 },
});
const entries = auditLog.queryByTarget("ch_123");
assert.strictEqual(entries.length, 1);
assert.strictEqual(entries[0].targetId, "ch_123");
});

await it("queries by actor", () => {
auditLog.append({
actor: "tenant-a",
action: "charge.create",
targetId: "ch_1",
targetType: "charge",
after: { amount: 100 },
});
auditLog.append({
actor: "tenant-b",
action: "charge.create",
targetId: "ch_2",
targetType: "charge",
after: { amount: 200 },
});
const entries = auditLog.queryByActor("tenant-a");
assert.strictEqual(entries.length, 1);
assert.strictEqual(entries[0].actor, "tenant-a");
});

await it("supports pagination", () => {
for (let i = 0; i < 5; i++) {
auditLog.append({
actor: "tenant-1",
action: "charge.create",
targetId: `ch_${i}`,
targetType: "charge",
after: { amount: i },
});
}
const entries = auditLog.queryByActor("tenant-1", 2, 1);
assert.strictEqual(entries.length, 2);
assert.strictEqual(entries[0].targetId, "ch_1");
assert.strictEqual(entries[1].targetId, "ch_2");
});

await it("never redacts non-sensitive fields", () => {
const payload = { amount: 100, currency: "USD" };
const redacted = redactSecrets(payload);
assert.strictEqual(redacted.amount, 100);
assert.strictEqual(redacted.currency, "USD");
});

await it("redacts secrets from payloads", () => {
const payload = {
amount: 100,
apiToken: "super-secret",
password: "hunter2",
nested: { ssn: "123-45-6789", name: "Alice" },
};
const redacted = redactSecrets(payload);
assert.strictEqual(redacted.apiToken, "[REDACTED]");
assert.strictEqual(redacted.password, "[REDACTED]");
assert.strictEqual((redacted.nested as Record<string, unknown>).ssn, "[REDACTED]");
assert.strictEqual((redacted.nested as Record<string, unknown>).name, "Alice");
});

await it("entries are ordered by timestamp", () => {
auditLog.append({
actor: "tenant-1",
action: "charge.create",
targetId: "ch_1",
targetType: "charge",
after: { amount: 100 },
});
auditLog.append({
actor: "tenant-1",
action: "charge.create",
targetId: "ch_2",
targetType: "charge",
after: { amount: 200 },
});
const entries = auditLog.queryByActor("tenant-1");
assert.ok(entries[0].timestamp <= entries[1].timestamp);
});
});
82 changes: 82 additions & 0 deletions src/auditLog.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { randomUUID } from "node:crypto";

export type AuditAction = "charge.create" | "charge.update" | "charge.refund";

export interface AuditEntry {
id: string;
actor: string; // tenantId or apiKey prefix
action: AuditAction;
targetId: string;
targetType: string;
before?: Record<string, unknown>;
after?: Record<string, unknown>;
timestamp: number;
requestId?: string;
}

/**
* Append-only, immutable audit log for state-changing payments operations.
* Entries are never updated or deleted.
*/
export class AuditLog {
private readonly entries: AuditEntry[] = [];

append(entry: Omit<AuditEntry, "id" | "timestamp">): AuditEntry {
const fullEntry: AuditEntry = {
...entry,
id: `audit_${randomUUID()}`,
timestamp: Date.now(),
};
this.entries.push(fullEntry);
return fullEntry;
}

/** Query by target id, ordered by timestamp ascending. */
queryByTarget(targetId: string, limit?: number, offset?: number): AuditEntry[] {
const matches = this.entries.filter((e) => e.targetId === targetId);
return this.paginate(matches, limit, offset);
}

/** Query by actor, ordered by timestamp ascending. */
queryByActor(actor: string, limit?: number, offset?: number): AuditEntry[] {
const matches = this.entries.filter((e) => e.actor === actor);
return this.paginate(matches, limit, offset);
}

/** Total count of entries. */
size(): number {
return this.entries.length;
}

/** Clear all entries (test helper only). */
clear(): void {
this.entries.length = 0;
}

private paginate(items: AuditEntry[], limit?: number, offset?: number): AuditEntry[] {
const start = offset ?? 0;
const end = limit === undefined ? undefined : start + limit;
return items.slice(start, end);
}
}

/**
* Redacts sensitive fields from a payload before recording in the audit log.
* Removes any key that looks like a secret, token, password, or PII.
*/
export function redactSecrets(
payload: Record<string, unknown>
): Record<string, unknown> {
const sensitiveKeys = /token|secret|password|ssn|sin|pin|cvv|authorization|auth/i;
const redacted: Record<string, unknown> = {};
for (const [key, value] of Object.entries(payload)) {
if (sensitiveKeys.test(key)) {
redacted[key] = "[REDACTED]";
} else if (typeof value === "object" && value !== null && !Array.isArray(value)) {
redacted[key] = redactSecrets(value as Record<string, unknown>);
} else {
redacted[key] = value;
}
}
return redacted;
}
13 changes: 13 additions & 0 deletions src/charges.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { createHash, randomUUID } from "node:crypto";
import { AuditLog, redactSecrets } from "./auditLog.js";

export const CHARGE_KEY_TTL_MS = 24 * 60 * 60 * 1000;
export const MAX_IDEMPOTENCY_KEY_LENGTH = 255;
Expand Down Expand Up @@ -139,6 +140,11 @@ export class InMemoryChargeIdempotencyStore implements ChargeIdempotencyStore {

export class InMemoryChargeStore {
private readonly charges: Charge[] = [];
private readonly auditLog?: AuditLog;

constructor(auditLog?: AuditLog) {
this.auditLog = auditLog;
}

create(tenantId: string, input: ChargeInput, now = Date.now()): Charge {
const charge: Charge = {
Expand All @@ -149,6 +155,13 @@ export class InMemoryChargeStore {
status: "succeeded",
};
this.charges.push(charge);
this.auditLog?.append({
actor: tenantId,
action: "charge.create",
targetId: charge.id,
targetType: "charge",
after: redactSecrets(charge as unknown as Record<string, unknown>),
});
return cloneCharge(charge);
}

Expand Down
43 changes: 42 additions & 1 deletion src/routes/charges.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,17 +16,26 @@ import {
paymentValidationError,
} from "../errors/domainError.js";
import { paymentErrorHandler } from "../middleware/paymentErrorHandler.js";
import { parseIntParam } from "../queryParams.js";
import { AuditLog } from "../auditLog.js";

export type ChargesRouterOptions = {
idempotencyStore?: InMemoryChargeIdempotencyStore;
chargeStore?: InMemoryChargeStore;
auditLog?: AuditLog;
};

function firstQueryString(value: unknown): string {
const raw = Array.isArray(value) ? value[0] : value;
return typeof raw === "string" ? raw : "";
}

export function createChargesRouter(options: ChargesRouterOptions = {}): Router {
const router = Router();
const auditLog = options.auditLog ?? new AuditLog();
const idempotencyStore =
options.idempotencyStore ?? new InMemoryChargeIdempotencyStore();
const chargeStore = options.chargeStore ?? new InMemoryChargeStore();
const chargeStore = options.chargeStore ?? new InMemoryChargeStore(auditLog);

router.post("/api/v1/charges", (req: Request, res: Response, next: NextFunction) => {
const requestId = paymentRequestId(req);
Expand Down Expand Up @@ -114,6 +123,38 @@ export function createChargesRouter(options: ChargesRouterOptions = {}): Router
}
);

router.get("/api/v1/charges/:chargeId/audit", (req: Request, res: Response) => {
const chargeId = String(req.params.chargeId);
const limit = parseIntParam(req.query.limit, {
defaultValue: 100,
min: 1,
max: 500,
});
const offset = parseIntParam(req.query.offset, {
defaultValue: 0,
min: 0,
max: 10_000,
});
const entries = auditLog.queryByTarget(chargeId, limit, offset);
res.status(200).json({ entries, requestId: paymentRequestId(req) });
});

router.get("/api/v1/audit", (req: Request, res: Response) => {
const actor = firstQueryString(req.query.actor);
const limit = parseIntParam(req.query.limit, {
defaultValue: 100,
min: 1,
max: 500,
});
const offset = parseIntParam(req.query.offset, {
defaultValue: 0,
min: 0,
max: 10_000,
});
const entries = auditLog.queryByActor(actor, limit, offset);
res.status(200).json({ entries, requestId: paymentRequestId(req) });
});

router.use(paymentErrorHandler);

return router;
Expand Down
Loading