-
Notifications
You must be signed in to change notification settings - Fork 2.7k
Add publishableKey support + /track/**/client #2737
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Conversation
The latest updates on your projects. Learn more about Vercel for GitHub.
|
WalkthroughAdds client-side tracking endpoints (lead, sale) protected by a new publishable-key middleware, introduces publishableKey on Project plus UI to manage it, updates workspace PATCH and Zod/types, and centralizes COMMON_CORS_HEADERS across multiple API routes. Changes
Sequence Diagram(s)sequenceDiagram
participant Client
participant Route as /api/track/{lead|sale}/client
participant Middleware as withPublishableKey
participant DB as Project DB
participant Tracker as trackLead/trackSale
Client->>Route: POST (body) + Authorization: Bearer <pub_key>
Route->>Middleware: invoke wrapped handler
Middleware->>DB: find Project by publishableKey
DB-->>Middleware: Project (plan, allowedHostnames, publishableKey)
Middleware->>Middleware: rate-limit check (600/min)
alt allowed host & plan OK
Middleware-->>Route: call handler with workspace
Route->>Route: parse body, verify hostname (verifyAnalyticsAllowedHostnames)
Route->>Tracker: call trackLead/trackSale(workspace, rawBody, fields)
Tracker-->>Route: result
Route-->>Client: 200 JSON + COMMON_CORS_HEADERS
else forbidden/invalid/ratelimit
Middleware-->>Client: 401/403/429 or error JSON + COMMON_CORS_HEADERS
end
Estimated code review effort🎯 4 (Complex) | ⏱️ ~45 minutes Possibly related PRs
Suggested reviewers
Poem
Tip 🔌 Remote MCP (Model Context Protocol) integration is now available!Pro plan users can now connect to remote MCP servers from the Integrations page. Connect with popular remote MCPs such as Notion and Linear to add more context to your reviews and chats. ✨ Finishing Touches
🧪 Generate unit tests
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. 🪧 TipsChatThere are 3 ways to chat with CodeRabbit:
SupportNeed help? Create a ticket on our support page for assistance with any issues or questions. CodeRabbit Commands (Invoked using PR/Issue comments)Type Other keywords and placeholders
CodeRabbit Configuration File (
|
There was a problem hiding this 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 (2)
packages/prisma/schema/workspace.prisma (1)
42-42
: Clarify nullability, rotation, and backfill strategy for publishableKey
publishableKey
is optional with a unique constraint. Confirm intent:
- If all projects using client APIs must have a key, consider making it non-nullable and backfilling existing rows.
- If optional by design, document/handle “no key” behavior in middleware.
Also consider:
- Enforcing a bounded length (e.g., @db.VarChar(64/128)) to keep index sizes predictable.
- Establishing a rotation/revocation story (single key vs. multiple keys table).
I can help generate a safe migration/backfill plan if needed.
Would you like me to draft a migration that generates keys for existing projects and adds a uniqueness-preserving index with length bounds?
apps/web/lib/auth/publishable-key.ts (1)
98-101
: Consider adding WWW-Authenticate for 401 responsesFor better client UX, include
WWW-Authenticate: Bearer
on 401 Unauthorized responses. IfhandleAndReturnErrorResponse
supports custom headers, set it there forcode: "unauthorized"
errors.
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (3)
apps/web/app/(ee)/api/track/lead/client/route.ts
(1 hunks)apps/web/lib/auth/publishable-key.ts
(1 hunks)packages/prisma/schema/workspace.prisma
(1 hunks)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
- GitHub Check: build
🔇 Additional comments (2)
apps/web/app/(ee)/api/track/lead/client/route.ts (2)
7-9
: Add OPTIONS for CORS preflight (client-side endpoint)Browsers will preflight
POST application/json
withOPTIONS
. Provide anOPTIONS
handler to return 204 and appropriate CORS headers. Consider reflecting and validating Origin againstworkspace.allowedHostnames
.Example:
export const OPTIONS = () => new Response(null, { status: 204, headers: { "Access-Control-Allow-Origin": "*", // or reflect validated origin "Access-Control-Allow-Methods": "POST, OPTIONS", "Access-Control-Allow-Headers": "Authorization, Content-Type", "Access-Control-Max-Age": "600", }, });Would you like me to wire this with allowed hostnames validation?
10-11
: No changes needed for rawBody handlingUpon inspection, parseRequestBody(req) returns the parsed JSON and trackLead’s rawBody parameter is never used for signature or checksum verification—it's simply carried through to the conversion logic. Passing the parsed object into rawBody matches the current implementation and does not break any behavior.
requiredPlan: [ | ||
"business", | ||
"business plus", | ||
"business extra", | ||
"business max", | ||
"advanced", | ||
"enterprise", | ||
], | ||
}, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🛠️ Refactor suggestion
Avoid plan list duplication; centralize allowed plans
This list is duplicated here and in middleware defaults. Centralize in a single constants module (e.g., @/lib/plans
) to prevent drift.
If plan matching should be case-insensitive, normalize here instead of repeating that logic across callers.
🤖 Prompt for AI Agents
In apps/web/app/(ee)/api/track/lead/client/route.ts around lines 35 to 43, the
requiredPlan array is duplicated in middleware defaults. To fix this, move the
plan list to a shared constants module like '@/lib/plans' and import it here.
Also, if plan matching needs to be case-insensitive, normalize the plan names
once in that module rather than repeating normalization logic in multiple
places.
const { success, limit, reset, remaining } = await ratelimit( | ||
600, | ||
"1 m", | ||
).limit(publishableKey); | ||
|
||
headers = { | ||
"Retry-After": reset.toString(), | ||
"X-RateLimit-Limit": limit.toString(), | ||
"X-RateLimit-Remaining": remaining.toString(), | ||
"X-RateLimit-Reset": reset.toString(), | ||
}; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🛠️ Refactor suggestion
Return rate limit headers on both success and limit-exceeded responses
You compute rate-limit metadata but only return headers on 429. Surfacing them on success helps clients throttle themselves.
- const { success, limit, reset, remaining } = await ratelimit(
+ const { success, limit, reset, remaining } = await ratelimit(
600,
"1 m",
).limit(publishableKey);
- headers = {
+ rateLimitHeaders = {
"Retry-After": reset.toString(),
"X-RateLimit-Limit": limit.toString(),
"X-RateLimit-Remaining": remaining.toString(),
"X-RateLimit-Reset": reset.toString(),
};
if (!success) {
return new Response("Too many requests.", {
status: 429,
- headers,
+ headers: rateLimitHeaders,
});
}
And after invoking the handler (see separate comment), set these headers on the success response as well.
Also applies to: 68-74
🤖 Prompt for AI Agents
In apps/web/lib/auth/publishable-key.ts around lines 56 to 66 and 68 to 74, the
rate limit headers are only set on 429 responses but should also be included on
successful responses. Modify the code to set the "Retry-After",
"X-RateLimit-Limit", "X-RateLimit-Remaining", and "X-RateLimit-Reset" headers on
the success response after invoking the handler, ensuring clients receive rate
limit metadata regardless of success or failure.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 11
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (3)
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/analytics/allowed-hostnames-form.tsx (2)
55-61
: Wildcard validation accepts invalid inputs like "*."isValidHostname returns true for ".","..com", etc. Tighten validation by checking the suffix after "*." with validDomainRegex.
Apply this diff:
- const isValidHostname = (hostname: string) => { - return ( - validDomainRegex.test(hostname) || - hostname === "localhost" || - hostname.startsWith("*.") - ); - }; + const isValidHostname = (hostname: string) => { + const value = hostname.trim().toLowerCase(); + if (value === "localhost") return true; + if (value.startsWith("*.")) { + // validate the remainder after "*." as a domain + const rest = value.slice(2); + return rest.length > 0 && validDomainRegex.test(rest); + } + return validDomainRegex.test(value); + };
63-97
: Normalize input and de-duplicate case-insensitively; guard on permissions; add error handling
- Normalize to lowercase/trim before validation and storage to avoid duplicates like "Example.com" vs "example.com".
- Short-circuit when permissionsError is present.
- Wrap network calls in try/catch to handle non-JSON errors.
Apply this diff:
const addHostname = async () => { - if (allowedHostnames?.includes(hostname)) { + if (permissionsError) { + toast.error(permissionsError); + return; + } + + const normalized = hostname.trim().toLowerCase(); + + if (allowedHostnames?.some((h) => h.toLowerCase() === normalized)) { toast.error("Hostname already exists."); return; } - if (!isValidHostname(hostname)) { + if (!isValidHostname(normalized)) { toast.error("Enter a valid domain."); return; } setProcessing(true); - const response = await fetch(`/api/workspaces/${id}`, { - method: "PATCH", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - allowedHostnames: [...(allowedHostnames || []), hostname], - }), - }); - - if (response.ok) { - toast.success("Hostname added."); - } else { - const { error } = await response.json(); - toast.error(error.message); - } + try { + const response = await fetch(`/api/workspaces/${id}`, { + method: "PATCH", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + allowedHostnames: [...(allowedHostnames || []), normalized], + }), + }); + if (response.ok) { + toast.success("Hostname added."); + } else { + const data = await response.json().catch(() => ({})); + toast.error(data?.error?.message ?? "Failed to add hostname."); + } + } catch { + toast.error("Network error while adding hostname."); + } mutate(); setProcessing(false); - setHostname(""); + setHostname(""); };apps/web/app/api/workspaces/[idOrSlug]/route.ts (1)
116-125
: Potentially updates defaultWorkspace to undefined when slug is not providedIf slug is undefined, the condition slug !== workspace.slug is true and the code sets defaultWorkspace to undefined for many users. Guard on slug being truthy before updating.
Apply this diff:
- if (slug !== workspace.slug) { + if (slug && slug !== workspace.slug) { await prisma.user.updateMany({ where: { defaultWorkspace: workspace.slug, }, data: { defaultWorkspace: slug, }, }); }
🧹 Nitpick comments (5)
apps/web/lib/types.ts (1)
179-189
: Redundant redeclaration of publishableKey on ExtendedWorkspacePropsWorkspaceProps extends Prisma’s Project, which already includes publishableKey (String? → string | null). Redeclaring it here is unnecessary and risks future type drift if the Prisma field changes.
Apply this diff to rely on the base type:
export interface ExtendedWorkspaceProps extends WorkspaceProps { domains: (WorkspaceProps["domains"][number] & { linkRetentionDays: number | null; })[]; defaultProgramId: string | null; allowedHostnames: string[]; users: (WorkspaceProps["users"][number] & { workspacePreferences?: z.infer<typeof workspacePreferencesSchema>; })[]; - publishableKey: string | null; }
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/analytics/allowed-hostnames-form.tsx (2)
98-129
: Avoid double-submission; wire Button as submit and disable on processing/permissionsForm onSubmit already calls addHostname(). If Button is type="submit", the onClick handler can cause duplicate calls. Also disable the button on processing and when permission is missing.
Apply this diff:
- <Button - text="Add Hostname" - variant="primary" - onClick={addHostname} - disabled={!isValidHostname(hostname) || hostname.length === 0} - loading={processing} - className="w-fit" - disabledTooltip={permissionsError || undefined} - /> + <Button + text="Add Hostname" + variant="primary" + type="submit" + disabled={ + processing || + !!permissionsError || + !isValidHostname(hostname.trim().toLowerCase()) + } + loading={processing} + className="w-fit" + disabledTooltip={permissionsError || undefined} + />
187-203
: Disable Delete action during processing and when permission is missingPrevent repeated PATCH requests and respect permission errors by disabling the button.
Apply this diff:
<Button variant="outline" className={cn( "h-8 px-1.5 outline-none transition-all duration-200", "border-transparent data-[state=open]:border-neutral-500 sm:group-hover/card:data-[state=closed]:border-neutral-200", )} icon={ processing ? ( <LoadingSpinner className="size-4 shrink-0" /> ) : ( <Trash className="size-4" /> ) } onClick={deleteHostname} + disabled={processing || !!permissionsError} disabledTooltip={permissionsError || undefined} />
apps/web/app/(ee)/api/track/sale/client/route.ts (1)
24-29
: Prefer schema-level validation over imperative checkcustomerExternalId emptiness should be enforced in trackSaleRequestSchema (e.g., .min(1)) rather than checked ad hoc here. This removes dead code paths and keeps validation centralized.
Apply this diff after updating the schema as suggested below:
- if (!customerExternalId) { - throw new DubApiError({ - code: "bad_request", - message: "customerExternalId is required", - }); - }Update the schema (apps/web/lib/zod/schemas/sales.ts) to ensure non-empty values:
// apps/web/lib/zod/schemas/sales.ts customerExternalId: z .string() .trim() .min(1, "customerExternalId is required") .max(100) .describe("..."),apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/analytics/publishable-key-form.tsx (1)
25-26
: Consider centralizing publishable key generation (optional)Current setup
- Client generates a key with
dub_pk_${nanoid(24)}
in
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/analytics/publishable-key-form.tsx:25- Server’s Zod schema (
apps/web/lib/zod/schemas/workspaces.ts:150
) simply allows any string forpublishableKey
- Auth middleware (
apps/web/lib/auth/publishable-key.ts:54
) only checks that the key starts with"dub_pk_"
Because format logic (client) and validation logic (server) live in separate places, there’s a risk of drift if you ever change the key contract. To keep everything in sync, you might:
- Move key generation to the server-side endpoint
- Extract a shared utility that defines and enforces the key format (both for generation and validation)
- Update the Zod schema and auth middleware to use that shared util
No immediate fixes are required—your current client-generated keys will be accepted as-is. This refactor is purely optional to reduce future maintenance overhead.
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
💡 Knowledge Base configuration:
- MCP integration is disabled by default for public repositories
- Jira integration is disabled by default for public repositories
- Linear integration is disabled by default for public repositories
You can enable these sources in your CodeRabbit configuration.
📒 Files selected for processing (8)
apps/web/app/(ee)/api/track/sale/client/route.ts
(1 hunks)apps/web/app/api/workspaces/[idOrSlug]/route.ts
(2 hunks)apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/analytics/allowed-hostnames-form.tsx
(2 hunks)apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/analytics/page.tsx
(1 hunks)apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/analytics/publishable-key-form.tsx
(1 hunks)apps/web/lib/auth/publishable-key.ts
(1 hunks)apps/web/lib/types.ts
(1 hunks)apps/web/lib/zod/schemas/workspaces.ts
(2 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
- apps/web/lib/auth/publishable-key.ts
🧰 Additional context used
🧬 Code Graph Analysis (4)
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/analytics/page.tsx (2)
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/analytics/allowed-hostnames-form.tsx (1)
AllowedHostnamesForm
(11-42)apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/analytics/publishable-key-form.tsx (1)
PublishableKeyForm
(12-163)
apps/web/app/(ee)/api/track/sale/client/route.ts (4)
apps/web/lib/auth/publishable-key.ts (1)
withPublishableKey
(22-114)apps/web/lib/zod/schemas/sales.ts (1)
trackSaleRequestSchema
(7-66)apps/web/lib/api/errors.ts (1)
DubApiError
(75-92)apps/web/lib/api/conversions/track-sale.ts (1)
trackSale
(28-286)
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/analytics/publishable-key-form.tsx (2)
apps/web/lib/swr/use-workspace.ts (1)
useWorkspace
(6-45)apps/web/ui/modals/confirm-modal.tsx (1)
useConfirmModal
(71-84)
apps/web/app/api/workspaces/[idOrSlug]/route.ts (1)
apps/web/lib/zod/schemas/workspaces.ts (1)
updateWorkspaceSchema
(148-151)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
- GitHub Check: build
🔇 Additional comments (7)
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/analytics/allowed-hostnames-form.tsx (1)
11-42
: Rename to AllowedHostnamesForm improves clarity and aligns with usageComponent rename/export looks good and matches the updated imports in analytics/page.tsx.
apps/web/app/(ee)/api/track/sale/client/route.ts (1)
8-56
: Overall route flow LGTMwithPublishableKey wrapper, plan gating, body parsing, schema validation, and handoff to trackSale are consistent with the lead route and error handling patterns.
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/analytics/page.tsx (1)
8-10
: Nice composition of analytics settingsGood ordering: hostnames, publishable key, then conversion toggle.
apps/web/app/api/workspaces/[idOrSlug]/route.ts (1)
77-101
: LGTM on allowedHostnames handling and cache syncValidation + cache updates remain intact; the new field inclusion is isolated and does not affect these paths.
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/analytics/publishable-key-form.tsx (2)
72-91
: Modal usage looks good.Clear copy, destructive confirmations in place, and actions wired to the handlers. No issues.
121-149
: Nice UX touches.Displaying the key with truncation + a CopyButton, and offering regenerate/revoke paths is straightforward and intuitive.
apps/web/lib/zod/schemas/workspaces.ts (1)
1-189
: Overall schema updates align with the new feature.Good separation between base and extended workspace schemas; allowing
null
/undefined
on updates matches revoke and generate flows.
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/analytics/page.tsx
Outdated
Show resolved
Hide resolved
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/analytics/publishable-key-form.tsx
Show resolved
Hide resolved
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/analytics/publishable-key-form.tsx
Show resolved
Hide resolved
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/analytics/publishable-key-form.tsx
Show resolved
Hide resolved
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/analytics/publishable-key-form.tsx
Show resolved
Hide resolved
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/analytics/publishable-key-form.tsx
Show resolved
Hide resolved
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 0
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (5)
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/analytics/allowed-hostnames-form.tsx (5)
56-62
: Wildcard validation currently accepts any string starting with "*." (including invalid ones).
isValidHostname
returns true for values like"*."
or"*.bad host"
. This can persist invalid entries in settings and break matching logic downstream.Apply:
- const isValidHostname = (hostname: string) => { - return ( - validDomainRegex.test(hostname) || - hostname === "localhost" || - hostname.startsWith("*.") - ); - }; + const isValidHostname = (hostname: string) => { + const h = hostname.toLowerCase(); + if (h === "localhost") return true; + if (h.startsWith("*.")) { + const rest = h.slice(2); + return rest.length > 0 && validDomainRegex.test(rest); + } + return validDomainRegex.test(h); + };
64-97
: Normalize input, compare case-insensitively, and harden network error handling.Prevents duplicates due to casing/whitespace, improves UX on network errors, and sets the field back only on success.
Apply:
- const addHostname = async () => { - if (allowedHostnames?.includes(hostname)) { + const addHostname = async () => { + const input = hostname.trim().toLowerCase(); + if (allowedHostnames?.some((h) => h.toLowerCase() === input)) { toast.error("Hostname already exists."); return; } - if (!isValidHostname(hostname)) { + if (!isValidHostname(input)) { toast.error("Enter a valid domain."); return; } setProcessing(true); - const response = await fetch(`/api/workspaces/${id}`, { - method: "PATCH", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - allowedHostnames: [...(allowedHostnames || []), hostname], - }), - }); - - if (response.ok) { - toast.success("Hostname added."); - } else { - const { error } = await response.json(); - toast.error(error.message); - } - - mutate(); - setProcessing(false); - setHostname(""); + try { + const response = await fetch(`/api/workspaces/${id}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + allowedHostnames: [...(allowedHostnames || []), input], + }), + }); + + if (response.ok) { + toast.success("Hostname added."); + setHostname(""); + } else { + const { error } = await response.json().catch(() => ({} as any)); + toast.error(error?.message ?? "Failed to add hostname."); + } + } catch { + toast.error("Network error. Please try again."); + } finally { + mutate(); + setProcessing(false); + } };
121-129
: Avoid double-submit and enforce permissions/processing on the Add button.Rely on the form’s onSubmit; disable when processing or lacking permission. This prevents duplicate PATCH calls and aligns the disabled tooltip with actual disabled state.
Apply:
<Button text="Add Hostname" variant="primary" - onClick={addHostname} - disabled={!isValidHostname(hostname) || hostname.length === 0} + type="submit" + disabled={ + !isValidHostname(hostname) || + hostname.length === 0 || + !!permissionsError || + processing + } loading={processing} - className="w-fit" + className="w-fit" disabledTooltip={permissionsError || undefined} />
144-166
: Harden delete flow with try/catch/finally and avoid sending undefined array.Ensures
mutate()
and state cleanup always run, and avoidsallowedHostnames: undefined
when SWR cache is momentarily empty.Apply:
const handleDeleteHostname = async () => { setProcessing(true); - const response = await fetch(`/api/workspaces/${id}`, { - method: "PATCH", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - allowedHostnames: allowedHostnames?.filter((h) => h !== hostname), - }), - }); - - if (response.ok) { - toast.success("Hostname deleted."); - } else { - const { error } = await response.json(); - toast.error(error.message); - } - - mutate(); - setProcessing(false); + try { + const response = await fetch(`/api/workspaces/${id}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + allowedHostnames: (allowedHostnames || []).filter((h) => h !== hostname), + }), + }); + + if (response.ok) { + toast.success("Hostname deleted."); + } else { + const { error } = await response.json().catch(() => ({} as any)); + toast.error(error?.message ?? "Failed to delete hostname."); + } + } catch { + toast.error("Network error. Please try again."); + } finally { + mutate(); + setProcessing(false); + } };
191-207
: Disable Delete button on insufficient permissions or while processing; add accessible label.Prevents opening the modal when the action isn’t allowed and improves a11y for icon-only buttons.
Apply:
<Button variant="outline" className={cn( "h-8 px-1.5 outline-none transition-all duration-200", "border-transparent data-[state=open]:border-neutral-500 sm:group-hover/card:data-[state=closed]:border-neutral-200", )} icon={ processing ? ( <LoadingSpinner className="size-4 shrink-0" /> ) : ( <Trash className="size-4" /> ) } - onClick={() => setShowConfirmModal(true)} + onClick={() => setShowConfirmModal(true)} + disabled={!!permissionsError || processing} + aria-label="Delete hostname" disabledTooltip={permissionsError || undefined} />Also applies to: 205-205
🧹 Nitpick comments (2)
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/analytics/allowed-hostnames-form.tsx (2)
168-174
: Optional: render destructive styling for the confirm action (if supported).If
ConfirmModal
supports a destructive variant for the confirm button, consider enabling it to better signal risk.Example (adjust to actual prop name):
useConfirmModal({ // ... confirmVariant: "destructive", });
176-178
: Redundantkey
prop inside card.The list key lives on the parent mapping (
<AllowedHostnameCard key={hostname} />
). The innerkey
here has no effect and can be removed for clarity.Apply:
- <CardList.Card - key={hostname} + <CardList.Card
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
💡 Knowledge Base configuration:
- MCP integration is disabled by default for public repositories
- Jira integration is disabled by default for public repositories
- Linear integration is disabled by default for public repositories
You can enable these sources in your CodeRabbit configuration.
📒 Files selected for processing (2)
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/analytics/allowed-hostnames-form.tsx
(5 hunks)apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/analytics/publishable-key-form.tsx
(1 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
- apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/analytics/publishable-key-form.tsx
🧰 Additional context used
🧬 Code Graph Analysis (1)
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/analytics/allowed-hostnames-form.tsx (1)
apps/web/ui/modals/confirm-modal.tsx (1)
useConfirmModal
(71-84)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
- GitHub Check: build
🔇 Additional comments (4)
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/analytics/allowed-hostnames-form.tsx (4)
5-5
: Confirm modal hook import looks good.Importing
useConfirmModal
aligns with the new confirmation UX and simplifies local state handling.
168-174
: Confirm modal integration reads well.Good use of the shared
useConfirmModal
hook and clear copy for a destructive action.
209-210
: Rendering theconfirmModal
near the card is fine.Keeps modal logic local to the card and avoids prop drilling.
12-13
: Component rename verified – all imports updatedI ran a project-wide search and confirmed there are no remaining imports or references to the old
AllowedHostnames
identifier. The only import of the new component is in:
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/analytics/page.tsx:2
import { AllowedHostnamesForm } from "./allowed-hostnames-form";
Everything’s up to date.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 2
🧹 Nitpick comments (9)
apps/web/lib/api/cors.ts (1)
1-5
: Make headers immutable and add a dynamic preflight helper (+ Vary/Max-Age).This prevents accidental mutation and makes OPTIONS robust by reflecting Access-Control-Request-Headers. Also add Vary to keep caches honest and Access-Control-Max-Age to cut preflight noise.
Apply this diff for immutability:
-export const COMMON_CORS_HEADERS = { +export const COMMON_CORS_HEADERS: Readonly<Record<string, string>> = Object.freeze({ "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Methods": "POST, OPTIONS", "Access-Control-Allow-Headers": "Content-Type, Authorization, X-Dub-Publishable-Key, X-Requested-With", -}; +});Add this helper (outside the changed lines; place below the constant):
// Build preflight headers that reflect requested headers to avoid CORS rejections. export function preflightHeaders(req: Request): Record<string, string> { const requested = req.headers.get("Access-Control-Request-Headers") ?? ""; return { ...COMMON_CORS_HEADERS, ...(requested && { "Access-Control-Allow-Headers": requested }), "Access-Control-Max-Age": "600", Vary: "Origin, Access-Control-Request-Headers, Access-Control-Request-Method", }; }apps/web/app/(ee)/api/track/open/route.ts (1)
1-1
: Use dynamic preflight headers to mirror requested headers and add Vary/Max-Age.Hardcoding Access-Control-Allow-Headers can break some clients. Mirror the Access-Control-Request-Headers and expose proper Vary/Max-Age using a helper.
Apply:
-import { COMMON_CORS_HEADERS } from "@/lib/api/cors"; +import { COMMON_CORS_HEADERS, preflightHeaders } from "@/lib/api/cors";-export const OPTIONS = () => { - return new Response(null, { - status: 204, - headers: COMMON_CORS_HEADERS, - }); -}; +export const OPTIONS = (req: Request) => + new Response(null, { + status: 204, + headers: preflightHeaders(req), + });Also applies to: 138-143
apps/web/app/(ee)/api/shopify/pixel/route.ts (1)
1-1
: Prefer dynamic preflight to accommodate Shopify/browser-added headers.Some storefront setups include additional headers; mirroring requested headers prevents brittle CORS failures.
Apply:
-import { COMMON_CORS_HEADERS } from "@/lib/api/cors"; +import { COMMON_CORS_HEADERS, preflightHeaders } from "@/lib/api/cors";-export const OPTIONS = () => { - return new Response(null, { - status: 204, - headers: COMMON_CORS_HEADERS, - }); -}; +export const OPTIONS = (req: Request) => + new Response(null, { + status: 204, + headers: preflightHeaders(req), + });Also applies to: 58-63
apps/web/app/(ee)/api/track/click/route.ts (1)
6-6
: Align OPTIONS with dynamic preflight; optionally narrow Allow-Origin post-allowlist.
- Dynamic preflight avoids header mismatches.
- Optional: after verifyAnalyticsAllowedHostnames succeeds, consider echoing the request Origin instead of "*" to tighten CORS posture for allowed hosts (logic stays the same; just a response header tweak).
Apply dynamic preflight:
-import { COMMON_CORS_HEADERS } from "@/lib/api/cors"; +import { COMMON_CORS_HEADERS, preflightHeaders } from "@/lib/api/cors";-export const OPTIONS = () => { - return new Response(null, { - status: 204, - headers: COMMON_CORS_HEADERS, - }); -}; +export const OPTIONS = (req: Request) => + new Response(null, { + status: 204, + headers: preflightHeaders(req), + });If you want to scope Allow-Origin after allowlist verification, I can draft a small helper to conditionally set it based on the Origin header.
Also applies to: 170-175
apps/web/app/api/oauth/token/route.ts (2)
1-1
: Use dynamic preflight for OPTIONS to reflect requested headers.Same rationale as other routes; safer for diverse clients and future header additions.
Apply:
-import { COMMON_CORS_HEADERS } from "@/lib/api/cors"; +import { COMMON_CORS_HEADERS, preflightHeaders } from "@/lib/api/cors";-export const OPTIONS = () => { - return new Response(null, { - status: 204, - headers: COMMON_CORS_HEADERS, - }); -}; +export const OPTIONS = (req: Request) => + new Response(null, { + status: 204, + headers: preflightHeaders(req), + });Also applies to: 32-37
19-21
: Double-check CORS exposure for the token endpoint fits your threat model.Allow-Origin: "*" on a token endpoint is a product decision. If tokens are intended to be retrievable by browsers from arbitrary origins (e.g., SPA + PKCE), this is fine. If not, consider restricting origins or disabling CORS here.
I can draft an Origin-allowlist solution keyed off environment config if you’d like.
Also applies to: 24-26
apps/web/app/(ee)/api/track/visit/route.ts (3)
2-2
: CORS centralization is good; double-check allowed headers for publishable-key flowsImporting
COMMON_CORS_HEADERS
reduces drift—nice. One caution: If client endpoints authenticated via a “publishable key” use a custom header (e.g.,X-Dub-Publishable-Key
), ensureCOMMON_CORS_HEADERS["Access-Control-Allow-Headers"]
includes it; otherwise, browsers will block cross-origin requests at preflight.Proposed update in
apps/web/lib/api/cors.ts
:export const COMMON_CORS_HEADERS = { "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Methods": "POST, OPTIONS", - "Access-Control-Allow-Headers": "Content-Type, Authorization", + "Access-Control-Allow-Headers": "Content-Type, Authorization, X-Dub-Publishable-Key", + // Optional: reduce preflight chatter across busy tracking endpoints + "Access-Control-Max-Age": "86400", };If the publishable key is carried via
Authorization: Bearer ...
, this change isn’t strictly required—please confirm whatwithPublishableKey
expects.
107-108
: Final JSON response uses common CORS — LGTMConsistent header application on the success path. Consider adding a Cache-Control header if you ever want clients/CDNs to cache this short-lived response, but not required.
118-119
: OPTIONS 204 with common CORS — LGTM; consider max-agePreflight handling is correct. If you adopt
Access-Control-Max-Age
inCOMMON_CORS_HEADERS
, this OPTIONS response will benefit automatically.
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
💡 Knowledge Base configuration:
- MCP integration is disabled by default for public repositories
- Jira integration is disabled by default for public repositories
- Linear integration is disabled by default for public repositories
You can enable these sources in your CodeRabbit configuration.
📒 Files selected for processing (9)
apps/web/app/(ee)/api/shopify/pixel/route.ts
(2 hunks)apps/web/app/(ee)/api/track/click/route.ts
(2 hunks)apps/web/app/(ee)/api/track/lead/client/route.ts
(1 hunks)apps/web/app/(ee)/api/track/lead/route.ts
(1 hunks)apps/web/app/(ee)/api/track/open/route.ts
(4 hunks)apps/web/app/(ee)/api/track/sale/client/route.ts
(1 hunks)apps/web/app/(ee)/api/track/visit/route.ts
(3 hunks)apps/web/app/api/oauth/token/route.ts
(2 hunks)apps/web/lib/api/cors.ts
(1 hunks)
🚧 Files skipped from review as they are similar to previous changes (2)
- apps/web/app/(ee)/api/track/sale/client/route.ts
- apps/web/app/(ee)/api/track/lead/client/route.ts
🧰 Additional context used
🧬 Code Graph Analysis (5)
apps/web/app/(ee)/api/track/click/route.ts (2)
apps/web/lib/api/cors.ts (1)
COMMON_CORS_HEADERS
(1-5)apps/web/lib/api/errors.ts (1)
handleAndReturnErrorResponse
(175-181)
apps/web/app/(ee)/api/track/visit/route.ts (2)
apps/web/lib/api/cors.ts (1)
COMMON_CORS_HEADERS
(1-5)apps/web/lib/api/errors.ts (1)
handleAndReturnErrorResponse
(175-181)
apps/web/app/(ee)/api/track/open/route.ts (2)
apps/web/lib/api/cors.ts (1)
COMMON_CORS_HEADERS
(1-5)apps/web/lib/api/errors.ts (1)
handleAndReturnErrorResponse
(175-181)
apps/web/app/api/oauth/token/route.ts (2)
apps/web/lib/api/cors.ts (1)
COMMON_CORS_HEADERS
(1-5)apps/web/lib/api/errors.ts (1)
handleAndReturnErrorResponse
(175-181)
apps/web/app/(ee)/api/shopify/pixel/route.ts (2)
apps/web/lib/api/cors.ts (1)
COMMON_CORS_HEADERS
(1-5)apps/web/lib/api/errors.ts (1)
handleAndReturnErrorResponse
(175-181)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
- GitHub Check: build
🔇 Additional comments (8)
apps/web/lib/api/cors.ts (1)
1-5
: Good centralization of CORS defaults.A single source of truth reduces drift across routes. Naming and placement look good.
apps/web/app/(ee)/api/track/open/route.ts (1)
49-51
: LGTM: migrated to COMMON_CORS_HEADERS consistently.Responses and error-paths now use the shared headers; OPTIONS returns expected headers. Behavior parity retained.
Also applies to: 61-62, 132-135, 141-142
apps/web/app/(ee)/api/shopify/pixel/route.ts (1)
51-52
: LGTM: swapped to COMMON_CORS_HEADERS across success, error, and OPTIONS.Change is scoped and consistent; no behavioral regressions expected.
Also applies to: 55-55, 61-62
apps/web/app/(ee)/api/track/click/route.ts (1)
6-6
: LGTM: consolidated CORS usage without touching business logic.Adoption is uniform; error responses still include CORS headers. Good.
Also applies to: 164-165, 166-167, 173-174
apps/web/app/api/oauth/token/route.ts (1)
1-1
: LGTM on centralization; token flow unchanged.Swapping to COMMON_CORS_HEADERS is consistent across both grant paths, errors, and OPTIONS.
Also applies to: 19-21, 25-26, 28-29, 35-36
apps/web/app/(ee)/api/track/lead/route.ts (1)
24-24
: Ensuremode
optionality and default behavior downstream
- The
trackLeadRequestSchema
inapps/web/lib/zod/schemas/leads.ts
does not declare amode
field, so requests may arrive withmode = undefined
. If you intend formode
to be optional or have a default, update the schema (e.g.,.optional()
or.default(...)
) to match expectations.- The
trackLead
call inapps/web/app/(ee)/api/track/lead/route.ts
resolves to an external implementation (no local definition). Please confirm that this function:
- Accepts an undefined
mode
without throwing- Applies the correct default or branches safely when
mode
is omitted- Add or update tests to cover both scenarios:
- An explicit
mode
value- Omitted
mode
(i.e. relying on the default)Please verify these points in your schema and downstream integration to ensure deterministic behavior.
apps/web/app/(ee)/api/track/visit/route.ts (2)
45-46
: Returning cachedclickId
with CORS headers — LGTMUsing
COMMON_CORS_HEADERS
here keeps cached and non-cached responses consistent for the browser. No further action.
111-112
: Error path includes CORS headers — LGTMPassing headers into
handleAndReturnErrorResponse
avoids CORS surprises on failures. Good catch.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 0
♻️ Duplicate comments (3)
apps/web/app/(ee)/api/track/lead/client/route.ts (3)
56-56
: Propagate rate limit headers on success (see sale route comment)Mirror the middleware-level fix so clients can read remaining quota. No action needed in this file if publishable-key middleware merges headers as suggested.
59-66
: Avoid plan list duplication; centralize allowed plansSame refactor as previously suggested; import from a single constants module.
6-6
: Add Vary header in COMMON_CORS_HEADERS (shared)As noted in the sale route, add Vary: Origin, Authorization to the shared CORS headers to make client routes safe around caches.
🧹 Nitpick comments (6)
apps/web/app/(ee)/api/track/sale/client/route.ts (6)
30-39
: Prefer const for parsed request propsThese values aren’t reassigned. Use const for clarity and to prevent accidental mutation.
- let { + const { customerExternalId, paymentProcessor, invoiceId, amount, currency, metadata, eventName, leadEventName, } = trackSaleRequestSchema.parse(body);
6-6
: Add Vary: Origin, Authorization to CORS headers to prevent cache key collisionsSince Authorization carries the publishable key (per learning) and Origin affects allowlisting, add Vary to avoid cross-tenant leakage via intermediary caches/CDNs. Optional: add Access-Control-Max-Age to reduce preflight cost.
apps/web/lib/api/cors.ts:
export const COMMON_CORS_HEADERS = { "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Methods": "POST, OPTIONS", "Access-Control-Allow-Headers": "Content-Type, Authorization", + "Vary": "Origin, Authorization", + "Access-Control-Max-Age": "600", };Note: This aligns with the publishable key sent via Authorization: Bearer dub_pk_… as captured in the retrieved learnings.
24-27
: Avoid hard-coding app.dub.co; centralize app originUse a single source of truth (e.g., APP_ORIGIN from config/constants) to build links in error messages. This prevents environment-specific leaks (staging, self-hosted) and eases future moves.
Example:
- message: `Request origin '${getHostnameFromRequest(req)}' is not included in the allowed hostnames for this workspace. Update your allowed hostnames here: https://app.dub.co/settings/analytics`, + message: `Request origin '${getHostnameFromRequest(req)}' is not included in the allowed hostnames for this workspace. Update your allowed hostnames here: ${APP_ORIGIN}/settings/analytics`,
64-71
: Avoid plan list duplication; centralize allowed plansSame list appears in multiple routes and defaults in middleware. Move to a shared constant (e.g., "@/lib/plans") to prevent drift and enable normalization (e.g., lowercasing) in one place.
61-61
: Add rate‐limit headers to successful responses in withPublishableKeyCurrently, withPublishableKey only applies the computed X-RateLimit-* and Retry-After headers when returning a 429 response. On successful requests the wrapper returns the handler’s Response (or object) without those headers, so downstream routes cannot inspect remaining quota.
To address this, update apps/web/lib/auth/publishable-key.ts to merge the headers object onto all successful handler results:
• File: apps/web/lib/auth/publishable-key.ts
– After calling the handler, detect if the returned value is a Response and set each header.
– Otherwise, wrap non-Response returns in a new Response with the headers applied.Illustrative diff:
--- a/apps/web/lib/auth/publishable-key.ts +++ b/apps/web/lib/auth/publishable-key.ts @@ after line ~70 - const searchParams = getSearchParams(req.url); - return await handler({ req, params, searchParams, workspace }); + const searchParams = getSearchParams(req.url); + // Invoke handler and propagate rate-limit headers on all responses + const result = await handler({ req, params, searchParams, workspace }); + // If it's already a Response, merge headers + if (result instanceof Response) { + for (const [key, value] of Object.entries(headers)) { + result.headers.set(key, value as string); + } + return result; + } + // Otherwise wrap the response data + return new Response(JSON.stringify(result), { + status: 200, + headers: { + "content-type": "application/json", + ...headers, + }, + });This change ensures all successful endpoints receive consistent rate-limit metadata without each route needing to handle it.
41-46
: Centralize non-emptycustomerExternalId
validation in your Zod schema and remove the runtime guardTo avoid divergent error messages and duplicated checks, move the “required and non-empty” validation into
trackSaleRequestSchema
and drop the manual guard in the route.• In
apps/web/lib/zod/schemas/sales.ts
, update thecustomerExternalId
definition:- customerExternalId: z - .string() - .trim() - .max(100) + customerExternalId: z + .string({ required_error: "customerExternalId is required" }) + .trim() + .min(1, "customerExternalId is required") + .max(100)• In
apps/web/app/(ee)/api/track/sale/client/route.ts
, remove the manual guard at lines 41–46:- if (!customerExternalId) { - throw new DubApiError({ - code: "bad_request", - message: "customerExternalId is required", - }); - }This ensures all “required and non-empty” logic lives in one place and yields consistent error messages.
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
💡 Knowledge Base configuration:
- MCP integration is disabled by default for public repositories
- Jira integration is disabled by default for public repositories
- Linear integration is disabled by default for public repositories
You can enable these sources in your CodeRabbit configuration.
📒 Files selected for processing (3)
apps/web/app/(ee)/api/track/lead/client/route.ts
(1 hunks)apps/web/app/(ee)/api/track/sale/client/route.ts
(1 hunks)apps/web/lib/analytics/verify-analytics-allowed-hostnames.ts
(2 hunks)
✅ Files skipped from review due to trivial changes (1)
- apps/web/lib/analytics/verify-analytics-allowed-hostnames.ts
🧰 Additional context used
🧠 Learnings (1)
📓 Common learnings
Learnt from: steven-tey
PR: dubinc/dub#2737
File: apps/web/lib/api/cors.ts:1-5
Timestamp: 2025-08-21T03:03:39.825Z
Learning: Dub publishable keys are sent via Authorization header using Bearer token format, not via custom X-Dub-Publishable-Key header. The publishable key middleware extracts keys using req.headers.get("Authorization")?.replace("Bearer ", "") and validates they start with "dub_pk_".
🧬 Code Graph Analysis (2)
apps/web/app/(ee)/api/track/lead/client/route.ts (6)
apps/web/lib/auth/publishable-key.ts (1)
withPublishableKey
(22-113)apps/web/lib/analytics/verify-analytics-allowed-hostnames.ts (2)
verifyAnalyticsAllowedHostnames
(13-59)getHostnameFromRequest
(1-11)apps/web/lib/api/errors.ts (1)
DubApiError
(75-92)apps/web/lib/zod/schemas/leads.ts (1)
trackLeadRequestSchema
(7-73)apps/web/lib/api/conversions/track-lead.ts (1)
trackLead
(28-316)apps/web/lib/api/cors.ts (1)
COMMON_CORS_HEADERS
(1-5)
apps/web/app/(ee)/api/track/sale/client/route.ts (7)
apps/web/app/(ee)/api/track/lead/client/route.ts (2)
POST
(14-68)OPTIONS
(70-75)apps/web/lib/auth/publishable-key.ts (1)
withPublishableKey
(22-113)apps/web/lib/analytics/verify-analytics-allowed-hostnames.ts (2)
verifyAnalyticsAllowedHostnames
(13-59)getHostnameFromRequest
(1-11)apps/web/lib/api/errors.ts (1)
DubApiError
(75-92)apps/web/lib/zod/schemas/sales.ts (1)
trackSaleRequestSchema
(7-66)apps/web/lib/api/conversions/track-sale.ts (1)
trackSale
(28-286)apps/web/lib/api/cors.ts (1)
COMMON_CORS_HEADERS
(1-5)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
- GitHub Check: build
🔇 Additional comments (4)
apps/web/app/(ee)/api/track/sale/client/route.ts (2)
18-28
: Origin allowlisting is enforced correctlyGood job guarding client-side tracking behind verifyAnalyticsAllowedHostnames and returning a clear, actionable error via DubApiError with the offending origin in the message.
75-80
: OPTIONS handler for CORS preflight looks goodResponding with 204 and COMMON_CORS_HEADERS is correct and consistent with other routes.
apps/web/app/(ee)/api/track/lead/client/route.ts (2)
18-28
: Origin allowlisting is enforced correctlyConsistent with sale route; clear error message and early exit on mismatch.
70-75
: OPTIONS handler for CORS preflight looks good204 + COMMON_CORS_HEADERS is consistent and correct.
Summary by CodeRabbit
New Features
API
UI
Database
Schema/Types
Bug Fixes