Skip to content

chore: remove legacy migration step#834

Open
adityachoudhari26 wants to merge 3 commits intomainfrom
remove-legacy-migration-step
Open

chore: remove legacy migration step#834
adityachoudhari26 wants to merge 3 commits intomainfrom
remove-legacy-migration-step

Conversation

@adityachoudhari26
Copy link
Member

@adityachoudhari26 adityachoudhari26 commented Mar 6, 2026

Summary by CodeRabbit

  • New Features
    • Added a "Work Queue" settings page with a navigation link, interactive charts, stats cards, filterable/paginated scopes table, payload viewer drawer, and a dialog to create/enqueue work items.
  • Chores
    • Removed legacy migration logic and trimmed an unused background worker to simplify initialization.
  • Tests
    • Deleted an obsolete restore-related test.

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Mar 6, 2026

📝 Walkthrough

Walkthrough

Adds a new work-queue feature (UI pages, dialogs, charts) backed by a new TRPC reconcile router and DB queries; removes legacy migration logic from workspace store Restore and a related test; minor main.go import/use removal and chart typing/formatting edits.

Changes

Cohort / File(s) Summary
Workspace store cleanup
apps/workspace-engine/pkg/workspace/store/store.go, apps/workspace-engine/pkg/workspace/store/restore_test.go
Removed legacy migration blocks from Store.Restore and deleted the deterministic Release ID backfill test. Review data restoration expectations and any callers relying on legacy backfill behavior.
Engine runner adjustment
apps/workspace-engine/main.go
Removed import and runner.Add usage for desiredrelease controller. Verify runner topology and worker responsibilities.
New TRPC reconcile API
packages/trpc/src/routes/reconcile.ts, packages/trpc/src/root.ts
Added reconcileRouter with procedures: create, listWorkScopes, listWorkPayloads, stats, chartData; wired reconcile into appRouter. Inspect DB upsert logic, paging, and aggregation queries for performance and correctness.
Work Queue page & routes
apps/web/app/routes.ts, apps/web/app/routes/ws/settings/work-queue.tsx, apps/web/app/routes/ws/settings/_layout.tsx
Added route registration and a full Work Queue page with filters, table, stats, pagination, WorkPayloadDrawer, and WorkQueueCharts integration. Check route guards, workspace scoping, and refetch intervals.
Work Queue UI components
apps/web/app/routes/ws/settings/_components/work-queue/CreateWorkItemDialog.tsx, .../WorkPayloadDrawer.tsx, .../WorkQueueCharts.tsx
New components: CreateWorkItemDialog (form + mutation), WorkPayloadDrawer (payload list, collapsible JSON), and WorkQueueCharts (multiple charts). Review JSON validation, TRPC calls, loading/empty states, and accessibility.
Chart typings/formatting
apps/web/app/components/ui/chart.tsx
Type signature formatting and minor className/order adjustments only. No runtime behavior changes expected, but check TypeScript exports and UI rendering unaffected.

Sequence Diagram(s)

sequenceDiagram
  participant User
  participant Browser as "Browser (UI)"
  participant TRPC as "trpc.reconcile (Server)"
  participant DB as "Database"

  User->>Browser: Open CreateWorkItemDialog / submit form
  Browser->>TRPC: reconcile.create(input: workspaceId, kind, scopeId, priority, notBefore, payload?)
  TRPC->>DB: upsert reconcileWorkScope (insert or update scope row)
  DB-->>TRPC: scope record (id, createdAt...)
  alt payload provided
    TRPC->>DB: upsert reconcileWorkPayload (link to scope id)
    DB-->>TRPC: payload record
  end
  TRPC-->>Browser: created scope response
  Browser->>Browser: invalidate caches, close dialog, reset form
  Browser-->>User: show success / updated list
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Suggested reviewers

  • jsbroks

Poem

Hopped in quick with code so bright,
New queues and charts to guide the night. 🐇
Payloads, scopes, and TRPC cheer,
Old migrations gone — fresh frontier! ✨

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 5.56% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The PR title 'chore: remove legacy migration step' directly aligns with the primary change: removing legacy migration logic from Store.Restore, eliminating 226 lines of migration code for legacy entities.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch remove-legacy-migration-step

Tip

Try Coding Plans. Let us write the prompt for your AI agent so you can ship faster (with fewer bugs).
Share your feedback on Discord.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 6

🧹 Nitpick comments (1)
packages/trpc/src/routes/reconcile.ts (1)

