Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 7 additions & 5 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion packages/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@
"@repo/google-calendar": "workspace:*",
"@repo/google-tasks": "workspace:*",
"@repo/temporal": "workspace:*",
"@upstash/ratelimit": "^2.0.5",
"@unkey/ratelimit": "^2.1.2",
"@upstash/redis": "^1.35.1"
},
"devDependencies": {
Expand Down
47 changes: 9 additions & 38 deletions packages/api/src/routers/early-access.ts
Original file line number Diff line number Diff line change
@@ -1,34 +1,11 @@
import { TRPCError } from "@trpc/server";
import { Ratelimit } from "@upstash/ratelimit";
import { Redis } from "@upstash/redis";
import { count, eq } from "drizzle-orm";
import { z } from "zod/v3";

import { db } from "@repo/db";
import { waitlist } from "@repo/db/schema";
import { env } from "@repo/env/server";

import { createTRPCRouter, publicProcedure } from "../trpc";
import { getIp } from "../utils/ip";

let ratelimit: Ratelimit | null = null;

function getRateLimiter() {
if (!ratelimit) {
const redis = new Redis({
url: env.UPSTASH_REDIS_REST_URL,
token: env.UPSTASH_REDIS_REST_TOKEN,
});

ratelimit = new Ratelimit({
redis,
limiter: Ratelimit.slidingWindow(2, "1m"),
analytics: true,
prefix: "ratelimit:early-access-waitlist",
});
}
return ratelimit;
}

export const earlyAccessRouter = createTRPCRouter({
getWaitlistCount: publicProcedure.query(async () => {
Expand All @@ -46,26 +23,20 @@ export const earlyAccessRouter = createTRPCRouter({
};
}),
joinWaitlist: publicProcedure
.meta({
procedureName: "earlyAccess.joinWaitlist",
ratelimit: {
namespace: "early-access-waitlist",
limit: 2,
duration: "1m",
},
})
.input(
z.object({
email: z.string().email(),
}),
)
.mutation(async ({ input, ctx }) => {
// Apply rate limiting if available
const limiter = getRateLimiter();
if (limiter) {
const ip = getIp(ctx.headers);
const { success } = await limiter.limit(ip);

if (!success) {
throw new TRPCError({
code: "TOO_MANY_REQUESTS",
message: "Too many requests. Please try again later.",
});
}
}

.mutation(async ({ input }) => {
Copy link

Choose a reason for hiding this comment

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

💡 Verification agent

🧩 Analysis chain

Prevent duplicate waitlist entries and normalize emails

There’s a race between the existence check and the insert, which can yield duplicates under concurrency. Also, email comparisons are typically case-insensitive; normalize before comparing/inserting.

  • Add a unique constraint on waitlist.email at the database level.
  • Use onConflictDoNothing (or equivalent) and normalize email:
// normalize
const email = input.email.trim().toLowerCase();

// atomic insert-or-ignore
const inserted = await db
  .insert(waitlist)
  .values({ email })
  .onConflictDoNothing({ target: waitlist.email });

// report outcome
if (inserted.rowCount === 0) {
  return { message: "You're already on the waitlist!" };
}
return { message: "You've been added to the waitlist!" };

Consider returning the same generic message regardless of prior presence to avoid leaking list membership.


🏁 Script executed:

#!/bin/bash
# Check if a unique constraint or index already exists for waitlist.email
rg -nP --type=ts -g '!**/node_modules/**' '(unique|uniqueIndex)\(.*waitlist.*email' db schema 2>/dev/null || echo "No unique constraint found; consider adding one in the Drizzle schema."

Length of output: 246


Add unique constraint on waitlist.email and use atomic insert with normalized emails

The current implementation still risks duplicate waitlist entries under concurrency and treats emails case-sensitively. We’ve verified there’s no unique constraint on waitlist.email in your Drizzle schema; please add one and switch to an atomic “insert-or-ignore” flow using a normalized email.

• db/schema.ts (Drizzle schema)
– Add .unique() (or a unique index) to the email column on the waitlist table.
• packages/api/src/routers/early-access.ts (mutation resolver)
– Normalize the incoming email via input.email.trim().toLowerCase().
– Replace the separate existence check + insert with:
diff .mutation(async ({ input }) => { - // existing exists-then-insert logic + const email = input.email.trim().toLowerCase(); + + const inserted = await db + .insert(waitlist) + .values({ email }) + .onConflictDoNothing({ target: waitlist.email }); + + if (inserted.rowCount === 0) { + return { message: "You're already on the waitlist!" }; + } + return { message: "You've been added to the waitlist!" }; })
• (Optionally) To prevent leaking who’s on the list, unify both outcomes under a generic message like "Your request has been received." [optional]

📝 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
.mutation(async ({ input }) => {
.mutation(async ({ input }) => {
const email = input.email.trim().toLowerCase();
const inserted = await db
.insert(waitlist)
.values({ email })
.onConflictDoNothing({ target: waitlist.email });
if (inserted.rowCount === 0) {
return { message: "You're already on the waitlist!" };
}
return { message: "You've been added to the waitlist!" };
})
🤖 Prompt for AI Agents
In packages/api/src/routers/early-access.ts around line 39 and db/schema.ts, the
waitlist flow is vulnerable to race conditions and treats emails
case-sensitively; modify db/schema.ts to add a unique constraint on
waitlist.email (either .unique() on the column or create a unique index in the
Drizzle schema) and update the mutation in
packages/api/src/routers/early-access.ts to normalize the input with
input.email.trim().toLowerCase(), then replace the separate existence-check +
insert with an atomic insert-or-ignore/upsert (use Drizzle's insert with
onConflict/doNothing or a parameterized INSERT ... ON CONFLICT DO NOTHING) so
duplicates are prevented under concurrency; optionally return a non-revealing
generic message like "Your request has been received." for both outcomes.

const userAlreadyInWaitlist = await db
.select()
.from(waitlist)
Expand Down
49 changes: 9 additions & 40 deletions packages/api/src/routers/places.ts
Original file line number Diff line number Diff line change
@@ -1,50 +1,19 @@
import { TRPCError } from "@trpc/server";
import { Ratelimit } from "@upstash/ratelimit";
import { Redis } from "@upstash/redis";

import { env } from "@repo/env/server";

import { GooglePlacesProvider } from "../providers/google-places";
import { autocompleteInputSchema } from "../schemas/places";
import { createTRPCRouter, publicProcedure } from "../trpc";
import { getIp } from "../utils/ip";

let ratelimit: Ratelimit | null = null;

function getRateLimiter() {
if (!ratelimit) {
const redis = new Redis({
url: env.UPSTASH_REDIS_REST_URL,
token: env.UPSTASH_REDIS_REST_TOKEN,
});

ratelimit = new Ratelimit({
redis,
limiter: Ratelimit.slidingWindow(20, "1m"),
analytics: true,
prefix: "ratelimit:google-places",
});
}

return ratelimit;
}

export const placesRouter = createTRPCRouter({
autocomplete: publicProcedure
.meta({
procedureName: "places.autocomplete",
ratelimit: {
namespace: "google-places",
limit: 20,
duration: "1m",
},
})
.input(autocompleteInputSchema)
.query(async ({ input, ctx }) => {
const identifier = getIp(ctx.headers);
const limiter = getRateLimiter();

const { success } = await limiter.limit(identifier);

if (!success) {
throw new TRPCError({
code: "TOO_MANY_REQUESTS",
message: "Too many requests. Please try again later.",
});
}

.query(async ({ input }) => {
const placesProvider = new GooglePlacesProvider();

return await placesProvider.autocomplete(input.input, {
Expand Down
17 changes: 8 additions & 9 deletions packages/api/src/trpc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,12 @@ import "server-only";

import * as Sentry from "@sentry/node";
import { TRPCError, initTRPC } from "@trpc/server";
import { Ratelimit } from "@upstash/ratelimit";
import { Ratelimit } from "@unkey/ratelimit";
import { ZodError } from "zod/v3";

import { auth } from "@repo/auth/server";
import { db } from "@repo/db";
import { env } from "@repo/env/server";

import {
getCalendarProvider,
Expand All @@ -16,7 +17,6 @@ import {
} from "./providers";
import { getAccounts } from "./utils/accounts";
import { getIp } from "./utils/ip";
import { getRedis } from "./utils/redis";
import { superjson } from "./utils/superjson";

type Unit = "ms" | "s" | "m" | "h" | "d";
Expand Down Expand Up @@ -77,15 +77,14 @@ export const rateLimitMiddleware = t.middleware(async ({ ctx, meta, next }) => {
}

const limiter = new Ratelimit({
redis: getRedis(),
limiter: Ratelimit.slidingWindow(
meta.ratelimit.limit,
meta.ratelimit.duration,
),
namespace: meta.ratelimit.namespace,
limit: meta.ratelimit.limit,
duration: meta.ratelimit.duration,
rootKey: env.UNKEY_ROOT_KEY,
});

const key = `${meta.ratelimit.namespace}:${meta.procedureName}:${ctx.rateLimit.id}`;
const result = await limiter.limit(key);
const identifier = `${meta.procedureName}:${ctx.rateLimit.id}`;
const result = await limiter.limit(identifier);

if (!result.success) {
throw new TRPCError({ code: "TOO_MANY_REQUESTS" });
Expand Down
1 change: 1 addition & 0 deletions packages/env/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export const env = createEnv({
VERCEL_URL: z.string().optional(),
UPSTASH_REDIS_REST_URL: z.string().url(),
UPSTASH_REDIS_REST_TOKEN: z.string().min(1),
UNKEY_ROOT_KEY: z.string().min(1),
Copy link

Choose a reason for hiding this comment

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

💡 Verification agent

🧩 Analysis chain

UNKEY_ROOT_KEY is required — confirm usage stays server-only and available in non-prod

  • It’s correctly typed as server-only. Keep imports to @repo/env/server (not the client bundle) to avoid accidental exposure.
  • With skipValidation outside production, dev builds won’t fail if the var is missing, but your middleware will. If you expect local dev without Unkey keys, consider a fail-open path or making this conditional in dev.

If local dev without Unkey is common, consider a no-op limiter when UNKEY_ROOT_KEY is absent in non-production to preserve developer flow.


🏁 Script executed:

#!/bin/bash
# Ensure all env imports use the server entry to prevent leaking secrets
rg -nP '@repo/env(?!/server)' -g '!**/node_modules/**' || echo "OK: only server imports detected."

Length of output: 827


Fix server-only imports for UNKEY_ROOT_KEY

The environment variable UNKEY_ROOT_KEY must remain strictly server-side to avoid leaking secrets. I’ve identified client imports that need to be updated:

  • Replace import { env } from "@repo/env/client" with import { env } from "@repo/env/server" in:
    • apps/web/src/components/settings-dialog/tabs/accounts.tsx (line 7)
    • apps/web/src/lib/trpc/client.tsx (line 17)

Optional: If local development without Unkey credentials is expected, consider implementing a no-op or “fail-open” path when UNKEY_ROOT_KEY is missing in non-production builds to preserve developer flow.

🤖 Prompt for AI Agents
In packages/env/src/server.ts around line 20, the UNKEY_ROOT_KEY env var is
defined as a server-only secret; ensure it never gets bundled into client code
by replacing any client imports of the env module with the server version:
change import { env } from "@repo/env/client" to import { env } from
"@repo/env/server" in apps/web/src/components/settings-dialog/tabs/accounts.tsx
(line ~7) and apps/web/src/lib/trpc/client.tsx (line ~17). Verify these files
only use server-side code where executed on the server (or move secret-dependent
logic to API routes). Optionally, for local dev, add a guarded no-op or
fail-open branch that only runs in non-production when UNKEY_ROOT_KEY is missing
so devs aren’t blocked, but do not expose the real secret in client bundles.

MARBLE_WORKSPACE_KEY: z.string().min(1).optional(),
MARBLE_API_URL: z.string().url().optional(),
},
Expand Down
2 changes: 2 additions & 0 deletions turbo.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
"NEXT_PUBLIC_VERCEL_ENV",
"UPSTASH_REDIS_REST_URL",
"UPSTASH_REDIS_REST_TOKEN",
"UNKEY_ROOT_KEY",
Copy link

Choose a reason for hiding this comment

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

💡 Verification agent

🧩 Analysis chain

UNKEY_ROOT_KEY added to build/dev env — make sure CI and preview envs provide it

Good to see UNKEY_ROOT_KEY wired into Turbo so local/dev builds won’t silently miss it. Ensure:

  • All GitHub Actions/CI pipelines that run build/dev export UNKEY_ROOT_KEY.
  • Any preview/staging deploy pipelines set it as well.

Consider listing UNKEY_ROOT_KEY in .env.example/docs to ease onboarding.

Also applies to: 52-52


🏁 Script executed:

#!/bin/bash
# Verify references to UNKEY_ROOT_KEY across workflows and docs
fd -H -a '.github/workflows' 2>/dev/null | xargs -r rg -n "UNKEY_ROOT_KEY" || echo "No references in workflows; ensure it's added where needed."

# Double-check no client code is reading UNKEY_ROOT_KEY directly
rg -nP --type=ts --type=tsx -g '!**/node_modules/**' 'process\.env\.UNKEY_ROOT_KEY' || echo "OK: no direct client reads found."

Length of output: 251


🏁 Script executed:

#!/bin/bash
# Locate any .env, .env.example, or related environment files
fd -t f -g '.env*' 2>/dev/null

# Search for UNKEY_ROOT_KEY in environment files and Markdown docs
rg -n 'UNKEY_ROOT_KEY' -g '*.env*' -g '*.md' || echo "UNKEY_ROOT_KEY not found in env/example or docs"

Length of output: 170


🏁 Script executed:

#!/bin/bash
echo "Listing all GitHub Actions workflow files:"
fd -t f -g ".github/workflows/*.yml" || echo "No workflow files found"

echo
echo "Checking for .env.example or .env.sample files:"
fd -t f -g ".env.example" -g ".env.sample" || echo "No example env files found"

Length of output: 714


🏁 Script executed:

#!/bin/bash
set -e

echo "==> Finding GitHub Actions workflow files"
if [ -d .github/workflows ]; then
  find .github/workflows -type f \( -name '*.yml' -o -name '*.yaml' \) -print
else
  echo "⚠️  No .github/workflows directory found"
fi

echo
echo "==> Searching workflows for UNKEY_ROOT_KEY"
if [ -d .github/workflows ]; then
  grep -RHn 'UNKEY_ROOT_KEY' .github/workflows || echo "⚠️  UNKEY_ROOT_KEY not found in any workflow"
fi

echo
echo "==> Checking for .env.example and .env.sample"
for f in .env.example .env.sample; do
  if [ -f "$f" ]; then
    echo "Found $f — checking contents"
    grep -Hn 'UNKEY_ROOT_KEY' "$f" || echo "⚠️  UNKEY_ROOT_KEY missing in $f"
  else
    echo "ℹ️  $f not present"
  fi
done

Length of output: 1107


Ensure UNKEY_ROOT_KEY is configured in CI workflows and example env files

Verification shows that although you’ve wired UNKEY_ROOT_KEY into turbo.json, it’s currently:

  • Absent from all GitHub Actions workflows that invoke build/dev (e.g. .github/workflows/check-format.yaml, .github/workflows/check-types.yaml, .github/workflows/lint.yaml)
  • Missing in .env.example for onboarding

Please add:

  • In each workflow that calls turbo run build or turbo run dev, under the top-level env: section:
    env:
      UNKEY_ROOT_KEY: ${{ secrets.UNKEY_ROOT_KEY }}
  • A placeholder entry in .env.example:
    # Unkey service root key
    UNKEY_ROOT_KEY=your_unkey_root_key_here

Also confirm that any staging/preview deployment pipelines export the same environment variable.

🤖 Prompt for AI Agents
In turbo.json around line 19 the UNKEY_ROOT_KEY token is referenced but CI and
examples lack configuration; add UNKEY_ROOT_KEY to every GitHub Actions workflow
that runs `turbo run build` or `turbo run dev` by inserting under the workflow's
top-level `env:` section `UNKEY_ROOT_KEY: ${{ secrets.UNKEY_ROOT_KEY }}` (apply
to .github/workflows/check-format.yaml, check-types.yaml, lint.yaml and any
other workflows invoking turbo), add a placeholder entry to .env.example with a
comment like `# Unkey service root key` and
`UNKEY_ROOT_KEY=your_unkey_root_key_here`, and ensure any staging/preview
deployment pipelines also export the same environment variable from their
secrets.

"SIMPLE_ANALYTICS_HOSTNAME",
"NEXT_PUBLIC_SIMPLE_ANALYTICS_HOSTNAME",
"MICROSOFT_CLIENT_ID",
Expand Down Expand Up @@ -48,6 +49,7 @@
"NEXT_PUBLIC_VERCEL_ENV",
"UPSTASH_REDIS_REST_URL",
"UPSTASH_REDIS_REST_TOKEN",
"UNKEY_ROOT_KEY",
"SIMPLE_ANALYTICS_HOSTNAME",
"NEXT_PUBLIC_SIMPLE_ANALYTICS_HOSTNAME",
"MICROSOFT_CLIENT_ID",
Expand Down