diff --git a/apps/web/__tests__/unit/agent-docs.test.ts b/apps/web/__tests__/unit/agent-docs.test.ts index 38d5f24f4a1..6691ae8b0a4 100644 --- a/apps/web/__tests__/unit/agent-docs.test.ts +++ b/apps/web/__tests__/unit/agent-docs.test.ts @@ -145,7 +145,8 @@ describe("Cap for Agents docs", () => { ); const webhooks = getDocBySlug("api/webhooks")?.content ?? ""; - expect(webhooks).toContain( + expect(webhooks).toContain("POST /api/developer/v1/webhooks"); + expect(webhooks).not.toContain( "does not currently expose a documented public webhook subscription API", ); }); diff --git a/apps/web/__tests__/unit/deliver-developer-webhook-workflow.test.ts b/apps/web/__tests__/unit/deliver-developer-webhook-workflow.test.ts new file mode 100644 index 00000000000..ef2ea05ab6c --- /dev/null +++ b/apps/web/__tests__/unit/deliver-developer-webhook-workflow.test.ts @@ -0,0 +1,95 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + sleep: vi.fn(), + attempt: vi.fn(), +})); + +vi.mock("workflow", () => ({ sleep: mocks.sleep })); +vi.mock("@/lib/developer-webhook-delivery", () => ({ + deliverDeveloperWebhookAttempt: mocks.attempt, +})); + +const { deliverDeveloperWebhookWorkflow } = await import( + "@/workflows/deliver-developer-webhook" +); + +const job = { + endpointId: "wh-1", + eventId: "evt_1", + eventType: "video.ready", + body: "{}", +}; + +beforeEach(() => { + vi.clearAllMocks(); + mocks.sleep.mockResolvedValue(undefined); + vi.spyOn(console, "error").mockImplementation(() => undefined); +}); + +describe("deliver developer webhook workflow", () => { + it("stops after the first attempt when it is delivered", async () => { + mocks.attempt.mockResolvedValue("delivered"); + expect(await deliverDeveloperWebhookWorkflow(job)).toEqual({ + outcome: "delivered", + attempts: 1, + }); + expect(mocks.attempt).toHaveBeenCalledWith(job); + expect(mocks.sleep).not.toHaveBeenCalled(); + }); + + it("retries a failing endpoint on the schedule and then delivers", async () => { + mocks.attempt + .mockResolvedValueOnce("retry") + .mockResolvedValueOnce("retry") + .mockResolvedValueOnce("delivered"); + expect(await deliverDeveloperWebhookWorkflow(job)).toEqual({ + outcome: "delivered", + attempts: 3, + }); + expect(mocks.sleep.mock.calls.map(([wait]) => wait)).toEqual([ + "60s", + "300s", + ]); + }); + + it("gives up after eight attempts spread over about a day", async () => { + mocks.attempt.mockResolvedValue("retry"); + expect(await deliverDeveloperWebhookWorkflow(job)).toEqual({ + outcome: "gave_up", + attempts: 8, + }); + expect(mocks.attempt).toHaveBeenCalledTimes(8); + expect(mocks.sleep.mock.calls.map(([wait]) => wait)).toEqual([ + "60s", + "300s", + "1800s", + "7200s", + "18000s", + "28800s", + "28800s", + ]); + }); + + it("stops retrying once the endpoint is deleted or disabled", async () => { + mocks.attempt + .mockResolvedValueOnce("retry") + .mockResolvedValueOnce("dropped"); + expect(await deliverDeveloperWebhookWorkflow(job)).toEqual({ + outcome: "dropped", + attempts: 2, + }); + expect(mocks.sleep).toHaveBeenCalledTimes(1); + }); + + it("keeps an attempt that could not run on the same schedule", async () => { + mocks.attempt + .mockRejectedValueOnce(new Error("database is away")) + .mockResolvedValueOnce("delivered"); + expect(await deliverDeveloperWebhookWorkflow(job)).toEqual({ + outcome: "delivered", + attempts: 2, + }); + expect(mocks.sleep.mock.calls.map(([wait]) => wait)).toEqual(["60s"]); + }); +}); diff --git a/apps/web/__tests__/unit/developer-transcribe-download.test.ts b/apps/web/__tests__/unit/developer-transcribe-download.test.ts index 7d4cbf10e39..d155ed5dee7 100644 --- a/apps/web/__tests__/unit/developer-transcribe-download.test.ts +++ b/apps/web/__tests__/unit/developer-transcribe-download.test.ts @@ -80,6 +80,25 @@ vi.mock("@/lib/developer-transcripts", async (importOriginal) => ({ TRANSCRIPT_URL_DOWNLOAD_TIMEOUT_MS: 2_000, })); +// requestPublicOnly refuses a loopback address, by name or as a literal, so +// the local server is reached through a name this lookup pins to it. +vi.mock("@/lib/s3-endpoint-policy", async (importOriginal) => { + const actual = + await importOriginal(); + const publicOnlyLookup: typeof actual.publicOnlyLookup = ( + _hostname, + options, + callback, + ) => { + if (typeof options === "object" && options.all) { + callback(null, [{ address: "127.0.0.1", family: 4 }]); + return; + } + callback(null, "127.0.0.1", 4); + }; + return { ...actual, publicOnlyLookup }; +}); + vi.mock("@/lib/public-media-fetch", async (importOriginal) => { const actual = await importOriginal(); @@ -263,7 +282,7 @@ const serve = async (options: { await new Promise((resolve) => state.server.listen(0, "127.0.0.1", resolve), ); - context.origin = `http://127.0.0.1:${(state.server.address() as AddressInfo).port}`; + context.origin = `http://media.test:${(state.server.address() as AddressInfo).port}`; origin = state; return state; }; diff --git a/apps/web/__tests__/unit/developer-webhook-delivery.test.ts b/apps/web/__tests__/unit/developer-webhook-delivery.test.ts new file mode 100644 index 00000000000..a5d00f767da --- /dev/null +++ b/apps/web/__tests__/unit/developer-webhook-delivery.test.ts @@ -0,0 +1,595 @@ +import { createServer, type IncomingMessage, type Server } from "node:http"; +import type { AddressInfo } from "node:net"; +import { + afterAll, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from "vitest"; + +const context = vi.hoisted(() => ({ + fake: null as null | { + db: unknown; + rows: (table: object) => Record[]; + seed: ( + table: object, + row: Record, + ) => Record; + reset: () => void; + }, + appDeleted: false, +})); + +vi.mock("@cap/database", async () => { + const schema = await import("@cap/database/schema"); + const { createWebhookFakeDatabase, webhookEndpointDefaults } = await import( + "./developer-webhook-fake-db" + ); + const fake = createWebhookFakeDatabase( + new Map([[schema.developerWebhookEndpoints, webhookEndpointDefaults]]), + { joined: () => !context.appDeleted }, + ); + context.fake = fake; + return { db: () => fake.db }; +}); +vi.mock("@cap/database/crypto", () => ({ + encrypt: async (text: string) => `sealed:${text}`, + decrypt: async (text: string) => text.replace(/^sealed:/, ""), +})); +vi.mock("@cap/env", () => ({ + NODE_ENV: "test", + buildEnv: {}, + serverEnv: () => ({}), +})); +// The production transport refuses loopback, so the local server is reached +// through a name this lookup pins to it. +vi.mock("@/lib/s3-endpoint-policy", async (importOriginal) => { + const actual = + await importOriginal(); + const publicOnlyLookup: typeof actual.publicOnlyLookup = ( + _hostname, + options, + callback, + ) => { + if (typeof options === "object" && options.all) { + callback(null, [{ address: "127.0.0.1", family: 4 }]); + return; + } + callback(null, "127.0.0.1", 4); + }; + return { ...actual, publicOnlyLookup }; +}); + +const { developerWebhookEndpoints } = await import("@cap/database/schema"); +const { + deliverDeveloperWebhookAttempt, + recordWebhookDelivery, + sendDeveloperWebhook, +} = await import("@/lib/developer-webhook-delivery"); +const { + WEBHOOK_DISABLE_AFTER_FAILURES, + WEBHOOK_DISABLE_AFTER_MS, + WEBHOOK_DISABLED_BY_FAILURES, +} = await import("@/lib/developer-webhook-events"); +const { verifyWebhookSignature } = await import( + "@/lib/developer-webhook-signature" +); +const { requestPublicOnly } = await import("@/lib/storage-fetch"); + +const SECRET = "cws_0123456789abcdefghjkmnpqrstvwx"; + +function fake() { + if (!context.fake) throw new Error("no database"); + return context.fake; +} + +function seedEndpoint(overrides: Record = {}) { + return fake().seed(developerWebhookEndpoints, { + id: "wh-1", + appId: "app-1", + url: "https://hooks.example.com/cap", + secretPrefix: SECRET.slice(0, 12), + encryptedSecret: `sealed:${SECRET}`, + ...overrides, + }); +} + +const endpoint = () => { + const [row] = fake().rows(developerWebhookEndpoints); + if (!row) throw new Error("no endpoint"); + return row; +}; + +const job = { + endpointId: "wh-1", + eventId: "evt_1", + eventType: "video.ready", + body: JSON.stringify({ id: "evt_1", type: "video.ready", data: {} }), +}; + +const answers = (status: number) => ({ + isBlocked: async () => false, + request: vi.fn(async () => new Response("ignored", { status })), +}); + +beforeEach(() => { + fake().reset(); + context.appDeleted = false; +}); + +describe("a customer endpoint over real HTTP", () => { + let server: Server; + let origin: string; + const received: { + headers: IncomingMessage["headers"]; + body: string; + method?: string; + }[] = []; + const held: IncomingMessage[] = []; + const closed: string[] = []; + + beforeAll(async () => { + server = createServer((req, res) => { + const chunks: Buffer[] = []; + req.on("data", (chunk) => chunks.push(chunk)); + req.on("end", () => { + received.push({ + headers: req.headers, + method: req.method, + body: Buffer.concat(chunks).toString("utf8"), + }); + if (req.url === "/hang") { + held.push(req); + return; + } + if (req.url === "/big") { + res.writeHead(200); + res.write("x".repeat(64 * 1024)); + held.push(req); + return; + } + if (req.url === "/reset-content-flood") { + res.writeHead(205, { "transfer-encoding": "chunked" }); + const flood = setInterval(() => res.write("x".repeat(1024)), 5); + req.socket.once("close", () => { + clearInterval(flood); + closed.push(req.url ?? ""); + }); + return; + } + if (req.url === "/reset-content-stall") { + res.writeHead(205, { "transfer-encoding": "chunked" }); + res.flushHeaders(); + req.socket.once("close", () => closed.push(req.url ?? "")); + return; + } + if (req.url === "/moved") { + res.writeHead(307, { location: "/ok" }); + res.end(); + return; + } + res.writeHead(req.url === "/down" ? 503 : 200); + res.end("thanks"); + }); + }); + await new Promise((resolve) => + server.listen(0, "127.0.0.1", resolve), + ); + origin = `http://127.0.0.1:${(server.address() as AddressInfo).port}`; + }); + + afterAll(async () => { + for (const req of held) req.socket.destroy(); + server.closeAllConnections(); + await new Promise((resolve) => server.close(resolve)); + }); + + beforeEach(() => { + received.length = 0; + }); + + const send = (path: string, timeoutMs?: number) => + sendDeveloperWebhook( + { + url: `${origin}${path}`, + secret: SECRET, + eventId: job.eventId, + eventType: job.eventType, + body: job.body, + }, + { allowPrivate: true, timeoutMs }, + ); + + it("posts the exact body with a signature the receiver can verify", async () => { + expect(await send("/ok")).toMatchObject({ ok: true, responseStatus: 200 }); + const [request] = received; + expect(request?.method).toBe("POST"); + expect(request?.body).toBe(job.body); + expect(request?.headers["content-type"]).toBe("application/json"); + expect(request?.headers["cap-event-id"]).toBe("evt_1"); + expect(request?.headers["cap-event-type"]).toBe("video.ready"); + expect( + verifyWebhookSignature({ + secret: SECRET, + body: request?.body ?? "", + header: String(request?.headers["cap-signature"]), + }), + ).toBe(true); + }); + + it("reports a server error as a failed delivery", async () => { + expect(await send("/down")).toMatchObject({ + ok: false, + errorCode: "http_error", + responseStatus: 503, + }); + }); + + it("does not follow a redirect", async () => { + expect(await send("/moved")).toMatchObject({ + ok: false, + errorCode: "redirect", + responseStatus: 307, + }); + expect(received).toHaveLength(1); + }); + + it("gives up on an endpoint that never answers", async () => { + const started = Date.now(); + expect(await send("/hang", 150)).toMatchObject({ + ok: false, + errorCode: "timeout", + responseStatus: null, + }); + expect(Date.now() - started).toBeLessThan(2_000); + }); + + it("does not wait for or read a response body that never ends", async () => { + const started = Date.now(); + expect(await send("/big", 5_000)).toMatchObject({ + ok: true, + responseStatus: 200, + }); + expect(Date.now() - started).toBeLessThan(2_000); + }); + + describe("through the transport a production build uses", () => { + const sendPinned = (path: string, timeoutMs?: number) => + sendDeveloperWebhook( + { + url: `${origin.replace("127.0.0.1", "hooks.test")}${path}`, + secret: SECRET, + eventId: job.eventId, + eventType: job.eventType, + body: job.body, + }, + { allowPrivate: true, request: requestPublicOnly, timeoutMs }, + ); + + it("posts the exact body with its length and a verifiable signature", async () => { + expect(await sendPinned("/ok")).toMatchObject({ + ok: true, + responseStatus: 200, + }); + const [request] = received; + expect(request?.method).toBe("POST"); + expect(request?.body).toBe(job.body); + expect(request?.headers["content-length"]).toBe( + String(Buffer.byteLength(job.body)), + ); + expect(request?.headers.host).toContain("hooks.test"); + expect( + verifyWebhookSignature({ + secret: SECRET, + body: request?.body ?? "", + header: String(request?.headers["cap-signature"]), + }), + ).toBe(true); + }); + + it("reports a server error and does not follow a redirect", async () => { + expect(await sendPinned("/down")).toMatchObject({ + ok: false, + errorCode: "http_error", + responseStatus: 503, + }); + expect(await sendPinned("/moved")).toMatchObject({ + ok: false, + errorCode: "redirect", + responseStatus: 307, + }); + expect(received).toHaveLength(2); + }); + + it("gives up on an endpoint that never answers", async () => { + const started = Date.now(); + expect(await sendPinned("/hang", 150)).toMatchObject({ + ok: false, + errorCode: "timeout", + }); + expect(Date.now() - started).toBeLessThan(2_000); + }); + + it.each(["/reset-content-flood", "/reset-content-stall"])( + "closes the connection after a no-content answer from %s", + async (path) => { + expect(await sendPinned(path, 5_000)).toMatchObject({ + ok: true, + responseStatus: 205, + }); + await vi.waitFor(() => expect(closed).toContain(path), { + timeout: 2_000, + }); + }, + ); + + it("does not wait for or read a response body that never ends", async () => { + const started = Date.now(); + expect(await sendPinned("/big", 5_000)).toMatchObject({ + ok: true, + responseStatus: 200, + }); + expect(Date.now() - started).toBeLessThan(2_000); + }); + }); + + it("reports a closed port as a failed connection", async () => { + const result = await sendDeveloperWebhook( + { ...job, url: "http://127.0.0.1:1/cap", secret: SECRET }, + { allowPrivate: true }, + ); + expect(result).toMatchObject({ + ok: false, + errorCode: "connection_failed", + }); + }); +}); + +describe("the delivery deadline and the signature time", () => { + const event = { + url: "https://hooks.example.com/cap", + secret: SECRET, + eventId: job.eventId, + eventType: job.eventType, + body: job.body, + }; + + it("counts a host lookup that never finishes against the deadline", async () => { + const request = vi.fn(); + const started = Date.now(); + const result = await sendDeveloperWebhook(event, { + allowPrivate: false, + isBlocked: () => new Promise(() => {}), + request, + timeoutMs: 100, + }); + expect(result).toMatchObject({ ok: false, errorCode: "timeout" }); + expect(Date.now() - started).toBeLessThan(1_500); + expect(request).not.toHaveBeenCalled(); + }); + + it("signs with the time the request is sent, not the time the lookup began", async () => { + let clock = 1_794_500_000_000; + const seen: RequestInit[] = []; + await sendDeveloperWebhook(event, { + allowPrivate: false, + now: () => clock, + isBlocked: async () => { + clock += 7_000; + return false; + }, + request: async (_url, init) => { + seen.push(init); + return new Response(null, { status: 204 }); + }, + }); + const header = new Headers(seen[0]?.headers).get("cap-signature"); + expect(header).toMatch(/^t=1794500007,/); + expect( + verifyWebhookSignature({ + secret: SECRET, + body: job.body, + header, + now: clock, + }), + ).toBe(true); + }); +}); + +describe("one delivery attempt", () => { + it("delivers, records the result and clears the failure streak", async () => { + seedEndpoint({ + consecutiveFailures: 4, + failingSince: new Date("2026-11-10T00:00:00Z"), + }); + const guards = answers(200); + + expect(await deliverDeveloperWebhookAttempt(job, guards)).toBe("delivered"); + + expect(guards.request).toHaveBeenCalledTimes(1); + expect(endpoint()).toMatchObject({ + lastDeliveryStatus: "succeeded", + lastResponseStatus: 200, + lastErrorCode: null, + consecutiveFailures: 0, + failingSince: null, + enabled: true, + }); + expect(endpoint().lastDeliveryAt).toBeInstanceOf(Date); + }); + + it("asks for a retry and counts the failure", async () => { + seedEndpoint(); + expect(await deliverDeveloperWebhookAttempt(job, answers(500))).toBe( + "retry", + ); + expect(endpoint()).toMatchObject({ + lastDeliveryStatus: "failed", + lastResponseStatus: 500, + lastErrorCode: "http_error", + consecutiveFailures: 1, + enabled: true, + }); + expect(endpoint().failingSince).toBeInstanceOf(Date); + }); + + it("keeps the start of a failure streak across later failures", async () => { + const failingSince = new Date("2026-11-10T00:00:00Z"); + seedEndpoint({ consecutiveFailures: 2, failingSince }); + await deliverDeveloperWebhookAttempt(job, answers(500)); + expect(endpoint()).toMatchObject({ consecutiveFailures: 3, failingSince }); + }); + + it("leaves the endpoint's updatedAt alone", async () => { + const seeded = seedEndpoint(); + const before = seeded.updatedAt; + await deliverDeveloperWebhookAttempt(job, answers(500)); + await deliverDeveloperWebhookAttempt(job, answers(200)); + expect(endpoint().updatedAt).toBe(before); + }); + + it.each([ + ["was deleted", { deletedAt: new Date() }], + ["was disabled", { enabled: false }], + ["no longer wants the event", { events: ["video.errored"] }], + ])("drops the delivery when the endpoint %s", async (_label, overrides) => { + seedEndpoint(overrides); + const guards = answers(200); + expect(await deliverDeveloperWebhookAttempt(job, guards)).toBe("dropped"); + expect(guards.request).not.toHaveBeenCalled(); + expect(endpoint().lastDeliveryAt).toBeNull(); + }); + + it("drops the delivery when the endpoint is gone or its app was deleted", async () => { + const guards = answers(200); + expect(await deliverDeveloperWebhookAttempt(job, guards)).toBe("dropped"); + seedEndpoint(); + context.appDeleted = true; + expect(await deliverDeveloperWebhookAttempt(job, guards)).toBe("dropped"); + expect(guards.request).not.toHaveBeenCalled(); + }); +}); + +describe("disabling an endpoint that keeps failing", () => { + const failed = { + ok: false as const, + responseStatus: 500, + errorCode: "http_error" as const, + durationMs: 5, + }; + const now = new Date("2026-11-20T12:00:00Z"); + const longAgo = new Date(now.getTime() - WEBHOOK_DISABLE_AFTER_MS - 1_000); + + it("disables after many failures over several days", async () => { + seedEndpoint({ + consecutiveFailures: WEBHOOK_DISABLE_AFTER_FAILURES - 1, + failingSince: longAgo, + }); + await recordWebhookDelivery("wh-1", failed, { + countsTowardStreak: true, + now, + }); + expect(endpoint()).toMatchObject({ + enabled: false, + disabledReason: WEBHOOK_DISABLED_BY_FAILURES, + consecutiveFailures: WEBHOOK_DISABLE_AFTER_FAILURES, + }); + }); + + it("keeps an endpoint with many failures in a short time", async () => { + seedEndpoint({ + consecutiveFailures: 500, + failingSince: new Date(now.getTime() - 60 * 60 * 1000), + }); + await recordWebhookDelivery("wh-1", failed, { + countsTowardStreak: true, + now, + }); + expect(endpoint()).toMatchObject({ enabled: true, disabledReason: null }); + }); + + it("keeps an endpoint with a few failures over a long time", async () => { + seedEndpoint({ consecutiveFailures: 3, failingSince: longAgo }); + await recordWebhookDelivery("wh-1", failed, { + countsTowardStreak: true, + now, + }); + expect(endpoint()).toMatchObject({ enabled: true, consecutiveFailures: 4 }); + }); + + it("keeps the count and the streak start together when a success and a failure land at once", async () => { + const succeeded = { ok: true as const, responseStatus: 200, durationMs: 5 }; + for (let round = 0; round < 20; round++) { + fake().reset(); + seedEndpoint({ consecutiveFailures: 3, failingSince: longAgo }); + const pair = [ + recordWebhookDelivery("wh-1", failed, { + countsTowardStreak: true, + now, + }), + recordWebhookDelivery("wh-1", succeeded, { + countsTowardStreak: true, + now, + }), + ]; + await Promise.all(round % 2 === 0 ? pair : pair.reverse()); + const row = endpoint(); + expect(row.consecutiveFailures === 0).toBe(row.failingSince === null); + if (row.failingSince !== null) { + expect([now, longAgo]).toContain(row.failingSince); + expect(row.consecutiveFailures).toBeGreaterThan(0); + } + expect(row.enabled).toBe(true); + } + }); + + it("starts a new streak from the failure that began it", async () => { + seedEndpoint(); + const first = new Date(now.getTime() - WEBHOOK_DISABLE_AFTER_MS - 60_000); + await recordWebhookDelivery("wh-1", failed, { + countsTowardStreak: true, + now: first, + }); + await recordWebhookDelivery( + "wh-1", + { ok: true, responseStatus: 200, durationMs: 5 }, + { countsTowardStreak: true, now: first }, + ); + for ( + let attempt = 0; + attempt < WEBHOOK_DISABLE_AFTER_FAILURES + 5; + attempt++ + ) { + await recordWebhookDelivery("wh-1", failed, { + countsTowardStreak: true, + now, + }); + } + expect(endpoint()).toMatchObject({ + enabled: true, + disabledReason: null, + failingSince: now, + consecutiveFailures: WEBHOOK_DISABLE_AFTER_FAILURES + 5, + }); + }); + + it("never counts a test event towards the streak", async () => { + seedEndpoint({ + consecutiveFailures: WEBHOOK_DISABLE_AFTER_FAILURES + 5, + failingSince: longAgo, + }); + await recordWebhookDelivery("wh-1", failed, { + countsTowardStreak: false, + now, + }); + expect(endpoint()).toMatchObject({ + enabled: true, + consecutiveFailures: WEBHOOK_DISABLE_AFTER_FAILURES + 5, + lastDeliveryStatus: "failed", + lastErrorCode: "http_error", + }); + }); +}); diff --git a/apps/web/__tests__/unit/developer-webhook-dispatch.test.ts b/apps/web/__tests__/unit/developer-webhook-dispatch.test.ts new file mode 100644 index 00000000000..8b3f28100dc --- /dev/null +++ b/apps/web/__tests__/unit/developer-webhook-dispatch.test.ts @@ -0,0 +1,408 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + type DeveloperVideoStore, + developerVideo, + processingState, +} from "../fixtures/developer-video"; + +type Endpoint = { + id: string; + appId: string; + events: string[]; + enabled: boolean; + deletedAt: Date | null; +}; + +const mocks = vi.hoisted(() => ({ + store: { video: null, appDeleted: false, updates: [] } as DeveloperVideoStore, + endpoints: [] as Endpoint[], + endpointQueries: 0, + failEndpointQuery: false, + hangEndpointQuery: false, + start: vi.fn(), + head: vi.fn(), + readText: vi.fn(), +})); + +vi.mock("@cap/database", async () => { + const { createDeveloperVideoDatabase } = await import( + "../fixtures/developer-video" + ); + const schema = await import("@cap/database/schema"); + const videos = await createDeveloperVideoDatabase(mocks.store); + const endpointRows = async () => { + mocks.endpointQueries += 1; + if (mocks.hangEndpointQuery) await new Promise(() => {}); + if (mocks.failEndpointQuery) throw new Error("database is away"); + const appId = mocks.store.video?.appId; + return mocks.endpoints.filter( + (endpoint) => + endpoint.appId === appId && endpoint.enabled && !endpoint.deletedAt, + ); + }; + // The real row lock makes two writers to one video take turns. The video + // fake has no lock, so transactions are queued here to stand in for it. + let turn: Promise = Promise.resolve(); + const database = { + ...videos, + select: (fields?: Record) => ({ + from: (table: object) => + table === schema.developerWebhookEndpoints + ? { where: endpointRows } + : videos.select(fields).from(), + }), + transaction: (run: (tx: unknown) => Promise) => { + const next = turn.then(() => run(database)); + turn = next.catch(() => undefined); + return next; + }, + }; + return { db: () => database }; +}); +vi.mock("@cap/env", () => ({ + NODE_ENV: "test", + serverEnv: () => ({ + WEB_URL: "https://web.example", + INFRA_PLAYBACK_URL: "https://play.example", + MEDIA_SERVER_WEBHOOK_URL: undefined, + }), +})); +vi.mock("workflow/api", () => ({ start: mocks.start })); +vi.mock("@/workflows/deliver-developer-webhook", () => ({ + deliverDeveloperWebhookWorkflow: "deliver-developer-webhook", +})); +vi.mock("@/lib/developer-video-storage", () => ({ + headDeveloperVideoObject: mocks.head, + readDeveloperVideoText: mocks.readText, + deleteDeveloperVideoObjects: vi.fn(), + developerVideoPrefix: (video: { appId: string; id: string }) => + `developer/${video.appId}/${video.id}/`, + internalDeveloperVideoUrl: async (key: string) => `https://storage/${key}`, + developerVideoUploadUrl: async (key: string) => `https://storage/put/${key}`, +})); +vi.mock("@/lib/developer-video-charge", () => ({ + DEVELOPER_VIDEO_MAX_DURATION_SECONDS: 4 * 60 * 60, + chargeDeveloperVideoUpload: vi.fn(async () => "charged"), +})); +vi.mock("@/lib/media-client", () => ({ + MEDIA_SERVER_SHUTDOWN_MESSAGE: "Server shutting down", + tryProbeVideoViaMediaServer: vi.fn(), + startMediaServerVideoJob: vi.fn(), + readMediaServerVideoJob: vi.fn(), +})); +vi.mock("@/lib/media-container", () => ({ sniffMediaUrl: vi.fn() })); + +const { applyDeveloperVideoProgress, failDeveloperVideoProcessing } = + await import("@/lib/developer-video-processing"); +const { enqueueDeveloperVideoEvent } = await import( + "@/lib/developer-webhook-dispatch" +); +const { developerVideoEventId } = await import( + "@/lib/developer-webhook-signature" +); +const { serializeDeveloperVideo } = await import("@/lib/developer-video-api"); + +const running = (token: string) => ({ + state: "running" as const, + token, + jobId: `job-${token}`, + startedAt: 2_000, +}); + +const ladder = { + encoder: "libx264", + renditions: [ + { + name: "1080p", + width: 1920, + height: 1080, + bandwidth: 4_000_000, + averageBandwidth: 3_000_000, + codecs: "avc1.640028,mp4a.40.2", + bytes: 5_000, + }, + ], + audio: { bytes: 300 }, +}; +const master = [ + "#EXTM3U", + "#EXT-X-STREAM-INF:BANDWIDTH=4000000,RESOLUTION=1920x1080", + "1080p.m3u8", +].join("\n"); + +const mp4Done = () => + applyDeveloperVideoProgress( + { videoId: "video-1", phase: "complete" }, + "mp4", + "m1", + ); +const hlsDone = () => + applyDeveloperVideoProgress( + { videoId: "video-1", phase: "complete", ladder }, + "hls", + "h1", + ); + +const settled = () => new Promise((resolve) => setTimeout(resolve, 0)); + +function video() { + if (!mocks.store.video) throw new Error("no video"); + return mocks.store.video; +} + +const jobs = () => + mocks.start.mock.calls.map( + ([, [job]]) => + job as { + endpointId: string; + eventId: string; + eventType: string; + body: string; + }, + ); + +beforeEach(() => { + vi.clearAllMocks(); + mocks.store.video = developerVideo({ + processing: processingState({ mp4: running("m1"), hls: running("h1") }), + }); + mocks.store.updates = []; + mocks.endpoints = [ + { + id: "wh-1", + appId: "app-1", + events: ["video.ready", "video.errored"], + enabled: true, + deletedAt: null, + }, + ]; + mocks.endpointQueries = 0; + mocks.failEndpointQuery = false; + mocks.hangEndpointQuery = false; + mocks.start.mockResolvedValue({ runId: "run-1" }); + mocks.head.mockResolvedValue({ bytes: 2_000, contentType: "video/mp4" }); + mocks.readText.mockResolvedValue(master); + vi.spyOn(console, "error").mockImplementation(() => undefined); + vi.spyOn(console, "warn").mockImplementation(() => undefined); +}); + +describe("video.ready", () => { + it("queues one delivery carrying the video as GET /videos/:id returns it", async () => { + await mp4Done(); + await settled(); + + expect(video().status).toBe("ready"); + expect(mocks.start).toHaveBeenCalledTimes(1); + const [job] = jobs(); + expect(job).toMatchObject({ + endpointId: "wh-1", + eventType: "video.ready", + eventId: developerVideoEventId("video-1", "video.ready"), + }); + const event = JSON.parse(job?.body ?? "{}"); + expect(event).toMatchObject({ + id: job?.eventId, + type: "video.ready", + }); + expect(Number.isNaN(Date.parse(event.createdAt))).toBe(false); + expect(event.data).toEqual( + JSON.parse(JSON.stringify(serializeDeveloperVideo(video()))), + ); + expect(event.data).toMatchObject({ id: "video-1", status: "ready" }); + expect(event.data.mp4Url).toContain("https://play.example/"); + expect(job?.body).not.toContain("s3Key"); + expect(job?.body).not.toContain("developer/app-1"); + }); + + it("queues nothing more when the encoder's callback arrives twice", async () => { + await mp4Done(); + await mp4Done(); + await mp4Done(); + await settled(); + expect(mocks.start).toHaveBeenCalledTimes(1); + }); + + it("queues one event when both outputs finish at once", async () => { + await Promise.all([mp4Done(), hlsDone()]); + await settled(); + expect(video().status).toBe("ready"); + expect(mocks.start).toHaveBeenCalledTimes(1); + expect(jobs()[0]?.eventType).toBe("video.ready"); + }); + + it("queues nothing when the second output finishes later", async () => { + await mp4Done(); + await settled(); + await hlsDone(); + await settled(); + expect(mocks.start).toHaveBeenCalledTimes(1); + }); + + it("queues one delivery for each endpoint that wants the event", async () => { + mocks.endpoints.push( + { + id: "wh-2", + appId: "app-1", + events: ["video.ready"], + enabled: true, + deletedAt: null, + }, + { + id: "wh-errors-only", + appId: "app-1", + events: ["video.errored"], + enabled: true, + deletedAt: null, + }, + { + id: "wh-off", + appId: "app-1", + events: ["video.ready"], + enabled: false, + deletedAt: null, + }, + { + id: "wh-other-app", + appId: "app-2", + events: ["video.ready"], + enabled: true, + deletedAt: null, + }, + ); + await mp4Done(); + await settled(); + expect(jobs().map((job) => job.endpointId)).toEqual(["wh-1", "wh-2"]); + expect(new Set(jobs().map((job) => job.eventId)).size).toBe(1); + expect(new Set(jobs().map((job) => job.body)).size).toBe(1); + }); + + it("reads nothing more and queues nothing for an app with no endpoints", async () => { + mocks.endpoints = []; + await mp4Done(); + await settled(); + expect(video().status).toBe("ready"); + expect(mocks.endpointQueries).toBe(1); + expect(mocks.start).not.toHaveBeenCalled(); + }); + + it("asks about endpoints only when the status changes", async () => { + await applyDeveloperVideoProgress( + { videoId: "video-1", phase: "processing", jobId: "job-9" }, + "mp4", + "m1", + ); + await settled(); + expect(mocks.endpointQueries).toBe(0); + }); +}); + +describe("video.errored", () => { + it("queues one event when processing fails, and none when failed again", async () => { + await failDeveloperVideoProcessing("video-1", "encode-1", "Too long"); + await failDeveloperVideoProcessing("video-1", "encode-1", "Too long"); + await settled(); + + expect(video().status).toBe("errored"); + expect(mocks.start).toHaveBeenCalledTimes(1); + const [job] = jobs(); + expect(job).toMatchObject({ + eventType: "video.errored", + eventId: developerVideoEventId("video-1", "video.errored"), + }); + expect(JSON.parse(job?.body ?? "{}").data).toMatchObject({ + status: "errored", + errorReason: "Too long", + }); + }); + + it("queues nothing for an output that fails after the video is ready", async () => { + await mp4Done(); + await settled(); + await applyDeveloperVideoProgress( + { videoId: "video-1", phase: "error", error: "boom" }, + "hls", + "h1", + ); + await applyDeveloperVideoProgress( + { videoId: "video-1", phase: "error", error: "boom" }, + "hls", + "h1", + ); + await settled(); + expect(video().status).toBe("ready"); + expect(jobs().map((job) => job.eventType)).toEqual(["video.ready"]); + }); +}); + +describe("queueing an event", () => { + it("sends nothing for a status that was never committed", async () => { + const queued = await enqueueDeveloperVideoEvent( + { id: "video-1", appId: "app-1" }, + "video.ready", + ); + expect(queued).toBe(0); + expect(mocks.start).not.toHaveBeenCalled(); + }); + + it("sends nothing for a deleted video", async () => { + mocks.store.video = developerVideo({ + status: "ready", + deletedAt: new Date(), + }); + expect( + await enqueueDeveloperVideoEvent( + { id: "video-1", appId: "app-1" }, + "video.ready", + ), + ).toBe(0); + }); + + it("uses the same event id and data when it is called again", async () => { + mocks.store.video = developerVideo({ status: "ready" }); + const target = { id: "video-1", appId: "app-1" }; + await enqueueDeveloperVideoEvent(target, "video.ready"); + await enqueueDeveloperVideoEvent(target, "video.ready"); + const [first, second] = jobs(); + expect(first?.eventId).toBe(second?.eventId); + expect(JSON.parse(first?.body ?? "{}").data).toEqual( + JSON.parse(second?.body ?? "{}").data, + ); + }); +}); + +describe("webhooks never hold up a video", () => { + it("marks the video ready while the customer's endpoint is still hanging", async () => { + const hanging = new Promise(() => {}); + mocks.start.mockReturnValue(hanging); + + const started = Date.now(); + const result = await mp4Done(); + + expect(result).toEqual({ success: true, applied: true }); + expect(video().status).toBe("ready"); + expect(Date.now() - started).toBeLessThan(1_000); + }); + + it("marks the video ready when queueing the delivery fails", async () => { + mocks.start.mockRejectedValue(new Error("queue is down")); + expect(await mp4Done()).toEqual({ success: true, applied: true }); + await settled(); + expect(video().status).toBe("ready"); + }); + + it("marks the video ready when the endpoint lookup fails", async () => { + mocks.failEndpointQuery = true; + expect(await mp4Done()).toEqual({ success: true, applied: true }); + await settled(); + expect(video().status).toBe("ready"); + expect(mocks.start).not.toHaveBeenCalled(); + }); + + it("records a failure while the endpoint lookup is still hanging", async () => { + mocks.hangEndpointQuery = true; + await failDeveloperVideoProcessing("video-1", "encode-1", "Too long"); + expect(video().status).toBe("errored"); + expect(mocks.endpointQueries).toBe(1); + expect(mocks.start).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/__tests__/unit/developer-webhook-docs.test.ts b/apps/web/__tests__/unit/developer-webhook-docs.test.ts new file mode 100644 index 00000000000..8337172baea --- /dev/null +++ b/apps/web/__tests__/unit/developer-webhook-docs.test.ts @@ -0,0 +1,233 @@ +import { execFileSync } from "node:child_process"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { compileMDX } from "next-mdx-remote/rsc"; +import { describe, expect, it, vi } from "vitest"; + +vi.mock("@cap/env", () => ({ + NODE_ENV: "production", + buildEnv: { NEXT_PUBLIC_WEB_URL: "https://cap.so" }, + serverEnv: () => ({}), +})); +vi.mock("@cap/database", () => ({ db: () => ({}) })); + +const { app } = await import("@/app/api/developer/v1/[...route]/webhooks"); +const { developerOpenApiSpec } = await import("@/lib/developer-openapi"); +const { + MAX_WEBHOOK_ENDPOINTS_PER_APP, + WEBHOOK_DISABLE_AFTER_FAILURES, + WEBHOOK_DISABLE_AFTER_MS, + WEBHOOK_EVENT_TYPES, + WEBHOOK_MAX_ATTEMPTS, + WEBHOOK_RETRY_DELAYS_SECONDS, + WEBHOOK_TEST_EVENT_TYPE, + WEBHOOK_TIMEOUT_MS, +} = await import("@/lib/developer-webhook-events"); +const { signWebhookBody, WEBHOOK_SIGNATURE_TOLERANCE_SECONDS } = await import( + "@/lib/developer-webhook-signature" +); +const { extractHeadings } = await import("@/utils/docs"); + +const read = (name: string) => + readFileSync(join(process.cwd(), "content/docs/api", name), "utf8"); +const guide = read("webhooks.mdx"); +const rest = read("rest-api.mdx"); +const spec = developerOpenApiSpec("https://cap.so"); + +const gap = (seconds: number) => + seconds % 3_600 === 0 + ? `${seconds / 3_600} hour${seconds === 3_600 ? "" : "s"}` + : `${seconds / 60} minute${seconds === 60 ? "" : "s"}`; + +describe("the webhooks guide", () => { + it("compiles as MDX and links only to headings that exist", async () => { + await expect( + compileMDX({ source: guide, components: {} }), + ).resolves.toBeDefined(); + const anchors = new Map([ + ["/docs/api/rest-api", extractHeadings(rest).map((h) => h.slug)], + ["", extractHeadings(guide).map((h) => h.slug)], + ]); + const links = [ + ...guide.matchAll(/\]\((\/docs\/api\/rest-api)?#([\w-]+)\)/g), + ]; + expect(links.length).toBeGreaterThan(4); + for (const [, page = "", anchor] of links) { + expect(anchors.get(page), `${page}#${anchor}`).toContain(anchor); + } + expect(rest).toContain("](/docs/api/webhooks)"); + expect(extractHeadings(rest).map((h) => h.slug)).toContain("webhooks"); + }); + + it("names every event type, header and limit the code uses", () => { + for (const type of [...WEBHOOK_EVENT_TYPES, WEBHOOK_TEST_EVENT_TYPE]) { + expect(guide).toContain(`\`${type}\``); + } + for (const name of ["Cap-Signature", "Cap-Event-Id", "Cap-Event-Type"]) { + expect(guide).toContain(name); + } + expect(guide).toContain( + `An app can have ${MAX_WEBHOOK_ENDPOINTS_PER_APP} endpoints`, + ); + expect(rest).toContain( + `An app can have ${MAX_WEBHOOK_ENDPOINTS_PER_APP} endpoints`, + ); + expect(guide).toContain(`within ${WEBHOOK_TIMEOUT_MS / 1000} seconds`); + expect(WEBHOOK_SIGNATURE_TOLERANCE_SECONDS).toBe(300); + expect(guide).toContain("more than five minutes"); + expect(guide).toContain("> 300"); + }); + + it("states the retry schedule the workflow follows", () => { + expect(guide).toContain( + `That is ${WEBHOOK_MAX_ATTEMPTS} attempts over about a day`, + ); + const rows = [...guide.matchAll(/^\| (\d) \| (.+) \|$/gm)].map( + ([, attempt, sent]) => [Number(attempt), sent] as const, + ); + expect(rows.map(([attempt]) => attempt)).toEqual( + Array.from({ length: WEBHOOK_MAX_ATTEMPTS }, (_, index) => index + 1), + ); + expect(rows[0]?.[1]).toBe("Straight away"); + WEBHOOK_RETRY_DELAYS_SECONDS.forEach((seconds, index) => { + expect(rows[index + 1]?.[1]).toContain(gap(seconds)); + }); + expect(guide).toContain( + `fails ${WEBHOOK_DISABLE_AFTER_FAILURES} attempts in a row over five days`, + ); + expect(WEBHOOK_DISABLE_AFTER_MS).toBe(5 * 24 * 60 * 60 * 1000); + }); + + it("gives a Node example that accepts Cap's signature and rejects a forged one", async () => { + const example = guide.match(/```js\n([\s\S]*?verifyCapWebhook[\s\S]*?)```/); + expect(example).not.toBeNull(); + const folder = mkdtempSync(join(tmpdir(), "cap-webhook-doc-")); + try { + writeFileSync(join(folder, "verify.mjs"), example?.[1] ?? ""); + const secret = "cws_0123456789abcdefghjkmnpqrstvwx"; + const body = JSON.stringify({ id: "evt_1", type: "video.ready" }); + const now = Math.floor(Date.now() / 1000); + const header = signWebhookBody(secret, body, now); + const cases = [ + [body, header, secret], + [`${body} `, header, secret], + [body, header, `${secret}x`], + [body, null, secret], + [body, "t=1,v1=zz", secret], + [body, signWebhookBody(secret, body, now - 301), secret], + ]; + writeFileSync(join(folder, "cases.json"), JSON.stringify(cases)); + writeFileSync( + join(folder, "run.mjs"), + [ + 'import { readFileSync } from "node:fs";', + 'import { verifyCapWebhook } from "./verify.mjs";', + 'const cases = JSON.parse(readFileSync(new URL("./cases.json", import.meta.url), "utf8"));', + "console.log(JSON.stringify(cases.map(([body, header, secret]) => verifyCapWebhook(body, header ?? undefined, secret))));", + ].join("\n"), + ); + const output = execFileSync(process.execPath, [join(folder, "run.mjs")], { + encoding: "utf8", + }); + expect(JSON.parse(output)).toEqual([ + true, + false, + false, + false, + false, + false, + ]); + } finally { + rmSync(folder, { recursive: true, force: true }); + } + }); + + it("teaches the REST API only", () => { + expect(guide).not.toMatch(/\bcap (developers|caps|webhooks)\b/); + expect(guide).not.toMatch(/\bMCP\b/); + expect(guide).not.toContain("—"); + }); +}); + +describe("webhooks in the OpenAPI description", () => { + const paths = spec.paths as Record>; + + it("describes every webhook route the API serves, and no others", () => { + const served = [ + ...new Set( + app.routes + .filter((route) => route.method !== "ALL") + .map( + (route) => + `${route.method} /api/developer/v1/webhooks${ + route.path === "/" ? "" : route.path.replace(/:(\w+)/g, "{$1}") + }`, + ), + ), + ].sort(); + const described = Object.entries(paths) + .filter(([path]) => path.startsWith("/api/developer/v1/webhooks")) + .flatMap(([path, item]) => + Object.keys(item).map((method) => `${method.toUpperCase()} ${path}`), + ) + .sort(); + expect(described).toEqual(served); + expect(described).toHaveLength(7); + }); + + it("returns the signing secret from create and rotate only", () => { + const schemas = spec.components.schemas as Record< + string, + { properties?: object } + >; + expect( + Object.keys(schemas.WebhookEndpoint?.properties ?? {}), + ).not.toContain("secret"); + const text = (operation: unknown) => JSON.stringify(operation); + for (const operation of [ + paths["/api/developer/v1/webhooks"]?.post, + paths["/api/developer/v1/webhooks/{id}/rotate-secret"]?.post, + ]) { + expect(text(operation)).toContain("WebhookEndpointWithSecret"); + } + for (const operation of [ + paths["/api/developer/v1/webhooks"]?.get, + paths["/api/developer/v1/webhooks/{id}"]?.get, + paths["/api/developer/v1/webhooks/{id}"]?.patch, + paths["/api/developer/v1/webhooks/{id}/test"]?.post, + ]) { + expect(text(operation)).not.toContain("WebhookEndpointWithSecret"); + } + }); + + it("promises retries for video events only", () => { + const outbound = spec.webhooks as Record< + string, + { post: { description: string } } + >; + for (const type of WEBHOOK_EVENT_TYPES) { + expect(outbound[type]?.post.description).toContain( + `up to ${WEBHOOK_MAX_ATTEMPTS} attempts`, + ); + expect(outbound[type]?.post.description).toContain("enabled endpoint"); + } + const test = outbound[WEBHOOK_TEST_EVENT_TYPE]?.post.description ?? ""; + expect(test).toContain("never retried"); + expect(test).toContain("whether or not that endpoint is enabled"); + expect(test).not.toContain("attempts"); + }); + + it("describes the request Cap sends for each event", () => { + const outbound = spec.webhooks as Record; + expect(Object.keys(outbound).sort()).toEqual( + [...WEBHOOK_EVENT_TYPES, WEBHOOK_TEST_EVENT_TYPE].sort(), + ); + for (const delivery of Object.values(outbound)) { + const text = JSON.stringify(delivery.post); + expect(text).toContain("Cap-Signature"); + expect(text).toContain("Cap-Event-Id"); + expect(text).toContain("Cap-Event-Type"); + } + }); +}); diff --git a/apps/web/__tests__/unit/developer-webhook-fake-db.ts b/apps/web/__tests__/unit/developer-webhook-fake-db.ts new file mode 100644 index 00000000000..f0999bf7afa --- /dev/null +++ b/apps/web/__tests__/unit/developer-webhook-fake-db.ts @@ -0,0 +1,100 @@ +import type { SQL } from "drizzle-orm"; +import { MySqlDialect } from "drizzle-orm/mysql-core"; +import { createFakeDatabase } from "./developer-transcript-fake-db"; + +type Row = Record; + +const dialect = new MySqlDialect(); + +const isSql = (value: unknown): value is SQL => + typeof value === "object" && value !== null && "queryChunks" in value; + +const keepsColumn = (value: unknown) => + isSql(value) && + /^`[^`]+`(\.`[^`]+`)?$/.test(dialect.sqlToQuery(value).sql.trim()); + +export const webhookEndpointDefaults = (): Row => ({ + events: ["video.ready", "video.errored"], + enabled: true, + disabledReason: null, + lastDeliveryAt: null, + lastDeliveryStatus: null, + lastResponseStatus: null, + lastErrorCode: null, + consecutiveFailures: 0, + failingSince: null, + deletedAt: null, +}); + +// Adds the two things the endpoint queries use that the shared fake does not +// read: a join to the app table, and an update that leaves updatedAt alone. +export function createWebhookFakeDatabase( + defaults: Map Row>, + options: { joined?: (row: Row) => boolean } = {}, +) { + const fake = createFakeDatabase(defaults); + const base = fake.db; + + const select = (fields?: Record) => ({ + from: (table: object) => { + const state = { joined: false }; + const chain = base.select(fields).from(table); + const rows = async () => { + const found = await chain; + return state.joined && options.joined + ? found.filter(options.joined) + : found; + }; + const wrapped = Object.assign(Promise.resolve().then(rows), { + innerJoin() { + state.joined = true; + return wrapped; + }, + where(condition: SQL | undefined) { + chain.where(condition); + return wrapped; + }, + orderBy() { + chain.orderBy(); + return wrapped; + }, + limit(count: number) { + chain.limit(count); + return wrapped; + }, + offset(count: number) { + chain.offset(count); + return wrapped; + }, + for() { + return wrapped; + }, + }); + return wrapped; + }, + }); + + const update = (table: object) => ({ + set: (values: Row) => + base + .update(table) + .set( + Object.fromEntries( + Object.entries(values).map(([key, value]) => [ + key, + keepsColumn(value) ? undefined : value, + ]), + ), + ), + }); + + const client = { ...base, select, update }; + return { + ...fake, + db: { + ...client, + transaction: (run: (tx: typeof client) => Promise) => + base.transaction(() => run(client)), + }, + }; +} diff --git a/apps/web/__tests__/unit/developer-webhook-guard.test.ts b/apps/web/__tests__/unit/developer-webhook-guard.test.ts new file mode 100644 index 00000000000..1173c7f4377 --- /dev/null +++ b/apps/web/__tests__/unit/developer-webhook-guard.test.ts @@ -0,0 +1,291 @@ +import { createServer, type Server } from "node:http"; +import type { AddressInfo } from "node:net"; +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; + +vi.mock("@cap/env", () => ({ + NODE_ENV: "production", + buildEnv: { NEXT_PUBLIC_IS_CAP: "true" }, + serverEnv: () => ({}), +})); +vi.mock("@cap/database", () => ({ db: () => ({}) })); + +const { checkWebhookUrl, sendDeveloperWebhook, webhookUrlProblem } = + await import("@/lib/developer-webhook-delivery"); +const { isBlockedS3Endpoint, isPrivateAddress } = await import( + "@/lib/s3-endpoint-policy" +); +const { requestPublicOnly } = await import("@/lib/storage-fetch"); +const { verifyWebhookSignature } = await import( + "@/lib/developer-webhook-signature" +); + +const resolvesTo = + (...addresses: string[]) => + (url: string) => + isBlockedS3Endpoint(url, async () => addresses); + +const event = { + secret: "cws_0123456789abcdefghjkmnpqrstvwx", + eventId: "evt_1", + eventType: "video.ready", + body: JSON.stringify({ id: "evt_1", type: "video.ready" }), +}; + +describe("webhook URL policy in a production build", () => { + it("is strict unless a caller opts in", () => { + expect(webhookUrlProblem("http://127.0.0.1/cap")).toBe("https_required"); + expect(webhookUrlProblem("http://127.0.0.1/cap", true)).toBeNull(); + }); + + it("accepts a public https URL", () => { + expect(webhookUrlProblem("https://hooks.example.com/cap?x=1")).toBeNull(); + }); + + it.each([ + ["plain http", "http://hooks.example.com/cap", "https_required"], + ["another scheme", "ftp://hooks.example.com/cap", "https_required"], + ["a file URL", "file:///etc/passwd", "https_required"], + ["no scheme", "hooks.example.com/cap", "invalid_url"], + ["an empty value", "", "invalid_url"], + [ + "a URL over the length cap", + `https://hooks.example.com/${"a".repeat(2_048)}`, + "invalid_url", + ], + [ + "a username and password", + "https://user:pass@hooks.example.com/cap", + "credentials_in_url", + ], + ["another port", "https://hooks.example.com:8443/cap", "unsupported_port"], + ["localhost", "https://localhost/cap", "private_address"], + ["a localhost subdomain", "https://app.localhost/cap", "private_address"], + ["an internal name", "https://db.internal/cap", "private_address"], + ["a .local name", "https://printer.local/cap", "private_address"], + ["loopback v4", "https://127.0.0.1/cap", "private_address"], + ["private 10/8", "https://10.1.2.3/cap", "private_address"], + ["private 172.16/12", "https://172.20.0.1/cap", "private_address"], + ["private 192.168/16", "https://192.168.1.10/cap", "private_address"], + ["carrier NAT", "https://100.64.0.1/cap", "private_address"], + ["the metadata address", "https://169.254.169.254/cap", "private_address"], + ["the unspecified address", "https://0.0.0.0/cap", "private_address"], + ["a documentation range", "https://203.0.113.9/cap", "private_address"], + ["multicast", "https://224.0.0.1/cap", "private_address"], + ["a decimal loopback", "https://2130706433/cap", "private_address"], + ["a hex loopback", "https://0x7f.1/cap", "private_address"], + ["an octal loopback", "https://017700000001/cap", "private_address"], + ["loopback v6", "https://[::1]/cap", "private_address"], + ["unique local v6", "https://[fd00:ec2::254]/cap", "private_address"], + ["link-local v6", "https://[fe80::1]/cap", "private_address"], + ["mapped v4 loopback", "https://[::ffff:127.0.0.1]/cap", "private_address"], + ["mapped v4 private", "https://[::ffff:a00:1]/cap", "private_address"], + [ + "mapped v4 metadata", + "https://[::ffff:169.254.169.254]/cap", + "private_address", + ], + [ + "NAT64 of a private v4", + "https://[64:ff9b::a00:1]/cap", + "private_address", + ], + ["6to4 of loopback", "https://[2002:7f00:1::]/cap", "private_address"], + ["Teredo", "https://[2001:0:4136:e378::1]/cap", "private_address"], + ])("refuses %s", (_label, url, problem) => { + expect(webhookUrlProblem(url)).toBe(problem); + }); + + it("measures the URL as it is stored, after encoding", () => { + const accented = `https://hooks.example.com/${"é".repeat(400)}`; + expect(accented.length).toBeLessThan(2_048); + expect(new URL(accented).href.length).toBeGreaterThan(2_048); + expect(webhookUrlProblem(accented)).toBe("invalid_url"); + expect(webhookUrlProblem(accented, true)).toBe("invalid_url"); + expect( + webhookUrlProblem(`https://hooks.example.com/${"é".repeat(300)}`), + ).toBeNull(); + }); + + it("refuses a name that resolves to a private address", async () => { + for (const address of ["10.0.0.5", "127.0.0.1", "::1", "::ffff:10.0.0.5"]) { + expect( + await checkWebhookUrl("https://hooks.example.com/cap", { + isBlocked: resolvesTo(address), + }), + ).toBe("private_address"); + } + }); + + it("refuses a name with one private address among public ones", async () => { + expect( + await checkWebhookUrl("https://hooks.example.com/cap", { + isBlocked: resolvesTo("93.184.216.34", "192.168.0.7"), + }), + ).toBe("private_address"); + }); + + it("refuses a name that does not resolve", async () => { + expect( + await checkWebhookUrl("https://hooks.example.com/cap", { + isBlocked: resolvesTo(), + }), + ).toBe("private_address"); + }); + + it("accepts a name that resolves to public addresses only", async () => { + expect( + await checkWebhookUrl("https://hooks.example.com/cap", { + isBlocked: resolvesTo("93.184.216.34", "2606:2800:220:1::1"), + }), + ).toBeNull(); + }); + + it("covers the reserved ranges added for webhooks", () => { + for (const address of [ + "192.0.2.1", + "198.51.100.1", + "203.0.113.1", + "192.88.99.1", + "2001:db8::1", + "100::1", + "64:ff9b:1::1", + "2002:a00:1::1", + "2001:0:1::1", + ]) { + expect(isPrivateAddress(address)).toBe(true); + } + for (const address of ["93.184.216.34", "8.8.8.8", "2606:4700::1111"]) { + expect(isPrivateAddress(address)).toBe(false); + } + }); +}); + +describe("sending a webhook in a production build", () => { + let server: Server; + let port: number; + let hits = 0; + + beforeAll(async () => { + server = createServer((_req, res) => { + hits += 1; + res.end("internal"); + }); + await new Promise((resolve) => + server.listen(0, "127.0.0.1", resolve), + ); + port = (server.address() as AddressInfo).port; + }); + + afterAll(async () => { + await new Promise((resolve) => server.close(resolve)); + }); + + it("does not connect to a private address written as a literal", async () => { + await expect( + requestPublicOnly(`http://127.0.0.1:${port}/`, { method: "POST" }), + ).rejects.toThrow("Refused to connect to private address"); + await expect( + requestPublicOnly(`http://[::ffff:127.0.0.1]:${port}/`), + ).rejects.toThrow("Refused to connect to private address"); + expect(hits).toBe(0); + }); + + it("does not connect when the name resolves to a private address at connect time", async () => { + const request = vi.fn((url: string, init: RequestInit) => + requestPublicOnly(url.replace("hooks.example.com", "localhost"), init), + ); + const result = await sendDeveloperWebhook( + { ...event, url: `https://hooks.example.com/cap` }, + { isBlocked: async () => false, request }, + ); + expect(request).toHaveBeenCalledTimes(1); + expect(result).toMatchObject({ ok: false, errorCode: "private_address" }); + expect(hits).toBe(0); + }); + + it("sends nothing when the URL no longer passes the policy", async () => { + const request = vi.fn(); + for (const url of [ + "http://hooks.example.com/cap", + "https://127.0.0.1/cap", + "https://user:pass@hooks.example.com/cap", + ]) { + const result = await sendDeveloperWebhook({ ...event, url }, { request }); + expect(result.ok).toBe(false); + } + const rebound = await sendDeveloperWebhook( + { ...event, url: "https://hooks.example.com/cap" }, + { request, isBlocked: resolvesTo("10.0.0.5") }, + ); + expect(rebound).toMatchObject({ ok: false, errorCode: "private_address" }); + expect(request).not.toHaveBeenCalled(); + }); + + it("treats a redirect as a failed delivery and never follows it", async () => { + const request = vi.fn( + async () => + new Response("moved", { + status: 302, + headers: { location: "http://169.254.169.254/latest/meta-data/" }, + }), + ); + const result = await sendDeveloperWebhook( + { ...event, url: "https://hooks.example.com/cap" }, + { isBlocked: async () => false, request }, + ); + expect(result).toMatchObject({ + ok: false, + errorCode: "redirect", + responseStatus: 302, + }); + expect(request).toHaveBeenCalledTimes(1); + }); + + it("signs the request and names the event in headers", async () => { + const seen: { url: string; init: RequestInit }[] = []; + const result = await sendDeveloperWebhook( + { ...event, url: "https://hooks.example.com/cap" }, + { + isBlocked: async () => false, + request: async (url, init) => { + seen.push({ url, init }); + return new Response(null, { status: 204 }); + }, + }, + ); + expect(result).toMatchObject({ ok: true, responseStatus: 204 }); + const [sent] = seen; + const headers = new Headers(sent?.init.headers); + expect(sent?.init.method).toBe("POST"); + expect(sent?.init.body).toBe(event.body); + expect(headers.get("content-type")).toBe("application/json"); + expect(headers.get("cap-event-id")).toBe("evt_1"); + expect(headers.get("cap-event-type")).toBe("video.ready"); + expect( + verifyWebhookSignature({ + secret: event.secret, + body: event.body, + header: headers.get("cap-signature"), + }), + ).toBe(true); + }); + + it.each([ + [200, true], + [201, true], + [299, true], + [400, false], + [404, false], + [500, false], + ])("counts %i as delivered: %s", async (status, delivered) => { + const result = await sendDeveloperWebhook( + { ...event, url: "https://hooks.example.com/cap" }, + { + isBlocked: async () => false, + request: async () => new Response("x", { status }), + }, + ); + expect(result.ok).toBe(delivered); + expect(result.responseStatus).toBe(status); + }); +}); diff --git a/apps/web/__tests__/unit/developer-webhook-private-targets.test.ts b/apps/web/__tests__/unit/developer-webhook-private-targets.test.ts new file mode 100644 index 00000000000..889eb66396d --- /dev/null +++ b/apps/web/__tests__/unit/developer-webhook-private-targets.test.ts @@ -0,0 +1,102 @@ +import { createServer, type Server } from "node:http"; +import type { AddressInfo } from "node:net"; +import { + afterAll, + afterEach, + beforeAll, + describe, + expect, + it, + vi, +} from "vitest"; + +// Whatever NODE_ENV says, the code a route or the delivery workflow runs +// (no guards passed) never reaches plain http or a private address. +const env = vi.hoisted(() => ({ + NODE_ENV: "development" as string | undefined, + buildEnv: { NEXT_PUBLIC_IS_CAP: "false" }, + serverEnv: () => ({}), +})); +vi.mock("@cap/env", () => env); +vi.mock("@cap/database", () => ({ db: () => ({}) })); + +const { checkWebhookUrl, sendDeveloperWebhook, webhookUrlProblem } = + await import("@/lib/developer-webhook-delivery"); + +const event = { + secret: "cws_0123456789abcdefghjkmnpqrstvwx", + eventId: "evt_1", + eventType: "video.ready", + body: "{}", +}; + +let server: Server; +let port: number; +let hits = 0; + +beforeAll(async () => { + server = createServer((_req, res) => { + hits += 1; + res.end("ok"); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + port = (server.address() as AddressInfo).port; +}); + +afterAll(async () => { + await new Promise((resolve) => server.close(resolve)); +}); + +afterEach(() => { + vi.unstubAllEnvs(); +}); + +describe.each([ + "development", + "test", + "staging", + "preview", + "", + undefined, + "production", +])("with NODE_ENV %s", (nodeEnv) => { + it("refuses private and plain http targets on create and on delivery", async () => { + env.NODE_ENV = nodeEnv; + vi.stubEnv("NODE_ENV", nodeEnv); + hits = 0; + + expect(webhookUrlProblem(`http://127.0.0.1:${port}/`)).toBe( + "https_required", + ); + expect(await checkWebhookUrl("https://127.0.0.1/cap")).toBe( + "private_address", + ); + expect(await checkWebhookUrl("https://localhost/cap")).toBe( + "private_address", + ); + expect(await checkWebhookUrl("https://169.254.169.254/cap")).toBe( + "private_address", + ); + + for (const url of [ + `http://127.0.0.1:${port}/`, + `http://localhost:${port}/`, + "https://127.0.0.1/cap", + "https://[::1]/cap", + ]) { + const result = await sendDeveloperWebhook({ ...event, url }); + expect(result.ok).toBe(false); + } + expect(hits).toBe(0); + }); +}); + +it("reaches a local server only when a test passes allowPrivate", async () => { + hits = 0; + const result = await sendDeveloperWebhook( + { ...event, url: `http://127.0.0.1:${port}/` }, + { allowPrivate: true }, + ); + expect(result.ok).toBe(true); + expect(hits).toBe(1); +}); diff --git a/apps/web/__tests__/unit/developer-webhook-rebinding.test.ts b/apps/web/__tests__/unit/developer-webhook-rebinding.test.ts new file mode 100644 index 00000000000..66384ee1dbd --- /dev/null +++ b/apps/web/__tests__/unit/developer-webhook-rebinding.test.ts @@ -0,0 +1,229 @@ +import type { LookupAddress } from "node:dns"; +import { createServer, request as httpRequest, type Server } from "node:http"; +import type { AddressInfo } from "node:net"; +import { + afterAll, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from "vitest"; + +// The name below answers with a public address the first time it is asked +// (the check) and a private one after that (the connection). The guard must +// dial only an address it has checked. +const dns = vi.hoisted(() => ({ + checkAnswers: [] as string[], + connectAnswers: [] as string[], + checkCalls: 0, + connectCalls: 0, +})); + +vi.mock("node:dns/promises", async (importOriginal) => { + const actual = await importOriginal(); + const lookup = async (hostname: string) => { + if (hostname !== "rebind.example") return actual.lookup(hostname); + dns.checkCalls += 1; + return dns.checkAnswers.map((address) => ({ + address, + family: address.includes(":") ? 6 : 4, + })); + }; + return { ...actual, default: { ...actual, lookup }, lookup }; +}); + +vi.mock("node:dns", async (importOriginal) => { + const actual = await importOriginal(); + const lookup = ( + hostname: string, + options: { all?: boolean } | number | undefined, + callback: ( + error: NodeJS.ErrnoException | null, + address: string | LookupAddress[], + family?: number, + ) => void, + ) => { + if (hostname !== "rebind.example") { + return (actual.lookup as (...args: unknown[]) => void)( + hostname, + options, + callback, + ); + } + dns.connectCalls += 1; + const answers = dns.connectAnswers.map((address) => ({ + address, + family: address.includes(":") ? 6 : 4, + })); + const all = typeof options === "object" && options?.all; + queueMicrotask(() => { + if (all) callback(null, answers); + else callback(null, answers[0]?.address ?? "", answers[0]?.family); + }); + }; + return { ...actual, default: { ...actual, lookup }, lookup }; +}); + +vi.mock("@cap/env", () => ({ + NODE_ENV: "production", + buildEnv: { NEXT_PUBLIC_IS_CAP: "true" }, + serverEnv: () => ({}), +})); +vi.mock("@cap/database", () => ({ db: () => ({}) })); + +const { sendDeveloperWebhook } = await import( + "@/lib/developer-webhook-delivery" +); +const { publicOnlyLookup } = await import("@/lib/s3-endpoint-policy"); +const { requestPublicOnly } = await import("@/lib/storage-fetch"); +const nodeDns = await import("node:dns"); + +const event = { + secret: "cws_0123456789abcdefghjkmnpqrstvwx", + eventId: "evt_1", + eventType: "video.ready", + body: JSON.stringify({ id: "evt_1", type: "video.ready" }), +}; + +let server: Server; +let port: number; +let hits = 0; + +beforeAll(async () => { + server = createServer((_req, res) => { + hits += 1; + res.end("internal"); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + port = (server.address() as AddressInfo).port; +}); + +afterAll(async () => { + await new Promise((resolve) => server.close(resolve)); +}); + +beforeEach(() => { + hits = 0; + dns.checkCalls = 0; + dns.connectCalls = 0; + dns.checkAnswers = ["93.184.216.34"]; + dns.connectAnswers = ["127.0.0.1"]; +}); + +describe("a name that changes to a private address between check and connect", () => { + it("passes the check, then is refused at connect and nothing is sent", async () => { + const result = await sendDeveloperWebhook({ + ...event, + url: "https://rebind.example/cap", + }); + expect(dns.checkCalls).toBe(1); + expect(dns.connectCalls).toBe(1); + expect(result).toMatchObject({ ok: false, errorCode: "private_address" }); + expect(hits).toBe(0); + }); + + it.each([ + ["the metadata address", "169.254.169.254"], + ["a unique local v6 address", "fd00:ec2::254"], + ["a mapped v4 loopback", "::ffff:127.0.0.1"], + ["one private address among public ones", "93.184.216.34,10.0.0.5"], + ])("refuses %s at connect", async (_label, answers) => { + dns.connectAnswers = answers.split(","); + const result = await sendDeveloperWebhook({ + ...event, + url: "https://rebind.example/cap", + }); + expect(result).toMatchObject({ ok: false, errorCode: "private_address" }); + }); + + it("dials only the address the connection's own lookup checked", async () => { + // Without the guard, the rebound answer is what gets dialled: this + // plain request with the same name reaches the local server. + const unguarded = await new Promise((resolve, reject) => { + const req = httpRequest( + { + host: "rebind.example", + port, + path: "/", + lookup: nodeDns.lookup, + }, + (res) => { + res.resume(); + resolve(res.statusCode ?? 0); + }, + ); + req.on("error", reject); + req.end(); + }); + expect(unguarded).toBe(200); + expect(hits).toBe(1); + + await expect( + requestPublicOnly(`http://rebind.example:${port}/`, { + method: "POST", + body: "{}", + }), + ).rejects.toThrow("Refused to connect to private address 127.0.0.1"); + expect(hits).toBe(1); + }); + + it.each([ + ["a 6to4 AAAA", ["93.184.216.34", "2002:a00:1::1"]], + ["a Teredo AAAA", ["2001:0:4136:e378::1", "93.184.216.34"]], + ["a documentation address", ["203.0.113.9", "2606:4700::1111"]], + ])( + "hands the connection only the public address next to %s", + async (_label, answers) => { + dns.connectAnswers = answers; + const result = await new Promise<{ + error: NodeJS.ErrnoException | null; + address: unknown; + }>((resolve) => + publicOnlyLookup("rebind.example", { all: true }, (error, address) => + resolve({ error, address }), + ), + ); + expect(result.error).toBeNull(); + const dialled = (result.address as { address: string }[]).map( + (entry) => entry.address, + ); + expect(dialled).toEqual( + answers.filter((address) => + ["93.184.216.34", "2606:4700::1111"].includes(address), + ), + ); + }, + ); + + it("refuses a name whose only addresses are reserved", async () => { + dns.connectAnswers = ["2002:a00:1::1", "192.0.2.1"]; + const error = await new Promise((resolve) => + publicOnlyLookup("rebind.example", { all: true }, (lookupError) => + resolve(lookupError), + ), + ); + expect(error?.code).toBe("ECONNREFUSED"); + }); + + it("still refuses a public address next to a NAT64 form of a private one", async () => { + dns.connectAnswers = ["93.184.216.34", "64:ff9b::a00:1"]; + const error = await new Promise((resolve) => + publicOnlyLookup("rebind.example", { all: true }, (lookupError) => + resolve(lookupError), + ), + ); + expect(error?.code).toBe("ECONNREFUSED"); + }); + + it("checks every address the lookup returns, not only the first", async () => { + dns.connectAnswers = ["93.184.216.34", "127.0.0.1"]; + const error = await new Promise((resolve) => + publicOnlyLookup("rebind.example", { all: true }, (lookupError) => + resolve(lookupError), + ), + ); + expect(error?.code).toBe("ECONNREFUSED"); + }); +}); diff --git a/apps/web/__tests__/unit/developer-webhook-signature.test.ts b/apps/web/__tests__/unit/developer-webhook-signature.test.ts new file mode 100644 index 00000000000..627c157b131 --- /dev/null +++ b/apps/web/__tests__/unit/developer-webhook-signature.test.ts @@ -0,0 +1,122 @@ +import { createHmac } from "node:crypto"; +import { describe, expect, it } from "vitest"; +import { + WEBHOOK_MAX_ATTEMPTS, + WEBHOOK_RETRY_DELAYS_SECONDS, + webhookRetryDelaySeconds, +} from "@/lib/developer-webhook-events"; +import { + createWebhookSecret, + developerVideoEventId, + signWebhookBody, + verifyWebhookSignature, +} from "@/lib/developer-webhook-signature"; + +const secret = "cws_0123456789abcdefghjkmnpqrstvwx"; +const body = JSON.stringify({ id: "evt_1", type: "video.ready" }); +const at = 1_794_500_000; + +describe("webhook signature", () => { + it("signs the timestamp and the raw body with the endpoint secret", () => { + const expected = createHmac("sha256", secret) + .update(`${at}.${body}`) + .digest("hex"); + expect(signWebhookBody(secret, body, at)).toBe(`t=${at},v1=${expected}`); + }); + + it("accepts its own signature inside the five minute window", () => { + const header = signWebhookBody(secret, body, at); + for (const drift of [0, 299, -299]) { + expect( + verifyWebhookSignature({ + secret, + body, + header, + now: (at + drift) * 1000, + }), + ).toBe(true); + } + }); + + it("rejects a timestamp older than five minutes", () => { + const header = signWebhookBody(secret, body, at); + expect( + verifyWebhookSignature({ secret, body, header, now: (at + 301) * 1000 }), + ).toBe(false); + }); + + it("rejects a changed body, another secret and a moved timestamp", () => { + const header = signWebhookBody(secret, body, at); + const now = at * 1000; + expect( + verifyWebhookSignature({ secret, body: `${body} `, header, now }), + ).toBe(false); + expect( + verifyWebhookSignature({ secret: `${secret}x`, body, header, now }), + ).toBe(false); + expect( + verifyWebhookSignature({ + secret, + body, + header: header.replace(`t=${at}`, `t=${at + 1}`), + now, + }), + ).toBe(false); + }); + + it.each([ + null, + "", + "v1=abc", + `t=${at}`, + `t=${at},v1=zz`, + `t=soon,v1=${"a".repeat(64)}`, + ])("rejects the malformed header %j", (header) => { + expect( + verifyWebhookSignature({ secret, body, header, now: at * 1000 }), + ).toBe(false); + }); + + it("makes secrets with a clear prefix and enough randomness", () => { + const first = createWebhookSecret(); + expect(first).toMatch(/^cws_[0-9a-z]{30}$/); + expect(createWebhookSecret()).not.toBe(first); + }); +}); + +describe("webhook event ids", () => { + it("names the same event for the same video and type every time", () => { + const id = developerVideoEventId("video-1", "video.ready"); + expect(id).toMatch(/^evt_[0-9a-f]{24}$/); + expect(developerVideoEventId("video-1", "video.ready")).toBe(id); + }); + + it("names a different event for another type or another video", () => { + const id = developerVideoEventId("video-1", "video.ready"); + expect(developerVideoEventId("video-1", "video.errored")).not.toBe(id); + expect(developerVideoEventId("video-2", "video.ready")).not.toBe(id); + }); +}); + +describe("webhook retry schedule", () => { + it("makes eight attempts with growing gaps over about a day", () => { + expect(WEBHOOK_MAX_ATTEMPTS).toBe(8); + expect([...WEBHOOK_RETRY_DELAYS_SECONDS]).toEqual([ + 60, 300, 1_800, 7_200, 18_000, 28_800, 28_800, + ]); + const total = WEBHOOK_RETRY_DELAYS_SECONDS.reduce((a, b) => a + b, 0); + expect(total).toBeGreaterThan(22 * 3_600); + expect(total).toBeLessThan(26 * 3_600); + for (let i = 1; i < WEBHOOK_RETRY_DELAYS_SECONDS.length; i++) { + expect(WEBHOOK_RETRY_DELAYS_SECONDS[i]).toBeGreaterThanOrEqual( + WEBHOOK_RETRY_DELAYS_SECONDS[i - 1] ?? 0, + ); + } + }); + + it("has a wait after every attempt but the last", () => { + expect(webhookRetryDelaySeconds(1)).toBe(60); + expect(webhookRetryDelaySeconds(7)).toBe(28_800); + expect(webhookRetryDelaySeconds(8)).toBeNull(); + }); +}); diff --git a/apps/web/__tests__/unit/developer-webhooks-api.test.ts b/apps/web/__tests__/unit/developer-webhooks-api.test.ts new file mode 100644 index 00000000000..c0234edcba0 --- /dev/null +++ b/apps/web/__tests__/unit/developer-webhooks-api.test.ts @@ -0,0 +1,498 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const context = vi.hoisted(() => ({ + fake: null as null | { + db: unknown; + rows: (table: object) => Record[]; + reset: () => void; + }, + resolved: ["93.184.216.34"] as string[], + request: null as + | null + | ((url: string, init: RequestInit) => Promise), +})); + +vi.mock("@cap/database", async () => { + const schema = await import("@cap/database/schema"); + const { createWebhookFakeDatabase, webhookEndpointDefaults } = await import( + "./developer-webhook-fake-db" + ); + const fake = createWebhookFakeDatabase( + new Map([[schema.developerWebhookEndpoints, webhookEndpointDefaults]]), + ); + context.fake = fake; + return { db: () => fake.db }; +}); +vi.mock("@cap/env", () => ({ + NODE_ENV: "production", + buildEnv: { + NEXT_PUBLIC_IS_CAP: "true", + NEXT_PUBLIC_WEB_URL: "https://cap.so", + }, + serverEnv: () => ({ DATABASE_ENCRYPTION_KEY: "ab".repeat(32) }), +})); +vi.mock("@/app/api/utils", async () => { + const { createMiddleware } = await import("hono/factory"); + const apps: Record = { + csk_app_a: "app-a", + csk_app_b: "app-b", + }; + return { + withDeveloperSecretAuth: createMiddleware(async (c, next) => { + const key = c.req.header("authorization")?.split(" ")[1] ?? ""; + const appId = apps[key]; + if (!appId) return c.json({ code: "invalid_secret_key" }, 401); + c.set("developerAppId", appId); + await next(); + }), + }; +}); +vi.mock("@/lib/s3-endpoint-policy", async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + isBlockedS3Endpoint: (url: string) => + actual.isBlockedS3Endpoint(url, async () => context.resolved), + }; +}); +vi.mock("@/lib/storage-fetch", () => ({ + requestPublicOnly: (url: string, init: RequestInit) => { + if (!context.request) throw new Error("no request expected"); + return context.request(url, init); + }, +})); + +const { developerWebhookEndpoints } = await import("@cap/database/schema"); +const { decrypt } = await import("@cap/database/crypto"); +const { app } = await import("@/app/api/developer/v1/[...route]/webhooks"); +const { verifyWebhookSignature } = await import( + "@/lib/developer-webhook-signature" +); + +const A = { Authorization: "Bearer csk_app_a" }; +const B = { Authorization: "Bearer csk_app_b" }; + +const send = ( + method: string, + path: string, + headers: Record, + body?: unknown, +) => + app.request(path, { + method, + headers: { + ...headers, + ...(body === undefined ? {} : { "Content-Type": "application/json" }), + }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + +async function create( + headers: Record = A, + body: Record = { url: "https://hooks.example.com/cap" }, +) { + const response = await send("POST", "/", headers, body); + return { + status: response.status, + body: (await response.json()) as { + data: Record & { id: string; secret: string }; + code?: string; + hint?: string; + }, + }; +} + +const rows = () => context.fake?.rows(developerWebhookEndpoints) ?? []; + +beforeEach(() => { + context.fake?.reset(); + context.resolved = ["93.184.216.34"]; + context.request = null; +}); + +describe("creating a webhook endpoint", () => { + it("returns the signing secret once and stores it encrypted", async () => { + const created = await create(); + expect(created.status).toBe(200); + const { id, secret } = created.body.data; + expect(secret).toMatch(/^cws_[0-9a-z]{30}$/); + expect(created.body.data).toMatchObject({ + url: "https://hooks.example.com/cap", + events: ["video.ready", "video.errored"], + enabled: true, + disabledReason: null, + secretPrefix: secret.slice(0, 12), + lastDeliveryAt: null, + consecutiveFailures: 0, + }); + + const [row] = rows(); + expect(row?.encryptedSecret).not.toContain(secret); + expect(await decrypt(String(row?.encryptedSecret))).toBe(secret); + + for (const response of [ + await send("GET", `/${id}`, A), + await send("GET", "/", A), + await send("PATCH", `/${id}`, A, { enabled: false }), + await send("POST", `/${id}/test`, A), + ]) { + const text = await response.text(); + expect(text).not.toContain(secret); + expect(text).not.toContain("encryptedSecret"); + } + }); + + it("takes the events and enabled flag it is given", async () => { + const created = await create(A, { + url: "https://hooks.example.com/cap", + events: ["video.errored"], + enabled: false, + }); + expect(created.body.data).toMatchObject({ + events: ["video.errored"], + enabled: false, + }); + }); + + it.each([ + ["an unknown field", { url: "https://hooks.example.com/cap", name: "x" }], + ["a missing url", {}], + ["an unknown event", { url: "https://a.example/x", events: ["video.up"] }], + ["no events", { url: "https://a.example/x", events: [] }], + [ + "an event listed twice", + { url: "https://a.example/x", events: ["video.ready", "video.ready"] }, + ], + [ + "a secret of the caller's choosing", + { url: "https://a.example/x", secret: "s" }, + ], + ])("refuses %s", async (_label, body) => { + const created = await create(A, body); + expect(created.status).toBe(400); + expect(created.body.code).toBe("invalid_request"); + expect(rows()).toHaveLength(0); + }); + + it.each([ + ["http", "http://hooks.example.com/cap"], + ["a private address", "https://10.0.0.8/cap"], + ["the metadata address", "https://169.254.169.254/latest"], + ["a mapped loopback address", "https://[::ffff:127.0.0.1]/cap"], + ["credentials", "https://user:pass@hooks.example.com/cap"], + ["another port", "https://hooks.example.com:8080/cap"], + ])("refuses a URL with %s", async (_label, url) => { + const created = await create(A, { url }); + expect(created.status).toBe(400); + expect(created.body.code).toBe("invalid_webhook_url"); + expect(rows()).toHaveLength(0); + }); + + it("refuses a URL that is too long once encoded, on create and on update", async () => { + const accented = `https://hooks.example.com/${"é".repeat(400)}`; + const created = await create(A, { url: accented }); + expect(created.status).toBe(400); + expect(created.body.code).toBe("invalid_webhook_url"); + expect(rows()).toHaveLength(0); + + const { id } = (await create(A)).body.data; + const updated = await send("PATCH", `/${id}`, A, { url: accented }); + expect(updated.status).toBe(400); + expect(rows()[0]?.url).toBe("https://hooks.example.com/cap"); + }); + + it("refuses a name that resolves to a private address", async () => { + context.resolved = ["192.168.1.20"]; + const created = await create(); + expect(created.status).toBe(400); + expect(created.body.code).toBe("invalid_webhook_url"); + expect(rows()).toHaveLength(0); + }); + + it("stops at five endpoints per app, and counts each app on its own", async () => { + for (let i = 0; i < 5; i++) { + expect((await create(A)).status).toBe(200); + } + const sixth = await create(A); + expect(sixth.status).toBe(409); + expect(sixth.body.code).toBe("webhook_limit_reached"); + expect(rows()).toHaveLength(5); + + expect((await create(B)).status).toBe(200); + }); + + it("does not let simultaneous requests pass the limit", async () => { + const results = await Promise.all( + Array.from({ length: 8 }, () => create(A)), + ); + expect(results.filter((result) => result.status === 200)).toHaveLength(5); + expect(rows()).toHaveLength(5); + }); + + it("frees a place when an endpoint is deleted", async () => { + const ids: string[] = []; + for (let i = 0; i < 5; i++) ids.push((await create(A)).body.data.id); + expect((await send("DELETE", `/${ids[0]}`, A)).status).toBe(200); + expect((await create(A)).status).toBe(200); + }); +}); + +describe("reading and changing webhook endpoints", () => { + it("lists an app's endpoints newest first with pagination", async () => { + const first = (await create(A)).body.data.id; + const second = (await create(A)).body.data.id; + const list = await (await send("GET", "/?limit=1", A)).json(); + expect(list.data.map((row: { id: string }) => row.id)).toEqual([second]); + expect(list.pagination).toEqual({ limit: 1, offset: 0, hasMore: true }); + const next = await (await send("GET", "/?limit=1&offset=1", A)).json(); + expect(next.data.map((row: { id: string }) => row.id)).toEqual([first]); + expect(next.pagination.hasMore).toBe(false); + }); + + it("refuses an unknown query parameter on the list", async () => { + const response = await send("GET", "/?userId=u1", A); + expect(response.status).toBe(400); + expect(await response.json()).toMatchObject({ code: "unknown_parameter" }); + }); + + it("updates url, events and enabled", async () => { + const { id } = (await create(A)).body.data; + const response = await send("PATCH", `/${id}`, A, { + url: "https://other.example.com/hook", + events: ["video.ready"], + enabled: false, + }); + expect(response.status).toBe(200); + expect((await response.json()).data).toMatchObject({ + url: "https://other.example.com/hook", + events: ["video.ready"], + enabled: false, + }); + }); + + it("refuses an update with nothing in it, an unknown field or a bad URL", async () => { + const { id } = (await create(A)).body.data; + expect((await send("PATCH", `/${id}`, A, {})).status).toBe(400); + expect((await send("PATCH", `/${id}`, A, { secret: "x" })).status).toBe( + 400, + ); + const bad = await send("PATCH", `/${id}`, A, { + url: "https://127.0.0.1/x", + }); + expect(bad.status).toBe(400); + expect(await bad.json()).toMatchObject({ code: "invalid_webhook_url" }); + expect(rows()[0]?.url).toBe("https://hooks.example.com/cap"); + }); + + it("turns a disabled endpoint back on with a clean failure streak", async () => { + const { id } = (await create(A)).body.data; + Object.assign(rows()[0] ?? {}, { + enabled: false, + disabledReason: "delivery_failures", + consecutiveFailures: 31, + failingSince: new Date("2026-11-01T00:00:00Z"), + }); + const response = await send("PATCH", `/${id}`, A, { enabled: true }); + expect((await response.json()).data).toMatchObject({ + enabled: true, + disabledReason: null, + consecutiveFailures: 0, + }); + expect(rows()[0]?.failingSince).toBeNull(); + }); + + it("deletes an endpoint, and a second delete still succeeds", async () => { + const { id } = (await create(A)).body.data; + expect(await (await send("DELETE", `/${id}`, A)).json()).toEqual({ + success: true, + }); + expect((await send("GET", `/${id}`, A)).status).toBe(404); + expect((await send("DELETE", `/${id}`, A)).status).toBe(200); + expect((await send("GET", "/", A)).status).toBe(200); + expect((await (await send("GET", "/", A)).json()).data).toEqual([]); + }); +}); + +describe("one app cannot reach another app's endpoints", () => { + it("answers not found for every call made with another app's key", async () => { + const { id } = (await create(A)).body.data; + context.request = async () => new Response(null, { status: 200 }); + + const attempts = [ + await send("GET", `/${id}`, B), + await send("PATCH", `/${id}`, B, { enabled: false }), + await send("PATCH", `/${id}`, B, { url: "https://evil.example.com/x" }), + await send("POST", `/${id}/rotate-secret`, B), + await send("DELETE", `/${id}`, B), + await send("POST", `/${id}/test`, B), + ]; + for (const response of attempts) { + expect(response.status).toBe(404); + expect(await response.json()).toMatchObject({ + code: "webhook_not_found", + }); + } + + expect((await (await send("GET", "/", B)).json()).data).toEqual([]); + expect(rows()[0]).toMatchObject({ + id, + appId: "app-a", + url: "https://hooks.example.com/cap", + enabled: true, + deletedAt: null, + lastDeliveryAt: null, + }); + expect((await send("GET", `/${id}`, A)).status).toBe(200); + }); + + it("refuses a call with no valid key", async () => { + const { id } = (await create(A)).body.data; + for (const [method, path] of [ + ["GET", "/"], + ["GET", `/${id}`], + ["PATCH", `/${id}`], + ["DELETE", `/${id}`], + ["POST", `/${id}/rotate-secret`], + ["POST", `/${id}/test`], + ["POST", "/"], + ] as const) { + expect((await send(method, path, {})).status).toBe(401); + } + }); +}); + +describe("rotating the signing secret", () => { + it("returns a new secret once, stores it encrypted and signs with it from then on", async () => { + const { id, secret: oldSecret } = (await create(A)).body.data; + const response = await send("POST", `/${id}/rotate-secret`, A); + expect(response.status).toBe(200); + const { data } = await response.json(); + expect(data.secret).toMatch(/^cws_[0-9a-z]{30}$/); + expect(data.secret).not.toBe(oldSecret); + expect(data).toMatchObject({ id, secretPrefix: data.secret.slice(0, 12) }); + + const [row] = rows(); + expect(await decrypt(String(row?.encryptedSecret))).toBe(data.secret); + for (const later of [ + await send("GET", `/${id}`, A), + await send("GET", "/", A), + ]) { + const text = await later.text(); + expect(text).not.toContain(data.secret); + expect(text).not.toContain(oldSecret); + } + + const seen: RequestInit[] = []; + context.request = async (_url, init) => { + seen.push(init); + return new Response(null, { status: 204 }); + }; + await send("POST", `/${id}/test`, A); + const header = new Headers(seen[0]?.headers).get("cap-signature"); + const body = String(seen[0]?.body); + expect(verifyWebhookSignature({ secret: data.secret, body, header })).toBe( + true, + ); + expect(verifyWebhookSignature({ secret: oldSecret, body, header })).toBe( + false, + ); + }); + + it("keeps the endpoint's settings and delivery state", async () => { + const { id } = (await create(A)).body.data; + await send("PATCH", `/${id}`, A, { + url: "https://hooks.example.com/other", + events: ["video.errored"], + enabled: false, + }); + const before = rows()[0]; + await send("POST", `/${id}/rotate-secret`, A); + expect(rows()[0]).toMatchObject({ + url: before?.url, + events: before?.events, + enabled: false, + consecutiveFailures: before?.consecutiveFailures, + }); + }); + + it("answers not found for a deleted endpoint", async () => { + const { id } = (await create(A)).body.data; + await send("DELETE", `/${id}`, A); + const encrypted = rows()[0]?.encryptedSecret; + const response = await send("POST", `/${id}/rotate-secret`, A); + expect(response.status).toBe(404); + expect(rows()[0]?.encryptedSecret).toBe(encrypted); + }); +}); + +describe("sending a test event", () => { + it("sends one signed webhook.test event and reports what came back", async () => { + const { id, secret } = (await create(A)).body.data; + const seen: { url: string; init: RequestInit }[] = []; + context.request = async (url, init) => { + seen.push({ url, init }); + return new Response("never shown to the caller", { status: 200 }); + }; + + const response = await send("POST", `/${id}/test`, A); + expect(response.status).toBe(200); + const text = await response.text(); + expect(text).not.toContain("never shown"); + const { data } = JSON.parse(text); + expect(data).toMatchObject({ + type: "webhook.test", + delivered: true, + responseStatus: 200, + errorCode: null, + }); + expect(data.eventId).toMatch(/^evt_test_/); + + expect(seen).toHaveLength(1); + const headers = new Headers(seen[0]?.init.headers); + const body = String(seen[0]?.init.body); + expect(JSON.parse(body)).toMatchObject({ + id: data.eventId, + type: "webhook.test", + data: { webhookId: id }, + }); + expect(headers.get("cap-event-type")).toBe("webhook.test"); + expect( + verifyWebhookSignature({ + secret, + body, + header: headers.get("cap-signature"), + }), + ).toBe(true); + expect(rows()[0]).toMatchObject({ + lastDeliveryStatus: "succeeded", + lastResponseStatus: 200, + }); + }); + + it("reports a failing endpoint without counting it towards disabling", async () => { + const { id } = (await create(A)).body.data; + context.request = async () => new Response("boom", { status: 500 }); + const { data } = await (await send("POST", `/${id}/test`, A)).json(); + expect(data).toMatchObject({ + delivered: false, + responseStatus: 500, + errorCode: "http_error", + }); + expect(rows()[0]).toMatchObject({ + consecutiveFailures: 0, + failingSince: null, + lastDeliveryStatus: "failed", + }); + }); + + it("sends nothing when the URL now resolves to a private address", async () => { + const { id } = (await create(A)).body.data; + context.resolved = ["10.0.0.9"]; + const { data } = await (await send("POST", `/${id}/test`, A)).json(); + expect(data).toMatchObject({ + delivered: false, + errorCode: "private_address", + }); + }); +}); diff --git a/apps/web/__tests__/unit/developer-webhooks-auth.test.ts b/apps/web/__tests__/unit/developer-webhooks-auth.test.ts new file mode 100644 index 00000000000..4b0c3d87895 --- /dev/null +++ b/apps/web/__tests__/unit/developer-webhooks-auth.test.ts @@ -0,0 +1,175 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + queries: 0, + keyRows: [] as { appId: string }[], + store: { + createWebhookEndpoint: vi.fn(), + deleteWebhookEndpoint: vi.fn(), + getWebhookEndpoint: vi.fn(), + listWebhookEndpoints: vi.fn(), + rotateWebhookEndpointSecret: vi.fn(), + sendWebhookTestEvent: vi.fn(), + updateWebhookEndpoint: vi.fn(), + }, +})); + +vi.mock("@cap/database", () => { + const chain = { + select: () => { + mocks.queries += 1; + return chain; + }, + from: () => chain, + innerJoin: () => chain, + leftJoin: () => chain, + where: () => chain, + limit: async () => mocks.keyRows, + update: () => { + mocks.queries += 1; + return chain; + }, + set: () => chain, + catch: () => chain, + }; + return { db: () => chain }; +}); +vi.mock("@cap/database/auth/session", () => ({ getCurrentUser: vi.fn() })); +vi.mock("@cap/database/auth/domain-utils", () => ({ + isBlockedAccountEmail: () => false, +})); +vi.mock("@cap/database/auth/request-site", () => ({ + isCrossSiteCookieWrite: () => false, +})); +vi.mock("@cap/env", () => ({ + NODE_ENV: "production", + buildEnv: { NEXT_PUBLIC_WEB_URL: "https://cap.so" }, +})); +vi.mock("@/lib/developer-key-hash", () => ({ + hashKey: async (key: string) => `hash:${key}`, +})); +vi.mock("@/lib/developer-webhooks", () => ({ + ...mocks.store, + serializeWebhookEndpoint: (endpoint: unknown) => endpoint, + serializeWebhookTestResult: (eventId: string) => ({ eventId }), +})); + +const { app } = await import("@/app/api/developer/v1/[...route]/webhooks"); + +const ROUTES = [ + ["GET", "/"], + ["POST", "/"], + ["GET", "/wh-1"], + ["PATCH", "/wh-1"], + ["DELETE", "/wh-1"], + ["POST", "/wh-1/rotate-secret"], + ["POST", "/wh-1/test"], +] as const; + +const call = (method: string, path: string, headers: Record) => + app.request(path, { + method, + headers: { "Content-Type": "application/json", ...headers }, + body: + method === "POST" || method === "PATCH" + ? JSON.stringify({ url: "https://hooks.example.com/cap" }) + : undefined, + }); + +beforeEach(() => { + mocks.queries = 0; + mocks.keyRows = [{ appId: "app-1" }]; + for (const mock of Object.values(mocks.store)) mock.mockReset(); +}); + +describe("webhook routes and a bad key", () => { + it.each([ + ["no key", {}, 401, "invalid_secret_key"], + [ + "a public key", + { Authorization: "Bearer cpk_public_key" }, + 403, + "public_key_not_allowed", + ], + [ + "an upload token", + { Authorization: "Bearer cut_upload_token" }, + 401, + "invalid_secret_key", + ], + [ + "an agent token", + { Authorization: "Bearer cap_cli_token" }, + 401, + "invalid_secret_key", + ], + [ + "a signing secret", + { Authorization: "Bearer cws_signing_secret" }, + 401, + "invalid_secret_key", + ], + [ + "a malformed header", + { Authorization: "csk_valid_key" }, + 401, + "invalid_secret_key", + ], + ])( + "refuses %s without a single database query", + async (_label, headers, status, code) => { + for (const [method, path] of ROUTES) { + const response = await call(method, path, headers); + expect(response.status).toBe(status); + expect(await response.json()).toMatchObject({ code }); + } + expect(mocks.queries).toBe(0); + for (const mock of Object.values(mocks.store)) { + expect(mock).not.toHaveBeenCalled(); + } + }, + ); + + it("refuses an unknown or revoked secret key before reaching any endpoint", async () => { + mocks.keyRows = []; + for (const [method, path] of ROUTES) { + const response = await call(method, path, { + Authorization: "Bearer csk_revoked", + }); + expect(response.status).toBe(401); + } + expect(mocks.queries).toBe(ROUTES.length); + for (const mock of Object.values(mocks.store)) { + expect(mock).not.toHaveBeenCalled(); + } + }); + + it("passes the app that owns the key to every call", async () => { + mocks.keyRows = [{ appId: "app-from-key" }]; + mocks.store.listWebhookEndpoints.mockResolvedValue({ + endpoints: [], + hasMore: false, + }); + mocks.store.getWebhookEndpoint.mockResolvedValue(null); + const missing = { + ok: false, + status: 404, + code: "webhook_not_found", + message: "Webhook endpoint not found", + hint: "", + }; + mocks.store.createWebhookEndpoint.mockResolvedValue(missing); + mocks.store.updateWebhookEndpoint.mockResolvedValue(missing); + mocks.store.deleteWebhookEndpoint.mockResolvedValue(missing); + mocks.store.rotateWebhookEndpointSecret.mockResolvedValue(missing); + mocks.store.sendWebhookTestEvent.mockResolvedValue(missing); + + for (const [method, path] of ROUTES) { + await call(method, path, { Authorization: "Bearer csk_valid_key" }); + } + for (const mock of Object.values(mocks.store)) { + expect(mock).toHaveBeenCalledTimes(1); + expect(mock.mock.calls[0]?.[0]).toBe("app-from-key"); + } + }); +}); diff --git a/apps/web/__tests__/unit/s3-endpoint-policy.test.ts b/apps/web/__tests__/unit/s3-endpoint-policy.test.ts index 92b46116268..c6fcf21e065 100644 --- a/apps/web/__tests__/unit/s3-endpoint-policy.test.ts +++ b/apps/web/__tests__/unit/s3-endpoint-policy.test.ts @@ -61,6 +61,41 @@ describe("isPrivateAddress", () => { ])("allows %s", (address) => { expect(isPrivateAddress(address)).toBe(false); }); + + // The webhook ranges (documentation, 6to4, Teredo, discard, local NAT64) + // must not catch the public addresses storage providers answer with. + it.each([ + "3.5.140.2", + "52.92.16.1", + "104.16.0.1", + "162.159.140.1", + "172.67.1.1", + "34.120.0.1", + "2001:1::1", + "2001:db9::1", + "2003::1", + "2600:9000::1", + "2606:4700::1111", + "2a06:98c1::1", + "2a00:1450::1", + ])("still allows the public storage address %s", (address) => { + expect(isPrivateAddress(address)).toBe(false); + }); + + it.each([ + "192.0.2.1", + "192.88.99.1", + "198.51.100.1", + "203.0.113.1", + "64:ff9b:1::a00:1", + "100::1", + "2001::1", + "2001:0:ffff::1", + "2001:db8::1", + "2002:a9fe:a9fe::1", + ])("blocks the reserved address %s", (address) => { + expect(isPrivateAddress(address)).toBe(true); + }); }); describe("isBlockedS3Endpoint", () => { @@ -92,6 +127,43 @@ describe("isBlockedS3Endpoint", () => { ).resolves.toBe(true); }); + // Main refused a name when any address in its answer was private. That + // stays true for those ranges. The ranges added for webhooks only drop + // their own addresses, so the public one is still used. + it.each([ + ["a 6to4 AAAA", ["52.216.1.1", "2002:a00:1::1"]], + ["a Teredo AAAA", ["52.216.1.1", "2001:0:4136:e378::1"]], + ["a local-use NAT64 AAAA", ["52.216.1.1", "64:ff9b:1::a00:1"]], + ["a documentation address", ["203.0.113.9", "52.216.1.1"]], + ["a discard prefix AAAA", ["2600:1f18::1", "100::1"]], + ])("allows a public address next to %s", async (_label, addresses) => { + await expect( + isBlockedS3Endpoint("https://s3.example.com", resolveTo(addresses)), + ).resolves.toBe(false); + }); + + it.each([ + ["loopback", ["52.216.1.1", "127.0.0.1"]], + ["a private v4", ["52.216.1.1", "192.168.0.7"]], + ["unique local v6", ["2600:1f18::1", "fd00::1"]], + ["NAT64 of a private v4", ["52.216.1.1", "64:ff9b::a00:1"]], + ["a mapped private v4", ["52.216.1.1", "::ffff:10.0.0.1"]], + ["a reserved and a private address", ["2002:a00:1::1", "10.0.0.1"]], + ])("still blocks a public address next to %s", async (_label, addresses) => { + await expect( + isBlockedS3Endpoint("https://s3.example.com", resolveTo(addresses)), + ).resolves.toBe(true); + }); + + it("blocks a name whose only addresses are reserved", async () => { + await expect( + isBlockedS3Endpoint( + "https://s3.example.com", + resolveTo(["2002:a00:1::1", "192.0.2.1"]), + ), + ).resolves.toBe(true); + }); + it("blocks hostnames that do not resolve", async () => { await expect( isBlockedS3Endpoint("https://s3.example.com", async () => { diff --git a/apps/web/__tests__/unit/storage-fetch.test.ts b/apps/web/__tests__/unit/storage-fetch.test.ts index 685a88a900f..b48b210894c 100644 --- a/apps/web/__tests__/unit/storage-fetch.test.ts +++ b/apps/web/__tests__/unit/storage-fetch.test.ts @@ -135,6 +135,29 @@ describe("requestPublicOnly", () => { } }); + it("still refuses a body that is not text", async () => { + await expect( + requestPublicOnly(`http://localhost:${port}/`, { + method: "PUT", + body: new Blob(["x"]), + }), + ).rejects.toThrow("only send a text request body"); + }); + + it("refuses a private address written as a literal before connecting", async () => { + lookupState.allowLoopback = true; + try { + await expect( + requestPublicOnly(`http://127.0.0.1:${port}/`), + ).rejects.toBeInstanceOf(StorageFetchRefusedError); + await expect( + requestPublicOnly(`http://[::1]:${port}/`), + ).rejects.toBeInstanceOf(StorageFetchRefusedError); + } finally { + lookupState.allowLoopback = false; + } + }); + it("refuses non-http schemes", async () => { await expect(requestPublicOnly("file:///etc/hosts")).rejects.toBeInstanceOf( StorageFetchRefusedError, diff --git a/apps/web/app/api/developer/v1/[...route]/app.ts b/apps/web/app/api/developer/v1/[...route]/app.ts index ae4eeeae394..0f32115792f 100644 --- a/apps/web/app/api/developer/v1/[...route]/app.ts +++ b/apps/web/app/api/developer/v1/[...route]/app.ts @@ -11,6 +11,7 @@ import * as transcripts from "./transcripts"; import * as uploads from "./uploads"; import * as usage from "./usage"; import * as videos from "./videos"; +import * as webhooks from "./webhooks"; export const app = new Hono() .basePath("/api/developer/v1") @@ -21,6 +22,7 @@ export const app = new Hono() .route("/transcripts", transcripts.app) .route("/usage", usage.app) .route("/transactions", transactions.app) + .route("/webhooks", webhooks.app) .onError(developerApiError); app.notFound((c) => developerApiNotFound(c, app.routes)); diff --git a/apps/web/app/api/developer/v1/[...route]/webhooks.ts b/apps/web/app/api/developer/v1/[...route]/webhooks.ts new file mode 100644 index 00000000000..ce6d78bae4b --- /dev/null +++ b/apps/web/app/api/developer/v1/[...route]/webhooks.ts @@ -0,0 +1,158 @@ +import { Hono } from "hono"; +import { z } from "zod"; +import { apiErrorBody } from "@/lib/api-errors"; +import { parseDeveloperListQuery } from "@/lib/developer-pagination"; +import { WEBHOOK_EVENT_TYPES } from "@/lib/developer-webhook-events"; +import { + createWebhookEndpoint, + deleteWebhookEndpoint, + getWebhookEndpoint, + listWebhookEndpoints, + rotateWebhookEndpointSecret, + sendWebhookTestEvent, + serializeWebhookEndpoint, + serializeWebhookTestResult, + updateWebhookEndpoint, + type WebhookFailure, +} from "@/lib/developer-webhooks"; +import { developerZValidator } from "@/utils/zod-validator"; +import { withDeveloperSecretAuth } from "../../../utils"; + +export const app = new Hono<{ + Variables: { + developerAppId: string; + developerKeyType: "secret"; + }; +}>().use(withDeveloperSecretAuth); + +const failureBody = (failure: WebhookFailure) => + apiErrorBody(failure.code, failure.message, failure.hint); + +const endpointFields = { + url: z.string().min(1).max(2048), + events: z + .array(z.enum(WEBHOOK_EVENT_TYPES)) + .min(1) + .refine((types) => new Set(types).size === types.length, { + message: "Each event type can be listed once", + }), + enabled: z.boolean(), +}; + +app.post( + "/", + developerZValidator( + "json", + z.strictObject({ + url: endpointFields.url, + events: endpointFields.events.optional(), + enabled: endpointFields.enabled.optional(), + }), + ), + async (c) => { + const result = await createWebhookEndpoint( + c.get("developerAppId"), + c.req.valid("json"), + ); + if (!result.ok) return c.json(failureBody(result), result.status); + return c.json({ + data: { + ...serializeWebhookEndpoint(result.endpoint), + secret: result.secret, + }, + }); + }, +); + +app.get("/", async (c) => { + const parsed = parseDeveloperListQuery(c.req.query(), []); + if (!parsed.ok) { + return c.json(apiErrorBody(parsed.code, parsed.message, parsed.hint), 400); + } + const { limit, offset } = parsed; + const { endpoints, hasMore } = await listWebhookEndpoints( + c.get("developerAppId"), + { limit, offset }, + ); + return c.json({ + data: endpoints.map(serializeWebhookEndpoint), + pagination: { limit, offset, hasMore }, + }); +}); + +app.get("/:id", async (c) => { + const endpoint = await getWebhookEndpoint( + c.get("developerAppId"), + c.req.param("id"), + ); + if (!endpoint) { + return c.json( + apiErrorBody( + "webhook_not_found", + "Webhook endpoint not found", + "Check the endpoint ID and that it belongs to this app. Deleted endpoints are not returned.", + ), + 404, + ); + } + return c.json({ data: serializeWebhookEndpoint(endpoint) }); +}); + +app.patch( + "/:id", + developerZValidator( + "json", + z + .strictObject({ + url: endpointFields.url.optional(), + events: endpointFields.events.optional(), + enabled: endpointFields.enabled.optional(), + }) + .refine((fields) => Object.keys(fields).length > 0, { + message: "Send at least one of url, events or enabled", + }), + ), + async (c) => { + const result = await updateWebhookEndpoint( + c.get("developerAppId"), + c.req.param("id"), + c.req.valid("json"), + ); + if (!result.ok) return c.json(failureBody(result), result.status); + return c.json({ data: serializeWebhookEndpoint(result.endpoint) }); + }, +); + +app.delete("/:id", async (c) => { + const result = await deleteWebhookEndpoint( + c.get("developerAppId"), + c.req.param("id"), + ); + if (!result.ok) return c.json(failureBody(result), result.status); + return c.json({ success: true }); +}); + +app.post("/:id/rotate-secret", async (c) => { + const result = await rotateWebhookEndpointSecret( + c.get("developerAppId"), + c.req.param("id"), + ); + if (!result.ok) return c.json(failureBody(result), result.status); + return c.json({ + data: { + ...serializeWebhookEndpoint(result.endpoint), + secret: result.secret, + }, + }); +}); + +app.post("/:id/test", async (c) => { + const result = await sendWebhookTestEvent( + c.get("developerAppId"), + c.req.param("id"), + ); + if (!result.ok) return c.json(failureBody(result), result.status); + return c.json({ + data: serializeWebhookTestResult(result.eventId, result.result), + }); +}); diff --git a/apps/web/content/docs/api/rest-api.mdx b/apps/web/content/docs/api/rest-api.mdx index 69ff5983941..c2e81b3699d 100644 --- a/apps/web/content/docs/api/rest-api.mdx +++ b/apps/web/content/docs/api/rest-api.mdx @@ -1089,6 +1089,31 @@ Rows written in the same second have the same `createdAt`. They are ordered so e The console's Billing page shows the same rows and exports them all as CSV. +### Webhooks + +Have Cap call your server when a video is ready or has failed, instead of polling for it. Register an https URL for the app and Cap sends it a signed `POST` request for each event. The [Webhooks](/docs/api/webhooks) guide covers the events, the request Cap sends, how to verify its signature, and retries. + +| Request | What it does | +|---------|--------------| +| `POST /api/developer/v1/webhooks` | Create an endpoint. The response carries its signing secret, once | +| `GET /api/developer/v1/webhooks` | List the app's endpoints with `limit` and `offset` | +| `GET /api/developer/v1/webhooks/:id` | Read one endpoint and how Cap's last call to it went | +| `PATCH /api/developer/v1/webhooks/:id` | Change `url`, `events` or `enabled` | +| `DELETE /api/developer/v1/webhooks/:id` | Delete an endpoint | +| `POST /api/developer/v1/webhooks/:id/rotate-secret` | Replace the signing secret. The new one is returned once | +| `POST /api/developer/v1/webhooks/:id/test` | Send a test event and report what the endpoint answered | + +**Example request:** + +```bash +curl -X POST https://cap.so/api/developer/v1/webhooks \ + -H "Authorization: Bearer csk_your_secret_key_here" \ + -H "Content-Type: application/json" \ + -d '{"url": "https://example.com/webhooks/cap", "events": ["video.ready", "video.errored"]}' +``` + +The events are `video.ready` and `video.errored`, and each carries the video as [Get Video](/docs/api/rest-api#get-video) returns it. An app can have 5 endpoints. + --- ## SDK API Endpoints diff --git a/apps/web/content/docs/api/webhooks.mdx b/apps/web/content/docs/api/webhooks.mdx index 584e604d872..49f0e68a863 100644 --- a/apps/web/content/docs/api/webhooks.mdx +++ b/apps/web/content/docs/api/webhooks.mdx @@ -1,22 +1,297 @@ --- title: "Webhooks" -summary: "Current webhook availability for public Cap integrations" +summary: "Have Cap call your server when a video is ready or has failed" tags: "API, Integration" --- -Cap does not currently expose a documented public webhook subscription API for customer applications. +A webhook tells your server the moment a video finishes processing, so you do not have to poll for it. You register an https URL for your app, and Cap sends it a signed `POST` request when a video becomes ready or fails. -The repository contains internal webhook endpoints for services such as Stripe and the media-processing pipeline. Those are implementation endpoints, not a stable customer webhook product, and integrations should not call them. +Webhooks belong to an app in [Cap Infra](/dashboard/infra) and are managed with the [REST API](/docs/api/rest-api#webhooks) and the app's secret key (`csk_`). -## Poll supported API state +## Events -Use the [REST API](/docs/api/rest-api) to read the supported developer state. For an uploaded video, poll [Get Video Status](/docs/api/rest-api#get-video-status) until `status` is `ready` or `errored`. Present it as processed when it is `ready`. +| Type | Sent when | +|------|-----------| +| `video.ready` | A video has finished processing and can be played | +| `video.errored` | A video could not be processed. `errorReason` says why | +| `webhook.test` | You asked for a [test event](#send-a-test-event) | -Keep polling bounded: +A video sends `video.ready` or `video.errored` once. Handlers should ignore a type they do not know, so a new event type never breaks them. -- Use backoff instead of a tight loop. -- Stop on terminal success or error. -- Store the last observed state locally. -- Respect HTTP errors and any retry guidance returned by the API. +On a ready video, one of `hlsUrl` and `mp4Url` can still be `null` while that output finishes encoding. No second event is sent when it arrives. Read it with [Get Video](/docs/api/rest-api#get-video). -Do not build against an undocumented route or assume that an internal webhook payload will remain stable. +## Create an endpoint + +``` +POST /api/developer/v1/webhooks +``` + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `url` | string | Yes | Where Cap sends events. See the rules below | +| `events` | string[] | No | The event types to send: `video.ready`, `video.errored` or both. Defaults to both | +| `enabled` | boolean | No | Defaults to `true` | + +A field the request does not know returns `400` with code `invalid_request`. + +**Example request:** + +```bash +curl -X POST https://cap.so/api/developer/v1/webhooks \ + -H "Authorization: Bearer csk_your_secret_key_here" \ + -H "Content-Type: application/json" \ + -d '{"url": "https://example.com/webhooks/cap"}' +``` + +**Example response:** + +```json +{ + "data": { + "id": "7k2m9x4q1p3v5wd", + "url": "https://example.com/webhooks/cap", + "events": ["video.ready", "video.errored"], + "enabled": true, + "disabledReason": null, + "secretPrefix": "cws_7m2k9x4q", + "lastDeliveryAt": null, + "lastDeliveryStatus": null, + "lastResponseStatus": null, + "lastErrorCode": null, + "consecutiveFailures": 0, + "createdAt": "2026-11-13T00:19:06.000Z", + "updatedAt": "2026-11-13T00:19:06.000Z", + "secret": "cws_your_signing_secret_here" + } +} +``` + +`secret` is the endpoint's signing secret. It is returned once, in this response, and never again. Store it with your other secrets. If you lose it or it leaks, [rotate it](#rotate-the-signing-secret). + +The URL must: + +- use `https` on the default port +- be at most 2,048 characters +- carry no username or password +- name a host that resolves to a public internet address. Private, loopback, link-local and reserved addresses are refused + +Anything else returns `400` with code `invalid_webhook_url`. An app can have 5 endpoints. One more returns `409` with code `webhook_limit_reached`. + +## Manage endpoints + +| Request | What it does | +|---------|--------------| +| `GET /api/developer/v1/webhooks` | List the app's endpoints, newest first, with `limit` and `offset` | +| `GET /api/developer/v1/webhooks/:id` | Read one endpoint and the result of its last delivery | +| `PATCH /api/developer/v1/webhooks/:id` | Change `url`, `events` or `enabled`. Send at least one | +| `DELETE /api/developer/v1/webhooks/:id` | Delete the endpoint. Deliveries waiting to be retried are dropped | +| `POST /api/developer/v1/webhooks/:id/rotate-secret` | [Rotate the signing secret](#rotate-the-signing-secret) | +| `POST /api/developer/v1/webhooks/:id/test` | [Send a test event](#send-a-test-event) | + +An ID that does not belong to the app returns `404` with code `webhook_not_found`. + +Every endpoint is returned with these fields: + +| Field | Type | Description | +|-------|------|-------------| +| `id` | string | The endpoint ID | +| `url` | string | Where events are sent | +| `events` | string[] | The event types sent to it | +| `enabled` | boolean | Events are only sent to an enabled endpoint | +| `disabledReason` | string \| null | `delivery_failures` when Cap disabled the endpoint, otherwise `null` | +| `secretPrefix` | string | The first characters of the signing secret, to tell endpoints apart | +| `lastDeliveryAt` | string \| null | When Cap last called the endpoint | +| `lastDeliveryStatus` | string \| null | `succeeded` or `failed` | +| `lastResponseStatus` | number \| null | The HTTP status your endpoint last answered with, or `null` when it did not answer | +| `lastErrorCode` | string \| null | Why the last delivery failed: `http_error`, `redirect`, `timeout`, `connection_failed` or `private_address` | +| `consecutiveFailures` | number | Failed attempts since the last success | +| `createdAt` | string | Creation timestamp (ISO 8601) | +| `updatedAt` | string | When the endpoint's settings last changed | + +## Rotate the signing secret + +```bash +curl -X POST https://cap.so/api/developer/v1/webhooks/7k2m9x4q1p3v5wd/rotate-secret \ + -H "Authorization: Bearer csk_your_secret_key" +``` + +The response is the endpoint with a new `secret`, returned once, like the one from create. The old secret stops working at once: every request from then on, including a retry of an earlier event, is signed with the new one. To rotate without dropping an event, have your receiver accept a signature from either secret, rotate, store the new secret, then remove the old one. + +## The request Cap sends + +``` +POST https://example.com/webhooks/cap +Content-Type: application/json +Cap-Signature: t=1794529214,v1=5f2b6c0e... +Cap-Event-Id: evt_2ef4c0e7d14b85e004ad32a8 +Cap-Event-Type: video.ready +``` + +```json +{ + "id": "evt_2ef4c0e7d14b85e004ad32a8", + "type": "video.ready", + "createdAt": "2026-11-13T00:20:14.211Z", + "data": { + "id": "abc123def456", + "appId": "app_789", + "externalUserId": "user_42", + "name": "Product Demo", + "status": "ready", + "errorReason": null, + "duration": 124.5, + "width": 1920, + "height": 1080, + "playbackId": "Zk3vQ9s2Lm8Rt1Xy7Bc4Df6G", + "hlsUrl": null, + "mp4Url": "https://cap.so/api/developer/playback/Zk3vQ9s2Lm8Rt1Xy7Bc4Df6G/video.mp4", + "posterUrl": "https://cap.so/api/developer/playback/Zk3vQ9s2Lm8Rt1Xy7Bc4Df6G/poster.jpg" + } +} +``` + +| Field | Description | +|-------|-------------| +| `id` | Identifies the event. Every retry, and the delivery to each of your endpoints, carries the same `id` | +| `type` | The event type | +| `createdAt` | When the event happened (ISO 8601) | +| `data` | For video events, the video exactly as [Get Video](/docs/api/rest-api#get-video) returns it. See the [Video Object Reference](/docs/api/rest-api#video-object-reference). The example above is shortened | + +`externalUserId` is the `userId` the video was created with, so the event tells you whose video it is. + +## Verify the signature + +Anyone who learns your URL can post to it, so check that each request came from Cap before you trust it. + +`Cap-Signature` holds a timestamp and a signature: `t=,v1=`. The signature is the hex HMAC-SHA256 of the timestamp, a dot, and the raw request body, keyed with the endpoint's signing secret (the whole `cws_...` string). + +1. Read `t` and `v1` from the header. +2. Compute HMAC-SHA256 of `.` with your signing secret. +3. Compare it with `v1` using a constant-time comparison. +4. Reject the request if `t` is more than five minutes from your clock, so a captured request cannot be replayed later. + +Sign the body exactly as it arrived, byte for byte. Parsing the JSON and serialising it again changes the bytes and the signature will not match. + +**Node.js:** + +```js +import { createHmac, timingSafeEqual } from "node:crypto"; + +export function verifyCapWebhook(rawBody, signatureHeader, secret) { + const parts = Object.fromEntries( + String(signatureHeader ?? "") + .split(",") + .map((part) => part.split("=")), + ); + const timestamp = Number(parts.t); + if (!Number.isInteger(timestamp) || !parts.v1) return false; + if (Math.abs(Date.now() / 1000 - timestamp) > 300) return false; + const expected = createHmac("sha256", secret) + .update(`${parts.t}.${rawBody}`) + .digest(); + const received = Buffer.from(parts.v1, "hex"); + return ( + received.length === expected.length && timingSafeEqual(received, expected) + ); +} +``` + +Call it with the raw body string, the `Cap-Signature` header and your signing secret, and answer `400` when it returns `false`. + +**Python:** + +```python +import hashlib +import hmac +import time + + +def verify_cap_webhook(raw_body: bytes, signature_header: str, secret: str) -> bool: + parts = dict( + part.split("=", 1) for part in signature_header.split(",") if "=" in part + ) + try: + timestamp = int(parts["t"]) + received = parts["v1"] + except (KeyError, ValueError): + return False + if abs(time.time() - timestamp) > 300: + return False + expected = hmac.new( + secret.encode(), f"{timestamp}.".encode() + raw_body, hashlib.sha256 + ).hexdigest() + return hmac.compare_digest(expected, received) +``` + +## Respond + +Answer with any `2xx` status within 10 seconds. Do the slow work after you have answered: store the event or put it on a queue, respond, then process it. + +Any other status, a redirect, or no answer within 10 seconds counts as a failed delivery. Redirects are never followed, so register the final URL. Cap does not read or store your response body. + +## Retries + +A failed delivery is tried again with growing gaps: + +| Attempt | Sent | +|---------|------| +| 1 | Straight away | +| 2 | 1 minute after attempt 1 failed | +| 3 | 5 minutes later | +| 4 | 30 minutes later | +| 5 | 2 hours later | +| 6 | 5 hours later | +| 7 | 8 hours later | +| 8 | 8 hours later | + +That is 8 attempts over about a day. After the last one the event is dropped. + +Every attempt carries the same event `id` and the same body, with a fresh timestamp and signature. An event can reach you more than once, for example when your answer was lost on the way back, so remember the ids you have handled and skip one you have seen. Events for different videos can arrive in any order. + +An endpoint that fails 20 attempts in a row over five days or more is disabled, and its `disabledReason` becomes `delivery_failures`. Once it is fixed, turn it back on: + +```bash +curl -X PATCH https://cap.so/api/developer/v1/webhooks/7k2m9x4q1p3v5wd \ + -H "Authorization: Bearer csk_your_secret_key_here" \ + -H "Content-Type: application/json" \ + -d '{"enabled": true}' +``` + +Events that happened while an endpoint was disabled or deleted are not sent afterwards. Use [Get Video Status](/docs/api/rest-api#get-video-status) to catch up on a video you are waiting for. + +## Send a test event + +Check an endpoint before you rely on it: + +```bash +curl -X POST https://cap.so/api/developer/v1/webhooks/7k2m9x4q1p3v5wd/test \ + -H "Authorization: Bearer csk_your_secret_key_here" +``` + +Cap sends one signed `webhook.test` event and waits up to 10 seconds for your answer: + +```json +{ + "data": { + "eventId": "evt_test_8bs22qpm4mbqh8p", + "type": "webhook.test", + "delivered": true, + "responseStatus": 200, + "errorCode": null, + "durationMs": 42 + } +} +``` + +`delivered` is `true` when your endpoint answered with a `2xx` status. Otherwise `errorCode` says what went wrong. A test event is sent once and never retried, it works on a disabled endpoint, and a failed test does not count towards disabling the endpoint. + +The test event's body looks like any other event, with `data` holding the endpoint ID: + +```json +{ + "id": "evt_test_8bs22qpm4mbqh8p", + "type": "webhook.test", + "createdAt": "2026-11-13T00:19:06.412Z", + "data": { "webhookId": "7k2m9x4q1p3v5wd" } +} +``` diff --git a/apps/web/lib/developer-openapi.ts b/apps/web/lib/developer-openapi.ts index 08a4bdf5859..d72554f54b7 100644 --- a/apps/web/lib/developer-openapi.ts +++ b/apps/web/lib/developer-openapi.ts @@ -14,6 +14,13 @@ import { transcriptFailureCodesText, transcriptRequestErrorsText, } from "@/lib/developer-transcript-errors"; +import { + MAX_WEBHOOK_ENDPOINTS_PER_APP, + WEBHOOK_DISABLE_AFTER_FAILURES, + WEBHOOK_EVENT_TYPES, + WEBHOOK_MAX_ATTEMPTS, + WEBHOOK_TEST_EVENT_TYPE, +} from "@/lib/developer-webhook-events"; import { INFRA_MCP_PATH } from "@/lib/infra-mcp-path"; import { INFRA_RESOLUTION_TIERS } from "@/lib/infra-pricing"; @@ -230,6 +237,97 @@ const transcriptSchema = { }, }; +const webhookIdParam = { + name: "id", + in: "path", + required: true, + description: "Webhook endpoint ID returned by createWebhookEndpoint.", + schema: { type: "string" }, +}; + +const WEBHOOK_URL_RULES = + "The URL must be https on the default port, at most 2,048 characters, with no username or password, and its host must resolve to a public address. Anything else returns 400 with code invalid_webhook_url."; + +const webhookEventsProperty = { + type: "array", + minItems: 1, + uniqueItems: true, + items: { type: "string", enum: [...WEBHOOK_EVENT_TYPES] }, + description: "The event types sent to this endpoint.", +}; + +const WEBHOOK_VERIFY_NOTE = + "Verify Cap-Signature before trusting the body, and use the event id to ignore a delivery you have already handled."; + +const videoEventDescription = (type: string) => + `Cap sends this request to every enabled endpoint subscribed to ${type}. Answer with any 2xx status within 10 seconds. Any other answer, a redirect or no answer is retried, up to ${WEBHOOK_MAX_ATTEMPTS} attempts over about a day. ${WEBHOOK_VERIFY_NOTE}`; + +const webhookDelivery = ( + type: string, + summary: string, + data: object, + description: string = videoEventDescription(type), +) => ({ + post: { + operationId: `receive${type + .split(".") + .map((word) => `${word.slice(0, 1).toUpperCase()}${word.slice(1)}`) + .join("")}`, + summary, + description, + tags: ["Webhooks"], + security: [], + parameters: [ + { + name: "Cap-Signature", + in: "header", + required: true, + description: + 't=,v1=." keyed with the endpoint\'s signing secret>. Reject a timestamp more than five minutes old.', + schema: { type: "string" }, + }, + { + name: "Cap-Event-Id", + in: "header", + required: true, + description: "The event's id, the same value as id in the body.", + schema: { type: "string" }, + }, + { + name: "Cap-Event-Type", + in: "header", + required: true, + description: "The event's type, the same value as type in the body.", + schema: { type: "string" }, + }, + ], + requestBody: { + required: true, + content: { + "application/json": { + schema: { + type: "object", + required: ["id", "type", "createdAt", "data"], + properties: { + id: { + type: "string", + description: + "Identifies the event. A retry, and a delivery to another endpoint, carry the same id.", + }, + type: { type: "string", const: type }, + createdAt: { type: "string", format: "date-time" }, + data, + }, + }, + }, + }, + }, + responses: { + "2XX": { description: "The event was received." }, + }, + }, +}); + const secretAuth = [{ secretKey: [] }]; const uploadTokenAuth = [{ uploadToken: [] }]; @@ -488,7 +586,34 @@ export const developerOpenApiSpec = (baseUrl: string) => ({ name: "SDK", description: "Upload a video's file from the browser (upload token).", }, + { + name: "Webhooks", + description: + "Have Cap call your server when a video is ready or has failed (secret key).", + }, ], + webhooks: { + "video.ready": webhookDelivery( + "video.ready", + "A video is ready to play", + ref("DeveloperVideo"), + ), + "video.errored": webhookDelivery( + "video.errored", + "A video could not be processed", + ref("DeveloperVideo"), + ), + [WEBHOOK_TEST_EVENT_TYPE]: webhookDelivery( + WEBHOOK_TEST_EVENT_TYPE, + "A test event sent by sendWebhookTestEvent", + { + type: "object", + required: ["webhookId"], + properties: { webhookId: { type: "string" } }, + }, + `Cap sends this request once, to the one endpoint named in sendWebhookTestEvent, whether or not that endpoint is enabled. It is never retried: what the endpoint answers within 10 seconds is returned by sendWebhookTestEvent. ${WEBHOOK_VERIFY_NOTE}`, + ), + }, paths: { "/api/developer/v1/videos": { get: { @@ -833,6 +958,141 @@ export const developerOpenApiSpec = (baseUrl: string) => ({ }, }, }, + "/api/developer/v1/webhooks": { + get: { + operationId: "listWebhookEndpoints", + summary: "List webhook endpoints", + description: + "Lists the app's webhook endpoints, newest first. Signing secrets are never returned here. An unknown query parameter or an out-of-range limit or offset returns 400.", + tags: ["Webhooks"], + security: secretAuth, + parameters: [ + { + name: "limit", + in: "query", + required: false, + description: "Maximum endpoints to return.", + schema: { type: "integer", minimum: 1, maximum: 100, default: 50 }, + }, + { + name: "offset", + in: "query", + required: false, + description: "Number of endpoints to skip.", + schema: { type: "integer", minimum: 0, default: 0 }, + }, + ], + responses: { + "200": json(ref("WebhookEndpointList"), "The app's endpoints."), + "400": response("BadRequest"), + ...restErrors, + }, + }, + post: { + operationId: "createWebhookEndpoint", + summary: "Create a webhook endpoint", + description: `Registers a URL that Cap calls when a video is ready or has failed. The response carries the endpoint's signing secret: store it, because it is never returned again. ${WEBHOOK_URL_RULES} An app can have ${MAX_WEBHOOK_ENDPOINTS_PER_APP} endpoints; one more returns 409 with code webhook_limit_reached. A field the request does not know returns 400 with code invalid_request.`, + tags: ["Webhooks"], + security: secretAuth, + requestBody: jsonBody("CreateWebhookEndpointRequest"), + responses: { + "200": json( + dataEnvelope(ref("WebhookEndpointWithSecret")), + "The created endpoint and its signing secret.", + ), + "400": response("BadRequest"), + "409": response("WebhookLimitReached"), + "415": response("UnsupportedMediaType"), + ...restErrors, + }, + }, + }, + "/api/developer/v1/webhooks/{id}": { + get: { + operationId: "getWebhookEndpoint", + summary: "Get a webhook endpoint", + description: + "Returns one endpoint owned by the app, with the result of its last delivery.", + tags: ["Webhooks"], + security: secretAuth, + parameters: [webhookIdParam], + responses: { + "200": json(dataEnvelope(ref("WebhookEndpoint")), "The endpoint."), + "404": response("WebhookNotFound"), + ...restErrors, + }, + }, + patch: { + operationId: "updateWebhookEndpoint", + summary: "Update a webhook endpoint", + description: `Changes an endpoint's URL, its event types or whether it is enabled. Send at least one of them. Setting enabled to true turns an endpoint back on after Cap disabled it and clears its failure count. The signing secret does not change; use rotateWebhookSecret for that. ${WEBHOOK_URL_RULES}`, + tags: ["Webhooks"], + security: secretAuth, + parameters: [webhookIdParam], + requestBody: jsonBody("UpdateWebhookEndpointRequest"), + responses: { + "200": json( + dataEnvelope(ref("WebhookEndpoint")), + "The updated endpoint.", + ), + "400": response("BadRequest"), + "404": response("WebhookNotFound"), + "415": response("UnsupportedMediaType"), + ...restErrors, + }, + }, + delete: { + operationId: "deleteWebhookEndpoint", + summary: "Delete a webhook endpoint", + description: + "Deletes an endpoint. Deliveries still waiting to be retried are dropped. Deleting an endpoint this app already deleted returns success, so the call is safe to repeat; an ID that never belonged to this app returns 404.", + tags: ["Webhooks"], + security: secretAuth, + parameters: [webhookIdParam], + responses: { + "200": json(ref("Success"), "The endpoint was deleted."), + "404": response("WebhookNotFound"), + ...restErrors, + }, + }, + }, + "/api/developer/v1/webhooks/{id}/rotate-secret": { + post: { + operationId: "rotateWebhookSecret", + summary: "Rotate a webhook signing secret", + description: + "Replaces the endpoint's signing secret and returns the new one. Store it, because it is never returned again. The old secret stops working at once: every delivery from this moment, including a retry of an earlier event, is signed with the new secret. Deploy a receiver that accepts both secrets first, rotate, then remove the old one.", + tags: ["Webhooks"], + security: secretAuth, + parameters: [webhookIdParam], + responses: { + "200": json( + dataEnvelope(ref("WebhookEndpointWithSecret")), + "The endpoint and its new signing secret.", + ), + "404": response("WebhookNotFound"), + ...restErrors, + }, + }, + }, + "/api/developer/v1/webhooks/{id}/test": { + post: { + operationId: "sendWebhookTestEvent", + summary: "Send a test event", + description: `Sends one signed ${WEBHOOK_TEST_EVENT_TYPE} event to the endpoint and waits up to 10 seconds for the answer. It is sent once and never retried, it works on a disabled endpoint, and a failure does not count towards disabling the endpoint. The response says whether the endpoint answered with a 2xx status. Nothing your endpoint returns in its body is read or stored.`, + tags: ["Webhooks"], + security: secretAuth, + parameters: [webhookIdParam], + responses: { + "200": json( + dataEnvelope(ref("WebhookTestResult")), + "What the endpoint answered.", + ), + "404": response("WebhookNotFound"), + ...restErrors, + }, + }, + }, "/api/developer/sdk/v1/upload/multipart/initiate": { post: { operationId: "initiateSdkUpload", @@ -1742,6 +2002,167 @@ export const developerOpenApiSpec = (baseUrl: string) => ({ uploadId: { type: "string" }, }, }, + WebhookEndpoint: { + type: "object", + required: [ + "id", + "url", + "events", + "enabled", + "disabledReason", + "secretPrefix", + "lastDeliveryAt", + "lastDeliveryStatus", + "lastResponseStatus", + "lastErrorCode", + "consecutiveFailures", + "createdAt", + "updatedAt", + ], + properties: { + id: { type: "string" }, + url: { type: "string", format: "uri" }, + events: webhookEventsProperty, + enabled: { + type: "boolean", + description: "Events are only sent to an enabled endpoint.", + }, + disabledReason: { + type: ["string", "null"], + enum: ["delivery_failures", null], + description: `delivery_failures when Cap disabled the endpoint after at least ${WEBHOOK_DISABLE_AFTER_FAILURES} failed attempts in a row over five days or more. null otherwise.`, + }, + secretPrefix: { + type: "string", + description: + "The first characters of the signing secret, to tell endpoints apart. The secret itself is only returned when the endpoint is created.", + }, + lastDeliveryAt: { + type: ["string", "null"], + format: "date-time", + description: "When Cap last called the endpoint.", + }, + lastDeliveryStatus: { + type: ["string", "null"], + enum: ["succeeded", "failed", null], + }, + lastResponseStatus: { + type: ["integer", "null"], + description: + "The HTTP status the endpoint last answered with, or null when it did not answer.", + }, + lastErrorCode: { + type: ["string", "null"], + enum: [ + "http_error", + "redirect", + "timeout", + "connection_failed", + "private_address", + "invalid_url", + "https_required", + "credentials_in_url", + "unsupported_port", + null, + ], + description: + "Why the last delivery failed: http_error for a status outside 2xx, redirect for a 3xx (redirects are not followed), timeout for no answer in 10 seconds, connection_failed when the connection or TLS handshake failed, private_address when the host resolved to a private or reserved address. null after a success.", + }, + consecutiveFailures: { + type: "integer", + description: "Failed attempts since the last success.", + }, + createdAt: { type: "string", format: "date-time" }, + updatedAt: { + type: "string", + format: "date-time", + description: + "When the endpoint's settings last changed. Deliveries do not move it.", + }, + }, + }, + WebhookEndpointWithSecret: { + allOf: [ + ref("WebhookEndpoint"), + { + type: "object", + required: ["secret"], + properties: { + secret: { + type: "string", + description: + "The signing secret, starting with cws_. Returned once, when the endpoint is created.", + }, + }, + }, + ], + }, + WebhookEndpointList: { + type: "object", + required: ["data", "pagination"], + properties: { + data: { type: "array", items: ref("WebhookEndpoint") }, + pagination: { + type: "object", + required: ["limit", "offset", "hasMore"], + properties: { + limit: { type: "integer" }, + offset: { type: "integer" }, + hasMore: { type: "boolean" }, + }, + }, + }, + }, + CreateWebhookEndpointRequest: { + type: "object", + additionalProperties: false, + required: ["url"], + properties: { + url: { type: "string", format: "uri", maxLength: 2048 }, + events: { + ...webhookEventsProperty, + description: + "The event types to send. Leave it out to receive every type.", + }, + enabled: { type: "boolean", default: true }, + }, + }, + UpdateWebhookEndpointRequest: { + type: "object", + additionalProperties: false, + minProperties: 1, + properties: { + url: { type: "string", format: "uri", maxLength: 2048 }, + events: webhookEventsProperty, + enabled: { type: "boolean" }, + }, + }, + WebhookTestResult: { + type: "object", + required: [ + "eventId", + "type", + "delivered", + "responseStatus", + "errorCode", + "durationMs", + ], + properties: { + eventId: { type: "string" }, + type: { type: "string", const: WEBHOOK_TEST_EVENT_TYPE }, + delivered: { + type: "boolean", + description: "true when the endpoint answered with a 2xx status.", + }, + responseStatus: { type: ["integer", "null"] }, + errorCode: { + type: ["string", "null"], + description: + "Why the delivery failed, with the same values as lastErrorCode. null when delivered.", + }, + durationMs: { type: "integer" }, + }, + }, }, responses: { BadRequest: json( @@ -1805,6 +2226,14 @@ export const developerOpenApiSpec = (baseUrl: string) => ({ ref("Error"), "Unexpected server error (code internal_error). Retry with backoff.", ), + WebhookNotFound: json( + ref("Error"), + "No webhook endpoint with this ID belongs to the app (code webhook_not_found).", + ), + WebhookLimitReached: json( + ref("Error"), + `The app already has ${MAX_WEBHOOK_ENDPOINTS_PER_APP} webhook endpoints (code webhook_limit_reached). Delete one first.`, + ), }, headers: { RateLimitLimit: { diff --git a/apps/web/lib/developer-video-processing.ts b/apps/web/lib/developer-video-processing.ts index 6773b0b9f86..72dfe7a3abb 100644 --- a/apps/web/lib/developer-video-processing.ts +++ b/apps/web/lib/developer-video-processing.ts @@ -57,6 +57,7 @@ import { internalDeveloperVideoUrl, readDeveloperVideoText, } from "./developer-video-storage"; +import { announceDeveloperVideoStatus } from "./developer-webhook-dispatch"; import { MEDIA_SERVER_SHUTDOWN_MESSAGE, readMediaServerVideoJob, @@ -123,6 +124,7 @@ async function mutateDeveloperVideo( .update(developerVideos) .set(updates) .where(eq(developerVideos.id, videoId)); + announceDeveloperVideoStatus(video, updates); return { ...video, ...updates } as DeveloperVideoRow; }; return executor ? run(executor) : db().transaction(run); diff --git a/apps/web/lib/developer-webhook-delivery.ts b/apps/web/lib/developer-webhook-delivery.ts new file mode 100644 index 00000000000..9d0dc09fe62 --- /dev/null +++ b/apps/web/lib/developer-webhook-delivery.ts @@ -0,0 +1,305 @@ +import { db } from "@cap/database"; +import { decrypt } from "@cap/database/crypto"; +import { developerApps, developerWebhookEndpoints } from "@cap/database/schema"; +import { and, eq, isNull, sql } from "drizzle-orm"; +import { + WEBHOOK_DISABLE_AFTER_FAILURES, + WEBHOOK_DISABLE_AFTER_MS, + WEBHOOK_DISABLED_BY_FAILURES, + WEBHOOK_TIMEOUT_MS, + type WebhookDeliveryJob, + type WebhookDeliveryOutcome, +} from "@/lib/developer-webhook-events"; +import { + signWebhookBody, + WEBHOOK_EVENT_ID_HEADER, + WEBHOOK_EVENT_TYPE_HEADER, + WEBHOOK_SIGNATURE_HEADER, +} from "@/lib/developer-webhook-signature"; +import { publicMediaUrlProblem } from "@/lib/public-media-fetch"; +import { isBlockedS3Endpoint } from "@/lib/s3-endpoint-policy"; +import { requestPublicOnly } from "@/lib/storage-fetch"; + +export type WebhookUrlProblem = + | "invalid_url" + | "https_required" + | "credentials_in_url" + | "unsupported_port" + | "private_address"; + +export type WebhookErrorCode = + | WebhookUrlProblem + | "timeout" + | "connection_failed" + | "redirect" + | "http_error"; + +export type WebhookSendResult = + | { ok: true; responseStatus: number; durationMs: number } + | { + ok: false; + responseStatus: number | null; + errorCode: WebhookErrorCode; + durationMs: number; + }; + +export type WebhookGuards = { + // Tests only. Lets a local server act as the customer endpoint. + allowPrivate?: boolean; + isBlocked?: (url: string) => Promise; + request?: (url: string, init: RequestInit) => Promise; + timeoutMs?: number; + now?: () => number; +}; + +// Plain http and private addresses are reachable only when a caller passes +// allowPrivate: true, and only tests do. No environment variable and no +// NODE_ENV value can turn it on, so a preview deployment, a self-hosted +// install or a misconfigured server always calls public https hosts only. +const PRIVATE_TARGETS_BY_DEFAULT = false; + +const URL_PROBLEM_MESSAGES: Record = { + invalid_url: "The URL is not valid", + https_required: "The URL must start with https://", + credentials_in_url: "The URL must not contain a username or password", + unsupported_port: "The URL must use the default https port", + private_address: "The URL must point to a public address", +}; + +// The stored URL is the parsed one, which is longer than what was sent when +// it holds characters that need encoding. +export const WEBHOOK_URL_MAX_LENGTH = 2_048; + +export const webhookUrlProblemMessage = (problem: WebhookUrlProblem) => + URL_PROBLEM_MESSAGES[problem]; + +export function webhookUrlProblem( + value: string, + allowPrivate: boolean = PRIVATE_TARGETS_BY_DEFAULT, +): WebhookUrlProblem | null { + const problem = publicMediaUrlProblem(value); + if (problem === "unsupported_scheme") return "https_required"; + if (problem === "invalid_url" || problem === "credentials_in_url") { + return problem; + } + const url = new URL(value); + if (url.href.length > WEBHOOK_URL_MAX_LENGTH) return "invalid_url"; + if (allowPrivate) return null; + if (url.protocol !== "https:") return "https_required"; + return problem; +} + +export async function checkWebhookUrl( + value: string, + guards: Pick = {}, +): Promise { + const allowPrivate = guards.allowPrivate ?? PRIVATE_TARGETS_BY_DEFAULT; + const problem = webhookUrlProblem(value, allowPrivate); + if (problem || allowPrivate) return problem; + return (await (guards.isBlocked ?? isBlockedS3Endpoint)(value)) + ? "private_address" + : null; +} + +const requestAnyAddress = (url: string, init: RequestInit) => + fetch(url, { ...init, redirect: "manual" }); + +const refusedPrivateAddress = (error: unknown) => + error instanceof Error && /private address/i.test(error.message); + +export async function sendDeveloperWebhook( + input: { + url: string; + secret: string; + eventId: string; + eventType: string; + body: string; + }, + guards: WebhookGuards = {}, +): Promise { + const now = guards.now ?? Date.now; + const started = now(); + const failed = ( + errorCode: WebhookErrorCode, + responseStatus: number | null = null, + ): WebhookSendResult => ({ + ok: false, + responseStatus, + errorCode, + durationMs: now() - started, + }); + + const allowPrivate = guards.allowPrivate ?? PRIVATE_TARGETS_BY_DEFAULT; + const request = + guards.request ?? (allowPrivate ? requestAnyAddress : requestPublicOnly); + const abort = new AbortController(); + let timedOut = false; + const timer = setTimeout(() => { + timedOut = true; + abort.abort(); + }, guards.timeoutMs ?? WEBHOOK_TIMEOUT_MS); + const deadline = new Promise((_, reject) => { + abort.signal.addEventListener( + "abort", + () => reject(new Error("Webhook delivery timed out")), + { once: true }, + ); + }); + deadline.catch(() => {}); + + try { + // Resolving the host is part of the delivery, so it runs inside the + // same deadline as the request. + const check = checkWebhookUrl(input.url, { + allowPrivate, + isBlocked: guards.isBlocked, + }); + check.catch(() => {}); + const problem = await Promise.race([check, deadline]); + if (problem) return failed(problem); + + const response = await request(input.url, { + method: "POST", + headers: { + "content-type": "application/json", + "user-agent": "Cap-Webhooks/1.0", + [WEBHOOK_SIGNATURE_HEADER]: signWebhookBody( + input.secret, + input.body, + Math.floor(now() / 1000), + ), + [WEBHOOK_EVENT_ID_HEADER]: input.eventId, + [WEBHOOK_EVENT_TYPE_HEADER]: input.eventType, + }, + body: input.body, + signal: abort.signal, + }); + const { status } = response; + await response.body?.cancel().catch(() => {}); + if (status >= 200 && status < 300) { + return { ok: true, responseStatus: status, durationMs: now() - started }; + } + return failed( + status >= 300 && status < 400 ? "redirect" : "http_error", + status, + ); + } catch (error) { + if (timedOut) return failed("timeout"); + return failed( + refusedPrivateAddress(error) ? "private_address" : "connection_failed", + ); + } finally { + clearTimeout(timer); + // Only the status is wanted. Closing the connection here means an + // endpoint that keeps sending, or never finishes, holds nothing open. + abort.abort(); + } +} + +// The count, the start of the streak and the decision to disable are read and +// written under the endpoint's row lock, so a success and a failure recorded +// at the same moment cannot leave a streak start behind without its count. +export async function recordWebhookDelivery( + endpointId: string, + result: WebhookSendResult, + options: { countsTowardStreak: boolean; now?: Date }, +): Promise { + const now = options.now ?? new Date(); + await db().transaction(async (tx) => { + const [endpoint] = await tx + .select({ + enabled: developerWebhookEndpoints.enabled, + consecutiveFailures: developerWebhookEndpoints.consecutiveFailures, + failingSince: developerWebhookEndpoints.failingSince, + }) + .from(developerWebhookEndpoints) + .where(eq(developerWebhookEndpoints.id, endpointId)) + .for("update"); + if (!endpoint) return; + + const failures = endpoint.consecutiveFailures + 1; + const failingSince = endpoint.failingSince ?? now; + const disable = + endpoint.enabled && + failures >= WEBHOOK_DISABLE_AFTER_FAILURES && + now.getTime() - failingSince.getTime() >= WEBHOOK_DISABLE_AFTER_MS; + const streak = !options.countsTowardStreak + ? {} + : result.ok + ? { consecutiveFailures: 0, failingSince: null } + : { + consecutiveFailures: failures, + failingSince, + ...(disable + ? { enabled: false, disabledReason: WEBHOOK_DISABLED_BY_FAILURES } + : {}), + }; + + await tx + .update(developerWebhookEndpoints) + .set({ + lastDeliveryAt: now, + lastDeliveryStatus: result.ok ? "succeeded" : "failed", + lastResponseStatus: result.responseStatus, + lastErrorCode: result.ok ? null : result.errorCode, + updatedAt: sql`${developerWebhookEndpoints.updatedAt}`, + ...streak, + }) + .where(eq(developerWebhookEndpoints.id, endpointId)); + }); +} + +async function findDeliverableEndpoint(endpointId: string) { + const [endpoint] = await db() + .select({ + id: developerWebhookEndpoints.id, + url: developerWebhookEndpoints.url, + encryptedSecret: developerWebhookEndpoints.encryptedSecret, + events: developerWebhookEndpoints.events, + enabled: developerWebhookEndpoints.enabled, + }) + .from(developerWebhookEndpoints) + .innerJoin( + developerApps, + and( + eq(developerWebhookEndpoints.appId, developerApps.id), + isNull(developerApps.deletedAt), + ), + ) + .where( + and( + eq(developerWebhookEndpoints.id, endpointId), + isNull(developerWebhookEndpoints.deletedAt), + ), + ) + .limit(1); + return endpoint ?? null; +} + +export async function deliverDeveloperWebhookAttempt( + job: WebhookDeliveryJob, + guards: WebhookGuards = {}, +): Promise { + const endpoint = await findDeliverableEndpoint(job.endpointId); + if ( + !endpoint || + !endpoint.enabled || + !endpoint.events.includes(job.eventType) + ) { + return "dropped"; + } + const result = await sendDeveloperWebhook( + { + url: endpoint.url, + secret: await decrypt(endpoint.encryptedSecret), + eventId: job.eventId, + eventType: job.eventType, + body: job.body, + }, + guards, + ); + await recordWebhookDelivery(endpoint.id, result, { + countsTowardStreak: true, + }); + return result.ok ? "delivered" : "retry"; +} diff --git a/apps/web/lib/developer-webhook-dispatch.ts b/apps/web/lib/developer-webhook-dispatch.ts new file mode 100644 index 00000000000..fc2393a8945 --- /dev/null +++ b/apps/web/lib/developer-webhook-dispatch.ts @@ -0,0 +1,116 @@ +import { db } from "@cap/database"; +import { + developerVideos, + developerWebhookEndpoints, +} from "@cap/database/schema"; +import { and, eq, isNull } from "drizzle-orm"; +import { after } from "next/server"; +import { start } from "workflow/api"; +import { serializeDeveloperVideo } from "@/lib/developer-video-api"; +import { + developerVideoEventType, + type WebhookEventType, +} from "@/lib/developer-webhook-events"; +import { developerVideoEventId } from "@/lib/developer-webhook-signature"; +import { deliverDeveloperWebhookWorkflow } from "@/workflows/deliver-developer-webhook"; + +const STATUS_OF_EVENT: Record = { + "video.ready": "ready", + "video.errored": "errored", +}; + +async function subscribedEndpointIds( + appId: string, + type: WebhookEventType, +): Promise { + const endpoints = await db() + .select({ + id: developerWebhookEndpoints.id, + events: developerWebhookEndpoints.events, + }) + .from(developerWebhookEndpoints) + .where( + and( + eq(developerWebhookEndpoints.appId, appId), + isNull(developerWebhookEndpoints.deletedAt), + eq(developerWebhookEndpoints.enabled, true), + ), + ); + return endpoints + .filter( + (endpoint) => + Array.isArray(endpoint.events) && endpoint.events.includes(type), + ) + .map((endpoint) => endpoint.id); +} + +// The caller is still inside the transaction that changes the status. The +// locking read waits for that transaction to end, so the event is built from +// what was committed, and a change that was rolled back sends nothing. +async function committedVideo(videoId: string) { + const [video] = await db() + .select() + .from(developerVideos) + .where(eq(developerVideos.id, videoId)) + .for("update"); + return video ?? null; +} + +export async function enqueueDeveloperVideoEvent( + target: { id: string; appId: string }, + type: WebhookEventType, +): Promise { + const endpointIds = await subscribedEndpointIds(target.appId, type); + if (endpointIds.length === 0) return 0; + + const video = await committedVideo(target.id); + if (!video || video.deletedAt || video.status !== STATUS_OF_EVENT[type]) { + return 0; + } + + const eventId = developerVideoEventId(video.id, type); + const body = JSON.stringify({ + id: eventId, + type, + createdAt: new Date().toISOString(), + data: serializeDeveloperVideo(video), + }); + const started = await Promise.allSettled( + endpointIds.map((endpointId) => + start(deliverDeveloperWebhookWorkflow, [ + { endpointId, eventId, eventType: type, body }, + ]), + ), + ); + const failures = started.filter((result) => result.status === "rejected"); + if (failures.length > 0) { + console.error("[developer-webhook] Failed to queue deliveries", { + videoId: video.id, + eventId, + failed: failures.length, + }); + } + return started.length - failures.length; +} + +// Never awaited: a video's status change must not wait for, or fail on, +// anything to do with webhooks. +export function announceDeveloperVideoStatus( + video: { id: string; appId: string; status: string }, + updates: { status?: string }, +): void { + const type = developerVideoEventType(video.status, updates.status); + if (!type) return; + const queued = enqueueDeveloperVideoEvent(video, type).catch((error) => { + console.error("[developer-webhook] Failed to queue an event", { + videoId: video.id, + type, + message: error instanceof Error ? error.message : String(error), + }); + }); + try { + after(queued); + } catch { + void queued; + } +} diff --git a/apps/web/lib/developer-webhook-events.ts b/apps/web/lib/developer-webhook-events.ts new file mode 100644 index 00000000000..76d73fca7be --- /dev/null +++ b/apps/web/lib/developer-webhook-events.ts @@ -0,0 +1,51 @@ +export const WEBHOOK_EVENT_TYPES = ["video.ready", "video.errored"] as const; +export type WebhookEventType = (typeof WEBHOOK_EVENT_TYPES)[number]; + +export const WEBHOOK_TEST_EVENT_TYPE = "webhook.test"; + +export const MAX_WEBHOOK_ENDPOINTS_PER_APP = 5; +export const WEBHOOK_TIMEOUT_MS = 10_000; + +// Eight attempts in all: the first straight away, then one after each of +// these gaps, which add up to 23 hours 36 minutes. +export const WEBHOOK_RETRY_DELAYS_SECONDS = [ + 60, + 5 * 60, + 30 * 60, + 2 * 60 * 60, + 5 * 60 * 60, + 8 * 60 * 60, + 8 * 60 * 60, +] as const; +export const WEBHOOK_MAX_ATTEMPTS = WEBHOOK_RETRY_DELAYS_SECONDS.length + 1; + +export const WEBHOOK_DISABLE_AFTER_FAILURES = 20; +export const WEBHOOK_DISABLE_AFTER_MS = 5 * 24 * 60 * 60 * 1000; +export const WEBHOOK_DISABLED_BY_FAILURES = "delivery_failures"; + +export type WebhookDeliveryOutcome = "delivered" | "retry" | "dropped"; + +export interface WebhookDeliveryJob { + endpointId: string; + eventId: string; + eventType: string; + body: string; +} + +export function webhookRetryDelaySeconds(attempt: number): number | null { + return WEBHOOK_RETRY_DELAYS_SECONDS[attempt - 1] ?? null; +} + +export function isWebhookEventType(value: string): value is WebhookEventType { + return WEBHOOK_EVENT_TYPES.some((type) => type === value); +} + +export function developerVideoEventType( + previous: string, + next: string | undefined, +): WebhookEventType | null { + if (next === undefined || next === previous) return null; + if (next === "ready") return "video.ready"; + if (next === "errored") return "video.errored"; + return null; +} diff --git a/apps/web/lib/developer-webhook-signature.ts b/apps/web/lib/developer-webhook-signature.ts new file mode 100644 index 00000000000..69082862f06 --- /dev/null +++ b/apps/web/lib/developer-webhook-signature.ts @@ -0,0 +1,74 @@ +import { createHash, createHmac, timingSafeEqual } from "node:crypto"; +import { nanoId, nanoIdLong } from "@cap/database/helpers"; +import type { WebhookEventType } from "@/lib/developer-webhook-events"; + +export const WEBHOOK_SECRET_PREFIX = "cws_"; +export const WEBHOOK_SIGNATURE_HEADER = "Cap-Signature"; +export const WEBHOOK_EVENT_ID_HEADER = "Cap-Event-Id"; +export const WEBHOOK_EVENT_TYPE_HEADER = "Cap-Event-Type"; +export const WEBHOOK_SIGNATURE_TOLERANCE_SECONDS = 5 * 60; + +const EVENT_ID_PREFIX = "evt_"; + +export const createWebhookSecret = () => + `${WEBHOOK_SECRET_PREFIX}${nanoIdLong()}`; + +export const createWebhookTestEventId = () => + `${EVENT_ID_PREFIX}test_${nanoId()}`; + +// A video reaches each of these states once, so the same video and type +// always name the same event. A repeated send carries the id the receiver +// has already seen. +export function developerVideoEventId( + videoId: string, + type: WebhookEventType, +): string { + const digest = createHash("sha256") + .update(`${type}:${videoId}`) + .digest("hex"); + return `${EVENT_ID_PREFIX}${digest.slice(0, 24)}`; +} + +function signature(secret: string, timestamp: number, body: string): string { + return createHmac("sha256", secret) + .update(`${timestamp}.${body}`) + .digest("hex"); +} + +export function signWebhookBody( + secret: string, + body: string, + timestamp: number, +): string { + return `t=${timestamp},v1=${signature(secret, timestamp, body)}`; +} + +export function verifyWebhookSignature(input: { + secret: string; + body: string; + header: string | null | undefined; + now?: number; + toleranceSeconds?: number; +}): boolean { + if (!input.header) return false; + const fields = new Map( + input.header.split(",").map((part) => { + const [name, ...rest] = part.split("="); + return [name?.trim() ?? "", rest.join("=").trim()] as const; + }), + ); + const rawTimestamp = fields.get("t") ?? ""; + const supplied = fields.get("v1") ?? ""; + if (!/^\d{1,12}$/.test(rawTimestamp) || !/^[0-9a-f]{64}$/.test(supplied)) { + return false; + } + const timestamp = Number(rawTimestamp); + const nowSeconds = Math.floor((input.now ?? Date.now()) / 1000); + const tolerance = + input.toleranceSeconds ?? WEBHOOK_SIGNATURE_TOLERANCE_SECONDS; + if (Math.abs(nowSeconds - timestamp) > tolerance) return false; + return timingSafeEqual( + Buffer.from(supplied, "hex"), + Buffer.from(signature(input.secret, timestamp, input.body), "hex"), + ); +} diff --git a/apps/web/lib/developer-webhooks.ts b/apps/web/lib/developer-webhooks.ts new file mode 100644 index 00000000000..77b8b6010e3 --- /dev/null +++ b/apps/web/lib/developer-webhooks.ts @@ -0,0 +1,319 @@ +import { db } from "@cap/database"; +import { decrypt, encrypt } from "@cap/database/crypto"; +import { nanoId } from "@cap/database/helpers"; +import { developerApps, developerWebhookEndpoints } from "@cap/database/schema"; +import { and, count, desc, eq, isNull } from "drizzle-orm"; +import { + checkWebhookUrl, + recordWebhookDelivery, + sendDeveloperWebhook, + type WebhookGuards, + type WebhookSendResult, + type WebhookUrlProblem, + webhookUrlProblemMessage, +} from "@/lib/developer-webhook-delivery"; +import { + MAX_WEBHOOK_ENDPOINTS_PER_APP, + WEBHOOK_EVENT_TYPES, + WEBHOOK_TEST_EVENT_TYPE, + type WebhookEventType, +} from "@/lib/developer-webhook-events"; +import { + createWebhookSecret, + createWebhookTestEventId, +} from "@/lib/developer-webhook-signature"; + +export type WebhookEndpointRow = typeof developerWebhookEndpoints.$inferSelect; + +export type WebhookFailure = { + ok: false; + status: 400 | 404 | 409; + code: string; + message: string; + hint: string; +}; + +const SECRET_PREFIX_LENGTH = 12; + +const failure = ( + status: WebhookFailure["status"], + code: string, + message: string, + hint: string, +): WebhookFailure => ({ ok: false, status, code, message, hint }); + +const notFound = () => + failure( + 404, + "webhook_not_found", + "Webhook endpoint not found", + "Check the endpoint ID and that it belongs to this app. Deleted endpoints are not returned.", + ); + +const URL_HINTS: Record = { + invalid_url: + "Send a full URL of at most 2,048 characters, such as https://example.com/webhooks/cap.", + https_required: "Webhooks are only sent over https.", + credentials_in_url: + "Remove the username and password. Verify each request with its Cap-Signature header instead.", + unsupported_port: "Serve the endpoint on port 443 and leave the port out.", + private_address: + "The host must resolve to a public internet address. Private, loopback, link-local and reserved addresses are refused.", +}; + +const invalidUrl = (problem: WebhookUrlProblem) => + failure( + 400, + "invalid_webhook_url", + webhookUrlProblemMessage(problem), + URL_HINTS[problem], + ); + +export function serializeWebhookEndpoint(endpoint: WebhookEndpointRow) { + return { + id: endpoint.id, + url: endpoint.url, + events: endpoint.events, + enabled: endpoint.enabled, + disabledReason: endpoint.disabledReason, + secretPrefix: endpoint.secretPrefix, + lastDeliveryAt: endpoint.lastDeliveryAt, + lastDeliveryStatus: endpoint.lastDeliveryStatus, + lastResponseStatus: endpoint.lastResponseStatus, + lastErrorCode: endpoint.lastErrorCode, + consecutiveFailures: endpoint.consecutiveFailures, + createdAt: endpoint.createdAt, + updatedAt: endpoint.updatedAt, + }; +} + +const liveEndpoint = (appId: string, endpointId: string) => + and( + eq(developerWebhookEndpoints.id, endpointId), + eq(developerWebhookEndpoints.appId, appId), + isNull(developerWebhookEndpoints.deletedAt), + ); + +export async function getWebhookEndpoint( + appId: string, + endpointId: string, +): Promise { + const [endpoint] = await db() + .select() + .from(developerWebhookEndpoints) + .where(liveEndpoint(appId, endpointId)) + .limit(1); + return endpoint ?? null; +} + +export async function listWebhookEndpoints( + appId: string, + page: { limit: number; offset: number }, +): Promise<{ endpoints: WebhookEndpointRow[]; hasMore: boolean }> { + const endpoints = await db() + .select() + .from(developerWebhookEndpoints) + .where( + and( + eq(developerWebhookEndpoints.appId, appId), + isNull(developerWebhookEndpoints.deletedAt), + ), + ) + .orderBy( + desc(developerWebhookEndpoints.createdAt), + desc(developerWebhookEndpoints.id), + ) + .limit(page.limit + 1) + .offset(page.offset); + return { + endpoints: endpoints.slice(0, page.limit), + hasMore: endpoints.length > page.limit, + }; +} + +export async function createWebhookEndpoint( + appId: string, + input: { url: string; events?: WebhookEventType[]; enabled?: boolean }, + guards: Pick = {}, +): Promise< + { ok: true; endpoint: WebhookEndpointRow; secret: string } | WebhookFailure +> { + const problem = await checkWebhookUrl(input.url, guards); + if (problem) return invalidUrl(problem); + + const id = nanoId(); + const secret = createWebhookSecret(); + const encryptedSecret = await encrypt(secret); + + const created = await db().transaction(async (tx) => { + // The app row is the lock, so two requests at once cannot both pass the + // count and leave the app over the limit. + await tx + .select({ id: developerApps.id }) + .from(developerApps) + .where(eq(developerApps.id, appId)) + .for("update"); + const [existing] = await tx + .select({ total: count() }) + .from(developerWebhookEndpoints) + .where( + and( + eq(developerWebhookEndpoints.appId, appId), + isNull(developerWebhookEndpoints.deletedAt), + ), + ); + if ((existing?.total ?? 0) >= MAX_WEBHOOK_ENDPOINTS_PER_APP) return false; + await tx.insert(developerWebhookEndpoints).values({ + id, + appId, + url: new URL(input.url).href, + secretPrefix: secret.slice(0, SECRET_PREFIX_LENGTH), + encryptedSecret, + events: [...(input.events ?? WEBHOOK_EVENT_TYPES)], + enabled: input.enabled ?? true, + }); + return true; + }); + if (!created) { + return failure( + 409, + "webhook_limit_reached", + `An app can have ${MAX_WEBHOOK_ENDPOINTS_PER_APP} webhook endpoints`, + "Delete an endpoint you no longer use, then create this one again.", + ); + } + + const endpoint = await getWebhookEndpoint(appId, id); + if (!endpoint) return notFound(); + return { ok: true, endpoint, secret }; +} + +export async function updateWebhookEndpoint( + appId: string, + endpointId: string, + input: { url?: string; events?: WebhookEventType[]; enabled?: boolean }, + guards: Pick = {}, +): Promise<{ ok: true; endpoint: WebhookEndpointRow } | WebhookFailure> { + const current = await getWebhookEndpoint(appId, endpointId); + if (!current) return notFound(); + + if (input.url !== undefined) { + const problem = await checkWebhookUrl(input.url, guards); + if (problem) return invalidUrl(problem); + } + const url = input.url === undefined ? undefined : new URL(input.url).href; + const restarts = + input.enabled === true || (url !== undefined && url !== current.url); + + await db() + .update(developerWebhookEndpoints) + .set({ + ...(url === undefined ? {} : { url }), + ...(input.events === undefined ? {} : { events: input.events }), + ...(input.enabled === undefined ? {} : { enabled: input.enabled }), + ...(input.enabled === true ? { disabledReason: null } : {}), + ...(restarts ? { consecutiveFailures: 0, failingSince: null } : {}), + }) + .where(liveEndpoint(appId, endpointId)); + + const endpoint = await getWebhookEndpoint(appId, endpointId); + return endpoint ? { ok: true, endpoint } : notFound(); +} + +// The old secret stops working at once: every delivery, including a retry of +// an earlier event, is signed when it is sent. The new secret is returned +// here and never again. +export async function rotateWebhookEndpointSecret( + appId: string, + endpointId: string, +): Promise< + { ok: true; endpoint: WebhookEndpointRow; secret: string } | WebhookFailure +> { + const current = await getWebhookEndpoint(appId, endpointId); + if (!current) return notFound(); + + const secret = createWebhookSecret(); + await db() + .update(developerWebhookEndpoints) + .set({ + secretPrefix: secret.slice(0, SECRET_PREFIX_LENGTH), + encryptedSecret: await encrypt(secret), + }) + .where(liveEndpoint(appId, endpointId)); + + const endpoint = await getWebhookEndpoint(appId, endpointId); + return endpoint ? { ok: true, endpoint, secret } : notFound(); +} + +export async function deleteWebhookEndpoint( + appId: string, + endpointId: string, +): Promise<{ ok: true } | WebhookFailure> { + const [endpoint] = await db() + .select({ deletedAt: developerWebhookEndpoints.deletedAt }) + .from(developerWebhookEndpoints) + .where( + and( + eq(developerWebhookEndpoints.id, endpointId), + eq(developerWebhookEndpoints.appId, appId), + ), + ) + .limit(1); + if (!endpoint) return notFound(); + if (!endpoint.deletedAt) { + await db() + .update(developerWebhookEndpoints) + .set({ deletedAt: new Date(), enabled: false }) + .where(liveEndpoint(appId, endpointId)); + } + return { ok: true }; +} + +export function serializeWebhookTestResult( + eventId: string, + result: WebhookSendResult, +) { + return { + eventId, + type: WEBHOOK_TEST_EVENT_TYPE, + delivered: result.ok, + responseStatus: result.responseStatus, + errorCode: result.ok ? null : result.errorCode, + durationMs: result.durationMs, + }; +} + +// One attempt, sent while the caller waits and never retried. It records the +// last delivery but leaves the failure streak alone, so testing an endpoint +// that is not up yet cannot count towards disabling it. +export async function sendWebhookTestEvent( + appId: string, + endpointId: string, + guards: WebhookGuards = {}, +): Promise< + { ok: true; eventId: string; result: WebhookSendResult } | WebhookFailure +> { + const endpoint = await getWebhookEndpoint(appId, endpointId); + if (!endpoint) return notFound(); + + const eventId = createWebhookTestEventId(); + const result = await sendDeveloperWebhook( + { + url: endpoint.url, + secret: await decrypt(endpoint.encryptedSecret), + eventId, + eventType: WEBHOOK_TEST_EVENT_TYPE, + body: JSON.stringify({ + id: eventId, + type: WEBHOOK_TEST_EVENT_TYPE, + createdAt: new Date().toISOString(), + data: { webhookId: endpoint.id }, + }), + }, + guards, + ); + await recordWebhookDelivery(endpoint.id, result, { + countsTowardStreak: false, + }); + return { ok: true, eventId, result }; +} diff --git a/apps/web/lib/s3-endpoint-policy.ts b/apps/web/lib/s3-endpoint-policy.ts index a9c5b383066..269848553e7 100644 --- a/apps/web/lib/s3-endpoint-policy.ts +++ b/apps/web/lib/s3-endpoint-policy.ts @@ -23,6 +23,20 @@ for (const [network, prefix] of [ privateRanges.addSubnet(network, prefix, "ipv4"); } +// Never dialled, but unlike the ranges above they do not condemn the whole +// name: a host with a public address and one of these (a 6to4 or Teredo +// AAAA, say) connects to the public one, as it did before these were added. +const reservedRanges = new BlockList(); + +for (const [network, prefix] of [ + ["192.0.2.0", 24], + ["192.88.99.0", 24], + ["198.51.100.0", 24], + ["203.0.113.0", 24], +] as const) { + reservedRanges.addSubnet(network, prefix, "ipv4"); +} + // BlockList already applies the IPv4 rules to IPv4-mapped addresses // (::ffff:a.b.c.d and ::ffff:7f00:1). Adding ::ffff:0:0/96 here would also // match every plain IPv4 address, so only the IPv4-compatible (::/96) and NAT64 @@ -38,11 +52,44 @@ for (const [network, prefix] of [ privateRanges.addSubnet(network, prefix, "ipv6"); } -export function isPrivateAddress(address: string) { +// 6to4 (2002::/16) and Teredo (2001::/32) carry an IPv4 address inside them, +// so a relay could deliver the request to a private IPv4 host that the IPv4 +// rules never saw. +for (const [network, prefix] of [ + ["64:ff9b:1::", 48], + ["100::", 64], + ["2001::", 32], + ["2001:db8::", 32], + ["2002::", 16], +] as const) { + reservedRanges.addSubnet(network, prefix, "ipv6"); +} + +const inRanges = (ranges: BlockList, address: string) => { const family = isIP(address); - if (family === 4) return privateRanges.check(address, "ipv4"); - if (family === 6) return privateRanges.check(address, "ipv6"); + if (family === 4) return ranges.check(address, "ipv4"); + if (family === 6) return ranges.check(address, "ipv6"); return true; +}; + +// True for any address that is never dialled. +export function isPrivateAddress(address: string) { + return inRanges(privateRanges, address) || inRanges(reservedRanges, address); +} + +// The addresses of a DNS answer that may be dialled, or null when the name is +// refused. One private address anywhere refuses the whole name. Reserved +// addresses are dropped, and the name is refused only when nothing is left. +export function dialableAddresses( + entries: readonly T[], + addressOf: (entry: T) => string, +): T[] | null { + if (entries.some((entry) => inRanges(privateRanges, addressOf(entry)))) + return null; + const usable = entries.filter( + (entry) => !inRanges(reservedRanges, addressOf(entry)), + ); + return usable.length === 0 ? null : usable; } export function shouldBlockPrivateS3Endpoints() { @@ -83,7 +130,7 @@ export async function isBlockedS3Endpoint( try { const addresses = await resolve(hostname); - return addresses.length === 0 || addresses.some(isPrivateAddress); + return dialableAddresses(addresses, (address) => address) === null; } catch { return true; } @@ -103,17 +150,22 @@ export const publicOnlyLookup: LookupFunction = ( const addresses: LookupAddress[] = Array.isArray(address) ? address : [{ address, family }]; - const blocked = addresses.find((entry) => isPrivateAddress(entry.address)); - if (blocked) { + const usable = dialableAddresses(addresses, (entry) => entry.address); + if (!usable) { + const blocked = + addresses.find((entry) => isPrivateAddress(entry.address)) ?? + addresses[0]; const blockedError: NodeJS.ErrnoException = new Error( - `Refused to connect to private address ${blocked.address}`, + `Refused to connect to private address ${blocked?.address ?? hostname}`, ); blockedError.code = "ECONNREFUSED"; callback(blockedError, address, family); return; } - callback(null, address, family); + // Only the addresses that passed are handed to the connection. + if (Array.isArray(address)) callback(null, usable, family); + else callback(null, address, family); }); }; diff --git a/apps/web/lib/storage-fetch.ts b/apps/web/lib/storage-fetch.ts index e8228c019d6..aaed7db873c 100644 --- a/apps/web/lib/storage-fetch.ts +++ b/apps/web/lib/storage-fetch.ts @@ -1,8 +1,10 @@ import { Agent as HttpAgent, request as httpRequest } from "node:http"; import { Agent as HttpsAgent, request as httpsRequest } from "node:https"; +import { isIP } from "node:net"; import { Readable } from "node:stream"; import { isBlockedS3Endpoint, + isPrivateAddress, publicOnlyLookup, shouldBlockPrivateS3Endpoints, } from "@/lib/s3-endpoint-policy"; @@ -33,13 +35,15 @@ const httpsAgent = new HttpsAgent({ }); // The connection resolves through publicOnlyLookup, so the address it dials is the address that was checked. +// Node skips the lookup when the host is already an IP address, so a literal is checked here instead. export function requestPublicOnly( url: string, init: RequestInit = {}, ): Promise { - if (init.body !== undefined && init.body !== null) + const body = init.body ?? undefined; + if (body !== undefined && typeof body !== "string") return Promise.reject( - new Error("Storage requests do not send a request body"), + new Error("Storage requests only send a text request body"), ); const target = new URL(url); const isHttps = target.protocol === "https:"; @@ -47,6 +51,13 @@ export function requestPublicOnly( return Promise.reject( new StorageFetchRefusedError("Storage URLs must use http or https"), ); + const literal = target.hostname.replace(/^\[|\]$/g, ""); + if (isIP(literal) && isPrivateAddress(literal)) + return Promise.reject( + new StorageFetchRefusedError( + `Refused to connect to private address ${literal}`, + ), + ); const method = init.method ?? "GET"; return new Promise((resolve, reject) => { const request = (isHttps ? httpsRequest : httpRequest)( @@ -76,7 +87,13 @@ export function requestPublicOnly( else if (value !== undefined) headers.set(name, value); } const hasBody = method !== "HEAD" && !BODYLESS_STATUSES.has(status); - if (!hasBody) response.resume(); + if (!hasBody) { + // These answers carry no content. A server that sends some + // anyway loses the connection instead of having it read + // without end. + response.on("error", () => {}); + response.on("data", () => response.destroy()); + } resolve( new Response( hasBody @@ -92,7 +109,7 @@ export function requestPublicOnly( }, ); request.on("error", reject); - request.end(); + request.end(body); }); } diff --git a/apps/web/workflows/deliver-developer-webhook.ts b/apps/web/workflows/deliver-developer-webhook.ts new file mode 100644 index 00000000000..20854bbb3ff --- /dev/null +++ b/apps/web/workflows/deliver-developer-webhook.ts @@ -0,0 +1,41 @@ +import { sleep } from "workflow"; +import { deliverDeveloperWebhookAttempt } from "@/lib/developer-webhook-delivery"; +import { + type WebhookDeliveryJob, + type WebhookDeliveryOutcome, + webhookRetryDelaySeconds, +} from "@/lib/developer-webhook-events"; + +export async function deliverDeveloperWebhookWorkflow( + job: WebhookDeliveryJob, +): Promise<{ outcome: WebhookDeliveryOutcome | "gave_up"; attempts: number }> { + "use workflow"; + + for (let attempt = 1; ; attempt++) { + const outcome = await attemptDelivery(job); + if (outcome !== "retry") return { outcome, attempts: attempt }; + const delay = webhookRetryDelaySeconds(attempt); + if (delay === null) return { outcome: "gave_up", attempts: attempt }; + await sleep(`${delay}s`); + } +} + +async function attemptDelivery( + job: WebhookDeliveryJob, +): Promise { + "use step"; + + // A thrown step is run again by the engine straight away and then fails + // the run. Returning "retry" keeps a database blip on the same schedule + // as a failed delivery. + try { + return await deliverDeveloperWebhookAttempt(job); + } catch (error) { + console.error("[developer-webhook] Delivery attempt failed to run", { + endpointId: job.endpointId, + eventId: job.eventId, + message: error instanceof Error ? error.message : String(error), + }); + return "retry"; + } +} diff --git a/packages/database/migrations/0058_developer_webhook_endpoints.sql b/packages/database/migrations/0058_developer_webhook_endpoints.sql new file mode 100644 index 00000000000..9f71ffc36c2 --- /dev/null +++ b/packages/database/migrations/0058_developer_webhook_endpoints.sql @@ -0,0 +1,23 @@ +CREATE TABLE `developer_webhook_endpoints` ( + `id` varchar(15) NOT NULL, + `appId` varchar(15) NOT NULL, + `url` varchar(2048) NOT NULL, + `secretPrefix` varchar(12) NOT NULL, + `encryptedSecret` text NOT NULL, + `events` json NOT NULL, + `enabled` boolean NOT NULL DEFAULT true, + `disabledReason` varchar(32), + `lastDeliveryAt` timestamp, + `lastDeliveryStatus` varchar(16), + `lastResponseStatus` int, + `lastErrorCode` varchar(32), + `consecutiveFailures` int NOT NULL DEFAULT 0, + `failingSince` timestamp, + `deletedAt` timestamp, + `createdAt` timestamp NOT NULL DEFAULT (now()), + `updatedAt` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT `developer_webhook_endpoints_id` PRIMARY KEY(`id`) +); +--> statement-breakpoint +ALTER TABLE `developer_webhook_endpoints` ADD CONSTRAINT `developer_webhook_endpoints_appId_developer_apps_id_fk` FOREIGN KEY (`appId`) REFERENCES `developer_apps`(`id`) ON DELETE no action ON UPDATE no action;--> statement-breakpoint +CREATE INDEX `dev_webhooks_app_list_idx` ON `developer_webhook_endpoints` (`appId`,`deletedAt`,`createdAt`,`id`); \ No newline at end of file diff --git a/packages/database/migrations/meta/0058_snapshot.json b/packages/database/migrations/meta/0058_snapshot.json new file mode 100644 index 00000000000..17172e2a7d0 --- /dev/null +++ b/packages/database/migrations/meta/0058_snapshot.json @@ -0,0 +1,6040 @@ +{ + "version": "5", + "dialect": "mysql", + "id": "c59a9144-1aee-456a-b1f5-babae94c9f0e", + "prevId": "85c09243-a7b6-4390-ad3c-4d54450a0a8d", + "tables": { + "accounts": { + "name": "accounts", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "providerAccountId": { + "name": "providerAccountId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_in": { + "name": "expires_in", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_token_expires_in": { + "name": "refresh_token_expires_in", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "token_type": { + "name": "token_type", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + }, + "tempColumn": { + "name": "tempColumn", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "user_id_idx": { + "name": "user_id_idx", + "columns": ["userId"], + "isUnique": false + }, + "provider_account_id_idx": { + "name": "provider_account_id_idx", + "columns": ["providerAccountId"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "accounts_id": { + "name": "accounts_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "agent_api_authorization_codes": { + "name": "agent_api_authorization_codes", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "codeHash": { + "name": "codeHash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "codeChallenge": { + "name": "codeChallenge", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "redirectUri": { + "name": "redirectUri", + "type": "varchar(512)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "scopes": { + "name": "scopes", + "type": "json", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expiresAt": { + "name": "expiresAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "consumedAt": { + "name": "consumedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": { + "code_hash_idx": { + "name": "code_hash_idx", + "columns": ["codeHash"], + "isUnique": true + }, + "expires_at_idx": { + "name": "expires_at_idx", + "columns": ["expiresAt"], + "isUnique": false + }, + "user_created_at_idx": { + "name": "user_created_at_idx", + "columns": ["userId", "createdAt"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "agent_api_authorization_codes_id": { + "name": "agent_api_authorization_codes_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "agent_api_idempotency": { + "name": "agent_api_idempotency", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "operation": { + "name": "operation", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "keyHash": { + "name": "keyHash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "requestHash": { + "name": "requestHash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "state": { + "name": "state", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "statusCode": { + "name": "statusCode", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "response": { + "name": "response", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expiresAt": { + "name": "expiresAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "user_operation_key_idx": { + "name": "user_operation_key_idx", + "columns": ["userId", "operation", "keyHash"], + "isUnique": true + }, + "expires_at_idx": { + "name": "expires_at_idx", + "columns": ["expiresAt"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "agent_api_idempotency_id": { + "name": "agent_api_idempotency_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "agent_api_keys": { + "name": "agent_api_keys", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tokenHash": { + "name": "tokenHash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'Cap CLI'" + }, + "scopes": { + "name": "scopes", + "type": "json", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expiresAt": { + "name": "expiresAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "revokedAt": { + "name": "revokedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "lastUsedAt": { + "name": "lastUsedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "token_hash_idx": { + "name": "token_hash_idx", + "columns": ["tokenHash"], + "isUnique": true + }, + "user_created_at_idx": { + "name": "user_created_at_idx", + "columns": ["userId", "createdAt"], + "isUnique": false + }, + "expires_at_idx": { + "name": "expires_at_idx", + "columns": ["expiresAt"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "agent_api_keys_id": { + "name": "agent_api_keys_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "agent_api_operations": { + "name": "agent_api_operations", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "resourceId": { + "name": "resourceId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "resultResourceId": { + "name": "resultResourceId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "state": { + "name": "state", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'queued'" + }, + "payload": { + "name": "payload", + "type": "json", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "result": { + "name": "result", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "errorCode": { + "name": "errorCode", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "errorMessage": { + "name": "errorMessage", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + }, + "completedAt": { + "name": "completedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "user_created_at_idx": { + "name": "user_created_at_idx", + "columns": ["userId", "createdAt"], + "isUnique": false + }, + "state_updated_at_idx": { + "name": "state_updated_at_idx", + "columns": ["state", "updatedAt"], + "isUnique": false + }, + "resource_id_idx": { + "name": "resource_id_idx", + "columns": ["resourceId"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "agent_api_operations_id": { + "name": "agent_api_operations_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "auth_api_keys": { + "name": "auth_api_keys", + "columns": { + "id": { + "name": "id", + "type": "varchar(36)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'unknown'" + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": { + "user_id_created_at_idx": { + "name": "user_id_created_at_idx", + "columns": ["userId", "createdAt"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "auth_api_keys_id": { + "name": "auth_api_keys_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "comments": { + "name": "comments", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "varchar(6)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "timestamp": { + "name": "timestamp", + "type": "float", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "authorId": { + "name": "authorId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "videoId": { + "name": "videoId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + }, + "parentCommentId": { + "name": "parentCommentId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mediaKey": { + "name": "mediaKey", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mediaDuration": { + "name": "mediaDuration", + "type": "float", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mediaMeta": { + "name": "mediaMeta", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "video_type_created_idx": { + "name": "video_type_created_idx", + "columns": ["videoId", "type", "createdAt", "id"], + "isUnique": false + }, + "author_id_idx": { + "name": "author_id_idx", + "columns": ["authorId"], + "isUnique": false + }, + "parent_comment_id_idx": { + "name": "parent_comment_id_idx", + "columns": ["parentCommentId"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "comments_id": { + "name": "comments_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "developer_api_keys": { + "name": "developer_api_keys", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "appId": { + "name": "appId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "keyType": { + "name": "keyType", + "type": "varchar(8)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "keyPrefix": { + "name": "keyPrefix", + "type": "varchar(12)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "keyHash": { + "name": "keyHash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "encryptedKey": { + "name": "encryptedKey", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "lastUsedAt": { + "name": "lastUsedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "revokedAt": { + "name": "revokedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": { + "key_hash_idx": { + "name": "key_hash_idx", + "columns": ["keyHash"], + "isUnique": true + }, + "app_key_type_idx": { + "name": "app_key_type_idx", + "columns": ["appId", "keyType"], + "isUnique": false + } + }, + "foreignKeys": { + "developer_api_keys_appId_developer_apps_id_fk": { + "name": "developer_api_keys_appId_developer_apps_id_fk", + "tableFrom": "developer_api_keys", + "tableTo": "developer_apps", + "columnsFrom": ["appId"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "developer_api_keys_id": { + "name": "developer_api_keys_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "developer_app_domains": { + "name": "developer_app_domains", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "appId": { + "name": "appId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "domain": { + "name": "domain", + "type": "varchar(253)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": { + "developer_app_domains_appId_developer_apps_id_fk": { + "name": "developer_app_domains_appId_developer_apps_id_fk", + "tableFrom": "developer_app_domains", + "tableTo": "developer_apps", + "columnsFrom": ["appId"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "developer_app_domains_id": { + "name": "developer_app_domains_id", + "columns": ["id"] + } + }, + "uniqueConstraints": { + "app_domain_unique": { + "name": "app_domain_unique", + "columns": ["appId", "domain"] + } + }, + "checkConstraint": {} + }, + "developer_apps": { + "name": "developer_apps", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ownerId": { + "name": "ownerId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "environment": { + "name": "environment", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "logoUrl": { + "name": "logoUrl", + "type": "varchar(1024)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "deletedAt": { + "name": "deletedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "owner_deleted_idx": { + "name": "owner_deleted_idx", + "columns": ["ownerId", "deletedAt"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "developer_apps_id": { + "name": "developer_apps_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "developer_credit_accounts": { + "name": "developer_credit_accounts", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "appId": { + "name": "appId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ownerId": { + "name": "ownerId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "balanceMicroCredits": { + "name": "balanceMicroCredits", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "stripeCustomerId": { + "name": "stripeCustomerId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "stripePaymentMethodId": { + "name": "stripePaymentMethodId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "autoTopUpEnabled": { + "name": "autoTopUpEnabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "autoTopUpThresholdMicroCredits": { + "name": "autoTopUpThresholdMicroCredits", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "autoTopUpAmountCents": { + "name": "autoTopUpAmountCents", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "app_id_unique": { + "name": "app_id_unique", + "columns": ["appId"], + "isUnique": true + }, + "owner_idx": { + "name": "owner_idx", + "columns": ["ownerId"], + "isUnique": false + }, + "auto_top_up_idx": { + "name": "auto_top_up_idx", + "columns": ["autoTopUpEnabled"], + "isUnique": false + } + }, + "foreignKeys": { + "developer_credit_accounts_appId_developer_apps_id_fk": { + "name": "developer_credit_accounts_appId_developer_apps_id_fk", + "tableFrom": "developer_credit_accounts", + "tableTo": "developer_apps", + "columnsFrom": ["appId"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "developer_credit_accounts_id": { + "name": "developer_credit_accounts_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "developer_credit_transactions": { + "name": "developer_credit_transactions", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "accountId": { + "name": "accountId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "amountMicroCredits": { + "name": "amountMicroCredits", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "balanceAfterMicroCredits": { + "name": "balanceAfterMicroCredits", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "referenceId": { + "name": "referenceId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "referenceType": { + "name": "referenceType", + "type": "varchar(32)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": { + "account_type_created_idx": { + "name": "account_type_created_idx", + "columns": ["accountId", "type", "createdAt"], + "isUnique": false + }, + "account_created_idx": { + "name": "account_created_idx", + "columns": ["accountId", "createdAt"], + "isUnique": false + }, + "account_ref_dedup_idx": { + "name": "account_ref_dedup_idx", + "columns": ["accountId", "referenceId", "referenceType"], + "isUnique": false + }, + "reference_type_id_idx": { + "name": "reference_type_id_idx", + "columns": ["referenceType", "referenceId"], + "isUnique": false + } + }, + "foreignKeys": { + "dev_credit_txn_account_fk": { + "name": "dev_credit_txn_account_fk", + "tableFrom": "developer_credit_transactions", + "tableTo": "developer_credit_accounts", + "columnsFrom": ["accountId"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "developer_credit_transactions_id": { + "name": "developer_credit_transactions_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "developer_daily_storage_snapshots": { + "name": "developer_daily_storage_snapshots", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "appId": { + "name": "appId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "snapshotDate": { + "name": "snapshotDate", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "totalDurationMinutes": { + "name": "totalDurationMinutes", + "type": "float", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "videoCount": { + "name": "videoCount", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "microCreditsCharged": { + "name": "microCreditsCharged", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "processedAt": { + "name": "processedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": { + "developer_daily_storage_snapshots_appId_developer_apps_id_fk": { + "name": "developer_daily_storage_snapshots_appId_developer_apps_id_fk", + "tableFrom": "developer_daily_storage_snapshots", + "tableTo": "developer_apps", + "columnsFrom": ["appId"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "developer_daily_storage_snapshots_id": { + "name": "developer_daily_storage_snapshots_id", + "columns": ["id"] + } + }, + "uniqueConstraints": { + "app_date_unique": { + "name": "app_date_unique", + "columns": ["appId", "snapshotDate"] + } + }, + "checkConstraint": {} + }, + "developer_transcripts": { + "name": "developer_transcripts", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "appId": { + "name": "appId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "externalUserId": { + "name": "externalUserId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'Untitled'" + }, + "status": { + "name": "status", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sourceType": { + "name": "sourceType", + "type": "varchar(8)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sourceUrl": { + "name": "sourceUrl", + "type": "varchar(2048)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "videoId": { + "name": "videoId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "requestedAt": { + "name": "requestedAt", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "s3Key": { + "name": "s3Key", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "resultKey": { + "name": "resultKey", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sourceBytes": { + "name": "sourceBytes", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "durationSeconds": { + "name": "durationSeconds", + "type": "float", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "requestedLanguage": { + "name": "requestedLanguage", + "type": "varchar(16)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "language": { + "name": "language", + "type": "varchar(16)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "text": { + "name": "text", + "type": "mediumtext", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "wordCount": { + "name": "wordCount", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reservedMicroCredits": { + "name": "reservedMicroCredits", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "billedMicroCredits": { + "name": "billedMicroCredits", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "errorCode": { + "name": "errorCode", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "errorMessage": { + "name": "errorMessage", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "runId": { + "name": "runId", + "type": "varchar(32)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "queuedAt": { + "name": "queuedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "startedAt": { + "name": "startedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "completedAt": { + "name": "completedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "deletedAt": { + "name": "deletedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "dev_transcripts_app_list_idx": { + "name": "dev_transcripts_app_list_idx", + "columns": ["appId", "deletedAt", "createdAt", "id"], + "isUnique": false + }, + "dev_transcripts_app_user_idx": { + "name": "dev_transcripts_app_user_idx", + "columns": ["appId", "externalUserId", "createdAt"], + "isUnique": false + }, + "dev_transcripts_app_status_idx": { + "name": "dev_transcripts_app_status_idx", + "columns": ["appId", "status", "createdAt"], + "isUnique": false + }, + "dev_transcripts_status_updated_idx": { + "name": "dev_transcripts_status_updated_idx", + "columns": ["status", "updatedAt"], + "isUnique": false + }, + "dev_transcripts_app_completed_idx": { + "name": "dev_transcripts_app_completed_idx", + "columns": [ + "appId", + "status", + "completedAt", + "durationSeconds", + "billedMicroCredits" + ], + "isUnique": false + }, + "dev_transcripts_app_status_held_idx": { + "name": "dev_transcripts_app_status_held_idx", + "columns": [ + "appId", + "status", + "createdAt", + "deletedAt", + "reservedMicroCredits" + ], + "isUnique": false + }, + "dev_transcripts_app_video_idx": { + "name": "dev_transcripts_app_video_idx", + "columns": ["appId", "videoId", "requestedAt", "id"], + "isUnique": false + } + }, + "foreignKeys": { + "developer_transcripts_appId_developer_apps_id_fk": { + "name": "developer_transcripts_appId_developer_apps_id_fk", + "tableFrom": "developer_transcripts", + "tableTo": "developer_apps", + "columnsFrom": ["appId"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "developer_transcripts_id": { + "name": "developer_transcripts_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "developer_videos": { + "name": "developer_videos", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "appId": { + "name": "appId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "externalUserId": { + "name": "externalUserId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'Untitled'" + }, + "duration": { + "name": "duration", + "type": "float", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "width": { + "name": "width", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "height": { + "name": "height", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "fps": { + "name": "fps", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "s3Key": { + "name": "s3Key", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "transcriptionStatus": { + "name": "transcriptionStatus", + "type": "varchar(16)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'uploading'" + }, + "playbackId": { + "name": "playbackId", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "maxResolutionTier": { + "name": "maxResolutionTier", + "type": "varchar(8)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'1080p'" + }, + "renditions": { + "name": "renditions", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "storedBytes": { + "name": "storedBytes", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "errorReason": { + "name": "errorReason", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "processing": { + "name": "processing", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "deletedAt": { + "name": "deletedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "filesDeletedAt": { + "name": "filesDeletedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "app_created_idx": { + "name": "app_created_idx", + "columns": ["appId", "createdAt"], + "isUnique": false + }, + "app_user_idx": { + "name": "app_user_idx", + "columns": ["appId", "externalUserId"], + "isUnique": false + }, + "app_deleted_idx": { + "name": "app_deleted_idx", + "columns": ["appId", "deletedAt"], + "isUnique": false + }, + "playback_id_idx": { + "name": "playback_id_idx", + "columns": ["playbackId"], + "isUnique": true + }, + "files_pending_idx": { + "name": "files_pending_idx", + "columns": ["filesDeletedAt", "deletedAt"], + "isUnique": false + } + }, + "foreignKeys": { + "developer_videos_appId_developer_apps_id_fk": { + "name": "developer_videos_appId_developer_apps_id_fk", + "tableFrom": "developer_videos", + "tableTo": "developer_apps", + "columnsFrom": ["appId"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "developer_videos_id": { + "name": "developer_videos_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "developer_webhook_endpoints": { + "name": "developer_webhook_endpoints", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "appId": { + "name": "appId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "varchar(2048)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "secretPrefix": { + "name": "secretPrefix", + "type": "varchar(12)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "encryptedSecret": { + "name": "encryptedSecret", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "events": { + "name": "events", + "type": "json", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "disabledReason": { + "name": "disabledReason", + "type": "varchar(32)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "lastDeliveryAt": { + "name": "lastDeliveryAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "lastDeliveryStatus": { + "name": "lastDeliveryStatus", + "type": "varchar(16)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "lastResponseStatus": { + "name": "lastResponseStatus", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "lastErrorCode": { + "name": "lastErrorCode", + "type": "varchar(32)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "consecutiveFailures": { + "name": "consecutiveFailures", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "failingSince": { + "name": "failingSince", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "deletedAt": { + "name": "deletedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "dev_webhooks_app_list_idx": { + "name": "dev_webhooks_app_list_idx", + "columns": ["appId", "deletedAt", "createdAt", "id"], + "isUnique": false + } + }, + "foreignKeys": { + "developer_webhook_endpoints_appId_developer_apps_id_fk": { + "name": "developer_webhook_endpoints_appId_developer_apps_id_fk", + "tableFrom": "developer_webhook_endpoints", + "tableTo": "developer_apps", + "columnsFrom": ["appId"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "developer_webhook_endpoints_id": { + "name": "developer_webhook_endpoints_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "directory_users": { + "name": "directory_users", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "organizationId": { + "name": "organizationId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "directoryId": { + "name": "directoryId", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "active": { + "name": "active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "directory_users_organization_id_idx": { + "name": "directory_users_organization_id_idx", + "columns": ["organizationId"], + "isUnique": false + }, + "directory_users_user_org_idx": { + "name": "directory_users_user_org_idx", + "columns": ["userId", "organizationId"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "directory_users_id": { + "name": "directory_users_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "folders": { + "name": "folders", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "color": { + "name": "color", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'normal'" + }, + "public": { + "name": "public", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "settings": { + "name": "settings", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "organizationId": { + "name": "organizationId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdById": { + "name": "createdById", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "parentId": { + "name": "parentId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "spaceId": { + "name": "spaceId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "organization_id_idx": { + "name": "organization_id_idx", + "columns": ["organizationId"], + "isUnique": false + }, + "created_by_id_idx": { + "name": "created_by_id_idx", + "columns": ["createdById"], + "isUnique": false + }, + "parent_id_idx": { + "name": "parent_id_idx", + "columns": ["parentId"], + "isUnique": false + }, + "space_id_idx": { + "name": "space_id_idx", + "columns": ["spaceId"], + "isUnique": false + }, + "public_parent_id_idx": { + "name": "public_parent_id_idx", + "columns": ["public", "parentId"], + "isUnique": false + }, + "public_space_parent_id_idx": { + "name": "public_space_parent_id_idx", + "columns": ["public", "spaceId", "parentId"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "folders_id": { + "name": "folders_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "imported_videos": { + "name": "imported_videos", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "orgId": { + "name": "orgId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_id": { + "name": "source_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "id_idx": { + "name": "id_idx", + "columns": ["id"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "imported_videos_orgId_source_source_id_pk": { + "name": "imported_videos_orgId_source_source_id_pk", + "columns": ["orgId", "source", "source_id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "integration_installations": { + "name": "integration_installations", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "externalId": { + "name": "externalId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "displayName": { + "name": "displayName", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "organizationId": { + "name": "organizationId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "installedByUserId": { + "name": "installedByUserId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "encryptedCredentials": { + "name": "encryptedCredentials", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "json", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "provider_external_id_idx": { + "name": "provider_external_id_idx", + "columns": ["provider", "externalId"], + "isUnique": true + }, + "organization_provider_display_name_idx": { + "name": "organization_provider_display_name_idx", + "columns": ["organizationId", "provider", "displayName"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "integration_installations_id": { + "name": "integration_installations_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "loom_migration_imports": { + "name": "loom_migration_imports", + "columns": { + "videoId": { + "name": "videoId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "requestId": { + "name": "requestId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "loom_migration_imports_request_idx": { + "name": "loom_migration_imports_request_idx", + "columns": ["requestId"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "loom_migration_imports_videoId": { + "name": "loom_migration_imports_videoId", + "columns": ["videoId"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "loom_migration_requests": { + "name": "loom_migration_requests", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "organizationId": { + "name": "organizationId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "activeOrganizationId": { + "name": "activeOrganizationId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "requestedByUserId": { + "name": "requestedByUserId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workspaceName": { + "name": "workspaceName", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "customerNote": { + "name": "customerNote", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "customerReply": { + "name": "customerReply", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "invitedAt": { + "name": "invitedAt", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "capMessage": { + "name": "capMessage", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expectedVideoCount": { + "name": "expectedVideoCount", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "importedVideoCount": { + "name": "importedVideoCount", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "queuedVideoCount": { + "name": "queuedVideoCount", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "activeImportCount": { + "name": "activeImportCount", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "activeImportLeaseToken": { + "name": "activeImportLeaseToken", + "type": "varchar(36)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "activeImportLeaseUntil": { + "name": "activeImportLeaseUntil", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "lastOperatorUserId": { + "name": "lastOperatorUserId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "lastOperatorAt": { + "name": "lastOperatorAt", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "completedAt": { + "name": "completedAt", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updatedAt": { + "name": "updatedAt", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "loom_migration_active_org_idx": { + "name": "loom_migration_active_org_idx", + "columns": ["activeOrganizationId"], + "isUnique": true + }, + "loom_migration_org_created_idx": { + "name": "loom_migration_org_created_idx", + "columns": ["organizationId", "createdAt"], + "isUnique": false + }, + "loom_migration_status_created_idx": { + "name": "loom_migration_status_created_idx", + "columns": ["status", "createdAt"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "loom_migration_requests_id": { + "name": "loom_migration_requests_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "loops_sync_jobs": { + "name": "loops_sync_jobs", + "columns": { + "userId": { + "name": "userId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "revision": { + "name": "revision", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "nextAttemptAt": { + "name": "nextAttemptAt", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "leaseToken": { + "name": "leaseToken", + "type": "varchar(36)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "leaseUntil": { + "name": "leaseUntil", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "failures": { + "name": "failures", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "lastError": { + "name": "lastError", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "profileHash": { + "name": "profileHash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "teammateJoinedAt": { + "name": "teammateJoinedAt", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "syncedEmail": { + "name": "syncedEmail", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "lastSyncedAt": { + "name": "lastSyncedAt", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "loops_sync_due_idx": { + "name": "loops_sync_due_idx", + "columns": ["nextAttemptAt"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "loops_sync_jobs_userId": { + "name": "loops_sync_jobs_userId", + "columns": ["userId"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "mcp_oauth_clients": { + "name": "mcp_oauth_clients", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "clientId": { + "name": "clientId", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "clientName": { + "name": "clientName", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "redirectUris": { + "name": "redirectUris", + "type": "json", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "activatedAt": { + "name": "activatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": { + "client_id_idx": { + "name": "client_id_idx", + "columns": ["clientId"], + "isUnique": true + }, + "inactive_created_at_idx": { + "name": "inactive_created_at_idx", + "columns": ["activatedAt", "createdAt"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "mcp_oauth_clients_id": { + "name": "mcp_oauth_clients_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "mcp_oauth_codes": { + "name": "mcp_oauth_codes", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "clientId": { + "name": "clientId", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "codeHash": { + "name": "codeHash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "codeChallenge": { + "name": "codeChallenge", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "redirectUri": { + "name": "redirectUri", + "type": "varchar(512)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "resource": { + "name": "resource", + "type": "varchar(512)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expiresAt": { + "name": "expiresAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "consumedAt": { + "name": "consumedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": { + "code_hash_idx": { + "name": "code_hash_idx", + "columns": ["codeHash"], + "isUnique": true + }, + "expires_at_idx": { + "name": "expires_at_idx", + "columns": ["expiresAt"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "mcp_oauth_codes_id": { + "name": "mcp_oauth_codes_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "mcp_oauth_registration_quotas": { + "name": "mcp_oauth_registration_quotas", + "columns": { + "windowId": { + "name": "windowId", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "registrations": { + "name": "registrations", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "expiresAt": { + "name": "expiresAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "expires_at_idx": { + "name": "expires_at_idx", + "columns": ["expiresAt"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "mcp_oauth_registration_quotas_windowId": { + "name": "mcp_oauth_registration_quotas_windowId", + "columns": ["windowId"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "mcp_oauth_tokens": { + "name": "mcp_oauth_tokens", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "clientId": { + "name": "clientId", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "familyId": { + "name": "familyId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "resource": { + "name": "resource", + "type": "varchar(512)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "accessHash": { + "name": "accessHash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "refreshHash": { + "name": "refreshHash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "accessExpiresAt": { + "name": "accessExpiresAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "refreshExpiresAt": { + "name": "refreshExpiresAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "revokedAt": { + "name": "revokedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": { + "access_hash_idx": { + "name": "access_hash_idx", + "columns": ["accessHash"], + "isUnique": true + }, + "refresh_hash_idx": { + "name": "refresh_hash_idx", + "columns": ["refreshHash"], + "isUnique": true + }, + "family_id_idx": { + "name": "family_id_idx", + "columns": ["familyId"], + "isUnique": false + }, + "client_active_idx": { + "name": "client_active_idx", + "columns": ["clientId", "revokedAt", "refreshExpiresAt"], + "isUnique": false + }, + "refresh_expires_at_idx": { + "name": "refresh_expires_at_idx", + "columns": ["refreshExpiresAt"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "mcp_oauth_tokens_id": { + "name": "mcp_oauth_tokens_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "media_processing_budgets": { + "name": "media_processing_budgets", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reserved_bytes": { + "name": "reserved_bytes", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "limit_bytes": { + "name": "limit_bytes", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "datetime(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "media_budget_expiry_idx": { + "name": "media_budget_expiry_idx", + "columns": ["expires_at"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "media_processing_budgets_id": { + "name": "media_processing_budgets_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "messenger_conversations": { + "name": "messenger_conversations", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "agent": { + "name": "agent", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "mode": { + "name": "mode", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'agent'" + }, + "userId": { + "name": "userId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "anonymousId": { + "name": "anonymousId", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "takeoverByUserId": { + "name": "takeoverByUserId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "takeoverAt": { + "name": "takeoverAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + }, + "lastMessageAt": { + "name": "lastMessageAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": { + "user_last_message_idx": { + "name": "user_last_message_idx", + "columns": ["userId", "lastMessageAt"], + "isUnique": false + }, + "anonymous_last_message_idx": { + "name": "anonymous_last_message_idx", + "columns": ["anonymousId", "lastMessageAt"], + "isUnique": false + }, + "mode_last_message_idx": { + "name": "mode_last_message_idx", + "columns": ["mode", "lastMessageAt"], + "isUnique": false + }, + "updated_at_idx": { + "name": "updated_at_idx", + "columns": ["updatedAt"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "messenger_conversations_id": { + "name": "messenger_conversations_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "messenger_messages": { + "name": "messenger_messages", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "conversationId": { + "name": "conversationId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "anonymousId": { + "name": "anonymousId", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": { + "conversation_created_at_idx": { + "name": "conversation_created_at_idx", + "columns": ["conversationId", "createdAt"], + "isUnique": false + }, + "role_created_at_idx": { + "name": "role_created_at_idx", + "columns": ["role", "createdAt"], + "isUnique": false + } + }, + "foreignKeys": { + "messenger_messages_conversationId_messenger_conversations_id_fk": { + "name": "messenger_messages_conversationId_messenger_conversations_id_fk", + "tableFrom": "messenger_messages", + "tableTo": "messenger_conversations", + "columnsFrom": ["conversationId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "messenger_messages_id": { + "name": "messenger_messages_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "messenger_support_emails": { + "name": "messenger_support_emails", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "conversationId": { + "name": "conversationId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userEmail": { + "name": "userEmail", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": { + "support_email_user_created_at_idx": { + "name": "support_email_user_created_at_idx", + "columns": ["userId", "createdAt"], + "isUnique": false + }, + "support_email_conversation_created_at_idx": { + "name": "support_email_conversation_created_at_idx", + "columns": ["conversationId", "createdAt"], + "isUnique": false + } + }, + "foreignKeys": { + "support_email_conversation_fk": { + "name": "support_email_conversation_fk", + "tableFrom": "messenger_support_emails", + "tableTo": "messenger_conversations", + "columnsFrom": ["conversationId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "messenger_support_emails_id": { + "name": "messenger_support_emails_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "mobile_push_claims": { + "name": "mobile_push_claims", + "columns": { + "id": { + "name": "id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": { + "created_at_idx": { + "name": "created_at_idx", + "columns": ["createdAt"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "mobile_push_claims_id": { + "name": "mobile_push_claims_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "mobile_push_tokens": { + "name": "mobile_push_tokens", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sessionHash": { + "name": "sessionHash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "environment": { + "name": "environment", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "token_idx": { + "name": "token_idx", + "columns": ["token"], + "isUnique": true + }, + "user_id_idx": { + "name": "user_id_idx", + "columns": ["userId"], + "isUnique": false + }, + "session_hash_idx": { + "name": "session_hash_idx", + "columns": ["sessionHash"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "mobile_push_tokens_id": { + "name": "mobile_push_tokens_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "notifications": { + "name": "notifications", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "orgId": { + "name": "orgId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "recipientId": { + "name": "recipientId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "data": { + "name": "data", + "type": "json", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "videoId": { + "name": "videoId", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "dedupKey": { + "name": "dedupKey", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "readAt": { + "name": "readAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": { + "org_id_idx": { + "name": "org_id_idx", + "columns": ["orgId"], + "isUnique": false + }, + "type_idx": { + "name": "type_idx", + "columns": ["type"], + "isUnique": false + }, + "read_at_idx": { + "name": "read_at_idx", + "columns": ["readAt"], + "isUnique": false + }, + "created_at_idx": { + "name": "created_at_idx", + "columns": ["createdAt"], + "isUnique": false + }, + "recipient_read_idx": { + "name": "recipient_read_idx", + "columns": ["recipientId", "readAt"], + "isUnique": false + }, + "recipient_created_idx": { + "name": "recipient_created_idx", + "columns": ["recipientId", "createdAt"], + "isUnique": false + }, + "dedup_key_idx": { + "name": "dedup_key_idx", + "columns": ["dedupKey"], + "isUnique": true + }, + "type_recipient_created_idx": { + "name": "type_recipient_created_idx", + "columns": ["type", "recipientId", "createdAt"], + "isUnique": false + }, + "type_recipient_video_created_idx": { + "name": "type_recipient_video_created_idx", + "columns": ["type", "recipientId", "videoId", "createdAt"], + "isUnique": false + }, + "video_id_idx": { + "name": "video_id_idx", + "columns": ["videoId"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "notifications_id": { + "name": "notifications_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "organization_directory_sync": { + "name": "organization_directory_sync", + "columns": { + "organizationId": { + "name": "organizationId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "requestedAt": { + "name": "requestedAt", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "syncedAt": { + "name": "syncedAt", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "leaseExpiresAt": { + "name": "leaseExpiresAt", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "lastError": { + "name": "lastError", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "heldRemovals": { + "name": "heldRemovals", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "removalsApprovedAt": { + "name": "removalsApprovedAt", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "organization_directory_sync_organizationId": { + "name": "organization_directory_sync_organizationId", + "columns": ["organizationId"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "organization_invites": { + "name": "organization_invites", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "organizationId": { + "name": "organizationId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "invitedEmail": { + "name": "invitedEmail", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "invitedByUserId": { + "name": "invitedByUserId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + }, + "expiresAt": { + "name": "expiresAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "organization_id_idx": { + "name": "organization_id_idx", + "columns": ["organizationId"], + "isUnique": false + }, + "invited_email_idx": { + "name": "invited_email_idx", + "columns": ["invitedEmail"], + "isUnique": false + }, + "invited_by_user_id_idx": { + "name": "invited_by_user_id_idx", + "columns": ["invitedByUserId"], + "isUnique": false + }, + "status_idx": { + "name": "status_idx", + "columns": ["status"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "organization_invites_id": { + "name": "organization_invites_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "organization_members": { + "name": "organization_members", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "organizationId": { + "name": "organizationId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "hasProSeat": { + "name": "hasProSeat", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "organization_id_idx": { + "name": "organization_id_idx", + "columns": ["organizationId"], + "isUnique": false + }, + "user_id_organization_id_idx": { + "name": "user_id_organization_id_idx", + "columns": ["userId", "organizationId"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "organization_members_id": { + "name": "organization_members_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "organization_sso": { + "name": "organization_sso", + "columns": { + "organizationId": { + "name": "organizationId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "purchasedByUserId": { + "name": "purchasedByUserId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "stripeCustomerId": { + "name": "stripeCustomerId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "stripeSubscriptionId": { + "name": "stripeSubscriptionId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "stripePriceId": { + "name": "stripePriceId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'unpaid'" + }, + "paidThrough": { + "name": "paidThrough", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "currentPeriodEnd": { + "name": "currentPeriodEnd", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cancelAtPeriodEnd": { + "name": "cancelAtPeriodEnd", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "checkoutAttemptId": { + "name": "checkoutAttemptId", + "type": "varchar(36)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "checkoutCurrency": { + "name": "checkoutCurrency", + "type": "varchar(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "checkoutPriceId": { + "name": "checkoutPriceId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "checkoutSessionId": { + "name": "checkoutSessionId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "checkoutStartedAt": { + "name": "checkoutStartedAt", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "sso_stripe_subscription_id_idx": { + "name": "sso_stripe_subscription_id_idx", + "columns": ["stripeSubscriptionId"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "organization_sso_organizationId": { + "name": "organization_sso_organizationId", + "columns": ["organizationId"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "organizations": { + "name": "organizations", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ownerId": { + "name": "ownerId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "tombstoneAt": { + "name": "tombstoneAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "allowedEmailDomain": { + "name": "allowedEmailDomain", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "defaultVideoVisibility": { + "name": "defaultVideoVisibility", + "type": "varchar(7)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "customDomain": { + "name": "customDomain", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "domainVerified": { + "name": "domainVerified", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "settings": { + "name": "settings", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "iconUrl": { + "name": "iconUrl", + "type": "varchar(1024)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "shareableLinkIconUrl": { + "name": "shareableLinkIconUrl", + "type": "varchar(1024)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + }, + "workosOrganizationId": { + "name": "workosOrganizationId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "workosConnectionId": { + "name": "workosConnectionId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "owner_id_tombstone_idx": { + "name": "owner_id_tombstone_idx", + "columns": ["ownerId", "tombstoneAt"], + "isUnique": false + }, + "custom_domain_idx": { + "name": "custom_domain_idx", + "columns": ["customDomain"], + "isUnique": false + }, + "workos_organization_id_idx": { + "name": "workos_organization_id_idx", + "columns": ["workosOrganizationId"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "organizations_id": { + "name": "organizations_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "s3_buckets": { + "name": "s3_buckets", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ownerId": { + "name": "ownerId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "organizationId": { + "name": "organizationId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "region": { + "name": "region", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "endpoint": { + "name": "endpoint", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "bucketName": { + "name": "bucketName", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "accessKeyId": { + "name": "accessKeyId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "secretAccessKey": { + "name": "secretAccessKey", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "('aws')" + }, + "active": { + "name": "active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "owner_organization_idx": { + "name": "owner_organization_idx", + "columns": ["ownerId", "organizationId"], + "isUnique": false + }, + "organization_id_idx": { + "name": "organization_id_idx", + "columns": ["organizationId"], + "isUnique": false + }, + "organization_active_idx": { + "name": "organization_active_idx", + "columns": ["organizationId", "active", "updatedAt"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "s3_buckets_id": { + "name": "s3_buckets_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "sessions": { + "name": "sessions", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sessionToken": { + "name": "sessionToken", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires": { + "name": "expires", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "session_token_idx": { + "name": "session_token_idx", + "columns": ["sessionToken"], + "isUnique": true + }, + "user_id_idx": { + "name": "user_id_idx", + "columns": ["userId"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "sessions_id": { + "name": "sessions_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "shared_videos": { + "name": "shared_videos", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "videoId": { + "name": "videoId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "folderId": { + "name": "folderId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "organizationId": { + "name": "organizationId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sharedByUserId": { + "name": "sharedByUserId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sharedAt": { + "name": "sharedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": { + "folder_id_idx": { + "name": "folder_id_idx", + "columns": ["folderId"], + "isUnique": false + }, + "organization_id_idx": { + "name": "organization_id_idx", + "columns": ["organizationId"], + "isUnique": false + }, + "shared_by_user_id_idx": { + "name": "shared_by_user_id_idx", + "columns": ["sharedByUserId"], + "isUnique": false + }, + "video_id_organization_id_idx": { + "name": "video_id_organization_id_idx", + "columns": ["videoId", "organizationId"], + "isUnique": false + }, + "video_id_folder_id_idx": { + "name": "video_id_folder_id_idx", + "columns": ["videoId", "folderId"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "shared_videos_id": { + "name": "shared_videos_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "signed_baas": { + "name": "signed_baas", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "organizationId": { + "name": "organizationId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "stripeSubscriptionId": { + "name": "stripeSubscriptionId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "entityName": { + "name": "entityName", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "entityType": { + "name": "entityType", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "entityAddress": { + "name": "entityAddress", + "type": "varchar(500)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "signerName": { + "name": "signerName", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "signerTitle": { + "name": "signerTitle", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "noticesEmail": { + "name": "noticesEmail", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "signatureData": { + "name": "signatureData", + "type": "longtext", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "signedAt": { + "name": "signedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "emailSentAt": { + "name": "emailSentAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "signed_baa_organization_id_idx": { + "name": "signed_baa_organization_id_idx", + "columns": ["organizationId"], + "isUnique": true + }, + "signed_baa_stripe_subscription_idx": { + "name": "signed_baa_stripe_subscription_idx", + "columns": ["stripeSubscriptionId"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "signed_baas_id": { + "name": "signed_baas_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "space_members": { + "name": "space_members", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "spaceId": { + "name": "spaceId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'member'" + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "user_id_idx": { + "name": "user_id_idx", + "columns": ["userId"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "space_members_id": { + "name": "space_members_id", + "columns": ["id"] + } + }, + "uniqueConstraints": { + "space_id_user_id_unique": { + "name": "space_id_user_id_unique", + "columns": ["spaceId", "userId"] + } + }, + "checkConstraint": {} + }, + "space_videos": { + "name": "space_videos", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "spaceId": { + "name": "spaceId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "folderId": { + "name": "folderId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "videoId": { + "name": "videoId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "addedById": { + "name": "addedById", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "addedAt": { + "name": "addedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": { + "folder_id_idx": { + "name": "folder_id_idx", + "columns": ["folderId"], + "isUnique": false + }, + "video_id_idx": { + "name": "video_id_idx", + "columns": ["videoId"], + "isUnique": false + }, + "added_by_id_idx": { + "name": "added_by_id_idx", + "columns": ["addedById"], + "isUnique": false + }, + "space_id_video_id_idx": { + "name": "space_id_video_id_idx", + "columns": ["spaceId", "videoId"], + "isUnique": false + }, + "space_id_folder_id_idx": { + "name": "space_id_folder_id_idx", + "columns": ["spaceId", "folderId"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "space_videos_id": { + "name": "space_videos_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "spaces": { + "name": "spaces", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "primary": { + "name": "primary", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "organizationId": { + "name": "organizationId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdById": { + "name": "createdById", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "iconUrl": { + "name": "iconUrl", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "varchar(1000)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "settings": { + "name": "settings", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + }, + "privacy": { + "name": "privacy", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'Private'" + }, + "public": { + "name": "public", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + } + }, + "indexes": { + "organization_id_idx": { + "name": "organization_id_idx", + "columns": ["organizationId"], + "isUnique": false + }, + "created_by_id_idx": { + "name": "created_by_id_idx", + "columns": ["createdById"], + "isUnique": false + }, + "public_organization_id_idx": { + "name": "public_organization_id_idx", + "columns": ["public", "organizationId"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "spaces_id": { + "name": "spaces_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "storage_integrations": { + "name": "storage_integrations", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ownerId": { + "name": "ownerId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "organizationId": { + "name": "organizationId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "displayName": { + "name": "displayName", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'active'" + }, + "active": { + "name": "active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "encryptedConfig": { + "name": "encryptedConfig", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "googleDriveAccessToken": { + "name": "googleDriveAccessToken", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "googleDriveAccessTokenExpiresAt": { + "name": "googleDriveAccessTokenExpiresAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "googleDriveTokenRefreshLeaseId": { + "name": "googleDriveTokenRefreshLeaseId", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "googleDriveTokenRefreshLeaseExpiresAt": { + "name": "googleDriveTokenRefreshLeaseExpiresAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "googleDriveStorageQuotaCache": { + "name": "googleDriveStorageQuotaCache", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "owner_provider_idx": { + "name": "owner_provider_idx", + "columns": ["ownerId", "provider"], + "isUnique": false + }, + "owner_active_idx": { + "name": "owner_active_idx", + "columns": ["ownerId", "active"], + "isUnique": false + }, + "organization_provider_idx": { + "name": "organization_provider_idx", + "columns": ["organizationId", "provider"], + "isUnique": false + }, + "organization_active_idx": { + "name": "organization_active_idx", + "columns": ["organizationId", "active", "status"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "storage_integrations_id": { + "name": "storage_integrations_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "storage_objects": { + "name": "storage_objects", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "integrationId": { + "name": "integrationId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ownerId": { + "name": "ownerId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "videoId": { + "name": "videoId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "objectKey": { + "name": "objectKey", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "objectKeyHash": { + "name": "objectKeyHash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "providerObjectId": { + "name": "providerObjectId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "uploadSessionUrl": { + "name": "uploadSessionUrl", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "uploadStatus": { + "name": "uploadStatus", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "contentType": { + "name": "contentType", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "contentLength": { + "name": "contentLength", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "integration_key_hash_idx": { + "name": "integration_key_hash_idx", + "columns": ["integrationId", "objectKeyHash"], + "isUnique": true + }, + "integration_status_idx": { + "name": "integration_status_idx", + "columns": ["integrationId", "uploadStatus"], + "isUnique": false + }, + "integration_object_key_prefix_idx": { + "name": "integration_object_key_prefix_idx", + "columns": ["integrationId", "`objectKey`(191)"], + "isUnique": false + }, + "video_id_idx": { + "name": "video_id_idx", + "columns": ["videoId"], + "isUnique": false + }, + "owner_id_idx": { + "name": "owner_id_idx", + "columns": ["ownerId"], + "isUnique": false + } + }, + "foreignKeys": { + "storage_objects_integrationId_storage_integrations_id_fk": { + "name": "storage_objects_integrationId_storage_integrations_id_fk", + "tableFrom": "storage_objects", + "tableTo": "storage_integrations", + "columnsFrom": ["integrationId"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "storage_objects_id": { + "name": "storage_objects_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "lastName": { + "name": "lastName", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "emailVerified": { + "name": "emailVerified", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "image": { + "name": "image", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "stripeCustomerId": { + "name": "stripeCustomerId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "stripeSubscriptionId": { + "name": "stripeSubscriptionId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "thirdPartyStripeSubscriptionId": { + "name": "thirdPartyStripeSubscriptionId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "stripeSubscriptionStatus": { + "name": "stripeSubscriptionStatus", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "stripeSubscriptionPriceId": { + "name": "stripeSubscriptionPriceId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "preferences": { + "name": "preferences", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "('null')" + }, + "activeOrganizationId": { + "name": "activeOrganizationId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + }, + "onboardingSteps": { + "name": "onboardingSteps", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "onboarding_completed_at": { + "name": "onboarding_completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "customBucket": { + "name": "customBucket", + "type": "varchar(15)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "inviteQuota": { + "name": "inviteQuota", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "defaultOrgId": { + "name": "defaultOrgId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "authSessionVersion": { + "name": "authSessionVersion", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "marketingOrigin": { + "name": "marketingOrigin", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'unknown'" + } + }, + "indexes": { + "email_idx": { + "name": "email_idx", + "columns": ["email"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "users_id": { + "name": "users_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "verification_tokens": { + "name": "verification_tokens", + "columns": { + "identifier": { + "name": "identifier", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires": { + "name": "expires", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "verification_tokens_identifier": { + "name": "verification_tokens_identifier", + "columns": ["identifier"] + } + }, + "uniqueConstraints": { + "verification_tokens_token_unique": { + "name": "verification_tokens_token_unique", + "columns": ["token"] + } + }, + "checkConstraint": {} + }, + "video_edits": { + "name": "video_edits", + "columns": { + "videoId": { + "name": "videoId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sourceKey": { + "name": "sourceKey", + "type": "varchar(512)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "editSpec": { + "name": "editSpec", + "type": "json", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": { + "video_edits_videoId_videos_id_fk": { + "name": "video_edits_videoId_videos_id_fk", + "tableFrom": "video_edits", + "tableTo": "videos", + "columnsFrom": ["videoId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "video_edits_videoId": { + "name": "video_edits_videoId", + "columns": ["videoId"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "video_processing_jobs": { + "name": "video_processing_jobs", + "columns": { + "video_id": { + "name": "video_id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "owner_id": { + "name": "owner_id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "generation": { + "name": "generation", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "manifest_sha256": { + "name": "manifest_sha256", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "state": { + "name": "state", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'committing'" + }, + "attempt_id": { + "name": "attempt_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "attempt_count": { + "name": "attempt_count", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "lease_expires_at": { + "name": "lease_expires_at", + "type": "datetime(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "next_retry_at": { + "name": "next_retry_at", + "type": "datetime(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workflow_run_id": { + "name": "workflow_run_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "remote_job_id": { + "name": "remote_job_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "verification": { + "name": "verification", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "output": { + "name": "output", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error_code": { + "name": "error_code", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "datetime(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "processing_state_retry_video_idx": { + "name": "processing_state_retry_video_idx", + "columns": ["state", "next_retry_at", "video_id"], + "isUnique": false + }, + "processing_state_lease_video_idx": { + "name": "processing_state_lease_video_idx", + "columns": ["state", "lease_expires_at", "video_id"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "video_processing_jobs_video_id": { + "name": "video_processing_jobs_video_id", + "columns": ["video_id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "video_uploads": { + "name": "video_uploads", + "columns": { + "video_id": { + "name": "video_id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "uploaded": { + "name": "uploaded", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "total": { + "name": "total", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "mode": { + "name": "mode", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "phase": { + "name": "phase", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'uploading'" + }, + "processing_progress": { + "name": "processing_progress", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "processing_message": { + "name": "processing_message", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "processing_error": { + "name": "processing_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "raw_file_key": { + "name": "raw_file_key", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "phase_updated_at_video_id_idx": { + "name": "phase_updated_at_video_id_idx", + "columns": ["phase", "updated_at", "video_id"], + "isUnique": false + }, + "started_at_idx": { + "name": "started_at_idx", + "columns": ["started_at"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "video_uploads_video_id": { + "name": "video_uploads_video_id", + "columns": ["video_id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "video_viewer_grants": { + "name": "video_viewer_grants", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "videoId": { + "name": "videoId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "invitedByUserId": { + "name": "invitedByUserId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "revokedAt": { + "name": "revokedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "video_email_idx": { + "name": "video_email_idx", + "columns": ["videoId", "email"], + "isUnique": true + } + }, + "foreignKeys": { + "video_viewer_grants_videoId_videos_id_fk": { + "name": "video_viewer_grants_videoId_videos_id_fk", + "tableFrom": "video_viewer_grants", + "tableTo": "videos", + "columnsFrom": ["videoId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "video_viewer_grants_id": { + "name": "video_viewer_grants_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "videos": { + "name": "videos", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ownerId": { + "name": "ownerId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "orgId": { + "name": "orgId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'My Video'" + }, + "bucket": { + "name": "bucket", + "type": "varchar(15)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "storageIntegrationId": { + "name": "storageIntegrationId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "duration": { + "name": "duration", + "type": "float", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "width": { + "name": "width", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "height": { + "name": "height", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "fps": { + "name": "fps", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "public": { + "name": "public", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "settings": { + "name": "settings", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "transcriptionStatus": { + "name": "transcriptionStatus", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "json", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "('{\"type\":\"MediaConvert\"}')" + }, + "folderId": { + "name": "folderId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "effectiveCreatedAt": { + "name": "effectiveCreatedAt", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "generated": { + "as": "COALESCE(\n STR_TO_DATE(JSON_UNQUOTE(JSON_EXTRACT(`metadata`, '$.customCreatedAt')), '%Y-%m-%dT%H:%i:%s.%fZ'),\n STR_TO_DATE(JSON_UNQUOTE(JSON_EXTRACT(`metadata`, '$.customCreatedAt')), '%Y-%m-%dT%H:%i:%sZ'),\n `createdAt`\n )", + "type": "stored" + } + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "xStreamInfo": { + "name": "xStreamInfo", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "firstViewEmailSentAt": { + "name": "firstViewEmailSentAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "isScreenshot": { + "name": "isScreenshot", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "awsRegion": { + "name": "awsRegion", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "awsBucket": { + "name": "awsBucket", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "videoStartTime": { + "name": "videoStartTime", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "audioStartTime": { + "name": "audioStartTime", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "jobId": { + "name": "jobId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "jobStatus": { + "name": "jobStatus", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "skipProcessing": { + "name": "skipProcessing", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + } + }, + "indexes": { + "owner_id_idx": { + "name": "owner_id_idx", + "columns": ["ownerId"], + "isUnique": false + }, + "owner_updated_id_idx": { + "name": "owner_updated_id_idx", + "columns": ["ownerId", "updatedAt", "id"], + "isUnique": false + }, + "is_public_idx": { + "name": "is_public_idx", + "columns": ["public"], + "isUnique": false + }, + "folder_id_idx": { + "name": "folder_id_idx", + "columns": ["folderId"], + "isUnique": false + }, + "storage_integration_id_idx": { + "name": "storage_integration_id_idx", + "columns": ["storageIntegrationId"], + "isUnique": false + }, + "org_owner_folder_idx": { + "name": "org_owner_folder_idx", + "columns": ["orgId", "ownerId", "folderId"], + "isUnique": false + }, + "org_owner_folder_effective_idx": { + "name": "org_owner_folder_effective_idx", + "columns": ["orgId", "ownerId", "folderId", "effectiveCreatedAt"], + "isUnique": false + }, + "org_effective_created_idx": { + "name": "org_effective_created_idx", + "columns": ["orgId", "effectiveCreatedAt"], + "isUnique": false + }, + "screenshot_transcription_created_idx": { + "name": "screenshot_transcription_created_idx", + "columns": ["isScreenshot", "transcriptionStatus", "createdAt"], + "isUnique": false + } + }, + "foreignKeys": { + "videos_storageIntegrationId_storage_integrations_id_fk": { + "name": "videos_storageIntegrationId_storage_integrations_id_fk", + "tableFrom": "videos", + "tableTo": "storage_integrations", + "columnsFrom": ["storageIntegrationId"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "videos_id": { + "name": "videos_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + } + }, + "views": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "tables": {}, + "indexes": { + "integration_object_key_prefix_idx": { + "columns": { + "`objectKey`(191)": { + "isExpression": true + } + } + } + } + } +} diff --git a/packages/database/migrations/meta/_journal.json b/packages/database/migrations/meta/_journal.json index 5813e80d308..b0e865f90da 100644 --- a/packages/database/migrations/meta/_journal.json +++ b/packages/database/migrations/meta/_journal.json @@ -407,6 +407,13 @@ "when": 1794545935647, "tag": "0057_developer_transcript_video", "breakpoints": true + }, + { + "idx": 58, + "version": "5", + "when": 1794549219180, + "tag": "0058_developer_webhook_endpoints", + "breakpoints": true } ] } diff --git a/packages/database/schema.ts b/packages/database/schema.ts index 886bdb548a0..c3780460514 100644 --- a/packages/database/schema.ts +++ b/packages/database/schema.ts @@ -2178,6 +2178,42 @@ export const developerDailyStorageSnapshots = mysqlTable( (table) => [unique("app_date_unique").on(table.appId, table.snapshotDate)], ); +export const developerWebhookEndpoints = mysqlTable( + "developer_webhook_endpoints", + { + id: nanoId("id").notNull().primaryKey(), + appId: nanoId("appId") + .notNull() + .references(() => developerApps.id), + url: varchar("url", { length: 2048 }).notNull(), + secretPrefix: varchar("secretPrefix", { length: 12 }).notNull(), + encryptedSecret: encryptedText("encryptedSecret").notNull(), + events: json("events").notNull().$type(), + enabled: boolean("enabled").notNull().default(true), + disabledReason: varchar("disabledReason", { length: 32 }), + lastDeliveryAt: timestamp("lastDeliveryAt"), + lastDeliveryStatus: varchar("lastDeliveryStatus", { + length: 16, + enum: ["succeeded", "failed"], + }), + lastResponseStatus: int("lastResponseStatus"), + lastErrorCode: varchar("lastErrorCode", { length: 32 }), + consecutiveFailures: int("consecutiveFailures").notNull().default(0), + failingSince: timestamp("failingSince"), + deletedAt: timestamp("deletedAt"), + createdAt: timestamp("createdAt").notNull().defaultNow(), + updatedAt: timestamp("updatedAt").notNull().defaultNow().onUpdateNow(), + }, + (table) => [ + index("dev_webhooks_app_list_idx").on( + table.appId, + table.deletedAt, + table.createdAt, + table.id, + ), + ], +); + export const developerAppsRelations = relations( developerApps, ({ one, many }) => ({ @@ -2270,3 +2306,13 @@ export const developerDailyStorageSnapshotsRelations = relations( }), }), ); + +export const developerWebhookEndpointsRelations = relations( + developerWebhookEndpoints, + ({ one }) => ({ + app: one(developerApps, { + fields: [developerWebhookEndpoints.appId], + references: [developerApps.id], + }), + }), +);