140-149: Inconsistent pagination API compared to listWorkScopes.

listWorkScopes returns { items, total } for proper pagination support, but listWorkPayloads returns only the items array without a total count. This inconsistency may cause issues for frontend pagination components expecting a uniform response shape.

Suggested fix for consistency
   .query(async ({ ctx, input }) => {
-    const items = await ctx.db
-      .select()
-      .from(schema.reconcileWorkPayload)
-      .where(eq(schema.reconcileWorkPayload.scopeRef, input.scopeId))
-      .orderBy(desc(schema.reconcileWorkPayload.createdAt))
-      .limit(input.limit)
-      .offset(input.offset);
+    const condition = eq(schema.reconcileWorkPayload.scopeRef, input.scopeId);
+
+    const [items, [total]] = await Promise.all([
+      ctx.db
+        .select()
+        .from(schema.reconcileWorkPayload)
+        .where(condition)
+        .orderBy(desc(schema.reconcileWorkPayload.createdAt))
+        .limit(input.limit)
+        .offset(input.offset),
+      ctx.db
+        .select({ count: count() })
+        .from(schema.reconcileWorkPayload)
+        .where(condition),
+    ]);

-    return items;
+    return { items, total: total?.count ?? 0 };
   }),
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/trpc/src/routes/reconcile.ts` around lines 140 - 149, The endpoint
currently returning only the array from the reconcile payload query (the .query
handler that selects from schema.reconcileWorkPayload) must be changed to match
listWorkScopes' pagination shape: run a separate count query using the same
where clause (eq(schema.reconcileWorkPayload.scopeRef, input.scopeId)) to
compute total, keep the existing ordered, limited and offset items query, and
return an object { items, total } instead of just items so frontend pagination
consumers receive a consistent response.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@apps/web/app/routes/ws/settings/work-queue.tsx`:
- Around line 211-255: The TableRow click is not keyboard-accessible; make the
row focusable and activate on Enter/Space by adding tabIndex={0} and
role="button" to the TableRow and implementing an onKeyDown handler that calls
setSelectedScopeId(scope.id) when the user presses Enter or Space (while
preserving the existing onClick). Update the TableRow JSX (the TableRow
component rendering each scope) to include these attributes and the onKeyDown
logic so keyboard users can open the drawer.
- Around line 105-120: The live-refreshing scopesQuery can leave offset pointing
past a shrinking total, causing empty pages; update the component to clamp
offset whenever scopesQuery.data?.total (total) changes: compute maxOffset =
Math.max(0, Math.floor((total - 1) / ITEMS_PER_PAGE) * ITEMS_PER_PAGE) and if
offset > maxOffset setOffset(maxOffset); ensure this logic runs after
scopesQuery resolves (e.g., in a useEffect that watches scopesQuery.data?.total)
and apply the same clamp for the other identical pagination block referenced
(lines ~261-279) so hasNext/hasPrev and displayed ranges remain valid.
- Around line 96-103: The stats cards are not refreshed on the same 10s loop as
the chart and list, so update the trpc.reconcile.stats.useQuery call
(statsQuery) to use the same polling options as chartQuery (e.g., pass {
refetchInterval: 10_000 } or the shared options object) so stats auto-refetches
every 10 seconds; also apply the same options to listWorkScopes if it exists to
keep all summary/data views in sync.

In `@apps/workspace-engine/main.go`:
- Line 68: The commented-out desiredrelease.New(WorkerID, db.GetPool(ctx)) call
unregisters the DesiredReleaseKind worker and leaves "desired-release" items
unprocessed; either re-enable the worker by restoring the
desiredrelease.New(WorkerID, db.GetPool(ctx)) registration so the
DesiredReleaseKind queue is consumed, or (if retiring support intentionally)
remove the "desired-release" producer/UI entry and any code that enqueues
DesiredReleaseKind work so no items are created; update whichever of
desiredrelease.New, DesiredReleaseKind, or the new work-queue
dialog/"desired-release" producers you change so producer and consumer remain
consistent.

