diff --git a/package.json b/package.json index 9e177c8..3b98463 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "tool:restore": "tsx tools/restore.ts", "tool:pruneBackups": "tsx tools/pruneBackups.ts", "tool:cleanupSessions": "tsx tools/cleanupSessions.ts", + "tool:pruneWebhookLogs": "tsx tools/pruneWebhookLogs.ts", "tool:seed-mirror": "tsx tools/seedMirror.ts", "cli": "tsx src/cli/cli.ts", "test": "vitest run", diff --git a/server.ts b/server.ts index f5a93f5..251843b 100644 --- a/server.ts +++ b/server.ts @@ -30,9 +30,11 @@ import { rateLimitMiddleware } from '~/api/rate-limit.server' import _records from '~/api/records' import _schemas from '~/api/schemas' import _versions from '~/api/versions' +import _webhooks from '~/api/webhooks' import { auth } from '~/lib/auth' import { getSessionUser } from '~/lib/auth.server' import { getMirrorConfig } from '~/lib/mirror-config' +import { startWebhookBackgroundJobs } from '~/lib/webhooks.server' const isProd = process.env.NODE_ENV === 'production' let vite: ViteDevServer | undefined @@ -229,11 +231,15 @@ const routes = app .route(...api('/api', './src/api/collections.ts', _collections)) .route(...api('/api/collections', './src/api/versions.ts', _versions)) .route(...api('/api/collections', './src/api/negotiate.ts', _negotiate)) + .route(...api('/api/collections', './src/api/webhooks.ts', _webhooks)) .route(...api('/api', './src/api/discussion.ts', _discussion)) .route(...api('/api/organizations', './src/api/organizations.ts', _organizations)) export type AppType = typeof routes +// Retry sweep + delivery-log purge for webhooks (in-process, unref'd intervals) +startWebhookBackgroundJobs() + // --- Legacy API routes (not yet converted to subapp pattern) --- app.get('/api/health', health.check) diff --git a/src/api/negotiate.ts b/src/api/negotiate.ts index 413d600..5cbf9cf 100644 --- a/src/api/negotiate.ts +++ b/src/api/negotiate.ts @@ -25,6 +25,11 @@ import { type SchemaEntry, stripToSchema, } from '../lib/version-helpers.server.js' +import { + bumpTypeFromChanges, + dispatchDeliveries, + enqueueWebhookDeliveries, +} from '../lib/webhooks.server.js' import { requireAuth, type AuthEnv } from './auth.server.js' const SESSION_TTL_MS = 10 * 60 * 1000 @@ -975,6 +980,27 @@ app.post( .set({ status: 'committed' }) .where(eq(schema.negotiateSessions.id, sessionId)) + // Fire webhooks for the new version — best-effort, never blocks/denies the 201. + try { + const deliveryIds = await enqueueWebhookDeliveries( + { + id: versionId!, + semver: sv.semver, + hash: versionHash, + major: sv.major, + minor: sv.minor, + patch: sv.patch, + recordCount: finalRecordHashes.length, + fileCount: session.fileHashes.length, + }, + bumpTypeFromChanges(schemaChanged, recordsChanged), + session.collectionId, + ) + dispatchDeliveries(deliveryIds) + } catch (err) { + console.error(`[webhooks] failed to enqueue for ${sv.semver}:`, err) + } + return c.json( { semver: sv.semver, diff --git a/src/api/versions.ts b/src/api/versions.ts index d2aab3a..f9ed321 100644 --- a/src/api/versions.ts +++ b/src/api/versions.ts @@ -23,6 +23,7 @@ import { resolveCollection, type SchemaEntry, } from '../lib/version-helpers.server.js' +import { dispatchDeliveries, enqueueWebhookDeliveries } from '../lib/webhooks.server.js' import { type AuthEnv, requireAuth } from './auth.server.js' const MAX_METADATA_BYTES = 64 * 1024 @@ -953,6 +954,7 @@ const app = new Hono() const sv = deriveSemver(latest.semver, false, false, true) + let newVersionId: number | undefined await db.transaction(async (tx) => { const [version] = await tx .insert(schema.versions) @@ -974,6 +976,8 @@ const app = new Hono() }) .returning({ id: schema.versions.id }) + newVersionId = version!.id + if (schemaEntries.length > 0) { await tx.insert(schema.versionSchemas).values( schemaEntries.map((e) => ({ @@ -1007,6 +1011,27 @@ const app = new Hono() .where(eq(schema.collections.id, collection.id)) }) + // Fire webhooks for the metadata (patch) version — best-effort. + try { + const deliveryIds = await enqueueWebhookDeliveries( + { + id: newVersionId!, + semver: sv.semver, + hash: versionHash, + major: sv.major, + minor: sv.minor, + patch: sv.patch, + recordCount: latest.recordCount, + fileCount: latest.fileCount, + }, + 'patch', + collection.id, + ) + dispatchDeliveries(deliveryIds) + } catch (err) { + console.error(`[webhooks] failed to enqueue for ${sv.semver}:`, err) + } + return c.json({ semver: sv.semver, hash: versionHash, metadata: newMetadata }, 201) }, ) diff --git a/src/api/webhooks.ts b/src/api/webhooks.ts new file mode 100644 index 0000000..faf51eb --- /dev/null +++ b/src/api/webhooks.ts @@ -0,0 +1,362 @@ +import { and, desc, eq } from 'drizzle-orm' +import { type Context, Hono } from 'hono' +import { openApi } from 'hono-zod-openapi' +import { z } from 'zod' + +import { db, schema } from '../db/client.server.js' +import { getOrgRole, resolveCollection } from '../lib/version-helpers.server.js' +import { + dispatchDeliveries, + generateWebhookSecret, + retryDelivery, + validateWebhookUrl, +} from '../lib/webhooks.server.js' +import { type AuthEnv, requireAuth } from './auth.server.js' + +const app = new Hono() + +const bumpFilterSchema = z + .array(z.enum(['major', 'minor', 'patch'])) + .min(1, 'Select at least one version type') + +/** + * Resolve the collection and verify the caller may manage its webhooks: + * owner/admin role in the owning org, plus API-key collection scoping. + * Webhook secrets are sensitive, so management is gated to owner/admin + * (mirrors the delete-collection gate), not any org member. + */ +async function authorizeWebhookAccess( + c: Context, + owner: string, + slug: string, +): Promise<{ collectionId: string } | { error: Response }> { + const collection = await resolveCollection(owner, slug) + if (!collection) { + return { error: c.json({ error: 'Not found', statusCode: 404 }, 404) } + } + const role = await getOrgRole(c.get('userId'), collection.organizationId) + if (role !== 'owner' && role !== 'admin') { + return { error: c.json({ error: 'Forbidden', statusCode: 403 }, 403) } + } + const scoped = c.get('apiKeyCollectionIds') + if (scoped && !scoped.includes(collection.id)) { + return { + error: c.json({ error: 'API key is not scoped to this collection', statusCode: 403 }, 403), + } + } + return { collectionId: collection.id } +} + +// List webhooks (secrets omitted) +app.get( + '/:owner/:slug/webhooks', + requireAuth('write'), + openApi({ + tags: ['Webhooks'], + summary: 'List webhooks for a collection', + request: { param: z.object({ owner: z.string(), slug: z.string() }) }, + responses: { 200: z.any() }, + }), + async (c) => { + const { owner, slug } = c.req.valid('param') + const auth = await authorizeWebhookAccess(c, owner, slug) + if ('error' in auth) return auth.error + + const hooks = await db + .select({ + id: schema.collectionWebhooks.id, + url: schema.collectionWebhooks.url, + bumpFilter: schema.collectionWebhooks.bumpFilter, + enabled: schema.collectionWebhooks.enabled, + createdAt: schema.collectionWebhooks.createdAt, + lastDeliveryAt: schema.collectionWebhooks.lastDeliveryAt, + }) + .from(schema.collectionWebhooks) + .where(eq(schema.collectionWebhooks.collectionId, auth.collectionId)) + .orderBy(desc(schema.collectionWebhooks.createdAt)) + + return c.json({ webhooks: hooks }) + }, +) + +// Create a webhook — returns the signing secret once +app.post( + '/:owner/:slug/webhooks', + requireAuth('write'), + openApi({ + tags: ['Webhooks'], + summary: 'Create a webhook', + request: { + param: z.object({ owner: z.string(), slug: z.string() }), + json: z.object({ + url: z.string().url(), + bumpFilter: bumpFilterSchema.optional(), + enabled: z.boolean().optional(), + }), + }, + responses: { 201: z.any() }, + }), + async (c) => { + const { owner, slug } = c.req.valid('param') + const { url, bumpFilter, enabled } = c.req.valid('json') + const auth = await authorizeWebhookAccess(c, owner, slug) + if ('error' in auth) return auth.error + + const check = validateWebhookUrl(url) + if (!check.ok) return c.json({ error: check.reason, statusCode: 422 }, 422) + + const secret = generateWebhookSecret() + const [created] = await db + .insert(schema.collectionWebhooks) + .values({ + collectionId: auth.collectionId, + url: check.url, + bumpFilter: bumpFilter ?? ['major', 'minor', 'patch'], + enabled: enabled ?? true, + secret, + createdBy: c.get('userId') ?? null, + }) + .returning({ + id: schema.collectionWebhooks.id, + url: schema.collectionWebhooks.url, + bumpFilter: schema.collectionWebhooks.bumpFilter, + enabled: schema.collectionWebhooks.enabled, + createdAt: schema.collectionWebhooks.createdAt, + }) + + // Secret is shown exactly once, at creation time. + return c.json({ ...created, secret }, 201) + }, +) + +// Update a webhook +app.patch( + '/:owner/:slug/webhooks/:id', + requireAuth('write'), + openApi({ + tags: ['Webhooks'], + summary: 'Update a webhook', + request: { + param: z.object({ owner: z.string(), slug: z.string(), id: z.string() }), + json: z.object({ + url: z.string().url().optional(), + bumpFilter: bumpFilterSchema.optional(), + enabled: z.boolean().optional(), + }), + }, + responses: { 200: z.any() }, + }), + async (c) => { + const { owner, slug, id } = c.req.valid('param') + const updates = c.req.valid('json') + const auth = await authorizeWebhookAccess(c, owner, slug) + if ('error' in auth) return auth.error + + const set: Record = {} + if (updates.url !== undefined) { + const check = validateWebhookUrl(updates.url) + if (!check.ok) return c.json({ error: check.reason, statusCode: 422 }, 422) + set.url = check.url + } + if (updates.bumpFilter !== undefined) set.bumpFilter = updates.bumpFilter + if (updates.enabled !== undefined) set.enabled = updates.enabled + if (Object.keys(set).length === 0) return c.json({ ok: true }) + + const [updated] = await db + .update(schema.collectionWebhooks) + .set(set) + .where( + and( + eq(schema.collectionWebhooks.id, id), + eq(schema.collectionWebhooks.collectionId, auth.collectionId), + ), + ) + .returning({ + id: schema.collectionWebhooks.id, + url: schema.collectionWebhooks.url, + bumpFilter: schema.collectionWebhooks.bumpFilter, + enabled: schema.collectionWebhooks.enabled, + }) + + if (!updated) return c.json({ error: 'Not found', statusCode: 404 }, 404) + return c.json(updated) + }, +) + +// Delete a webhook (its deliveries cascade) +app.delete( + '/:owner/:slug/webhooks/:id', + requireAuth('write'), + openApi({ + tags: ['Webhooks'], + summary: 'Delete a webhook', + request: { param: z.object({ owner: z.string(), slug: z.string(), id: z.string() }) }, + responses: { 200: z.any() }, + }), + async (c) => { + const { owner, slug, id } = c.req.valid('param') + const auth = await authorizeWebhookAccess(c, owner, slug) + if ('error' in auth) return auth.error + + const deleted = await db + .delete(schema.collectionWebhooks) + .where( + and( + eq(schema.collectionWebhooks.id, id), + eq(schema.collectionWebhooks.collectionId, auth.collectionId), + ), + ) + .returning({ id: schema.collectionWebhooks.id }) + + if (deleted.length === 0) return c.json({ error: 'Not found', statusCode: 404 }, 404) + return c.json({ ok: true }) + }, +) + +// Delivery log for a webhook +app.get( + '/:owner/:slug/webhooks/:id/deliveries', + requireAuth('write'), + openApi({ + tags: ['Webhooks'], + summary: 'List recent deliveries for a webhook', + request: { param: z.object({ owner: z.string(), slug: z.string(), id: z.string() }) }, + responses: { 200: z.any() }, + }), + async (c) => { + const { owner, slug, id } = c.req.valid('param') + const auth = await authorizeWebhookAccess(c, owner, slug) + if ('error' in auth) return auth.error + + const parsedLimit = Number.parseInt(c.req.query('limit') ?? '50', 10) + const limit = Math.min(Number.isFinite(parsedLimit) ? parsedLimit : 50, 200) + + // Confirm the webhook belongs to this collection before listing its log. + const [hook] = await db + .select({ id: schema.collectionWebhooks.id }) + .from(schema.collectionWebhooks) + .where( + and( + eq(schema.collectionWebhooks.id, id), + eq(schema.collectionWebhooks.collectionId, auth.collectionId), + ), + ) + .limit(1) + if (!hook) return c.json({ error: 'Not found', statusCode: 404 }, 404) + + const deliveries = await db + .select({ + id: schema.webhookDeliveries.id, + event: schema.webhookDeliveries.event, + semver: schema.webhookDeliveries.semver, + bumpType: schema.webhookDeliveries.bumpType, + status: schema.webhookDeliveries.status, + attempts: schema.webhookDeliveries.attempts, + responseCode: schema.webhookDeliveries.responseCode, + error: schema.webhookDeliveries.error, + durationMs: schema.webhookDeliveries.durationMs, + createdAt: schema.webhookDeliveries.createdAt, + deliveredAt: schema.webhookDeliveries.deliveredAt, + }) + .from(schema.webhookDeliveries) + .where(eq(schema.webhookDeliveries.webhookId, id)) + .orderBy(desc(schema.webhookDeliveries.createdAt)) + .limit(limit) + + return c.json({ deliveries }) + }, +) + +// Send a test delivery +app.post( + '/:owner/:slug/webhooks/:id/test', + requireAuth('write'), + openApi({ + tags: ['Webhooks'], + summary: 'Send a test delivery', + request: { param: z.object({ owner: z.string(), slug: z.string(), id: z.string() }) }, + responses: { 200: z.any() }, + }), + async (c) => { + const { owner, slug, id } = c.req.valid('param') + const auth = await authorizeWebhookAccess(c, owner, slug) + if ('error' in auth) return auth.error + + const [hook] = await db + .select({ id: schema.collectionWebhooks.id }) + .from(schema.collectionWebhooks) + .where( + and( + eq(schema.collectionWebhooks.id, id), + eq(schema.collectionWebhooks.collectionId, auth.collectionId), + ), + ) + .limit(1) + if (!hook) return c.json({ error: 'Not found', statusCode: 404 }, 404) + + const payload = { + event: 'ping', + collection: { owner, slug }, + version: null, + bumpType: 'patch' as const, + test: true, + } + const [delivery] = await db + .insert(schema.webhookDeliveries) + .values({ + webhookId: id, + collectionId: auth.collectionId, + bumpType: 'patch', + event: 'ping', + payload, + status: 'pending', + }) + .returning({ id: schema.webhookDeliveries.id }) + + dispatchDeliveries([delivery!.id]) + return c.json({ ok: true, deliveryId: delivery!.id }) + }, +) + +// Retry a delivery +app.post( + '/:owner/:slug/webhooks/:id/deliveries/:deliveryId/retry', + requireAuth('write'), + openApi({ + tags: ['Webhooks'], + summary: 'Retry a delivery', + request: { + param: z.object({ + owner: z.string(), + slug: z.string(), + id: z.string(), + deliveryId: z.string(), + }), + }, + responses: { 200: z.any() }, + }), + async (c) => { + const { owner, slug, id, deliveryId } = c.req.valid('param') + const auth = await authorizeWebhookAccess(c, owner, slug) + if ('error' in auth) return auth.error + + const [delivery] = await db + .select({ id: schema.webhookDeliveries.id }) + .from(schema.webhookDeliveries) + .where( + and( + eq(schema.webhookDeliveries.id, deliveryId), + eq(schema.webhookDeliveries.collectionId, auth.collectionId), + eq(schema.webhookDeliveries.webhookId, id), + ), + ) + .limit(1) + if (!delivery) return c.json({ error: 'Not found', statusCode: 404 }, 404) + + const ok = await retryDelivery(deliveryId, auth.collectionId) + if (!ok) return c.json({ error: 'Not found', statusCode: 404 }, 404) + return c.json({ ok: true }) + }, +) + +export default app diff --git a/src/components/WebhooksSettings.tsx b/src/components/WebhooksSettings.tsx new file mode 100644 index 0000000..3323dde --- /dev/null +++ b/src/components/WebhooksSettings.tsx @@ -0,0 +1,349 @@ +import { type FormEvent, useState } from 'react' + +const BUMP_TYPES = ['major', 'minor', 'patch'] as const +type BumpType = (typeof BUMP_TYPES)[number] + +interface Webhook { + id: string + url: string + bumpFilter: string[] + enabled: boolean + createdAt: string + lastDeliveryAt: string | null +} + +interface Delivery { + id: string + event: string + semver: string | null + bumpType: string + status: 'pending' | 'success' | 'failed' + attempts: number + responseCode: number | null + error: string | null + durationMs: number | null + createdAt: string + deliveredAt: string | null +} + +const STATUS_STYLES: Record = { + success: 'text-green-700', + failed: 'text-red-700', + pending: 'text-ink-muted', +} + +export default function WebhooksSettings({ + owner, + collection, + initialWebhooks, +}: { + owner: string + collection: string + initialWebhooks: Webhook[] +}) { + const base = `/api/collections/${owner}/${collection}/webhooks` + + const [webhooks, setWebhooks] = useState(initialWebhooks) + const [url, setUrl] = useState('') + const [filter, setFilter] = useState(['major', 'minor', 'patch']) + const [submitting, setSubmitting] = useState(false) + const [error, setError] = useState('') + const [notice, setNotice] = useState('') + const [newSecret, setNewSecret] = useState(null) + + // Per-webhook delivery logs, loaded on demand + const [deliveries, setDeliveries] = useState>({}) + const [loadingLog, setLoadingLog] = useState(null) + + function toggleFilter(bump: BumpType) { + setFilter((prev) => (prev.includes(bump) ? prev.filter((b) => b !== bump) : [...prev, bump])) + } + + async function handleCreate(e: FormEvent) { + e.preventDefault() + setError('') + setNotice('') + setNewSecret(null) + if (filter.length === 0) { + setError('Select at least one version type to trigger on.') + return + } + setSubmitting(true) + try { + const res = await fetch(base, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({ url: url.trim(), bumpFilter: filter }), + }) + const body = await res.json().catch(() => ({})) + if (res.ok) { + setNewSecret(body.secret) + setWebhooks((prev) => [ + { + id: body.id, + url: body.url, + bumpFilter: body.bumpFilter, + enabled: body.enabled, + createdAt: body.createdAt, + lastDeliveryAt: null, + }, + ...prev, + ]) + setUrl('') + setFilter(['major', 'minor', 'patch']) + } else { + setError(body.error ?? 'Failed to create webhook.') + } + } finally { + setSubmitting(false) + } + } + + async function handleToggle(hook: Webhook) { + const res = await fetch(`${base}/${hook.id}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({ enabled: !hook.enabled }), + }) + if (res.ok) { + setWebhooks((prev) => prev.map((w) => (w.id === hook.id ? { ...w, enabled: !w.enabled } : w))) + } + } + + async function handleDelete(id: string) { + const res = await fetch(`${base}/${id}`, { method: 'DELETE', credentials: 'include' }) + if (res.ok) setWebhooks((prev) => prev.filter((w) => w.id !== id)) + } + + async function handleTest(id: string) { + setNotice('') + setError('') + const res = await fetch(`${base}/${id}/test`, { method: 'POST', credentials: 'include' }) + if (res.ok) { + setNotice('Test delivery sent. Open the delivery log to see the result.') + // Refresh the log if it's already open + if (deliveries[id]) await loadDeliveries(id) + } else { + const body = await res.json().catch(() => ({})) + setError(body.error ?? 'Test delivery failed.') + } + } + + async function loadDeliveries(id: string) { + setLoadingLog(id) + try { + const res = await fetch(`${base}/${id}/deliveries`, { credentials: 'include' }) + if (res.ok) { + const body = await res.json() + setDeliveries((prev) => ({ ...prev, [id]: body.deliveries ?? [] })) + } + } finally { + setLoadingLog(null) + } + } + + async function handleRetry(webhookId: string, deliveryId: string) { + const res = await fetch(`${base}/${webhookId}/deliveries/${deliveryId}/retry`, { + method: 'POST', + credentials: 'include', + }) + if (res.ok) { + // Give the dispatch a moment, then refresh + setTimeout(() => loadDeliveries(webhookId), 800) + } + } + + return ( +
+

