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..88c2c8f --- /dev/null +++ b/src/routes/http/api/tags.ts @@ -0,0 +1,56 @@ +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 { listTags } from "../../../storage/db/postgres/helpers/tags.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 tags = await listTags(); + + 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/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/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)) + ); + } +} diff --git a/src/utils/authenticateHttpApiKey.ts b/src/utils/authenticateHttpApiKey.ts new file mode 100644 index 0000000..8bcddf6 --- /dev/null +++ b/src/utils/authenticateHttpApiKey.ts @@ -0,0 +1,51 @@ +import { AuthError } from "../errors/auth"; +import { apiKeyCache } from "./apiKeyCache"; +import { findApiKeyByHash } from "../storage/db/postgres/helpers/apiKeys"; +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 apiKeyRecord = await findApiKeyByHash(apiKeyHash); + + 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; +}