In `@packages/trpc/src/routes/reconcile.ts`:
- Around line 132-150: listWorkPayloads currently accepts only scopeId and
returns payloads without verifying the scope belongs to a workspace the caller
can access; update the procedure (listWorkPayloads) to enforce workspace-scoped
authorization by either (A) requiring workspaceId in the input and adding
.where(eq(schema.reconcileWorkPayload.workspaceRef, input.workspaceId)) to the
query, or (B) first loading the scope row (select from the scope table that
relates to schema.reconcileWorkPayload, e.g. schema.scope) using the provided
scopeId and checking its workspaceId against the caller's permitted workspace(s)
from ctx (session/permissions), and only then running the payload query filtered
by that workspace; ensure the check rejects/throws when the caller lacks access
before returning items.
- Around line 17-84: All procedures in reconcileRouter (create, listWorkScopes,
listWorkPayloads, stats, chartData) need per-workspace authorization meta: add
.meta({ authorizationCheck: ({ canUser, input }) =>
canUser.perform(Permission.ReconcileXXX).on({ type: "workspace", id:
input.workspaceId }) }) to each procedure using the appropriate Permission
(e.g., ReconcileCreate for create, ReconcileList for
listWorkScopes/stats/chartData). For listWorkPayloads, because input only
contains scopeId, first resolve the scope's workspaceId (via
schema.reconcileWorkScope lookup in the handler or add a pre-check) and use that
workspaceId in the authorizationCheck so the check enforces access to the
scope's workspace. Ensure you reference the reconcileRouter procedures (create,
listWorkScopes, listWorkPayloads, stats, chartData) and the Permission enum when
implementing.

---

Nitpick comments:
In `@packages/trpc/src/routes/reconcile.ts`:
- Around line 140-149: The endpoint currently returning only the array from the
reconcile payload query (the .query handler that selects from
schema.reconcileWorkPayload) must be changed to match listWorkScopes' pagination
shape: run a separate count query using the same where clause
(eq(schema.reconcileWorkPayload.scopeRef, input.scopeId)) to compute total, keep
the existing ordered, limited and offset items query, and return an object {
items, total } instead of just items so frontend pagination consumers receive a
consistent response.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: ff44f1c4-95bf-4622-bcec-07c48127a89c

📥 Commits

Reviewing files that changed from the base of the PR and between f23ed60 and 0f4e6b0.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (11)
  • apps/web/app/components/ui/chart.tsx
  • apps/web/app/routes.ts
  • apps/web/app/routes/ws/settings/_components/work-queue/CreateWorkItemDialog.tsx
  • apps/web/app/routes/ws/settings/_components/work-queue/WorkPayloadDrawer.tsx
  • apps/web/app/routes/ws/settings/_components/work-queue/WorkQueueCharts.tsx
  • apps/web/app/routes/ws/settings/_layout.tsx
  • apps/web/app/routes/ws/settings/work-queue.tsx
  • apps/workspace-engine/main.go
  • apps/workspace-engine/pkg/workspace/store/restore_test.go
  • packages/trpc/src/root.ts
  • packages/trpc/src/routes/reconcile.ts
💤 Files with no reviewable changes (1)
  • apps/workspace-engine/pkg/workspace/store/restore_test.go
✅ Files skipped from review due to trivial changes (1)
  • apps/web/app/components/ui/chart.tsx

Comment on lines +96 to +103
const statsQuery = trpc.reconcile.stats.useQuery({
workspaceId: workspace.id,
});

const chartQuery = trpc.reconcile.chartData.useQuery(
{ workspaceId: workspace.id },
{ refetchInterval: 10_000 },
);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Keep the stats cards on the same refresh loop.

chartData and listWorkScopes refresh every 10 seconds, but stats only loads once. After claim/expiry/retry transitions, the summary cards drift out of sync with the rest of the page until a manual reload.

