diff --git a/apps/backend/drizzle/0006_gigantic_otto_octavius.sql b/apps/backend/drizzle/0006_gigantic_otto_octavius.sql new file mode 100644 index 0000000..2d86b9f --- /dev/null +++ b/apps/backend/drizzle/0006_gigantic_otto_octavius.sql @@ -0,0 +1,11 @@ +CREATE TABLE `api_keys` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `key_hash` text NOT NULL, + `name` text, + `description` text, + `expires_at` text, + `created_at` text NOT NULL, + `updated_at` text NOT NULL +); +--> statement-breakpoint +CREATE UNIQUE INDEX `api_keys_key_hash_unique` ON `api_keys` (`key_hash`); \ No newline at end of file diff --git a/apps/backend/drizzle/meta/0006_snapshot.json b/apps/backend/drizzle/meta/0006_snapshot.json new file mode 100644 index 0000000..8dc5b88 --- /dev/null +++ b/apps/backend/drizzle/meta/0006_snapshot.json @@ -0,0 +1,276 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "777a608b-5340-48bf-af83-f1ebed3bef83", + "prevId": "5270772c-dde8-4413-983d-94850d0683be", + "tables": { + "api_keys": { + "name": "api_keys", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "key_hash": { + "name": "key_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "api_keys_key_hash_unique": { + "name": "api_keys_key_hash_unique", + "columns": [ + "key_hash" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "downloads": { + "name": "downloads", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "torrent_filename": { + "name": "torrent_filename", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "peer_id": { + "name": "peer_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "peer_name": { + "name": "peer_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "item_id": { + "name": "item_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "filename": { + "name": "filename", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "dest_path": { + "name": "dest_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "part_path": { + "name": "part_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "release_size": { + "name": "release_size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "release_json": { + "name": "release_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expected_bytes": { + "name": "expected_bytes", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expected_bytes_source": { + "name": "expected_bytes_source", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expected_bytes_mismatch": { + "name": "expected_bytes_mismatch", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "downloaded_bytes": { + "name": "downloaded_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "completed_at": { + "name": "completed_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "qb_category": { + "name": "qb_category", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "qb_source_server": { + "name": "qb_source_server", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "downloads_status_idx": { + "name": "downloads_status_idx", + "columns": [ + "status" + ], + "isUnique": false + }, + "downloads_updated_at_idx": { + "name": "downloads_updated_at_idx", + "columns": [ + "updated_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "downloads_status_check": { + "name": "downloads_status_check", + "value": "\"downloads\".\"status\" in ('downloading', 'import_queued', 'imported', 'failed')" + }, + "downloads_expected_bytes_source_check": { + "name": "downloads_expected_bytes_source_check", + "value": "\"downloads\".\"expected_bytes_source\" is null or \"downloads\".\"expected_bytes_source\" in ('content_length', 'content_range', 'release_size')" + } + } + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/apps/backend/drizzle/meta/_journal.json b/apps/backend/drizzle/meta/_journal.json index a81ae63..75ac643 100644 --- a/apps/backend/drizzle/meta/_journal.json +++ b/apps/backend/drizzle/meta/_journal.json @@ -43,6 +43,13 @@ "when": 1782255959947, "tag": "0005_young_sabretooth", "breakpoints": true + }, + { + "idx": 6, + "version": "6", + "when": 1782296886480, + "tag": "0006_gigantic_otto_octavius", + "breakpoints": true } ] -} +} \ No newline at end of file diff --git a/apps/backend/src/app.ts b/apps/backend/src/app.ts index 0bfb4f4..4d630ce 100644 --- a/apps/backend/src/app.ts +++ b/apps/backend/src/app.ts @@ -1,6 +1,7 @@ import type { AppConfig } from './lib/config' import type { Envs } from './lib/envs' import type { ConnectorManager } from './lib/servers' +import type { ApiKeysRepository } from './modules/api-keys/api-keys.repository' import type { DownloadsRepository } from './modules/downloads/downloads.repository' import type { DownloadsService } from './modules/downloads/downloads.service' import { httpInstrumentationMiddleware } from '@hono/otel' @@ -28,6 +29,7 @@ import { getTorznabRouter } from './modules/torznab/torznab.router' interface AppServices { downloadsRepository?: DownloadsRepository downloadsService?: DownloadsService + apiKeysRepository?: ApiKeysRepository } // Only the live `servers`/`peers` getters are used here, so accept the structural @@ -94,7 +96,7 @@ export function getApp(envs: Envs, config: AppConfig, connManager: { servers: Co app.route('/api/v2', getQbittorrentRouter(qbController)) } - app.use('*', requireApiKey(config.jack?.apiKey ?? '')) + app.use('*', requireApiKey(config.jack?.apiKey ?? '', services.apiKeysRepository)) app.route('/servers', serversRouter) app.route('/items', itemsRouter) diff --git a/apps/backend/src/database/schema.ts b/apps/backend/src/database/schema.ts index 206c697..552a98c 100644 --- a/apps/backend/src/database/schema.ts +++ b/apps/backend/src/database/schema.ts @@ -42,3 +42,18 @@ export const downloads = sqliteTable('downloads', { export type DownloadRow = typeof downloads.$inferSelect export type NewDownloadRow = typeof downloads.$inferInsert + +export const apiKeys = sqliteTable('api_keys', { + id: integer('id').primaryKey({ autoIncrement: true }), + // .unique() already creates a unique index on key_hash, which the planner uses + // for every findByHash lookup — no separate index needed. + keyHash: text('key_hash').notNull().unique(), + name: text('name'), + description: text('description'), + expiresAt: text('expires_at'), + createdAt: text('created_at').notNull(), + updatedAt: text('updated_at').notNull(), +}) + +export type ApiKeyRow = typeof apiKeys.$inferSelect +export type NewApiKeyRow = typeof apiKeys.$inferInsert diff --git a/apps/backend/src/index.ts b/apps/backend/src/index.ts index 2055d44..b686458 100644 --- a/apps/backend/src/index.ts +++ b/apps/backend/src/index.ts @@ -9,6 +9,7 @@ import { ConnectorManager } from './lib/servers' import { PROTOCOL_VERSION } from './lib/version' import { logger } from './logger' import { getManagementApp } from './management-app' +import { ApiKeysRepository } from './modules/api-keys/api-keys.repository' import { ConfigService } from './modules/config/config.service' import { DownloadsRepository } from './modules/downloads/downloads.repository' import { DownloadsService } from './modules/downloads/downloads.service' @@ -35,6 +36,7 @@ await connectorManager.initAll() const database = await openDatabase({ appConfigPath: envs.APP_CONFIG_PATH }) const downloadsRepository = new DownloadsRepository(database.db) +const apiKeysRepository = new ApiKeysRepository(database.db) // Seed the management service from the shared raw object returned by getAppConfig // so the service's persisted state can never diverge from the loaded runtime config. @@ -46,7 +48,7 @@ const downloadsService = config.downloads ? new DownloadsService(config.downloads, connectorManager, downloadsRepository) : undefined -const app = getApp(envs, config, connectorManager, { downloadsRepository, downloadsService }) +const app = getApp(envs, config, connectorManager, { downloadsRepository, downloadsService, apiKeysRepository }) const server = Bun.serve({ fetch: app.fetch, }) @@ -76,6 +78,7 @@ function startManagementServer() { connectors: connectorManager, configService, downloadsRepository, + apiKeysRepository, }) const instance = Bun.serve({ port: envs.MANAGEMENT_PORT, fetch: managementApp.fetch }) logger.info({ port: instance.port }, 'Management API listening') diff --git a/apps/backend/src/lib/crypto.test.ts b/apps/backend/src/lib/crypto.test.ts new file mode 100644 index 0000000..3cd9371 --- /dev/null +++ b/apps/backend/src/lib/crypto.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, test } from 'bun:test' +import { API_KEY_PREFIX, generateApiKey, hashKey, isGeneratedKey } from './crypto' + +describe('crypto', () => { + describe('hashKey', () => { + test('returns hex SHA-256 hash', () => { + const hash = hashKey('test-key') + + expect(hash).toHaveLength(64) + expect(hash).toMatch(/^[a-f0-9]+$/) + }) + + test('same input produces same hash', () => { + const hash1 = hashKey('same-key') + const hash2 = hashKey('same-key') + + expect(hash1).toBe(hash2) + }) + + test('different inputs produce different hashes', () => { + const hash1 = hashKey('key-one') + const hash2 = hashKey('key-two') + + expect(hash1).not.toBe(hash2) + }) + }) + + describe('generateApiKey', () => { + test('returns key with jack_ prefix', () => { + const key = generateApiKey() + + expect(key.startsWith(API_KEY_PREFIX)).toBe(true) + }) + + test('returns key with 69 total characters (jack_ + 64 hex)', () => { + const key = generateApiKey() + + expect(key).toHaveLength(69) + }) + + test('generates unique keys', () => { + const key1 = generateApiKey() + const key2 = generateApiKey() + + expect(key1).not.toBe(key2) + }) + }) + + describe('isGeneratedKey', () => { + test('returns true for jack_ prefixed keys', () => { + expect(isGeneratedKey('jack_abc123')).toBe(true) + }) + + test('returns false for other keys', () => { + expect(isGeneratedKey('other_key')).toBe(false) + expect(isGeneratedKey('abc123')).toBe(false) + }) + }) +}) diff --git a/apps/backend/src/lib/crypto.ts b/apps/backend/src/lib/crypto.ts new file mode 100644 index 0000000..676b0bf --- /dev/null +++ b/apps/backend/src/lib/crypto.ts @@ -0,0 +1,17 @@ +const API_KEY_PREFIX = 'jack_' + +export function hashKey(key: string): string { + return new Bun.CryptoHasher('sha256').update(key).digest('hex') +} + +export function generateApiKey(): string { + const randomBytes = crypto.getRandomValues(new Uint8Array(32)) + const hex = Array.from(randomBytes).map(b => b.toString(16).padStart(2, '0')).join('') + return `${API_KEY_PREFIX}${hex}` +} + +export function isGeneratedKey(key: string): boolean { + return key.startsWith(API_KEY_PREFIX) +} + +export { API_KEY_PREFIX } diff --git a/apps/backend/src/management-app.ts b/apps/backend/src/management-app.ts index 3c2564f..2c40b34 100644 --- a/apps/backend/src/management-app.ts +++ b/apps/backend/src/management-app.ts @@ -1,10 +1,13 @@ import type { ConnectorManager } from './lib/servers' +import type { ApiKeysRepository } from './modules/api-keys/api-keys.repository' import type { ConfigService } from './modules/config/config.service' import type { DownloadsRepository } from './modules/downloads/downloads.repository' import { Hono } from 'hono' import { secureHeaders } from 'hono/secure-headers' import { handleError } from './middleware/handle-error' import { requireManagementKey } from './middleware/require-management-key' +import { ApiKeysController } from './modules/api-keys/api-keys.controller' +import { getApiKeysRouter } from './modules/api-keys/api-keys.router' import { ConfigController } from './modules/config/config.controller' import { getConfigRouter } from './modules/config/config.router' import { StatusController } from './modules/status/status.controller' @@ -17,6 +20,7 @@ export function getManagementApp(params: { connectors: { servers: ConnectorManager['servers'], peers: ConnectorManager['peers'] } configService?: ConfigService downloadsRepository?: DownloadsRepository + apiKeysRepository?: ApiKeysRepository }) { const app = new Hono() @@ -34,6 +38,11 @@ export function getManagementApp(params: { const statusController = new StatusController(params.connectors, params.downloadsRepository) app.route('/', getStatusRouter(statusController)) + if (params.apiKeysRepository) { + const apiKeysController = new ApiKeysController(params.apiKeysRepository) + app.route('/api-keys', getApiKeysRouter(apiKeysController)) + } + app.onError(handleError(params.environment)) return app diff --git a/apps/backend/src/middleware/log-requests.ts b/apps/backend/src/middleware/log-requests.ts index ab7a896..2d0501a 100644 --- a/apps/backend/src/middleware/log-requests.ts +++ b/apps/backend/src/middleware/log-requests.ts @@ -1,5 +1,6 @@ import type { AttributeValue, Span } from '@opentelemetry/api' import type { Context } from 'hono' +import type { AuthVariables } from './require-auth' import { trace } from '@opentelemetry/api' import { createMiddleware } from 'hono/factory' import { redactUrl, setSpanAttribute, setSpanAttributes } from '../lib/span-attributes' @@ -224,7 +225,7 @@ async function addHttpSpanAttributes(span: Span, ctx: Context, durationMs: numbe } } -export const logRequests = createMiddleware(async (ctx, next) => { +export const logRequests = createMiddleware<{ Variables: AuthVariables }>(async (ctx, next) => { const span = trace.getActiveSpan() const start = performance.now() const requestBody = span ? await captureRequestBody(ctx) : undefined @@ -233,6 +234,11 @@ export const logRequests = createMiddleware(async (ctx, next) => { if (span) { await addHttpSpanAttributes(span, ctx, durationMs, requestBody) + + const apiKeyName = ctx.get('apiKeyName') + if (apiKeyName) { + setSpanAttribute(span, 'api_key.name', apiKeyName) + } } const logObject = { diff --git a/apps/backend/src/middleware/require-auth.test.ts b/apps/backend/src/middleware/require-auth.test.ts new file mode 100644 index 0000000..90959dc --- /dev/null +++ b/apps/backend/src/middleware/require-auth.test.ts @@ -0,0 +1,157 @@ +import type { AuthVariables } from './require-auth' +import { Database } from 'bun:sqlite' +import { beforeEach, describe, expect, test } from 'bun:test' +import { drizzle } from 'drizzle-orm/bun-sqlite' +import { Hono } from 'hono' +import { runMigrations } from '../database/connection' +import * as schema from '../database/schema' +import { generateApiKey, hashKey } from '../lib/crypto' +import { ApiKeysRepository } from '../modules/api-keys/api-keys.repository' +import { handleError } from './handle-error' +import { requireApiKey } from './require-auth' + +interface SuccessResponse { + keyName: string | null +} + +interface ErrorResponse { + ok: false + error: { message: string } +} + +describe('requireApiKey', () => { + let repo: ApiKeysRepository + const masterKey = 'master-secret-key' + + beforeEach(() => { + const sqlite = new Database(':memory:') + const db = drizzle({ client: sqlite, schema }) + runMigrations(db) + repo = new ApiKeysRepository(db) + }) + + function createApp(apiKey: string, repository?: ApiKeysRepository) { + const app = new Hono<{ Variables: AuthVariables }>() + app.use('*', requireApiKey(apiKey, repository)) + app.get('/test', c => c.json({ keyName: c.get('apiKeyName') ?? null })) + app.onError(handleError('test')) + return app + } + + test('missing key returns 401', async () => { + const app = createApp(masterKey, repo) + + const res = await app.request('/test') + + expect(res.status).toBe(401) + const body = await res.json() as SuccessResponse | ErrorResponse + expect((body as ErrorResponse).error.message).toContain('missing API key') + }) + + test('master key passes without DB lookup', async () => { + const app = createApp(masterKey, repo) + + const res = await app.request('/test', { + headers: { 'X-Api-Key': masterKey }, + }) + + expect(res.status).toBe(200) + const body = await res.json() as SuccessResponse | ErrorResponse + expect((body as SuccessResponse).keyName).toBeNull() + }) + + test('empty master key disables auth', async () => { + const app = createApp('', repo) + + const res = await app.request('/test') + + expect(res.status).toBe(200) + }) + + test('valid jack_ key from DB passes and sets name', async () => { + const key = generateApiKey() + repo.create({ keyHash: hashKey(key), name: 'Test Key' }) + const app = createApp(masterKey, repo) + + const res = await app.request('/test', { + headers: { 'X-Api-Key': key }, + }) + + expect(res.status).toBe(200) + const body = await res.json() as SuccessResponse | ErrorResponse + expect((body as SuccessResponse).keyName).toBe('Test Key') + }) + + test('valid jack_ key without name sets "unnamed"', async () => { + const key = generateApiKey() + repo.create({ keyHash: hashKey(key) }) + const app = createApp(masterKey, repo) + + const res = await app.request('/test', { + headers: { 'X-Api-Key': key }, + }) + + expect(res.status).toBe(200) + const body = await res.json() as SuccessResponse | ErrorResponse + expect((body as SuccessResponse).keyName).toBe('unnamed') + }) + + test('expired jack_ key returns 401', async () => { + const key = generateApiKey() + const pastDate = new Date(Date.now() - 86400000).toISOString() + repo.create({ keyHash: hashKey(key), expiresAt: pastDate }) + const app = createApp(masterKey, repo) + + const res = await app.request('/test', { + headers: { 'X-Api-Key': key }, + }) + + expect(res.status).toBe(401) + const body = await res.json() as SuccessResponse | ErrorResponse + expect((body as ErrorResponse).error.message).toContain('API key expired') + }) + + test('non-existent jack_ key returns 401', async () => { + const key = generateApiKey() + const app = createApp(masterKey, repo) + + const res = await app.request('/test', { + headers: { 'X-Api-Key': key }, + }) + + expect(res.status).toBe(401) + const body = await res.json() as SuccessResponse | ErrorResponse + expect((body as ErrorResponse).error.message).toContain('invalid API key') + }) + + test('non-jack_ key that does not match master returns 401 (no DB lookup)', async () => { + const app = createApp(masterKey, repo) + + const res = await app.request('/test', { + headers: { 'X-Api-Key': 'wrong-key' }, + }) + + expect(res.status).toBe(401) + const body = await res.json() as SuccessResponse | ErrorResponse + expect((body as ErrorResponse).error.message).toContain('invalid API key') + }) + + test('jack_ key without repository returns 401', async () => { + const key = generateApiKey() + const app = createApp(masterKey) + + const res = await app.request('/test', { + headers: { 'X-Api-Key': key }, + }) + + expect(res.status).toBe(401) + }) + + test('key via query param works', async () => { + const app = createApp(masterKey, repo) + + const res = await app.request(`/test?apikey=${masterKey}`) + + expect(res.status).toBe(200) + }) +}) diff --git a/apps/backend/src/middleware/require-auth.ts b/apps/backend/src/middleware/require-auth.ts index 92557ca..44c36c9 100644 --- a/apps/backend/src/middleware/require-auth.ts +++ b/apps/backend/src/middleware/require-auth.ts @@ -1,25 +1,52 @@ +import type { ApiKeysRepository } from '../modules/api-keys/api-keys.repository' import { createMiddleware } from 'hono/factory' +import { hashKey, isGeneratedKey } from '../lib/crypto' import { UnauthorizedError } from '../lib/errors/UnauthorizedError' -let _middleware: ReturnType | null = null +export interface AuthVariables { + apiKeyName?: string +} -export function requireApiKey(apiKey: string) { - _middleware ??= createMiddleware((ctx, next) => { - if (apiKey === '') { +export function requireApiKey(masterKey: string, apiKeysRepository?: ApiKeysRepository) { + return createMiddleware<{ Variables: AuthVariables }>(async (ctx, next) => { + if (masterKey === '') { return next() } + const key = ctx.req.query('apikey') ?? ctx.req.header('x-api-key') if (!key) { throw new UnauthorizedError('missing API key') } - if (key === apiKey) { + if (key === masterKey) { return next() } - throw new UnauthorizedError('invalid API key') - }) + if (!isGeneratedKey(key)) { + throw new UnauthorizedError('invalid API key') + } + + if (!apiKeysRepository) { + throw new UnauthorizedError('invalid API key') + } + + const keyHash = hashKey(key) + const apiKey = apiKeysRepository.findByHash(keyHash) - return _middleware + if (!apiKey) { + throw new UnauthorizedError('invalid API key') + } + + if (apiKey.expiresAt) { + const expiresAt = new Date(apiKey.expiresAt) + if (expiresAt <= new Date()) { + throw new UnauthorizedError('API key expired') + } + } + + ctx.set('apiKeyName', apiKey.name ?? 'unnamed') + + return next() + }) } diff --git a/apps/backend/src/modules/api-keys/api-keys.controller.ts b/apps/backend/src/modules/api-keys/api-keys.controller.ts new file mode 100644 index 0000000..90ecfd9 --- /dev/null +++ b/apps/backend/src/modules/api-keys/api-keys.controller.ts @@ -0,0 +1,95 @@ +import type { ApiKeysRepository } from './api-keys.repository' +import type { CreateApiKeyBody, UpdateApiKeyBody } from './api-keys.schema' +import { generateApiKey, hashKey } from '../../lib/crypto' +import { NotFoundError } from '../../lib/errors/NotFoundError' + +export interface ApiKeyResponse { + id: number + name: string | null + description: string | null + expiresAt: string | null + createdAt: string + updatedAt: string +} + +export interface CreateApiKeyResponse extends ApiKeyResponse { + key: string +} + +export class ApiKeysController { + constructor(private readonly repository: ApiKeysRepository) {} + + create(input: CreateApiKeyBody): CreateApiKeyResponse { + const rawKey = generateApiKey() + const keyHash = hashKey(rawKey) + + const record = this.repository.create({ + keyHash, + name: input.name, + description: input.description, + expiresAt: input.expiresAt, + }) + + return { + id: record.id, + key: rawKey, + name: record.name, + description: record.description, + expiresAt: record.expiresAt, + createdAt: record.createdAt, + updatedAt: record.updatedAt, + } + } + + list(): ApiKeyResponse[] { + return this.repository.list().map(record => ({ + id: record.id, + name: record.name, + description: record.description, + expiresAt: record.expiresAt, + createdAt: record.createdAt, + updatedAt: record.updatedAt, + })) + } + + get(id: number): ApiKeyResponse { + const record = this.repository.get(id) + if (!record) { + throw new NotFoundError(`API key ${id} not found`) + } + + return { + id: record.id, + name: record.name, + description: record.description, + expiresAt: record.expiresAt, + createdAt: record.createdAt, + updatedAt: record.updatedAt, + } + } + + update(id: number, input: UpdateApiKeyBody): ApiKeyResponse { + const record = this.repository.update(id, input) + if (!record) { + throw new NotFoundError(`API key ${id} not found`) + } + + return { + id: record.id, + name: record.name, + description: record.description, + expiresAt: record.expiresAt, + createdAt: record.createdAt, + updatedAt: record.updatedAt, + } + } + + delete(id: number): { ok: true } { + const deleted = this.repository.delete(id) + if (!deleted) { + throw new NotFoundError(`API key ${id} not found`) + } + + return { ok: true } + } +} diff --git a/apps/backend/src/modules/api-keys/api-keys.repository.test.ts b/apps/backend/src/modules/api-keys/api-keys.repository.test.ts new file mode 100644 index 0000000..8ad2d38 --- /dev/null +++ b/apps/backend/src/modules/api-keys/api-keys.repository.test.ts @@ -0,0 +1,110 @@ +import { Database } from 'bun:sqlite' +import { beforeEach, describe, expect, test } from 'bun:test' +import { drizzle } from 'drizzle-orm/bun-sqlite' +import { runMigrations } from '../../database/connection' +import * as schema from '../../database/schema' +import { ApiKeysRepository } from './api-keys.repository' + +describe('ApiKeysRepository', () => { + let repo: ApiKeysRepository + + beforeEach(() => { + const sqlite = new Database(':memory:') + const db = drizzle({ client: sqlite, schema }) + runMigrations(db) + repo = new ApiKeysRepository(db) + }) + + test('create() inserts a new key and returns it', () => { + const result = repo.create({ + keyHash: 'hash123', + name: 'Test Key', + description: 'A test key', + }) + + expect(result.id).toBe(1) + expect(result.keyHash).toBe('hash123') + expect(result.name).toBe('Test Key') + expect(result.description).toBe('A test key') + expect(result.expiresAt).toBeNull() + expect(result.createdAt).toBeDefined() + expect(result.updatedAt).toBeDefined() + }) + + test('create() with expiration', () => { + const expiresAt = new Date(Date.now() + 86400000).toISOString() + const result = repo.create({ + keyHash: 'hash456', + expiresAt, + }) + + expect(result.expiresAt).toBe(expiresAt) + }) + + test('findByHash() returns matching record', () => { + repo.create({ keyHash: 'findme' }) + + const result = repo.findByHash('findme') + + expect(result).not.toBeNull() + expect(result!.keyHash).toBe('findme') + }) + + test('findByHash() returns null for non-existent hash', () => { + const result = repo.findByHash('nonexistent') + + expect(result).toBeNull() + }) + + test('get() returns record by id', () => { + const created = repo.create({ keyHash: 'gettest' }) + + const result = repo.get(created.id) + + expect(result).not.toBeNull() + expect(result!.id).toBe(created.id) + }) + + test('list() returns all keys', () => { + repo.create({ keyHash: 'first', name: 'First' }) + repo.create({ keyHash: 'second', name: 'Second' }) + + const result = repo.list() + + expect(result.length).toBe(2) + const names = result.map(r => r.name).sort() + expect(names).toEqual(['First', 'Second']) + }) + + test('update() modifies existing record', () => { + const created = repo.create({ keyHash: 'updateme', name: 'Old Name' }) + + const result = repo.update(created.id, { name: 'New Name' }) + + expect(result).not.toBeNull() + expect(result!.name).toBe('New Name') + expect(result!.updatedAt).toBeDefined() + }) + + test('update() returns null for non-existent id', () => { + const result = repo.update(999, { name: 'Test' }) + + expect(result).toBeNull() + }) + + test('delete() removes record', () => { + const created = repo.create({ keyHash: 'deleteme' }) + + const deleted = repo.delete(created.id) + const found = repo.get(created.id) + + expect(deleted).toBe(true) + expect(found).toBeNull() + }) + + test('delete() returns false for non-existent id', () => { + const result = repo.delete(999) + + expect(result).toBe(false) + }) +}) diff --git a/apps/backend/src/modules/api-keys/api-keys.repository.ts b/apps/backend/src/modules/api-keys/api-keys.repository.ts new file mode 100644 index 0000000..a4ff368 --- /dev/null +++ b/apps/backend/src/modules/api-keys/api-keys.repository.ts @@ -0,0 +1,73 @@ +import type { AppDatabase } from '../../database/connection' +import type { ApiKeyRow, NewApiKeyRow } from '../../database/schema' +import { desc, eq } from 'drizzle-orm' +import { apiKeys } from '../../database/schema' + +export interface CreateApiKeyInput { + keyHash: string + name?: string | null + description?: string | null + expiresAt?: string | null +} + +export interface UpdateApiKeyInput { + name?: string | null + description?: string | null + expiresAt?: string | null +} + +function nowIso() { + return new Date().toISOString() +} + +export class ApiKeysRepository { + constructor(private readonly db: AppDatabase) {} + + create(input: CreateApiKeyInput): ApiKeyRow { + const timestamp = nowIso() + const values: NewApiKeyRow = { + keyHash: input.keyHash, + name: input.name ?? null, + description: input.description ?? null, + expiresAt: input.expiresAt ?? null, + createdAt: timestamp, + updatedAt: timestamp, + } + + return this.db.insert(apiKeys).values(values).returning().get() + } + + findByHash(keyHash: string): ApiKeyRow | null { + return this.db.select().from(apiKeys).where(eq(apiKeys.keyHash, keyHash)).get() ?? null + } + + get(id: number): ApiKeyRow | null { + return this.db.select().from(apiKeys).where(eq(apiKeys.id, id)).get() ?? null + } + + list(): ApiKeyRow[] { + return this.db.select().from(apiKeys).orderBy(desc(apiKeys.createdAt)).all() + } + + update(id: number, input: UpdateApiKeyInput): ApiKeyRow | null { + return this.db.update(apiKeys) + .set({ + ...(input.name !== undefined ? { name: input.name } : {}), + ...(input.description !== undefined ? { description: input.description } : {}), + ...(input.expiresAt !== undefined ? { expiresAt: input.expiresAt } : {}), + updatedAt: nowIso(), + }) + .where(eq(apiKeys.id, id)) + .returning() + .get() ?? null + } + + delete(id: number): boolean { + const existing = this.get(id) + if (!existing) { + return false + } + this.db.delete(apiKeys).where(eq(apiKeys.id, id)).run() + return true + } +} diff --git a/apps/backend/src/modules/api-keys/api-keys.router.test.ts b/apps/backend/src/modules/api-keys/api-keys.router.test.ts new file mode 100644 index 0000000..57887e5 --- /dev/null +++ b/apps/backend/src/modules/api-keys/api-keys.router.test.ts @@ -0,0 +1,188 @@ +import { Database } from 'bun:sqlite' +import { beforeEach, describe, expect, test } from 'bun:test' +import { drizzle } from 'drizzle-orm/bun-sqlite' +import { Hono } from 'hono' +import { runMigrations } from '../../database/connection' +import * as schema from '../../database/schema' +import { handleError } from '../../middleware/handle-error' +import { ApiKeysController } from './api-keys.controller' +import { ApiKeysRepository } from './api-keys.repository' +import { getApiKeysRouter } from './api-keys.router' + +interface ApiKeyResponse { + id: number + key?: string + name: string | null + description: string | null + expiresAt: string | null + createdAt: string + updatedAt: string + keyHash?: never +} + +describe('API Keys Router', () => { + let app: Hono + + beforeEach(() => { + const sqlite = new Database(':memory:') + const db = drizzle({ client: sqlite, schema }) + runMigrations(db) + + const repo = new ApiKeysRepository(db) + const controller = new ApiKeysController(repo) + + app = new Hono() + app.route('/api-keys', getApiKeysRouter(controller)) + app.onError(handleError('test')) + }) + + describe('POST /api-keys', () => { + test('creates key and returns it with raw key', async () => { + const res = await app.request('/api-keys', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: 'Test Key', description: 'For testing' }), + }) + + expect(res.status).toBe(201) + const body = await res.json() as ApiKeyResponse + expect(body.id).toBe(1) + expect(body.key).toMatch(/^jack_[a-f0-9]{64}$/) + expect(body.name).toBe('Test Key') + expect(body.description).toBe('For testing') + }) + + test('creates key with expiration', async () => { + const expiresAt = new Date(Date.now() + 86400000).toISOString() + const res = await app.request('/api-keys', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ expiresAt }), + }) + + expect(res.status).toBe(201) + const body = await res.json() as ApiKeyResponse + expect(body.expiresAt).toBe(expiresAt) + }) + + test('creates key with no metadata', async () => { + const res = await app.request('/api-keys', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({}), + }) + + expect(res.status).toBe(201) + const body = await res.json() as ApiKeyResponse + expect(body.key).toMatch(/^jack_/) + }) + }) + + describe('GET /api-keys', () => { + test('lists all keys without hashes', async () => { + await app.request('/api-keys', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: 'Key 1' }), + }) + await app.request('/api-keys', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: 'Key 2' }), + }) + + const res = await app.request('/api-keys') + + expect(res.status).toBe(200) + const body = await res.json() as ApiKeyResponse[] + expect(body.length).toBe(2) + expect(body[0]!.key).toBeUndefined() + expect(body[0]!.keyHash).toBeUndefined() + }) + }) + + describe('GET /api-keys/:id', () => { + test('returns key by id', async () => { + const createRes = await app.request('/api-keys', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: 'Get Test' }), + }) + const created = await createRes.json() as ApiKeyResponse + + const res = await app.request(`/api-keys/${created.id}`) + + expect(res.status).toBe(200) + const body = await res.json() as ApiKeyResponse + expect(body.id).toBe(created.id) + expect(body.name).toBe('Get Test') + }) + + test('returns 404 for non-existent id', async () => { + const res = await app.request('/api-keys/999') + + expect(res.status).toBe(404) + }) + }) + + describe('PATCH /api-keys/:id', () => { + test('updates key metadata', async () => { + const createRes = await app.request('/api-keys', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: 'Original' }), + }) + const created = await createRes.json() as ApiKeyResponse + + const res = await app.request(`/api-keys/${created.id}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: 'Updated' }), + }) + + expect(res.status).toBe(200) + const body = await res.json() as ApiKeyResponse + expect(body.name).toBe('Updated') + }) + + test('returns 404 for non-existent id', async () => { + const res = await app.request('/api-keys/999', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: 'Test' }), + }) + + expect(res.status).toBe(404) + }) + }) + + describe('DELETE /api-keys/:id', () => { + test('deletes key', async () => { + const createRes = await app.request('/api-keys', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: 'Delete Me' }), + }) + const created = await createRes.json() as ApiKeyResponse + + const res = await app.request(`/api-keys/${created.id}`, { + method: 'DELETE', + }) + + expect(res.status).toBe(200) + const body = await res.json() as { ok: boolean } + expect(body.ok).toBe(true) + + const getRes = await app.request(`/api-keys/${created.id}`) + expect(getRes.status).toBe(404) + }) + + test('returns 404 for non-existent id', async () => { + const res = await app.request('/api-keys/999', { + method: 'DELETE', + }) + + expect(res.status).toBe(404) + }) + }) +}) diff --git a/apps/backend/src/modules/api-keys/api-keys.router.ts b/apps/backend/src/modules/api-keys/api-keys.router.ts new file mode 100644 index 0000000..b757880 --- /dev/null +++ b/apps/backend/src/modules/api-keys/api-keys.router.ts @@ -0,0 +1,45 @@ +import type { ApiKeysController } from './api-keys.controller' +import { Hono } from 'hono' +import { validator as zValidator } from 'hono-openapi' +import z from 'zod' +import { CreateApiKeyBody, UpdateApiKeyBody } from './api-keys.schema' + +const idParam = z.object({ + id: z.coerce.number().int().positive(), +}) + +export function getApiKeysRouter(controller: ApiKeysController) { + const app = new Hono() + + app.post('/', zValidator('json', CreateApiKeyBody), (c) => { + const body = c.req.valid('json') + const result = controller.create(body) + return c.json(result, 201) + }) + + app.get('/', (c) => { + const result = controller.list() + return c.json(result) + }) + + app.get('/:id', zValidator('param', idParam), (c) => { + const { id } = c.req.valid('param') + const result = controller.get(id) + return c.json(result) + }) + + app.patch('/:id', zValidator('param', idParam), zValidator('json', UpdateApiKeyBody), (c) => { + const { id } = c.req.valid('param') + const body = c.req.valid('json') + const result = controller.update(id, body) + return c.json(result) + }) + + app.delete('/:id', zValidator('param', idParam), (c) => { + const { id } = c.req.valid('param') + const result = controller.delete(id) + return c.json(result) + }) + + return app +} diff --git a/apps/backend/src/modules/api-keys/api-keys.schema.ts b/apps/backend/src/modules/api-keys/api-keys.schema.ts new file mode 100644 index 0000000..40d3be5 --- /dev/null +++ b/apps/backend/src/modules/api-keys/api-keys.schema.ts @@ -0,0 +1,17 @@ +import z from 'zod' + +export const CreateApiKeyBody = z.object({ + name: z.string().max(100).nullish(), + description: z.string().max(500).nullish(), + expiresAt: z.string().datetime().nullish(), +}) + +export type CreateApiKeyBody = z.infer + +export const UpdateApiKeyBody = z.object({ + name: z.string().max(100).nullish(), + description: z.string().max(500).nullish(), + expiresAt: z.string().datetime().nullish(), +}) + +export type UpdateApiKeyBody = z.infer diff --git a/apps/ui/app/components/ApiKeyForm.vue b/apps/ui/app/components/ApiKeyForm.vue new file mode 100644 index 0000000..8a375b7 --- /dev/null +++ b/apps/ui/app/components/ApiKeyForm.vue @@ -0,0 +1,137 @@ + + +