Skip to content

feat(paywall): gate relay + automations + remote workspaces, dither preview - #3850

Merged
saddlepaddle merged 6 commits into
mainfrom
paywall-relay-dither-ui
Apr 29, 2026
Merged

saddlepaddle merged 6 commits into
mainfrom
paywall-relay-dither-ui

Conversation

@saddlepaddle

@saddlepaddle saddlepaddle commented Apr 29, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

  • UI gates for three Pro features that previously leaked to free users:
    • Relay toggle in Settings → Security (on-toggle only; off stays ungated and keeps the new on/off confirm dialog from [codex] warn before relay toggle restarts host service #3845).
    • Automations sidebar entry is now visible to free users with paywall on click + direct-URL guard via a new automations/layout.tsx.
    • Remote/cloud rows in the v2-workspaces list — open + add-to-sidebar gated. Local rows unaffected. The workspace page itself is intentionally not gated (avoid accidentally locking users out of local workspaces during weird states); server-side host.checkAccess already refuses unpaid tunnels.
  • Server-side defense, zero new round-trips: findOrgMembership LEFT JOINs subscriptions so plan checks ride the existing membership read. New requireActiveOrgMembershipWithSubscription sibling, used in automation.create / setEnabled / runNow. host.checkAccess returns { allowed, paidPlan } (relay's 5-min LRU caches the combined boolean — no per-request API spam). dispatchAutomation joins subscriptions into resolveTargetHost and short-circuits unpaid runs with a recorded skip.
  • Paywall preview refresh: swaps MeshGradient for a DitheredBackground (shaders-react Dithering shape="warp" type="4x4") ported from marketing. Adds AutomationsDemo + RemoteWorkspacesDemo, drops obsolete CloudWorkspacesDemo/IntegrationsDemo. Reorders PRO_FEATURES to remote-workspaces → automations → team-collaboration → tasks → mobile-app, tightens the remote-workspaces copy so feature-switching no longer shifts the modal height.

Test plan

  • Free org — sidebar visibility: Automations entry is visible. Click → paywall opens preselected to "automations". Cancel returns. Upgrade → /settings/billing/plans.
  • Free org — relay toggle: flip Settings → Security toggle on → paywall (preselected "remote-workspaces"); switch stays off, no confirm dialog. Toggling off shows the existing confirm.
  • Free org — direct URL: navigate to /automations and /automations/<id> → paywall + redirect to /v2-workspaces.
  • Free org — workspaces list: clicking a remote-device or cloud row → paywall. Clicking the + (add to sidebar) on those rows → paywall. Local rows still open + add instantly.
  • Free org — direct workspace URL: pasting a remote /v2-workspace/<id> does not open the paywall (intentional). Page renders but host data fails to load (relay refuses tunnel).
  • Paid org: all flows work as today; no paywall, automations work, relay confirm appears as before.
  • Server: with a free-org JWT, host.checkAccess returns paidPlan: false; automation.create returns 403; QStash dispatch for a free org records a skipped run before relay/host calls.
  • Query count: confirm automation.create and host.checkAccess issue the same number of round-trips before/after this change (subscription join rides the existing membership/access query).
  • Relay no-spam: tunnel up → host.checkAccess fires at most once per 5 min per (token, hostId).
  • Visual: open paywall on each feature, switch between them, verify dither cross-fades smoothly and demo cards stay readable; modal height stays steady across selections.
  • bun run lint:fix && bun run typecheck clean.

Summary by cubic

Gates relay, automations, and remote workspaces behind Pro, updates the paywall preview with a dithered background, and filters unpaid automations before enqueue; dispatch still bails without writing runs. Standardizes active-subscription checks using ACTIVE_SUBSCRIPTION_STATUSES.

  • New Features

    • UI gates: relay “on” toggle; Automations entry with click paywall + direct-route guard; remote/cloud workspace row open/add; local rows stay open; workspace page remains ungated.
    • Paywall preview: swapped to a dithered background from @paper-design/shaders-react; added Automations and Remote Workspaces demos; removed obsolete demos; modal height stays steady.
    • Constants/UI: gated IDs updated to automations and remote-workspaces; integrations settings open directly for all.
  • Refactors

    • Membership helpers: added findOrgMembershipWithSubscription and requireActiveOrgMembershipWithSubscription (select newest active/trialing subscription).
    • Automations: create, setEnabled (when enabling), and runNow require a paid plan; the evaluate cron only includes paid orgs; dispatch short-circuits unpaid orgs with skipped_unpaid and no run row.
    • Host/relay: host.checkAccess returns { allowed, paidPlan }; relay requires both and caches for 5 minutes per (token, hostId).
    • Billing: all subscription joins use ACTIVE_SUBSCRIPTION_STATUSES for consistent “active” checks.

Written for commit cd3a9a7. Summary will update on new commits. Review in cubic

Summary by CodeRabbit

Release Notes

  • New Features

    • Automations: New premium feature enabling you to schedule and automatically execute workflows across your workspace
    • Remote Workspaces: New premium feature allowing secure connections to and management of remote workspace machines
    • Integrations now available on all subscription plans
  • Chores

    • Updated paywall feature previews with new visual designs and improved presentation
    • Removed Cloud Workspaces feature preview from paywall

…review

Tightens the Pro paywall around three free-leaking surfaces and refreshes
the paywall preview shader.

UI gates
- Relay (Settings → Security): turning on "Allow remote workspaces to
  access this device" now goes through gateFeature(REMOTE_WORKSPACES).
  Off-toggling stays ungated and keeps the new on/off confirm dialog.
- Automations: sidebar entry is visible to free users (was hidden); click
  routes through gateFeature(AUTOMATIONS). Direct URLs to /automations
  redirect via a new layout-level guard.
- Remote / cloud workspaces in the v2-workspaces list: open + add-to-sidebar
  on remote-device / cloud rows are gated. Local rows are unaffected. The
  workspace page itself is intentionally not gated — server-side
  host.checkAccess already returns paidPlan: false, so unpaid users hit a
  data-layer block instead of an accidental modal lock-out.

Server-side defense (zero new round-trips)
- findOrgMembership now LEFT JOINs subscriptions in the same statement, so
  callers gating on plan don't pay an extra query. Adds
  requireActiveOrgMembershipWithSubscription sibling.
- automation.create / setEnabled / runNow refuse non-paid orgs.
- host.checkAccess returns { allowed, paidPlan }; relay's existing
  5-min LRU caches the combined boolean — no per-request API spam.
- dispatchAutomation joins subscriptions into resolveTargetHost and
  short-circuits unpaid runs with a recorded skip.

Paywall preview
- Replaces MeshGradient with a DitheredBackground (shaders-react Dithering
  warp/4x4) ported from marketing, matching the new visual direction.
- Adds AutomationsDemo + RemoteWorkspacesDemo; drops obsolete
  CloudWorkspacesDemo / IntegrationsDemo.
- Reorders PRO_FEATURES to remote-workspaces → automations →
  team-collaboration → tasks → mobile-app and tightens the
  remote-workspaces description so feature-switching no longer shifts
  the modal height.
@coderabbitai

coderabbitai Bot commented Apr 29, 2026 •

Copy link
Copy Markdown
Contributor

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

The changes introduce paywall gating for automations and remote workspaces features by replacing UI demo components, updating feature constants, and enforcing subscription/paid plan verification throughout the frontend and backend layers. Demo components swap from integrations/cloud-workspaces to automations/remote-workspaces, with corresponding database queries, TRPC endpoints, and API routes now validating paid plan status.

Changes

Cohort / File(s) Summary
Feature Preview Components
apps/desktop/src/renderer/components/Paywall/components/FeaturePreview/FeaturePreview.tsx, apps/desktop/src/renderer/components/Paywall/components/FeaturePreview/components/DitheredBackground/..., apps/desktop/src/renderer/components/Paywall/components/FeaturePreview/components/AutomationsDemo/..., apps/desktop/src/renderer/components/Paywall/components/FeaturePreview/components/RemoteWorkspacesDemo/..., apps/desktop/src/renderer/components/Paywall/components/FeaturePreview/components/IntegrationsDemo/..., apps/desktop/src/renderer/components/Paywall/components/FeaturePreview/components/CloudWorkspacesDemo/...
Replaces MeshGradient with DitheredBackground component. Removes IntegrationsDemo and CloudWorkspacesDemo demo components; adds AutomationsDemo and RemoteWorkspacesDemo with new UI previews. Updates preview container styling with dark background.
Paywall Constants & Routing
apps/desktop/src/renderer/components/Paywall/constants.ts, apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/layout.tsx
Updates gated features from integrations/cloud-workspaces to automations/remote-workspaces with new icons and descriptions. Adds new automations route with paywall gating enforcement via usePaywall() and feature access checks.
Dashboard UI Integration
apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarHeader/DashboardSidebarHeader.tsx, apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacesList/components/V2WorkspaceRow/V2WorkspaceRow.tsx
Adds feature-flag and paywall gating for automations and remote workspaces navigation. Conditionally gates remote workspace actions based on workspace hostType and subscription status.
Integration Settings
apps/desktop/src/renderer/routes/_authenticated/settings/integrations/components/IntegrationsSettings/IntegrationsSettings.tsx
Removes paywall gating from integrations settings, allowing direct web URL opens without feature gate checks.
Security Settings
apps/desktop/src/renderer/routes/_authenticated/settings/security/components/SecuritySettings/SecuritySettings.tsx
Adds paywall gating to remote workspace security setting toggle via usePaywall(), blocking enablement without paid plan.
Database Utilities
packages/db/src/utils/membership.ts
Adds findOrgMembershipWithSubscription function that joins membership with organization's active/trialing subscriptions. Introduces OrgMembershipWithSubscription type for paired membership and subscription data.
Backend Access Control
packages/trpc/src/router/utils/active-org.ts, packages/trpc/src/router/integration/utils.ts
Adds requireActiveOrgMembershipWithSubscription helper that retrieves organization membership with subscription. Adds verifyOrgMembershipWithSubscription utility for membership validation with subscription lookup.
Automation TRPC Endpoints
packages/trpc/src/router/automation/automation.ts, packages/trpc/src/router/automation/dispatch.ts
Enforces paid/active subscription checks on automation create, enable, and run operations. Adds skipped_unpaid outcome when dispatching automations for unpaid organizations. Updates resolveTargetHost to compute paidPlan flag via subscription left-join.
Host Access Endpoint
packages/trpc/src/router/host/host.ts
Extends checkAccess to return paidPlan boolean alongside allowed by joining subscriptions and validating paid plan status.
Automation Evaluation API
apps/api/src/app/api/automations/evaluate/route.ts
Updates automation evaluation query to exclude automations from organizations without active/trialing paid subscriptions via EXISTS subquery.
Relay Access Control
apps/relay/src/access.ts
Updates checkHostAccess to require both result.allowed and result.paidPlan to be true for access verification.

Sequence Diagram

sequenceDiagram
    participant User as User
    participant UI as Frontend UI
    participant TRPC as TRPC Backend
    participant DB as Database
    
    rect rgba(100, 150, 200, 0.5)
    Note over User,DB: Accessing Automations Feature
    
    User->>UI: Navigate to automations
    UI->>TRPC: Call automations route with user context
    TRPC->>DB: requireActiveOrgMembershipWithSubscription(userId, orgId)
    DB-->>TRPC: Return membership + subscription
    
    alt Subscription is active/paid
        TRPC->>UI: Route allowed, render layout
        UI-->>User: Display automations content
    else Subscription missing/inactive/free
        TRPC-->>UI: Return FORBIDDEN error
        UI->>User: Redirect to workspace list (gateFeature flow)
    end
    end
    
    rect rgba(150, 100, 200, 0.5)
    Note over User,DB: Running Automation (Paid Check)
    
    User->>UI: Click "Run Now" on automation
    UI->>TRPC: automationRouter.runNow()
    TRPC->>DB: Verify subscription via requirePaidSubscription
    
    alt Organization has paid/active subscription
        TRPC->>DB: dispatchAutomation()
        DB-->>TRPC: Dispatch with paidPlan: true
        TRPC-->>UI: Success response
    else No paid subscription
        DB-->>TRPC: Return skipped_unpaid outcome
        TRPC-->>UI: Error: feature requires paid plan
    end
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Poem

🐰 Automations hop and dance with glee,
Remote workspaces now gated, don't you see?
With dithered backgrounds shimmering bright,
And subscriptions checked at every height!
The demos are swapped, the backends aligned—
A feature-rich update, thoughtfully designed! ✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 17.39% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title 'feat(paywall): gate relay + automations + remote workspaces, dither preview' clearly and concisely summarizes the main changes: adding paywall gating for three features and refreshing the paywall preview UI with a dithered background.
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.
Description check ✅ Passed The pull request description is comprehensive and well-structured, covering all key sections including summary, test plan, changes, and implementation details.

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

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch paywall-relay-dither-ui

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.

@greptile-apps

greptile-apps Bot commented Apr 29, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR adds UI and server-side paywall gates for Relay, Automations, and Remote Workspaces, replacing the MeshGradient background with a DitheredBackground shader, and wires plan checks into automation.create/setEnabled/runNow and host.checkAccess by piggybacking a subscriptions LEFT JOIN onto the existing membership query.

  • dispatch.ts — wrong DB status for unpaid-plan blocks: recordSkipped always inserts status: "skipped_offline" (hardcoded), but the new free-plan short-circuit calls it and returns dispatch_failed to the caller. Automation runs blocked by a plan restriction will appear as "offline" in the run history and in any alert that counts skipped_offline events.
  • relay/access.ts — negative results not cached: the LRU only stores true, so free-plan org members trigger a fresh host.checkAccess API call on every relay check rather than being rate-limited to one per 5 minutes as the PR description states.

Confidence Score: 4/5

Safe to merge with the dispatch status mismatch resolved; the DB inconsistency will silently corrupt run-history data for free-plan orgs.

One P1 finding (wrong skipped_offline status written to automation_runs for plan-blocked runs) and one P2 (uncached negative relay results). The P1 causes observable data corruption in the run history table that will mislead users and operators, warranting a 4 rather than 5.

packages/trpc/src/router/automation/dispatch.ts (status mismatch) and apps/relay/src/access.ts (uncached denials).

Important Files Changed

Filename Overview
packages/trpc/src/router/automation/dispatch.ts Adds plan check in resolveTargetHost and short-circuits unpaid automations, but calls recordSkipped (which always writes status: skipped_offline) while returning dispatch_failed — incorrect status in DB for plan-blocked runs.
apps/relay/src/access.ts Correctly extends access check to require paidPlan, but only caches positive (ok = true) results — free-plan members bypass the LRU and hit the API on every relay check.
packages/db/src/utils/membership.ts Refactors findOrgMembership to LEFT JOIN subscriptions in the same query, returning { membership, subscription } — eliminates round-trips for plan-gated callers; query is correct and limited to 1 row.
packages/trpc/src/router/automation/automation.ts Gates create, setEnabled (enable-path only), and runNow behind requirePaidSubscription; correctly uses the new requireActiveOrgMembershipWithSubscription to avoid extra round-trips.
packages/trpc/src/router/host/host.ts Extends checkAccess to LEFT JOIN subscriptions and return paidPlan alongside allowed; clean and additive change.
apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/layout.tsx New layout guard for /automations routes that redirects free users and opens paywall; useCurrentPlan falls back to session plan during load so paid users avoid false redirects.
apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacesList/components/V2WorkspaceRow/V2WorkspaceRow.tsx Adds gateFeature to handleOpen and handleAddToSidebar for non-local-device workspaces; local-device rows pass through unconditionally as intended.
Prompt To Fix All With AI
This is a comment left during a code review.
Path: packages/trpc/src/router/automation/dispatch.ts
Line: 61-68

Comment:
**Wrong DB status recorded for unpaid-plan blocks**

`recordSkipped` hard-codes `status: "skipped_offline"` in every call (line 309 of the current file), so this new free-plan short-circuit writes `skipped_offline` to `automation_runs` even though the reason is a plan restriction, not a downed host. Any dashboard or alert that filters on `skipped_offline` to count "offline host" events will be silently inflated, and the run history shown to users will display a misleading "offline" reason for what was actually a paywall block.

Either add a new `"skipped_unpaid"` status to the `automationRuns` schema and pass it through `recordSkipped`, or change the return `status` here to `"skipped_offline"` to be consistent with the DB record — the latter is the minimal fix but the former is cleaner for observability.

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: apps/relay/src/access.ts
Line: 19-23

Comment:
**Negative results are never cached, defeating the anti-spam goal for free-plan members**

Only `ok = true` is written to the LRU cache. A free-plan org member (`allowed: true, paidPlan: false`) will see `ok = false` on every relay check and therefore hit the `host.checkAccess` API on every call — the PR description's "at most once per 5 min" guarantee only holds for paid users. Before this PR, non-members also experienced uncached denials, but this introduces a new class of legitimately-authenticated users (free-plan members) who will spam the endpoint.

Consider caching the combined boolean regardless of its value, or use a `Map<string, boolean>` so the cached entry can hold `false` explicitly.

How can I resolve this? If you propose a fix, please make it concise.

Reviews (1): Last reviewed commit: "feat(paywall): gate relay + automations ..." | Re-trigger Greptile

Comment on lines +61 to +68
const { host, paidPlan } = resolved;
if (!paidPlan) {
const error = "automations require a Pro plan";
const inserted = await recordSkipped(
automation,
scheduledFor,
host.machineId,
error,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1 Wrong DB status recorded for unpaid-plan blocks

recordSkipped hard-codes status: "skipped_offline" in every call (line 309 of the current file), so this new free-plan short-circuit writes skipped_offline to automation_runs even though the reason is a plan restriction, not a downed host. Any dashboard or alert that filters on skipped_offline to count "offline host" events will be silently inflated, and the run history shown to users will display a misleading "offline" reason for what was actually a paywall block.

Either add a new "skipped_unpaid" status to the automationRuns schema and pass it through recordSkipped, or change the return status here to "skipped_offline" to be consistent with the DB record — the latter is the minimal fix but the former is cleaner for observability.

Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/trpc/src/router/automation/dispatch.ts
Line: 61-68

Comment:
**Wrong DB status recorded for unpaid-plan blocks**

`recordSkipped` hard-codes `status: "skipped_offline"` in every call (line 309 of the current file), so this new free-plan short-circuit writes `skipped_offline` to `automation_runs` even though the reason is a plan restriction, not a downed host. Any dashboard or alert that filters on `skipped_offline` to count "offline host" events will be silently inflated, and the run history shown to users will display a misleading "offline" reason for what was actually a paywall block.

Either add a new `"skipped_unpaid"` status to the `automationRuns` schema and pass it through `recordSkipped`, or change the return `status` here to `"skipped_offline"` to be consistent with the DB record — the latter is the minimal fix but the former is cleaner for observability.

How can I resolve this? If you propose a fix, please make it concise.

Comment thread apps/relay/src/access.ts
Comment on lines +19 to +23
const ok = result.allowed && result.paidPlan;
if (ok) {
allowedCache.set(key, true);
}
return result.allowed;
return ok;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 Negative results are never cached, defeating the anti-spam goal for free-plan members

Only ok = true is written to the LRU cache. A free-plan org member (allowed: true, paidPlan: false) will see ok = false on every relay check and therefore hit the host.checkAccess API on every call — the PR description's "at most once per 5 min" guarantee only holds for paid users. Before this PR, non-members also experienced uncached denials, but this introduces a new class of legitimately-authenticated users (free-plan members) who will spam the endpoint.

Consider caching the combined boolean regardless of its value, or use a Map<string, boolean> so the cached entry can hold false explicitly.

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/relay/src/access.ts
Line: 19-23

Comment:
**Negative results are never cached, defeating the anti-spam goal for free-plan members**

Only `ok = true` is written to the LRU cache. A free-plan org member (`allowed: true, paidPlan: false`) will see `ok = false` on every relay check and therefore hit the `host.checkAccess` API on every call — the PR description's "at most once per 5 min" guarantee only holds for paid users. Before this PR, non-members also experienced uncached denials, but this introduces a new class of legitimately-authenticated users (free-plan members) who will spam the endpoint.

Consider caching the combined boolean regardless of its value, or use a `Map<string, boolean>` so the cached entry can hold `false` explicitly.

How can I resolve this? If you propose a fix, please make it concise.

@github-actions

github-actions Bot commented Apr 29, 2026 •

Copy link
Copy Markdown
Contributor

🧹 Preview Cleanup Complete

The following preview resources have been cleaned up:

  • ✅ Neon database branch

Thank you for your contribution! 🎉

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

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 (1)
apps/desktop/src/renderer/components/Paywall/components/FeaturePreview/components/DitheredBackground/DitheredBackground.tsx (1)

1-33: Consider consolidating duplicated DitheredBackground logic into one shared component.

This implementation matches the local version in apps/desktop/src/renderer/routes/_authenticated/components/V1MigrationSummaryModal/V1MigrationSummaryModal.tsx (Line 245-274). Centralizing it would reduce drift when tuning shader props.

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

In
`@apps/desktop/src/renderer/components/Paywall/components/FeaturePreview/components/DitheredBackground/DitheredBackground.tsx`
around lines 1 - 33, Duplicate DitheredBackground logic should be consolidated
into a single shared component: extract the current DitheredBackground function
(the component using lazy import of Dithering, props interface
DitheredBackgroundProps, and the JSX with Suspense/Dithering) into a
common/shared UI module and export it; then update the other copy in
V1MigrationSummaryModal to import and use that shared DitheredBackground instead
of its local implementation. Ensure the shared component preserves the same
props (colors, className) and the exact Dithering props (colorBack, colorFront,
shape, type, speed, className, minPixelRatio) so behavior remains identical.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In
`@apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/layout.tsx`:
- Around line 14-18: The effect in the layout uses gateFeature (from usePaywall)
which is recreated each render, causing the dependency array to retrigger the
side effects and navigation repeatedly; add a one-shot guard via useRef (e.g.,
called hasRunRef) and check it at the start of the useEffect so the body runs
only once when allowed is false, call gateFeature(GATED_FEATURES.AUTOMATIONS, ()
=> {}) as before, and change navigate({ to: "/v2-workspaces" }) to navigate({
to: "/v2-workspaces", replace: true }) to avoid stacking history entries; update
the dependency array to still include allowed but rely on the ref to prevent
repeat runs.

In `@packages/trpc/src/router/automation/dispatch.ts`:
- Around line 62-71: The unpaid-plan branch records the run via recordSkipped
(which sets automation_runs.status = "skipped_offline") but returns status
"dispatch_failed", causing a mismatch; update the return in the unpaid-plan
branch in dispatch.ts to return the persisted run status (use inserted?.status
?? "skipped_offline" or explicitly "skipped_offline") and keep runId as
inserted?.id ?? null so the returned status aligns with the automation_runs
record created by recordSkipped.

---

Nitpick comments:
In
`@apps/desktop/src/renderer/components/Paywall/components/FeaturePreview/components/DitheredBackground/DitheredBackground.tsx`:
- Around line 1-33: Duplicate DitheredBackground logic should be consolidated
into a single shared component: extract the current DitheredBackground function
(the component using lazy import of Dithering, props interface
DitheredBackgroundProps, and the JSX with Suspense/Dithering) into a
common/shared UI module and export it; then update the other copy in
V1MigrationSummaryModal to import and use that shared DitheredBackground instead
of its local implementation. Ensure the shared component preserves the same
props (colors, className) and the exact Dithering props (colorBack, colorFront,
shape, type, speed, className, minPixelRatio) so behavior remains identical.
🪄 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

Run ID: baab9e0a-7853-4bbe-8af3-042a6d1e0d58

📥 Commits

Reviewing files that changed from the base of the PR and between 35a2777 and 3d2b754.

📒 Files selected for processing (25)
  • apps/desktop/src/renderer/components/Paywall/components/FeaturePreview/FeaturePreview.tsx
  • apps/desktop/src/renderer/components/Paywall/components/FeaturePreview/components/AutomationsDemo/AutomationsDemo.tsx
  • apps/desktop/src/renderer/components/Paywall/components/FeaturePreview/components/AutomationsDemo/index.ts
  • apps/desktop/src/renderer/components/Paywall/components/FeaturePreview/components/CloudWorkspacesDemo/CloudWorkspacesDemo.tsx
  • apps/desktop/src/renderer/components/Paywall/components/FeaturePreview/components/CloudWorkspacesDemo/index.ts
  • apps/desktop/src/renderer/components/Paywall/components/FeaturePreview/components/DitheredBackground/DitheredBackground.tsx
  • apps/desktop/src/renderer/components/Paywall/components/FeaturePreview/components/DitheredBackground/index.ts
  • apps/desktop/src/renderer/components/Paywall/components/FeaturePreview/components/IntegrationsDemo/IntegrationsDemo.tsx
  • apps/desktop/src/renderer/components/Paywall/components/FeaturePreview/components/IntegrationsDemo/index.ts
  • apps/desktop/src/renderer/components/Paywall/components/FeaturePreview/components/RemoteWorkspacesDemo/RemoteWorkspacesDemo.tsx
  • apps/desktop/src/renderer/components/Paywall/components/FeaturePreview/components/RemoteWorkspacesDemo/index.ts
  • apps/desktop/src/renderer/components/Paywall/constants.ts
  • apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/layout.tsx
  • apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarHeader/DashboardSidebarHeader.tsx
  • apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacesList/components/V2WorkspaceRow/V2WorkspaceRow.tsx
  • apps/desktop/src/renderer/routes/_authenticated/settings/integrations/components/IntegrationsSettings/IntegrationsSettings.tsx
  • apps/desktop/src/renderer/routes/_authenticated/settings/security/components/SecuritySettings/SecuritySettings.tsx
  • apps/relay/src/access.ts
  • packages/db/src/utils/membership.ts
  • packages/trpc/src/router/automation/automation.ts
  • packages/trpc/src/router/automation/dispatch.ts
  • packages/trpc/src/router/host/host.ts
  • packages/trpc/src/router/integration/utils.ts
  • packages/trpc/src/router/organization/organization.ts
  • packages/trpc/src/router/utils/active-org.ts
💤 Files with no reviewable changes (4)
  • apps/desktop/src/renderer/components/Paywall/components/FeaturePreview/components/IntegrationsDemo/index.ts
  • apps/desktop/src/renderer/components/Paywall/components/FeaturePreview/components/CloudWorkspacesDemo/index.ts
  • apps/desktop/src/renderer/components/Paywall/components/FeaturePreview/components/IntegrationsDemo/IntegrationsDemo.tsx
  • apps/desktop/src/renderer/components/Paywall/components/FeaturePreview/components/CloudWorkspacesDemo/CloudWorkspacesDemo.tsx

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

1 issue found across 25 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="packages/db/src/utils/membership.ts">

<violation number="1" location="packages/db/src/utils/membership.ts:44">
P2: The LEFT JOIN may match multiple subscriptions per org (e.g., both an "active" and a "trialing" row during transitions). Without an explicit `ORDER BY`, `.limit(1)` returns a non-deterministic row. Consider adding an ordering preference (e.g., `active` before `trialing`, or most recently created) to ensure consistent results.</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.

Comment thread packages/db/src/utils/membership.ts Resolved
Restores the original `findOrgMembership` / `verifyOrgMembership` shapes so
non-gating callers (organization, integration utils) keep their old return
types, and moves the subscription join into dedicated siblings used only by
plan-gating procedures.

- findOrgMembershipWithSubscription / verifyOrgMembershipWithSubscription
  are the new joined helpers.
- requireActiveOrgMembershipWithSubscription routes through the new
  verify*WithSubscription helper.
- Reverts incidental rename churn in organization.ts.
- automation_runs: add `skipped_unpaid` status so paywall blocks don't
  inflate the `skipped_offline` metric. `recordSkipped` now takes the
  status as a parameter; dispatchAutomation uses it for the unpaid path
  and returns a matching `skipped_unpaid` outcome (was: persisting
  `skipped_offline` while returning `dispatch_failed` — both wrong).
  Includes drizzle migration 0040.
- findOrgMembershipWithSubscription: deterministic ORDER BY (active
  before trialing, then most recent) so a brief active+trialing overlap
  doesn't return a non-deterministic row.
- automations layout: useRef one-shot guard + replace navigation so the
  paywall + redirect don't re-fire on rerender (gateFeature isn't
  memoized) and don't stack history entries.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (1)
packages/db/src/utils/membership.ts (1)

58-61: Add a stable tie-breaker to make ordering fully deterministic.

Line 60 can still tie for two rows with the same status and createdAt, which can produce nondeterministic row selection under limit(1).

Proposed refinement
 		.orderBy(
 			sql`case ${subscriptions.status} when 'active' then 0 when 'trialing' then 1 else 2 end`,
 			desc(subscriptions.createdAt),
+			// Add a final stable key (e.g., primary key) to break timestamp ties:
+			desc(subscriptions.id),
 		)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/db/src/utils/membership.ts` around lines 58 - 61, The ordering can
still tie when subscriptions.status and subscriptions.createdAt are equal;
update the orderBy to include a stable tie-breaker (the subscription primary key
or another unique column) so results are deterministic under limit(1).
Concretely, after the existing case expression and createdAt ordering in the
query that uses orderBy(...), add a deterministic secondary order key such as
subscriptions.id (or subscriptions.uuid) with a fixed direction so identical
status+createdAt rows have a consistent order when selecting the first row.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/trpc/src/router/automation/dispatch.ts`:
- Around line 225-253: The LEFT JOIN against subscriptions in the dbWs.select
that builds the row for v2Hosts (using v2Hosts, subscriptions, dbWs.select, and
later evaluated by isPaidPlan/isActiveSubscriptionStatus) can return an
arbitrary subscription when multiple active/trialing rows exist; make the
subscription selection deterministic by either adding an ORDER BY on a
subscriptions timestamp (e.g., subscriptions.createdAt or
subscriptions.updatedAt) or by changing the query to fetch all matching
subscriptions for the organization and then selecting the intended one in code
(e.g., newest/most-recent status) before computing paidPlan; update the
select/join to include that deterministic ordering or the post-query selection
logic so paidPlan gating is unambiguous.

---

Nitpick comments:
In `@packages/db/src/utils/membership.ts`:
- Around line 58-61: The ordering can still tie when subscriptions.status and
subscriptions.createdAt are equal; update the orderBy to include a stable
tie-breaker (the subscription primary key or another unique column) so results
are deterministic under limit(1). Concretely, after the existing case expression
and createdAt ordering in the query that uses orderBy(...), add a deterministic
secondary order key such as subscriptions.id (or subscriptions.uuid) with a
fixed direction so identical status+createdAt rows have a consistent order when
selecting the first row.
🪄 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

Run ID: a0c0b9bb-b20b-4bc4-be95-7ae15938530f

📥 Commits

Reviewing files that changed from the base of the PR and between 1ce7f85 and 2efdd95.

📒 Files selected for processing (9)
  • apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/$automationId/components/PreviousRunsList/PreviousRunsList.tsx
  • apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/layout.tsx
  • packages/db/drizzle/0040_automation_run_skipped_unpaid_status.sql
  • packages/db/drizzle/meta/0040_snapshot.json
  • packages/db/drizzle/meta/_journal.json
  • packages/db/src/schema/enums.ts
  • packages/db/src/utils/membership.ts
  • packages/trpc/src/router/automation/automation.ts
  • packages/trpc/src/router/automation/dispatch.ts
✅ Files skipped from review due to trivial changes (3)
  • apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/$automationId/components/PreviousRunsList/PreviousRunsList.tsx
  • packages/db/drizzle/meta/_journal.json
  • packages/db/drizzle/0040_automation_run_skipped_unpaid_status.sql
🚧 Files skipped from review as they are similar to previous changes (2)
  • apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/layout.tsx
  • packages/trpc/src/router/automation/automation.ts

Filters automation evaluate-cron to skip rows whose org isn't on a paid
plan, so unpaid automations never enqueue, never enter dispatch, and
never write a run row. This is the primary fix; the dispatch-side
`skipped_unpaid` path stays as defense-in-depth (the subscription join
is already paid for by host resolution, so it's free).

Also simplifies the deterministic ORDER BY across the three
subscription joins (membership / dispatch / host.checkAccess) to plain
`desc(subscriptions.createdAt)` — pick the newest matching row, drop
the contrived case-expression preference.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/db/src/utils/membership.ts`:
- Around line 27-31: The docstring for the function that mirrors
findOrgMembership but also fetches a subscription (referenced as "Same as
`findOrgMembership` but pulls the org's currently-paying subscription") is
misleading because the SQL join only filters status IN (active, trialing) and
can return free plans; update the docstring to say it returns the org's "active
or trialing subscription (may be free)" or, if you intended to only return
paying plans, add an extra predicate to the join/query to exclude free plans
(e.g., WHERE plan_type != 'free' or similar) and update the function
name/comment accordingly; make the change in the function that constructs the
membership+subscription join (the variant of findOrgMembership that returns
subscription).
🪄 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

Run ID: 17ab845e-6b47-4fc9-8d06-101411789541

📥 Commits

Reviewing files that changed from the base of the PR and between 2efdd95 and 513301f.

📒 Files selected for processing (4)
  • apps/api/src/app/api/automations/evaluate/route.ts
  • packages/db/src/utils/membership.ts
  • packages/trpc/src/router/automation/dispatch.ts
  • packages/trpc/src/router/host/host.ts
🚧 Files skipped from review as they are similar to previous changes (2)
  • packages/trpc/src/router/host/host.ts
  • packages/trpc/src/router/automation/dispatch.ts

Comment on lines +27 to +31
/**
* Same as `findOrgMembership` but pulls the org's currently-paying subscription
* in the same statement, so callers gating on plan don't need a second
* round-trip. `subscription` is null when the org has no active/trialing row.
*/

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Docstring overstates paid-plan filtering.

Line 28 says this returns the org’s “currently-paying subscription”, but the join only filters status IN (active, trialing) and can return a free plan. Please tighten wording or add a plan predicate if “paying” is intended.

Suggested doc fix
- * Same as `findOrgMembership` but pulls the org's currently-paying subscription
+ * Same as `findOrgMembership` but pulls the org's current active/trialing subscription
  * in the same statement, so callers gating on plan don't need a second
- * round-trip. `subscription` is null when the org has no active/trialing row.
+ * round-trip. `subscription` is null when the org has no active/trialing row.
📝 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
/**
* Same as `findOrgMembership` but pulls the org's currently-paying subscription
* in the same statement, so callers gating on plan don't need a second
* round-trip. `subscription` is null when the org has no active/trialing row.
*/
/**
* Same as `findOrgMembership` but pulls the org's current active/trialing subscription
* in the same statement, so callers gating on plan don't need a second
* round-trip. `subscription` is null when the org has no active/trialing row.
*/
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/db/src/utils/membership.ts` around lines 27 - 31, The docstring for
the function that mirrors findOrgMembership but also fetches a subscription
(referenced as "Same as `findOrgMembership` but pulls the org's currently-paying
subscription") is misleading because the SQL join only filters status IN
(active, trialing) and can return free plans; update the docstring to say it
returns the org's "active or trialing subscription (may be free)" or, if you
intended to only return paying plans, add an extra predicate to the join/query
to exclude free plans (e.g., WHERE plan_type != 'free' or similar) and update
the function name/comment accordingly; make the change in the function that
constructs the membership+subscription join (the variant of findOrgMembership
that returns subscription).

…patch

Now that the cron evaluate-step filters unpaid orgs out of the enqueue
batch, the dispatch-side unpaid check only fires in a tiny race window
between SELECT and dispatch. That doesn't justify a new DB enum +
migration: defense-in-depth bails before any work, but no longer writes
a run row.

- Reverts enum + 0040 migration.
- Reverts recordSkipped to its single-status form.
- Unpaid path returns { status: "skipped_unpaid", runId: null } without
  a DB write (status mirrors the runtime-only "conflict" outcome).
…ription joins

Replaces 5 inline ["active", "trialing"] arrays with the shared
constant from @superset/shared/billing so adding a new active-equivalent
status (e.g. past_due_grace) only needs one edit instead of grepping
across queries.
@saddlepaddle
saddlepaddle merged commit cc5c103 into main Apr 29, 2026
13 of 14 checks passed
@Kitenite
Kitenite deleted the paywall-relay-dither-ui branch May 6, 2026 04:52
MocA-Love pushed a commit to MocA-Love/superset that referenced this pull request May 21, 2026
…review (ParadevAI#3850)

* feat(paywall): gate relay + automations + remote workspaces, dither preview

Tightens the Pro paywall around three free-leaking surfaces and refreshes
the paywall preview shader.

UI gates
- Relay (Settings → Security): turning on "Allow remote workspaces to
  access this device" now goes through gateFeature(REMOTE_WORKSPACES).
  Off-toggling stays ungated and keeps the new on/off confirm dialog.
- Automations: sidebar entry is visible to free users (was hidden); click
  routes through gateFeature(AUTOMATIONS). Direct URLs to /automations
  redirect via a new layout-level guard.
- Remote / cloud workspaces in the v2-workspaces list: open + add-to-sidebar
  on remote-device / cloud rows are gated. Local rows are unaffected. The
  workspace page itself is intentionally not gated — server-side
  host.checkAccess already returns paidPlan: false, so unpaid users hit a
  data-layer block instead of an accidental modal lock-out.

Server-side defense (zero new round-trips)
- findOrgMembership now LEFT JOINs subscriptions in the same statement, so
  callers gating on plan don't pay an extra query. Adds
  requireActiveOrgMembershipWithSubscription sibling.
- automation.create / setEnabled / runNow refuse non-paid orgs.
- host.checkAccess returns { allowed, paidPlan }; relay's existing
  5-min LRU caches the combined boolean — no per-request API spam.
- dispatchAutomation joins subscriptions into resolveTargetHost and
  short-circuits unpaid runs with a recorded skip.

Paywall preview
- Replaces MeshGradient with a DitheredBackground (shaders-react Dithering
  warp/4x4) ported from marketing, matching the new visual direction.
- Adds AutomationsDemo + RemoteWorkspacesDemo; drops obsolete
  CloudWorkspacesDemo / IntegrationsDemo.
- Reorders PRO_FEATURES to remote-workspaces → automations →
  team-collaboration → tasks → mobile-app and tightens the
  remote-workspaces description so feature-switching no longer shifts
  the modal height.

* refactor(trpc): split membership-with-subscription into its own helper

Restores the original `findOrgMembership` / `verifyOrgMembership` shapes so
non-gating callers (organization, integration utils) keep their old return
types, and moves the subscription join into dedicated siblings used only by
plan-gating procedures.

- findOrgMembershipWithSubscription / verifyOrgMembershipWithSubscription
  are the new joined helpers.
- requireActiveOrgMembershipWithSubscription routes through the new
  verify*WithSubscription helper.
- Reverts incidental rename churn in organization.ts.

* fix(automations,paywall): address PR review

- automation_runs: add `skipped_unpaid` status so paywall blocks don't
  inflate the `skipped_offline` metric. `recordSkipped` now takes the
  status as a parameter; dispatchAutomation uses it for the unpaid path
  and returns a matching `skipped_unpaid` outcome (was: persisting
  `skipped_offline` while returning `dispatch_failed` — both wrong).
  Includes drizzle migration 0040.
- findOrgMembershipWithSubscription: deterministic ORDER BY (active
  before trialing, then most recent) so a brief active+trialing overlap
  doesn't return a non-deterministic row.
- automations layout: useRef one-shot guard + replace navigation so the
  paywall + redirect don't re-fire on rerender (gateFeature isn't
  memoized) and don't stack history entries.

* perf(automations): drop unpaid orgs at the cron, not in dispatch

Filters automation evaluate-cron to skip rows whose org isn't on a paid
plan, so unpaid automations never enqueue, never enter dispatch, and
never write a run row. This is the primary fix; the dispatch-side
`skipped_unpaid` path stays as defense-in-depth (the subscription join
is already paid for by host resolution, so it's free).

Also simplifies the deterministic ORDER BY across the three
subscription joins (membership / dispatch / host.checkAccess) to plain
`desc(subscriptions.createdAt)` — pick the newest matching row, drop
the contrived case-expression preference.

* refactor(automations): drop skipped_unpaid enum, bail silently in dispatch

Now that the cron evaluate-step filters unpaid orgs out of the enqueue
batch, the dispatch-side unpaid check only fires in a tiny race window
between SELECT and dispatch. That doesn't justify a new DB enum +
migration: defense-in-depth bails before any work, but no longer writes
a run row.

- Reverts enum + 0040 migration.
- Reverts recordSkipped to its single-status form.
- Unpaid path returns { status: "skipped_unpaid", runId: null } without
  a DB write (status mirrors the runtime-only "conflict" outcome).

* refactor(billing): use ACTIVE_SUBSCRIPTION_STATUSES constant in subscription joins

Replaces 5 inline ["active", "trialing"] arrays with the shared
constant from @superset/shared/billing so adding a new active-equivalent
status (e.g. past_due_grace) only needs one edit instead of grepping
across queries.

This branch was previously deployed

1 inactive deployment
preview — cd3a9a7d Deployed Apr 29, 2026 by saddlepaddle via Deploy Docs #4542
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.

1 participant