From b274219fdc807450fd7845d7025e041057cb5395 Mon Sep 17 00:00:00 2001 From: KingFRANKHOOD Date: Wed, 25 Feb 2026 17:36:31 +0100 Subject: [PATCH 1/2] feat: API key repository and key generation --- eslint.config.js | 33 ++++++++++ jest.config.cjs | 10 +++ jest.config.js | 6 -- src/apiKeys/apiKeyRepository.test.ts | 59 +++++++++++++++++ src/apiKeys/apiKeyRepository.ts | 97 ++++++++++++++++++++++++++++ src/apiKeys/keyGeneration.test.ts | 30 +++++++++ src/apiKeys/keyGeneration.ts | 24 +++++++ src/index.test.ts | 3 +- 8 files changed, 254 insertions(+), 8 deletions(-) create mode 100644 eslint.config.js create mode 100644 jest.config.cjs delete mode 100644 jest.config.js create mode 100644 src/apiKeys/apiKeyRepository.test.ts create mode 100644 src/apiKeys/apiKeyRepository.ts create mode 100644 src/apiKeys/keyGeneration.test.ts create mode 100644 src/apiKeys/keyGeneration.ts diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..79b1028 --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,33 @@ +import tsParser from '@typescript-eslint/parser'; +import tsPlugin from '@typescript-eslint/eslint-plugin'; + +export default [ + { + ignores: ['dist/**', 'node_modules/**'] + }, + { + files: ['**/*.ts'], + languageOptions: { + parser: tsParser, + parserOptions: { + sourceType: 'module' + }, + globals: { + console: 'readonly', + process: 'readonly', + module: 'readonly', + require: 'readonly', + describe: 'readonly', + it: 'readonly', + expect: 'readonly', + jest: 'readonly' + } + }, + plugins: { + '@typescript-eslint': tsPlugin + }, + rules: { + ...tsPlugin.configs.recommended.rules + } + } +]; diff --git a/jest.config.cjs b/jest.config.cjs new file mode 100644 index 0000000..6be45c7 --- /dev/null +++ b/jest.config.cjs @@ -0,0 +1,10 @@ +/** @type {import('ts-jest').JestConfigWithTsJest} */ +module.exports = { + preset: 'ts-jest/presets/default-esm', + testEnvironment: 'node', + testMatch: ['**/?(*.)+(spec|test).ts'], + extensionsToTreatAsEsm: ['.ts'], + moduleNameMapper: { + '^(\\.{1,2}/.*)\\.js$': '$1' + } +}; diff --git a/jest.config.js b/jest.config.js deleted file mode 100644 index 779b71c..0000000 --- a/jest.config.js +++ /dev/null @@ -1,6 +0,0 @@ -/** @type {import('ts-jest').JestConfigWithTsJest} */ -module.exports = { - preset: 'ts-jest', - testEnvironment: 'node', - testMatch: ['**/?(*.)+(spec|test).ts'] -}; \ No newline at end of file diff --git a/src/apiKeys/apiKeyRepository.test.ts b/src/apiKeys/apiKeyRepository.test.ts new file mode 100644 index 0000000..59212b3 --- /dev/null +++ b/src/apiKeys/apiKeyRepository.test.ts @@ -0,0 +1,59 @@ +import { ApiKeyRepository } from './apiKeyRepository.js'; + +describe('ApiKeyRepository', () => { + it('creates a key and finds by prefix', () => { + const repository = new ApiKeyRepository(); + + const created = repository.create('user-1', 'api-1', 'hash-1', 'prefix01', ['read'], 100); + const byPrefix = repository.findByKeyPrefix('prefix01'); + + expect(created.id).toBeTruthy(); + expect(byPrefix).toHaveLength(1); + expect(byPrefix[0]?.id).toBe(created.id); + }); + + it('finds keys by user and api', () => { + const repository = new ApiKeyRepository(); + + repository.create('user-1', 'api-1', 'hash-1', 'prefix01', ['read'], 100); + repository.create('user-1', 'api-1', 'hash-2', 'prefix02', ['write'], 200); + repository.create('user-1', 'api-2', 'hash-3', 'prefix03', ['read'], 100); + + const records = repository.findByUserAndApi('user-1', 'api-1'); + + expect(records).toHaveLength(2); + expect(records.every((record) => record.apiId === 'api-1')).toBe(true); + }); + + it('revoke sets revokedAt and excludes key from lookups', () => { + const repository = new ApiKeyRepository(); + const created = repository.create('user-1', 'api-1', 'hash-1', 'prefix01', ['read'], 100); + + const revoked = repository.revoke(created.id); + + expect(revoked).not.toBeNull(); + expect(revoked?.revokedAt).not.toBeNull(); + expect(repository.findByKeyPrefix('prefix01')).toHaveLength(0); + expect(repository.findByUserAndApi('user-1', 'api-1')).toHaveLength(0); + }); + + it('recordUsage increments usage and updates lastUsedAt', () => { + const repository = new ApiKeyRepository(); + const created = repository.create('user-1', 'api-1', 'hash-1', 'prefix01', ['read'], 100); + + const updated = repository.recordUsage(created.id); + + expect(updated).not.toBeNull(); + expect(updated?.usageCount).toBe(1); + expect(updated?.lastUsedAt).not.toBeNull(); + }); + + it('recordUsage returns null for revoked key', () => { + const repository = new ApiKeyRepository(); + const created = repository.create('user-1', 'api-1', 'hash-1', 'prefix01', ['read'], 100); + + repository.revoke(created.id); + + expect(repository.recordUsage(created.id)).toBeNull(); + }); +}); \ No newline at end of file diff --git a/src/apiKeys/apiKeyRepository.ts b/src/apiKeys/apiKeyRepository.ts new file mode 100644 index 0000000..797ae12 --- /dev/null +++ b/src/apiKeys/apiKeyRepository.ts @@ -0,0 +1,97 @@ +export type ApiKeyScopes = string[]; + +export type ApiKeyRecord = { + id: string; + userId: string; + apiId: string; + keyHash: string; + prefix: string; + scopes: ApiKeyScopes; + rateLimit: number; + createdAt: Date; + updatedAt: Date; + lastUsedAt: Date | null; + usageCount: number; + revokedAt: Date | null; +}; + +const createId = (): string => + `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`; + +export class ApiKeyRepository { + private readonly records = new Map(); + + create(userId: string, apiId: string, keyHash: string, prefix: string, scopes: string[], rateLimit: number): ApiKeyRecord { + const now = new Date(); + const record: ApiKeyRecord = { + id: createId(), + userId, + apiId, + keyHash, + prefix, + scopes: [...scopes], + rateLimit, + createdAt: now, + updatedAt: now, + lastUsedAt: null, + usageCount: 0, + revokedAt: null + }; + + this.records.set(record.id, record); + return { ...record, scopes: [...record.scopes] }; + } + + findByKeyPrefix(prefix: string): ApiKeyRecord[] { + return this.toPublicRecords( + [...this.records.values()].filter((record) => record.prefix === prefix && !record.revokedAt) + ); + } + + findByUserAndApi(userId: string, apiId: string): ApiKeyRecord[] { + return this.toPublicRecords( + [...this.records.values()].filter( + (record) => record.userId === userId && record.apiId === apiId && !record.revokedAt + ) + ); + } + + revoke(id: string): ApiKeyRecord | null { + const existing = this.records.get(id); + if (!existing || existing.revokedAt) { + return null; + } + + const now = new Date(); + const updated: ApiKeyRecord = { + ...existing, + revokedAt: now, + updatedAt: now + }; + + this.records.set(id, updated); + return { ...updated, scopes: [...updated.scopes] }; + } + + recordUsage(id: string): ApiKeyRecord | null { + const existing = this.records.get(id); + if (!existing || existing.revokedAt) { + return null; + } + + const now = new Date(); + const updated: ApiKeyRecord = { + ...existing, + usageCount: existing.usageCount + 1, + lastUsedAt: now, + updatedAt: now + }; + + this.records.set(id, updated); + return { ...updated, scopes: [...updated.scopes] }; + } + + private toPublicRecords(records: ApiKeyRecord[]): ApiKeyRecord[] { + return records.map((record) => ({ ...record, scopes: [...record.scopes] })); + } +} \ No newline at end of file diff --git a/src/apiKeys/keyGeneration.test.ts b/src/apiKeys/keyGeneration.test.ts new file mode 100644 index 0000000..666f6c4 --- /dev/null +++ b/src/apiKeys/keyGeneration.test.ts @@ -0,0 +1,30 @@ +import { generateSecureKey, hashApiKey } from './keyGeneration.js'; + +describe('keyGeneration', () => { + it('should generate a key, hash, and 8-char prefix', () => { + const result = generateSecureKey(); + + expect(result.key).toBeTruthy(); + expect(result.key.length).toBeGreaterThanOrEqual(32); + expect(result.hash).toHaveLength(64); + expect(result.prefix).toHaveLength(8); + expect(result.prefix).toBe(result.key.slice(0, 8)); + }); + + it('should hash deterministically with sha256', () => { + const key = 'test-key-value'; + const hash1 = hashApiKey(key); + const hash2 = hashApiKey(key); + + expect(hash1).toBe(hash2); + expect(hash1).toHaveLength(64); + }); + + it('should generate unique keys in normal operation', () => { + const first = generateSecureKey(); + const second = generateSecureKey(); + + expect(first.key).not.toBe(second.key); + expect(first.hash).not.toBe(second.hash); + }); +}); \ No newline at end of file diff --git a/src/apiKeys/keyGeneration.ts b/src/apiKeys/keyGeneration.ts new file mode 100644 index 0000000..5ea7c77 --- /dev/null +++ b/src/apiKeys/keyGeneration.ts @@ -0,0 +1,24 @@ +import { createHash, randomBytes } from 'crypto'; + +const PREFIX_LENGTH = 8; +const KEY_BYTES = 32; + +export type GeneratedApiKey = { + key: string; + hash: string; + prefix: string; +}; + +export function hashApiKey(key: string): string { + return createHash('sha256').update(key, 'utf8').digest('hex'); +} + +export function generateSecureKey(): GeneratedApiKey { + const key = randomBytes(KEY_BYTES).toString('base64url'); + + return { + key, + hash: hashApiKey(key), + prefix: key.slice(0, PREFIX_LENGTH) + }; +} \ No newline at end of file diff --git a/src/index.test.ts b/src/index.test.ts index 73a05dc..5e6e3f6 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -1,6 +1,5 @@ import request from 'supertest'; -import express from 'express'; -import app from './index'; +import app from './index.js'; describe('Health API', () => { it('should return ok status', async () => { From 039f9fd420c6f2a59bcf31f215b11e1983321aaf Mon Sep 17 00:00:00 2001 From: KingFRANKHOOD Date: Thu, 26 Feb 2026 12:48:37 +0100 Subject: [PATCH 2/2] update CI --- src/migrations.test.ts | 23 +++++++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/src/migrations.test.ts b/src/migrations.test.ts index 7682202..003b83c 100644 --- a/src/migrations.test.ts +++ b/src/migrations.test.ts @@ -2,7 +2,12 @@ import assert from 'node:assert/strict'; import { describe, it } from 'node:test'; import fs from 'node:fs'; import path from 'node:path'; +<<<<<<< Updated upstream import { describe, it } from 'node:test'; +======= +import assert from 'node:assert/strict'; +import test from 'node:test'; +>>>>>>> Stashed changes const migrationDir = path.join(process.cwd(), 'migrations'); const upMigrationPath = path.join( @@ -18,8 +23,7 @@ function read(filePath: string): string { return fs.readFileSync(filePath, 'utf8'); } -describe('Issue #9 migrations', () => { - it('creates api_keys table with required columns and constraints', () => { +test('Issue #9 migrations creates api_keys table with required columns and constraints', () => { const sql = read(upMigrationPath); assert.match(sql, /create table api_keys/i); @@ -39,9 +43,13 @@ describe('Issue #9 migrations', () => { assert.doesNotMatch(sql, /\bapi_key\b/i); assert.doesNotMatch(sql, /\braw_key\b/i); +<<<<<<< Updated upstream }); +======= +}); +>>>>>>> Stashed changes - it('creates vaults table with required columns and constraints', () => { +test('Issue #9 migrations creates vaults table with required columns and constraints', () => { const sql = read(upMigrationPath); assert.match(sql, /create table vaults/i); @@ -53,12 +61,19 @@ describe('Issue #9 migrations', () => { assert.match(sql, /\bcreated_at\b/i); assert.match(sql, /\bupdated_at\b/i); assert.match(sql, /unique\s*\(\s*user_id\s*,\s*network\s*\)/i); +<<<<<<< Updated upstream }); +======= +}); +>>>>>>> Stashed changes - it('includes rollback migration for both tables', () => { +test('Issue #9 migrations includes rollback migration for both tables', () => { const sql = read(downMigrationPath); assert.match(sql, /drop table if exists vaults/i); assert.match(sql, /drop table if exists api_keys/i); +<<<<<<< Updated upstream }); +======= +>>>>>>> Stashed changes });