From 7f54b11af1c1aed818a65a88c6e10a424ceb2dea Mon Sep 17 00:00:00 2001 From: Devyash Saini Date: Sun, 10 May 2026 11:41:50 +0530 Subject: [PATCH 1/3] feat(tags): add GET /api/v1/tags endpoint with auth - Add authenticateHttpApiKey utility for HTTP Bearer token validation - Add handleListTags handler that queries tags via Drizzle - Register route in registerApiRoutes.ts - Add lifecycle log for the new endpoint --- src/routes/http/api/registerApiRoutes.ts | 13 ++++- src/routes/http/api/tags.ts | 60 +++++++++++++++++++++++ src/servers/fastifyServer.ts | 3 ++ src/utils/authenticateHttpApiKey.ts | 62 ++++++++++++++++++++++++ 4 files changed, 137 insertions(+), 1 deletion(-) create mode 100644 src/routes/http/api/tags.ts create mode 100644 src/utils/authenticateHttpApiKey.ts diff --git a/src/routes/http/api/registerApiRoutes.ts b/src/routes/http/api/registerApiRoutes.ts index 4775d02..2be8769 100644 --- a/src/routes/http/api/registerApiRoutes.ts +++ b/src/routes/http/api/registerApiRoutes.ts @@ -1,5 +1,6 @@ -import type { FastifyInstance, FastifyRequest, FastifyReply } from "fastify"; +import type { FastifyRequest, FastifyReply } from "fastify"; import { handleOnboarding } from "./onboarding.ts"; +import { handleListTags } from "./tags.ts"; export async function registerApiRoutes( server: ReturnType @@ -13,4 +14,14 @@ export async function registerApiRoutes( return handleOnboarding(request, reply); } ); + + server.get( + "/api/v1/tags", + async ( + request: FastifyRequest, + reply: FastifyReply + ) => { + return handleListTags(request, reply); + } + ); } \ No newline at end of file diff --git a/src/routes/http/api/tags.ts b/src/routes/http/api/tags.ts new file mode 100644 index 0000000..63c26ed --- /dev/null +++ b/src/routes/http/api/tags.ts @@ -0,0 +1,60 @@ +import type { FastifyRequest, FastifyReply } from "fastify"; +import * as Sentry from "@sentry/bun"; +import { createWideEventBuilder, generateRequestId } from "../../../context/requestContext.ts"; +import { logger } from "../../../errors/logger.ts"; +import { AuthError } from "../../../errors/auth.ts"; +import { authenticateHttpApiKey } from "../../../utils/authenticateHttpApiKey.ts"; +import { getPostgresDB } from "../../../storage/db/postgres/db.ts"; +import { tagsTable } from "../../../storage/db/postgres/schema.ts"; + +interface ListTagsResponse { + tags: string[]; +} + +export async function handleListTags( + request: FastifyRequest, + reply: FastifyReply +): Promise { + const builder = createWideEventBuilder( + generateRequestId(), + request.method, + request.url + ); + + try { + const authHeader = request.headers.authorization; + await authenticateHttpApiKey(authHeader); + + const db = getPostgresDB(); + const rows = await db.select({ key: tagsTable.key }).from(tagsTable); + + const tags = rows.map((row) => row.key); + + builder.setSuccess(200).addContext({ tagCount: tags.length }); + reply.code(200); + return { tags }; + } catch (error) { + Sentry.captureException(error, { + extra: { context: "list tags route handler" }, + }); + + if (error instanceof AuthError) { + builder.setError(401, { + type: error.type, + message: error.message, + }); + reply.code(401); + return { tags: [] }; + } + + const err = error instanceof Error ? error : new Error(String(error)); + builder.setError(500, { + type: "InternalError", + message: err.message, + }); + reply.code(500); + return { tags: [] }; + } finally { + logger.emit(builder.build()); + } +} diff --git a/src/servers/fastifyServer.ts b/src/servers/fastifyServer.ts index 62bcd79..e09e1b3 100644 --- a/src/servers/fastifyServer.ts +++ b/src/servers/fastifyServer.ts @@ -35,4 +35,7 @@ export async function startFastifyServer(port: number, grpcPort: number): Promis logger.lifecycle("API endpoint available", { url: `http://localhost:${port}/api/v1/internals/onboarding`, }); + logger.lifecycle("Tags endpoint available", { + url: `http://localhost:${port}/api/v1/tags`, + }); } diff --git a/src/utils/authenticateHttpApiKey.ts b/src/utils/authenticateHttpApiKey.ts new file mode 100644 index 0000000..614196b --- /dev/null +++ b/src/utils/authenticateHttpApiKey.ts @@ -0,0 +1,62 @@ +import { eq } from "drizzle-orm"; +import { AuthError } from "../errors/auth"; +import { apiKeyCache } from "./apiKeyCache"; +import { getPostgresDB } from "../storage/db/postgres/db"; +import { apiKeysTable } from "../storage/db/postgres/schema"; +import { hashAPIKey } from "./hashAPIKey"; +import { DateTime } from "luxon"; + +export async function authenticateHttpApiKey( + authHeader: string | undefined +): Promise { + if (!authHeader) { + throw AuthError.missingHeader(); + } + + if (!authHeader.startsWith("Bearer ")) { + throw AuthError.invalidHeaderFormat(); + } + + const apiKey = authHeader.slice("Bearer ".length).trim(); + + if (!apiKey.startsWith("scrn_") || apiKey.length !== 37) { + throw AuthError.invalidAPIKey("Invalid API key format"); + } + + const apiKeyHash = hashAPIKey(apiKey); + + const cached = apiKeyCache.get(apiKeyHash); + if (cached) { + return cached.id; + } + + const db = getPostgresDB(); + const [apiKeyRecord] = await db + .select({ + id: apiKeysTable.id, + expiresAt: apiKeysTable.expiresAt, + revoked: apiKeysTable.revoked, + }) + .from(apiKeysTable) + .where(eq(apiKeysTable.key, apiKeyHash)) + .limit(1); + + if (!apiKeyRecord) { + throw AuthError.invalidAPIKey("API key not found"); + } + + if (apiKeyRecord.revoked) { + throw AuthError.revokedAPIKey(); + } + + if (DateTime.utc() > DateTime.fromISO(apiKeyRecord.expiresAt)) { + throw AuthError.expiredAPIKey(); + } + + apiKeyCache.set(apiKeyHash, { + id: apiKeyRecord.id, + expiresAt: apiKeyRecord.expiresAt, + }); + + return apiKeyRecord.id; +} From e02979500b0d780ea2aa4a4f57c5dc99b1c24d32 Mon Sep 17 00:00:00 2001 From: Devyash Saini Date: Sun, 10 May 2026 23:22:16 +0530 Subject: [PATCH 2/3] refactor: extract tags query to helper, follow codebase pattern - Add storage/db/postgres/helpers/tags.ts with listTags() helper - Route handler now calls listTags() instead of direct DB query - Consistent with existing helpers (users.ts, apiKeys.ts, metadata.ts) --- src/routes/http/api/tags.ts | 8 ++------ src/storage/db/postgres/helpers/tags.ts | 17 +++++++++++++++++ 2 files changed, 19 insertions(+), 6 deletions(-) create mode 100644 src/storage/db/postgres/helpers/tags.ts diff --git a/src/routes/http/api/tags.ts b/src/routes/http/api/tags.ts index 63c26ed..88c2c8f 100644 --- a/src/routes/http/api/tags.ts +++ b/src/routes/http/api/tags.ts @@ -4,8 +4,7 @@ import { createWideEventBuilder, generateRequestId } from "../../../context/requ import { logger } from "../../../errors/logger.ts"; import { AuthError } from "../../../errors/auth.ts"; import { authenticateHttpApiKey } from "../../../utils/authenticateHttpApiKey.ts"; -import { getPostgresDB } from "../../../storage/db/postgres/db.ts"; -import { tagsTable } from "../../../storage/db/postgres/schema.ts"; +import { listTags } from "../../../storage/db/postgres/helpers/tags.ts"; interface ListTagsResponse { tags: string[]; @@ -25,10 +24,7 @@ export async function handleListTags( const authHeader = request.headers.authorization; await authenticateHttpApiKey(authHeader); - const db = getPostgresDB(); - const rows = await db.select({ key: tagsTable.key }).from(tagsTable); - - const tags = rows.map((row) => row.key); + const tags = await listTags(); builder.setSuccess(200).addContext({ tagCount: tags.length }); reply.code(200); diff --git a/src/storage/db/postgres/helpers/tags.ts b/src/storage/db/postgres/helpers/tags.ts new file mode 100644 index 0000000..07bba08 --- /dev/null +++ b/src/storage/db/postgres/helpers/tags.ts @@ -0,0 +1,17 @@ +import { getPostgresDB } from "../db"; +import { tagsTable } from "../schema"; +import { StorageError } from "../../../../errors/storage"; + +export async function listTags(): Promise { + const db = getPostgresDB(); + + try { + const rows = await db.select({ key: tagsTable.key }).from(tagsTable); + return rows.map((row) => row.key); + } catch (e) { + throw StorageError.queryFailed( + "Failed to list tags", + e instanceof Error ? e : new Error(String(e)) + ); + } +} From 075c820a6ce48fd4cf3646920083bc42943c13eb Mon Sep 17 00:00:00 2001 From: Devyash Saini Date: Sun, 10 May 2026 23:38:03 +0530 Subject: [PATCH 3/3] refactor: extract api key DB lookup to findApiKeyByHash helper - Add findApiKeyByHash() to helpers/apiKeys.ts - authenticateHttpApiKey now uses the helper instead of direct DB query - Domain logic (revoked, expired checks) stays in the auth util --- src/storage/db/postgres/helpers/apiKeys.ts | 32 ++++++++++++++++++++++ src/utils/authenticateHttpApiKey.ts | 15 ++-------- 2 files changed, 34 insertions(+), 13 deletions(-) diff --git a/src/storage/db/postgres/helpers/apiKeys.ts b/src/storage/db/postgres/helpers/apiKeys.ts index 025a9b7..51eaa8a 100644 --- a/src/storage/db/postgres/helpers/apiKeys.ts +++ b/src/storage/db/postgres/helpers/apiKeys.ts @@ -1,6 +1,7 @@ import { getPostgresDB } from "../db"; import { apiKeysTable } from "../schema"; import { StorageError } from "../../../../errors/storage"; +import { eq } from "drizzle-orm"; type CreateApiKeyInput = { name: string; @@ -67,3 +68,34 @@ export async function createApiKey( ); } } + +type ApiKeyRecord = { + id: string; + expiresAt: string; + revoked: boolean; +}; + +export async function findApiKeyByHash( + apiKeyHash: string +): Promise { + const db = getPostgresDB(); + + try { + const [apiKeyRecord] = await db + .select({ + id: apiKeysTable.id, + expiresAt: apiKeysTable.expiresAt, + revoked: apiKeysTable.revoked, + }) + .from(apiKeysTable) + .where(eq(apiKeysTable.key, apiKeyHash)) + .limit(1); + + return apiKeyRecord ?? null; + } catch (e) { + throw StorageError.queryFailed( + "Failed to look up API key", + e instanceof Error ? e : new Error(String(e)) + ); + } +} diff --git a/src/utils/authenticateHttpApiKey.ts b/src/utils/authenticateHttpApiKey.ts index 614196b..8bcddf6 100644 --- a/src/utils/authenticateHttpApiKey.ts +++ b/src/utils/authenticateHttpApiKey.ts @@ -1,8 +1,6 @@ -import { eq } from "drizzle-orm"; import { AuthError } from "../errors/auth"; import { apiKeyCache } from "./apiKeyCache"; -import { getPostgresDB } from "../storage/db/postgres/db"; -import { apiKeysTable } from "../storage/db/postgres/schema"; +import { findApiKeyByHash } from "../storage/db/postgres/helpers/apiKeys"; import { hashAPIKey } from "./hashAPIKey"; import { DateTime } from "luxon"; @@ -30,16 +28,7 @@ export async function authenticateHttpApiKey( return cached.id; } - const db = getPostgresDB(); - const [apiKeyRecord] = await db - .select({ - id: apiKeysTable.id, - expiresAt: apiKeysTable.expiresAt, - revoked: apiKeysTable.revoked, - }) - .from(apiKeysTable) - .where(eq(apiKeysTable.key, apiKeyHash)) - .limit(1); + const apiKeyRecord = await findApiKeyByHash(apiKeyHash); if (!apiKeyRecord) { throw AuthError.invalidAPIKey("API key not found");