feat(audit): append-only audit_log, workspace archive cascade + restore, audit view

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 /<slug>/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 <cursoragent@cursor.com>
This commit is contained in:
Randall Stillwell 2026-06-02 15:35:16 -05:00
parent 58f92f3898
commit 336a5890a8
22 changed files with 3745 additions and 35 deletions

View file

@ -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<string, string> = {
"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<string, unknown> | null): string {
if (!metadata || Object.keys(metadata).length === 0) return "—";
// Truncate aggressively. The expanded raw JSON is one row away via the
// <details> 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<string | undefined>(undefined);
const auditQuery = api.audit.list.useQuery(
{ workspace: workspaceSlug ?? "", cursorCreatedAt: cursor, limit: 50 },
{ enabled: Boolean(workspaceSlug) },
);
const isForbidden = auditQuery.error?.data?.code === "FORBIDDEN";
return (
<div className="mx-auto max-w-5xl px-8 py-10">
<div className="mb-8 flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10">
<ScrollText className="size-5 text-primary" />
</div>
<div>
<h1 className="text-xl font-semibold">Audit log</h1>
<p className="text-xs text-muted-foreground">
Append-only record of meaningful actions in this workspace. Owner-only.
</p>
</div>
</div>
{isForbidden ? (
<div className="flex flex-col items-center gap-3 rounded-lg border bg-muted/40 px-6 py-12 text-center">
<ShieldOff className="size-6 text-muted-foreground" />
<p className="text-sm font-medium">Owner-only view</p>
<p className="max-w-sm text-xs text-muted-foreground">
Only the workspace owner can read the audit log. Ask an owner to share
specific events if you need them for support.
</p>
</div>
) : auditQuery.isLoading ? (
<div className="space-y-2">
{Array.from({ length: 6 }).map((_, i) => (
<Skeleton key={i} className="h-12 w-full" />
))}
</div>
) : auditQuery.error ? (
<div className="rounded-lg border border-destructive/40 bg-destructive/5 px-4 py-3 text-sm text-destructive">
Couldn&apos;t load the audit log: {auditQuery.error.message}
</div>
) : !auditQuery.data || auditQuery.data.rows.length === 0 ? (
<div className="flex flex-col items-center gap-3 rounded-lg border bg-muted/40 px-6 py-12 text-center">
<History className="size-6 text-muted-foreground" />
<p className="text-sm font-medium">No events yet</p>
<p className="max-w-sm text-xs text-muted-foreground">
Actions like creating invites, changing roles, or archiving the workspace
will appear here.
</p>
</div>
) : (
<>
<div className="overflow-hidden rounded-lg border">
<table className="w-full text-sm">
<thead className="bg-muted/40 text-xs uppercase tracking-wide text-muted-foreground">
<tr>
<th className="px-4 py-2 text-left font-medium">When</th>
<th className="px-4 py-2 text-left font-medium">Action</th>
<th className="px-4 py-2 text-left font-medium">Actor</th>
<th className="px-4 py-2 text-left font-medium">Target</th>
<th className="px-4 py-2 text-left font-medium">Details</th>
</tr>
</thead>
<tbody className="divide-y">
{auditQuery.data.rows.map((row) => (
<tr key={row.id} className="hover:bg-muted/30">
<td className="whitespace-nowrap px-4 py-2 text-xs tabular-nums text-muted-foreground">
{formatTimestamp(row.createdAt)}
</td>
<td className="px-4 py-2 font-medium">{actionLabel(row.action)}</td>
<td className="px-4 py-2 text-xs">
{row.actorUserId ? (
<span>
{row.actorName ?? row.actorEmail ?? row.actorUserId.slice(0, 8)}
</span>
) : (
<span className="italic text-muted-foreground">
system
{row.metadata && typeof (row.metadata as { system_actor?: unknown }).system_actor === "string"
? `: ${(row.metadata as { system_actor: string }).system_actor}`
: ""}
</span>
)}
</td>
<td className="px-4 py-2 text-xs text-muted-foreground">
<code className="rounded bg-muted px-1 py-0.5">{row.targetType}</code>
{row.targetId ? (
<span className="ml-1 font-mono text-[10px]">{row.targetId.slice(0, 8)}</span>
) : null}
</td>
<td className="px-4 py-2">
{row.metadata && Object.keys(row.metadata).length > 0 ? (
<details className="text-xs">
<summary className="cursor-pointer text-muted-foreground hover:text-foreground">
{metadataSummary(row.metadata)}
</summary>
<pre className="mt-1 overflow-x-auto rounded bg-muted px-2 py-1 text-[10px]">
{JSON.stringify(row.metadata, null, 2)}
</pre>
</details>
) : (
<span className="text-xs text-muted-foreground"></span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
{auditQuery.data.nextCursor ? (
<div className="mt-4 flex justify-center">
<Button
variant="outline"
size="sm"
onClick={() => setCursor(auditQuery.data.nextCursor ?? undefined)}
disabled={auditQuery.isFetching}
>
{auditQuery.isFetching ? (
<Loader2 className="size-3 animate-spin" />
) : (
"Load older"
)}
</Button>
</div>
) : null}
</>
)}
</div>
);
}

View file

@ -14,6 +14,7 @@ import {
Pencil,
Plus,
Presentation,
ScrollText,
Search,
Settings,
User,
@ -130,6 +131,15 @@ export function TopHeader({ onOpenSearch, onQuickAction }: TopHeaderProps) {
<Zap className="size-4 text-muted-foreground" />
Automations
</DropdownMenuItem>
<DropdownMenuItem
className="gap-2.5 px-4"
onSelect={() =>
workspaceId && router.push(`/${workspaceId}/settings/audit`)
}
>
<ScrollText className="size-4 text-muted-foreground" />
Audit log
</DropdownMenuItem>
<DropdownMenuSeparator />
{/* Create Workspace */}
<div className="px-3 py-2">

View file

@ -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;
/** `<target>.<verb>` — 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<string, unknown> | null;
};
export async function recordAudit(
db: typeof defaultDb,
input: RecordAuditInput,
): Promise<void> {
// 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 "<target>.<verb>", 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,
});
}

View file

@ -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;

View file

@ -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;

View file

@ -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,

View file

@ -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;
}),
});

View file

@ -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");

File diff suppressed because it is too large Load diff

View file

@ -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
}
]
}

View file

@ -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 `<target>.<verb>` (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<Record<string, unknown> | 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),
}),
);

View file

@ -11,3 +11,4 @@ export * from "./forms";
export * from "./favorites";
export * from "./markdown_backlog";
export * from "./cursor_sync";
export * from "./audit";

View file

@ -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({

View file

@ -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],

View file

@ -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

View file

@ -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`

View file

@ -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`

View file

@ -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`

View file

@ -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`

View file

@ -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

View file

@ -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`

View file

@ -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