+ Webhooks +

+

+ POST a signed payload to a URL whenever a new version is created. Choose which version bump + types trigger each endpoint. Requests are signed with{' '} + HMAC-SHA256 in the{' '} + X-Underlay-Signature header. +

+ + {newSecret && ( +
+

Webhook created

+

+ Copy the signing secret now — it won't be shown again. +

+ + {newSecret} + +
+ )} + + {error && ( +

+ {error} +

+ )} + {notice && ( +

+ {notice} +

+ )} + + {/* Create form */} +
+
+ + setUrl(e.target.value)} + placeholder="https://example.org/hooks/underlay" + className="bg-parchment border-rule focus:border-ink w-full border px-3 py-2 text-sm focus:outline-none" + /> +
+
+ Trigger on +
+ {BUMP_TYPES.map((bump) => ( + + ))} +
+
+ +
+ + {/* List */} + {webhooks.length === 0 ? ( +

No webhooks configured.

+ ) : ( +
+ {webhooks.map((hook) => ( +
+
+
+

{hook.url}

+
+ {hook.bumpFilter.map((b) => ( + + {b} + + ))} + {!hook.enabled && · disabled} + {hook.lastDeliveryAt && ( + · last fired {new Date(hook.lastDeliveryAt).toLocaleString()} + )} +
+
+
+ + + +
+
+ + {/* Delivery log */} +
{ + if ((e.target as HTMLDetailsElement).open && !deliveries[hook.id]) { + loadDeliveries(hook.id) + } + }} + > + + Delivery log + +
+ {loadingLog === hook.id && !deliveries[hook.id] ? ( +

Loading…

+ ) : (deliveries[hook.id]?.length ?? 0) === 0 ? ( +

No deliveries yet.

+ ) : ( +
+ + + + + + + + + + + + + {deliveries[hook.id]!.map((d) => ( + + + + + + + + + ))} + +
WhenEventStatusCodeAttempts
+ {new Date(d.createdAt).toLocaleString()} + + {d.event} + {d.semver && · {d.semver}} + + {d.status} + {d.error && d.status === 'failed' && ( + + {d.error} + + )} + {d.responseCode ?? '—'}{d.attempts} + {d.status !== 'success' && ( + + )} +
+
+ )} +
+
+
+ ))} +
+ )} +
+ ) +} diff --git a/src/db/migrations/0006_moaning_mauler.sql b/src/db/migrations/0006_moaning_mauler.sql new file mode 100644 index 0000000..30649fb --- /dev/null +++ b/src/db/migrations/0006_moaning_mauler.sql @@ -0,0 +1,42 @@ +CREATE TABLE "collection_webhooks" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "collection_id" uuid NOT NULL, + "url" text NOT NULL, + "bump_filter" text[] DEFAULT '{major,minor,patch}'::text[] NOT NULL, + "secret" text NOT NULL, + "enabled" boolean DEFAULT true NOT NULL, + "created_by" text, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + "last_delivery_at" timestamp with time zone +); +--> statement-breakpoint +CREATE TABLE "webhook_deliveries" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "webhook_id" uuid NOT NULL, + "collection_id" uuid NOT NULL, + "version_id" bigint, + "semver" text, + "bump_type" text NOT NULL, + "event" text DEFAULT 'version.created' NOT NULL, + "payload" jsonb NOT NULL, + "status" text DEFAULT 'pending' NOT NULL, + "attempts" integer DEFAULT 0 NOT NULL, + "response_code" integer, + "error" text, + "duration_ms" integer, + "next_attempt_at" timestamp with time zone, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "delivered_at" timestamp with time zone +); +--> statement-breakpoint +ALTER TABLE "collection_webhooks" ADD CONSTRAINT "collection_webhooks_collection_id_collections_id_fk" FOREIGN KEY ("collection_id") REFERENCES "public"."collections"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "collection_webhooks" ADD CONSTRAINT "collection_webhooks_created_by_user_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."user"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "webhook_deliveries" ADD CONSTRAINT "webhook_deliveries_webhook_id_collection_webhooks_id_fk" FOREIGN KEY ("webhook_id") REFERENCES "public"."collection_webhooks"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "webhook_deliveries" ADD CONSTRAINT "webhook_deliveries_collection_id_collections_id_fk" FOREIGN KEY ("collection_id") REFERENCES "public"."collections"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "webhook_deliveries" ADD CONSTRAINT "webhook_deliveries_version_id_versions_id_fk" FOREIGN KEY ("version_id") REFERENCES "public"."versions"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "collection_webhooks_collection_id_idx" ON "collection_webhooks" USING btree ("collection_id");--> statement-breakpoint +CREATE INDEX "webhook_deliveries_webhook_id_idx" ON "webhook_deliveries" USING btree ("webhook_id");--> statement-breakpoint +CREATE INDEX "webhook_deliveries_collection_id_idx" ON "webhook_deliveries" USING btree ("collection_id");--> statement-breakpoint +CREATE INDEX "webhook_deliveries_created_at_idx" ON "webhook_deliveries" USING btree ("created_at");--> statement-breakpoint +CREATE INDEX "webhook_deliveries_sweep_idx" ON "webhook_deliveries" USING btree ("status","next_attempt_at"); \ No newline at end of file diff --git a/src/db/migrations/meta/0006_snapshot.json b/src/db/migrations/meta/0006_snapshot.json new file mode 100644 index 0000000..4c15d5b --- /dev/null +++ b/src/db/migrations/meta/0006_snapshot.json @@ -0,0 +1,2637 @@ +{ + "id": "6a188973-de2c-4826-af54-a3bfb08dd5d7", + "prevId": "d572506e-2ebc-4aa5-9838-44abbc70e395", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "account_user_id_idx": { + "name": "account_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.apikey": { + "name": "apikey", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "config_id": { + "name": "config_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'default'" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "start": { + "name": "start", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "prefix": { + "name": "prefix", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "refill_interval": { + "name": "refill_interval", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "refill_amount": { + "name": "refill_amount", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_refill_at": { + "name": "last_refill_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + }, + "rate_limit_enabled": { + "name": "rate_limit_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + }, + "rate_limit_time_window": { + "name": "rate_limit_time_window", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 86400000 + }, + "rate_limit_max": { + "name": "rate_limit_max", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 10 + }, + "request_count": { + "name": "request_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "remaining": { + "name": "remaining", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_request": { + "name": "last_request", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "permissions": { + "name": "permissions", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "apikey_config_id_idx": { + "name": "apikey_config_id_idx", + "columns": [ + { + "expression": "config_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "apikey_reference_id_idx": { + "name": "apikey_reference_id_idx", + "columns": [ + { + "expression": "reference_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "apikey_key_idx": { + "name": "apikey_key_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ark_collections": { + "name": "ark_collections", + "schema": "", + "columns": { + "collection_id": { + "name": "collection_id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "ark_id": { + "name": "ark_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "custom_url": { + "name": "custom_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "ark_collections_collection_id_collections_id_fk": { + "name": "ark_collections_collection_id_collections_id_fk", + "tableFrom": "ark_collections", + "tableTo": "collections", + "columnsFrom": ["collection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "ark_collections_ark_id_unique": { + "name": "ark_collections_ark_id_unique", + "nullsNotDistinct": false, + "columns": ["ark_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ark_record_types": { + "name": "ark_record_types", + "schema": "", + "columns": { + "collection_id": { + "name": "collection_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "record_type": { + "name": "record_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "redirect_url_field": { + "name": "redirect_url_field", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "ark_record_types_collection_id_collections_id_fk": { + "name": "ark_record_types_collection_id_collections_id_fk", + "tableFrom": "ark_record_types", + "tableTo": "collections", + "columnsFrom": ["collection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "ark_record_types_collection_id_record_type_pk": { + "name": "ark_record_types_collection_id_record_type_pk", + "columns": ["collection_id", "record_type"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ark_shoulders": { + "name": "ark_shoulders", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "shoulder": { + "name": "shoulder", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "ark_shoulders_organization_id_organization_id_fk": { + "name": "ark_shoulders_organization_id_organization_id_fk", + "tableFrom": "ark_shoulders", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "ark_shoulders_organization_id_unique": { + "name": "ark_shoulders_organization_id_unique", + "nullsNotDistinct": false, + "columns": ["organization_id"] + }, + "ark_shoulders_shoulder_unique": { + "name": "ark_shoulders_shoulder_unique", + "nullsNotDistinct": false, + "columns": ["shoulder"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.collection_webhooks": { + "name": "collection_webhooks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "collection_id": { + "name": "collection_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "bump_filter": { + "name": "bump_filter", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{major,minor,patch}'::text[]" + }, + "secret": { + "name": "secret", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_delivery_at": { + "name": "last_delivery_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "collection_webhooks_collection_id_idx": { + "name": "collection_webhooks_collection_id_idx", + "columns": [ + { + "expression": "collection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "collection_webhooks_collection_id_collections_id_fk": { + "name": "collection_webhooks_collection_id_collections_id_fk", + "tableFrom": "collection_webhooks", + "tableTo": "collections", + "columnsFrom": ["collection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "collection_webhooks_created_by_user_id_fk": { + "name": "collection_webhooks_created_by_user_id_fk", + "tableFrom": "collection_webhooks", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.collections": { + "name": "collections", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "public": { + "name": "public", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "forked_from": { + "name": "forked_from", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "collections_organization_id_idx": { + "name": "collections_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "collections_organization_id_organization_id_fk": { + "name": "collections_organization_id_organization_id_fk", + "tableFrom": "collections", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "collections_forked_from_collections_id_fk": { + "name": "collections_forked_from_collections_id_fk", + "tableFrom": "collections", + "tableTo": "collections", + "columnsFrom": ["forked_from"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "collections_organization_id_slug_unique": { + "name": "collections_organization_id_slug_unique", + "nullsNotDistinct": false, + "columns": ["organization_id", "slug"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.files": { + "name": "files", + "schema": "", + "columns": { + "hash": { + "name": "hash", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "size": { + "name": "size", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.instance_settings": { + "name": "instance_settings", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "value": { + "name": "value", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invitation": { + "name": "invitation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "inviter_id": { + "name": "inviter_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "invitation_organization_id_idx": { + "name": "invitation_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_email_idx": { + "name": "invitation_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invitation_organization_id_organization_id_fk": { + "name": "invitation_organization_id_organization_id_fk", + "tableFrom": "invitation", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitation_inviter_id_user_id_fk": { + "name": "invitation_inviter_id_user_id_fk", + "tableFrom": "invitation", + "tableTo": "user", + "columnsFrom": ["inviter_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.member": { + "name": "member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "member_organization_id_idx": { + "name": "member_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "member_user_id_idx": { + "name": "member_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "member_organization_id_organization_id_fk": { + "name": "member_organization_id_organization_id_fk", + "tableFrom": "member", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "member_user_id_user_id_fk": { + "name": "member_user_id_user_id_fk", + "tableFrom": "member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.negotiate_session_manifest": { + "name": "negotiate_session_manifest", + "schema": "", + "columns": { + "session_id": { + "name": "session_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "record_id": { + "name": "record_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "private": { + "name": "private", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "needed": { + "name": "needed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + } + }, + "indexes": { + "nsm_session_needed_idx": { + "name": "nsm_session_needed_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "needed", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "negotiate_session_manifest_session_id_negotiate_sessions_id_fk": { + "name": "negotiate_session_manifest_session_id_negotiate_sessions_id_fk", + "tableFrom": "negotiate_session_manifest", + "tableTo": "negotiate_sessions", + "columnsFrom": ["session_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "negotiate_session_manifest_session_id_hash_pk": { + "name": "negotiate_session_manifest_session_id_hash_pk", + "columns": ["session_id", "hash"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.negotiate_sessions": { + "name": "negotiate_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "collection_id": { + "name": "collection_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "base_semver": { + "name": "base_semver", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "schemas": { + "name": "schemas", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "file_hashes": { + "name": "file_hashes", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "needed_files": { + "name": "needed_files", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "app_id": { + "name": "app_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "strip_unknown_fields": { + "name": "strip_unknown_fields", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'open'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "negotiate_sessions_collection_id_collections_id_fk": { + "name": "negotiate_sessions_collection_id_collections_id_fk", + "tableFrom": "negotiate_sessions", + "tableTo": "collections", + "columnsFrom": ["collection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "negotiate_sessions_user_id_user_id_fk": { + "name": "negotiate_sessions_user_id_user_id_fk", + "tableFrom": "negotiate_sessions", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization": { + "name": "organization", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "bio": { + "name": "bio", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "website": { + "name": "website", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ark_naan": { + "name": "ark_naan", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "kf_org_id": { + "name": "kf_org_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + } + }, + "indexes": { + "organization_slug_uidx": { + "name": "organization_slug_uidx", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "organization_slug_unique": { + "name": "organization_slug_unique", + "nullsNotDistinct": false, + "columns": ["slug"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.page_comments": { + "name": "page_comments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "page": { + "name": "page", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "anchor": { + "name": "anchor", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "quote": { + "name": "quote", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "quote_context": { + "name": "quote_context", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "parent_id": { + "name": "parent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "approved_at": { + "name": "approved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "approved_by": { + "name": "approved_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'open'" + }, + "resolution_note": { + "name": "resolution_note", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "edited_at": { + "name": "edited_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "page_comments_page_anchor_idx": { + "name": "page_comments_page_anchor_idx", + "columns": [ + { + "expression": "page", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "anchor", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "page_comments_user_id_idx": { + "name": "page_comments_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "page_comments_user_id_user_id_fk": { + "name": "page_comments_user_id_user_id_fk", + "tableFrom": "page_comments", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.record_objects": { + "name": "record_objects", + "schema": "", + "columns": { + "hash": { + "name": "hash", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "record_id": { + "name": "record_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "private": { + "name": "private", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "record_objects_record_id_idx": { + "name": "record_objects_record_id_idx", + "columns": [ + { + "expression": "record_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.schema_labels": { + "name": "schema_labels", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "schema_id": { + "name": "schema_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "schema_labels_label_idx": { + "name": "schema_labels_label_idx", + "columns": [ + { + "expression": "label", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "schema_labels_schema_id_schemas_id_fk": { + "name": "schema_labels_schema_id_schemas_id_fk", + "tableFrom": "schema_labels", + "tableTo": "schemas", + "columnsFrom": ["schema_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "schema_labels_schema_id_label_unique": { + "name": "schema_labels_schema_id_label_unique", + "nullsNotDistinct": false, + "columns": ["schema_id", "label"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.schemas": { + "name": "schemas", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "schema": { + "name": "schema", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "schema_hash": { + "name": "schema_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "schemas_schema_hash_unique": { + "name": "schemas_schema_hash_unique", + "nullsNotDistinct": false, + "columns": ["schema_hash"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "active_organization_id": { + "name": "active_organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "session_user_id_idx": { + "name": "session_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sync_runs": { + "name": "sync_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "trigger": { + "name": "trigger", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "collections_synced": { + "name": "collections_synced", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "collections_created": { + "name": "collections_created", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "collections_failed": { + "name": "collections_failed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "versions_pulled": { + "name": "versions_pulled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "files_downloaded": { + "name": "files_downloaded", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "files_skipped": { + "name": "files_skipped", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "errors": { + "name": "errors", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "logs": { + "name": "logs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.version_files": { + "name": "version_files", + "schema": "", + "columns": { + "version_id": { + "name": "version_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "file_hash": { + "name": "file_hash", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "version_files_file_hash_idx": { + "name": "version_files_file_hash_idx", + "columns": [ + { + "expression": "file_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "version_files_version_id_versions_id_fk": { + "name": "version_files_version_id_versions_id_fk", + "tableFrom": "version_files", + "tableTo": "versions", + "columnsFrom": ["version_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "version_files_file_hash_files_hash_fk": { + "name": "version_files_file_hash_files_hash_fk", + "tableFrom": "version_files", + "tableTo": "files", + "columnsFrom": ["file_hash"], + "columnsTo": ["hash"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "version_files_version_id_file_hash_pk": { + "name": "version_files_version_id_file_hash_pk", + "columns": ["version_id", "file_hash"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.version_records": { + "name": "version_records", + "schema": "", + "columns": { + "version_id": { + "name": "version_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "record_hash": { + "name": "record_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "public_record_hash": { + "name": "public_record_hash", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "version_records_record_hash_idx": { + "name": "version_records_record_hash_idx", + "columns": [ + { + "expression": "record_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "version_records_public_record_hash_idx": { + "name": "version_records_public_record_hash_idx", + "columns": [ + { + "expression": "public_record_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "public_record_hash IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "version_records_version_id_versions_id_fk": { + "name": "version_records_version_id_versions_id_fk", + "tableFrom": "version_records", + "tableTo": "versions", + "columnsFrom": ["version_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "version_records_record_hash_record_objects_hash_fk": { + "name": "version_records_record_hash_record_objects_hash_fk", + "tableFrom": "version_records", + "tableTo": "record_objects", + "columnsFrom": ["record_hash"], + "columnsTo": ["hash"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "version_records_version_id_record_hash_pk": { + "name": "version_records_version_id_record_hash_pk", + "columns": ["version_id", "record_hash"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.version_schemas": { + "name": "version_schemas", + "schema": "", + "columns": { + "version_id": { + "name": "version_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "schema_id": { + "name": "schema_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "version_schemas_schema_id_idx": { + "name": "version_schemas_schema_id_idx", + "columns": [ + { + "expression": "schema_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "version_schemas_version_id_versions_id_fk": { + "name": "version_schemas_version_id_versions_id_fk", + "tableFrom": "version_schemas", + "tableTo": "versions", + "columnsFrom": ["version_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "version_schemas_schema_id_schemas_id_fk": { + "name": "version_schemas_schema_id_schemas_id_fk", + "tableFrom": "version_schemas", + "tableTo": "schemas", + "columnsFrom": ["schema_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "version_schemas_version_id_slug_pk": { + "name": "version_schemas_version_id_slug_pk", + "columns": ["version_id", "slug"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.versions": { + "name": "versions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "collection_id": { + "name": "collection_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "semver": { + "name": "semver", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "major": { + "name": "major", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "minor": { + "name": "minor", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "patch": { + "name": "patch", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "public_hash": { + "name": "public_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "base_semver": { + "name": "base_semver", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "pushed_by": { + "name": "pushed_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "app_id": { + "name": "app_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "signature": { + "name": "signature", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "record_count": { + "name": "record_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "file_count": { + "name": "file_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "total_bytes": { + "name": "total_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'ready'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "versions_ordering_idx": { + "name": "versions_ordering_idx", + "columns": [ + { + "expression": "collection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "major", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "minor", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "patch", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "versions_collection_id_collections_id_fk": { + "name": "versions_collection_id_collections_id_fk", + "tableFrom": "versions", + "tableTo": "collections", + "columnsFrom": ["collection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "versions_pushed_by_user_id_fk": { + "name": "versions_pushed_by_user_id_fk", + "tableFrom": "versions", + "tableTo": "user", + "columnsFrom": ["pushed_by"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "versions_collection_id_semver_unique": { + "name": "versions_collection_id_semver_unique", + "nullsNotDistinct": false, + "columns": ["collection_id", "semver"] + }, + "versions_collection_id_hash_unique": { + "name": "versions_collection_id_hash_unique", + "nullsNotDistinct": false, + "columns": ["collection_id", "hash"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.webhook_deliveries": { + "name": "webhook_deliveries", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "webhook_id": { + "name": "webhook_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "collection_id": { + "name": "collection_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "version_id": { + "name": "version_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "semver": { + "name": "semver", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "bump_type": { + "name": "bump_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event": { + "name": "event", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'version.created'" + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "response_code": { + "name": "response_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "next_attempt_at": { + "name": "next_attempt_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "delivered_at": { + "name": "delivered_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "webhook_deliveries_webhook_id_idx": { + "name": "webhook_deliveries_webhook_id_idx", + "columns": [ + { + "expression": "webhook_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_deliveries_collection_id_idx": { + "name": "webhook_deliveries_collection_id_idx", + "columns": [ + { + "expression": "collection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_deliveries_created_at_idx": { + "name": "webhook_deliveries_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_deliveries_sweep_idx": { + "name": "webhook_deliveries_sweep_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_attempt_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "webhook_deliveries_webhook_id_collection_webhooks_id_fk": { + "name": "webhook_deliveries_webhook_id_collection_webhooks_id_fk", + "tableFrom": "webhook_deliveries", + "tableTo": "collection_webhooks", + "columnsFrom": ["webhook_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "webhook_deliveries_collection_id_collections_id_fk": { + "name": "webhook_deliveries_collection_id_collections_id_fk", + "tableFrom": "webhook_deliveries", + "tableTo": "collections", + "columnsFrom": ["collection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "webhook_deliveries_version_id_versions_id_fk": { + "name": "webhook_deliveries_version_id_versions_id_fk", + "tableFrom": "webhook_deliveries", + "tableTo": "versions", + "columnsFrom": ["version_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/src/db/migrations/meta/_journal.json b/src/db/migrations/meta/_journal.json index aefbb5a..20dc478 100644 --- a/src/db/migrations/meta/_journal.json +++ b/src/db/migrations/meta/_journal.json @@ -43,6 +43,13 @@ "when": 1781223203253, "tag": "0005_instance_settings", "breakpoints": true + }, + { + "idx": 6, + "version": "7", + "when": 1784050001911, + "tag": "0006_moaning_mauler", + "breakpoints": true } ] } diff --git a/src/db/schema.ts b/src/db/schema.ts index f2102c9..5308a5a 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -491,3 +491,72 @@ export const instanceSettings = pgTable('instance_settings', { value: jsonb('value').notNull(), updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(), }) + +// --- Webhooks (fire on new version) --- + +export const collectionWebhooks = pgTable( + 'collection_webhooks', + { + id: uuid('id').defaultRandom().primaryKey(), + collectionId: uuid('collection_id') + .notNull() + .references(() => collections.id, { onDelete: 'cascade' }), + url: text('url').notNull(), + // Which version bump types trigger this webhook — a subset of major/minor/patch. + // All three = fire on every version. + bumpFilter: text('bump_filter') + .array() + .notNull() + .default(sql`'{major,minor,patch}'::text[]`), + secret: text('secret').notNull(), + enabled: boolean('enabled').notNull().default(true), + createdBy: text('created_by').references(() => user.id), + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), + updatedAt: timestamp('updated_at', { withTimezone: true }) + .defaultNow() + .$onUpdate(() => new Date()) + .notNull(), + lastDeliveryAt: timestamp('last_delivery_at', { withTimezone: true }), + }, + (t) => [index('collection_webhooks_collection_id_idx').on(t.collectionId)], +) + +export const webhookDeliveries = pgTable( + 'webhook_deliveries', + { + id: uuid('id').defaultRandom().primaryKey(), + webhookId: uuid('webhook_id') + .notNull() + .references(() => collectionWebhooks.id, { onDelete: 'cascade' }), + collectionId: uuid('collection_id') + .notNull() + .references(() => collections.id, { onDelete: 'cascade' }), + // The version this delivery is about. NULL-able so the log survives a version + // being pruned; semver is denormalized for display. + versionId: bigint('version_id', { mode: 'number' }).references(() => versions.id, { + onDelete: 'set null', + }), + semver: text('semver'), + bumpType: text('bump_type', { enum: ['major', 'minor', 'patch'] }).notNull(), + event: text('event').notNull().default('version.created'), + // The core payload (without the per-attempt delivery envelope), sent as the request body. + payload: jsonb('payload').notNull(), + status: text('status', { enum: ['pending', 'success', 'failed'] }) + .notNull() + .default('pending'), + attempts: integer('attempts').notNull().default(0), + responseCode: integer('response_code'), + error: text('error'), + durationMs: integer('duration_ms'), + nextAttemptAt: timestamp('next_attempt_at', { withTimezone: true }), + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), + deliveredAt: timestamp('delivered_at', { withTimezone: true }), + }, + (t) => [ + index('webhook_deliveries_webhook_id_idx').on(t.webhookId), + index('webhook_deliveries_collection_id_idx').on(t.collectionId), + index('webhook_deliveries_created_at_idx').on(t.createdAt), + // Retry sweep scans for due, non-terminal deliveries + index('webhook_deliveries_sweep_idx').on(t.status, t.nextAttemptAt), + ], +) diff --git a/src/lib/webhooks.server.test.ts b/src/lib/webhooks.server.test.ts new file mode 100644 index 0000000..d3aa3e1 --- /dev/null +++ b/src/lib/webhooks.server.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, it } from 'vitest' + +import { isPrivateIp, signPayload, validateWebhookUrl } from './webhooks.server.js' + +describe('signPayload', () => { + it('produces a deterministic signature for a known input', () => { + const sig = signPayload('secret', 'hello') + // Reference HMAC-SHA256("secret", "hello") + expect(sig).toBe('sha256=88aab3ede8d3adf94d26ab90d3bafd4a2083070c3bcce9c014ee04a443847c0b') + // Stable across calls + expect(signPayload('secret', 'hello')).toBe(sig) + }) + + it('prefixes the hex digest with sha256=', () => { + const sig = signPayload('secret', 'hello') + expect(sig).toMatch(/^sha256=[0-9a-f]{64}$/) + }) + + it('yields a different signature for a different secret', () => { + expect(signPayload('secret-a', 'hello')).not.toBe(signPayload('secret-b', 'hello')) + }) + + it('yields a different signature for a different body', () => { + expect(signPayload('secret', 'hello')).not.toBe(signPayload('secret', 'world')) + }) +}) + +describe('isPrivateIp', () => { + it('flags loopback addresses', () => { + expect(isPrivateIp('127.0.0.1')).toBe(true) + expect(isPrivateIp('127.9.9.9')).toBe(true) + expect(isPrivateIp('::1')).toBe(true) + }) + + it('flags private IPv4 ranges', () => { + expect(isPrivateIp('10.0.0.1')).toBe(true) + expect(isPrivateIp('10.255.255.255')).toBe(true) + expect(isPrivateIp('172.16.0.1')).toBe(true) + expect(isPrivateIp('172.31.255.255')).toBe(true) + expect(isPrivateIp('192.168.0.1')).toBe(true) + expect(isPrivateIp('192.168.1.100')).toBe(true) + }) + + it('does not flag public IPv4 addresses just outside the private ranges', () => { + expect(isPrivateIp('172.15.0.1')).toBe(false) + expect(isPrivateIp('172.32.0.1')).toBe(false) + expect(isPrivateIp('192.169.0.1')).toBe(false) + expect(isPrivateIp('11.0.0.1')).toBe(false) + }) + + it('flags link-local addresses including the cloud metadata endpoint', () => { + expect(isPrivateIp('169.254.0.1')).toBe(true) + expect(isPrivateIp('169.254.169.254')).toBe(true) + }) + + it('flags IPv6 unique-local (fc00::/7)', () => { + expect(isPrivateIp('fc00::1')).toBe(true) + expect(isPrivateIp('fd12:3456:789a::1')).toBe(true) + expect(isPrivateIp('fe80::1')).toBe(true) // link-local + }) + + it('flags IPv4-mapped IPv6 pointing at a private address', () => { + expect(isPrivateIp('::ffff:127.0.0.1')).toBe(true) + expect(isPrivateIp('::ffff:10.0.0.1')).toBe(true) + }) + + it('does not flag normal public IPs', () => { + expect(isPrivateIp('8.8.8.8')).toBe(false) + expect(isPrivateIp('1.1.1.1')).toBe(false) + expect(isPrivateIp('93.184.216.34')).toBe(false) + expect(isPrivateIp('2606:4700:4700::1111')).toBe(false) + }) +}) + +describe('validateWebhookUrl', () => { + it('accepts a normal https public URL', () => { + const result = validateWebhookUrl('https://example.org/hooks/underlay') + expect(result.ok).toBe(true) + if (result.ok) expect(result.url).toBe('https://example.org/hooks/underlay') + }) + + it('rejects http when insecure URLs are not allowed (production)', () => { + const result = validateWebhookUrl('http://example.org/hook', { allowInsecure: false }) + expect(result.ok).toBe(false) + if (!result.ok) expect(result.reason).toMatch(/https/) + }) + + it('allows http only when insecure URLs are permitted (non-production)', () => { + expect(validateWebhookUrl('http://example.org/hook', { allowInsecure: true }).ok).toBe(true) + }) + + it('rejects localhost and internal hostnames', () => { + expect(validateWebhookUrl('https://localhost/hook').ok).toBe(false) + expect(validateWebhookUrl('https://foo.localhost/hook').ok).toBe(false) + expect(validateWebhookUrl('https://svc.internal/hook').ok).toBe(false) + expect(validateWebhookUrl('https://db.local/hook').ok).toBe(false) + }) + + it('rejects literal private IP hosts', () => { + expect(validateWebhookUrl('https://127.0.0.1/hook').ok).toBe(false) + expect(validateWebhookUrl('https://10.0.0.1/hook').ok).toBe(false) + expect(validateWebhookUrl('https://169.254.169.254/latest/meta-data').ok).toBe(false) + }) + + it('rejects malformed URLs', () => { + expect(validateWebhookUrl('not a url').ok).toBe(false) + expect(validateWebhookUrl('ftp://example.org/x').ok).toBe(false) + }) +}) diff --git a/src/lib/webhooks.server.ts b/src/lib/webhooks.server.ts new file mode 100644 index 0000000..f15a369 --- /dev/null +++ b/src/lib/webhooks.server.ts @@ -0,0 +1,435 @@ +/** + * Webhook delivery — fire registered endpoints when a new version is created. + * + * Flow: + * 1. At version commit, `enqueueWebhookDeliveries` writes a `pending` row per + * matching (enabled, bump-filter) webhook. Cheap and durable; never blocks + * the commit response. + * 2. The commit handler kicks off `dispatchDeliveries` without awaiting — a + * best-effort immediate attempt. + * 3. `runRetrySweep` (in-process interval, started from server.ts) retries + * rows that are still pending (e.g. after a restart) or failed and due, + * with exponential backoff up to MAX_ATTEMPTS. + * 4. `purgeOldDeliveries` drops rows older than the retention window. Wired + * both as an in-process daily interval and as `tool:pruneWebhookLogs`. + * + * Framework-free: no Hono/React imports, callable from routes, intervals, and + * the cron tool alike. + */ +import crypto from 'node:crypto' +import { isIP } from 'node:net' + +import { and, eq, inArray, lt, lte, or, sql } from 'drizzle-orm' + +import { db, schema } from '../db/client.server.js' + +const DELIVERY_TIMEOUT_MS = 10_000 +const MAX_ATTEMPTS = 5 +const RETENTION_MS = 30 * 24 * 60 * 60 * 1000 // 30 days +const SWEEP_INTERVAL_MS = 60_000 +const PURGE_INTERVAL_MS = 24 * 60 * 60 * 1000 +const SWEEP_BATCH = 100 +const BACKOFF_BASE_MS = 60_000 // 1 min +const BACKOFF_CAP_MS = 6 * 60 * 60 * 1000 // 6 h +const SIGNATURE_HEADER = 'x-underlay-signature' + +export type BumpType = 'major' | 'minor' | 'patch' + +export interface WebhookVersionInfo { + id: number + semver: string + hash: string + major: number + minor: number + patch: number + recordCount: number + fileCount: number +} + +// --- SSRF protection --- + +/** Private / loopback / link-local / unique-local ranges that must never be POSTed to. */ +export function isPrivateIp(ip: string): boolean { + const kind = isIP(ip) + if (kind === 4) { + const [a = 0, b = 0] = ip.split('.').map((n) => parseInt(n, 10)) + if (a === 10) return true + if (a === 127) return true + if (a === 0) return true + if (a === 169 && b === 254) return true // link-local (incl. cloud metadata 169.254.169.254) + if (a === 172 && b >= 16 && b <= 31) return true + if (a === 192 && b === 168) return true + if (a === 100 && b >= 64 && b <= 127) return true // carrier-grade NAT + return false + } + if (kind === 6) { + const norm = ip.toLowerCase() + if (norm === '::1' || norm === '::') return true + + const firstVal = parseInt(norm.split(':')[0] || '0', 16) + if (!Number.isNaN(firstVal) && (firstVal & 0xffc0) === 0xfe80) return true // link-local fe80::/10 + + if (norm.startsWith('fc') || norm.startsWith('fd')) return true // unique-local + // IPv4-mapped (::ffff:a.b.c.d) + const mapped = norm.match(/::ffff:(\d+\.\d+\.\d+\.\d+)$/) + if (mapped) return isPrivateIp(mapped[1]!) + return false + } + return false +} + +function isBlockedHostname(hostname: string): boolean { + const h = hostname.toLowerCase().replace(/\.$/, '') + if (h === 'localhost' || h.endsWith('.localhost')) return true + if (h.endsWith('.local') || h.endsWith('.internal')) return true + return false +} + +/** + * Validate a webhook URL for shape and obvious SSRF vectors. Syntactic checks + * only (no DNS) — safe to call synchronously at save time. `allowInsecure` + * permits http:// outside production for local testing. + */ +export function validateWebhookUrl( + raw: string, + { allowInsecure = process.env.NODE_ENV !== 'production' } = {}, +): { ok: true; url: string } | { ok: false; reason: string } { + let parsed: URL + try { + parsed = new URL(raw) + } catch { + return { ok: false, reason: 'Invalid URL' } + } + if (parsed.protocol !== 'https:' && !(allowInsecure && parsed.protocol === 'http:')) { + return { ok: false, reason: 'Webhook URL must use https' } + } + const host = parsed.hostname + if (isBlockedHostname(host)) { + return { ok: false, reason: 'Webhook URL host is not allowed' } + } + // Literal IP in the URL — reject private ranges up front. + if (isIP(host) && isPrivateIp(host)) { + return { ok: false, reason: 'Webhook URL resolves to a private address' } + } + return { ok: true, url: parsed.toString() } +} + +/** + * Delivery-time SSRF check: resolve the hostname and reject if any resolved + * address is private. Catches DNS names that point at internal infrastructure. + */ +async function assertResolvesPublic(hostname: string): Promise { + if (isIP(hostname)) { + if (isPrivateIp(hostname)) throw new Error('Webhook host resolves to a private address') + return + } + const { lookup } = await import('node:dns/promises') + const results = await lookup(hostname, { all: true }) + for (const { address } of results) { + if (isPrivateIp(address)) throw new Error('Webhook host resolves to a private address') + } +} + +// --- Signing --- + +export function signPayload(secret: string, body: string): string { + return `sha256=${crypto.createHmac('sha256', secret).update(body).digest('hex')}` +} + +export function generateWebhookSecret(): string { + return `ulwhsec_${crypto.randomBytes(24).toString('hex')}` +} + +// --- Enqueue --- + +/** drizzle db or a transaction handle — both expose the same query builder. */ +type Executor = typeof db + +/** Precedence-based bump type from the change flags used by deriveSemver. */ +export function bumpTypeFromChanges(schemaChanged: boolean, recordsChanged: boolean): BumpType { + if (schemaChanged) return 'major' + if (recordsChanged) return 'minor' + return 'patch' +} + +/** + * Insert a `pending` delivery row for every enabled webhook on the collection + * whose bump filter includes `bumpType`. Returns the new delivery ids (for + * immediate dispatch). Never throws into the caller's critical path — resolve + * failures are surfaced by the caller's try/catch. + */ +export async function enqueueWebhookDeliveries( + version: WebhookVersionInfo, + bumpType: BumpType, + collectionId: string, + exec: Executor = db, +): Promise { + const hooks = await exec + .select({ id: schema.collectionWebhooks.id, bumpFilter: schema.collectionWebhooks.bumpFilter }) + .from(schema.collectionWebhooks) + .where( + and( + eq(schema.collectionWebhooks.collectionId, collectionId), + eq(schema.collectionWebhooks.enabled, true), + ), + ) + + const matching = hooks.filter((h) => h.bumpFilter.includes(bumpType)) + if (matching.length === 0) return [] + + const owner = await resolveOwnerSlug(collectionId, exec) + if (!owner) return [] + + const payload = { + event: 'version.created', + collection: { owner: owner.orgSlug, slug: owner.collectionSlug }, + version: { + semver: version.semver, + hash: version.hash, + major: version.major, + minor: version.minor, + patch: version.patch, + recordCount: version.recordCount, + fileCount: version.fileCount, + }, + bumpType, + } + + const rows = await exec + .insert(schema.webhookDeliveries) + .values( + matching.map((h) => ({ + webhookId: h.id, + collectionId, + versionId: version.id, + semver: version.semver, + bumpType, + event: 'version.created', + payload, + status: 'pending' as const, + })), + ) + .returning({ id: schema.webhookDeliveries.id }) + + return rows.map((r) => r.id) +} + +async function resolveOwnerSlug( + collectionId: string, + exec: Executor = db, +): Promise<{ orgSlug: string; collectionSlug: string } | null> { + const [row] = await exec + .select({ + orgSlug: schema.organization.slug, + collectionSlug: schema.collections.slug, + }) + .from(schema.collections) + .innerJoin(schema.organization, eq(schema.collections.organizationId, schema.organization.id)) + .where(eq(schema.collections.id, collectionId)) + .limit(1) + return row ?? null +} + +// --- Delivery --- + +/** Deliver a single row by id. Records the outcome; never throws. */ +export async function deliverOne(deliveryId: string): Promise { + const [row] = await db + .select({ + id: schema.webhookDeliveries.id, + attempts: schema.webhookDeliveries.attempts, + status: schema.webhookDeliveries.status, + event: schema.webhookDeliveries.event, + payload: schema.webhookDeliveries.payload, + webhookId: schema.webhookDeliveries.webhookId, + url: schema.collectionWebhooks.url, + secret: schema.collectionWebhooks.secret, + enabled: schema.collectionWebhooks.enabled, + }) + .from(schema.webhookDeliveries) + .innerJoin( + schema.collectionWebhooks, + eq(schema.webhookDeliveries.webhookId, schema.collectionWebhooks.id), + ) + .where(eq(schema.webhookDeliveries.id, deliveryId)) + .limit(1) + + if (!row) return + if (row.status === 'success') return + + const attempt = row.attempts + 1 + const deliveryTimestamp = new Date() + + if (!row.enabled) { + await markFailed(deliveryId, attempt, null, 'Webhook disabled', 0, /* terminal */ true) + return + } + + const body = JSON.stringify({ + ...(row.payload as Record), + delivery: { id: row.id, timestamp: deliveryTimestamp.toISOString() }, + }) + + const startedAt = Date.now() + try { + const { hostname } = new URL(row.url) + await assertResolvesPublic(hostname) + + const controller = new AbortController() + const timer = setTimeout(() => controller.abort(), DELIVERY_TIMEOUT_MS) + let res: Response + try { + res = await fetch(row.url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'User-Agent': 'Underlay-Webhook/1.0', + 'X-Underlay-Event': String(row.event), + 'X-Underlay-Delivery': row.id, + [SIGNATURE_HEADER]: signPayload(row.secret, body), + }, + body, + signal: controller.signal, + redirect: 'error', + }) + } finally { + clearTimeout(timer) + } + + const durationMs = Date.now() - startedAt + if (res.ok) { + await db + .update(schema.webhookDeliveries) + .set({ + status: 'success', + attempts: attempt, + responseCode: res.status, + error: null, + durationMs, + nextAttemptAt: null, + deliveredAt: new Date(), + }) + .where(eq(schema.webhookDeliveries.id, deliveryId)) + await db + .update(schema.collectionWebhooks) + .set({ lastDeliveryAt: new Date() }) + .where(eq(schema.collectionWebhooks.id, row.webhookId)) + } else { + await markFailed(deliveryId, attempt, res.status, `HTTP ${res.status}`, durationMs) + } + } catch (err) { + const durationMs = Date.now() - startedAt + const message = err instanceof Error ? err.message : String(err) + await markFailed(deliveryId, attempt, null, message, durationMs) + } +} + +async function markFailed( + deliveryId: string, + attempt: number, + responseCode: number | null, + error: string, + durationMs: number, + terminal = false, +): Promise { + const exhausted = terminal || attempt >= MAX_ATTEMPTS + const backoff = Math.min(BACKOFF_BASE_MS * 2 ** (attempt - 1), BACKOFF_CAP_MS) + await db + .update(schema.webhookDeliveries) + .set({ + status: 'failed', + attempts: attempt, + responseCode, + error: error.slice(0, 2000), + durationMs, + // Once exhausted, stop scheduling retries (sweep filters on attempts < MAX). + nextAttemptAt: exhausted ? null : new Date(Date.now() + backoff), + deliveredAt: new Date(), + }) + .where(eq(schema.webhookDeliveries.id, deliveryId)) +} + +/** Fire-and-forget dispatch of freshly enqueued deliveries. */ +export function dispatchDeliveries(ids: string[]): void { + if (ids.length === 0) return + void Promise.allSettled(ids.map((id) => deliverOne(id))) +} + +/** Reset a delivery for an immediate manual retry. Returns false if not retriable/not found. */ +export async function retryDelivery(deliveryId: string, collectionId: string): Promise { + const [row] = await db + .select({ id: schema.webhookDeliveries.id }) + .from(schema.webhookDeliveries) + .where( + and( + eq(schema.webhookDeliveries.id, deliveryId), + eq(schema.webhookDeliveries.collectionId, collectionId), + ), + ) + .limit(1) + if (!row) return false + // Give it a fresh attempt budget and dispatch now. + await db + .update(schema.webhookDeliveries) + .set({ status: 'pending', attempts: 0, nextAttemptAt: null }) + .where(eq(schema.webhookDeliveries.id, deliveryId)) + dispatchDeliveries([deliveryId]) + return true +} + +// --- Background jobs --- + +/** Pick up due, non-terminal deliveries and (re)attempt them. */ +export async function runRetrySweep(): Promise { + const now = new Date() + const due = await db + .select({ id: schema.webhookDeliveries.id }) + .from(schema.webhookDeliveries) + .where( + and( + inArray(schema.webhookDeliveries.status, ['pending', 'failed']), + lt(schema.webhookDeliveries.attempts, MAX_ATTEMPTS), + or( + sql`${schema.webhookDeliveries.nextAttemptAt} IS NULL`, + lte(schema.webhookDeliveries.nextAttemptAt, now), + ), + ), + ) + .orderBy(schema.webhookDeliveries.createdAt) + .limit(SWEEP_BATCH) + + for (const { id } of due) { + await deliverOne(id) + } + return due.length +} + +/** Delete deliveries older than the retention window. */ +export async function purgeOldDeliveries(): Promise { + const cutoff = new Date(Date.now() - RETENTION_MS) + const deleted = await db + .delete(schema.webhookDeliveries) + .where(lt(schema.webhookDeliveries.createdAt, cutoff)) + .returning({ id: schema.webhookDeliveries.id }) + return deleted.length +} + +let jobsStarted = false + +/** Start the in-process retry sweep + purge intervals. Idempotent per process. */ +export function startWebhookBackgroundJobs(): void { + if (jobsStarted) return + jobsStarted = true + + setInterval(() => { + runRetrySweep().catch((err) => console.error('[webhooks] retry sweep failed:', err)) + }, SWEEP_INTERVAL_MS).unref() + + setInterval(() => { + purgeOldDeliveries() + .then((n) => { + if (n > 0) console.log(`[webhooks] purged ${n} delivery log row(s) older than 30 days`) + }) + .catch((err) => console.error('[webhooks] purge failed:', err)) + }, PURGE_INTERVAL_MS).unref() +} diff --git a/src/routes/[owner]/[collection]/settings.data.ts b/src/routes/[owner]/[collection]/settings.data.ts index 8b47da8..76411a5 100644 --- a/src/routes/[owner]/[collection]/settings.data.ts +++ b/src/routes/[owner]/[collection]/settings.data.ts @@ -12,13 +12,16 @@ export async function loader({ params, request }: LoaderFunctionArgs) { const headers = { Cookie: request.headers.get('Cookie') ?? '' } const prefix = `/api/collections/${params.owner}/${params.collection}` - const [data, arkSettings] = await Promise.all([ + const [data, arkSettings, webhooksResult] = await Promise.all([ fetch(new URL(prefix, base), { headers }).then((r) => (r.ok ? r.json() : null)), fetch(new URL(`${prefix}/ark`, base), { headers }).then((r) => r.ok ? r.json() : { enabled: false, customUrl: null, arkUrl: null }, ), + fetch(new URL(`${prefix}/webhooks`, base), { headers }).then((r) => + r.ok ? r.json() : { webhooks: [] }, + ), ]) if (!data) throw new Response('Not Found', { status: 404 }) - return { data, arkSettings } + return { data, arkSettings, webhooks: webhooksResult.webhooks ?? [] } } diff --git a/src/routes/[owner]/[collection]/settings.tsx b/src/routes/[owner]/[collection]/settings.tsx index 43e5f8e..07a6efb 100644 --- a/src/routes/[owner]/[collection]/settings.tsx +++ b/src/routes/[owner]/[collection]/settings.tsx @@ -2,6 +2,7 @@ import { type FormEvent, useState } from 'react' import { Link, useLoaderData, useParams } from 'react-router' import BaseLayout from '~/components/BaseLayout' +import WebhooksSettings from '~/components/WebhooksSettings' import { useAppContext } from '~/lib/app-context' import { CollectionNav } from '.' @@ -9,7 +10,7 @@ import { CollectionNav } from '.' export default function CollectionSettingsPage() { const { owner, collection } = useParams() const { currentUser } = useAppContext() - const loaderData = useLoaderData() as { data: any; arkSettings: any } + const loaderData = useLoaderData() as { data: any; arkSettings: any; webhooks: any[] } const [data, setData] = useState(loaderData.data) const [arkSettings, setArkSettings] = useState(loaderData.arkSettings) @@ -451,6 +452,13 @@ export default function CollectionSettingsPage() { + {/* Webhooks */} + + {/* Transfer */}

diff --git a/tools/cron.ts b/tools/cron.ts index 2a4ccfa..b3f04cf 100644 --- a/tools/cron.ts +++ b/tools/cron.ts @@ -41,6 +41,11 @@ if (process.env.NODE_ENV === 'production') { timezone: 'UTC', }) + // Daily at 3:45 AM UTC — prune webhook delivery-log rows older than 30 days + cron.schedule('45 3 * * *', () => run('Prune webhook logs', 'tool:pruneWebhookLogs'), { + timezone: 'UTC', + }) + // Mirror sync — only if mirror mode is enabled const mirrorConfig = getMirrorConfig() if (mirrorConfig.enabled) { diff --git a/tools/pruneWebhookLogs.ts b/tools/pruneWebhookLogs.ts new file mode 100644 index 0000000..4131d7b --- /dev/null +++ b/tools/pruneWebhookLogs.ts @@ -0,0 +1,19 @@ +/** + * Delete webhook delivery-log rows older than the 30-day retention window. + * + * The app also runs this on an in-process daily interval (see + * startWebhookBackgroundJobs), which covers self-hosters who don't run the + * cron container. This tool is the cron-scheduled equivalent. + */ +import { purgeOldDeliveries } from '../src/lib/webhooks.server.js' + +async function main() { + const deleted = await purgeOldDeliveries() + console.log(`[prune-webhook-logs] Deleted ${deleted} delivery row(s) older than 30 days`) + process.exit(0) +} + +main().catch((err) => { + console.error('[prune-webhook-logs] Failed:', err) + process.exit(1) +})