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
6 changes: 6 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,12 @@ JWT_ACCESS_TOKEN_TTL_SECONDS=900
REDIS_URL=redis://localhost:6379
ENABLE_REDIS_CACHE=true

# Query cost governor (#755): rolling per-wallet/per-IP database query budget
QUERY_COST_BUDGET=200
QUERY_COST_WINDOW_MS=60000
# QUERY_COST_MAP_JSON={"GET /search": 8}
# QUERY_COST_ADMIN_WALLETS=

# Key trade lockup (seconds between a buy and when the keys unlock for sale)
LOCKUP_DURATION_SECONDS=0

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "KeyOwnership" ADD COLUMN "lastBuyAt" TIMESTAMP(3);
25 changes: 21 additions & 4 deletions src/config.schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,24 @@ export const envSchema = z
// Left unset by default, so no caller is trusted unless configured.
TRACE_ID_TRUSTED_TOKEN: optionalNonEmptyString,
INTERNAL_SERVICE_KEY: optionalNonEmptyString,

// Query cost governor (#755): rolling per-wallet (or per-IP, when
// unauthenticated) database query budget. See
// src/middlewares/query-cost-governor.middleware.ts.
QUERY_COST_BUDGET: z.coerce.number().int().positive().default(200),
QUERY_COST_WINDOW_MS: z.coerce
.number()
.int()
.positive()
.default(60_000),
// JSON object overriding/extending the default route->cost map in
// src/constants/query-cost.constants.ts, e.g.
// '{"GET /search": 8, "GET /custom-route": 2}'. Merged over the
// defaults, not a full replacement, so operators only need to
// specify what differs.
QUERY_COST_MAP_JSON: optionalNonEmptyString,
// Comma-separated wallet addresses that bypass the governor entirely.
QUERY_COST_ADMIN_WALLETS: optionalNonEmptyString,
HORIZON_WEBHOOK_SECRET: optionalNonEmptyString,
WEBHOOK_RETRY_BASE_DELAY_MS: z.coerce
.number()
Expand All @@ -247,7 +265,9 @@ export const envSchema = z
.default(5000),
SSE_REPLAY_MAX_EVENTS: z.coerce.number().int().positive().default(100),

// Server-Sent Events (SSE) subscription limits
// SSE subscription management (src/modules/subscriptions) — a wallet's
// subscription set, persisted in Redis, distinct from the per-connection
// heartbeat/queue/replay tuning above.
SSE_MAX_CONNECTIONS_PER_WALLET: z.coerce
.number()
.int()
Expand All @@ -268,9 +288,6 @@ export const envSchema = z
.int()
.positive()
.default(1000),

// Stellar auth challenge signing secret
STELLAR_AUTH_SECRET: optionalNonEmptyString,
})
.superRefine((data, ctx) => {
if (data.MODE === 'production' && data.STELLAR_NETWORK === 'testnet') {
Expand Down
1 change: 1 addition & 0 deletions src/constants/error.constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export const ErrorCode = {
FORBIDDEN: 'FORBIDDEN',
CONFLICT: 'CONFLICT',
BAD_REQUEST: 'BAD_REQUEST',
UNPROCESSABLE_ENTITY: 'UNPROCESSABLE_ENTITY',
INTERNAL_ERROR: 'INTERNAL_ERROR',
RATE_LIMIT: 'RATE_LIMIT',
PRISMA_ERROR: 'DATABASE_ERROR',
Expand Down
29 changes: 29 additions & 0 deletions src/constants/query-cost.constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
// src/constants/query-cost.constants.ts
// Default route->cost map for the query cost governor (#755).
//
// Keys are `${METHOD} ${pattern}`, where `pattern` uses Express-style
// `:param` segments (matched via src/utils/query-cost.utils.ts, not
// req.route — the governor runs as router-level middleware, before Express
// resolves the specific route, so req.route isn't populated yet).
//
// The issue's own example routes (`GET /search`, `GET /creators/:id/history`)
// don't exist verbatim in this codebase; substituted for the closest real
// equivalents (`GET /keys/search`, `GET /creators/:id/stats`) — see the PR
// description for the full mapping rationale.
export const DEFAULT_QUERY_COST_MAP: Record<string, number> = {
'GET /creators': 1,
'GET /creators/:id/holders': 3,
'GET /creators/:id/stats': 2,
'GET /keys/search': 5,
};

/** Cost applied to any authenticated route with no explicit entry in the map. */
export const DEFAULT_QUERY_COST = 1;

/**
* Route patterns exempt from query cost governance entirely: health checks
* (must stay reachable regardless of load) and the governor's own internal
* management routes (resetting a wallet's budget must never itself be
* throttled by that same budget).
*/
export const QUERY_COST_EXEMPT_PATH_PREFIXES = ['/health', '/internal/qcost'];
9 changes: 1 addition & 8 deletions src/middlewares/jwt.middleware.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Request, Response, NextFunction } from 'express';
import jwt, { SignOptions } from 'jsonwebtoken';
import jwt from 'jsonwebtoken';
import { envConfig } from '../config';
import { sendUnauthorized } from '../utils/api-response.utils';
import { logger } from '../utils/logger.utils';
Expand Down Expand Up @@ -69,10 +69,3 @@ export function jwtAuth(req: Request, res: Response, next: NextFunction): void {
sendUnauthorized(res, 'Invalid or expired token');
}
}

export function signJwt(payload: JwtPayload): string {
const options: SignOptions = {
expiresIn: envConfig.JWT_EXPIRES_IN as any,
};
return jwt.sign(payload, envConfig.JWT_SECRET, options);
}
Loading
Loading