♻️ Suggested fix
-  const statsQuery = trpc.reconcile.stats.useQuery({
-    workspaceId: workspace.id,
-  });
+  const statsQuery = trpc.reconcile.stats.useQuery(
+    { workspaceId: workspace.id },
+    { refetchInterval: 10_000 },
+  );
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const statsQuery = trpc.reconcile.stats.useQuery({
workspaceId: workspace.id,
});
const chartQuery = trpc.reconcile.chartData.useQuery(
{ workspaceId: workspace.id },
{ refetchInterval: 10_000 },
);
const statsQuery = trpc.reconcile.stats.useQuery(
{ workspaceId: workspace.id },
{ refetchInterval: 10_000 },
);
const chartQuery = trpc.reconcile.chartData.useQuery(
{ workspaceId: workspace.id },
{ refetchInterval: 10_000 },
);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/app/routes/ws/settings/work-queue.tsx` around lines 96 - 103, The
stats cards are not refreshed on the same 10s loop as the chart and list, so
update the trpc.reconcile.stats.useQuery call (statsQuery) to use the same
polling options as chartQuery (e.g., pass { refetchInterval: 10_000 } or the
shared options object) so stats auto-refetches every 10 seconds; also apply the
same options to listWorkScopes if it exists to keep all summary/data views in
sync.

Comment on lines +105 to +120
const scopesQuery = trpc.reconcile.listWorkScopes.useQuery(
{
workspaceId: workspace.id,
limit: ITEMS_PER_PAGE,
offset,
kind: kindFilter === "all" ? undefined : kindFilter,
claimed: claimedFilter,
},
{ refetchInterval: 10_000 },
);

const kinds = statsQuery.data?.byKind ?? [];
const scopes = scopesQuery.data?.items ?? [];
const total = scopesQuery.data?.total ?? 0;
const hasNext = offset + ITEMS_PER_PAGE < total;
const hasPrev = offset > 0;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Clamp pagination against a shrinking live total.

listWorkScopes auto-refreshes every 10 seconds, but offset is never adjusted when total drops. On the last page, that can strand the user on an empty table and render invalid ranges like 51–50 of 50 until they page back manually.

♻️ Suggested fix
-import { useState } from "react";
+import { useEffect, useState } from "react";
@@
   const scopes = scopesQuery.data?.items ?? [];
   const total = scopesQuery.data?.total ?? 0;
   const hasNext = offset + ITEMS_PER_PAGE < total;
   const hasPrev = offset > 0;
+
+  useEffect(() => {
+    if (total === 0) {
+      setOffset(0);
+      return;
+    }
+
+    if (offset >= total) {
+      setOffset(Math.floor((total - 1) / ITEMS_PER_PAGE) * ITEMS_PER_PAGE);
+    }
+  }, [offset, total]);
@@
-          Showing {offset + 1}–{Math.min(offset + ITEMS_PER_PAGE, total)} of{" "}
-          {total}
+          {total === 0
+            ? "Showing 0 of 0"
+            : `Showing ${offset + 1}–${Math.min(offset + ITEMS_PER_PAGE, total)} of ${total}`}

Also applies to: 261-279

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/app/routes/ws/settings/work-queue.tsx` around lines 105 - 120, The
live-refreshing scopesQuery can leave offset pointing past a shrinking total,
causing empty pages; update the component to clamp offset whenever
scopesQuery.data?.total (total) changes: compute maxOffset = Math.max(0,
Math.floor((total - 1) / ITEMS_PER_PAGE) * ITEMS_PER_PAGE) and if offset >
maxOffset setOffset(maxOffset); ensure this logic runs after scopesQuery
resolves (e.g., in a useEffect that watches scopesQuery.data?.total) and apply
the same clamp for the other identical pagination block referenced (lines
~261-279) so hasNext/hasPrev and displayed ranges remain valid.

