diff --git a/server/api/.env.example b/server/api/.env.example index be301fe5..12aa0c25 100644 --- a/server/api/.env.example +++ b/server/api/.env.example @@ -1 +1,6 @@ SENTRY_DSN_API= +# Sentry Internal Integration Client Secret used to verify the +# `Sentry-Hook-Signature` header on POST /api/webhooks/sentry. +# Sentry Internal Integration の Client Secret。`/api/webhooks/sentry` に +# 届く `Sentry-Hook-Signature` ヘッダの HMAC-SHA256 署名検証に使う。 +SENTRY_WEBHOOK_SECRET= diff --git a/server/api/src/__tests__/routes/admin/errors.test.ts b/server/api/src/__tests__/routes/admin/errors.test.ts new file mode 100644 index 00000000..c2150388 --- /dev/null +++ b/server/api/src/__tests__/routes/admin/errors.test.ts @@ -0,0 +1,323 @@ +/** + * 管理 API: `GET /api/admin/errors`, `GET /api/admin/errors/:id`, + * `PATCH /api/admin/errors/:id` のテスト。 + * + * Tests for the admin `api_errors` list / detail / status-update endpoints. + * + * DB chain order on each request: + * [0] adminRequired → ADMIN_ROLE_RESULT (or USER_ROLE_RESULT for 403 cases) + * [1..] handler queries (depends on the route) + */ +import { describe, it, expect, vi } from "vitest"; +import type { Context, Next } from "hono"; +import type { AppEnv } from "../../../types/index.js"; + +vi.mock("../../../middleware/auth.js", () => ({ + authRequired: async (c: Context, next: Next) => { + const userId = c.req.header("x-test-user-id"); + const userEmail = c.req.header("x-test-user-email"); + if (!userId) return c.json({ message: "Unauthorized" }, 401); + c.set("userId", userId); + if (userEmail) c.set("userEmail", userEmail); + await next(); + }, + authOptional: async (c: Context, next: Next) => { + const userId = c.req.header("x-test-user-id"); + const userEmail = c.req.header("x-test-user-email"); + if (userId) c.set("userId", userId); + if (userEmail) c.set("userEmail", userEmail); + await next(); + }, +})); + +const ADMIN_ROLE_RESULT = [{ role: "admin" }]; +const USER_ROLE_RESULT = [{ role: "user" }]; + +import { createAdminTestApp, adminAuthHeaders } from "./setup.js"; + +/** + * `apiErrors` 行のテストフィクスチャ。シリアライズ後の比較で扱いやすいよう + * Date は `new Date(...)` ではなくフィクスチャ生成側で固定する。 + * + * Test fixture for an `api_errors` row; Date instances are created up front so + * post-serialization comparisons are deterministic. + */ +function makeRow(overrides: Record = {}) { + return { + id: "00000000-0000-0000-0000-000000000001", + sentryIssueId: "sentry-issue-1", + fingerprint: null, + title: "TypeError: x is undefined", + route: "POST /api/ingest", + statusCode: 500, + occurrences: 3, + firstSeenAt: new Date("2026-05-01T00:00:00Z"), + lastSeenAt: new Date("2026-05-04T00:00:00Z"), + severity: "unknown" as const, + status: "open" as const, + aiSummary: null, + aiSuspectedFiles: null, + aiRootCause: null, + aiSuggestedFix: null, + githubIssueNumber: null, + createdAt: new Date("2026-05-01T00:00:00Z"), + updatedAt: new Date("2026-05-04T00:00:00Z"), + ...overrides, + }; +} + +// ── GET /api/admin/errors ──────────────────────────────────────────────────── + +describe("GET /api/admin/errors", () => { + it("returns 200 with rows, total, limit, and offset", async () => { + const r1 = makeRow({ id: "00000000-0000-0000-0000-000000000001" }); + const r2 = makeRow({ id: "00000000-0000-0000-0000-000000000002" }); + // listApiErrors issues: [list, count] + const { app } = createAdminTestApp([ADMIN_ROLE_RESULT, [r1, r2], [{ count: 2 }]]); + + const res = await app.request("/api/admin/errors", { headers: adminAuthHeaders() }); + + expect(res.status).toBe(200); + const body = (await res.json()) as { + errors: { id: string }[]; + total: number; + limit: number; + offset: number; + }; + expect(body.errors).toHaveLength(2); + expect(body.total).toBe(2); + expect(body.limit).toBe(50); + expect(body.offset).toBe(0); + }); + + it("clamps limit to MAX_LIMIT (200) and accepts offset", async () => { + const { app } = createAdminTestApp([ADMIN_ROLE_RESULT, [], [{ count: 0 }]]); + + const res = await app.request("/api/admin/errors?limit=10000&offset=5", { + headers: adminAuthHeaders(), + }); + + expect(res.status).toBe(200); + const body = (await res.json()) as { limit: number; offset: number }; + expect(body.limit).toBe(200); + expect(body.offset).toBe(5); + }); + + it("ignores unknown status / severity filters", async () => { + const { app } = createAdminTestApp([ADMIN_ROLE_RESULT, [], [{ count: 0 }]]); + + const res = await app.request("/api/admin/errors?status=garbage&severity=nope", { + headers: adminAuthHeaders(), + }); + expect(res.status).toBe(200); + }); + + it("returns 401 without auth", async () => { + const { app } = createAdminTestApp([]); + const res = await app.request("/api/admin/errors", { + headers: { "Content-Type": "application/json" }, + }); + expect(res.status).toBe(401); + }); + + it("returns 403 when user is not admin", async () => { + const { app } = createAdminTestApp([USER_ROLE_RESULT]); + const res = await app.request("/api/admin/errors", { headers: adminAuthHeaders() }); + expect(res.status).toBe(403); + }); +}); + +// ── GET /api/admin/errors/:id ─────────────────────────────────────────────── + +describe("GET /api/admin/errors/:id", () => { + it("returns 200 with the row when found", async () => { + const row = makeRow({ id: "00000000-0000-0000-0000-000000000010" }); + const { app } = createAdminTestApp([ADMIN_ROLE_RESULT, [row]]); + + const res = await app.request("/api/admin/errors/00000000-0000-0000-0000-000000000010", { + headers: adminAuthHeaders(), + }); + + expect(res.status).toBe(200); + const body = (await res.json()) as { error: { id: string } }; + expect(body.error.id).toBe("00000000-0000-0000-0000-000000000010"); + }); + + it("returns 404 when the row is not found", async () => { + const { app } = createAdminTestApp([ADMIN_ROLE_RESULT, []]); + + const res = await app.request("/api/admin/errors/00000000-0000-0000-0000-000000000099", { + headers: adminAuthHeaders(), + }); + + expect(res.status).toBe(404); + }); + + it("returns 403 when user is not admin", async () => { + const { app } = createAdminTestApp([USER_ROLE_RESULT]); + const res = await app.request("/api/admin/errors/00000000-0000-0000-0000-000000000010", { + headers: adminAuthHeaders(), + }); + expect(res.status).toBe(403); + }); + + it("returns 404 when id is not a valid UUID (no DB query issued)", async () => { + // 不正な UUID は Postgres まで投げると 500 になるので、ルート層で 404 にする。 + // Malformed UUIDs would otherwise surface as a Postgres-level 500; reject early. + const { app, chains } = createAdminTestApp([ADMIN_ROLE_RESULT]); + const res = await app.request("/api/admin/errors/not-a-uuid", { headers: adminAuthHeaders() }); + expect(res.status).toBe(404); + // Only the adminRequired role-check chain ran; no errors-table SELECT was issued. + expect(chains).toHaveLength(1); + }); +}); + +// ── PATCH /api/admin/errors/:id ───────────────────────────────────────────── + +const VALID_UUID = "00000000-0000-0000-0000-000000000001"; + +describe("PATCH /api/admin/errors/:id", () => { + it("updates status when transition is valid", async () => { + const before = makeRow({ status: "open" }); + const after = makeRow({ ...before, status: "investigating" }); + // updateApiErrorStatus issues: [select current, update returning]. + const { app } = createAdminTestApp([ADMIN_ROLE_RESULT, [before], [after]]); + + const res = await app.request(`/api/admin/errors/${before.id}`, { + method: "PATCH", + headers: adminAuthHeaders(), + body: JSON.stringify({ status: "investigating" }), + }); + + expect(res.status).toBe(200); + const body = (await res.json()) as { error: { status: string } }; + expect(body.error.status).toBe("investigating"); + }); + + it("returns 200 (no-op) when transitioning to the same status", async () => { + const row = makeRow({ status: "open" }); + // Same-status short-circuits: only the SELECT is issued. + const { app } = createAdminTestApp([ADMIN_ROLE_RESULT, [row]]); + + const res = await app.request(`/api/admin/errors/${row.id}`, { + method: "PATCH", + headers: adminAuthHeaders(), + body: JSON.stringify({ status: "open" }), + }); + + expect(res.status).toBe(200); + }); + + it("returns 400 when body is invalid JSON", async () => { + const { app } = createAdminTestApp([ADMIN_ROLE_RESULT]); + const res = await app.request(`/api/admin/errors/${VALID_UUID}`, { + method: "PATCH", + headers: adminAuthHeaders(), + body: "{not-json", + }); + expect(res.status).toBe(400); + }); + + it("returns 400 when status is missing", async () => { + const { app } = createAdminTestApp([ADMIN_ROLE_RESULT]); + const res = await app.request(`/api/admin/errors/${VALID_UUID}`, { + method: "PATCH", + headers: adminAuthHeaders(), + body: JSON.stringify({}), + }); + expect(res.status).toBe(400); + }); + + it("returns 400 when body is JSON null", async () => { + // body が "null" のときに body.status を読むと TypeError になりうる。 + // Sending a literal `null` body must short-circuit to 400 before + // dereferencing `.status`. + const { app } = createAdminTestApp([ADMIN_ROLE_RESULT]); + const res = await app.request(`/api/admin/errors/${VALID_UUID}`, { + method: "PATCH", + headers: adminAuthHeaders(), + body: "null", + }); + expect(res.status).toBe(400); + }); + + it("returns 400 when status is not a recognized value", async () => { + const { app } = createAdminTestApp([ADMIN_ROLE_RESULT]); + const res = await app.request(`/api/admin/errors/${VALID_UUID}`, { + method: "PATCH", + headers: adminAuthHeaders(), + body: JSON.stringify({ status: "garbage" }), + }); + expect(res.status).toBe(400); + }); + + it("returns 404 when id is not a valid UUID (no DB query issued)", async () => { + const { app, chains } = createAdminTestApp([ADMIN_ROLE_RESULT]); + const res = await app.request("/api/admin/errors/not-a-uuid", { + method: "PATCH", + headers: adminAuthHeaders(), + body: JSON.stringify({ status: "investigating" }), + }); + expect(res.status).toBe(404); + // Only the adminRequired role-check chain ran; no SELECT/UPDATE was issued. + expect(chains).toHaveLength(1); + }); + + it("returns 400 on a disallowed transition (ignored -> resolved)", async () => { + const before = makeRow({ status: "ignored" }); + // updateApiErrorStatus throws after the SELECT; only one chain is needed. + const { app } = createAdminTestApp([ADMIN_ROLE_RESULT, [before]]); + + const res = await app.request(`/api/admin/errors/${before.id}`, { + method: "PATCH", + headers: adminAuthHeaders(), + body: JSON.stringify({ status: "resolved" }), + }); + + expect(res.status).toBe(400); + }); + + it("returns 404 when the row does not exist", async () => { + const { app } = createAdminTestApp([ADMIN_ROLE_RESULT, []]); + const res = await app.request("/api/admin/errors/00000000-0000-0000-0000-000000000099", { + method: "PATCH", + headers: adminAuthHeaders(), + body: JSON.stringify({ status: "investigating" }), + }); + expect(res.status).toBe(404); + }); + + it("returns 409 when a concurrent update wins the race", async () => { + const before = makeRow({ status: "open" }); + // Mock the conflict: SELECT returns row, but the conditional UPDATE returns no rows. + const { app } = createAdminTestApp([ADMIN_ROLE_RESULT, [before], []]); + + const res = await app.request(`/api/admin/errors/${before.id}`, { + method: "PATCH", + headers: adminAuthHeaders(), + body: JSON.stringify({ status: "investigating" }), + }); + + expect(res.status).toBe(409); + }); + + it("returns 401 without auth", async () => { + const { app } = createAdminTestApp([]); + const res = await app.request(`/api/admin/errors/${VALID_UUID}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ status: "investigating" }), + }); + expect(res.status).toBe(401); + }); + + it("returns 403 when user is not admin", async () => { + const { app } = createAdminTestApp([USER_ROLE_RESULT]); + const res = await app.request(`/api/admin/errors/${VALID_UUID}`, { + method: "PATCH", + headers: adminAuthHeaders(), + body: JSON.stringify({ status: "investigating" }), + }); + expect(res.status).toBe(403); + }); +}); diff --git a/server/api/src/__tests__/routes/webhooks/sentry.test.ts b/server/api/src/__tests__/routes/webhooks/sentry.test.ts new file mode 100644 index 00000000..de6667f8 --- /dev/null +++ b/server/api/src/__tests__/routes/webhooks/sentry.test.ts @@ -0,0 +1,331 @@ +/** + * `POST /api/webhooks/sentry` のテスト。 + * + * - 署名 OK: 200 + DB upsert(モックチェーンの呼び出しを検証) + * - 署名 NG: 403 + * - 署名ヘッダ欠落: 403 + * - シークレット未設定: 500 + * - sentry_issue_id を抽出できないペイロード: 200 + ignored + * + * Tests for the Sentry Internal Integration webhook receiver. + */ +import crypto from "node:crypto"; +import { Hono } from "hono"; +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import type { Context, Next } from "hono"; +import type { AppEnv } from "../../../types/index.js"; + +// notes/setup.js を経由して middleware/auth.js が auth.ts を読み込まないよう、 +// テスト先頭でモックする(webhook ルート自体は auth に依存しない)。 +// Mock middleware/auth before importing notes/setup so the transitive +// `import { auth } from "../auth.js"` (which needs DATABASE_URL) is short-circuited. +// The webhook route itself does not depend on auth — this mock just keeps the +// shared `createMockDb` import side-effect-free in this test file. +vi.mock("../../../middleware/auth.js", () => ({ + authRequired: async (_c: Context, next: Next) => { + await next(); + }, + authOptional: async (_c: Context, next: Next) => { + await next(); + }, +})); + +import sentryRoutes, { extractSentrySummary } from "../../../routes/webhooks/sentry.js"; +import { errorHandler } from "../../../middleware/errorHandler.js"; +import { createMockDb } from "../notes/setup.js"; + +const TEST_SECRET = "sentry-test-client-secret"; + +/** + * テスト用アプリ。dbResults はハンドラ内のクエリ結果を順番に返す。 + * Build a test app whose mock DB returns `dbResults` in order. + */ +function createApp(dbResults: unknown[]) { + const { db, chains } = createMockDb(dbResults); + const app = new Hono(); + app.onError(errorHandler); + app.use("*", async (c, next) => { + c.set("db", db as unknown as AppEnv["Variables"]["db"]); + await next(); + }); + app.route("/api/webhooks/sentry", sentryRoutes); + return { app, chains }; +} + +/** + * 与えられた body から Sentry-Hook-Signature 互換の HMAC-SHA256 hex を計算する。 + * Compute the matching HMAC-SHA256 hex signature for a body. + */ +function sign(body: string, secret = TEST_SECRET): string { + return crypto.createHmac("sha256", secret).update(body, "utf8").digest("hex"); +} + +describe("extractSentrySummary", () => { + it("returns null when payload is not an object", () => { + expect(extractSentrySummary(null)).toBeNull(); + expect(extractSentrySummary("nope")).toBeNull(); + expect(extractSentrySummary(42)).toBeNull(); + }); + + it("returns null when no sentry_issue_id can be located", () => { + expect(extractSentrySummary({ data: {} })).toBeNull(); + expect(extractSentrySummary({ data: { event: { tags: [] } } })).toBeNull(); + }); + + it("extracts from data.issue (issue.created shape)", () => { + const result = extractSentrySummary({ + action: "created", + data: { + issue: { + id: "1234567890", + title: "TypeError: x is undefined", + shortId: "API-1A", + metadata: { transaction: "POST /api/ingest" }, + }, + }, + }); + expect(result).not.toBeNull(); + expect(result?.sentryIssueId).toBe("1234567890"); + expect(result?.title).toBe("TypeError: x is undefined"); + expect(result?.route).toBe("POST /api/ingest"); + }); + + it("extracts from data.event with request + contexts.response, stripping URL to path", () => { + // フォールバックで URL を使う場合は origin / query / fragment を削り、 + // pathname だけを残す(capability token や絶対 URL を api_errors.route に + // 入れないため)。 + // When falling back to request.url, strip origin / query / fragment so we + // never persist capability tokens or absolute URLs into api_errors.route. + const result = extractSentrySummary({ + action: "triggered", + data: { + event: { + issue_id: "9999", + title: "ReferenceError: foo is not defined", + request: { + method: "POST", + url: "https://example.com/api/ingest?token=secret#frag", + }, + contexts: { response: { status_code: 500 } }, + fingerprint: ["abc-123"], + }, + }, + }); + expect(result?.sentryIssueId).toBe("9999"); + expect(result?.statusCode).toBe(500); + expect(result?.fingerprint).toBe("abc-123"); + expect(result?.route).toBe("POST /api/ingest"); + }); + + it("prefers tags.transaction over request.url when both exist", () => { + // tags.transaction はスクラブ済みのルートテンプレなのでそちらを優先する。 + // The transaction tag carries Sentry's already-scrubbed route template, so + // it wins over a raw request URL. + const result = extractSentrySummary({ + data: { + event: { + issue_id: "1", + title: "boom", + request: { method: "GET", url: "https://example.com/api/ingest?token=secret" }, + tags: [["transaction", "GET /api/pages/:id"]], + }, + }, + }); + expect(result?.route).toBe("GET /api/pages/:id"); + }); + + it("extracts from data.error when issue/event slots are absent", () => { + // 一部の Sentry イベント種別では識別子が data.error 配下にしかない。 + // For event shapes where the id only lives under `data.error`, the + // extractor must still locate it; otherwise those errors silently drop + // out of admin error tracking. + const result = extractSentrySummary({ + data: { + error: { + id: "error-only-77", + title: "Some API error", + }, + }, + }); + expect(result?.sentryIssueId).toBe("error-only-77"); + expect(result?.title).toBe("Some API error"); + }); + + it("falls back to a default title when none is provided", () => { + const result = extractSentrySummary({ + data: { issue: { id: "noTitle" } }, + }); + expect(result?.sentryIssueId).toBe("noTitle"); + expect(result?.title).toBe("Sentry issue"); + }); + + it("reads transaction tag from array-of-tuples format", () => { + const result = extractSentrySummary({ + data: { + event: { + issue_id: "55", + title: "boom", + tags: [ + ["transaction", "GET /api/pages/:id"], + ["response.status_code", "404"], + ], + }, + }, + }); + expect(result?.route).toBe("GET /api/pages/:id"); + expect(result?.statusCode).toBe(404); + }); +}); + +describe("POST /api/webhooks/sentry", () => { + const ORIGINAL_ENV = process.env.SENTRY_WEBHOOK_SECRET; + + beforeEach(() => { + process.env.SENTRY_WEBHOOK_SECRET = TEST_SECRET; + vi.spyOn(console, "error").mockImplementation(() => undefined); + vi.spyOn(console, "log").mockImplementation(() => undefined); + }); + + afterEach(() => { + process.env.SENTRY_WEBHOOK_SECRET = ORIGINAL_ENV; + vi.restoreAllMocks(); + }); + + it("returns 200 and upserts the issue when signature matches", async () => { + const upsertedRow = { + id: "00000000-0000-0000-0000-0000000000aa", + sentryIssueId: "abc-123", + occurrences: 1, + }; + // upsertFromSentrySummary issues a single insert chain that resolves to [row]. + const { app, chains } = createApp([[upsertedRow]]); + const body = JSON.stringify({ + action: "created", + data: { + issue: { + id: "abc-123", + title: "TypeError: cannot read properties of undefined", + metadata: { transaction: "POST /api/ingest" }, + }, + }, + }); + + const res = await app.request("/api/webhooks/sentry", { + method: "POST", + headers: { + "Content-Type": "application/json", + "sentry-hook-signature": sign(body), + "sentry-hook-resource": "issue", + }, + body, + }); + + expect(res.status).toBe(200); + const json = (await res.json()) as { received: boolean; id: string }; + expect(json.received).toBe(true); + expect(json.id).toBe(upsertedRow.id); + + // The handler issued exactly one insert chain. + const insertChains = chains.filter((c) => c.startMethod === "insert"); + expect(insertChains).toHaveLength(1); + }); + + it("returns 403 when signature is missing", async () => { + const { app } = createApp([]); + const body = JSON.stringify({ data: { issue: { id: "x", title: "y" } } }); + + const res = await app.request("/api/webhooks/sentry", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body, + }); + + expect(res.status).toBe(403); + }); + + it("returns 403 when signature does not match the body", async () => { + const { app } = createApp([]); + const body = JSON.stringify({ data: { issue: { id: "x", title: "y" } } }); + + const res = await app.request("/api/webhooks/sentry", { + method: "POST", + headers: { + "Content-Type": "application/json", + "sentry-hook-signature": sign("different-body"), + }, + body, + }); + + expect(res.status).toBe(403); + }); + + it("returns 403 when signature is computed with a different secret", async () => { + const { app } = createApp([]); + const body = JSON.stringify({ data: { issue: { id: "x", title: "y" } } }); + + const res = await app.request("/api/webhooks/sentry", { + method: "POST", + headers: { + "Content-Type": "application/json", + "sentry-hook-signature": sign(body, "wrong-secret"), + }, + body, + }); + + expect(res.status).toBe(403); + }); + + it("returns 500 when SENTRY_WEBHOOK_SECRET is not configured", async () => { + delete process.env.SENTRY_WEBHOOK_SECRET; + const { app } = createApp([]); + const body = JSON.stringify({ data: { issue: { id: "x", title: "y" } } }); + + const res = await app.request("/api/webhooks/sentry", { + method: "POST", + headers: { + "Content-Type": "application/json", + "sentry-hook-signature": "anything", + }, + body, + }); + + expect(res.status).toBe(500); + }); + + it("returns 200 + ignored when no sentry_issue_id can be extracted", async () => { + const { app, chains } = createApp([]); + const body = JSON.stringify({ action: "ping", data: {} }); + + const res = await app.request("/api/webhooks/sentry", { + method: "POST", + headers: { + "Content-Type": "application/json", + "sentry-hook-signature": sign(body), + "sentry-hook-resource": "installation", + }, + body, + }); + + expect(res.status).toBe(200); + const json = (await res.json()) as { received: boolean; ignored: boolean }; + expect(json).toMatchObject({ received: true, ignored: true }); + // No DB chain was issued: payload was acknowledged without an insert. + expect(chains).toHaveLength(0); + }); + + it("returns 400 when body is not valid JSON (signature still required)", async () => { + const { app } = createApp([]); + const body = "{not-json"; + + const res = await app.request("/api/webhooks/sentry", { + method: "POST", + headers: { + "Content-Type": "application/json", + "sentry-hook-signature": sign(body), + }, + body, + }); + + expect(res.status).toBe(400); + }); +}); diff --git a/server/api/src/app.ts b/server/api/src/app.ts index 17bab838..0f15d034 100644 --- a/server/api/src/app.ts +++ b/server/api/src/app.ts @@ -34,6 +34,7 @@ import thumbGenerateRoutes from "./routes/thumbnail/imageGenerate.js"; import thumbCommitRoutes from "./routes/thumbnail/commit.js"; import thumbServeRoutes from "./routes/thumbnail/serve.js"; import webhookPolarRoutes from "./routes/webhooks/polar.js"; +import webhookSentryRoutes from "./routes/webhooks/sentry.js"; import checkoutRoutes from "./routes/checkout.js"; import subscriptionManageRoutes from "./routes/subscriptionManage.js"; import lintRoutes from "./routes/lint.js"; @@ -98,6 +99,10 @@ export function createApp(): Hono { // Webhook (no JWT auth — uses Standard Webhooks signature) app.route("/api/webhooks/polar", webhookPolarRoutes); + // Sentry Internal Integration webhook (HMAC-SHA256 via Client Secret) + // Sentry Internal Integration の webhook(Client Secret による HMAC 署名検証) + app.route("/api/webhooks/sentry", webhookSentryRoutes); + // Checkout & Customer Portal app.route("/api", checkoutRoutes); diff --git a/server/api/src/routes/admin/errors.ts b/server/api/src/routes/admin/errors.ts new file mode 100644 index 00000000..d9f13a40 --- /dev/null +++ b/server/api/src/routes/admin/errors.ts @@ -0,0 +1,200 @@ +/** + * `GET /api/admin/errors`, `GET /api/admin/errors/:id`, + * `PATCH /api/admin/errors/:id` — 管理画面用 API エラー一覧 / 詳細 / 状態更新。 + * + * Admin-only API for the `api_errors` workflow board (Epic #616 Phase 1). + * Authentication & admin role enforcement happen at the parent + * `/api/admin/*` mount; this router only handles request shaping and + * delegates to `apiErrorService` for DB access. + * + * @see ../../services/apiErrorService.ts + * @see https://github.com/otomatty/zedi/issues/616 + * @see https://github.com/otomatty/zedi/issues/803 + */ +import { Hono } from "hono"; +import { + ALLOWED_API_ERROR_STATUS_TRANSITIONS, + ApiErrorInvalidTransitionError, + ApiErrorStatusConflictError, + API_ERROR_LIST_DEFAULT_LIMIT, + API_ERROR_LIST_MAX_LIMIT, + getApiErrorById, + listApiErrors, + updateApiErrorStatus, +} from "../../services/apiErrorService.js"; +import type { ApiErrorSeverity, ApiErrorStatus } from "../../schema/apiErrors.js"; +import type { AppEnv } from "../../types/index.js"; + +const app = new Hono(); + +// 単一の Source of truth から status の許容値を導出する。サービス側で +// `ApiErrorStatus` を増やすと自動的にこのリストにも反映され、ドリフトを防げる。 +// Derive the accepted status values from the single source of truth in +// `apiErrorService`. Adding a new status to `ApiErrorStatus` automatically +// expands the transition map and therefore this list, preventing drift. +const VALID_STATUSES = Object.keys( + ALLOWED_API_ERROR_STATUS_TRANSITIONS, +) as readonly ApiErrorStatus[]; +const VALID_SEVERITIES = ["high", "medium", "low", "unknown"] as const; + +/** + * UUID v1〜v5 を許容する正規表現。`api_errors.id` は `uuid` 型なので + * 不正な形式が来た時点で 404 を返し、Postgres まで投げない(500 を防ぐ)。 + * + * RFC 4122 UUID matcher (any version). `api_errors.id` is a Postgres `uuid` + * column, so passing a malformed string would surface as a 500 from the DB. + * Reject early with 404 to keep the route resilient to garbage input. + */ +const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +/** + * クエリ文字列を `ApiErrorStatus` に変換する。空 / 不正値は `null`。 + * Parse a query value into a valid `ApiErrorStatus`, returning `null` for + * missing or unrecognized inputs. + */ +function parseStatus(raw: string | undefined): ApiErrorStatus | null { + const trimmed = raw?.trim(); + if (!trimmed) return null; + return (VALID_STATUSES as readonly string[]).includes(trimmed) + ? (trimmed as ApiErrorStatus) + : null; +} + +/** + * クエリ文字列を `ApiErrorSeverity` に変換する。空 / 不正値は `null`。 + * Parse a query value into a valid `ApiErrorSeverity`, returning `null` for + * missing or unrecognized inputs. + */ +function parseSeverity(raw: string | undefined): ApiErrorSeverity | null { + const trimmed = raw?.trim(); + if (!trimmed) return null; + return (VALID_SEVERITIES as readonly string[]).includes(trimmed) + ? (trimmed as ApiErrorSeverity) + : null; +} + +/** + * 整数のクエリパラメータを範囲内に丸める。 + * Clamp an integer query parameter into `[lo, hi]`, falling back to `fallback` + * for missing or non-finite inputs. + */ +function clampInt(raw: string | undefined, fallback: number, lo: number, hi: number): number { + if (raw === undefined) return fallback; + const n = parseInt(raw, 10); + if (!Number.isFinite(n)) return fallback; + return Math.min(Math.max(lo, n), hi); +} + +/** + * GET /api/admin/errors — 一覧取得(ページネーション + フィルタ)。 + * + * クエリ: + * - `status`: open / investigating / resolved / ignored + * - `severity`: high / medium / low / unknown + * - `limit`: 1〜200(既定 50) / `offset`: 0〜 + * + * GET /api/admin/errors — paginated list with optional status / severity + * filters. Unknown query values are silently dropped (treated as no filter) + * to keep the admin UI forgiving when extending the enums. + */ +app.get("/", async (c) => { + const db = c.get("db"); + const status = parseStatus(c.req.query("status")); + const severity = parseSeverity(c.req.query("severity")); + const limit = clampInt( + c.req.query("limit"), + API_ERROR_LIST_DEFAULT_LIMIT, + 1, + API_ERROR_LIST_MAX_LIMIT, + ); + const offset = clampInt(c.req.query("offset"), 0, 0, Number.MAX_SAFE_INTEGER); + + const { rows, total } = await listApiErrors(db, { + status: status ?? undefined, + severity: severity ?? undefined, + limit, + offset, + }); + + return c.json({ errors: rows, total, limit, offset }); +}); + +/** + * GET /api/admin/errors/:id — 詳細取得。 + * GET /api/admin/errors/:id — fetch a single row by primary key. + */ +app.get("/:id", async (c) => { + const id = c.req.param("id"); + if (!UUID_RE.test(id)) { + return c.json({ error: "Not found" }, 404); + } + const db = c.get("db"); + const row = await getApiErrorById(db, id); + if (!row) { + return c.json({ error: "Not found" }, 404); + } + return c.json({ error: row }); +}); + +/** + * PATCH /api/admin/errors/:id — ワークフロー状態 (`status`) を更新する。 + * + * - 400: 不正な JSON、未知の status、許可されていない遷移 + * - 404: 行が存在しない + * - 409: 並行更新が検知された (`ApiErrorStatusConflictError`) + * + * PATCH /api/admin/errors/:id — update workflow `status`. + * + * - 400: invalid JSON, unknown status, or disallowed transition + * - 404: row not found + * - 409: `ApiErrorStatusConflictError` from a concurrent update + */ +app.patch("/:id", async (c) => { + const id = c.req.param("id"); + if (!UUID_RE.test(id)) { + return c.json({ error: "Not found" }, 404); + } + const db = c.get("db"); + + let body: { status?: unknown } | null; + try { + body = await c.req.json<{ status?: unknown } | null>(); + } catch { + return c.json({ error: "invalid JSON body" }, 400); + } + + // `c.req.json()` は body が "null" や非オブジェクトでも throw しない場合がある。 + // ここで明示的にガードしないと `body.status` で TypeError になる。 + // Hono's `c.req.json()` doesn't throw on a literal `null` or non-object body, + // so guard explicitly before reading `.status`. + if (!body || typeof body !== "object" || typeof body.status !== "string") { + return c.json({ error: "status is required" }, 400); + } + const nextStatus = parseStatus(body.status); + if (!nextStatus) { + return c.json( + { + error: `status must be one of ${VALID_STATUSES.join(", ")}`, + }, + 400, + ); + } + + try { + const row = await updateApiErrorStatus(db, { id, nextStatus }); + if (!row) { + return c.json({ error: "Not found" }, 404); + } + return c.json({ error: row }); + } catch (err) { + if (err instanceof ApiErrorStatusConflictError) { + return c.json({ error: "status changed concurrently; refetch and retry" }, 409); + } + if (err instanceof ApiErrorInvalidTransitionError) { + return c.json({ error: err.message }, 400); + } + throw err; + } +}); + +export default app; diff --git a/server/api/src/routes/admin/index.ts b/server/api/src/routes/admin/index.ts index 00d645fb..982f7bbf 100644 --- a/server/api/src/routes/admin/index.ts +++ b/server/api/src/routes/admin/index.ts @@ -7,6 +7,7 @@ import { pages } from "../../schema/pages.js"; import { recordAuditLog } from "../../lib/auditLog.js"; import { getUserImpact, anonymizeUser } from "../../lib/userDelete.js"; import auditLogsRoutes from "./auditLogs.js"; +import errorsRoutes from "./errors.js"; import type { AppEnv } from "../../types/index.js"; import type { UserStatus } from "../../schema/users.js"; @@ -18,6 +19,10 @@ app.use("*", adminRequired); // 監査ログのサブルート / Audit log sub-routes app.route("/audit-logs", auditLogsRoutes); +// API エラー一覧 / 詳細 / 状態更新サブルート(Epic #616 Phase 1) +// API errors sub-routes (list / detail / status update) for Epic #616 Phase 1. +app.route("/errors", errorsRoutes); + /** GET /api/admin/me — current admin user (for admin UI). */ app.get("/me", (c) => { const userId = c.get("userId"); diff --git a/server/api/src/routes/webhooks/sentry.ts b/server/api/src/routes/webhooks/sentry.ts new file mode 100644 index 00000000..3ec037ee --- /dev/null +++ b/server/api/src/routes/webhooks/sentry.ts @@ -0,0 +1,376 @@ +/** + * `POST /api/webhooks/sentry` — Sentry Internal Integration の Webhook 受信。 + * + * Sentry の Internal Integration 設定で発行される Client Secret を使った + * HMAC-SHA256 署名 (`Sentry-Hook-Signature` ヘッダ) を検証してから、 + * ペイロードから集約サマリを抽出して `apiErrorService.upsertFromSentrySummary` + * に渡す。署名検証に失敗した場合は 403、署名が取れているがペイロードから + * `sentry_issue_id` を抽出できない場合は 200 を返す(Sentry の自動再送を + * 招かないため)。 + * + * Sentry Internal Integration webhook receiver. Verifies the + * `Sentry-Hook-Signature` HMAC-SHA256 header against the configured Client + * Secret in constant time, normalizes the payload, and forwards the summary + * to `apiErrorService.upsertFromSentrySummary`. Returns 403 on signature + * failure. Payloads we cannot map to a `sentry_issue_id` are acknowledged + * with 200 (with `received: true, ignored: true`) so Sentry's automatic + * retry policy does not loop on event types we don't handle yet. + * + * @see https://docs.sentry.io/organization/integrations/integration-platform/webhooks/ + * @see https://github.com/otomatty/zedi/issues/616 + * @see https://github.com/otomatty/zedi/issues/803 + */ +import crypto from "node:crypto"; +import { Hono } from "hono"; +import { HTTPException } from "hono/http-exception"; +import { + ApiErrorValidationError, + upsertFromSentrySummary, +} from "../../services/apiErrorService.js"; +import type { AppEnv } from "../../types/index.js"; + +const app = new Hono(); + +/** + * Sentry Webhook の署名 / リソースヘッダ名。 + * Header names used by Sentry Internal Integration webhooks. + */ +const SIGNATURE_HEADER = "sentry-hook-signature"; +const RESOURCE_HEADER = "sentry-hook-resource"; + +/** + * 与えられた raw body と Client Secret から HMAC-SHA256 hex 署名を計算する。 + * + * Compute the expected HMAC-SHA256 hex signature for the given raw body using + * the Sentry Internal Integration Client Secret. + */ +function computeSignature(rawBody: string, secret: string): string { + return crypto.createHmac("sha256", secret).update(rawBody, "utf8").digest("hex"); +} + +/** + * 署名ヘッダ値を一定時間で比較する。長さ不一致や hex デコード失敗は false。 + * + * Constant-time compare a Sentry signature header against the expected HMAC. + * Returns false on length mismatch or hex decode failure (instead of throwing) + * so webhooks with malformed headers fail closed without leaking timing. + */ +export function verifySentrySignature( + rawBody: string, + signatureHeader: string | undefined, + secret: string, +): boolean { + if (!signatureHeader || !secret) return false; + const expected = computeSignature(rawBody, secret); + // Hex 文字列同士で比較する。長さが違うと timingSafeEqual が throw するため + // 先に Buffer の長さで弾く。 + // Compare as hex strings; timingSafeEqual throws on length mismatch so we + // pre-check Buffer lengths to fail closed without leaking timing info. + let expectedBuf: Buffer; + let actualBuf: Buffer; + try { + expectedBuf = Buffer.from(expected, "hex"); + actualBuf = Buffer.from(signatureHeader, "hex"); + } catch { + return false; + } + if (expectedBuf.length === 0 || expectedBuf.length !== actualBuf.length) return false; + return crypto.timingSafeEqual(expectedBuf, actualBuf); +} + +/** 文字列でない値は null に正規化する / Coerce non-string values to null. */ +function asString(value: unknown): string | null { + return typeof value === "string" && value.length > 0 ? value : null; +} + +/** 整数でなければ null に正規化する / Coerce to a finite integer or null. */ +function asInteger(value: unknown): number | null { + if (typeof value === "number" && Number.isFinite(value) && Number.isInteger(value)) return value; + if (typeof value === "string" && /^-?\d+$/.test(value)) { + const parsed = parseInt(value, 10); + return Number.isFinite(parsed) ? parsed : null; + } + return null; +} + +/** + * オブジェクト型ガード。 + * Type guard for plain object lookups. + */ +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +/** + * 抽出後の正規化済みサマリ。`apiErrorService.upsertFromSentrySummary` の入力に使う。 + * + * Normalized summary returned by `extractSentrySummary`; mirrors the subset of + * `UpsertFromSentrySummaryInput` we can reliably derive from Sentry payloads. + */ +export interface SentrySummaryExtraction { + sentryIssueId: string; + title: string; + fingerprint: string | null; + route: string | null; + statusCode: number | null; +} + +/** + * `event.tags` / `issue.tags` を共通フォーマット(key→value マップ)に正規化する。 + * Sentry は同一フィールドを「配列のタプル `[key,value]`」「`{key,value}` の配列」 + * 「平坦なオブジェクト」のいずれでも送ってくるため、抽出側で扱う前に統一する。 + * + * Normalize Sentry's polymorphic `tags` shape (tuple-array, object-array, or + * flat object) into a flat `Record` so the extraction helpers + * can use a single lookup path. + */ +function normalizeTags(rawTags: unknown): Record { + const out: Record = {}; + if (Array.isArray(rawTags)) { + for (const t of rawTags) { + if (Array.isArray(t) && typeof t[0] === "string" && typeof t[1] === "string") { + out[t[0]] = t[1]; + } else if (isRecord(t) && typeof t.key === "string" && typeof t.value === "string") { + out[t.key] = t.value; + } + } + } else if (isRecord(rawTags)) { + for (const [k, v] of Object.entries(rawTags)) { + if (typeof v === "string") out[k] = v; + } + } + return out; +} + +/** + * fingerprint の候補リストから最初に有効な値を返す。 + * 配列なら先頭の文字列、単純な文字列ならそのまま採用する。 + * + * Pick the first usable fingerprint from a list of candidates (array of + * strings → first element; string → as-is). Returns null when no candidate + * has a usable shape. + */ +function pickFingerprint(candidates: readonly unknown[]): string | null { + for (const c of candidates) { + if (Array.isArray(c) && c.length > 0 && typeof c[0] === "string" && c[0].length > 0) { + return c[0]; + } + if (typeof c === "string" && c.length > 0) return c; + } + return null; +} + +/** + * `data.issue` / `data.event` / `data.group` / `data.id` の順に + * Sentry issue ID を探す。なければ `null`。 + * + * Locate the Sentry issue id from the well-known payload slots, in priority + * order. Returns `null` when no slot has a non-empty string value — the caller + * uses that as the signal to acknowledge with 200 + ignored. + */ +function extractSentryIssueId( + data: Record, + issue: Record | null, + event: Record | null, + error: Record | null, +): string | null { + const group = isRecord(data.group) ? data.group : null; + const eventIssue = event && isRecord(event.issue) ? event.issue : null; + return ( + asString(issue?.id) ?? + asString(event?.issue_id) ?? + asString(eventIssue?.id) ?? + asString(error?.id) ?? + asString(group?.id) ?? + asString(data.id) + ); +} + +/** + * 与えられた URL を pathname だけに正規化する(origin / query / fragment を捨てる)。 + * + * Reduce a URL string to just its pathname (origin / query / fragment stripped) + * so we don't persist capability tokens or other request-specific bytes into + * `api_errors.route`. Falls back to a manual split when the value is not a + * fully-qualified URL. + */ +function urlToPath(url: string): string { + try { + return new URL(url).pathname || url; + } catch { + return url.split(/[?#]/, 1)[0] ?? url; + } +} + +/** + * route 推定: `tags.transaction` → `issue.metadata.transaction` → + * `issue.shortId` → `event.request.url`(pathname のみ) の順に試す。 + * + * URL を最後の手段にしているのは Phase 1 の方針(Sentry 側でスクラブ済みの + * 集約サマリだけ持つ)に従い、生 URL が `api_errors.route` に流入して + * トークンやクエリ文字列が残るのを避けるため。 + * + * Derive a `route` string from a Sentry payload. Priority: the `transaction` + * tag, then issue metadata, then the issue short id, and finally `request.url` + * reduced to its pathname. URLs are the last resort and always sanitized so + * the column does not retain origins, query strings, or fragments — Phase 1 + * stores summary-only data so capability tokens and PII never persist here. + */ +function extractRoute( + issue: Record | null, + event: Record | null, + tags: Record, +): string | null { + const metadata = isRecord(issue?.metadata) ? issue.metadata : null; + if (tags.transaction) return tags.transaction; + const metaTransaction = asString(metadata?.transaction); + if (metaTransaction) return metaTransaction; + const shortId = asString(issue?.shortId); + if (shortId) return shortId; + + const request = event && isRecord(event.request) ? event.request : null; + const reqUrl = asString(request?.url); + if (!reqUrl) return null; + const path = urlToPath(reqUrl); + const reqMethod = asString(request?.method); + return reqMethod ? `${reqMethod} ${path}` : path; +} + +/** + * HTTP ステータスコード推定: `event.contexts.response.status_code` + * → `tags["response.status_code"]` → `tags["http.status_code"]` の順。 + * + * Derive an HTTP status code from a Sentry payload. Looks at the response + * context first, then standard tag names. Returns `null` when no recognized + * field is present or the value is not a finite integer. + */ +function extractStatusCode( + event: Record | null, + tags: Record, +): number | null { + const contexts = event && isRecord(event.contexts) ? event.contexts : null; + const response = contexts && isRecord(contexts.response) ? contexts.response : null; + const fromContexts = asInteger(response?.status_code); + if (fromContexts !== null) return fromContexts; + return asInteger(tags["response.status_code"]) ?? asInteger(tags["http.status_code"]); +} + +/** + * Sentry Webhook ペイロードから upsert 用サマリを抽出する。 + * + * `data.issue` / `data.event` / `data.error` の順で issue ID 候補を探し、 + * 見つからなければ `null` を返す(呼び出し側が 200 + ignored で受理する)。 + * 取り出すフィールドはイベント種別をまたいで最も汎用なものに限定する + * (title, route, statusCode, fingerprint)。 + * + * Extract a normalized upsert summary from a Sentry webhook payload. Walks + * the well-known `data.issue` → `data.event` → `data.error` slots, returns + * `null` when no `sentry_issue_id` is available (caller acknowledges with + * 200 + ignored). Extraction is intentionally minimal — title, route, + * status code, fingerprint — covering Phase 1 event types. + */ +export function extractSentrySummary(payload: unknown): SentrySummaryExtraction | null { + if (!isRecord(payload)) return null; + const data = isRecord(payload.data) ? payload.data : null; + if (!data) return null; + + const issue = isRecord(data.issue) ? data.issue : null; + const event = isRecord(data.event) ? data.event : null; + const error = isRecord(data.error) ? data.error : null; + + const sentryIssueId = extractSentryIssueId(data, issue, event, error); + if (!sentryIssueId) return null; + + const title = + asString(issue?.title) ?? + asString(event?.title) ?? + asString(error?.title) ?? + asString(data.title) ?? + "Sentry issue"; + + const fingerprint = pickFingerprint([event?.fingerprint, issue?.fingerprint, data.fingerprint]); + + // tags を一度だけ正規化して route / statusCode 双方で使い回す。 + // Normalize tags once and share across route/statusCode extraction. + const tags = normalizeTags(event?.tags ?? issue?.tags); + + return { + sentryIssueId, + title, + fingerprint, + route: extractRoute(issue, event, tags), + statusCode: extractStatusCode(event, tags), + }; +} + +/** + * POST /api/webhooks/sentry — Sentry Internal Integration 受信エンドポイント。 + * POST /api/webhooks/sentry — Sentry Internal Integration receiver. + */ +app.post("/", async (c) => { + const secret = process.env.SENTRY_WEBHOOK_SECRET; + if (!secret) { + throw new HTTPException(500, { message: "Sentry webhook secret not configured" }); + } + + // 署名計算は raw body 必須。c.req.json() を呼ぶと Hono が body を消費するので + // 先に text() で raw を取り出してから JSON.parse する。 + // Capture the raw body before parsing JSON; signature verification needs the + // exact bytes Sentry signed. + const rawBody = await c.req.text(); + const signatureHeader = c.req.header(SIGNATURE_HEADER); + if (!verifySentrySignature(rawBody, signatureHeader, secret)) { + console.error("[sentry-webhook] Signature verification failed"); + throw new HTTPException(403, { message: "Invalid webhook signature" }); + } + + let payload: unknown; + try { + payload = JSON.parse(rawBody); + } catch { + return c.json({ error: "Invalid JSON body" }, 400); + } + + const resource = c.req.header(RESOURCE_HEADER) ?? "unknown"; + const summary = extractSentrySummary(payload); + if (!summary) { + // 既知でない resource / 未対応イベントは 200 で受理する。Sentry は 4xx / 5xx + // を見て自動リトライするため、こちらが処理対象外と判断したものは 200 を返す。 + // Acknowledge unhandled resources with 200; Sentry retries 4xx/5xx, and we + // don't want to loop on event shapes Phase 1 doesn't cover. + console.log(`[sentry-webhook] Ignored resource=${resource} (no sentry_issue_id)`); + return c.json({ received: true, ignored: true }); + } + + const db = c.get("db"); + try { + const row = await upsertFromSentrySummary(db, { + sentryIssueId: summary.sentryIssueId, + title: summary.title, + fingerprint: summary.fingerprint, + route: summary.route, + statusCode: summary.statusCode, + occurrencesDelta: 1, + }); + console.log( + `[sentry-webhook] resource=${resource} upserted issue=${row.sentryIssueId} occurrences=${row.occurrences}`, + ); + return c.json({ received: true, id: row.id }); + } catch (err) { + // 入力検証エラー (sentryIssueId / title / statusCode 等) は 400 で返す。 + // それ以外は 500 にフォールバックさせる。サービス層が型付きエラーを投げるので + // メッセージ文字列に依存せず instanceof で分岐する。 + // Boundary validation errors → 400; anything else → bubble up to 500. + // We branch on `instanceof ApiErrorValidationError` rather than parsing the + // error message so service-side message tweaks don't accidentally flip + // 400-eligible failures into 500s. + const message = err instanceof Error ? err.message : "unknown error"; + console.error(`[sentry-webhook] Failed to upsert: ${message}`); + if (err instanceof ApiErrorValidationError) { + return c.json({ error: message }, 400); + } + throw err; + } +}); + +export default app; diff --git a/server/api/src/services/apiErrorService.ts b/server/api/src/services/apiErrorService.ts index 185f24d0..cbf359a9 100644 --- a/server/api/src/services/apiErrorService.ts +++ b/server/api/src/services/apiErrorService.ts @@ -27,6 +27,50 @@ import type { } from "../schema/apiErrors.js"; import type { Database } from "../types/index.js"; +/** + * `upsertFromSentrySummary` の境界バリデーションで投げるエラー。 + * Thrown by `upsertFromSentrySummary` when the external (webhook) payload + * fails boundary validation (empty / null required fields, invalid timestamps, + * non-integer `statusCode`, inverted timeline, etc.). + * + * 呼び出し側(webhook ルート)は HTTP 400 にマップすることを想定する。 + * Callers (e.g. the Sentry webhook route) are expected to map this to + * HTTP 400 instead of letting it bubble up to the generic 500 path. + */ +export class ApiErrorValidationError extends Error { + /** + * 構造化メッセージで境界バリデーションエラーを生成する。 + * Build a boundary-validation error tagged with a structured message. + */ + constructor(message: string) { + super(message); + this.name = "ApiErrorValidationError"; + } +} + +/** + * `assertValidApiErrorStatusTransition` が許可されていない遷移を検出したときに投げる。 + * Thrown when `assertValidApiErrorStatusTransition` rejects a `from -> to` + * transition that isn't permitted by `ALLOWED_API_ERROR_STATUS_TRANSITIONS`. + * + * 呼び出し側は HTTP 400 にマップすることを想定する。 + * Callers map this to HTTP 400. + */ +export class ApiErrorInvalidTransitionError extends Error { + /** + * 現在および要求された遷移先の status を保持してエラーを生成する。 + * Build a transition error that retains both endpoints so the route layer + * can surface them in the 400 response without re-parsing the message. + */ + constructor( + public readonly current: ApiErrorStatus, + public readonly next: ApiErrorStatus, + ) { + super(`Invalid api_errors status transition: ${current} -> ${next}`); + this.name = "ApiErrorInvalidTransitionError"; + } +} + /** * 状態遷移の許可マップ。`from -> to` の集合で表現する。 * @@ -77,7 +121,7 @@ export function assertValidApiErrorStatusTransition( next: ApiErrorStatus, ): void { if (!isValidApiErrorStatusTransition(current, next)) { - throw new Error(`Invalid api_errors status transition: ${current} -> ${next}`); + throw new ApiErrorInvalidTransitionError(current, next); } } @@ -128,23 +172,23 @@ function normalizeUpsertInput(input: UpsertFromSentrySummaryInput): { } { const sentryIssueId = (input.sentryIssueId ?? "").trim(); if (!sentryIssueId) { - throw new Error("sentryIssueId is required"); + throw new ApiErrorValidationError("sentryIssueId is required"); } const title = (input.title ?? "").trim(); if (!title) { - throw new Error("title is required"); + throw new ApiErrorValidationError("title is required"); } if ( input.statusCode != null && (!Number.isFinite(input.statusCode) || !Number.isInteger(input.statusCode)) ) { - throw new Error("statusCode must be a finite integer"); + throw new ApiErrorValidationError("statusCode must be a finite integer"); } if (input.firstSeenAt && !Number.isFinite(input.firstSeenAt.getTime())) { - throw new Error("firstSeenAt must be a valid Date"); + throw new ApiErrorValidationError("firstSeenAt must be a valid Date"); } if (input.lastSeenAt && !Number.isFinite(input.lastSeenAt.getTime())) { - throw new Error("lastSeenAt must be a valid Date"); + throw new ApiErrorValidationError("lastSeenAt must be a valid Date"); } const rawDelta = input.occurrencesDelta ?? 1; const occurrencesDelta = Number.isFinite(rawDelta) ? Math.max(1, Math.floor(rawDelta)) : 1; @@ -155,7 +199,7 @@ function normalizeUpsertInput(input: UpsertFromSentrySummaryInput): { // Monotonicity invariant: firstSeenAt <= lastSeenAt. Prevents a malformed // webhook from persisting an inverted timeline into `api_errors`. if (firstSeenAt.getTime() > now.getTime()) { - throw new Error("firstSeenAt must be less than or equal to lastSeenAt"); + throw new ApiErrorValidationError("firstSeenAt must be less than or equal to lastSeenAt"); } return { sentryIssueId, title, occurrencesDelta, firstSeenAt, now }; }