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
11 changes: 8 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,20 @@ jobs:
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4

- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 22

- name: Setup Bun
uses: oven-sh/setup-bun@v1
uses: oven-sh/setup-bun@v2
with:
bun-version: latest

- name: Install dependencies
run: bun install
run: bun install --frozen-lockfile

- name: Lint
run: bun run lint
Expand Down
19 changes: 18 additions & 1 deletion bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@
"@eslint/js": "^10.0.1",
"@types/big.js": "^6.2.2",
"@types/bun": "latest",
"@types/node": "^25.5.0",
"@vitest/coverage-v8": "^4.0.18",
"eslint": "^10.0.0",
"eslint-config-prettier": "^10.1.8",
Expand Down
135 changes: 9 additions & 126 deletions src/core/config.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { AnchorKitConfigSchema } from '@/utils/validation.ts';
import { ConfigError } from '@/core/errors.ts';
import type { AnchorKitConfig, Asset, NetworkConfig } from '@/types/config.ts';
import { Networks } from '@stellar/stellar-sdk';
import { DatabaseUrlSchema } from '@/utils/validation.ts';

/**
* AnchorConfig
Expand Down Expand Up @@ -192,121 +194,16 @@ export class AnchorConfig {
* Throws ConfigError if validation fails.
*/
public validate(): void {
if (!this.config) {
throw new ConfigError('Configuration object is missing');
}

const { network, server, security, assets, framework } = this.config;

// Validate Required Top-Level Fields
if (!network) {
throw new ConfigError('Missing required top-level field: network');
}
if (!server) {
throw new ConfigError('Missing required top-level field: server');
}
if (!security) {
throw new ConfigError('Missing required top-level field: security');
}
if (!assets) {
throw new ConfigError('Missing required top-level field: assets');
}
if (!framework) {
throw new ConfigError('Missing required top-level field: framework');
}

// Validate Required Secrets
if (!security.sep10SigningKey) {
throw new ConfigError('Missing required secret: security.sep10SigningKey');
}
if (!security.interactiveJwtSecret) {
throw new ConfigError('Missing required secret: security.interactiveJwtSecret');
}
if (!security.distributionAccountSecret) {
throw new ConfigError('Missing required secret: security.distributionAccountSecret');
}

// Validate Assets configuration
if (!assets.assets || !Array.isArray(assets.assets) || assets.assets.length === 0) {
throw new ConfigError('At least one asset must be configured in assets.assets');
}

// Validate Framework Database config
if (!framework.database || !framework.database.provider || !framework.database.url) {
throw new ConfigError('Missing required database configuration in framework.database');
}

if (
framework.queue &&
framework.queue.concurrency !== undefined &&
framework.queue.concurrency < 1
) {
throw new ConfigError('framework.queue.concurrency must be >= 1');
}

if (
framework.watchers &&
framework.watchers.pollIntervalMs !== undefined &&
framework.watchers.pollIntervalMs < 10
) {
throw new ConfigError('framework.watchers.pollIntervalMs must be >= 10');
}

if (
framework.http &&
framework.http.maxBodyBytes !== undefined &&
framework.http.maxBodyBytes < 1024
) {
throw new ConfigError('framework.http.maxBodyBytes must be >= 1024');
}

if (security.authTokenLifetimeSeconds !== undefined && security.authTokenLifetimeSeconds <= 0) {
throw new ConfigError('security.authTokenLifetimeSeconds must be > 0');
}

if (framework.rateLimit) {
const rateValues = [
framework.rateLimit.windowMs,
framework.rateLimit.authChallengeMax,
framework.rateLimit.authTokenMax,
framework.rateLimit.webhookMax,
framework.rateLimit.depositMax,
];
if (rateValues.some((value) => value !== undefined && value <= 0)) {
throw new ConfigError('framework.rateLimit values must be > 0');
}
}

// Validate database URL loosely (could be a connection string or file path)
if (!this.isValidDatabaseUrl(framework.database.url)) {
throw new ConfigError('Invalid database URL format');
}

// Validate specific URLs if they are provided
if (server.interactiveDomain && !this.isValidUrl(server.interactiveDomain)) {
throw new ConfigError('Invalid URL format for server.interactiveDomain');
}

if (network.horizonUrl && !this.isValidUrl(network.horizonUrl)) {
throw new ConfigError('Invalid URL format for network.horizonUrl');
}

const { metadata } = this.config;
if (metadata?.tomlUrl && !this.isValidUrl(metadata.tomlUrl)) {
throw new ConfigError('Invalid URL format for metadata.tomlUrl');
}

// Validate network-related values
const validNetworks = ['public', 'testnet', 'futurenet'];
if (!validNetworks.includes(network.network)) {
throw new ConfigError(
`Invalid network: ${network.network}. Must be one of: ${validNetworks.join(', ')}`,
);
try {
AnchorKitConfigSchema.validate(this.config);
} catch (error) {
throw new ConfigError((error as Error).message);
}
}

/**
* Helper to check for standard HTTP/HTTPS URLs
* @deprecated Use ValidationUtils.isValidUrl instead
*/
private isValidUrl(urlString: string): boolean {
try {
Expand All @@ -320,23 +217,9 @@ export class AnchorConfig {

/**
* Helper to validate database connection strings or file paths
* @deprecated Use ValidationUtils.isValidDatabaseUrl instead
*/
private isValidDatabaseUrl(urlString: string): boolean {
if (!urlString || typeof urlString !== 'string') return false;

const validSchemes = ['postgresql:', 'postgres:', 'mysql:', 'mysql2:', 'sqlite:', 'file:'];

if (validSchemes.some((scheme) => urlString.startsWith(scheme))) {
return true;
}

// In case it's another valid URI
try {
if (typeof URL !== 'function') throw new Error('URL not available');
new URL(urlString);
return true;
} catch {
return false;
}
return DatabaseUrlSchema.isValid(urlString);
}
}
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export * from './types';
export { AnchorInstance, createAnchor } from './core/factory';
export * from './core/errors';
export * as utils from './utils';
export { AssetSchema, DatabaseUrlSchema } from './utils';
export type {
DatabaseAdapter,
QueueAdapter,
Expand Down
8 changes: 6 additions & 2 deletions src/runtime/http/express-router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
} from '@stellar/stellar-sdk';
import jwt from 'jsonwebtoken';
import { createHash, randomUUID } from 'node:crypto';
import { IdempotencyUtils } from '@/utils/idempotency.ts';
import type { IncomingMessage, ServerResponse } from 'node:http';

export type ExpressLikeMiddleware = (
Expand Down Expand Up @@ -489,11 +490,14 @@ export class AnchorExpressRouter {
return;
}

const idempotencyKey = req.headers['idempotency-key'];
const idempotencyKey = IdempotencyUtils.extractIdempotencyHeader(
req.headers,
'idempotency-key',
);
const scope = `deposit:${auth.account}`;
const requestHash = sha256(JSON.stringify({ assetCode, amount }));

if (typeof idempotencyKey === 'string' && idempotencyKey.length > 0) {
if (idempotencyKey !== null) {
const existing = await this.database.getIdempotencyRecord(scope, idempotencyKey);
if (existing) {
if (existing.requestHash !== requestHash) {
Expand Down
44 changes: 39 additions & 5 deletions src/runtime/queue/in-memory-queue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ export class InMemoryQueueAdapter implements QueueAdapter {
private running = false;
private activeWorkers = 0;
private worker: ((job: QueueJob) => Promise<void>) | null = null;
private stopPromise: Promise<void> | null = null;
private resolveStop: (() => void) | null = null;

constructor(options: InMemoryQueueOptions) {
this.concurrency = options.concurrency;
Expand All @@ -28,26 +30,58 @@ export class InMemoryQueueAdapter implements QueueAdapter {

public async stop(): Promise<void> {
this.running = false;

if (this.activeWorkers === 0) {
return;
}

if (this.stopPromise) {
return this.stopPromise;
}

this.stopPromise = new Promise<void>((resolve) => {
this.resolveStop = resolve;
});

// In case activeWorkers reached 0 between our check and creating the promise
if (this.activeWorkers === 0) {
this.resolveStop?.();
this.resolveStop = null;
this.stopPromise = null;
}

return this.stopPromise || Promise.resolve();
}

private kick(): void {
if (!this.running || !this.worker) return;

while (this.activeWorkers < this.concurrency && this.jobs.length > 0) {
if (!this.running) break;

const job = this.jobs.shift();
if (!job) break;

this.activeWorkers += 1;
const worker = this.worker;

void worker(job)
.catch(() => {
(async () => {
try {
await worker(job);
} catch {
// Best-effort queue for MVP: job errors are handled by worker logic.
})
.finally(() => {
} finally {
this.activeWorkers -= 1;

if (!this.running && this.activeWorkers === 0 && this.resolveStop) {
this.resolveStop();
this.resolveStop = null;
this.stopPromise = null;
}

this.kick();
});
}
})();
}
}
}
Loading
Loading