Skip to content

Feat/dashboard backfill - #67

Merged
SteakFisher merged 14 commits into
mainfrom
feat/dashboard-backfill
May 31, 2026
Merged

Feat/dashboard backfill#67
SteakFisher merged 14 commits into
mainfrom
feat/dashboard-backfill

Conversation

@thedevyashsaini

@thedevyashsaini thedevyashsaini commented May 30, 2026

Copy link
Copy Markdown
Member

Summary by CodeRabbit

  • New Features
    • Added HTTP endpoints for API key management (create, list, revoke), tags and expressions (create/list/delete), webhook deliveries listing, and expanded webhook endpoint operations.
  • Behavior Changes
    • Soft-deletion introduced for tags and expressions so deleted items are excluded from listings.
    • Stronger input validation, clearer error responses, and stricter webhook permission/URL rules (dashboard vs production/test).
  • Bug Fixes / Improvements
    • Listings now exclude deleted records; test-webhook sending requires dashboard auth.

Review Change Stack

@coderabbitai

coderabbitai Bot commented May 30, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@thedevyashsaini, we couldn't start this review because you've reached your PR review rate limit.

More reviews will be available in 14 minutes and 29 seconds. Learn how PR review limits work.

Your organization has run out of usage credits. Purchase more in the billing tab.

⌛ How to resolve this issue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans include higher PR review limits than trial, open-source, and free plans. In all cases, reviews become available again over time. During sustained high-volume PR review activity, CodeRabbit may temporarily slow when the next review becomes available.

Please see our Fair Usage Limits Policy for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 5221a472-766a-4152-8a64-40a26a0db539

📥 Commits

Reviewing files that changed from the base of the PR and between 3a32fbc and a1088a3.

📒 Files selected for processing (4)
  • src/routes/http/api/expressions.ts
  • src/routes/http/api/tags.ts
  • src/routes/http/api/webhookDeliveries.ts
  • src/utils/parseExpr.ts
📝 Walkthrough

Walkthrough

Adds soft-delete columns to tags and expressions, implements corresponding storage upsert/delete/list operations, introduces HTTP handlers for tags, expressions, API keys, webhook endpoints/deliveries with Zod validation and auth, updates onboarding auth, and registers all new routes.

Changes

API Expansion with Soft-Delete Support

Layer / File(s) Summary
Database Schema and Soft-Delete Foundation
src/storage/db/postgres/schema.ts, src/utils/fetchTagAmount.ts
tagsTable and expressionsTable gain nullable deletedAt columns; expressionsTable.key uniqueness removed. Query utilities updated to exclude soft-deleted rows via isNull(deletedAt).
Storage Layer: Tags CRUD with Soft-Delete
src/storage/db/postgres/helpers/tags.ts
listTags excludes deleted rows. New createTag implements upsert-on-non-deleted and invalidates cache. New deleteTag soft-deletes a tag and returns boolean status.
Storage Layer: Expressions CRUD with Soft-Delete
src/storage/db/postgres/helpers/expressions.ts
listExpressions and findExpressionByKey exclude deleted rows. New createExpression does update-or-insert; deleteExpression soft-deletes and returns boolean.
Tags API Handlers
src/routes/http/api/tags.ts
Adds handleCreateTag and handleDeleteTag with Zod validation and auth; refactors handleListTags error handling.
Expressions API Handlers
src/routes/http/api/expressions.ts
Adds handleCreateExpression and handleDeleteExpression with Zod validation and auth; refactors handleListExpressions error handling.
API Keys Management Handlers
src/routes/http/api/apiKeys.ts, src/storage/db/postgres/helpers/apiKeys.ts
Adds handleCreateApiKey, handleListApiKeys, handleRevokeApiKey; adds getApiKeyRoleById helper. Create provisions webhook endpoint and returns plaintext key; list excludes dashboard/revoked keys and left-joins endpoints.
Webhook Endpoints and Test Send
src/routes/http/api/webhookEndpoints.ts
Adds validation schemas, includes apiKeyId in endpoint responses, resolves target apiKeyId for creation with permission/HTTPS checks, and requires dashboard auth + target test key validation for send-test.
Webhook Deliveries, Onboarding Auth, and Route Registration
src/routes/http/api/webhookDeliveries.ts, src/routes/http/api/onboarding.ts, src/routes/http/api/registerApiRoutes.ts
Adds handleListDeliveries with query validation and pagination; updates handleOnboarding to authenticate via Authorization header and map AuthError to 401; expands registerApiRoutes to wire new endpoints (tags, expressions, api-keys, webhook endpoints, deliveries, config).
Proto submodule update
proto
Submodule reference updated to a new commit SHA.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Suggested reviewers

  • SteakFisher

Poem

🐰 I nibble code beneath moon's light,
Soft-deletes tucked in burrows tight,
Routes and webhooks hop in line,
Auth keeps watch — the logs all shine,
Keys and tags now rest safe at night.

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (1 warning, 1 inconclusive)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Title check ❓ Inconclusive The title 'Feat/dashboard backfill' is vague and generic, using non-descriptive terms that don't clearly convey what the changeset implements despite comprehensive additions of API endpoints, database helpers, and authentication features. Consider using a more descriptive title like 'Add API key and webhook management endpoints with soft-delete support' to better communicate the primary changes.
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

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

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/dashboard-backfill

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.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 8

🧹 Nitpick comments (2)
src/routes/http/api/expressions.ts (2)

92-110: 💤 Low value

Avoid reporting expected client errors (AuthError, ZodError) to Sentry.

