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 .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -84,13 +84,14 @@ jobs:
--health-timeout 5s
--health-retries 5
env:
NODE_ENV: test
DATABASE_URL: postgresql://chainlearn_test:test_password@localhost:5432/chainlearn_test
REDIS_URL: redis://localhost:6379
JWT_SECRET: test-secret-key-that-is-at-least-64-characters-long-for-testing-purposes-only
STELLAR_NETWORK: testnet
STELLAR_HORIZON_URL: https://horizon-testnet.stellar.org
STELLAR_SOROBAN_RPC_URL: https://soroban-testnet.stellar.org
STELLAR_PLATFORM_SECRET: ${{ (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) && secrets.STELLAR_PLATFORM_SECRET || '' }}
STELLAR_PLATFORM_SECRET: SBZVMB74Z76QZ3ZQY6ADDING6S5AIJWXE3MVRULCNPG7ZBJRYUX3CBNN
STELLAR_QUIZ_CONTRACT_ID: CB6Q2YKQQHH7GV7CU5RZDYM5S5OE2GABYLG5IY6YO5XLBAALBQKXYB53
STELLAR_REWARD_CONTRACT_ID: CBAKHFY4SIBRIVYH2Y2QDUIUZPGYGS4B26YBHC6RLV5QZ7OHH5FOF55T
STELLAR_CREDENTIAL_CONTRACT_ID: CD4ZJWLPGYLCYR7G5DZQ4EJWVMMF5VXU5Z2ECRSKGWV6GBV5S3F52K7
Expand Down
24 changes: 21 additions & 3 deletions src/config/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { z } from "zod";
import "dotenv/config";
import { logger } from "../utils/logger.js";
import dotenv from "dotenv";

// Test mode gets its own optional .env.test file (#475), gitignored like
Expand Down Expand Up @@ -163,6 +165,12 @@ function loadConfig(): Env {

// In test mode, warn but don't exit — tests mock what they need.
// Merge with process.env so CI-provided values (DATABASE_URL, REDIS_URL, etc.)
// are preserved; only truly missing vars get test defaults.
logger.warn(
{
fieldErrors: result.error.flatten().fieldErrors,
},
"Missing env vars in test mode (expected if mocking config)"
// are preserved; only non-critical vars get obviously-fake test defaults.
// Every field is passed through from process.env (populated above by
// real environment variables, then .env.test, in that precedence)
Expand All @@ -178,6 +186,14 @@ function loadConfig(): Env {
DATABASE_URL: process.env.DATABASE_URL,
REDIS_URL: process.env.REDIS_URL || "redis://localhost:6379",
CORS_ORIGINS: process.env.CORS_ORIGINS,
JWT_SECRET:
process.env.JWT_SECRET || "test-secret-key-that-is-at-least-sixty-four-characters-long-for-tests",
STELLAR_HORIZON_URL: process.env.STELLAR_HORIZON_URL || "https://horizon-testnet.stellar.org",
STELLAR_SOROBAN_RPC_URL: process.env.STELLAR_SOROBAN_RPC_URL || "https://soroban-testnet.stellar.org",
STELLAR_PLATFORM_SECRET: process.env.STELLAR_PLATFORM_SECRET || "SBZVMB74Z76QZ3ZQY6ADDING6S5AIJWXE3MVRULCNPG7ZBJRYUX3CBNN",
STELLAR_QUIZ_CONTRACT_ID: process.env.STELLAR_QUIZ_CONTRACT_ID || "CB6Q2YKQQHH7GV7CU5RZDYM5S5OE2GABYLG5IY6YO5XLBAALBQKXYB53",
STELLAR_REWARD_CONTRACT_ID: process.env.STELLAR_REWARD_CONTRACT_ID || "CBAKHFY4SIBRIVYH2Y2QDUIUZPGYGS4B26YBHC6RLV5QZ7OHH5FOF55T",
STELLAR_CREDENTIAL_CONTRACT_ID: process.env.STELLAR_CREDENTIAL_CONTRACT_ID || "CD4ZJWLPGYLCYR7G5DZQ4EJWVMMF5VXU5Z2ECRSKGWV6GBV5S3F52K7",
JWT_SECRET: process.env.JWT_SECRET,
STELLAR_HORIZON_URL:
process.env.STELLAR_HORIZON_URL ||
Expand Down Expand Up @@ -213,9 +229,11 @@ function loadConfig(): Env {
process.env.STELLAR_CREDENTIAL_CONTRACT_ID || TEST_FALLBACKS.STELLAR_CREDENTIAL_CONTRACT_ID,
});
}
console.error(
"Invalid environment variables:",
result.error.flatten().fieldErrors
logger.error(
{
fieldErrors: result.error.flatten().fieldErrors,
},
"Invalid environment variables"
);
process.exit(1);
}
Expand Down
4 changes: 4 additions & 0 deletions src/database/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -510,6 +510,10 @@ export const webhookAttempts = pgTable(
]
);

// ─── Type exports for use in services ────────────────────────────────────────

export type Webhook = typeof webhooks.$inferSelect;
export type WebhookAttempt = typeof webhookAttempts.$inferSelect;
// ─── Course Reports ─────────────────────────────────────────────────────────

export const courseReports = pgTable(
Expand Down
7 changes: 4 additions & 3 deletions src/middleware/rate-limit.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { FastifyRateLimitOptions, RateLimitOptions } from "@fastify/rate-limit";
import type { FastifyRequest } from "fastify";
import { config } from "../config/index.js";
import type { AuthenticatedRequest } from "./auth.js";

const errorResponseBuilder = (
_request: FastifyRequest,
Expand All @@ -17,7 +18,7 @@
timeWindow: config.RATE_LIMIT_WINDOW_MS,
keyGenerator: (request: FastifyRequest) => {
// Prefer authenticated user id, fall back to IP
const authReq = request as any;
const authReq = request as AuthenticatedRequest;
return authReq.authUser?.id ?? request.ip;
},
errorResponseBuilder,
Expand Down Expand Up @@ -51,7 +52,7 @@
max: 10,
timeWindow: "1 minute",
keyGenerator: (request: FastifyRequest) => {
const authReq = request as any;
const authReq = request as AuthenticatedRequest;
return authReq.authUser?.id ?? request.ip;
},
errorResponseBuilder,
Expand All @@ -65,7 +66,7 @@
max: 5,
timeWindow: "1 minute",
keyGenerator: (request: FastifyRequest) => {
const authReq = request as any;
const authReq = request as AuthenticatedRequest;
return authReq.authUser?.id ?? request.ip;
},
errorResponseBuilder,
Expand All @@ -83,7 +84,7 @@
max: 5,
timeWindow: "1 minute",
keyGenerator: (request: FastifyRequest) => {
const authReq = request as any;

Check warning on line 87 in src/middleware/rate-limit.ts

View workflow job for this annotation

GitHub Actions / Lint & Typecheck

Unexpected any. Specify a different type
return authReq.authUser?.id ?? request.ip;
},
errorResponseBuilder,
Expand Down
3 changes: 2 additions & 1 deletion src/middleware/response-envelope.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { FastifyRequest, FastifyReply } from "fastify";
import type { VersionedRequest } from "../routes/versioning.js";
import { logger } from "../utils/logger.js";

export async function responseEnvelope(
Expand All @@ -11,7 +12,7 @@ export async function responseEnvelope(
const body = JSON.parse(payload);
if (!body.meta) {
body.meta = {
version: (request as any).apiVersion ?? "v1",
version: (request as VersionedRequest).apiVersion ?? "v1",
timestamp: new Date().toISOString(),
requestId: request.id,
};
Expand Down
4 changes: 2 additions & 2 deletions src/middleware/validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ export function validate(schemas: ValidationSchemas) {
if (!result.success) {
errors.querystring = formatZodErrors(result.error);
} else {
request.query = result.data as any;
request.query = result.data as unknown as typeof request.query;
}
}

Expand All @@ -38,7 +38,7 @@ export function validate(schemas: ValidationSchemas) {
if (!result.success) {
errors.params = formatZodErrors(result.error);
} else {
request.params = result.data as any;
request.params = result.data as unknown as typeof request.params;
}
}

Expand Down
13 changes: 8 additions & 5 deletions src/modules/admin/webhook.service.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import crypto from "node:crypto";
import { eq, desc, count, sql } from "drizzle-orm";
import { eq, and, desc, count, lt, or, sql } from "drizzle-orm";
import { db } from "../../config/database.js";
import { webhooks, webhookAttempts } from "../../database/schema.js";
import { NotFoundError, ConflictError } from "../../utils/errors.js";
import { NotFoundError } from "../../utils/errors.js";
import { logger } from "../../utils/logger.js";
import { auditLog } from "../../audit/index.js";
import { decodeCursor, encodeCursor } from "../../utils/cursor-pagination.js";
Expand All @@ -11,7 +12,9 @@ import type {
UpdateWebhookBody,
WebhookResponse,
WebhookAttemptResponse,
WebhookStats,
} from "./webhook.types.js";
import type { Webhook, WebhookAttempt } from "../../database/schema.js";

export class WebhookService {
/**
Expand Down Expand Up @@ -59,7 +62,7 @@ export class WebhookService {
throw new NotFoundError("Webhook");
}

const updates: any = {
const updates: Partial<Webhook> = {
updatedAt: new Date(),
};

Expand Down Expand Up @@ -245,7 +248,7 @@ export class WebhookService {
/**
* Get webhook statistics (success/failure counts, etc.).
*/
async getWebhookStats(webhookId: string): Promise<any> {
async getWebhookStats(webhookId: string): Promise<WebhookStats> {
const [webhook] = await db
.select()
.from(webhooks)
Expand Down Expand Up @@ -281,7 +284,7 @@ export class WebhookService {
};
}

private toResponse(webhook: any): WebhookResponse {
private toResponse(webhook: Webhook): WebhookResponse {
return {
id: webhook.id,
url: webhook.url,
Expand All @@ -292,7 +295,7 @@ export class WebhookService {
};
}

private toAttemptResponse(attempt: any): WebhookAttemptResponse {
private toAttemptResponse(attempt: WebhookAttempt): WebhookAttemptResponse {
return {
id: attempt.id,
webhookId: attempt.webhookId,
Expand Down
9 changes: 9 additions & 0 deletions src/modules/admin/webhook.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,3 +87,12 @@ export interface WebhookAttemptResponse {
retryCount: number;
createdAt: Date;
}

export interface WebhookStats {
webhookId: string;
totalAttempts: number;
succeeded: number;
failed: number;
pending: number;
successRate: number;
}
8 changes: 6 additions & 2 deletions src/routes/versioning.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import type { FastifyInstance } from "fastify";
import type { FastifyInstance, FastifyRequest } from "fastify";
import { registerV1Routes } from "./v1/index.js";
import { responseEnvelope } from "../middleware/response-envelope.js";

export interface VersionedRequest extends FastifyRequest {
apiVersion: string;
}

export function cacheControlHeader(
url: string,
statusCode: number = 200,
Expand Down Expand Up @@ -31,7 +35,7 @@ export async function registerVersionedRoutes(app: FastifyInstance) {
app.register(
async function v1(app) {
app.addHook("onRequest", async (request) => {
(request as any).apiVersion = "v1";
(request as VersionedRequest).apiVersion = "v1";
});
app.addHook("onSend", async (request, reply, payload) => {
const header = cacheControlHeader(request.url, reply.statusCode);
Expand Down
15 changes: 8 additions & 7 deletions src/services/webhook-dispatcher.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import crypto from "node:crypto";
import { eq } from "drizzle-orm";
import { eq, and, lte, isNull } from "drizzle-orm";
import { db } from "../config/database.js";
import { webhooks, webhookAttempts } from "../database/schema.js";
import { logger } from "../utils/logger.js";
import type { WebhookPayload } from "../modules/admin/webhook.types.js";
import { getRequestId } from "../utils/request-context.js";
import type { WebhookPayload, WebhookEventType } from "../modules/admin/webhook.types.js";

Expand Down Expand Up @@ -159,7 +160,7 @@ export async function dispatchWebhook(
.values({
webhookId: webhook.id,
event: payload.event,
payload: payload as any,
payload: payload as unknown as Record<string, unknown>,
statusCode: result.statusCode ?? null,
errorMessage: result.error ?? null,
succeededAt: result.success ? new Date() : null,
Expand Down Expand Up @@ -233,11 +234,11 @@ export async function processWebhookRetries(): Promise<void> {
.select()
.from(webhookAttempts)
.where(
(col: any) =>
col("next_retry_at") &&
col("next_retry_at") <= now &&
!col("succeeded_at") &&
!col("failed_at")
and(
lte(webhookAttempts.nextRetryAt, now),
isNull(webhookAttempts.succeededAt),
isNull(webhookAttempts.failedAt)
)
);

if (readyForRetry.length === 0) return;
Expand Down
28 changes: 28 additions & 0 deletions src/stellar/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,19 @@ export class StellarClient {
);
logger.info({ requestId, hash: result.hash }, "Transaction submitted successfully");
return result;
} catch (err: unknown) {
const unknownErr = err as { response?: { data?: { extras?: { result_codes?: unknown; envelope_xdr?: string } } } };
const extras = unknownErr.response?.data?.extras;
if (extras) {
logger.error(
{ resultCodes: extras.result_codes, envelope: extras.envelope_xdr },
"Transaction failed",
);
}
throw new StellarError(
extras?.result_codes
? `Tx failed: ${JSON.stringify(extras.result_codes)}`
: "Transaction submission failed",
} catch (err) {
const clientError = toStellarClientError(err, "Transaction submission failed");
logger.error(
Expand Down Expand Up @@ -152,6 +165,14 @@ export class StellarClient {
"read"
);
return true;
} catch (err: unknown) {
const unknownErr = err as { response?: { status?: number }; status?: number; message?: string };
const status = unknownErr.response?.status ?? unknownErr.status;
if (status === 404) return false;
logger.error({ err, publicKey }, "accountExists check failed");
throw new StellarError(
`Could not verify account ${publicKey}: ${unknownErr.message ?? String(err)}`,
);
} catch (err) {
if (getHttpStatus(err) === 404) return false;
const clientError = toStellarClientError(err, `Could not verify account ${publicKey}`);
Expand Down Expand Up @@ -184,6 +205,12 @@ export class StellarClient {
return { status: "SUCCESS" };
}
return { status: "FAILED" };
} catch (err: unknown) {
const unknownErr = err as { response?: { status?: number }; status?: number };
const status = unknownErr.response?.status ?? unknownErr.status;
if (status === 404) return { status: "NOT_FOUND" };
logger.error({ err, txHash }, "getTransaction failed");
throw new StellarError(`Could not fetch transaction ${txHash}`);
} catch (err) {
if (getHttpStatus(err) === 404) return { status: "NOT_FOUND" };
const clientError = toStellarClientError(err, `Could not fetch transaction ${txHash}`);
Expand Down Expand Up @@ -252,6 +279,7 @@ export class StellarClient {
try {
// Use a shorter timeout for health checks (3s) to fail fast if RPC is unreachable
await withTimeout(this.soroban.getLatestLedger(), 3_000);
} catch (err: unknown) {
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
logger.warn({ message }, "Soroban RPC health check failed");
Expand Down
19 changes: 18 additions & 1 deletion src/stellar/transactions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import {
getNetworkPassphrase,
getSorobanServer,
} from "../config/stellar.js";
import { config } from "../config/index.js";
import { stellarClient } from "./client.js";
import { logger } from "../utils/logger.js";
import { StellarError, StellarClientError } from "../utils/errors.js";
Expand All @@ -27,6 +26,23 @@ function isBadSeqError(err: StellarError): boolean {
return true;
}

// Robust detection: check Horizon response structure
const unknownErr = err as unknown;
if (
unknownErr &&
typeof unknownErr === "object" &&
"response" in unknownErr
) {
const response = (unknownErr as { response?: { status?: number; data?: { extras?: { result_codes?: { transaction?: string } } } } }).response;
if (response?.status === 400) {
const resultCodes = response?.data?.extras?.result_codes;
if (resultCodes?.transaction === "tx_bad_seq") {
return true;
}
}
}

return false;
// Fallback string matching, for a StellarError that didn't go through
// toStellarClientError (e.g. constructed directly elsewhere).
return err.message.includes("bad_seq") || err.message.includes("tx_bad_seq");
Expand Down Expand Up @@ -101,6 +117,7 @@ export async function invokeContract(

const result = await stellarClient.submitTransaction(preparedTx);
return result.hash;
} catch (err: unknown) {
} catch (err) {
if (err instanceof StellarError && isBadSeqError(err)) {
await sequenceCache.invalidate(keypair.publicKey());
Expand Down
1 change: 0 additions & 1 deletion tests/e2e/quiz-feedback.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { describe, it, expect, beforeAll, afterAll } from "vitest";
import type { FastifyInstance } from "fastify";
import { buildApp } from "../../src/server.js";

describe("Quiz Feedback Customization API (Issue #322)", () => {
Expand Down
Loading