Comment on lines +211 to +255
{scopes.map((scope) => (
<TableRow
key={scope.id}
className="cursor-pointer"
onClick={() => setSelectedScopeId(scope.id)}
>
<TableCell>
<Badge
variant="outline"
className="max-w-[180px] truncate"
title={scope.kind}
>
{scope.kind}
</Badge>
</TableCell>
<TableCell className="font-mono text-xs">
{scope.scopeType || "-"}
</TableCell>
<TableCell
className="max-w-[200px] truncate font-mono text-xs"
title={scope.scopeId}
>
{scope.scopeId || "-"}
</TableCell>
<TableCell>{scope.priority}</TableCell>
<TableCell>
<ClaimStatusBadge
claimedBy={scope.claimedBy}
claimedUntil={scope.claimedUntil}
/>
</TableCell>
<TableCell className="max-w-[150px] truncate text-xs">
{scope.claimedBy ?? "-"}
</TableCell>
<TableCell className="text-xs text-muted-foreground">
{formatDistanceToNowStrict(new Date(scope.notBefore), {
addSuffix: true,
})}
</TableCell>
<TableCell className="text-xs text-muted-foreground">
{formatDistanceToNowStrict(new Date(scope.eventTs), {
addSuffix: true,
})}
</TableCell>
</TableRow>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Make payload inspection keyboard-accessible.

The drawer is only reachable through an onClick on TableRow, which leaves the action untabbable and unusable from the keyboard. That blocks keyboard users from inspecting payloads.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/app/routes/ws/settings/work-queue.tsx` around lines 211 - 255, The
TableRow click is not keyboard-accessible; make the row focusable and activate
on Enter/Space by adding tabIndex={0} and role="button" to the TableRow and
implementing an onKeyDown handler that calls setSelectedScopeId(scope.id) when
the user presses Enter or Space (while preserving the existing onClick). Update
the TableRow JSX (the TableRow component rendering each scope) to include these
attributes and the onKeyDown logic so keyboard users can open the drawer.

jobverificationmetric.New(WorkerID, db.GetPool(ctx)),
relationshipeval.New(WorkerID, db.GetPool(ctx)),
desiredrelease.New(WorkerID, db.GetPool(ctx)),
// desiredrelease.New(WorkerID, db.GetPool(ctx)),
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Commenting out desiredrelease.New strands desired-release items.

From the provided context, this is the worker that processes DesiredReleaseKind, and the new work-queue dialog still exposes "desired-release" creation. With this registration removed, those scopes will sit pending unless another consumer exists. If retirement is intentional, remove the kind from the UI/producers in the same PR.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/workspace-engine/main.go` at line 68, The commented-out
desiredrelease.New(WorkerID, db.GetPool(ctx)) call unregisters the
DesiredReleaseKind worker and leaves "desired-release" items unprocessed; either
re-enable the worker by restoring the desiredrelease.New(WorkerID,
db.GetPool(ctx)) registration so the DesiredReleaseKind queue is consumed, or
(if retiring support intentionally) remove the "desired-release" producer/UI
entry and any code that enqueues DesiredReleaseKind work so no items are
created; update whichever of desiredrelease.New, DesiredReleaseKind, or the new
work-queue dialog/"desired-release" producers you change so producer and
consumer remain consistent.

Comment on lines +17 to +84
export const reconcileRouter = router({
create: protectedProcedure
.input(
z.object({
workspaceId: z.string().uuid(),
kind: z.string().min(1),
scopeType: z.string().default(""),
scopeId: z.string().default(""),
priority: z.number().int().min(0).max(32767).default(100),
notBefore: z.coerce.date().optional(),
payload: z
.object({
payloadType: z.string().default(""),
payloadKey: z.string().default(""),
payload: z.record(z.string(), z.any()).default({}),
})
.optional(),
}),
)
.mutation(async ({ ctx, input }) => {
const [scope] = await ctx.db
.insert(schema.reconcileWorkScope)
.values({
workspaceId: input.workspaceId,
kind: input.kind,
scopeType: input.scopeType,
scopeId: input.scopeId,
priority: input.priority,
notBefore: input.notBefore ?? new Date(),
})
.onConflictDoUpdate({
target: [
schema.reconcileWorkScope.workspaceId,
schema.reconcileWorkScope.kind,
schema.reconcileWorkScope.scopeType,
schema.reconcileWorkScope.scopeId,
],
set: {
eventTs: new Date(),
priority: input.priority,
notBefore: input.notBefore ?? new Date(),
},
})
.returning();

if (input.payload && scope) {
await ctx.db
.insert(schema.reconcileWorkPayload)
.values({
scopeRef: scope.id,
payloadType: input.payload.payloadType,
payloadKey: input.payload.payloadKey,
payload: input.payload.payload,
})
.onConflictDoUpdate({
target: [
schema.reconcileWorkPayload.scopeRef,
schema.reconcileWorkPayload.payloadType,
schema.reconcileWorkPayload.payloadKey,
],
set: {
payload: input.payload.payload,
},
});
}

return scope;
}),
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# First, check how other TRPC routers handle workspace authorization
echo "=== Authorization patterns in other routers ==="
rg -n "authorizationCheck" packages/trpc/src/routes/ -A 3 -B 1

Repository: ctrlplanedev/ctrlplane

Length of output: 6683


🏁 Script executed:

#!/bin/bash
# Check the full reconcile.ts file to see all procedures
echo "=== Full reconcile.ts file ==="
wc -l packages/trpc/src/routes/reconcile.ts

Repository: ctrlplanedev/ctrlplane

Length of output: 138


🏁 Script executed:

#!/bin/bash
# Read the reconcile.ts file to see all procedures
cat -n packages/trpc/src/routes/reconcile.ts

Repository: ctrlplanedev/ctrlplane

Length of output: 11543


🏁 Script executed:

#!/bin/bash
# Check how protectedProcedure is defined in trpc.ts
echo "=== protectedProcedure definition ==="
rg -n "protectedProcedure" packages/trpc/src/trpc.ts -B 2 -A 2

Repository: ctrlplanedev/ctrlplane

Length of output: 166


Add authorization checks to all procedures.

All five procedures (create, listWorkScopes, listWorkPayloads, stats, chartData) use protectedProcedure but lack authorizationCheck meta. This allows any authenticated user to access or modify reconcile data for any workspace.

Follow the pattern used throughout the codebase (e.g., workspace.ts, systems.ts, deployments.ts):

.meta({
  authorizationCheck: ({ canUser, input }) =>
    canUser
      .perform(Permission.ReconcileList) // or appropriate permission
      .on({ type: "workspace", id: input.workspaceId }),
})

listWorkPayloads is particularly vulnerable—it only accepts scopeId with no workspace validation.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/trpc/src/routes/reconcile.ts` around lines 17 - 84, All procedures
in reconcileRouter (create, listWorkScopes, listWorkPayloads, stats, chartData)
need per-workspace authorization meta: add .meta({ authorizationCheck: ({
canUser, input }) => canUser.perform(Permission.ReconcileXXX).on({ type:
"workspace", id: input.workspaceId }) }) to each procedure using the appropriate
Permission (e.g., ReconcileCreate for create, ReconcileList for
listWorkScopes/stats/chartData). For listWorkPayloads, because input only
contains scopeId, first resolve the scope's workspaceId (via
schema.reconcileWorkScope lookup in the handler or add a pre-check) and use that
workspaceId in the authorizationCheck so the check enforces access to the
scope's workspace. Ensure you reference the reconcileRouter procedures (create,
listWorkScopes, listWorkPayloads, stats, chartData) and the Permission enum when
implementing.

Comment on lines +132 to +150
listWorkPayloads: protectedProcedure
.input(
z.object({
scopeId: z.number(),
limit: z.number().min(1).max(500).default(100),
offset: z.number().min(0).default(0),
}),
)
.query(async ({ ctx, input }) => {
const items = await ctx.db
.select()
.from(schema.reconcileWorkPayload)
.where(eq(schema.reconcileWorkPayload.scopeRef, input.scopeId))
.orderBy(desc(schema.reconcileWorkPayload.createdAt))
.limit(input.limit)
.offset(input.offset);

return items;
}),
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

listWorkPayloads lacks workspace-scoped authorization.

This procedure accepts only a scopeId (the numeric primary key) without verifying that the scope belongs to a workspace the caller has access to. An authenticated user could enumerate scope IDs to access payloads from any workspace.

Either:

  1. Add a join/subquery to verify workspace ownership, or
  2. Require workspaceId in input and filter accordingly, or
  3. Verify the scope's workspace against user permissions before querying.
Suggested approach: verify workspace access
 listWorkPayloads: protectedProcedure
   .input(
     z.object({
       scopeId: z.number(),
+      workspaceId: z.string().uuid(),
       limit: z.number().min(1).max(500).default(100),
       offset: z.number().min(0).default(0),
     }),
   )
+  .meta({
+    authorizationCheck: async ({ canUser, input }) =>
+      canUser.access("workspace", input.workspaceId),
+  })
   .query(async ({ ctx, input }) => {
     const items = await ctx.db
       .select()
       .from(schema.reconcileWorkPayload)
-      .where(eq(schema.reconcileWorkPayload.scopeRef, input.scopeId))
+      .innerJoin(
+        schema.reconcileWorkScope,
+        eq(schema.reconcileWorkPayload.scopeRef, schema.reconcileWorkScope.id),
+      )
+      .where(
+        and(
+          eq(schema.reconcileWorkPayload.scopeRef, input.scopeId),
+          eq(schema.reconcileWorkScope.workspaceId, input.workspaceId),
+        ),
+      )
       .orderBy(desc(schema.reconcileWorkPayload.createdAt))
       .limit(input.limit)
       .offset(input.offset);

     return items;
   }),
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/trpc/src/routes/reconcile.ts` around lines 132 - 150,
listWorkPayloads currently accepts only scopeId and returns payloads without
verifying the scope belongs to a workspace the caller can access; update the
procedure (listWorkPayloads) to enforce workspace-scoped authorization by either
(A) requiring workspaceId in the input and adding
.where(eq(schema.reconcileWorkPayload.workspaceRef, input.workspaceId)) to the
query, or (B) first loading the scope row (select from the scope table that
relates to schema.reconcileWorkPayload, e.g. schema.scope) using the provided
scopeId and checking its workspaceId against the caller's permitted workspace(s)
from ctx (session/permissions), and only then running the payload query filtered
by that workspace; ensure the check rejects/throws when the caller lacks access
before returning items.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants