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
8 changes: 8 additions & 0 deletions esure-backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,14 @@
"types": "./dist/scenario-schema.d.ts",
"default": "./dist/scenario-schema.js"
},
"./report-schema": {
"types": "./dist/report-schema.d.ts",
"default": "./dist/report-schema.js"
},
"./report-schema.js": {
"types": "./dist/report-schema.d.ts",
"default": "./dist/report-schema.js"
},
"./scenario-loader": {
"types": "./dist/scenario-loader.d.ts",
"default": "./dist/scenario-loader.js"
Expand Down
64 changes: 60 additions & 4 deletions esure-backend/src/app.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { createHash } from "node:crypto";
import Fastify, { type FastifyInstance } from "fastify";
import Fastify, { type FastifyInstance, type FastifyReply, type FastifyRequest } from "fastify";
export type AppInstance = FastifyInstance & { runs: RunService };

export async function closeAfterDrain(app: { close: () => Promise<void>; runs: Pick<AppInstance["runs"], "drain"> }, timeoutMs: number): Promise<void> {
Expand Down Expand Up @@ -43,6 +43,38 @@ const runBodySchema = {
},
} as const;

const scenarioIdParamsSchema = {
type: "object",
additionalProperties: false,
required: ["scenarioId"],
properties: { scenarioId: { type: "string", pattern: "^[a-z0-9-]{1,64}$" } },
} as const;

const versionParamsSchema = {
type: "object",
additionalProperties: false,
required: ["scenarioId", "version"],
properties: {
scenarioId: { type: "string", pattern: "^[a-z0-9-]{1,64}$" },
version: { type: "string", pattern: "^[1-9][0-9]{0,9}$" },
},
} as const;

const historyQuerySchema = {
type: "object",
additionalProperties: false,
properties: {
limit: { type: "string", pattern: "^[1-9][0-9]{0,2}$" },
cursor: { type: "string", pattern: "^[1-9][0-9]{0,9}$" },
},
} as const;

const exportQuerySchema = {
type: "object",
additionalProperties: false,
properties: { format: { type: "string", enum: ["json", "yaml"] } },
} as const;

/** Maximum Idempotency-Key length accepted (issue #72). */
const IDEMPOTENCY_KEY_MAX_LENGTH = 128;

Expand All @@ -56,6 +88,7 @@ const IDEMPOTENCY_MAX_ENTRIES = 1_000;
const IDEMPOTENCY_RETENTION_MS = 86_400_000;

export function buildApp(options: BuildAppOptions): AppInstance {
const clock = options.clock ?? Date.now;
const app = Fastify({ logger: options.logger ?? false, bodyLimit: options.config.bodyLimitBytes, ajv: { customOptions: { removeAdditional: false } } });
app.addContentTypeParser(["application/yaml", "application/x-yaml", "text/yaml"], { parseAs: "string" }, (_request, body, done) => done(null, body));
// Issue #77: fastify's default text/plain parser would otherwise accept
Expand Down Expand Up @@ -89,7 +122,7 @@ export function buildApp(options: BuildAppOptions): AppInstance {
const ledger = options.ledger ?? new StellarTestnetGateway(options.config.horizonUrl, options.config.friendbotUrl, {
fundingConcurrency: options.config.maxConcurrentFriendbotRequests,
});
const runs = new RunService(persistence.runStore ?? new InMemoryRunStore(options.config.maxStoredRuns, options.config.runRetentionMs), ledger, {
const runs = new RunService(persistence.runStore ?? new InMemoryRunStore(options.config.maxStoredRuns, options.config.runRetentionMs, clock), ledger, {
maxConcurrentRuns: options.config.maxConcurrentRuns, runTimeoutMs: options.config.runTimeoutMs, stepTimeoutMs: options.config.stepTimeoutMs,
}, clock);

Expand Down Expand Up @@ -235,13 +268,14 @@ export function buildApp(options: BuildAppOptions): AppInstance {
app.get<{ Params: { runId: string } }>("/api/v1/runs/:runId", async (request, reply) => {
const run = await runs.get(request.params.runId);
if (!run) return reply.code(404).send(apiError("RUN_NOT_FOUND", "The requested run does not exist.", request.id));
return withRetentionMetadata(run, options.config.runRetentionMs);
return sendConditionalRunReport(request, reply, withRetentionMetadata(run, options.config.runRetentionMs));
});
app.get<{ Params: { runId: string } }>("/api/v1/runs/:runId/report", async (request, reply) => {
const run = await runs.get(request.params.runId);
if (!run) return reply.code(404).send(apiError("RUN_NOT_FOUND", "The requested run does not exist.", request.id));
if (run.status !== "passed" && run.status !== "failed") return reply.code(409).send(apiError("RUN_NOT_COMPLETE", "The requested run is not complete.", request.id));
return reply.header("content-disposition", `attachment; filename=esure-run-${run.id}.json`).send(withRetentionMetadata(run, options.config.runRetentionMs));
reply.header("content-disposition", `attachment; filename=esure-run-${run.id}.json`);
return sendConditionalRunReport(request, reply, withRetentionMetadata(run, options.config.runRetentionMs));
});

app.setErrorHandler((error, request, reply) => {
Expand Down Expand Up @@ -284,6 +318,28 @@ function catalogueETag(items: { id: string; version: number; contentHash: string
return `"${digest}"`;
}

function sendConditionalRunReport(
request: FastifyRequest,
reply: FastifyReply,
report: RunReport,
) {
const body = JSON.stringify(report);
const etag = `"${createHash("sha256").update(body).digest("hex")}"`;
reply.header("ETag", etag);
reply.header("Cache-Control", "no-cache");

const ifNoneMatchHeader = request.headers["if-none-match"];
const ifNoneMatch = Array.isArray(ifNoneMatchHeader) ? ifNoneMatchHeader.join(",") : ifNoneMatchHeader;
if (ifNoneMatch && ifNoneMatch.split(",").some((candidate) => {
const tag = candidate.trim();
return tag === "*" || (tag.startsWith("W/") ? tag.slice(2) : tag) === etag;
})) {
return reply.code(304).send();
}

return reply.type("application/json").send(body);
}

// ── Issue #72: idempotency helpers ─────────────────────────────────────────
function extractIdempotencyKey(header: unknown): string | undefined {
if (typeof header !== "string") return undefined;
Expand Down
1 change: 1 addition & 0 deletions esure-backend/src/domain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,7 @@ export interface RunReport {
status: RunStatus;
createdAt: string;
completedAt?: string;
expiresAt?: string;
steps: StepResult[];
assertions: AssertionResult[];
summary: RunSummary;
Expand Down
30 changes: 27 additions & 3 deletions esure-backend/src/openapi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,17 +129,41 @@ export function openApiDocument() {
"Active reports never include `expiresAt` — a running run has no terminal expiry to claim.",
"`expiresAt` is a best-effort upper bound, not a guarantee: the in-memory run store may evict a",
"terminal run earlier under capacity pressure, and no run state survives a process restart.",
"Supports conditional GET with `If-None-Match`; a matching report returns 304 without a body.",
"Responses include an ETag for the complete representation and `Cache-Control: no-cache`,",
"so active reports are revalidated rather than treated as immutable.",
].join(" "),
parameters: [{ name: "runId", in: "path", required: true, schema: { type: "string", format: "uuid" } }],
responses: { "200": { description: "Run report" }, "404": { description: "Not found" } },
responses: {
"200": {
description: "Run report",
headers: {
"ETag": { schema: { type: "string" }, description: "Fingerprint of the complete run representation; changes when any report field changes." },
"Cache-Control": { schema: { type: "string" }, description: "no-cache requires revalidation before reuse, including for active reports." },
},
},
"304": { description: "Run report unchanged; no response body." },
"404": { description: "Not found" },
},
},
},
"/api/v1/runs/{runId}/report": {
get: {
operationId: "downloadRunReport",
description: "Final JSON report for a terminal run, including the same best-effort `expiresAt` retention hint as GET /api/v1/runs/{runId} (issue #76).",
description: "Final JSON report for a terminal run, including the same best-effort `expiresAt` retention hint and ETag revalidation behavior as GET /api/v1/runs/{runId}.",
parameters: [{ name: "runId", in: "path", required: true, schema: { type: "string", format: "uuid" } }],
responses: { "200": { description: "Final JSON report" }, "409": { description: "Run incomplete" } },
responses: {
"200": {
description: "Final JSON report",
headers: {
"ETag": { schema: { type: "string" }, description: "Fingerprint of the complete report representation." },
"Cache-Control": { schema: { type: "string" }, description: "no-cache requires revalidation before reuse." },
},
},
"304": { description: "Report unchanged; no response body." },
"409": { description: "Run incomplete" },
"404": { description: "Not found" },
},
},
},
},
Expand Down
147 changes: 147 additions & 0 deletions esure-backend/src/report-schema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
import { Ajv } from "ajv";
import type { RunReport } from "./domain.js";

const jsonValue = {
anyOf: [
{ type: "string" },
{ type: "number" },
{ type: "boolean" },
{ type: "null" },
{ type: "array", maxItems: 1000, items: { $ref: "#/$defs/jsonValue" } },
{ type: "object", maxProperties: 1000, additionalProperties: { $ref: "#/$defs/jsonValue" } },
],
} as const;

export const runReportSchemaV1 = {
$id: "https://esure.dev/schemas/run-report-v1.json",
type: "object",
additionalProperties: false,
required: [
"id", "scenarioId", "scenarioVersion", "scenarioSchemaVersion", "scenarioContentHash",
"network", "status", "createdAt", "steps", "assertions", "summary",
],
properties: {
id: { type: "string", minLength: 1 },
scenarioId: { type: "string", minLength: 1, maxLength: 64 },
scenarioVersion: { type: "integer", minimum: 1, maximum: 2_147_483_647 },
scenarioSchemaVersion: { const: 1 },
scenarioContentHash: { type: "string", minLength: 1 },
network: { const: "testnet" },
status: { enum: ["requested", "validating", "running", "passed", "failed"] },
createdAt: { type: "string", minLength: 1 },
completedAt: { type: "string", minLength: 1 },
expiresAt: { type: "string", minLength: 1 },
steps: {
type: "array",
maxItems: 1000,
items: {
type: "object",
additionalProperties: false,
required: ["id", "type", "status", "message"],
properties: {
id: { type: "string", minLength: 1 },
type: { type: "string", minLength: 1 },
status: { enum: ["passed", "failed"] },
transactionHash: { type: "string" },
ledger: { type: "integer", minimum: 1 },
feeCharged: { type: "string", pattern: "^(0|[1-9][0-9]*)$" },
stellarTransactionCode: { type: "string" },
stellarOperationCodes: { type: "array", maxItems: 100, items: { type: "string" } },
message: { type: "string" },
startedAt: { type: "string", minLength: 1 },
completedAt: { type: "string", minLength: 1 },
durationMs: { type: "number", minimum: 0 },
},
},
},
assertions: {
type: "array",
maxItems: 1000,
items: {
type: "object",
additionalProperties: false,
required: ["type", "status", "expected", "actual", "message"],
properties: {
type: {
enum: [
"transactionMemoEquals", "balanceEquals", "balanceChangedBy", "stepSucceeded",
"transactionConfirmed", "stepFailedWith", "trustlineExists", "trustlineMissing",
"trustlineLimitEquals", "accountExists", "accountMissing", "feeAwareNativeBalanceDelta",
],
},
status: { enum: ["passed", "failed"] },
expected: jsonValue,
actual: jsonValue,
message: { type: "string" },
},
},
},
summary: {
type: "object",
additionalProperties: false,
required: ["stepsPassed", "stepsFailed", "assertionsPassed", "assertionsFailed"],
properties: {
stepsPassed: { type: "integer", minimum: 0 },
stepsFailed: { type: "integer", minimum: 0 },
assertionsPassed: { type: "integer", minimum: 0 },
assertionsFailed: { type: "integer", minimum: 0 },
},
},
error: {
type: "object",
additionalProperties: false,
required: ["code", "message", "category", "retryable"],
properties: {
code: { type: "string", minLength: 1 },
message: { type: "string" },
category: { enum: ["stellar", "network", "timeout", "capacity", "validation", "internal"] },
retryable: { type: "boolean" },
failedStepId: { type: "string" },
stellarTransactionCode: { type: "string" },
stellarOperationCodes: { type: "array", maxItems: 100, items: { type: "string" } },
},
},
},
$defs: { jsonValue },
} as const;

const ajv = new Ajv({ allErrors: true, strict: true });
const validateSchema = ajv.compile<RunReport>(runReportSchemaV1);

export class RunReportValidationError extends Error {
constructor(readonly issues: string[]) {
super("Run report is invalid.");
this.name = "RunReportValidationError";
}
}

export function validateRunReport(value: unknown): RunReport {
if (!validateSchema(value)) {
throw new RunReportValidationError(
(validateSchema.errors ?? []).map((error) => `${error.instancePath || "/"} ${error.message ?? "is invalid"}`),
);
}

const report = value;
const timestampIssues: string[] = [];
for (const [field, timestamp] of [
["createdAt", report.createdAt],
["completedAt", report.completedAt],
["expiresAt", report.expiresAt],
] as const) {
if (timestamp !== undefined && !isIsoDateTime(timestamp)) timestampIssues.push(`/${field} must be a valid ISO date-time`);
}
for (const [index, step] of report.steps.entries()) {
for (const field of ["startedAt", "completedAt"] as const) {
const timestamp = step[field];
if (timestamp !== undefined && !isIsoDateTime(timestamp)) timestampIssues.push(`/steps/${index}/${field} must be a valid ISO date-time`);
}
}
if (timestampIssues.length) throw new RunReportValidationError(timestampIssues);
return report;
}

function isIsoDateTime(value: string): boolean {
return /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/.test(value)
&& Number.isFinite(Date.parse(value));
}
Loading
Loading