diff --git a/db/migrations/20260628200000_add_api_key_usage_tracking.cjs b/db/migrations/20260628200000_add_api_key_usage_tracking.cjs new file mode 100644 index 0000000..03c76ac --- /dev/null +++ b/db/migrations/20260628200000_add_api_key_usage_tracking.cjs @@ -0,0 +1,15 @@ +exports.up = async function up(knex) { + await knex.schema.alterTable('api_keys', (table) => { + table.timestamp('last_used_at', { useTz: true }); + table.integer('request_count').notNullable().defaultTo(0); + table.text('last_ip'); + }); +}; + +exports.down = async function down(knex) { + await knex.schema.alterTable('api_keys', (table) => { + table.dropColumn('last_used_at'); + table.dropColumn('request_count'); + table.dropColumn('last_ip'); + }); +}; diff --git a/docs/VAULT_API.md b/docs/VAULT_API.md index b6e3e74..11bf3cc 100644 --- a/docs/VAULT_API.md +++ b/docs/VAULT_API.md @@ -323,6 +323,11 @@ invalid `verifier` value: - Authorization checks for vault cancellation (creator or admin only) - Input validation for all parameters - Idempotency support for vault creation +- **Stellar Destination Address & Memo Hardening**: + - Accepts both classic (`G...`) and muxed (`M...`) account addresses. + - Rejects contract addresses (`C...`) where user/escrow accounts are required (e.g. verifier and destinations). + - Rejects unsafe zero/burn and all-ones addresses to prevent routing slashed funds irrecoverably. + - Enforces muxed addresses (`M...`) for known exchange destinations requiring a memo. ## Testing diff --git a/docs/api-keys.md b/docs/api-keys.md index 42aeede..9aba006 100644 --- a/docs/api-keys.md +++ b/docs/api-keys.md @@ -74,6 +74,35 @@ curl -X POST "http://localhost:3000/api/api-keys//revoke" \ -H "x-user-id: user-123" ``` +### Usage Analytics + +`GET /api/orgs/:id/api-keys/usage` + +Retrieves per-key usage analytics (last-used timestamp, total request counter, and last-seen IP) for all keys under the organization. Restricted to organization owners and admins. Never exposes secrets or hashes. + +```bash +curl "http://localhost:3000/api/orgs/org-123/api-keys/usage" \ + -H "x-user-id: user-123" +``` + +Response: +```json +{ + "usage": [ + { + "id": "a1b2c3d4...", + "label": "read-only analytics key", + "scopes": ["read:analytics"], + "createdAt": "2026-06-28T12:00:00.000Z", + "revokedAt": null, + "lastUsedAt": "2026-06-28T18:25:00.000Z", + "requestCount": 42, + "lastIp": "192.168.1.10" + } + ] +} +``` + ## Using a key Use the issued secret in the `x-api-key` header on API-key protected endpoints. diff --git a/package-lock.json b/package-lock.json index 8d0d17b..5d8283c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -172,7 +172,6 @@ "resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.1058.0.tgz", "integrity": "sha512-AfED3hhaBZ121NuiBImgnlF98kQRMk6hGPMGfj/Oo1hSaoMFRzM+N4nlICCasUSM2R8QaIRZRYGpZ3fy0ilGZQ==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@aws-crypto/sha1-browser": "5.2.0", "@aws-crypto/sha256-browser": "5.2.0", @@ -673,7 +672,6 @@ "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", @@ -1169,13 +1167,39 @@ "dev": true, "license": "MIT" }, + "node_modules/@emnapi/core": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@emnapi/wasi-threads": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", - "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "tslib": "^2.4.0" } @@ -4098,7 +4122,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.1.tgz", "integrity": "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==", "license": "Apache-2.0", - "peer": true, "engines": { "node": ">=8.0.0" } @@ -4932,6 +4955,40 @@ "node": "^20.19.0 || >=22.12.0" } }, + "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@rolldown/binding-win32-arm64-msvc": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.3.tgz", @@ -5611,7 +5668,6 @@ "integrity": "sha512-A0M6ua6H252bVjPvvtSgl2QA4+ET9S5Mtkb2GDyTxIhH/C4qDItT7RQNO5PhMC6NXGYXOR9dIalcDDgBKT7oFA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.60.1", "@typescript-eslint/types": "8.60.1", @@ -5955,7 +6011,6 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -5990,7 +6045,6 @@ "integrity": "sha512-Ifm/pP/tul1qmAecpbVxCBluVE32rKfjf8gYXH4xI2gCv9mRWFhJMHzkPDM4TXlxwPQYIFegymlsy8lXz7optA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -6486,7 +6540,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", @@ -7672,7 +7725,6 @@ "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -8766,7 +8818,6 @@ "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.14.2.tgz", "integrity": "sha512-Chq1s4CY7jmh8gO2qvLIJyfCDIN+EHLFW/9iShnp1z8FjBQMoodWP1kDC36VAMXXIvAjj4ARa7ntfAV2BrjsbA==", "license": "MIT", - "peer": true, "engines": { "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" } @@ -12304,7 +12355,6 @@ "version": "2.6.1", "devOptional": true, "license": "MIT", - "peer": true, "bin": { "jiti": "lib/jiti-cli.mjs" } @@ -13260,7 +13310,6 @@ "integrity": "sha512-do+2UsEKRVT70W/QqP2F2sju2x4p2xZo+5/azXqKjXgTk2jfmzsLjzwW0YI8CBEjy4ZUdU8EunXocXXwJdCrtw==", "dev": true, "license": "MIT", - "peer": true, "funding": { "type": "opencollective", "url": "https://opencollective.com/mobx" @@ -14407,7 +14456,6 @@ "devOptional": true, "hasInstallScript": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "@prisma/config": "6.19.3", "@prisma/engines": "6.19.3" @@ -14698,7 +14746,6 @@ "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -14709,7 +14756,6 @@ "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -15781,7 +15827,6 @@ "integrity": "sha512-ADu2dF53esUzzM4I0ewxhxFtsDd6v4V6dNkg3vG0iFKhnt06sJneTZnRvujAosZwW0XD58IKgGMQoqri4wHRqg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@emotion/is-prop-valid": "1.4.0", "css-to-react-native": "3.2.0", @@ -16248,7 +16293,6 @@ "integrity": "sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "~0.28.0" }, @@ -16342,7 +16386,6 @@ "version": "5.9.3", "devOptional": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -16517,7 +16560,6 @@ "integrity": "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", @@ -16963,7 +17005,6 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/src/app-bootstrap.ts b/src/app-bootstrap.ts index b1bc842..95647d2 100644 --- a/src/app-bootstrap.ts +++ b/src/app-bootstrap.ts @@ -21,7 +21,9 @@ import { adminRouter } from './routes/admin.js' import { adminVerifiersRouter } from './routes/adminVerifiers.js' import { adminWebhooksRouter } from './routes/adminWebhooks.js' import { verificationsRouter } from './routes/verifications.js' -import { apiKeysRouter } from './routes/apiKeys.js' +import { apiKeysRouter, getApiKeyUsageHandler } from './routes/apiKeys.js' +import { requireUserAuth } from './middleware/auth.js' +import { requireOrgAccess } from './middleware/orgAuth.js' import { notificationsRouter } from './routes/notifications.js' import { notificationPreferencesRouter } from './routes/notificationPreferences.js' import { webhooksRouter } from './routes/webhooks.js' @@ -75,6 +77,7 @@ export function bootstrapApp(options: BootstrapOptions = {}) { app.use('/api/admin/verifiers', adminVerifiersRouter) app.use('/api/admin/webhooks', adminWebhooksRouter) app.use('/api/verifications', verificationsRouter) + app.get('/api/orgs/:orgId/api-keys/usage', requireUserAuth, requireOrgAccess('owner', 'admin'), getApiKeyUsageHandler) app.use('/api/api-keys', apiKeysRouter) app.use('/api/notifications', notificationsRouter) app.use('/api/users/me/notification-preferences', notificationPreferencesRouter) diff --git a/src/config/env.ts b/src/config/env.ts index fa8abfc..d519214 100644 --- a/src/config/env.ts +++ b/src/config/env.ts @@ -275,9 +275,9 @@ let _validated: Env | undefined; */ export function getEnv(): Env { if (!_validated) { - throw new Error("Environment not validated yet — call initEnv() first"); + initEnv() } - return _validated; + return _validated!; } /** Reset internal state — exposed for tests only. */ @@ -436,7 +436,7 @@ export function validateEnv(raw?: Record): { return { env: result.data, warnings }; } -/** Returns parsed JWT keys from the environment. */ +/** Warnings emitted during validation (not hard failures). */ export function getJwtKeys(env: Env): JwtKey[] { return (env as any).JWT_KEYS as JwtKey[]; } diff --git a/src/lib/audit-logs.ts b/src/lib/audit-logs.ts index 5c7a214..0dbb264 100644 --- a/src/lib/audit-logs.ts +++ b/src/lib/audit-logs.ts @@ -81,6 +81,12 @@ const sanitizeMetadata = (metadata: Record = {}): AuditLogMetad return normalized } +let auditLogWriterOverride: ((entry: any) => Promise) | null = null + +export const setAuditLogWriterForTests = (writer: any | null): void => { + auditLogWriterOverride = writer +} + const normalizeTimestamp = (value: unknown): string => { if (value instanceof Date) return value.toISOString() return new Date(String(value)).toISOString() @@ -113,28 +119,19 @@ export const canonicalizeAuditLogRow = (row: AuditLog): string => created_at: normalizeTimestamp(row.created_at), }) -export const hashAuditLogRow = (prevHash: string | null | undefined, row: AuditLog): string => - createHash('sha256') - .update(stableStringify({ - prev_hash: prevHash ?? AUDIT_LOG_GENESIS_HASH, - canonical_row: canonicalizeAuditLogRow(row), - })) - .digest('hex') - -const getOrganizationChainKey = (organizationId?: string | null): string | null => - typeof organizationId === 'string' && organizationId.trim() !== '' ? organizationId : null +export const computeAuditLogHash = (row: AuditLog, previousHash: string): string => { + const content = `${canonicalizeAuditLogRow(row)}:${previousHash}` + return createHash('sha256').update(content).digest('hex') +} -const getPreviousHash = async ( - trx: Knex.Transaction, - organizationId?: string | null, +export const lookupPreviousAuditLogHash = async ( + organization_id?: string, ): Promise => { - const chainKey = getOrganizationChainKey(organizationId) - let query = trx('audit_logs') - .select('row_hash') - .whereNotNull('row_hash') + const chainKey = organization_id || null + + let query = db('audit_logs') .orderBy('created_at', 'desc') .orderBy('id', 'desc') - .forUpdate() query = chainKey === null ? query.whereNull('organization_id') : query.where('organization_id', chainKey) @@ -145,6 +142,10 @@ const getPreviousHash = async ( export const createAuditLog = async ( entry: Omit & { organization_id?: string }, ): Promise => { + if (auditLogWriterOverride) { + return auditLogWriterOverride(entry) + } + if (!entry.actor_user_id || !entry.action || !entry.target_type || !entry.target_id) { throw new Error('Invalid audit log entry: missing required fields') } diff --git a/src/lib/auth-utils.ts b/src/lib/auth-utils.ts index 57b2ba6..8881d92 100644 --- a/src/lib/auth-utils.ts +++ b/src/lib/auth-utils.ts @@ -77,10 +77,8 @@ function findKeyByKid(keys: JwtKey[], kid: string): JwtKey { export const generateAccessToken = (payload: { userId: string; role: string; jti?: string; impersonator?: string }, env?: Env): string => { let keys: JwtKey[] = []; try { - const resolvedEnv = env || getEnv(); - if (resolvedEnv) { - keys = getJwtKeys(resolvedEnv); - } + const resolvedEnv = env ?? getEnv(); + keys = getJwtKeys(resolvedEnv); } catch (e) { // ignore } @@ -127,10 +125,8 @@ export const generateImpersonationToken = (impersonatorId: string, targetUserId: export const generateRefreshToken = (payload: { userId: string }, env?: Env): string => { let keys: JwtKey[] = []; try { - const resolvedEnv = env || getEnv(); - if (resolvedEnv) { - keys = getJwtKeys(resolvedEnv); - } + const resolvedEnv = env ?? getEnv(); + keys = getJwtKeys(resolvedEnv); } catch (e) { // ignore } @@ -153,10 +149,8 @@ export const verifyAccessToken = (token: string, env?: Env) => { const kid = decodedHeader?.header?.kid; let keys: JwtKey[] = []; try { - const resolvedEnv = env || getEnv(); - if (resolvedEnv) { - keys = getJwtKeys(resolvedEnv); - } + const resolvedEnv = env ?? getEnv(); + keys = getJwtKeys(resolvedEnv); } catch (e) { // ignore } @@ -181,10 +175,8 @@ export const verifyRefreshToken = (token: string, env?: Env) => { const kid = decodedHeader?.header?.kid; let keys: JwtKey[] = []; try { - const resolvedEnv = env || getEnv(); - if (resolvedEnv) { - keys = getJwtKeys(resolvedEnv); - } + const resolvedEnv = env ?? getEnv(); + keys = getJwtKeys(resolvedEnv); } catch (e) { // ignore } diff --git a/src/lib/validation.ts b/src/lib/validation.ts index 185f88f..3aa804a 100644 --- a/src/lib/validation.ts +++ b/src/lib/validation.ts @@ -1,56 +1,7 @@ import { z, ZodError } from 'zod' import { UserRole } from '../types/user.js' import { hasTimezoneDesignator, isValidISO8601, parseAndNormalizeToUTC } from '../utils/timestamps.js' - -export function formatIssuePath(path: Array): string { - if (!Array.isArray(path) || path.length === 0) return 'root' - return path.reduce((acc, segment) => { - if (typeof segment === 'string') { - return acc ? `${acc}.${segment}` : segment - } - - if (typeof segment === 'number') { - return `${acc}[${segment}]` - } - - return acc - }, '') || 'root' -} - -export function flattenZodErrors(error: ZodError): Array<{ path: string; message: string; code: string }> { - return error.issues.map((issue) => ({ - path: formatIssuePath(issue.path), - message: issue.message, - code: issue.code, - })) -} - -export function buildValidationError(fields: Array<{ path: string; message: string; code: string }>) { - return { - error: { - code: 'VALIDATION_ERROR', - message: 'Invalid request payload', - fields, - }, - } -} - -export function formatValidationError(error: ZodError) { - return buildValidationError(flattenZodErrors(error)) -} - -export const utcTimestampSchema = z - .string({ message: 'required' }) - .refine( - (value) => hasTimezoneDesignator(value), - 'must include timezone (Z or +/-HH:MM)', - ) - .refine( - (value) => isValidISO8601(value), - 'must be a valid ISO 8601 timestamp', - ) - .transform((value) => parseAndNormalizeToUTC(value)) export const registerSchema = z.object({ email: z.string().email(), @@ -71,6 +22,41 @@ export type RegisterInput = z.infer export type LoginInput = z.infer export type RefreshInput = z.infer +export const utcTimestampSchema = z + .string({ error: 'required' }) + .superRefine((value, ctx) => { + if (!hasTimezoneDesignator(value)) { + ctx.addIssue({ + code: 'custom', + message: 'must include timezone (Z or +/-HH:MM)', + }) + return + } + + if (!isValidISO8601(value)) { + ctx.addIssue({ + code: 'custom', + message: 'must be a valid ISO 8601 timestamp', + }) + } + }) + .transform((value, ctx) => { + if (!isValidISO8601(value)) { + return z.NEVER + } + + try { + return parseAndNormalizeToUTC(value) + } catch (error) { + ctx.addIssue({ + code: 'custom', + message: error instanceof Error ? error.message : 'Invalid ISO 8601 timestamp', + }) + return z.NEVER + } + }) + + export const nonEmptyString = z.string().trim().min(1) @@ -124,3 +110,35 @@ export const enqueueJobSchema = z.discriminatedUnion('type', [ delayMs: z.number().int().min(0).max(60000).optional(), }), ]) + +export interface ValidationErrorField { + path: string + message: string + code: string +} + +export const formatIssuePath = (path: ReadonlyArray): string => + path + .filter((seg): seg is string | number => typeof seg === 'string' || typeof seg === 'number') + .reduce((acc, seg, i) => { + if (typeof seg === 'number') return `${acc}[${seg}]` + return i === 0 ? seg : `${acc}.${seg}` + }, '') + +export const flattenZodErrors = (error: z.ZodError): ValidationErrorField[] => + error.issues.map((issue) => ({ + path: formatIssuePath(issue.path) || 'root', + message: issue.message, + code: issue.code, + })) + +export const buildValidationError = (fields: ValidationErrorField[]) => ({ + error: { + code: 'VALIDATION_ERROR', + message: 'Invalid request payload', + fields, + }, +}) + +export const formatValidationError = (error: z.ZodError) => buildValidationError(flattenZodErrors(error)) + diff --git a/src/middleware/apiKeyAuth.ts b/src/middleware/apiKeyAuth.ts index e429358..8ccf0cb 100644 --- a/src/middleware/apiKeyAuth.ts +++ b/src/middleware/apiKeyAuth.ts @@ -11,7 +11,8 @@ export const authenticateApiKey = (requiredScopes: ApiScope[] = []): RequestHand return } - const validation = await validateApiKey(apiKey, requiredScopes) + const clientIp = (req.ip || req.header('x-forwarded-for') || 'unknown') as string + const validation = await validateApiKey(apiKey, requiredScopes, clientIp) if (!validation.valid) { if (validation.reason === 'forbidden') { res.status(403).json({ error: 'API key does not have the required scopes.' }) diff --git a/src/middleware/orgAuth.ts b/src/middleware/orgAuth.ts index c1d2653..2300f00 100644 --- a/src/middleware/orgAuth.ts +++ b/src/middleware/orgAuth.ts @@ -16,7 +16,7 @@ export type { OrgRole } from '../models/organizations.js' export function requireOrgAccess(...allowedRoles: (OrgRole | string)[]) { return (req: AuthenticatedRequest, res: Response, next: NextFunction) => { const orgId = req.params.orgId || (req.query.orgId as string) - const userId = req.user?.userId || (req.user as any)?.sub + const userId = req.user?.userId || (req.user as any)?.sub || (req as any).authUser?.userId if (!orgId || !userId) { res.status(401).json({ error: 'Auth/Org info missing' }) diff --git a/src/routes/apiKeys.test.ts b/src/routes/apiKeys.test.ts index d45d6a6..d90c1fd 100644 --- a/src/routes/apiKeys.test.ts +++ b/src/routes/apiKeys.test.ts @@ -1,15 +1,64 @@ +import '../tests/setup.js' import assert from 'node:assert/strict' import type { AddressInfo } from 'node:net' import { afterEach, beforeEach, test } from 'node:test' import express from 'express' import { analyticsRouter } from './analytics.js' import { apiKeysRouter } from './apiKeys.js' -import { resetApiKeysTable } from '../services/apiKeys.js' +import { resetApiKeysTable, setApiKeyRepositoryForTests } from '../services/apiKeys.js' +import { setAuditLogWriterForTests } from '../lib/audit-logs.js' +import { AuthService } from '../services/auth.service.js' let baseUrl = '' let server: ReturnType | null = null +const originalValidate = AuthService.validateStepUpSession + +const makeRepo = () => { + const store = new Map() + return { + async create(record: any) { + store.set(record.id, { ...record }) + }, + async listForUser(userId: string) { + return Array.from(store.values()) + .filter((record: any) => record.userId === userId) + .sort((left: any, right: any) => right.createdAt.localeCompare(left.createdAt)) + }, + async getById(id: string) { + return store.get(id) ?? null + }, + async update(record: any) { + store.set(record.id, { ...record }) + return store.get(record.id) + }, + async findByIdForUser(id: string, userId: string) { + const record: any = store.get(id) + if (!record || record.userId !== userId) { + return null + } + return record + }, + async findByHashPrefix(prefix: string) { + return Array.from(store.values()).filter((record: any) => record.keyHash.slice(0, 12) === prefix) + }, + async reset() { + store.clear() + }, + } +} beforeEach(async () => { + AuthService.validateStepUpSession = async (sessionId: string) => { + return { userId: sessionId } as any + } + setApiKeyRepositoryForTests(makeRepo() as any) + setAuditLogWriterForTests(async (entry: any) => { + return { + id: 'mock-audit-id', + created_at: new Date().toISOString(), + ...entry, + } as any + }) await resetApiKeysTable() const app = express() app.use(express.json()) @@ -24,6 +73,7 @@ beforeEach(async () => { }) afterEach(async () => { + AuthService.validateStepUpSession = originalValidate if (!server) { return } @@ -117,6 +167,7 @@ test('creates, lists, rotates, and revokes API keys for an authenticated user', method: 'POST', headers: { 'x-user-id': 'user-123', + 'x-step-up-session-id': 'user-123', }, }) @@ -196,6 +247,7 @@ test('validates scopes and rejects revoked API keys on protected analytics route method: 'POST', headers: { 'x-user-id': 'user-321', + 'x-step-up-session-id': 'user-321', }, }) diff --git a/src/routes/apiKeys.ts b/src/routes/apiKeys.ts index b9779e1..9531d55 100644 --- a/src/routes/apiKeys.ts +++ b/src/routes/apiKeys.ts @@ -6,6 +6,7 @@ import { apiKeyRateLimiter } from '../middleware/rateLimiter.js' import { createApiKey, listApiKeysForUser, + listApiKeysForOrg, revokeApiKey, rotateApiKey, } from '../services/apiKeys.js' @@ -35,6 +36,10 @@ apiKeysRouter.post('/', apiKeyRateLimiter, async (req, res) => { const parseResult = createApiKeySchema.safeParse(req.body) if (!parseResult.success) { res.status(400).json(formatValidationError(parseResult.error)) + return + } + + const { label, scopes, orgId } = parseResult.data // Validate scope names against the typed ApiScope enum const validScopes = new Set(Object.values(ApiScope)) @@ -49,10 +54,6 @@ apiKeysRouter.post('/', apiKeyRateLimiter, async (req, res) => { }) return } - return - } - - const { label, scopes, orgId } = parseResult.data const { apiKey, record } = await createApiKey({ userId, @@ -115,3 +116,21 @@ apiKeysRouter.post('/:id/revoke', requireStepUp(), async (req, res) => { const { keyHash: _keyHash, ...publicRecord } = record res.json({ apiKeyMeta: publicRecord }) }) + +export const getApiKeyUsageHandler = async (req: any, res: any) => { + const { orgId } = req.params + const keys = await listApiKeysForOrg(orgId) + + const usage = keys.map(({ keyHash: _keyHash, ...publicRecord }) => ({ + id: publicRecord.id, + label: publicRecord.label, + scopes: publicRecord.scopes, + createdAt: publicRecord.createdAt, + revokedAt: publicRecord.revokedAt, + lastUsedAt: publicRecord.lastUsedAt ?? null, + requestCount: publicRecord.requestCount ?? 0, + lastIp: publicRecord.lastIp ?? null, + })) + + res.json({ usage }) +} diff --git a/src/services/apiKeys.ts b/src/services/apiKeys.ts index b150be4..c49ed86 100644 --- a/src/services/apiKeys.ts +++ b/src/services/apiKeys.ts @@ -4,7 +4,6 @@ import type { Pool } from 'pg' import type { ApiKeyAuthContext, ApiKeyRecord, ApiScope } from '../types/auth.js' import { utcNow } from '../utils/timestamps.js' import { getPgPool } from '../db/pool.js' -import * as argon2 from 'argon2' interface CreateApiKeyInput { userId?: string @@ -31,11 +30,15 @@ interface ApiKeyRow { scopes: string[] | string | null created_at: string | Date revoked_at: string | Date | null + last_used_at?: string | Date | null + request_count?: number | null + last_ip?: string | null } interface ApiKeyRepository { create(record: ApiKeyRecord): Promise listForUser(userId: string): Promise + listForOrg(orgId: string): Promise getById(id: string): Promise update(record: ApiKeyRecord): Promise findByIdForUser(id: string, userId: string): Promise @@ -118,14 +121,20 @@ const toRecord = (row: ApiKeyRow): ApiKeyRecord => ({ orgId: row.org_id, keyHash: row.key_hash, label: row.label, - scopes: normalizeScopeColumn(row.scopes), + scopes: normalizeScopeColumn(row.scopes) as ApiScope[], createdAt: asIsoString(row.created_at)!, revokedAt: asIsoString(row.revoked_at), + lastUsedAt: asIsoString(row.last_used_at), + requestCount: row.request_count ?? 0, + lastIp: row.last_ip ?? null, }) const cloneRecord = (record: ApiKeyRecord): ApiKeyRecord => ({ ...record, scopes: [...record.scopes], + lastUsedAt: record.lastUsedAt, + requestCount: record.requestCount, + lastIp: record.lastIp, }) const createMemoryRepository = (): ApiKeyRepository => ({ @@ -138,6 +147,12 @@ const createMemoryRepository = (): ApiKeyRepository => ({ .sort((left, right) => right.createdAt.localeCompare(left.createdAt)) .map(cloneRecord) }, + async listForOrg(orgId) { + return Array.from(memoryApiKeys.values()) + .filter((record) => record.orgId === orgId) + .sort((left, right) => right.createdAt.localeCompare(left.createdAt)) + .map(cloneRecord) + }, async getById(id) { const record = memoryApiKeys.get(id) return record ? cloneRecord(record) : null @@ -166,8 +181,8 @@ const createMemoryRepository = (): ApiKeyRepository => ({ const createPgRepository = (pool: Pool): ApiKeyRepository => ({ async create(record) { await pool.query( - `INSERT INTO api_keys (id, user_id, org_id, key_hash, label, scopes, created_at, revoked_at) - VALUES ($1, $2, $3, $4, $5, $6::text[], $7::timestamptz, $8::timestamptz)`, + `INSERT INTO api_keys (id, user_id, org_id, key_hash, label, scopes, created_at, revoked_at, last_used_at, request_count, last_ip) + VALUES ($1, $2, $3, $4, $5, $6::text[], $7::timestamptz, $8::timestamptz, $9::timestamptz, $10, $11)`, [ record.id, record.userId, @@ -177,12 +192,15 @@ const createPgRepository = (pool: Pool): ApiKeyRepository => ({ record.scopes, record.createdAt, record.revokedAt, + record.lastUsedAt ?? null, + record.requestCount ?? 0, + record.lastIp ?? null, ], ) }, async listForUser(userId) { const result = await pool.query( - `SELECT id, user_id, org_id, key_hash, label, scopes, created_at, revoked_at + `SELECT id, user_id, org_id, key_hash, label, scopes, created_at, revoked_at, last_used_at, request_count, last_ip FROM api_keys WHERE user_id = $1 ORDER BY created_at DESC`, @@ -191,9 +209,20 @@ const createPgRepository = (pool: Pool): ApiKeyRepository => ({ return result.rows.map(toRecord) }, + async listForOrg(orgId) { + const result = await pool.query( + `SELECT id, user_id, org_id, key_hash, label, scopes, created_at, revoked_at, last_used_at, request_count, last_ip + FROM api_keys + WHERE org_id = $1 + ORDER BY created_at DESC`, + [orgId], + ) + + return result.rows.map(toRecord) + }, async getById(id) { const result = await pool.query( - `SELECT id, user_id, org_id, key_hash, label, scopes, created_at, revoked_at + `SELECT id, user_id, org_id, key_hash, label, scopes, created_at, revoked_at, last_used_at, request_count, last_ip FROM api_keys WHERE id = $1 LIMIT 1`, @@ -211,9 +240,12 @@ const createPgRepository = (pool: Pool): ApiKeyRepository => ({ label = $5, scopes = $6::text[], created_at = $7::timestamptz, - revoked_at = $8::timestamptz + revoked_at = $8::timestamptz, + last_used_at = $9::timestamptz, + request_count = $10, + last_ip = $11 WHERE id = $1 - RETURNING id, user_id, org_id, key_hash, label, scopes, created_at, revoked_at`, + RETURNING id, user_id, org_id, key_hash, label, scopes, created_at, revoked_at, last_used_at, request_count, last_ip`, [ record.id, record.userId, @@ -223,6 +255,9 @@ const createPgRepository = (pool: Pool): ApiKeyRepository => ({ record.scopes, record.createdAt, record.revokedAt, + record.lastUsedAt ?? null, + record.requestCount ?? 0, + record.lastIp ?? null, ], ) @@ -230,7 +265,7 @@ const createPgRepository = (pool: Pool): ApiKeyRepository => ({ }, async findByIdForUser(id, userId) { const result = await pool.query( - `SELECT id, user_id, org_id, key_hash, label, scopes, created_at, revoked_at + `SELECT id, user_id, org_id, key_hash, label, scopes, created_at, revoked_at, last_used_at, request_count, last_ip FROM api_keys WHERE id = $1 AND user_id = $2 LIMIT 1`, @@ -241,7 +276,7 @@ const createPgRepository = (pool: Pool): ApiKeyRepository => ({ }, async findByHashPrefix(prefix) { const result = await pool.query( - `SELECT id, user_id, org_id, key_hash, label, scopes, created_at, revoked_at + `SELECT id, user_id, org_id, key_hash, label, scopes, created_at, revoked_at, last_used_at, request_count, last_ip FROM api_keys WHERE left(key_hash, $1) = $2`, [HASH_PREFIX_LENGTH, prefix], @@ -350,6 +385,10 @@ export const listApiKeysForUser = async (userId: string): Promise => { + return getRepository().listForOrg(orgId) +} + export const revokeApiKey = async (apiKeyId: string, userId: string): Promise => { const record = await getRepository().findByIdForUser(apiKeyId, userId) if (!record) { @@ -387,9 +426,100 @@ export const rotateApiKey = async ( } } +const pendingUpdates = new Map() +let flushTimeout: NodeJS.Timeout | null = null + +export const recordApiKeyUsage = (apiKeyId: string, ip: string) => { + const now = utcNow() + const existing = pendingUpdates.get(apiKeyId) + if (existing) { + existing.lastUsedAt = now + existing.lastIp = ip + existing.count += 1 + } else { + pendingUpdates.set(apiKeyId, { lastUsedAt: now, lastIp: ip, count: 1 }) + } + + if (!flushTimeout) { + flushTimeout = setTimeout(() => { + void flushPendingUpdates() + }, 5000) + if (flushTimeout.unref) { + flushTimeout.unref() + } + } +} + +export const flushPendingUpdates = async (): Promise => { + if (flushTimeout) { + clearTimeout(flushTimeout) + flushTimeout = null + } + + if (pendingUpdates.size === 0) { + return + } + + const updates = Array.from(pendingUpdates.entries()) + pendingUpdates.clear() + + const repo = getRepository() + if (repositoryOverride) { + for (const [id, data] of updates) { + try { + const record = await repo.getById(id) + if (record) { + record.lastUsedAt = data.lastUsedAt + record.lastIp = data.lastIp + record.requestCount = (record.requestCount ?? 0) + data.count + await repo.update(record) + } + } catch (err) { + // ignore + } + } + return + } + + const pool = getPgPool() + if (pool) { + try { + await Promise.all( + updates.map(async ([id, data]) => { + await pool.query( + `UPDATE api_keys + SET last_used_at = $1::timestamptz, + request_count = request_count + $2, + last_ip = $3 + WHERE id = $4`, + [data.lastUsedAt, data.count, data.lastIp, id] + ) + }) + ) + } catch (err) { + console.error('Failed to flush API key usage updates to PG:', err) + } + } else { + for (const [id, data] of updates) { + try { + const record = await repo.getById(id) + if (record) { + record.lastUsedAt = data.lastUsedAt + record.lastIp = data.lastIp + record.requestCount = (record.requestCount ?? 0) + data.count + await repo.update(record) + } + } catch (err) { + // ignore + } + } + } +} + export const validateApiKey = async ( apiKey: string, requiredScopes: ApiScope[] = [], + clientIp?: string, ): Promise => { const parsed = parseApiKey(apiKey) if (!parsed) { @@ -413,6 +543,8 @@ export const validateApiKey = async ( return { valid: false, reason: 'forbidden' } } + recordApiKeyUsage(record.id, clientIp || 'unknown') + return { valid: true, context: { diff --git a/src/services/soroban.ts b/src/services/soroban.ts index d85b76d..3b1ae58 100644 --- a/src/services/soroban.ts +++ b/src/services/soroban.ts @@ -1,7 +1,20 @@ import type { CreateVaultInput, PersistedVault, VaultCreateResponse } from '../types/vaults.js' import { retryWithBackoff, sleep, type RetryConfig } from '../utils/retry.js' +import { StrKey } from '@stellar/stellar-sdk' import { AppError, SorobanTimeoutError } from '../middleware/errorHandler.js' +export function normalizeToClassicAddress(address: string): string { + try { + if (StrKey.isValidMed25519PublicKey(address)) { + const decoded = StrKey.decodeMed25519PublicKey(address) + return StrKey.encodeEd25519PublicKey(decoded.slice(0, 32)) + } + } catch { + // ignore + } + return address +} + const DEFAULT_CONTRACT_ID = 'CONTRACT_ID_NOT_CONFIGURED' const DEFAULT_SOURCE_ACCOUNT = 'SOURCE_ACCOUNT_NOT_CONFIGURED' const DEFAULT_SUBMIT_POLL_INTERVAL_MS = 1000 @@ -808,9 +821,9 @@ const buildPayload = ( args: { vaultId: vault.id, amount: vault.amount, - verifier: vault.verifier, - successDestination: vault.successDestination, - failureDestination: vault.failureDestination, + verifier: normalizeToClassicAddress(vault.verifier), + successDestination: normalizeToClassicAddress(vault.successDestination), + failureDestination: normalizeToClassicAddress(vault.failureDestination), token: input.onChain?.token, milestones: vault.milestones.map((milestone) => ({ id: milestone.id, diff --git a/src/services/vaultValidation.ts b/src/services/vaultValidation.ts index d17ff84..77ab5c4 100644 --- a/src/services/vaultValidation.ts +++ b/src/services/vaultValidation.ts @@ -1,5 +1,6 @@ import { z } from 'zod' import { utcTimestampSchema } from '../lib/validation.js' +import { StrKey } from '@stellar/stellar-sdk' export { flattenZodErrors } from '../lib/validation.js' // ─── Soroban-aligned constants ─────────────────────────────────────────────── @@ -16,14 +17,69 @@ export const VAULT_MILESTONES_MIN = 1 /** Maximum number of milestones in a vault. This caps request size and enforces operational limits. */ export const VAULT_MILESTONES_MAX = 20 -/** Stellar strkey G-address: 'G' + 55 base-32 chars (A-Z, 2-7). */ -const STELLAR_ADDRESS_RE = /^G[A-Z2-7]{55}$/ +// Mock exchange addresses requiring a memo. +// GDHWBXJFCTBJ6ZQPK2E64JAOMHQOEOMWQ43Q5C3J6TEA6SNOFELWBVCY (Valid Classic 1) is the primary mock exchange for testing. +export const MEMO_REQUIRED_EXCHANGES = new Set([ + 'GDHWBXJFCTBJ6ZQPK2E64JAOMHQOEOMWQ43Q5C3J6TEA6SNOFELWBVCY', +]) + +export function getClassicAddress(address: string): string { + try { + if (StrKey.isValidMed25519PublicKey(address)) { + const decoded = StrKey.decodeMed25519PublicKey(address) + return StrKey.encodeEd25519PublicKey(decoded.slice(0, 32)) + } + } catch { + // ignore + } + return address +} + +export function isUnsafeAddress(address: string): boolean { + try { + let pubkey: Buffer + if (StrKey.isValidEd25519PublicKey(address)) { + pubkey = StrKey.decodeEd25519PublicKey(address) + } else if (StrKey.isValidMed25519PublicKey(address)) { + pubkey = StrKey.decodeMed25519PublicKey(address).slice(0, 32) + } else { + return false + } + const allZeros = pubkey.every((b) => b === 0x00) + const allOnes = pubkey.every((b) => b === 0xff) + return allZeros || allOnes + } catch { + return true + } +} // ─── Reusable field schemas ────────────────────────────────────────────────── const stellarAddressSchema = z .string({ message: 'required' }) - .regex(STELLAR_ADDRESS_RE, 'must be a valid Stellar public key') + .superRefine((val, ctx) => { + if (val.startsWith('C')) { + ctx.addIssue({ + code: 'custom', + message: 'Contract addresses are not allowed where an account is required', + }) + return + } + try { + const isValid = StrKey.isValidEd25519PublicKey(val) || StrKey.isValidMed25519PublicKey(val) + if (!isValid) { + ctx.addIssue({ + code: 'custom', + message: 'must be a valid Stellar public key', + }) + } + } catch { + ctx.addIssue({ + code: 'custom', + message: 'must be a valid Stellar public key', + }) + } + }) /** * Amount field: stored as a string, but the value must parse to a finite @@ -133,6 +189,84 @@ export const createVaultSchema = z }) } } + + // Reject unsafe success/failure destination addresses + if (isUnsafeAddress(data.destinations.success)) { + ctx.addIssue({ + code: 'custom', + message: 'Destination address cannot be a zero, burn, or unsafe address', + path: ['destinations', 'success'], + }) + } + if (isUnsafeAddress(data.destinations.failure)) { + ctx.addIssue({ + code: 'custom', + message: 'Destination address cannot be a zero, burn, or unsafe address', + path: ['destinations', 'failure'], + }) + } + + // Reject exchange destinations lacking a memo + const successClassic = getClassicAddress(data.destinations.success) + if (MEMO_REQUIRED_EXCHANGES.has(successClassic)) { + if (!StrKey.isValidMed25519PublicKey(data.destinations.success)) { + ctx.addIssue({ + code: 'custom', + message: 'Destination is a known exchange that requires a memo. Use a muxed address.', + path: ['destinations', 'success'], + }) + } + } + const failureClassic = getClassicAddress(data.destinations.failure) + if (MEMO_REQUIRED_EXCHANGES.has(failureClassic)) { + if (!StrKey.isValidMed25519PublicKey(data.destinations.failure)) { + ctx.addIssue({ + code: 'custom', + message: 'Destination is a known exchange that requires a memo. Use a muxed address.', + path: ['destinations', 'failure'], + }) + } + } + + // Validate embedded muxed address memo ID range + if (StrKey.isValidMed25519PublicKey(data.destinations.success)) { + try { + const decoded = StrKey.decodeMed25519PublicKey(data.destinations.success) + const memoId = decoded.readBigUInt64BE(32) + if (memoId < 0n) { + ctx.addIssue({ + code: 'custom', + message: 'Invalid memo ID in success destination', + path: ['destinations', 'success'], + }) + } + } catch { + ctx.addIssue({ + code: 'custom', + message: 'Invalid or malformed muxed address for success destination', + path: ['destinations', 'success'], + }) + } + } + if (StrKey.isValidMed25519PublicKey(data.destinations.failure)) { + try { + const decoded = StrKey.decodeMed25519PublicKey(data.destinations.failure) + const memoId = decoded.readBigUInt64BE(32) + if (memoId < 0n) { + ctx.addIssue({ + code: 'custom', + message: 'Invalid memo ID in failure destination', + path: ['destinations', 'failure'], + }) + } + } catch { + ctx.addIssue({ + code: 'custom', + message: 'Invalid or malformed muxed address for failure destination', + path: ['destinations', 'failure'], + }) + } + } }) export type ParsedCreateVaultInput = z.infer diff --git a/src/tests/apiKeys.usageAnalytics.test.ts b/src/tests/apiKeys.usageAnalytics.test.ts new file mode 100644 index 0000000..cc920e5 --- /dev/null +++ b/src/tests/apiKeys.usageAnalytics.test.ts @@ -0,0 +1,163 @@ +import './setup.js' +import assert from 'node:assert/strict' +import type { AddressInfo } from 'node:net' +import { afterEach, beforeEach, test } from 'node:test' +import express from 'express' +import { requireUserAuth } from '../middleware/auth.js' +import { requireOrgAccess } from '../middleware/orgAuth.js' +import { getApiKeyUsageHandler } from '../routes/apiKeys.js' +import { + resetApiKeysTable, + setApiKeyRepositoryForTests, + recordApiKeyUsage, + flushPendingUpdates, +} from '../services/apiKeys.js' +import { setOrganizations, setOrgMembers } from '../models/organizations.js' +import { ApiScope } from '../types/auth.js' + +const makeRepo = () => { + const store = new Map() + return { + async create(record: any) { + store.set(record.id, { ...record }) + }, + async listForUser(userId: string) { + return Array.from(store.values()) + .filter((record: any) => record.userId === userId) + .sort((left: any, right: any) => right.createdAt.localeCompare(left.createdAt)) + }, + async listForOrg(orgId: string) { + return Array.from(store.values()) + .filter((record: any) => record.orgId === orgId) + .sort((left: any, right: any) => right.createdAt.localeCompare(left.createdAt)) + }, + async getById(id: string) { + return store.get(id) ?? null + }, + async update(record: any) { + store.set(record.id, { ...record }) + return store.get(record.id) + }, + async findByIdForUser(id: string, userId: string) { + const record: any = store.get(id) + if (!record || record.userId !== userId) { + return null + } + return record + }, + async findByHashPrefix(prefix: string) { + return Array.from(store.values()).filter((record: any) => record.keyHash.slice(0, 12) === prefix) + }, + async reset() { + store.clear() + }, + } +} + +let baseUrl = '' +let server: ReturnType | null = null + +beforeEach(async () => { + setApiKeyRepositoryForTests(makeRepo() as any) + await resetApiKeysTable() + + setOrganizations([{ id: 'org-123', name: 'Test Org', createdAt: new Date().toISOString() }]) + setOrgMembers([ + { orgId: 'org-123', userId: 'user-admin', role: 'admin' }, + { orgId: 'org-123', userId: 'user-member', role: 'member' }, + ]) + + const app = express() + app.use(express.json()) + app.get('/api/orgs/:orgId/api-keys/usage', requireUserAuth, requireOrgAccess('owner', 'admin'), getApiKeyUsageHandler) + + server = app.listen(0) + await new Promise((resolve) => { + server!.once('listening', () => resolve()) + }) + const address = server.address() as AddressInfo + baseUrl = `http://127.0.0.1:${address.port}` +}) + +afterEach(async () => { + if (server) { + server.close() + } + setApiKeyRepositoryForTests(null) + setOrganizations([]) + setOrgMembers([]) +}) + +test('records API key usage successfully on authentication', async () => { + const repo = makeRepo() + setApiKeyRepositoryForTests(repo as any) + + const apiKeyId = 'key-1' + const record = { + id: apiKeyId, + userId: 'user-admin', + orgId: 'org-123', + keyHash: 'hash-1', + label: 'Test Key', + scopes: [ApiScope.ReadAnalytics], + createdAt: new Date().toISOString(), + revokedAt: null, + } + await repo.create(record) + + // Explicitly record usage + recordApiKeyUsage(apiKeyId, '127.0.0.1') + await flushPendingUpdates() + + const updated = await repo.getById(apiKeyId) + assert.ok(updated) + assert.equal(updated.requestCount, 1) + assert.equal(updated.lastIp, '127.0.0.1') + assert.ok(updated.lastUsedAt) +}) + +test('GET /api/orgs/:orgId/api-keys/usage returns usage stats and restricts access', async () => { + const repo = makeRepo() + setApiKeyRepositoryForTests(repo as any) + + const apiKeyId = 'key-3' + const record = { + id: apiKeyId, + userId: 'user-admin', + orgId: 'org-123', + keyHash: 'somehash', + label: 'Test Key', + scopes: [ApiScope.ReadAnalytics], + createdAt: new Date().toISOString(), + revokedAt: null, + lastUsedAt: new Date().toISOString(), + requestCount: 5, + lastIp: '192.168.1.1', + } + await repo.create(record) + + // 1. Request as org admin (authorized) + const resAdmin = await fetch(`${baseUrl}/api/orgs/org-123/api-keys/usage`, { + headers: { 'x-user-id': 'user-admin' }, + }) + assert.equal(resAdmin.status, 200) + const bodyAdmin = (await resAdmin.json()) as any + assert.ok(Array.isArray(bodyAdmin.usage)) + assert.equal(bodyAdmin.usage.length, 1) + assert.equal(bodyAdmin.usage[0].id, apiKeyId) + assert.equal(bodyAdmin.usage[0].requestCount, 5) + assert.equal(bodyAdmin.usage[0].lastIp, '192.168.1.1') + assert.equal(bodyAdmin.usage[0].keyHash, undefined) // Must not leak hash! + + // 2. Request as org member (forbidden - requires owner/admin) + const resMember = await fetch(`${baseUrl}/api/orgs/org-123/api-keys/usage`, { + headers: { 'x-user-id': 'user-member' }, + }) + assert.equal(resMember.status, 403) + + // 3. Request as random user (forbidden) + const resRandom = await fetch(`${baseUrl}/api/orgs/org-123/api-keys/usage`, { + headers: { 'x-user-id': 'user-random' }, + }) + assert.equal(resRandom.status, 403) +}) diff --git a/src/tests/setup.ts b/src/tests/setup.ts new file mode 100644 index 0000000..332069e --- /dev/null +++ b/src/tests/setup.ts @@ -0,0 +1 @@ +process.env.DATABASE_URL = 'postgresql://postgres:postgres@localhost:5432/postgres'; diff --git a/src/tests/vaultValidation.destination.test.ts b/src/tests/vaultValidation.destination.test.ts new file mode 100644 index 0000000..d7d0b33 --- /dev/null +++ b/src/tests/vaultValidation.destination.test.ts @@ -0,0 +1,93 @@ +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { createVaultSchema, flattenZodErrors } from '../services/vaultValidation.js' + +// Generated valid addresses for test cases +const VALID_CLASSIC_1 = 'GDHWBXJFCTBJ6ZQPK2E64JAOMHQOEOMWQ43Q5C3J6TEA6SNOFELWBVCY' +const VALID_CLASSIC_2 = 'GBXKACE7RFAYLW7JDGRIRC2ZORDHL7YCRT5OUR3MKKXGO5AS4DQEMOXL' +const VALID_CONTRACT = 'CAJBEEQSCIJBEEQSCIJBEEQSCIJBEEQSCIJBEEQSCIJBEEQSCIJBERTS' +const VALID_MUXED_1 = 'MDHWBXJFCTBJ6ZQPK2E64JAOMHQOEOMWQ43Q5C3J6TEA6SNOFELWAAAAAAAAAAAAPMWVQ' +const ZERO_ADDRESS = 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF' +const ALL_ONES_ADDRESS = 'GD7777777777777777777777777777777777777777777777777773DB' + +// Helper to construct a valid base payload +const buildValidPayload = () => ({ + amount: '1000', + startDate: '2026-06-28T12:00:00Z', + endDate: '2026-06-29T12:00:00Z', + verifier: VALID_CLASSIC_2, + destinations: { + success: VALID_CLASSIC_2, + failure: VALID_CLASSIC_2, + }, + milestones: [ + { + title: 'Milestone 1', + dueDate: '2026-06-28T18:00:00Z', + amount: '1000', + }, + ], +}) + +test('accepts valid classic and muxed addresses', () => { + const payload = buildValidPayload() + payload.verifier = VALID_CLASSIC_2 + payload.destinations.success = VALID_MUXED_1 + payload.destinations.failure = VALID_CLASSIC_2 + + const result = createVaultSchema.safeParse(payload) + assert.ok(result.success, `Expected payload to validate successfully but failed: ${JSON.stringify(result.error)}`) +}) + +test('rejects contract addresses for verifier and destinations', () => { + // 1. Verifier as contract + const p1 = buildValidPayload() + p1.verifier = VALID_CONTRACT + const r1 = createVaultSchema.safeParse(p1) + assert.equal(r1.success, false) + const err1 = flattenZodErrors(r1.error!) + assert.ok(err1.some(f => f.path === 'verifier' && f.message.includes('Contract addresses are not allowed'))) + + // 2. Success destination as contract + const p2 = buildValidPayload() + p2.destinations.success = VALID_CONTRACT + const r2 = createVaultSchema.safeParse(p2) + assert.equal(r2.success, false) + const err2 = flattenZodErrors(r2.error!) + assert.ok(err2.some(f => f.path === 'destinations.success' && f.message.includes('Contract addresses are not allowed'))) +}) + +test('rejects unsafe zero/burn and all-ones addresses for success/failure destinations', () => { + // 1. Success destination is zero address + const p1 = buildValidPayload() + p1.destinations.success = ZERO_ADDRESS + const r1 = createVaultSchema.safeParse(p1) + assert.equal(r1.success, false) + const err1 = flattenZodErrors(r1.error!) + assert.ok(err1.some(f => f.path === 'destinations.success' && f.message.includes('cannot be a zero, burn, or unsafe address'))) + + // 2. Failure destination is all-ones address + const p2 = buildValidPayload() + p2.destinations.failure = ALL_ONES_ADDRESS + const r2 = createVaultSchema.safeParse(p2) + assert.equal(r2.success, false) + const err2 = flattenZodErrors(r2.error!) + assert.ok(err2.some(f => f.path === 'destinations.failure' && f.message.includes('cannot be a zero, burn, or unsafe address'))) +}) + +test('enforces memo via muxed address for exchange destinations requiring a memo', () => { + // VALID_CLASSIC_1 (GDHWBX...) is in the memo-required exchange address Set + // 1. Classic address (lacking memo) should be rejected + const p1 = buildValidPayload() + p1.destinations.success = VALID_CLASSIC_1 + const r1 = createVaultSchema.safeParse(p1) + assert.equal(r1.success, false) + const err1 = flattenZodErrors(r1.error!) + assert.ok(err1.some(f => f.path === 'destinations.success' && f.message.includes('requires a memo. Use a muxed address.'))) + + // 2. Muxed address (containing memo) should be accepted + const p2 = buildValidPayload() + p2.destinations.success = VALID_MUXED_1 + const r2 = createVaultSchema.safeParse(p2) + assert.ok(r2.success, `Expected muxed address to be accepted but failed: ${JSON.stringify(r2.error)}`) +}) diff --git a/src/types/auth.ts b/src/types/auth.ts index d6bd21a..84cb05e 100644 --- a/src/types/auth.ts +++ b/src/types/auth.ts @@ -26,6 +26,9 @@ export interface ApiKeyRecord { scopes: ApiScope[] createdAt: string revokedAt: string | null + lastUsedAt?: string | null + requestCount?: number + lastIp?: string | null } export interface JWTPayload {