From 2909b6e4aac13aafadbd7ef45567bbfd1fe5f1e9 Mon Sep 17 00:00:00 2001 From: Roz Date: Wed, 24 Jun 2026 12:35:05 +0200 Subject: [PATCH 1/5] feat(backend): add multiple API key support - Add api_keys table with hash storage, name, description, expiration - Update requireApiKey middleware to check DB for jack_* prefixed keys - Add CRUD endpoints for API key management at /api-keys - Inject authenticated key name into request context - Add key name to log spans when available - Reject expired keys automatically Keys use 'jack_' prefix for fast rejection of invalid formats. Master key from config still works unchanged. --- .../drizzle/0006_gigantic_otto_octavius.sql | 12 + apps/backend/drizzle/meta/0006_snapshot.json | 283 ++++++++++++++++++ apps/backend/drizzle/meta/_journal.json | 9 +- apps/backend/src/app.ts | 4 +- apps/backend/src/database/schema.ts | 15 + apps/backend/src/index.ts | 5 +- apps/backend/src/lib/crypto.test.ts | 59 ++++ apps/backend/src/lib/crypto.ts | 17 ++ apps/backend/src/management-app.ts | 9 + apps/backend/src/middleware/log-requests.ts | 8 +- .../src/middleware/require-auth.test.ts | 157 ++++++++++ apps/backend/src/middleware/require-auth.ts | 43 ++- .../modules/api-keys/api-keys.controller.ts | 100 +++++++ .../api-keys/api-keys.repository.test.ts | 110 +++++++ .../modules/api-keys/api-keys.repository.ts | 100 +++++++ .../modules/api-keys/api-keys.router.test.ts | 188 ++++++++++++ .../src/modules/api-keys/api-keys.router.ts | 56 ++++ 17 files changed, 1163 insertions(+), 12 deletions(-) create mode 100644 apps/backend/drizzle/0006_gigantic_otto_octavius.sql create mode 100644 apps/backend/drizzle/meta/0006_snapshot.json create mode 100644 apps/backend/src/lib/crypto.test.ts create mode 100644 apps/backend/src/lib/crypto.ts create mode 100644 apps/backend/src/middleware/require-auth.test.ts create mode 100644 apps/backend/src/modules/api-keys/api-keys.controller.ts create mode 100644 apps/backend/src/modules/api-keys/api-keys.repository.test.ts create mode 100644 apps/backend/src/modules/api-keys/api-keys.repository.ts create mode 100644 apps/backend/src/modules/api-keys/api-keys.router.test.ts create mode 100644 apps/backend/src/modules/api-keys/api-keys.router.ts 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..da37598 --- /dev/null +++ b/apps/backend/drizzle/0006_gigantic_otto_octavius.sql @@ -0,0 +1,12 @@ +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`);--> statement-breakpoint +CREATE INDEX `api_keys_key_hash_idx` 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..308f3c5 --- /dev/null +++ b/apps/backend/drizzle/meta/0006_snapshot.json @@ -0,0 +1,283 @@ +{ + "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 + }, + "api_keys_key_hash_idx": { + "name": "api_keys_key_hash_idx", + "columns": [ + "key_hash" + ], + "isUnique": false + } + }, + "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..8ef6e81 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 }), + 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(), +}, t => [ + index('api_keys_key_hash_idx').on(t.keyHash), +]) + +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..ea01833 --- /dev/null +++ b/apps/backend/src/modules/api-keys/api-keys.controller.ts @@ -0,0 +1,100 @@ +import type { ApiKeysRepository, UpdateApiKeyInput } from './api-keys.repository' +import { generateApiKey, hashKey } from '../../lib/crypto' +import { NotFoundError } from '../../lib/errors/NotFoundError' + +export interface CreateApiKeyRequest { + name?: string | null + description?: string | null + expiresAt?: string | null +} + +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: CreateApiKeyRequest): 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: UpdateApiKeyInput): 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..4f41464 --- /dev/null +++ b/apps/backend/src/modules/api-keys/api-keys.repository.ts @@ -0,0 +1,100 @@ +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 ApiKeyRecord { + id: number + keyHash: string + name: string | null + description: string | null + expiresAt: string | null + createdAt: string + updatedAt: string +} + +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() +} + +function toRecord(row: ApiKeyRow): ApiKeyRecord { + return { + id: row.id, + keyHash: row.keyHash, + name: row.name, + description: row.description, + expiresAt: row.expiresAt, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + } +} + +export class ApiKeysRepository { + constructor(private readonly db: AppDatabase) {} + + create(input: CreateApiKeyInput): ApiKeyRecord { + 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, + } + + const row = this.db.insert(apiKeys).values(values).returning().get() + return toRecord(row) + } + + findByHash(keyHash: string): ApiKeyRecord | null { + const row = this.db.select().from(apiKeys).where(eq(apiKeys.keyHash, keyHash)).get() + return row ? toRecord(row) : null + } + + get(id: number): ApiKeyRecord | null { + const row = this.db.select().from(apiKeys).where(eq(apiKeys.id, id)).get() + return row ? toRecord(row) : null + } + + list(): ApiKeyRecord[] { + return this.db.select().from(apiKeys).orderBy(desc(apiKeys.createdAt)).all().map(toRecord) + } + + update(id: number, input: UpdateApiKeyInput): ApiKeyRecord | null { + const row = 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() + + return row ? toRecord(row) : 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..6da303e --- /dev/null +++ b/apps/backend/src/modules/api-keys/api-keys.router.ts @@ -0,0 +1,56 @@ +import type { ApiKeysController } from './api-keys.controller' +import { Hono } from 'hono' +import { validator as zValidator } from 'hono-openapi' +import { z } from 'zod' + +const createApiKeySchema = z.object({ + name: z.string().max(100).nullish(), + description: z.string().max(500).nullish(), + expiresAt: z.string().datetime().nullish(), +}) + +const updateApiKeySchema = z.object({ + name: z.string().max(100).nullish(), + description: z.string().max(500).nullish(), + expiresAt: z.string().datetime().nullish(), +}) + +const idParamSchema = z.object({ + id: z.coerce.number().int().positive(), +}) + +export function getApiKeysRouter(controller: ApiKeysController) { + const app = new Hono() + + app.post('/', zValidator('json', createApiKeySchema), (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', idParamSchema), (c) => { + const { id } = c.req.valid('param') + const result = controller.get(id) + return c.json(result) + }) + + app.patch('/:id', zValidator('param', idParamSchema), zValidator('json', updateApiKeySchema), (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', idParamSchema), (c) => { + const { id } = c.req.valid('param') + const result = controller.delete(id) + return c.json(result) + }) + + return app +} From 896976fdeb08dfee0f8498232ca7877fd4748c1d Mon Sep 17 00:00:00 2001 From: Roz Date: Wed, 24 Jun 2026 12:54:46 +0200 Subject: [PATCH 2/5] refactor(api-keys): single source of truth for request schemas Move create/update request schemas into api-keys.schema.ts (schema const + inferred type, mirroring lib/config.ts). Router imports the schemas for validation; controller imports the inferred types, replacing its duplicate request interface. Align zod import with the default-import convention used by sibling routers. --- .../modules/api-keys/api-keys.controller.ts | 13 +++------- .../src/modules/api-keys/api-keys.router.ts | 25 ++++++------------- .../src/modules/api-keys/api-keys.schema.ts | 17 +++++++++++++ 3 files changed, 28 insertions(+), 27 deletions(-) create mode 100644 apps/backend/src/modules/api-keys/api-keys.schema.ts diff --git a/apps/backend/src/modules/api-keys/api-keys.controller.ts b/apps/backend/src/modules/api-keys/api-keys.controller.ts index ea01833..90ecfd9 100644 --- a/apps/backend/src/modules/api-keys/api-keys.controller.ts +++ b/apps/backend/src/modules/api-keys/api-keys.controller.ts @@ -1,13 +1,8 @@ -import type { ApiKeysRepository, UpdateApiKeyInput } from './api-keys.repository' +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 CreateApiKeyRequest { - name?: string | null - description?: string | null - expiresAt?: string | null -} - export interface ApiKeyResponse { id: number name: string | null @@ -24,7 +19,7 @@ export interface CreateApiKeyResponse extends ApiKeyResponse { export class ApiKeysController { constructor(private readonly repository: ApiKeysRepository) {} - create(input: CreateApiKeyRequest): CreateApiKeyResponse { + create(input: CreateApiKeyBody): CreateApiKeyResponse { const rawKey = generateApiKey() const keyHash = hashKey(rawKey) @@ -73,7 +68,7 @@ export class ApiKeysController { } } - update(id: number, input: UpdateApiKeyInput): ApiKeyResponse { + update(id: number, input: UpdateApiKeyBody): ApiKeyResponse { const record = this.repository.update(id, input) if (!record) { throw new NotFoundError(`API key ${id} not found`) diff --git a/apps/backend/src/modules/api-keys/api-keys.router.ts b/apps/backend/src/modules/api-keys/api-keys.router.ts index 6da303e..b757880 100644 --- a/apps/backend/src/modules/api-keys/api-keys.router.ts +++ b/apps/backend/src/modules/api-keys/api-keys.router.ts @@ -1,28 +1,17 @@ import type { ApiKeysController } from './api-keys.controller' import { Hono } from 'hono' import { validator as zValidator } from 'hono-openapi' -import { z } from 'zod' +import z from 'zod' +import { CreateApiKeyBody, UpdateApiKeyBody } from './api-keys.schema' -const createApiKeySchema = z.object({ - name: z.string().max(100).nullish(), - description: z.string().max(500).nullish(), - expiresAt: z.string().datetime().nullish(), -}) - -const updateApiKeySchema = z.object({ - name: z.string().max(100).nullish(), - description: z.string().max(500).nullish(), - expiresAt: z.string().datetime().nullish(), -}) - -const idParamSchema = z.object({ +const idParam = z.object({ id: z.coerce.number().int().positive(), }) export function getApiKeysRouter(controller: ApiKeysController) { const app = new Hono() - app.post('/', zValidator('json', createApiKeySchema), (c) => { + app.post('/', zValidator('json', CreateApiKeyBody), (c) => { const body = c.req.valid('json') const result = controller.create(body) return c.json(result, 201) @@ -33,20 +22,20 @@ export function getApiKeysRouter(controller: ApiKeysController) { return c.json(result) }) - app.get('/:id', zValidator('param', idParamSchema), (c) => { + 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', idParamSchema), zValidator('json', updateApiKeySchema), (c) => { + 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', idParamSchema), (c) => { + app.delete('/:id', zValidator('param', idParam), (c) => { const { id } = c.req.valid('param') const result = controller.delete(id) return c.json(result) 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 From b7493add0071383cddaba3a7d0e926d4a1d75179 Mon Sep 17 00:00:00 2001 From: Roz Date: Wed, 24 Jun 2026 12:58:37 +0200 Subject: [PATCH 3/5] refactor(api-keys): return drizzle row directly, drop no-op mapper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ApiKeyRecord was a structural clone of the drizzle-inferred ApiKeyRow and toRecord was an identity copy — the Row/Record split only earns its keep when the stored shape differs from the domain shape (as in downloads, which parses releaseJson into a Release). API keys need no such transform, so the repository now returns ApiKeyRow directly. Input interfaces stay (they carry keyHash, which the HTTP body doesn't). --- .../modules/api-keys/api-keys.repository.ts | 49 +++++-------------- 1 file changed, 11 insertions(+), 38 deletions(-) diff --git a/apps/backend/src/modules/api-keys/api-keys.repository.ts b/apps/backend/src/modules/api-keys/api-keys.repository.ts index 4f41464..a4ff368 100644 --- a/apps/backend/src/modules/api-keys/api-keys.repository.ts +++ b/apps/backend/src/modules/api-keys/api-keys.repository.ts @@ -3,16 +3,6 @@ import type { ApiKeyRow, NewApiKeyRow } from '../../database/schema' import { desc, eq } from 'drizzle-orm' import { apiKeys } from '../../database/schema' -export interface ApiKeyRecord { - id: number - keyHash: string - name: string | null - description: string | null - expiresAt: string | null - createdAt: string - updatedAt: string -} - export interface CreateApiKeyInput { keyHash: string name?: string | null @@ -30,22 +20,10 @@ function nowIso() { return new Date().toISOString() } -function toRecord(row: ApiKeyRow): ApiKeyRecord { - return { - id: row.id, - keyHash: row.keyHash, - name: row.name, - description: row.description, - expiresAt: row.expiresAt, - createdAt: row.createdAt, - updatedAt: row.updatedAt, - } -} - export class ApiKeysRepository { constructor(private readonly db: AppDatabase) {} - create(input: CreateApiKeyInput): ApiKeyRecord { + create(input: CreateApiKeyInput): ApiKeyRow { const timestamp = nowIso() const values: NewApiKeyRow = { keyHash: input.keyHash, @@ -56,26 +34,23 @@ export class ApiKeysRepository { updatedAt: timestamp, } - const row = this.db.insert(apiKeys).values(values).returning().get() - return toRecord(row) + return this.db.insert(apiKeys).values(values).returning().get() } - findByHash(keyHash: string): ApiKeyRecord | null { - const row = this.db.select().from(apiKeys).where(eq(apiKeys.keyHash, keyHash)).get() - return row ? toRecord(row) : null + findByHash(keyHash: string): ApiKeyRow | null { + return this.db.select().from(apiKeys).where(eq(apiKeys.keyHash, keyHash)).get() ?? null } - get(id: number): ApiKeyRecord | null { - const row = this.db.select().from(apiKeys).where(eq(apiKeys.id, id)).get() - return row ? toRecord(row) : null + get(id: number): ApiKeyRow | null { + return this.db.select().from(apiKeys).where(eq(apiKeys.id, id)).get() ?? null } - list(): ApiKeyRecord[] { - return this.db.select().from(apiKeys).orderBy(desc(apiKeys.createdAt)).all().map(toRecord) + list(): ApiKeyRow[] { + return this.db.select().from(apiKeys).orderBy(desc(apiKeys.createdAt)).all() } - update(id: number, input: UpdateApiKeyInput): ApiKeyRecord | null { - const row = this.db.update(apiKeys) + 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 } : {}), @@ -84,9 +59,7 @@ export class ApiKeysRepository { }) .where(eq(apiKeys.id, id)) .returning() - .get() - - return row ? toRecord(row) : null + .get() ?? null } delete(id: number): boolean { From 76f1f4c7219a9f28a8bb52accd5d3d80fd3a7c5e Mon Sep 17 00:00:00 2001 From: Roz Date: Wed, 24 Jun 2026 13:07:50 +0200 Subject: [PATCH 4/5] fix(api-keys): drop redundant key_hash index .unique() on key_hash already creates a unique index the planner uses for every findByHash lookup, so the explicit api_keys_key_hash_idx just made Drizzle emit and maintain a second index on the same column. Removed it from the schema and hand-edited the 0006 migration + snapshot rather than cutting a new migration. drizzle-kit check passes; no schema drift. --- apps/backend/drizzle/0006_gigantic_otto_octavius.sql | 3 +-- apps/backend/drizzle/meta/0006_snapshot.json | 7 ------- apps/backend/src/database/schema.ts | 6 +++--- 3 files changed, 4 insertions(+), 12 deletions(-) diff --git a/apps/backend/drizzle/0006_gigantic_otto_octavius.sql b/apps/backend/drizzle/0006_gigantic_otto_octavius.sql index da37598..2d86b9f 100644 --- a/apps/backend/drizzle/0006_gigantic_otto_octavius.sql +++ b/apps/backend/drizzle/0006_gigantic_otto_octavius.sql @@ -8,5 +8,4 @@ CREATE TABLE `api_keys` ( `updated_at` text NOT NULL ); --> statement-breakpoint -CREATE UNIQUE INDEX `api_keys_key_hash_unique` ON `api_keys` (`key_hash`);--> statement-breakpoint -CREATE INDEX `api_keys_key_hash_idx` ON `api_keys` (`key_hash`); \ No newline at end of file +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 index 308f3c5..8dc5b88 100644 --- a/apps/backend/drizzle/meta/0006_snapshot.json +++ b/apps/backend/drizzle/meta/0006_snapshot.json @@ -64,13 +64,6 @@ "key_hash" ], "isUnique": true - }, - "api_keys_key_hash_idx": { - "name": "api_keys_key_hash_idx", - "columns": [ - "key_hash" - ], - "isUnique": false } }, "foreignKeys": {}, diff --git a/apps/backend/src/database/schema.ts b/apps/backend/src/database/schema.ts index 8ef6e81..552a98c 100644 --- a/apps/backend/src/database/schema.ts +++ b/apps/backend/src/database/schema.ts @@ -45,15 +45,15 @@ 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(), -}, t => [ - index('api_keys_key_hash_idx').on(t.keyHash), -]) +}) export type ApiKeyRow = typeof apiKeys.$inferSelect export type NewApiKeyRow = typeof apiKeys.$inferInsert From a0e964ff0e23128d41571fd4f06c5060d66218f6 Mon Sep 17 00:00:00 2001 From: Roz <3948961+roziscoding@users.noreply.github.com> Date: Wed, 24 Jun 2026 21:07:47 +0200 Subject: [PATCH 5/5] feat(ui): add Settings tab with API key management (#46) --- apps/ui/app/components/ApiKeyForm.vue | 137 +++++++++++++++ apps/ui/app/layouts/default.vue | 1 + apps/ui/app/pages/settings.vue | 241 ++++++++++++++++++++++++++ apps/ui/app/types/management.ts | 20 +++ 4 files changed, 399 insertions(+) create mode 100644 apps/ui/app/components/ApiKeyForm.vue create mode 100644 apps/ui/app/pages/settings.vue 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 @@ + + +