Sentry.captureException runs unconditionally before the AuthError/ZodError branches, so every 401 and 400 client-side validation/auth failure is sent to Sentry as an exception, creating noise that masks genuine 5xx faults. Consider capturing only after ruling out the expected error types.

♻️ Capture only unexpected errors
   } catch (error) {
-    Sentry.captureException(error, {
-      extra: { context: "create expression route handler" },
-    });
-
     if (error instanceof AuthError) {
       builder.setError(401, { type: error.type, message: error.message });
       reply.code(401);
       return { error: error.message };
     }

     if (error instanceof ZodError) {
       const issues = error.issues
         .map((issue) => `${issue.path.join(".")}: ${issue.message}`)
         .join("; ");
       builder.setError(400, { type: "ValidationError", message: issues });
       reply.code(400);
       return { error: issues };
     }

     const err = error instanceof Error ? error : new Error(String(error));
+    Sentry.captureException(err, {
+      extra: { context: "create expression route handler" },
+    });
     builder.setError(500, { type: "InternalError", message: err.message });
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/routes/http/api/expressions.ts` around lines 92 - 110, The
Sentry.captureException call currently runs for all errors including expected
AuthError and ZodError; update the catch block in the create expression route
handler so you first check if (error instanceof AuthError) and if (error
instanceof ZodError) and handle those (builder.setError / reply.code / return)
before calling Sentry.captureException, and only call
Sentry.captureException(error, { extra: { context: "create expression route
handler" } }) in the final else / default branch for unexpected errors.

135-136: ⚡ Quick win

Validate params.key with Zod instead of casting.

request.params is cast to { key: string } without validation, so a missing/empty key flows straight into deleteExpression. Validating with a small Zod schema (consistent with handleCreateExpression) removes the cast and rejects bad input with a 400.

As per coding guidelines: "Use Zod schemas for all request validation" and "avoid any and do NOT cast which can be inferred".

♻️ Validate route params
+const deleteExpressionParamsSchema = z.object({
+  key: z.string().min(1).max(128),
+});
-    const params = request.params as { key: string };
-    const deleted = await deleteExpression(params.key);
+    const params = deleteExpressionParamsSchema.parse(request.params);
+    const deleted = await deleteExpression(params.key);

This requires adding a ZodError → 400 branch in this handler's catch block, mirroring handleCreateExpression.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/routes/http/api/expressions.ts` around lines 135 - 136, Replace the
unsafe cast of request.params with a Zod-validated params schema (e.g., create a
z.object({ key: z.string().min(1) }) similar to handleCreateExpression) and
parse request.params before calling deleteExpression so that a missing/empty key
is rejected; remove the cast to { key: string } and pass the validated key to
deleteExpression, and update the handler's catch block to detect ZodError and
return a 400 response (mirroring the ZodError → 400 branch in
handleCreateExpression).
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/routes/http/api/apiKeys.ts`:
- Around line 60-77: Wrap the API key creation and webhook provisioning in a
single Drizzle transaction so both writes succeed or fail together: inside
handleCreateApiKey replace the separate await createApiKey(...) and await
upsertWebhookEndpoint(...) calls with a single transaction that creates the key
(createApiKey logic) and, if validated.webhookUrl, generates the keypair
(generateWebhookKeyPair) and upserts the webhook endpoint
(upsertWebhookEndpoint), and only call
invalidateWebhookEndpointCache(keyRecord.id) after the transaction commits;
additionally, in handleRevokeApiKey stop casting request.params and validate the
id with a Zod schema (return 400 on validation failure) before performing any DB
revoke logic.
- Around line 199-208: Replace the unsafe cast of request.params with a
Zod-validated params object (e.g., const paramsSchema = z.object({ id:
z.string().uuid() }) and parse request.params) before calling getPostgresDB()
and executing db.update(apiKeysTable). Catch ZodError and return a 400 response
(matching the other handlers in this file) so malformed UUIDs never reach the
update(...) call that sets revoked/revokedAt using DateTime.utc().toISO(); do
not call db.update when validation fails.

In `@src/routes/http/api/tags.ts`:
- Around line 138-139: Replace the unchecked cast of request.params with runtime
validation: create a Zod schema (e.g., tagParamsSchema = z.object({ key:
z.string() })) and parse request.params using tagParamsSchema.parse or safeParse
in the same style as handleCreateTag; on ZodError return a 400 response (or
convert to the same domain error response flow) and only call
deleteTag(params.key) with the validated key. Ensure you reference
request.params, deleteTag, and the new tagParamsSchema (or use Fastify route
generics) so the route validates input before DB operations.

In `@src/routes/http/api/webhookDeliveries.ts`:
- Around line 37-46: The current filter compares query.apiKeyId to
webhookDeliveriesTable.endpointId; change the logic to first find endpoint ids
from webhookEndpointsTable where apiKeyId equals the incoming query.apiKeyId
(e.g., SELECT id FROM webhookEndpointsTable WHERE apiKeyId = query.apiKeyId) and
then filter webhookDeliveriesTable by endpointId IN those ids (use
webhookDeliveriesTable.endpointId IN (...) or a join between
webhookDeliveriesTable and webhookEndpointsTable filtering on
webhookEndpointsTable.apiKeyId). Also update the request parsing path: wrap
listDeliveriesQuerySchema.parse(request.query) so that if it throws a ZodError
you catch it (distinct from AuthError) and map it to a 400 validation
error/response instead of falling through to the generic 500; reference
listDeliveriesQuerySchema.parse and ZodError in the handler catch logic to
ensure Zod validation failures are handled explicitly.
- Around line 34-67: The handler's call to
listDeliveriesQuerySchema.parse(request.query) can throw ZodError but only
AuthError is handled; update the error handling in handleListDeliveries to
detect ZodError (import from zod if needed), build a concatenated issues message
from error.issues (matching other routes), then call builder.setError(400, {
type: "ValidationError", message: concatenatedMessage }), set reply.code(400)
and return { error: concatenatedMessage }; keep the existing AuthError and
generic 500 branches unchanged.

In `@src/storage/db/postgres/helpers/expressions.ts`:
- Around line 51-68: createExpression currently does a non-atomic
select-then-update/insert against expressionsTable (checking isNull(deletedAt))
which can lead to duplicate active rows under concurrency; add a partial unique
index on expressionsTable.key where deleted_at IS NULL in your migrations and
replace the select-then-insert flow in createExpression with an atomic upsert
(INSERT ... ON CONFLICT(key) DO UPDATE SET expr=EXCLUDED.expr) or wrap the logic
in a transaction with appropriate row-level lock handling and explicitly catch
unique-constraint violations to handle races; reference expressionsTable,
createExpression, deletedAt and handle the conflict error path rather than
relying on .limit(1).

In `@src/storage/db/postgres/helpers/tags.ts`:
- Around line 29-45: Wrap the select/insert/update in a single Drizzle
transaction using db.transaction so the upsert for tagsTable is atomic: inside
the transaction, do a SELECT ... WHERE key = key FOR UPDATE (using tagsTable and
db) and if you find an active row update its amount and clear tagCache; if you
find a soft-deleted row revive it by updating amount and nulling deletedAt;
otherwise INSERT the new row; additionally catch unique-constraint errors on the
INSERT and, in that error handler, perform an UPDATE by id for the conflicting
key to avoid duplicate active rows; always delete tagCache for the key after the
write.

In `@src/storage/db/postgres/schema.ts`:
- Around line 265-273: The expressions table lost the uniqueness guarantee on
active keys—restore a partial unique constraint on the key column for
non-deleted rows: add a unique index on expressions.key where deleted_at IS NULL
(reference: expressionsTable, the key column and deletedAt/timestamp
"deleted_at") and add the same partial unique index in the corresponding
migration; ensure any upsert/insert code paths handle unique-constraint errors
explicitly (catch unique violation and handle according to existing upsert
logic) and perform the schema change inside a migration transaction per project
guidelines.

---

Nitpick comments:
In `@src/routes/http/api/expressions.ts`:
- Around line 92-110: The Sentry.captureException call currently runs for all
errors including expected AuthError and ZodError; update the catch block in the
create expression route handler so you first check if (error instanceof
AuthError) and if (error instanceof ZodError) and handle those (builder.setError
/ reply.code / return) before calling Sentry.captureException, and only call
Sentry.captureException(error, { extra: { context: "create expression route
handler" } }) in the final else / default branch for unexpected errors.
- Around line 135-136: Replace the unsafe cast of request.params with a
Zod-validated params schema (e.g., create a z.object({ key: z.string().min(1) })
similar to handleCreateExpression) and parse request.params before calling
deleteExpression so that a missing/empty key is rejected; remove the cast to {
key: string } and pass the validated key to deleteExpression, and update the
handler's catch block to detect ZodError and return a 400 response (mirroring
the ZodError → 400 branch in handleCreateExpression).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 67c23753-c02a-4f5c-955c-1e2e560112fa

📥 Commits

Reviewing files that changed from the base of the PR and between dee4622 and 45b8291.

📒 Files selected for processing (11)
  • proto
  • src/routes/http/api/apiKeys.ts
  • src/routes/http/api/expressions.ts
  • src/routes/http/api/onboarding.ts
  • src/routes/http/api/registerApiRoutes.ts
  • src/routes/http/api/tags.ts
  • src/routes/http/api/webhookDeliveries.ts
  • src/storage/db/postgres/helpers/expressions.ts
  • src/storage/db/postgres/helpers/tags.ts
  • src/storage/db/postgres/schema.ts
  • src/utils/fetchTagAmount.ts

Comment thread src/routes/http/api/apiKeys.ts Outdated
Comment on lines +199 to +208
const params = request.params as { id: string };
const db = getPostgresDB();
const now = DateTime.utc().toISO();

const result = await db
.update(apiKeysTable)
.set({ revoked: true, revokedAt: now })
.where(
and(eq(apiKeysTable.id, params.id), eq(apiKeysTable.revoked, false))
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Locate the file and print the relevant section around the cited lines
FILE="src/routes/http/api/apiKeys.ts"
if [ ! -f "$FILE" ]; then
  echo "File not found: $FILE"
  exit 1
fi

echo "== File: $FILE =="
# Show a bit more context around lines 180-240
nl -ba "$FILE" | sed -n '160,240p'

Repository: ScrawnDotDev/Scrawn

Length of output: 168


🏁 Script executed:

#!/bin/bash
set -euo pipefail

FILE="src/routes/http/api/apiKeys.ts"
nl -ba "$FILE" | sed -n '160,240p'

Repository: ScrawnDotDev/Scrawn

Length of output: 106


🏁 Script executed:

#!/bin/bash
set -euo pipefail

FILE="src/routes/http/api/apiKeys.ts"
wc -l "$FILE"
nl -ba "$FILE" | sed -n '180,230p'

Repository: ScrawnDotDev/Scrawn

Length of output: 159


🏁 Script executed:

#!/bin/bash
set -euo pipefail

FILE="src/routes/http/api/apiKeys.ts"
nl -ba "$FILE" | sed -n '1,260p' | tail -n +170 | head -n 120

Repository: ScrawnDotDev/Scrawn

Length of output: 106


🏁 Script executed:

#!/bin/bash
set -euo pipefail

FILE="src/routes/http/api/apiKeys.ts"
nl -ba "$FILE" | sed -n '190,215p'

Repository: ScrawnDotDev/Scrawn

Length of output: 106


🏁 Script executed:

#!/bin/bash
set -euo pipefail
FILE="src/routes/http/api/apiKeys.ts"
nl -ba "$FILE" | sed -n '190,215p'

Repository: ScrawnDotDev/Scrawn

Length of output: 106


🏁 Script executed:

#!/bin/bash
set -euo pipefail

FILE="src/routes/http/api/apiKeys.ts"
echo "== $FILE =="
nl -ba "$FILE" | sed -n '170,240p'

echo
echo "== Search for revoke handler and Zod schemas in this file =="
rg -n "handleRevokeApiKey|revokeApiKey|revokeApiKeyParamsSchema|z\.object|request\.params as" "$FILE" || true

Repository: ScrawnDotDev/Scrawn

Length of output: 161


🏁 Script executed:

#!/bin/bash
set -euo pipefail

FILE="src/routes/http/api/apiKeys.ts"
echo "== File: $FILE =="
awk 'NR>=160 && NR<=240 {printf "%d\t%s\n", NR, $0}' "$FILE"

echo
echo "== Search patterns in $FILE =="
rg -n "handleRevokeApiKey|revokeApiKey|revokeApiKeyParamsSchema|z\.object|request\.params as" "$FILE" || true

Repository: ScrawnDotDev/Scrawn

Length of output: 2791


🏁 Script executed:

#!/bin/bash
set -euo pipefail
FILE="src/routes/http/api/apiKeys.ts"

echo "== Any Zod schemas in this file =="
rg -n "z\.object|safeParse|ZodError|parse\(" "$FILE" || true

echo
echo "== Look for revoke-related schemas/usages =="
rg -n "revoke|revokedAt|params" "$FILE" || true

Repository: ScrawnDotDev/Scrawn

Length of output: 865


🏁 Script executed:

#!/bin/bash
set -euo pipefail
FILE="src/routes/http/api/apiKeys.ts"

echo "== Any Zod schemas in this file =="
rg -n "z\.object|safeParse|ZodError|parse\(" "$FILE" || true

echo
echo "== Look for revoke-related schemas/usages =="
rg -n "revoke|revokedAt|params" "$FILE" || true

Repository: ScrawnDotDev/Scrawn

Length of output: 865


🏁 Script executed:

#!/bin/bash
set -euo pipefail
FILE="src/routes/http/api/apiKeys.ts"

awk 'NR>=1 && NR<=130 {printf "%d\t%s\n", NR, $0}' "$FILE"

Repository: ScrawnDotDev/Scrawn

Length of output: 4644


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Find the definition of apiKeysTable (and the type of its id column)
rg -n "export const apiKeysTable|apiKeysTable\s*=" -S src/storage/db/postgres/schema* src/storage/db/postgres/schema || true
rg -n "apiKeysTable\.id" -S src/storage/db/postgres/schema src/storage/db/postgres -g'*.ts' || true

Repository: ScrawnDotDev/Scrawn

Length of output: 1221


🏁 Script executed:

#!/bin/bash
set -euo pipefail
FILE="src/storage/db/postgres/schema.ts"
awk 'NR>=60 && NR<=120 {printf "%d\t%s\n", NR, $0}' "$FILE"

echo
echo "== Check apiKeysTable.id column type =="
rg -n "apiKeysTable\s*=\s*pgTable|id:\s*[^,]+|uuid|text\(" "$FILE"

Repository: ScrawnDotDev/Scrawn

Length of output: 4811


🏁 Script executed:

#!/bin/bash
set -euo pipefail
rg -n "handleRevokeApiKey" src/routes src || true

Repository: ScrawnDotDev/Scrawn

Length of output: 537


🏁 Script executed:

#!/bin/bash
set -euo pipefail
FILE="src/routes/http/api/registerApiRoutes.ts"
awk 'NR>=60 && NR<=140 {printf "%d\t%s\n", NR, $0}' "$FILE"

Repository: ScrawnDotDev/Scrawn

Length of output: 2357


🏁 Script executed:

#!/bin/bash
set -euo pipefail
FILE="src/routes/http/api/registerApiRoutes.ts"

rg -n "schema|params|z\.object|Zod|uuid" "$FILE" || true

Repository: ScrawnDotDev/Scrawn

Length of output: 45


🏁 Script executed:

#!/bin/bash
set -euo pipefail
rg -n "instanceof ZodError|ZodError" src | head -n 50

Repository: ScrawnDotDev/Scrawn

Length of output: 2429


🏁 Script executed:

#!/bin/bash
set -euo pipefail
rg -n "request\.params as|parse\(request\.params\)" src/routes/http/api || true

Repository: ScrawnDotDev/Scrawn

Length of output: 330


🏁 Script executed:

#!/bin/bash
set -euo pipefail

for FILE in src/routes/http/api/tags.ts src/routes/http/api/expressions.ts; do
  echo "==== $FILE (params casting area) ===="
  awk 'NR>=110 && NR<=190 {printf "%d\t%s\n", NR, $0}' "$FILE"
  echo
  echo "== ZodError handling in $FILE =="
  rg -n "if\s*\(\s*error\s+instanceof\s+ZodError|ZodError" "$FILE"
  echo
done

Repository: ScrawnDotDev/Scrawn

Length of output: 4417


Validate api-keys/:id with Zod (no request.params cast) and return 400 for invalid UUIDs.

src/storage/db/postgres/schema.ts defines apiKeysTable.id as a UUID (uuid("id")), so const params = request.params as { id: string } can let malformed ids hit the update query and end up as a 500. Parse params with Zod and map ZodError to a 400 like the other handlers in this file.

🛠️ Suggested change
+const revokeApiKeyParamsSchema = z.object({
+  id: z.string().uuid("Invalid API key ID"),
+});
+
 export async function handleRevokeApiKey(
   request: FastifyRequest,
   reply: FastifyReply
 ): Promise<Record<string, unknown> | { error: string }> {
@@
-    const params = request.params as { id: string };
+    const params = revokeApiKeyParamsSchema.parse(request.params);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/routes/http/api/apiKeys.ts` around lines 199 - 208, Replace the unsafe
cast of request.params with a Zod-validated params object (e.g., const
paramsSchema = z.object({ id: z.string().uuid() }) and parse request.params)
before calling getPostgresDB() and executing db.update(apiKeysTable). Catch
ZodError and return a 400 response (matching the other handlers in this file) so
malformed UUIDs never reach the update(...) call that sets revoked/revokedAt
using DateTime.utc().toISO(); do not call db.update when validation fails.

Comment thread src/routes/http/api/tags.ts Outdated
Comment thread src/routes/http/api/webhookDeliveries.ts
Comment thread src/routes/http/api/webhookDeliveries.ts
Comment on lines +51 to +68
try {
const existing = await db
.select({ id: expressionsTable.id })
.from(expressionsTable)
.where(
and(eq(expressionsTable.key, key), isNull(expressionsTable.deletedAt))
)
.limit(1);

if (existing[0]) {
await db
.update(expressionsTable)
.set({ expr })
.where(eq(expressionsTable.id, existing[0].id));
return;
}

await db.insert(expressionsTable).values({ key, expr });

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
rg -nP -C3 'expressions' --type=ts -g '*schema*'
fd -e sql | xargs rg -nl -i 'expressions' 2>/dev/null
fd -e sql | xargs rg -nP -i 'unique.*key|key.*unique' 2>/dev/null

Repository: ScrawnDotDev/Scrawn

Length of output: 788


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Show full expressionsTable definition (including deletedAt/deleted_at and any indexes)
rg -n "export const expressionsTable" -S src/storage/db/postgres/schema.ts
start=$(rg -n "export const expressionsTable" -S src/storage/db/postgres/schema.ts | head -n1 | cut -d: -f1)
# print ~80 lines around it
python3 - <<PY
import pathlib,sys
path=pathlib.Path("src/storage/db/postgres/schema.ts")
lines=path.read_text().splitlines()
# compute start line from earlier captured env? can't; re-run quickly
import subprocess,shlex,os,glob
PY
# simpler: just re-run with sed using line number
n=$(rg -n "export const expressionsTable" -S src/storage/db/postgres/schema.ts | head -n1 | cut -d: -f1)
sed -n "$((n-10)),$((n+120))p" src/storage/db/postgres/schema.ts

# Search for deletedAt/deleted_at and any unique/index definitions related to expressions
rg -n "expressions.*(deletedAt|deleted_at|deletedAt|deleted_at|unique|index|onConflict)" -S src/storage/db/postgres/schema.ts src/storage/db/postgres/migrations 2>/dev/null || true

# Search all SQL files for expressions uniqueness/indexes
fd -e sql | xargs rg -nP -i "expressions" 2>/dev/null | head -n 50
fd -e sql | xargs rg -nP -i "unique.*\\(.*key|unique.*key|create\\s+unique\\s+index|partial\\s+unique\\s+index|where\\s+deleted" 2>/dev/null | head -n 80

# Inspect createExpression implementation for context/locking/transaction usage
fd -e ts -e tsx src/storage/db/postgres/helpers/expressions.ts 2>/dev/null
rg -n "createExpression" -S src/storage/db/postgres/helpers/expressions.ts
sed -n "1,220p" src/storage/db/postgres/helpers/expressions.ts

Repository: ScrawnDotDev/Scrawn

Length of output: 2725


Major: make createExpression atomic to prevent duplicate non-deleted expressions.key rows
File: src/storage/db/postgres/helpers/expressions.ts (lines 51-68)

try {
  const existing = await db
    .select({ id: expressionsTable.id })
    .from(expressionsTable)
    .where(and(eq(expressionsTable.key, key), isNull(expressionsTable.deletedAt)))
    .limit(1);

  if (existing[0]) {
    await db
      .update(expressionsTable)
      .set({ expr })
      .where(eq(expressionsTable.id, existing[0].id));
    return;
  }

  await db.insert(expressionsTable).values({ key, expr });
  • createExpression uses a non-atomic select-then-update/insert flow while expressionsTable supports soft deletes via deletedAt.
  • No uniqueness/partial-uniqueness constraint on expressions.key (e.g., key WHERE deleted_at IS NULL) was found in the Drizzle schema/migrations searches, so concurrent calls can both miss and insert, creating duplicate active rows; .limit(1) makes subsequent reads nondeterministic.
  • Fix by enforcing atomicity: add a partial unique index on (key) where deleted_at IS NULL and switch to an atomic INSERT ... ON CONFLICT (or transaction + locking), handling unique-constraint violations explicitly per the guidelines.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/storage/db/postgres/helpers/expressions.ts` around lines 51 - 68,
createExpression currently does a non-atomic select-then-update/insert against
expressionsTable (checking isNull(deletedAt)) which can lead to duplicate active
rows under concurrency; add a partial unique index on expressionsTable.key where
deleted_at IS NULL in your migrations and replace the select-then-insert flow in
createExpression with an atomic upsert (INSERT ... ON CONFLICT(key) DO UPDATE
SET expr=EXCLUDED.expr) or wrap the logic in a transaction with appropriate
row-level lock handling and explicitly catch unique-constraint violations to
handle races; reference expressionsTable, createExpression, deletedAt and handle
the conflict error path rather than relying on .limit(1).

Comment on lines +29 to +45
const existing = await db
.select({ id: tagsTable.id })
.from(tagsTable)
.where(and(eq(tagsTable.key, key), isNull(tagsTable.deletedAt)))
.limit(1);

if (existing[0]) {
await db
.update(tagsTable)
.set({ amount })
.where(eq(tagsTable.id, existing[0].id));
tagCache.delete(key);
return;
}

await db.insert(tagsTable).values({ key, amount });
tagCache.delete(key);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical | 🏗️ Heavy lift

Make the tag upsert atomic.

This is a TOCTOU write path: two requests for the same key can both miss existing[0], then race into conflicting inserts or duplicate active rows. It also won't revive a soft-deleted row if that key still has a uniqueness constraint. Wrap the lookup/write in a single Drizzle transaction and handle the unique-conflict path explicitly instead of doing a separate select and insert.

As per coding guidelines, "Use Drizzle ORM with transactions; validate all inputs before DB operations; handle unique constraint violations explicitly".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/storage/db/postgres/helpers/tags.ts` around lines 29 - 45, Wrap the
select/insert/update in a single Drizzle transaction using db.transaction so the
upsert for tagsTable is atomic: inside the transaction, do a SELECT ... WHERE
key = key FOR UPDATE (using tagsTable and db) and if you find an active row
update its amount and clear tagCache; if you find a soft-deleted row revive it
by updating amount and nulling deletedAt; otherwise INSERT the new row;
additionally catch unique-constraint errors on the INSERT and, in that error
handler, perform an UPDATE by id for the conflicting key to avoid duplicate
active rows; always delete tagCache for the key after the write.

Comment on lines 265 to 273
export const expressionsTable = pgTable("expressions", {
id: uuid("id").primaryKey().defaultRandom(),
key: text("key").notNull().unique(),
key: text("key").notNull(),
expr: text("expr").notNull(),
deletedAt: timestamp("deleted_at", {
withTimezone: true,
mode: "string",
}),
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Preserve uniqueness for active expression keys.

Dropping .unique() from key without adding a partial unique index allows multiple non-deleted rows with the same key. That makes key-based reads ambiguous and removes the DB guarantee the later upsert path needs.

Suggested schema change
-export const expressionsTable = pgTable("expressions", {
-  id: uuid("id").primaryKey().defaultRandom(),
-  key: text("key").notNull(),
-  expr: text("expr").notNull(),
-  deletedAt: timestamp("deleted_at", {
-    withTimezone: true,
-    mode: "string",
-  }),
-});
+export const expressionsTable = pgTable(
+  "expressions",
+  {
+    id: uuid("id").primaryKey().defaultRandom(),
+    key: text("key").notNull(),
+    expr: text("expr").notNull(),
+    deletedAt: timestamp("deleted_at", {
+      withTimezone: true,
+      mode: "string",
+    }),
+  },
+  (table) => ({
+    uniqueActiveKey: uniqueIndex("unique_active_expression_key")
+      .on(table.key)
+      .where(sql`${table.deletedAt} IS NULL`),
+  })
+);

Mirror the same constraint in the migration. As per coding guidelines, "Use Drizzle ORM with transactions; validate all inputs before DB operations; handle unique constraint violations explicitly".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/storage/db/postgres/schema.ts` around lines 265 - 273, The expressions
table lost the uniqueness guarantee on active keys—restore a partial unique
constraint on the key column for non-deleted rows: add a unique index on
expressions.key where deleted_at IS NULL (reference: expressionsTable, the key
column and deletedAt/timestamp "deleted_at") and add the same partial unique
index in the corresponding migration; ensure any upsert/insert code paths handle
unique-constraint errors explicitly (catch unique violation and handle according
to existing upsert logic) and perform the schema change inside a migration
transaction per project guidelines.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/routes/http/api/webhookEndpoints.ts (1)

343-357: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Missing ZodError handling in handleSendTestWebhook.

sendTestSchema.parse(body) on line 290 can throw ZodError, but the catch block lacks a handler for it. Validation failures will return 500 instead of 400, inconsistent with handleCreateWebhookEndpoint.

🐛 Proposed fix
     if (error instanceof AuthError) {
       builder.setError(401, { type: error.type, message: error.message });
       reply.code(401);
       return { error: error.message };
     }
 
+    if (error instanceof ZodError) {
+      const issues = error.issues
+        .map((issue) => `${issue.path.join(".")}: ${issue.message}`)
+        .join("; ");
+      builder.setError(400, { type: "ValidationError", message: issues });
+      reply.code(400);
+      return { error: issues };
+    }
+
     const err = error instanceof Error ? error : new Error(String(error));
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/routes/http/api/webhookEndpoints.ts` around lines 343 - 357, The catch
block in handleSendTestWebhook currently treats all errors as 500; add handling
for ZodError thrown by sendTestSchema.parse(body) so validation failures return
400 like handleCreateWebhookEndpoint: detect error instanceof ZodError, call
builder.setError(400, { type: "ValidationError", message: error.message }) (or
similar payload consistent with existing validation responses), set
reply.code(400) and return the validation error details; preserve existing
Sentry.captureException and existing AuthError and generic 500 handling for
other cases.
🧹 Nitpick comments (1)
src/routes/http/api/webhookEndpoints.ts (1)

85-97: 💤 Low value

Defensive check is unreachable.

The condition targetApiKeyId !== auth.apiKeyId && auth.role !== "dashboard" can never be true: when auth.role !== "dashboard", the ternary on lines 85-88 guarantees targetApiKeyId === auth.apiKeyId. The check is harmless but dead code.

♻️ Suggested simplification
     const targetApiKeyId =
       validated.apiKeyId && auth.role === "dashboard"
         ? validated.apiKeyId
         : auth.apiKeyId;
 
-    if (targetApiKeyId !== auth.apiKeyId && auth.role !== "dashboard") {
-      builder.setError(403, {
-        type: "PermissionDenied",
-        message: "Only dashboard keys can set webhooks for other keys",
-      });
-      reply.code(403);
-      return { error: "Only dashboard keys can set webhooks for other keys" };
-    }
-
     const targetKey = await getApiKeyRoleById(targetApiKeyId);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/routes/http/api/webhookEndpoints.ts` around lines 85 - 97, The
conditional guarding webhook assignment is dead code: because targetApiKeyId is
computed via the ternary using auth.role, the subsequent if (targetApiKeyId !==
auth.apiKeyId && auth.role !== "dashboard") can never be true; remove that
unreachable if-block and simplify the assignment to compute targetApiKeyId only
based on auth.role and validated.apiKeyId (keep references to targetApiKeyId,
auth.role, validated.apiKeyId) so the logic is clear and there is no redundant
permission check.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@src/routes/http/api/webhookEndpoints.ts`:
- Around line 343-357: The catch block in handleSendTestWebhook currently treats
all errors as 500; add handling for ZodError thrown by
sendTestSchema.parse(body) so validation failures return 400 like
handleCreateWebhookEndpoint: detect error instanceof ZodError, call
builder.setError(400, { type: "ValidationError", message: error.message }) (or
similar payload consistent with existing validation responses), set
reply.code(400) and return the validation error details; preserve existing
Sentry.captureException and existing AuthError and generic 500 handling for
other cases.

---

Nitpick comments:
In `@src/routes/http/api/webhookEndpoints.ts`:
- Around line 85-97: The conditional guarding webhook assignment is dead code:
because targetApiKeyId is computed via the ternary using auth.role, the
subsequent if (targetApiKeyId !== auth.apiKeyId && auth.role !== "dashboard")
can never be true; remove that unreachable if-block and simplify the assignment
to compute targetApiKeyId only based on auth.role and validated.apiKeyId (keep
references to targetApiKeyId, auth.role, validated.apiKeyId) so the logic is
clear and there is no redundant permission check.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: b094bec0-c636-4494-b06a-66fd7e3a2f28

📥 Commits

Reviewing files that changed from the base of the PR and between 45b8291 and 3a32fbc.

📒 Files selected for processing (3)
  • src/routes/http/api/apiKeys.ts
  • src/routes/http/api/webhookEndpoints.ts
  • src/storage/db/postgres/helpers/apiKeys.ts

@greptile-apps

greptile-apps Bot commented May 31, 2026

Copy link
Copy Markdown

Greptile Summary

This PR adds dashboard backfill endpoints for API key management (create/list/revoke), tag and expression CRUD with soft-deletion, webhook delivery listing, and expands webhook endpoint operations to allow dashboard keys to manage webhooks on behalf of other keys.

  • API key endpoints and tag/expression mutation endpoints are added; several lack the dashboard role guard that other write operations in the codebase enforce.
  • Webhook delivery listing returns all deliveries system-wide — including full requestBody — to any authenticated key with no ownership scope or role restriction.
  • Schema: deletedAt soft-delete columns added to tags and expressions tables; the unique() constraint on expressionsTable.key is dropped without a corresponding migration file in the diff.

Confidence Score: 3/5

Not safe to merge: multiple new endpoints expose write operations on shared data and full delivery history including event payloads to any authenticated key.

The delivery listing endpoint returns every webhook delivery in the system — including request bodies — to any valid API key without scope restriction. The four new tag/expression mutation handlers similarly accept any authenticated key, allowing test or production keys to overwrite or delete the global tag amounts used in expression evaluation. These are current auth boundary defects on newly added paths.

src/routes/http/api/webhookDeliveries.ts, src/routes/http/api/tags.ts, src/routes/http/api/expressions.ts, and src/routes/http/api/apiKeys.ts all need dashboard role guards before these endpoints are safe to expose.

Important Files Changed

Filename Overview
src/routes/http/api/webhookDeliveries.ts New endpoint exposes all delivery records (including requestBody) to any authenticated key; missing dashboard role guard and ownership check.
src/routes/http/api/tags.ts handleCreateTag and handleDeleteTag added without role guard; any test/production key can mutate global tag values.
src/routes/http/api/expressions.ts handleCreateExpression and handleDeleteExpression added without role guard; any key can mutate global expressions.
src/routes/http/api/apiKeys.ts New API-key management endpoints lack dashboard role guards on create, list, and revoke operations.
src/routes/http/api/webhookEndpoints.ts Refactored to allow dashboard keys to manage webhooks for other keys; HTTPS guard for production correctly moved inside handler; sendTestWebhook now requires dashboard role.
src/storage/db/postgres/helpers/apiKeys.ts Added getApiKeyRoleById helper; does not filter revoked/expired keys.
src/storage/db/postgres/schema.ts Added deletedAt to tags and expressions tables; removed unique() constraint from expressionsTable.key — no migration file present in this diff.

Reviews (2): Last reviewed commit: "fix: add inputCacheTokens to expression ..." | Re-trigger Greptile

Comment on lines +61 to +66
name: validated.name,
key: apiKeyHash,
role: validated.role,
expiresAt: expiresAt.toISO(),
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Missing role check on key creation

handleCreateApiKey authenticates the caller but never checks that auth.role === "dashboard". As written, any valid test or production key can call POST /api/v1/api-keys and mint new production keys — a direct privilege escalation. A test key should not be able to create a production key.

Comment on lines +204 to +216

if ((result.count ?? 0) === 0) {
builder.setError(404, {
type: "NotFoundError",
message: "API key not found or already revoked",
});
reply.code(404);
return { error: "API key not found or already revoked" };
}

builder.setSuccess(200);
reply.code(200);
return { message: "API key revoked" };

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Missing role check on key revocation

handleRevokeApiKey authenticates the caller but does not verify the caller has the dashboard role. Any live test or production key can call DELETE /api/v1/api-keys/:id with any UUID and revoke any other key in the system, including the dashboard key itself.

Comment on lines +40 to +49
reply: FastifyReply
): Promise<Record<string, unknown> | { error: string }> {
const builder = createWideEventBuilder(
generateRequestId(),
request.method,
request.url
);

try {
const auth = await authenticateHttpApiKey(request.headers.authorization);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 HTTPS not enforced for production webhook URL at key-creation time

createApiKeySchema accepts any valid URL for webhookUrl regardless of the requested role. When role: "production" is submitted, the webhookUrl may be plain HTTP. handleCreateWebhookEndpoint enforces HTTPS for production keys, but that check is absent here, so handleCreateApiKey creates the endpoint via upsertWebhookEndpoint with a non-HTTPS URL before any such guard can fire.

Comment on lines +79 to +98
export async function getApiKeyRoleById(
id: string
): Promise<{ role: "dashboard" | "production" | "test" } | null> {
const db = getPostgresDB();

try {
const [record] = await db
.select({ role: apiKeysTable.role })
.from(apiKeysTable)
.where(eq(apiKeysTable.id, id))
.limit(1);

return record ?? null;
} catch (e) {
throw StorageError.queryFailed(
"Failed to look up API key role",
e instanceof Error ? e : new Error(String(e))
);
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 getApiKeyRoleById does not filter revoked or expired keys

Both handleCreateWebhookEndpoint and handleSendTestWebhook call getApiKeyRoleById(targetApiKeyId) to decide whether the target is valid. The query has no revoked = false or expiry filter, so a revoked (or expired) key will still return a role and pass the role check, allowing webhooks to be attached to or test events sent to keys that have already been revoked.

Comment on lines +143 to +169
webhookEndpointId: webhookEndpointsTable.id,
})
.from(apiKeysTable)
.leftJoin(
webhookEndpointsTable,
and(
eq(apiKeysTable.id, webhookEndpointsTable.apiKeyId),
isNull(webhookEndpointsTable.deletedAt)
)
)
.where(
and(ne(apiKeysTable.role, "dashboard"), eq(apiKeysTable.revoked, false))
)
.orderBy(apiKeysTable.createdAt);

builder.setSuccess(200);
reply.code(200);
return { keys };
} catch (error) {
Sentry.captureException(error, {
extra: { context: "list API keys handler" },
});

if (error instanceof AuthError) {
builder.setError(401, { type: error.type, message: error.message });
reply.code(401);
return { error: error.message };

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 handleListApiKeys lacks a role guard

Any valid test or production key can call GET /api/v1/api-keys and enumerate every non-revoked non-dashboard key in the system, including their webhook URLs and public keys. This endpoint should require auth.role === "dashboard" to prevent one tenant's key from discovering the full key inventory.

Comment on lines +37 to +54
await authenticateHttpApiKey(request.headers.authorization);

const query = listDeliveriesQuerySchema.parse(request.query);
const db = getPostgresDB();

let conditions = undefined;
if (query.apiKeyId) {
const endpoints = await db
.select({ id: webhookEndpointsTable.id })
.from(webhookEndpointsTable)
.where(eq(webhookEndpointsTable.apiKeyId, query.apiKeyId));
const ids = endpoints.map((e) => e.id);
if (ids.length > 0) {
conditions = inArray(webhookDeliveriesTable.endpointId, ids);
} else {
conditions = eq(webhookDeliveriesTable.endpointId, "");
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 No scope restriction — all deliveries visible to any key

handleListDeliveries performs only authentication, not authorization. When apiKeyId is omitted, the query returns every delivery in the system across all API keys. Even when apiKeyId is supplied, there is no check that it belongs to the calling key, so any test or production key can read the full event history — including requestBody — of every other key by iterating UUIDs. Consistent with the rest of the dashboard API, this endpoint should require auth.role === "dashboard" before serving any results.

Comment on lines +88 to +90
const authHeader = request.headers.authorization;
await authenticateHttpApiKey(authHeader);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 No role guard on tag/expression mutation endpoints

handleCreateTag and handleDeleteTag (lines 88–90 and 139–141) authenticate the caller but do not verify auth.role === "dashboard". Any live test or production key can create or overwrite tags (altering the billing/rate-limit amounts used in expression evaluation) or delete them. The same pattern appears in handleCreateExpression and handleDeleteExpression in expressions.ts (lines 330–332 and 393–395). Given that handleSendTestWebhook was intentionally tightened to dashboard-only in this same PR, these write operations should follow the same guard.

@SteakFisher
SteakFisher merged commit 533e33c into main May 31, 2026
1 of 2 checks passed
@SteakFisher
SteakFisher deleted the feat/dashboard-backfill branch June 6, 2026 18:36
@SteakFisher
SteakFisher restored the feat/dashboard-backfill branch June 6, 2026 18:36
@SteakFisher
SteakFisher deleted the feat/dashboard-backfill branch June 8, 2026 16:31
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