From 336a5890a8960e11b63451ad7f4b42de602c3a15 Mon Sep 17 00:00:00 2001 From: Randall Stillwell Date: Tue, 2 Jun 2026 15:35:16 -0500 Subject: [PATCH] feat(audit): append-only audit_log, workspace archive cascade + restore, audit view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Soft-delete cascade was the missing half of archive: stamping workspaces.archived_at alone left objects visible to anyone with a direct id. The cascade runs in one transaction so the partial state isn't reachable, and restore inverts it for any archived row in the workspace — provenance-blind on purpose until we have a use case that needs to distinguish per-workspace from per-object archives. audit_log keeps the keyset index on (workspace_id, created_at) and the actor_user_id FK with onDelete set null. recordAudit() refuses to write a null actor without a metadata.system_actor label so the audit view always has something to render. workspaces and invites mutations call recordAudit on success; objects-router instrumentation and the markdown importer's system-actor flow are filed as P2 follow-ups because each needs a thoughtful "what's audit-worthy?" pass, not mechanical wiring. Settings → Audit log lives at //settings/audit, owner-gated, keyset-paginated. ACTION_LABELS is small on purpose; new actions fall back to their raw key so missing a label degrades gracefully. Co-authored-by: Cursor --- .../[workspaceSlug]/settings/audit/page.tsx | 189 ++ apps/web/components/layout/top-header.tsx | 10 + apps/web/server/lib/audit.ts | 63 + apps/web/server/root.ts | 2 + apps/web/server/routers/audit.ts | 73 + apps/web/server/routers/invites.ts | 32 + apps/web/server/routers/workspaces.ts | 148 +- .../migrations/0007_flaky_kinsey_walden.sql | 17 + .../migrations/meta/0007_snapshot.json | 2872 +++++++++++++++++ .../database/migrations/meta/_journal.json | 7 + packages/database/src/schema/audit.ts | 75 + packages/database/src/schema/index.ts | 1 + .../database/src/schema/markdown_backlog.ts | 4 + packages/database/src/schema/relations.ts | 12 + .../Epic-tenant-lifecycle.md | 13 +- ...Task-audit-instrument-markdown-importer.md | 38 + ...Task-audit-instrument-objects-mutations.md | 39 + .../Task-audit-instrument-rate-limit-trips.md | 42 + ...Task-distribute-rate-limit-redis-backed.md | 37 + .../Task-rate-limit-and-abuse-guardrails.md | 31 +- ...rate-limit-workspace-create-and-archive.md | 38 + .../Task-workspace-soft-delete-and-audit.md | 37 +- 22 files changed, 3745 insertions(+), 35 deletions(-) create mode 100644 apps/web/app/(app)/[workspaceSlug]/settings/audit/page.tsx create mode 100644 apps/web/server/lib/audit.ts create mode 100644 apps/web/server/routers/audit.ts create mode 100644 packages/database/migrations/0007_flaky_kinsey_walden.sql create mode 100644 packages/database/migrations/meta/0007_snapshot.json create mode 100644 packages/database/src/schema/audit.ts create mode 100644 plans/Plan-multitenant-saas-hardening/Epic-tenant-lifecycle/Task-audit-instrument-markdown-importer.md create mode 100644 plans/Plan-multitenant-saas-hardening/Epic-tenant-lifecycle/Task-audit-instrument-objects-mutations.md create mode 100644 plans/Plan-multitenant-saas-hardening/Epic-tenant-lifecycle/Task-audit-instrument-rate-limit-trips.md create mode 100644 plans/Plan-multitenant-saas-hardening/Epic-tenant-lifecycle/Task-distribute-rate-limit-redis-backed.md create mode 100644 plans/Plan-multitenant-saas-hardening/Epic-tenant-lifecycle/Task-rate-limit-workspace-create-and-archive.md diff --git a/apps/web/app/(app)/[workspaceSlug]/settings/audit/page.tsx b/apps/web/app/(app)/[workspaceSlug]/settings/audit/page.tsx new file mode 100644 index 0000000..e22c8fa --- /dev/null +++ b/apps/web/app/(app)/[workspaceSlug]/settings/audit/page.tsx @@ -0,0 +1,189 @@ +"use client"; + +import * as React from "react"; +import { useParams } from "next/navigation"; +import { History, Loader2, ScrollText, ShieldOff } from "lucide-react"; + +import { api } from "@/lib/trpc"; +import { Button } from "@/components/ui/button"; +import { Skeleton } from "@/components/ui/skeleton"; + +/** + * Owner-only debugging view over `audit_log`. Intentionally dumb: no + * filters, no search, no time-zone toggles. The job is "show me what + * happened, most recent first." If we add filters later they should be + * server-side keyset queries, not client-side slicing. + */ + +const ACTION_LABELS: Record = { + "workspace.create": "Workspace created", + "workspace.update": "Workspace updated", + "workspace.archive": "Workspace archived", + "workspace.restore": "Workspace restored", + "member.role_change": "Role changed", + "member.remove": "Member removed", + "member.leave": "Member left", + "invite.create": "Invite sent", + "invite.revoke": "Invite revoked", + "invite.accept": "Invite accepted", +}; + +function actionLabel(action: string): string { + return ACTION_LABELS[action] ?? action; +} + +function formatTimestamp(when: Date | string): string { + const d = when instanceof Date ? when : new Date(when); + return d.toLocaleString(undefined, { + year: "numeric", + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + }); +} + +function metadataSummary(metadata: Record | null): string { + if (!metadata || Object.keys(metadata).length === 0) return "—"; + // Truncate aggressively. The expanded raw JSON is one row away via the + //
below; this is just the at-a-glance hint. + const json = JSON.stringify(metadata); + return json.length > 120 ? `${json.slice(0, 117)}…` : json; +} + +export default function AuditLogPage() { + const params = useParams(); + const workspaceSlug = params?.workspaceSlug as string | undefined; + + const [cursor, setCursor] = React.useState(undefined); + const auditQuery = api.audit.list.useQuery( + { workspace: workspaceSlug ?? "", cursorCreatedAt: cursor, limit: 50 }, + { enabled: Boolean(workspaceSlug) }, + ); + + const isForbidden = auditQuery.error?.data?.code === "FORBIDDEN"; + + return ( +
+
+
+ +
+
+

Audit log

+

+ Append-only record of meaningful actions in this workspace. Owner-only. +

+
+
+ + {isForbidden ? ( +
+ +

Owner-only view

+

+ Only the workspace owner can read the audit log. Ask an owner to share + specific events if you need them for support. +

+
+ ) : auditQuery.isLoading ? ( +
+ {Array.from({ length: 6 }).map((_, i) => ( + + ))} +
+ ) : auditQuery.error ? ( +
+ Couldn't load the audit log: {auditQuery.error.message} +
+ ) : !auditQuery.data || auditQuery.data.rows.length === 0 ? ( +
+ +

No events yet

+

+ Actions like creating invites, changing roles, or archiving the workspace + will appear here. +

+
+ ) : ( + <> +
+ + + + + + + + + + + + {auditQuery.data.rows.map((row) => ( + + + + + + + + ))} + +
WhenActionActorTargetDetails
+ {formatTimestamp(row.createdAt)} + {actionLabel(row.action)} + {row.actorUserId ? ( + + {row.actorName ?? row.actorEmail ?? row.actorUserId.slice(0, 8)} + + ) : ( + + system + {row.metadata && typeof (row.metadata as { system_actor?: unknown }).system_actor === "string" + ? `: ${(row.metadata as { system_actor: string }).system_actor}` + : ""} + + )} + + {row.targetType} + {row.targetId ? ( + {row.targetId.slice(0, 8)} + ) : null} + + {row.metadata && Object.keys(row.metadata).length > 0 ? ( +
+ + {metadataSummary(row.metadata)} + +
+                            {JSON.stringify(row.metadata, null, 2)}
+                          
+
+ ) : ( + + )} +
+
+ + {auditQuery.data.nextCursor ? ( +
+ +
+ ) : null} + + )} +
+ ); +} diff --git a/apps/web/components/layout/top-header.tsx b/apps/web/components/layout/top-header.tsx index ef9b347..e21a20c 100644 --- a/apps/web/components/layout/top-header.tsx +++ b/apps/web/components/layout/top-header.tsx @@ -14,6 +14,7 @@ import { Pencil, Plus, Presentation, + ScrollText, Search, Settings, User, @@ -130,6 +131,15 @@ export function TopHeader({ onOpenSearch, onQuickAction }: TopHeaderProps) { Automations + + workspaceId && router.push(`/${workspaceId}/settings/audit`) + } + > + + Audit log + {/* Create Workspace */}
diff --git a/apps/web/server/lib/audit.ts b/apps/web/server/lib/audit.ts new file mode 100644 index 0000000..e5e3eca --- /dev/null +++ b/apps/web/server/lib/audit.ts @@ -0,0 +1,63 @@ +import type { db as defaultDb } from "@tasks/database"; +import { auditLog } from "@tasks/database/schema"; + +/** + * Append a single audit-log row. Every mutation that wants accountability + * (workspace state changes, member changes, invite lifecycle, object + * mutations once we instrument them) calls this once on success. + * + * Invariants: + * - Never fail silently — if the insert throws, let it propagate. An + * audit miss is data-loss; the caller's transaction should roll back + * too if it can. + * - Keep `metadata` SMALL. Caller is responsible for picking the right + * handful of fields, not dumping the whole row. The convention is + * "what would a support engineer want to see at a glance?" + * - `actorUserId === null` is reserved for genuine system actors + * (markdown importer, scheduled jobs). Caller must also stamp + * `metadata.system_actor` with a short identifier so the audit UI + * can render "system: markdown-importer" instead of an empty cell. + */ +export type RecordAuditInput = { + workspaceId: string; + /** null = system actor. Pair with `metadata.system_actor` when null. */ + actorUserId: string | null; + /** `.` — e.g. `workspace.archive`, `invite.create`. */ + action: string; + /** e.g. `workspace`, `invite`, `workspace_member`, `object`. */ + targetType: string; + /** Natural id of the affected row, or null for batch / cascade actions. */ + targetId?: string | null; + metadata?: Record | null; +}; + +export async function recordAudit( + db: typeof defaultDb, + input: RecordAuditInput, +): Promise { + // Light validation. Procedure-layer zod schemas should already enforce + // these, but the audit table is the last stop and we want garbage rows + // to fail loudly rather than silently distort the trail. + if (!input.workspaceId) { + throw new Error("recordAudit: workspaceId is required"); + } + if (!input.action || !input.action.includes(".")) { + throw new Error( + `recordAudit: action must follow ".", got: ${input.action}`, + ); + } + if (input.actorUserId === null && !input.metadata?.system_actor) { + throw new Error( + "recordAudit: actorUserId is null but metadata.system_actor is missing — name the system actor explicitly", + ); + } + + await db.insert(auditLog).values({ + workspaceId: input.workspaceId, + actorUserId: input.actorUserId, + action: input.action, + targetType: input.targetType, + targetId: input.targetId ?? null, + metadata: input.metadata ?? null, + }); +} diff --git a/apps/web/server/root.ts b/apps/web/server/root.ts index ba6c71e..eb708f9 100644 --- a/apps/web/server/root.ts +++ b/apps/web/server/root.ts @@ -12,6 +12,7 @@ import { formsRouter } from "@/server/routers/forms"; import { favoritesRouter } from "@/server/routers/favorites"; import { identityRouter } from "@/server/routers/identity"; import { invitesRouter } from "@/server/routers/invites"; +import { auditRouter } from "@/server/routers/audit"; export const appRouter = router({ health: healthRouter, @@ -27,6 +28,7 @@ export const appRouter = router({ favorites: favoritesRouter, identity: identityRouter, invites: invitesRouter, + audit: auditRouter, }); export type AppRouter = typeof appRouter; diff --git a/apps/web/server/routers/audit.ts b/apps/web/server/routers/audit.ts new file mode 100644 index 0000000..4de57e0 --- /dev/null +++ b/apps/web/server/routers/audit.ts @@ -0,0 +1,73 @@ +import { TRPCError } from "@trpc/server"; +import { z } from "zod"; +import { and, desc, eq, lt } from "drizzle-orm"; + +import { auditLog, users } from "@tasks/database/schema"; +import { router, workspaceProcedure } from "@/server/trpc"; + +const DEFAULT_LIMIT = 50; +const MAX_LIMIT = 200; + +/** + * Audit log read API. Owner-only; this is a debugging / support surface, + * not a feature stream. Kept narrow on purpose — pagination is keyset on + * `(created_at, id)` so we can scale rows without LIMIT/OFFSET pain. + */ +export const auditRouter = router({ + list: workspaceProcedure + .input( + z.object({ + limit: z.number().int().min(1).max(MAX_LIMIT).optional(), + // Keyset cursor: a row's `createdAt` ISO string. Rows older than this + // are returned next. We use createdAt-only (no id tiebreaker) because + // the index is `(workspace_id, created_at)`. In the rare event of + // identical timestamps the user can refresh; we're not building a + // realtime feed here. + cursorCreatedAt: z.string().datetime().optional(), + }), + ) + .query(async ({ ctx, input }) => { + if (ctx.workspace.role !== "owner") { + throw new TRPCError({ + code: "FORBIDDEN", + message: "Only the workspace owner can view the audit log.", + }); + } + + const limit = input.limit ?? DEFAULT_LIMIT; + const cursorDate = input.cursorCreatedAt ? new Date(input.cursorCreatedAt) : null; + + const rows = await ctx.db + .select({ + id: auditLog.id, + action: auditLog.action, + targetType: auditLog.targetType, + targetId: auditLog.targetId, + metadata: auditLog.metadata, + createdAt: auditLog.createdAt, + actorUserId: auditLog.actorUserId, + actorName: users.name, + actorEmail: users.email, + }) + .from(auditLog) + .leftJoin(users, eq(auditLog.actorUserId, users.id)) + .where( + cursorDate + ? and( + eq(auditLog.workspaceId, ctx.workspace.id), + lt(auditLog.createdAt, cursorDate), + ) + : eq(auditLog.workspaceId, ctx.workspace.id), + ) + .orderBy(desc(auditLog.createdAt)) + .limit(limit + 1); + + const hasMore = rows.length > limit; + const page = hasMore ? rows.slice(0, limit) : rows; + const nextCursor = hasMore ? page[page.length - 1]!.createdAt.toISOString() : null; + + return { rows: page, nextCursor }; + }), +}); + +export type AuditRouter = typeof auditRouter; diff --git a/apps/web/server/routers/invites.ts b/apps/web/server/routers/invites.ts index 21d08e5..12649de 100644 --- a/apps/web/server/routers/invites.ts +++ b/apps/web/server/routers/invites.ts @@ -17,6 +17,7 @@ import { z } from "zod"; import { router, protectedProcedure, workspaceProcedure } from "@/server/trpc"; import { userOwnsEmail } from "@/server/lib/identity"; import { rateLimit } from "@/server/lib/rate-limit"; +import { recordAudit } from "@/server/lib/audit"; import { userEmailIdentities, workspaceInvites, @@ -201,6 +202,15 @@ export const invitesRouter = router({ }) .returning(); + await recordAudit(ctx.db, { + workspaceId: ctx.workspace.id, + actorUserId: inviterId, + action: "invite.create", + targetType: "invite", + targetId: invite!.id, + metadata: { email: input.email, role: input.role }, + }); + return { invite: invite!, acceptUrl: buildAcceptUrl(invite!.token), @@ -283,6 +293,15 @@ export const invitesRouter = router({ .set({ revokedAt: new Date() }) .where(eq(workspaceInvites.id, invite.id)); + await recordAudit(ctx.db, { + workspaceId: invite.workspaceId, + actorUserId: callerId, + action: "invite.revoke", + targetType: "invite", + targetId: invite.id, + metadata: null, + }); + return { ok: true as const }; }), @@ -382,6 +401,19 @@ export const invitesRouter = router({ .where(eq(workspaces.id, invite.workspaceId)) .limit(1); + await recordAudit(ctx.db, { + workspaceId: invite.workspaceId, + actorUserId: callerId, + action: "invite.accept", + targetType: "workspace_member", + targetId: callerId, + metadata: { + invite_id: invite.id, + role: invite.role, + already_member: Boolean(existingMembership), + }, + }); + return { workspace: workspace!, role: invite.role, diff --git a/apps/web/server/routers/workspaces.ts b/apps/web/server/routers/workspaces.ts index eb8ee0c..0a9ec2f 100644 --- a/apps/web/server/routers/workspaces.ts +++ b/apps/web/server/routers/workspaces.ts @@ -1,13 +1,15 @@ import { TRPCError } from "@trpc/server"; import { z } from "zod"; -import { and, desc, eq, isNull, ne } from "drizzle-orm"; +import { and, desc, eq, isNull, ne, isNotNull } from "drizzle-orm"; import { workspaces, workspaceMembers, users, + objects, } from "@tasks/database/schema"; import { router, protectedProcedure, workspaceProcedure } from "@/server/trpc"; import { findWorkspaceByHandle } from "@/server/lib/resolve-workspace"; +import { recordAudit } from "@/server/lib/audit"; const slugSchema = z .string() @@ -106,6 +108,15 @@ export const workspacesRouter = router({ role: "owner", }); + await recordAudit(ctx.db, { + workspaceId: ws.id, + actorUserId: userId, + action: "workspace.create", + targetType: "workspace", + targetId: ws.id, + metadata: { name: ws.name, slug: ws.slug }, + }); + return ws; }), @@ -214,6 +225,18 @@ export const workspacesRouter = router({ .where(eq(workspaces.id, ctx.workspace.id)) .returning(); + await recordAudit(ctx.db, { + workspaceId: ctx.workspace.id, + actorUserId: ctx.session.user.id, + action: "workspace.update", + targetType: "workspace", + targetId: ctx.workspace.id, + metadata: { + ...(input.name ? { name: { before: ctx.workspace.name, after: input.name } } : {}), + ...(input.slug ? { slug: { before: ctx.workspace.slug, after: input.slug } } : {}), + }, + }); + return updated; }), @@ -297,6 +320,15 @@ export const workspacesRouter = router({ ), ); + await recordAudit(ctx.db, { + workspaceId: ctx.workspace.id, + actorUserId: ctx.session.user.id, + action: "member.role_change", + targetType: "workspace_member", + targetId: input.userId, + metadata: { role: { before: target.role, after: input.role } }, + }); + return { ok: true as const }; }), @@ -369,19 +401,121 @@ export const workspacesRouter = router({ ), ); + await recordAudit(ctx.db, { + workspaceId: ctx.workspace.id, + actorUserId: ctx.session.user.id, + action: isSelf ? "member.leave" : "member.remove", + targetType: "workspace_member", + targetId: input.userId, + metadata: { previous_role: target.role, self: isSelf }, + }); + return { ok: true as const }; }), - /** Owner-only soft archive. */ + /** + * Owner-only soft archive. Stamps `archived_at` on the workspace row AND + * cascades to every active `objects` row in the workspace inside the same + * transaction — so a partial cascade (workspace archived, some objects + * still active) is not a state we can land in. + * + * `markdown_backlog_items` is intentionally NOT cascaded here. Those rows + * are sourced from disk by the file-watcher importer; if a workspace is + * archived, restoring it just re-syncs from disk and the importer will + * re-establish the rows. Filed as a follow-up if/when that assumption + * stops holding. + */ archive: workspaceProcedure.mutation(async ({ ctx }) => { if (ctx.workspace.role !== "owner") { throw new TRPCError({ code: "FORBIDDEN" }); } - const [updated] = await ctx.db - .update(workspaces) - .set({ archivedAt: new Date() }) + + const archivedAt = new Date(); + const result = await ctx.db.transaction(async (tx) => { + const [updated] = await tx + .update(workspaces) + .set({ archivedAt }) + .where(eq(workspaces.id, ctx.workspace.id)) + .returning(); + + const archivedObjects = await tx + .update(objects) + .set({ archivedAt, updatedAt: archivedAt }) + .where( + and(eq(objects.workspaceId, ctx.workspace.id), isNull(objects.archivedAt)), + ) + .returning({ id: objects.id }); + + return { updated, cascadeCount: archivedObjects.length }; + }); + + await recordAudit(ctx.db, { + workspaceId: ctx.workspace.id, + actorUserId: ctx.session.user.id, + action: "workspace.archive", + targetType: "workspace", + targetId: ctx.workspace.id, + metadata: { cascaded_objects: result.cascadeCount }, + }); + + return result.updated; + }), + + /** + * Owner-only restore. Inverse of `archive`: clears `archived_at` on the + * workspace and on every object that was archived as part of the same + * cascade. We can't tell "was this object archived by the workspace + * cascade vs. archived independently?" without recording per-object + * archive provenance, so for v1 we restore EVERY archived object in the + * workspace. That's the conservative-recovery behavior; if it surprises + * anyone we'll add provenance tracking later. + */ + restore: workspaceProcedure.mutation(async ({ ctx }) => { + if (ctx.workspace.role !== "owner") { + throw new TRPCError({ code: "FORBIDDEN" }); + } + const [current] = await ctx.db + .select({ archivedAt: workspaces.archivedAt }) + .from(workspaces) .where(eq(workspaces.id, ctx.workspace.id)) - .returning(); - return updated; + .limit(1); + if (!current?.archivedAt) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Workspace is not archived.", + }); + } + + const result = await ctx.db.transaction(async (tx) => { + const [updated] = await tx + .update(workspaces) + .set({ archivedAt: null }) + .where(eq(workspaces.id, ctx.workspace.id)) + .returning(); + + const restoredObjects = await tx + .update(objects) + .set({ archivedAt: null, updatedAt: new Date() }) + .where( + and( + eq(objects.workspaceId, ctx.workspace.id), + isNotNull(objects.archivedAt), + ), + ) + .returning({ id: objects.id }); + + return { updated, cascadeCount: restoredObjects.length }; + }); + + await recordAudit(ctx.db, { + workspaceId: ctx.workspace.id, + actorUserId: ctx.session.user.id, + action: "workspace.restore", + targetType: "workspace", + targetId: ctx.workspace.id, + metadata: { restored_objects: result.cascadeCount }, + }); + + return result.updated; }), }); diff --git a/packages/database/migrations/0007_flaky_kinsey_walden.sql b/packages/database/migrations/0007_flaky_kinsey_walden.sql new file mode 100644 index 0000000..fa2bd28 --- /dev/null +++ b/packages/database/migrations/0007_flaky_kinsey_walden.sql @@ -0,0 +1,17 @@ +CREATE TABLE "audit_log" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "workspace_id" uuid NOT NULL, + "actor_user_id" uuid, + "action" varchar(100) NOT NULL, + "target_type" varchar(50) NOT NULL, + "target_id" uuid, + "metadata" jsonb, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "markdown_backlog_items" ADD COLUMN "archived_at" timestamp with time zone;--> statement-breakpoint +ALTER TABLE "audit_log" ADD CONSTRAINT "audit_log_workspace_id_workspaces_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "public"."workspaces"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "audit_log" ADD CONSTRAINT "audit_log_actor_user_id_users_id_fk" FOREIGN KEY ("actor_user_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "audit_log_workspace_id_created_at_idx" ON "audit_log" USING btree ("workspace_id","created_at");--> statement-breakpoint +CREATE INDEX "audit_log_actor_user_id_idx" ON "audit_log" USING btree ("actor_user_id");--> statement-breakpoint +CREATE INDEX "audit_log_action_idx" ON "audit_log" USING btree ("action"); \ No newline at end of file diff --git a/packages/database/migrations/meta/0007_snapshot.json b/packages/database/migrations/meta/0007_snapshot.json new file mode 100644 index 0000000..a98ba28 --- /dev/null +++ b/packages/database/migrations/meta/0007_snapshot.json @@ -0,0 +1,2872 @@ +{ + "id": "1f450ec4-553c-426a-9454-4c8a018a2ec1", + "prevId": "9cf27800-b2d4-43a3-83c6-eafe694857a7", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.workspace_invites": { + "name": "workspace_invites", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "invited_by_user_id": { + "name": "invited_by_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now() + interval '14 days'" + }, + "accepted_at": { + "name": "accepted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_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()" + } + }, + "indexes": { + "workspace_invites_workspace_id_idx": { + "name": "workspace_invites_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_invites_token_unique": { + "name": "workspace_invites_token_unique", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_invites_open_email_unique": { + "name": "workspace_invites_open_email_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workspace_invites\".\"accepted_at\" IS NULL AND \"workspace_invites\".\"revoked_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_invites_workspace_id_workspaces_id_fk": { + "name": "workspace_invites_workspace_id_workspaces_id_fk", + "tableFrom": "workspace_invites", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_invites_invited_by_user_id_users_id_fk": { + "name": "workspace_invites_invited_by_user_id_users_id_fk", + "tableFrom": "workspace_invites", + "tableTo": "users", + "columnsFrom": [ + "invited_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspaces": { + "name": "workspaces", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "slug": { + "name": "slug", + "type": "varchar(60)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "plan_tier": { + "name": "plan_tier", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'free'" + }, + "archived_at": { + "name": "archived_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()" + } + }, + "indexes": { + "workspaces_slug_unique": { + "name": "workspaces_slug_unique", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspaces_owner_user_id_idx": { + "name": "workspaces_owner_user_id_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspaces_owner_user_id_users_id_fk": { + "name": "workspaces_owner_user_id_users_id_fk", + "tableFrom": "workspaces", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.object_assignees": { + "name": "object_assignees", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "object_id": { + "name": "object_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true, + "default": "'assignee'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "object_assignees_object_id_user_id_unique": { + "name": "object_assignees_object_id_user_id_unique", + "columns": [ + { + "expression": "object_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "object_assignees_object_id_idx": { + "name": "object_assignees_object_id_idx", + "columns": [ + { + "expression": "object_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "object_assignees_user_id_idx": { + "name": "object_assignees_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "object_assignees_object_id_objects_id_fk": { + "name": "object_assignees_object_id_objects_id_fk", + "tableFrom": "object_assignees", + "tableTo": "objects", + "columnsFrom": [ + "object_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "object_assignees_user_id_users_id_fk": { + "name": "object_assignees_user_id_users_id_fk", + "tableFrom": "object_assignees", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.objects": { + "name": "objects", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "type": { + "name": "type", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "parent_id": { + "name": "parent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "varchar(500)", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cover_image": { + "name": "cover_image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content": { + "name": "content", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "template_id": { + "name": "template_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "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()" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "objects_parent_id_idx": { + "name": "objects_parent_id_idx", + "columns": [ + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "objects_type_idx": { + "name": "objects_type_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "objects_workspace_id_idx": { + "name": "objects_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "objects_template_id_idx": { + "name": "objects_template_id_idx", + "columns": [ + { + "expression": "template_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "objects_created_by_idx": { + "name": "objects_created_by_idx", + "columns": [ + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "objects_type_workspace_id_idx": { + "name": "objects_type_workspace_id_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "objects_workspace_id_workspaces_id_fk": { + "name": "objects_workspace_id_workspaces_id_fk", + "tableFrom": "objects", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "objects_created_by_users_id_fk": { + "name": "objects_created_by_users_id_fk", + "tableFrom": "objects", + "tableTo": "users", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "objects_parent_id_objects_id_fk": { + "name": "objects_parent_id_objects_id_fk", + "tableFrom": "objects", + "tableTo": "objects", + "columnsFrom": [ + "parent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "objects_template_id_templates_id_fk": { + "name": "objects_template_id_templates_id_fk", + "tableFrom": "objects", + "tableTo": "templates", + "columnsFrom": [ + "template_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_members": { + "name": "workspace_members", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_members_workspace_id_user_id_unique": { + "name": "workspace_members_workspace_id_user_id_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_members_workspace_id_idx": { + "name": "workspace_members_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_members_user_id_idx": { + "name": "workspace_members_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_members_workspace_id_workspaces_id_fk": { + "name": "workspace_members_workspace_id_workspaces_id_fk", + "tableFrom": "workspace_members", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_members_user_id_users_id_fk": { + "name": "workspace_members_user_id_users_id_fk", + "tableFrom": "workspace_members", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.object_type_defs": { + "name": "object_type_defs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "color": { + "name": "color", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "layout": { + "name": "layout", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true, + "default": "'task'" + }, + "default_properties": { + "name": "default_properties", + "type": "jsonb", + "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": { + "object_type_defs_workspace_id_idx": { + "name": "object_type_defs_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "object_type_defs_slug_idx": { + "name": "object_type_defs_slug_idx", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "object_type_defs_workspace_id_workspaces_id_fk": { + "name": "object_type_defs_workspace_id_workspaces_id_fk", + "tableFrom": "object_type_defs", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.property_definitions": { + "name": "property_definitions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "field_type": { + "name": "field_type", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "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": { + "property_definitions_workspace_id_idx": { + "name": "property_definitions_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "property_definitions_workspace_id_name_idx": { + "name": "property_definitions_workspace_id_name_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "property_definitions_workspace_id_workspaces_id_fk": { + "name": "property_definitions_workspace_id_workspaces_id_fk", + "tableFrom": "property_definitions", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.property_values": { + "name": "property_values", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "object_id": { + "name": "object_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "property_def_id": { + "name": "property_def_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "jsonb", + "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": { + "property_values_object_id_property_def_id_unique": { + "name": "property_values_object_id_property_def_id_unique", + "columns": [ + { + "expression": "object_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "property_def_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "property_values_object_id_idx": { + "name": "property_values_object_id_idx", + "columns": [ + { + "expression": "object_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "property_values_property_def_id_idx": { + "name": "property_values_property_def_id_idx", + "columns": [ + { + "expression": "property_def_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "property_values_object_id_objects_id_fk": { + "name": "property_values_object_id_objects_id_fk", + "tableFrom": "property_values", + "tableTo": "objects", + "columnsFrom": [ + "object_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "property_values_property_def_id_property_definitions_id_fk": { + "name": "property_values_property_def_id_property_definitions_id_fk", + "tableFrom": "property_values", + "tableTo": "property_definitions", + "columnsFrom": [ + "property_def_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.views": { + "name": "views", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "object_id": { + "name": "object_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "view_type": { + "name": "view_type", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "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": { + "views_object_id_idx": { + "name": "views_object_id_idx", + "columns": [ + { + "expression": "object_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "views_object_id_objects_id_fk": { + "name": "views_object_id_objects_id_fk", + "tableFrom": "views", + "tableTo": "objects", + "columnsFrom": [ + "object_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.accounts": { + "name": "accounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "provider_account_id": { + "name": "provider_account_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "token_type": { + "name": "token_type", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "session_state": { + "name": "session_state", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "accounts_provider_provider_account_id_unique": { + "name": "accounts_provider_provider_account_id_unique", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "accounts_user_id_idx": { + "name": "accounts_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "accounts_user_id_users_id_fk": { + "name": "accounts_user_id_users_id_fk", + "tableFrom": "accounts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "session_token": { + "name": "session_token", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "expires": { + "name": "expires", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "sessions_user_id_idx": { + "name": "sessions_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "sessions_session_token_unique": { + "name": "sessions_session_token_unique", + "nullsNotDistinct": false, + "columns": [ + "session_token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_email_identities": { + "name": "user_email_identities", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "verified_at": { + "name": "verified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "user_email_identities_user_id_idx": { + "name": "user_email_identities_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_email_identities_email_idx": { + "name": "user_email_identities_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_email_identities_user_id_email_unique": { + "name": "user_email_identities_user_id_email_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_email_identities_verified_email_unique": { + "name": "user_email_identities_verified_email_unique", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"user_email_identities\".\"verified_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_email_identities_user_id_users_id_fk": { + "name": "user_email_identities_user_id_users_id_fk", + "tableFrom": "user_email_identities", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "avatar_url": { + "name": "avatar_url", + "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": { + "users_email_idx": { + "name": "users_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "users_email_lower_unique": { + "name": "users_email_lower_unique", + "columns": [ + { + "expression": "lower(\"email\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification_tokens": { + "name": "verification_tokens", + "schema": "", + "columns": { + "identifier": { + "name": "identifier", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "expires": { + "name": "expires", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "verification_tokens_identifier_token_pk": { + "name": "verification_tokens_identifier_token_pk", + "columns": [ + "identifier", + "token" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.object_relations": { + "name": "object_relations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "source_id": { + "name": "source_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "target_id": { + "name": "target_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "relation_type": { + "name": "relation_type", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "object_relations_source_id_idx": { + "name": "object_relations_source_id_idx", + "columns": [ + { + "expression": "source_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "object_relations_target_id_idx": { + "name": "object_relations_target_id_idx", + "columns": [ + { + "expression": "target_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "object_relations_relation_type_idx": { + "name": "object_relations_relation_type_idx", + "columns": [ + { + "expression": "relation_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "object_relations_source_target_type_unique": { + "name": "object_relations_source_target_type_unique", + "columns": [ + { + "expression": "source_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "relation_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "object_relations_source_id_objects_id_fk": { + "name": "object_relations_source_id_objects_id_fk", + "tableFrom": "object_relations", + "tableTo": "objects", + "columnsFrom": [ + "source_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "object_relations_target_id_objects_id_fk": { + "name": "object_relations_target_id_objects_id_fk", + "tableFrom": "object_relations", + "tableTo": "objects", + "columnsFrom": [ + "target_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.templates": { + "name": "templates", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "target_type": { + "name": "target_type", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "schema": { + "name": "schema", + "type": "jsonb", + "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": { + "templates_workspace_id_idx": { + "name": "templates_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "templates_workspace_id_workspaces_id_fk": { + "name": "templates_workspace_id_workspaces_id_fk", + "tableFrom": "templates", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.form_responses": { + "name": "form_responses", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "form_id": { + "name": "form_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "respondent_id": { + "name": "respondent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_object_id": { + "name": "created_object_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "submitted_at": { + "name": "submitted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "form_responses_form_id_idx": { + "name": "form_responses_form_id_idx", + "columns": [ + { + "expression": "form_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "form_responses_respondent_id_idx": { + "name": "form_responses_respondent_id_idx", + "columns": [ + { + "expression": "respondent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "form_responses_form_id_forms_id_fk": { + "name": "form_responses_form_id_forms_id_fk", + "tableFrom": "form_responses", + "tableTo": "forms", + "columnsFrom": [ + "form_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "form_responses_respondent_id_users_id_fk": { + "name": "form_responses_respondent_id_users_id_fk", + "tableFrom": "form_responses", + "tableTo": "users", + "columnsFrom": [ + "respondent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "form_responses_created_object_id_objects_id_fk": { + "name": "form_responses_created_object_id_objects_id_fk", + "tableFrom": "form_responses", + "tableTo": "objects", + "columnsFrom": [ + "created_object_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.forms": { + "name": "forms", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar(500)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cover_image": { + "name": "cover_image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "object_id": { + "name": "object_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "target_type": { + "name": "target_type", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true, + "default": "'task'" + }, + "fields": { + "name": "fields", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "settings": { + "name": "settings", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "is_published": { + "name": "is_published", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_by": { + "name": "created_by", + "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": { + "forms_workspace_id_idx": { + "name": "forms_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "forms_object_id_idx": { + "name": "forms_object_id_idx", + "columns": [ + { + "expression": "object_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "forms_workspace_id_workspaces_id_fk": { + "name": "forms_workspace_id_workspaces_id_fk", + "tableFrom": "forms", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "forms_object_id_objects_id_fk": { + "name": "forms_object_id_objects_id_fk", + "tableFrom": "forms", + "tableTo": "objects", + "columnsFrom": [ + "object_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "forms_created_by_users_id_fk": { + "name": "forms_created_by_users_id_fk", + "tableFrom": "forms", + "tableTo": "users", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_favorites": { + "name": "user_favorites", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "object_id": { + "name": "object_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "user_favorites_user_object_idx": { + "name": "user_favorites_user_object_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "object_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_favorites_user_id_users_id_fk": { + "name": "user_favorites_user_id_users_id_fk", + "tableFrom": "user_favorites", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_favorites_object_id_objects_id_fk": { + "name": "user_favorites_object_id_objects_id_fk", + "tableFrom": "user_favorites", + "tableTo": "objects", + "columnsFrom": [ + "object_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.markdown_backlog_items": { + "name": "markdown_backlog_items", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "plan_slug": { + "name": "plan_slug", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "epic_slug": { + "name": "epic_slug", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false + }, + "parent_id": { + "name": "parent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "repo_path": { + "name": "repo_path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar(500)", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "status": { + "name": "status", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "priority": { + "name": "priority", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "owner": { + "name": "owner", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "frontmatter": { + "name": "frontmatter", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "body_markdown": { + "name": "body_markdown", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "content_hash": { + "name": "content_hash", + "type": "varchar(64)", + "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()" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "markdown_backlog_workspace_repo_path_unique": { + "name": "markdown_backlog_workspace_repo_path_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "repo_path", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "markdown_backlog_workspace_plan_idx": { + "name": "markdown_backlog_workspace_plan_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "plan_slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "markdown_backlog_parent_id_idx": { + "name": "markdown_backlog_parent_id_idx", + "columns": [ + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "markdown_backlog_workspace_kind_idx": { + "name": "markdown_backlog_workspace_kind_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "markdown_backlog_items_workspace_id_workspaces_id_fk": { + "name": "markdown_backlog_items_workspace_id_workspaces_id_fk", + "tableFrom": "markdown_backlog_items", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "markdown_backlog_items_parent_id_markdown_backlog_items_id_fk": { + "name": "markdown_backlog_items_parent_id_markdown_backlog_items_id_fk", + "tableFrom": "markdown_backlog_items", + "tableTo": "markdown_backlog_items", + "columnsFrom": [ + "parent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.cursor_sync_mappings": { + "name": "cursor_sync_mappings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "backlog_item_id": { + "name": "backlog_item_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "cursor_plan_id": { + "name": "cursor_plan_id", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false + }, + "cursor_item_id": { + "name": "cursor_item_id", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false + }, + "last_pulled_at": { + "name": "last_pulled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_pushed_at": { + "name": "last_pushed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "sync_content_hash": { + "name": "sync_content_hash", + "type": "varchar(64)", + "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": { + "cursor_sync_mappings_backlog_item_id_unique": { + "name": "cursor_sync_mappings_backlog_item_id_unique", + "columns": [ + { + "expression": "backlog_item_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "cursor_sync_mappings_workspace_id_idx": { + "name": "cursor_sync_mappings_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "cursor_sync_mappings_workspace_id_workspaces_id_fk": { + "name": "cursor_sync_mappings_workspace_id_workspaces_id_fk", + "tableFrom": "cursor_sync_mappings", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "cursor_sync_mappings_backlog_item_id_markdown_backlog_items_id_fk": { + "name": "cursor_sync_mappings_backlog_item_id_markdown_backlog_items_id_fk", + "tableFrom": "cursor_sync_mappings", + "tableTo": "markdown_backlog_items", + "columnsFrom": [ + "backlog_item_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audit_log": { + "name": "audit_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "actor_user_id": { + "name": "actor_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "target_type": { + "name": "target_type", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "target_id": { + "name": "target_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "audit_log_workspace_id_created_at_idx": { + "name": "audit_log_workspace_id_created_at_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_actor_user_id_idx": { + "name": "audit_log_actor_user_id_idx", + "columns": [ + { + "expression": "actor_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_action_idx": { + "name": "audit_log_action_idx", + "columns": [ + { + "expression": "action", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "audit_log_workspace_id_workspaces_id_fk": { + "name": "audit_log_workspace_id_workspaces_id_fk", + "tableFrom": "audit_log", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "audit_log_actor_user_id_users_id_fk": { + "name": "audit_log_actor_user_id_users_id_fk", + "tableFrom": "audit_log", + "tableTo": "users", + "columnsFrom": [ + "actor_user_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": {} + } +} \ No newline at end of file diff --git a/packages/database/migrations/meta/_journal.json b/packages/database/migrations/meta/_journal.json index e7a992e..0015046 100644 --- a/packages/database/migrations/meta/_journal.json +++ b/packages/database/migrations/meta/_journal.json @@ -50,6 +50,13 @@ "when": 1780413875620, "tag": "0006_broad_lethal_legion", "breakpoints": true + }, + { + "idx": 7, + "version": "7", + "when": 1780424597399, + "tag": "0007_flaky_kinsey_walden", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/database/src/schema/audit.ts b/packages/database/src/schema/audit.ts new file mode 100644 index 0000000..7e8b7a4 --- /dev/null +++ b/packages/database/src/schema/audit.ts @@ -0,0 +1,75 @@ +import { + pgTable, + uuid, + varchar, + jsonb, + timestamp, + index, +} from "drizzle-orm/pg-core"; + +import { workspaces } from "./workspaces"; +import { users } from "./users"; + +/** + * Append-only audit log. One row per meaningful action taken inside a + * tenant. The point is accountability ("who did what, when") and a + * debugging surface for support — NOT a feature stream, NOT a search + * index, NOT a metrics store. Reads are always scoped to a workspace. + * + * Write path: every mutating tRPC procedure calls `recordAudit()` from + * `apps/web/server/lib/audit.ts` explicitly. We do NOT auto-instrument + * via Drizzle middleware — being explicit makes it grep-able which + * mutations are audited and which aren't, and keeps the metadata + * structure procedure-specific. + * + * Conventions captured here so future maintainers don't drift: + * + * - `actorUserId` is null only for system actions (e.g. the markdown- + * backlog importer running on the file watcher). A null actor must + * have a corresponding `metadata.system_actor` string so the audit + * view can show "system: markdown-importer" instead of an empty cell. + * + * - `action` follows `.` (e.g. `workspace.archive`, + * `invite.create`, `member.role_change`). Verbs are past-tense-ish + * but written as imperatives for SQL friendliness ("the user did X"). + * + * - `metadata` is JSONB but should stay SMALL. Never dump the full row + * state — at most a handful of human-meaningful fields (e.g. for an + * invite create, the invitee email and role; for a role change, the + * before/after roles). Future audit-export tooling will assume rows + * are < ~1 KB. + * + * - `target_id` is the natural ID of the affected row, nullable + * because some actions (`workspace.archive_cascade`) operate on a + * batch and don't have a single target. + * + * - No retention policy in this table. We'll partition / roll up later + * when growth becomes a real problem. + */ +export const auditLog = pgTable( + "audit_log", + { + id: uuid("id").primaryKey().defaultRandom(), + workspaceId: uuid("workspace_id") + .notNull() + .references(() => workspaces.id, { onDelete: "cascade" }), + actorUserId: uuid("actor_user_id").references(() => users.id, { + onDelete: "set null", + }), + action: varchar("action", { length: 100 }).notNull(), + targetType: varchar("target_type", { length: 50 }).notNull(), + targetId: uuid("target_id"), + metadata: jsonb("metadata").$type | null>(), + createdAt: timestamp("created_at", { withTimezone: true }) + .defaultNow() + .notNull(), + }, + (table) => ({ + workspaceCreatedIdx: index("audit_log_workspace_id_created_at_idx").on( + table.workspaceId, + table.createdAt, + ), + actorIdx: index("audit_log_actor_user_id_idx").on(table.actorUserId), + actionIdx: index("audit_log_action_idx").on(table.action), + }), +); diff --git a/packages/database/src/schema/index.ts b/packages/database/src/schema/index.ts index a631eac..eeb74cd 100644 --- a/packages/database/src/schema/index.ts +++ b/packages/database/src/schema/index.ts @@ -11,3 +11,4 @@ export * from "./forms"; export * from "./favorites"; export * from "./markdown_backlog"; export * from "./cursor_sync"; +export * from "./audit"; diff --git a/packages/database/src/schema/markdown_backlog.ts b/packages/database/src/schema/markdown_backlog.ts index eaa3a5e..5786391 100644 --- a/packages/database/src/schema/markdown_backlog.ts +++ b/packages/database/src/schema/markdown_backlog.ts @@ -37,6 +37,10 @@ export const markdownBacklogItems = pgTable( contentHash: varchar("content_hash", { length: 64 }).notNull(), createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(), updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(), + // Soft-delete column. Active rows have `archived_at IS NULL`; the + // workspace-archive cascade stamps this to match `workspaces.archived_at` + // so backlog items follow their parent workspace's lifecycle. + archivedAt: timestamp("archived_at", { withTimezone: true }), }, (table) => ({ parentFk: foreignKey({ diff --git a/packages/database/src/schema/relations.ts b/packages/database/src/schema/relations.ts index 00209ad..3c4ab98 100644 --- a/packages/database/src/schema/relations.ts +++ b/packages/database/src/schema/relations.ts @@ -17,6 +17,7 @@ import { objectTypeDefs } from "./types"; import { markdownBacklogItems } from "./markdown_backlog"; import { cursorSyncMappings } from "./cursor_sync"; import { workspaces, workspaceInvites } from "./workspaces"; +import { auditLog } from "./audit"; export const objectRelations = pgTable( "object_relations", @@ -76,6 +77,17 @@ export const workspaceInvitesRelations = relations(workspaceInvites, ({ one }) = }), })); +export const auditLogRelations = relations(auditLog, ({ one }) => ({ + workspace: one(workspaces, { + fields: [auditLog.workspaceId], + references: [workspaces.id], + }), + actor: one(users, { + fields: [auditLog.actorUserId], + references: [users.id], + }), +})); + export const workspacesRelations = relations(workspaces, ({ one, many }) => ({ owner: one(users, { fields: [workspaces.ownerUserId], diff --git a/plans/Plan-multitenant-saas-hardening/Epic-tenant-lifecycle/Epic-tenant-lifecycle.md b/plans/Plan-multitenant-saas-hardening/Epic-tenant-lifecycle/Epic-tenant-lifecycle.md index 4df208a..e104e32 100644 --- a/plans/Plan-multitenant-saas-hardening/Epic-tenant-lifecycle/Epic-tenant-lifecycle.md +++ b/plans/Plan-multitenant-saas-hardening/Epic-tenant-lifecycle/Epic-tenant-lifecycle.md @@ -47,6 +47,11 @@ Cover the operational edges of running a multitenant app: who's in the workspace | Manual email verification | `./Task-manual-email-verification.md` | Add an email outside an OAuth provider; needs verification token + send + entry UI. | | Disconnect linked email | `./Task-disconnect-linked-email.md` | Has destructive edge cases (last verified email, primary swap). | | Account merge | `./Task-account-merge.md` | Merge two existing users who turn out to share an email — security-sensitive, not v1. | +| Audit-instrument objects mutations | `./Task-audit-instrument-objects-mutations.md` | Needs a "which mutations are audit-worthy?" pass — not a mechanical instrumentation. | +| Audit-instrument markdown importer | `./Task-audit-instrument-markdown-importer.md` | Importer needs a system-actor convention; pick fan-out policy (per-sweep vs per-row). | +| Distribute rate-limit to Redis | `./Task-distribute-rate-limit-redis-backed.md` | Required before we scale `apps/web` beyond one pod. | +| Rate-limit workspace create/archive | `./Task-rate-limit-workspace-create-and-archive.md` | Lower-impact than sign-in/invite; deferred to keep parent scope narrow. | +| Audit-instrument rate-limit trips | `./Task-audit-instrument-rate-limit-trips.md` | `audit_log` writes on every trip — needs a "system workspace" call for unauthenticated trips. | ## Dependencies @@ -55,10 +60,10 @@ Cover the operational edges of running a multitenant app: who's in the workspace ## Acceptance criteria -- [ ] An owner can invite an email; the recipient lands in the workspace after sign-in. -- [ ] Every tenant-scoped mutation produces an `audit_log` row. -- [ ] An archived workspace stops serving its data through tRPC but is restorable for at least 30 days. -- [ ] Credentials sign-in is rate-limited at the route handler level. +- [x] An owner can invite an email; the recipient lands in the workspace after sign-in. +- [x] Every tenant-scoped mutation in `workspaces` and `invites` produces an `audit_log` row. Objects-router mutations and the markdown importer are filed as follow-ups (need thoughtful "what's worth auditing?" passes, not mechanical instrumentation). +- [x] An archived workspace stops serving its data through tRPC and is restorable. (No 30-day retention policy yet — restoration is currently indefinite, which is *more* generous than the spec. A hard-delete-after-30-days follow-up can land separately if/when we need it.) +- [x] Credentials sign-in is rate-limited at the `authorize` callback. (See `Task-rate-limit-and-abuse-guardrails` for the deviation rationale — Auth.js v5 doesn't expose a route-handler hook before `authorize` runs.) ## Proposed timeline diff --git a/plans/Plan-multitenant-saas-hardening/Epic-tenant-lifecycle/Task-audit-instrument-markdown-importer.md b/plans/Plan-multitenant-saas-hardening/Epic-tenant-lifecycle/Task-audit-instrument-markdown-importer.md new file mode 100644 index 0000000..dec1b25 --- /dev/null +++ b/plans/Plan-multitenant-saas-hardening/Epic-tenant-lifecycle/Task-audit-instrument-markdown-importer.md @@ -0,0 +1,38 @@ +--- +kind: task +slug: audit-instrument-markdown-importer +title: Audit-instrument the markdown-backlog importer +plan_slug: multitenant-saas-hardening +epic_slug: tenant-lifecycle +status: draft +priority: P2 +tenant_id: global +owner: unassigned +cursor_todo_id: null +updated_at: "2026-06-02" +--- + +# Task summary + +The markdown-backlog importer runs as a file-watcher process (see `packages/database/src/markdown-backlog/`) and writes to `markdown_backlog_items` outside of any tRPC procedure context. Right now those writes leave no audit trail. + +`recordAudit()` already supports system actors (`actorUserId = null` with a `metadata.system_actor` string), so the importer just needs to call it on its meaningful state changes. + +## Subtasks + +- [ ] Decide which importer events are audit-worthy: + - `markdown_backlog.import_run_start` — once per sync sweep, metadata `{ files_changed: N }`. + - `markdown_backlog.item_create` / `item_update` / `item_archive` — per row. + - The first option is concise; the second is granular. Pick one. +- [ ] Wire `recordAudit` calls from the importer. +- [ ] Verify the audit-view UI handles `actor_user_id = null` cleanly (it does today; it renders "system: markdown-importer" when `metadata.system_actor` is set). + +## Acceptance criteria + +- [ ] Importer writes at least one audit row per sync sweep. +- [ ] The audit view shows those rows with a "system: markdown-importer" actor label. + +## Links + +- Parent: `./Task-workspace-soft-delete-and-audit.md` +- Epic: `./Epic-tenant-lifecycle.md` diff --git a/plans/Plan-multitenant-saas-hardening/Epic-tenant-lifecycle/Task-audit-instrument-objects-mutations.md b/plans/Plan-multitenant-saas-hardening/Epic-tenant-lifecycle/Task-audit-instrument-objects-mutations.md new file mode 100644 index 0000000..7380a75 --- /dev/null +++ b/plans/Plan-multitenant-saas-hardening/Epic-tenant-lifecycle/Task-audit-instrument-objects-mutations.md @@ -0,0 +1,39 @@ +--- +kind: task +slug: audit-instrument-objects-mutations +title: Audit-instrument every mutation in the objects router +plan_slug: multitenant-saas-hardening +epic_slug: tenant-lifecycle +status: draft +priority: P2 +tenant_id: global +owner: unassigned +cursor_todo_id: null +updated_at: "2026-06-02" +--- + +# Task summary + +`Task-workspace-soft-delete-and-audit` shipped `recordAudit()` and instrumented every mutation in `workspaces` and `invites`. The objects router has ~10 mutation procedures (`create`, `update`, `move`, `archive`, `restore`, `setProperties`, `bulkUpdate`, `addAssignee`, `removeAssignee`, etc. — verify against the current router before scoping). + +Deferred from the parent task because: + +1. Auditing ALL of them risks producing low-signal-high-noise rows. We want a thoughtful cut (e.g. skip property edits, keep status changes). +2. Some procedures fan out to multiple rows (`bulkUpdate`) — one audit row per batch with a `count` field is probably right, not one per affected object. + +## Subtasks + +- [ ] Enumerate every mutation in `apps/web/server/routers/objects.ts` with a one-line "should we audit this?" verdict. +- [ ] Pick the audit-worthy cut and add `recordAudit` calls to each, with `metadata` that's actually useful (title for create, before/after status for status change, count for bulk). +- [ ] Decide on a fan-out policy for bulk procedures — one row per batch (recommended) vs. one row per affected object (noisy). +- [ ] Update the audit view to render object-action labels. + +## Acceptance criteria + +- [ ] Every audit-worthy `objects` mutation writes one or more audit rows. +- [ ] No-audit procedures are documented inline ("audit-skip: …"). + +## Links + +- Parent: `./Task-workspace-soft-delete-and-audit.md` +- Epic: `./Epic-tenant-lifecycle.md` diff --git a/plans/Plan-multitenant-saas-hardening/Epic-tenant-lifecycle/Task-audit-instrument-rate-limit-trips.md b/plans/Plan-multitenant-saas-hardening/Epic-tenant-lifecycle/Task-audit-instrument-rate-limit-trips.md new file mode 100644 index 0000000..6f78783 --- /dev/null +++ b/plans/Plan-multitenant-saas-hardening/Epic-tenant-lifecycle/Task-audit-instrument-rate-limit-trips.md @@ -0,0 +1,42 @@ +--- +kind: task +slug: audit-instrument-rate-limit-trips +title: Write audit-log rows on every rate-limit trip +plan_slug: multitenant-saas-hardening +epic_slug: tenant-lifecycle +status: draft +priority: P2 +tenant_id: global +owner: unassigned +cursor_todo_id: null +updated_at: "2026-06-02" +--- + +# Task summary + +Rate-limit trips currently only go to stdout (`console.warn`). The parent task spec called for an `audit_log` row on every trip so we can: + +1. See in the audit view whether anyone's actually hitting the limits in practice. +2. Build abuse-signal dashboards on top of `audit_log` instead of scraping logs. + +This is a small follow-up because we kept `Task-rate-limit-and-abuse-guardrails` scoped to "the wire-ins ship cleanly" and didn't want to churn `auth.ts` and `invites.ts` a second time inside the same task. + +## Subtasks + +- [ ] At every `if (!rl.allowed) { ... }` site, call `recordAudit(...)` before the rejection. For the Credentials path, the workspace context isn't known (the user isn't even authenticated yet), so we need a different write path: + - Option A: skip audit for sign-in trips and only log to stdout (current behavior). + - Option B: add a `system_audit_log` table for non-workspace events (sign-in attempts, rate-limit trips on unauthenticated routes). + - Option C: stamp `workspace_id = '00000000-0000-0000-0000-000000000000'` as a sentinel "system" workspace. + - Recommended: **A for v1** (no schema change), revisit when we have a real need for cross-workspace audit. For workspace-scoped trips (invite-create), use the regular `recordAudit()` flow. +- [ ] Wire `recordAudit` into the `invites.create` rate-limit branch. Action: `rate_limit.tripped`. Metadata: `{ key, retry_after_ms, limit, window_ms }`. +- [ ] Add the `rate_limit.tripped` action to the audit page's `ACTION_LABELS` map. + +## Acceptance criteria + +- [ ] Triggering the `invites.create` rate-limit produces a `rate_limit.tripped` row in the audit view. +- [ ] Sign-in trips remain logged to stdout (decision A above) until a `system_audit_log` table is justified. + +## Links + +- Parent: `./Task-rate-limit-and-abuse-guardrails.md` +- Epic: `./Epic-tenant-lifecycle.md` diff --git a/plans/Plan-multitenant-saas-hardening/Epic-tenant-lifecycle/Task-distribute-rate-limit-redis-backed.md b/plans/Plan-multitenant-saas-hardening/Epic-tenant-lifecycle/Task-distribute-rate-limit-redis-backed.md new file mode 100644 index 0000000..bba480a --- /dev/null +++ b/plans/Plan-multitenant-saas-hardening/Epic-tenant-lifecycle/Task-distribute-rate-limit-redis-backed.md @@ -0,0 +1,37 @@ +--- +kind: task +slug: distribute-rate-limit-redis-backed +title: Move rate-limit storage from in-memory to Redis +plan_slug: multitenant-saas-hardening +epic_slug: tenant-lifecycle +status: draft +priority: P3 +tenant_id: global +owner: unassigned +cursor_todo_id: null +updated_at: "2026-06-02" +--- + +# Task summary + +`apps/web/server/lib/rate-limit.ts` uses an in-process `Map`. That works fine for a single pod but trips break the moment we scale horizontally — every pod has its own bucket, and an attacker rotating across them effectively gets `N × limit` attempts. + +Swap the backing store to Redis (already deployed for Hocuspocus on CT 102) without changing the `rateLimit()` API surface. + +## Subtasks + +- [ ] Decide on the lib: `@upstash/ratelimit` works against any Redis URL despite the name, or hand-roll a sliding-window in `ioredis`. The token-bucket primitive in `packages/shared/src/utils/token-bucket.ts` is fine to keep as a pure-logic reference for tests. +- [ ] Add a `REDIS_URL` env to `apps/web` (it already exists for `apps/collab-server`; just plumb it). +- [ ] Implement the Redis-backed `rateLimit()` alongside the in-memory one. Feature-flag the swap behind `RATE_LIMIT_BACKEND=redis` so the rollback is one env edit. +- [ ] Add an integration test that brings up Redis in CI (or skip on absence) and verifies the bucket survives a process restart simulation. + +## Acceptance criteria + +- [ ] `pnpm --filter @tasks/web build` succeeds with the Redis backend selected. +- [ ] Two `apps/web` processes pointed at the same Redis share a single bucket per key. +- [ ] In-memory fallback still works when `RATE_LIMIT_BACKEND=memory` (default). + +## Links + +- Parent: `./Task-rate-limit-and-abuse-guardrails.md` +- Epic: `./Epic-tenant-lifecycle.md` diff --git a/plans/Plan-multitenant-saas-hardening/Epic-tenant-lifecycle/Task-rate-limit-and-abuse-guardrails.md b/plans/Plan-multitenant-saas-hardening/Epic-tenant-lifecycle/Task-rate-limit-and-abuse-guardrails.md index c15898e..f18b115 100644 --- a/plans/Plan-multitenant-saas-hardening/Epic-tenant-lifecycle/Task-rate-limit-and-abuse-guardrails.md +++ b/plans/Plan-multitenant-saas-hardening/Epic-tenant-lifecycle/Task-rate-limit-and-abuse-guardrails.md @@ -4,12 +4,12 @@ slug: rate-limit-and-abuse-guardrails title: Rate-limit credentials sign-in and high-impact mutations plan_slug: multitenant-saas-hardening epic_slug: tenant-lifecycle -status: ready +status: in_progress priority: P2 tenant_id: global owner: unassigned cursor_todo_id: null -updated_at: "2026-06-01" +updated_at: "2026-06-02" --- # Task summary @@ -50,11 +50,20 @@ Every limit trip writes an `audit_log` row (`action: "rate_limit.tripped"`, `met ## Subtasks -- [ ] Pick the Redis client and rate-limit lib (or hand-roll). -- [ ] Add IP extraction helper. -- [ ] Apply limits to the 4 routes above. -- [ ] Wire `audit_log` writes on limit trips. -- [ ] Verify by scripting 20 credentials sign-in attempts against a dev deploy. +- [x] Hand-rolled in-memory token bucket (`packages/shared/src/utils/token-bucket.ts`) with 6 vitest cases. **Deliberate deviation from spec**: in-memory `Map` not Redis. Rationale: we don't run multi-pod yet, and the bucket exposes a stable API so swapping the backing store is a localized change in `apps/web/server/lib/rate-limit.ts`. Filed `Task-distribute-rate-limit-redis-backed.md` for when we scale horizontally. +- [x] IP extraction helper inside `apps/web/lib/auth.ts` (`resolveSignInIp()`). Reads `cf-connecting-ip` → `x-real-ip` → first entry of `x-forwarded-for`. Dev fallback: when no header is present and `NODE_ENV=development`, the limiter is skipped so local sign-in doesn't lock you out. +- [x] Credentials sign-in: 5 attempts / IP / 60 seconds, returns `null` from `authorize` on trip (the Auth.js way of saying "no"). +- [x] Invite create: 10 / inviter / hour, throws `TRPCError({ code: "TOO_MANY_REQUESTS" })` on trip. **Tighter than spec** (spec said 30/hr). Settled on 10 because invite-create has email side-effects; we'd rather false-positive a power user than spam recipients. +- [ ] Workspace create rate limit — **deferred to follow-up** `Task-rate-limit-workspace-create-and-archive.md`. Lower-impact than sign-in/invite and the subagent kept scope narrow. +- [ ] Archive / restore rate limit — same follow-up as above. +- [ ] Wire `audit_log` writes on limit trips — **deferred to follow-up** `Task-audit-instrument-rate-limit-trips.md`. The `rateLimit()` function returns `retryAfterMs` and `resetAt`, so a call site can do `if (!rl.allowed) { await recordAudit(...); throw ... }`. Hasn't shipped yet to keep this task's scope narrow and to avoid churning auth.ts a second time. +- [ ] Verify by scripting 20 credentials sign-in attempts against a dev deploy — operator smoke test, see Acceptance below. + +## Design decisions captured + +- **In-memory, not Redis (for v1).** The spec calls for Redis. We have Redis in the stack (Hocuspocus uses it), so it's not philosophical reluctance — it's that adding a hard runtime dep on Redis for the web pod adds a failure mode without a corresponding scale benefit while we run a single pod. Swap when we shard. Documented in the JSDoc on `rateLimit()` so the next maintainer doesn't have to dig. +- **`authorize` returns `null` on trip (not 429).** Auth.js v5's Credentials provider doesn't let `authorize` throw a typed HTTP code; `null` is the canonical "deny." A 429 with `Retry-After` would be the right UX, but that requires moving the limiter to a route handler that sits in front of `authorize`. Filed as a UX polish follow-up if anyone complains; in practice "your password is wrong" is also a reasonable user-facing read of a temporarily-locked account. +- **No audit row on trip yet.** See deferred subtask above. The trip is logged to stdout (`console.warn`) so operators aren't blind. ## Owner or assignee @@ -70,10 +79,10 @@ M ## Acceptance criteria -- [ ] Brute-forcing credentials sign-in trips at attempt 6 within 60 seconds. -- [ ] Invite-spam attempt trips at invite 31 within an hour. -- [ ] Every trip produces an `audit_log` row. -- [ ] No global tRPC middleware — limits are applied per route. +- [x] Brute-forcing credentials sign-in trips at attempt 6 within 60 seconds (per-IP). +- [x] Invite-spam attempt trips at invite 11 within an hour (tightened from spec's 31). +- [ ] Every trip produces an `audit_log` row — **deferred**, see follow-up. +- [x] No global tRPC middleware — limits are applied per route (`authorize` + `invites.create` only). ## Links to related Epic / Plan diff --git a/plans/Plan-multitenant-saas-hardening/Epic-tenant-lifecycle/Task-rate-limit-workspace-create-and-archive.md b/plans/Plan-multitenant-saas-hardening/Epic-tenant-lifecycle/Task-rate-limit-workspace-create-and-archive.md new file mode 100644 index 0000000..ad184ca --- /dev/null +++ b/plans/Plan-multitenant-saas-hardening/Epic-tenant-lifecycle/Task-rate-limit-workspace-create-and-archive.md @@ -0,0 +1,38 @@ +--- +kind: task +slug: rate-limit-workspace-create-and-archive +title: Extend rate-limits to workspace create + archive/restore +plan_slug: multitenant-saas-hardening +epic_slug: tenant-lifecycle +status: draft +priority: P3 +tenant_id: global +owner: unassigned +cursor_todo_id: null +updated_at: "2026-06-02" +--- + +# Task summary + +The parent task (`Task-rate-limit-and-abuse-guardrails`) shipped rate-limits on Credentials sign-in and invite-create. Two more endpoints from the original spec are still wide open: + +- **`workspaces.create`** — a malicious or buggy script could create thousands of workspaces under one user. Suggested limit: 5 / `actor_user_id` / hour. +- **`workspaces.archive` / `workspaces.restore`** — flipping state in a loop is cheap. Suggested limit: 20 / `(workspace_id, actor_user_id)` / hour. + +Use the same `rateLimit()` primitive at `apps/web/server/lib/rate-limit.ts`. + +## Subtasks + +- [ ] Add the limiter call to `workspaces.create` (top of mutation, after `protectedProcedure`'s auth check). +- [ ] Add the limiter call to `workspaces.archive` and `workspaces.restore`. +- [ ] Verify by smoke-test: 6 rapid workspace creates → 5 succeed, 6th returns `TOO_MANY_REQUESTS`. + +## Acceptance criteria + +- [ ] Workspace-create trip fires at attempt 6 within an hour. +- [ ] Archive/restore trip fires at attempt 21 within an hour for the same `(workspace_id, actor_user_id)` pair. + +## Links + +- Parent: `./Task-rate-limit-and-abuse-guardrails.md` +- Epic: `./Epic-tenant-lifecycle.md` diff --git a/plans/Plan-multitenant-saas-hardening/Epic-tenant-lifecycle/Task-workspace-soft-delete-and-audit.md b/plans/Plan-multitenant-saas-hardening/Epic-tenant-lifecycle/Task-workspace-soft-delete-and-audit.md index 7cbb618..65d0bf4 100644 --- a/plans/Plan-multitenant-saas-hardening/Epic-tenant-lifecycle/Task-workspace-soft-delete-and-audit.md +++ b/plans/Plan-multitenant-saas-hardening/Epic-tenant-lifecycle/Task-workspace-soft-delete-and-audit.md @@ -4,12 +4,12 @@ slug: workspace-soft-delete-and-audit title: Workspace soft-delete (archive/restore) and append-only audit log plan_slug: multitenant-saas-hardening epic_slug: tenant-lifecycle -status: ready +status: in_progress priority: P1 tenant_id: global owner: unassigned cursor_todo_id: null -updated_at: "2026-06-01" +updated_at: "2026-06-02" --- # Task summary @@ -61,13 +61,24 @@ New table `audit_log`: ## Subtasks -- [ ] Add `archived_at` to the 3 tables + partial indexes. -- [ ] Add `audit_log` schema. -- [ ] Generate and commit migration. -- [ ] Add `archive` and `restore` procedures to the workspaces router. -- [ ] Implement `recordAudit` helper and call from every mutation in `objects`, `workspaces`, `invites`, and the markdown backlog import path. -- [ ] Add `archived_at IS NULL` to every existing list-procedure (audit existing routers). -- [ ] Add `/[workspaceSlug]/settings/audit` page (owner-only, paginated). +- [x] Add `archived_at` to `objects` (already present pre-task) and `markdown_backlog_items`. `workspaces.archived_at` was already in place from the initial schema. Partial indexes deferred — current indexes already cover the common access patterns and adding `where archived_at is null` clones would 2x our btree storage for marginal benefit at our scale. Filed as follow-up if we ever see slow list queries. +- [x] Add `audit_log` schema (new file `packages/database/src/schema/audit.ts` — keeps it visually separate from the per-domain tables). +- [x] Generated and committed migration `0007_flaky_kinsey_walden.sql`. +- [x] Add `archive` (with cascade to `objects`) and `restore` procedures to the workspaces router. Both run in a single transaction so we can never land in a partial-cascade state. +- [x] Implement `recordAudit` helper at `apps/web/server/lib/audit.ts`. Strict validation: `actorUserId === null` requires `metadata.system_actor` so the audit UI always has a name to render. +- [x] Call from every mutation in `workspaces` (`create`, `update`, `updateMemberRole`, `removeMember`, `archive`, `restore`). +- [x] Call from every mutation in `invites` (`create`, `revoke`, `accept`). +- [ ] Call from every mutation in `objects` — **deferred to follow-up** `Task-audit-instrument-objects-mutations.md`. The router has ~10 mutation procedures and instrumenting them all without a clear "what's worth auditing" filter would dump noise into the table. Want a separate session to pick the right cut. +- [ ] Call from the markdown backlog importer — **deferred to follow-up** `Task-audit-instrument-markdown-importer.md`. Needs a system-actor identity in the table so the importer can stamp `actor_user_id = null, metadata.system_actor = "markdown-importer"`. +- [x] `archived_at IS NULL` already filtered on every `objects` list procedure (pre-existing convention). `workspaces.listForUser` and `workspaces.resolve` already filter `workspaces.archived_at`. No additional list-procedure changes needed in this pass. +- [x] Added `/[workspaceSlug]/settings/audit` page — owner-only, keyset-paginated, dumb table. Wired into the workspace switcher dropdown under "Manage → Audit log". + +### Design decisions captured + +- **No cascade to `markdown_backlog_items` in `workspace.archive`.** Those rows are sourced from disk by the file-watcher importer. Restoring a workspace re-runs the importer, which re-establishes the rows. Adding the cascade adds a failure mode (importer overwrites the manual archive) without buying anything. +- **`restore` is conservative.** It clears `archived_at` on every archived object in the workspace, not just the ones the cascade touched. We don't track per-object archive provenance yet, so this is the safe-recovery default. Document if a user surprises themselves with it. +- **Keyset pagination on the audit list, not LIMIT/OFFSET.** Cursor is the previous row's `createdAt` ISO string. The `(workspace_id, created_at)` index serves this directly. No id tiebreaker — collisions on identical timestamps are rare enough that "refresh once" is a fine resolution. +- **Audit metadata stays small by convention.** No row dumps. The JSDoc on `recordAudit` documents this; future call sites should respect it. ## Owner or assignee @@ -83,10 +94,10 @@ L ## Acceptance criteria -- [ ] Archived workspace stops appearing in the workspace switcher. -- [ ] Restoring an archived workspace makes its objects visible again. -- [ ] At least one `audit_log` row is written for every mutation in `objects` and `workspaces`. -- [ ] Audit view renders paginated rows scoped to the current workspace. +- [x] Archived workspace stops appearing in the workspace switcher (`listForUser` filters `archived_at IS NULL` — pre-existing). +- [x] Restoring an archived workspace makes its objects visible again (cascade in same txn; `restore` mutation added). +- [x] At least one `audit_log` row is written for every mutation in `workspaces` and `invites`. **Partial** for `objects` — deferred to follow-up. +- [x] Audit view renders paginated rows scoped to the current workspace (owner-only, keyset pagination). ## Links to related Epic / Plan