Skip to content

Commit bb5e320

Browse files
committed
refactor(mcp): drop the ordered-publication consistency layer for the minimal fix set
A four-angle LOC review (correctness, overengineering, dead-code, intent) found the fencing-token / discoveryRevision / conditional-publication apparatus and its 5 bounded-retry loops (~700 lines) to be correct but disproportionate to a low-contention, self-healing per-workspace-per-server discovery path — and it introduced two regressions: failing closed on a transient Redis blip (blocking DB status writes) and clearCache writing the DB for every server in the workspace. Revert service.ts, the storage adapters, the refresh route, and types to the production-tested base (which keeps the simple lastConnected<=discoveryStartedAt ordering guard and plain cache set/delete), and keep only the genuinely-good, decoupled fixes: authType-aware UnauthorizedError classification, OAuth-pending UX labels, DCR->422, credential-safe error diagnostics, and HTTP/2 negotiation.
1 parent b9dfe48 commit bb5e320

14 files changed

Lines changed: 605 additions & 2514 deletions

File tree

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
---
2+
name: selfhost-billing-architecture
3+
description: Reference for how Sim's billing, Stripe integration, and credit/usage-limit system is architected — entities, cost metering pipeline, and enforcement layers. Use when a self-hosted fork needs to merge, extend, or reimplement billing (e.g. wiring custom tool costs into the usage ledger, or adding an instance-wide spending limit).
4+
---
5+
6+
# Sim Billing Architecture (for self-hosted forks)
7+
8+
Sim's billing is designed to be **optional and additive**: every self-host deployment already writes a full cost ledger (`usage_log`) with no config, and enforcement (blocking execution, Stripe charges) is a separate layer gated behind env flags. This means a fork can adopt as much or as little of this system as it wants — read costs off the ledger for a dashboard, turn on the existing per-user/per-org dollar caps, or build new enforcement on top of the same primitives, without needing Stripe at all.
9+
10+
## 1. The three env flags that control everything
11+
12+
| Flag | Source | Effect when true |
13+
|---|---|---|
14+
| `isHosted` | `apps/sim/lib/core/config/env-flags.ts` — true only when `NEXT_PUBLIC_APP_URL` is `sim.ai`/`*.sim.ai` | Gates a few hosted-only behaviors (e.g. per-member org caps, disabling `DISABLE_AUTH`/private DB hosts). Not relevant to a self-hosted fork — leave it false. |
15+
| `isBillingEnabled` | `isTruthy(env.BILLING_ENABLED)` | **The master switch.** Registers the Stripe plugin with better-auth, turns on hard usage-limit enforcement, Redis concurrency reservation, threshold overage invoicing, and limit-notification emails. Independent of `isHosted`. |
16+
| `isOrganizationsEnabled` | `isBillingEnabled \|\| ORGANIZATIONS_ENABLED \|\| isAccessControlEnabled` | Lets a fork turn on multi-user orgs without needing Stripe billing at all. |
17+
18+
With `BILLING_ENABLED` unset (the self-host default): the Stripe plugin is never registered, `stripeCustomerId` is never populated, and every enforcement function short-circuits to a non-blocking default (e.g. `checkServerSideUsageLimits` returns `{ isExceeded: false, limit: 99999 }`, `reserveExecutionSlot` no-ops). **The cost ledger keeps writing regardless**`recordUsage()` is unconditional — so the logs page still shows real dollar costs even with billing fully off.
19+
20+
## 2. Database entities (`packages/db/schema.ts`)
21+
22+
- **`usageLog`** (append-only ledger, the source of truth) — one row per billable event: `userId`, `category` (`'model' | 'fixed' | 'tool'`), `source` (`'workflow' | 'wand' | 'copilot' | ...`), `description` (model/tool name), `cost` (decimal $), `eventKey` (unique, idempotency), `billingEntityType`/`billingEntityId` (`'user'|'organization'` — who actually gets billed, separate from `userId` who caused it), `billingPeriodStart/End`, `workspaceId`, `workflowId`, `executionId`.
23+
- **`userStats`** — personal billing record: `currentUsageLimit` (personal $ cap), `currentPeriodCost` (baseline for current period; real spend = baseline + `usage_log` rows), `creditBalance` (goodwill/purchased credit, raises the ceiling rather than being decremented per-call), `billingBlocked`/`billingBlockedReason` (account freeze, e.g. from a payment dispute).
24+
- **`organization`**`orgUsageLimit` (decimal — **pooled org-wide $ cap**, the closest existing thing to a "global limit"), `creditBalance` (org-level), `departedMemberUsage` (usage snapshot preserved so pooled totals stay correct when a member leaves), `limitNotifications` (jsonb dedup high-water marks for threshold emails).
25+
- **`organizationMemberUsageLimit`** — per-`(organizationId, userId)` $ cap set by an admin. Explicitly hosted-only in its own comment; `checkOrgMemberUsageLimit` guards on the compound condition `!isHosted || !isBillingEnabled` (i.e. it also requires billing to be on, not just `isHosted`) — a fork wanting per-member caps self-hosted should drop both conditions, not rebuild the table.
26+
- **`subscription`** — one row per Stripe subscription (or synthetic enterprise sub), keyed by `referenceId` (a user id OR an org id — that's what makes a subscription personal vs. org-scoped).
27+
28+
There is no separate invoice table — Stripe is the system of record for invoices; Sim only persists derived state (subscription row, ledger, credit balances).
29+
30+
## 3. Stripe wiring
31+
32+
All Stripe integration goes through the `@better-auth/stripe` plugin, configured in `apps/sim/lib/auth/auth.ts`. It's registered conditionally:
33+
34+
```ts
35+
...(isBillingEnabled && stripeClient
36+
? [ stripe({ stripeClient, stripeWebhookSecret: env.STRIPE_WEBHOOK_SECRET || '', ... }) ]
37+
: []),
38+
```
39+
40+
- **There are two separate Stripe client instantiations — don't conflate them.** `auth.ts` builds its own `stripeClient` (lines ~176-183) purely to decide whether to register the plugin and to pass into it; `lib/billing/stripe-client.ts`'s `getStripeClient()`/`requireStripeClient()` singleton is a distinct instantiation used elsewhere (e.g. the invoices route, billing portal). Both read the same `STRIPE_SECRET_KEY`, but they are not the same object in memory.
41+
- `apps/sim/app/api/auth/webhook/stripe/route.ts` — thin pass-through (`toNextJsHandler(auth.handler)`); better-auth verifies the signature and dispatches.
42+
- Subscription lifecycle hooks (`onSubscriptionComplete` / `onSubscriptionUpdate` / `onSubscriptionDeleted`) are defined **inline inside the plugin config in `auth.ts` itself**, not in `lib/billing/webhooks/subscription.ts` — that file only holds the delegate functions they call out to (`handleSubscriptionCreated`, `handleSubscriptionDeleted`, etc.). The inline hooks resolve the Sim plan from the Stripe price id, sync usage limits, auto-create an org on Team/Enterprise purchase, and (on deletion) issue a final overage invoice.
43+
- `onEvent()` manually switches on events the plugin doesn't structurally model: `invoice.payment_succeeded/failed/finalized`, `customer.subscription.created` (manual enterprise onboarding via `webhooks/enterprise.ts`), `checkout.session.expired`, `charge.dispute.created/closed` (account freeze).
44+
- Money-affecting handlers are wrapped in `lib/billing/webhooks/idempotency.ts` (Postgres-backed, 7-day TTL) since Stripe delivers webhooks at-least-once.
45+
- A fork not using Stripe can ignore all of this — none of it runs without `STRIPE_SECRET_KEY` + `BILLING_ENABLED`.
46+
47+
## 4. Cost metering pipeline — how a dollar amount gets onto the ledger
48+
49+
1. Every workflow execution incurs a flat `BASE_EXECUTION_CHARGE` (`lib/billing/constants.ts`).
50+
2. LLM cost: `calculateCost(model, promptTokens, completionTokens, ...)` in `providers/utils.ts` — looks up $/1M-token pricing per model.
51+
3. **Tool cost — the extension point for a custom tool**: a tool's execute function returns `{ ...output, cost: { total: <dollars>, ...breakdown } }`. Any tool result carrying `output.cost.total` is automatically picked up by `sumToolCosts()` (`providers/utils.ts`) when used inside an agent, or by trace-span cost aggregation for a standalone tool block. There is no separate cost registry to update — emitting `cost.total` on the tool's own output is the entire contract.
52+
- If the tool should use a Sim-provided (hosted) API key rather than BYOK, declare `hosting: ToolHostingConfig` on the `ToolConfig` (see the `add-hosted-key` skill) and `applyHostedKeyCostToResult()` computes and injects the cost automatically post-execution.
53+
4. `calculateCostSummary(traceSpans)` (`lib/logs/execution/logging-factory.ts`) aggregates a full execution's trace spans into `{ totalCost, baseExecutionCharge, models, charges }`.
54+
5. `lib/logs/execution/logger.ts` diffs that against what's already been recorded for the `executionId` (so pause/resume never double-bills) and calls `recordUsage()` (`lib/billing/core/usage-log.ts`), which inserts one `usage_log` row per cost line with a SHA-256 `eventKey` for idempotent inserts (`onConflictDoNothing`).
55+
56+
**This whole pipeline runs unconditionally** — it's the same code path whether or not `BILLING_ENABLED` is set. A custom tool that emits `cost.total` gets billed/tracked correctly with zero changes to the enforcement layer.
57+
58+
## 5. Enforcement — where a limit actually blocks execution
59+
60+
Gated entirely behind `BILLING_ENABLED`. Entry point: `apps/sim/lib/execution/preprocessing.ts` (`preprocessExecution`, runs before every workflow execution):
61+
62+
- **`preprocessing.ts` calls `checkServerSideUsageLimits(actorUserId, subscription)` and `checkOrgMemberUsageLimit(actorUserId, workspaceId)` directly** (both in `lib/billing/calculations/usage-monitor.ts`) — on `isExceeded`, returns HTTP `402` and the workflow never runs. **This is the important gotcha: `preprocessing.ts` does NOT go through `checkActorUsageLimits()`.** They are two independent call paths — `checkActorUsageLimits()` is a separate composer used by other billable surfaces (copilot, voice, wand, etc.), not by the workflow-execution gate itself. Adding a check only inside `checkActorUsageLimits()` will not affect normal workflow runs — see the corrected recipe in §6.
63+
- `reserveExecutionSlot()` (`lib/billing/calculations/usage-reservation.ts`) — Redis Lua-script atomic admission control bounding concurrent in-flight executions per billing entity (closes the "many parallel requests all read stale usage" race). Per-plan caps live in `MAX_CONCURRENT_EXECUTIONS`.
64+
- `checkBillingBlocked(userId)` — hard account freeze from `userStats.billingBlocked` (e.g. set by a payment dispute webhook), independent of usage limits.
65+
- `checkActorUsageLimits()` composes the pooled-org-or-personal check with the per-member check into one `{ isExceeded, message, scope }` result for its own set of callers (copilot, voice, wand, enrichment, KB indexing) — useful if a fork is gating one of those surfaces, but **not** a substitute for a change in `preprocessing.ts` when the goal is limiting workflow execution.
66+
- **Fail-closed is not universal — check each function.** `checkServerSideUsageLimits` fails closed (blocks on infra error, documented in its own catch block). `checkOrgMemberUsageLimit` explicitly fails **open** on error (per its own TSDoc/catch block) — an infra hiccup lets a per-member-capped user through rather than blocking them. Don't assume every check in this file has the same failure mode; verify each one you build on.
67+
- Threshold emails (`lib/billing/core/limit-notifications.ts`, 80%/100% with re-arm hysteresis) are a separate, advisory-only layer that runs alongside the hard gates, not instead of them. The email category key (`LimitCategory`) is a **closed union type** (`'storage' | 'tables' | 'seats'` today, not an open/generic string) — adding a new category (e.g. a "global" one) requires extending that type first, not just passing a new string.
68+
69+
## 6. Building a true global (instance-wide) spending limit
70+
71+
Today the "global-est" existing primitive is `organization.orgUsageLimit` — a pooled cap across all members of **one** org. On sim.ai that's the right unit, but a self-hosted instance may have multiple orgs (or none) and want a single cap across the whole deployment. There is no existing table/flag for that — it needs to be added. Recommended approach, reusing every primitive above rather than building a parallel system:
72+
73+
1. **Add an env-driven cap**, e.g. `GLOBAL_SPEND_LIMIT_DOLLARS`, read next to the other billing env vars in `lib/core/config/env.ts`.
74+
2. **Add an aggregate cost read** in `lib/billing/core/usage-log.ts` style — sum `usage_log.cost` across the current billing period with no `billingEntityId` filter (instance-wide), the same shape as the existing `getBillingPeriodUsageCost*` helpers.
75+
3. **Add a new check function** alongside `checkServerSideUsageLimits`/`checkOrgMemberUsageLimit` in `usage-monitor.ts` (e.g. `checkGlobalUsageLimit()`).
76+
4. **Wire it into every enforcement entry point explicitly — do not rely on `checkActorUsageLimits()` alone.** Because `preprocessing.ts` calls `checkServerSideUsageLimits`/`checkOrgMemberUsageLimit` directly (§5), the new check needs its own call added inside `preprocessing.ts` (for workflow execution) as well as inside `checkActorUsageLimits()` (for copilot/voice/wand/enrichment/KB indexing) if the global cap should apply everywhere. Treat this as two call sites to touch, not one — verify each surface you care about actually calls your new function before considering it done.
77+
5. Decide the failure mode deliberately (fail-closed vs. fail-open) rather than assuming — the two existing functions in this file disagree with each other (§5), so pick and document one explicitly for the new check.
78+
6. Decide whether this cap should apply even with `BILLING_ENABLED=false` (a pure self-host safety rail with no Stripe involved at all) — if so, gate it on its own env var's presence rather than on `isBillingEnabled`, mirroring how `isOrganizationsEnabled` is independently switchable.
79+
7. Reuse the existing threshold-email machinery (`limit-notifications.ts`) for an instance-wide 80%/100% warning if desired — note `LimitCategory` is a closed union today (§5), so add a `'global'` member to that type before passing it through.
80+
81+
This keeps the global limit as one more gate composed into the existing primitives, but — unlike the pooled-org cap, which really does have one call site — a true instance-wide cap must be threaded through each enforcement entry point by hand.
82+
83+
## 7. Merging upstream
84+
85+
Because the ledger write path (`recordUsage`) and the tool-cost contract (`cost.total` on tool output) are both unconditional and un-gated, a fork can carry custom tools and a custom global-limit check as a small, mergeable diff on top of this system rather than a divergent billing implementation — conflicts on `git merge`/rebase should mostly be limited to the new check function, its env var, and its two call sites (`preprocessing.ts` and `checkActorUsageLimits()`) from §6.
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
'use client'
2+
3+
import { useEffect } from 'react'
4+
5+
const CONFIG = {
6+
/** Debounce window before flushing buffered events to the server. */
7+
FLUSH_MS: 1500,
8+
/** Max buffered events before forcing an early flush. */
9+
MAX_BUFFER: 500,
10+
/** Ancestor display-name pattern that defines the in-scope subtree. */
11+
SCOPE: /copilot|panel/i,
12+
/** How far up the fiber tree to look for an in-scope ancestor. */
13+
MAX_DEPTH: 80,
14+
/** Poll attempts while waiting for the React Scan global to load. */
15+
HOOK_RETRIES: 50,
16+
HOOK_RETRY_MS: 200,
17+
} as const
18+
19+
interface RenderEvent {
20+
t: number
21+
scope: string
22+
name: string
23+
phase?: string
24+
count: number
25+
time: number | null
26+
unnecessary: boolean
27+
}
28+
29+
interface ScanFiber {
30+
type?: { displayName?: string; name?: string } | null
31+
return?: ScanFiber | null
32+
}
33+
34+
interface ScanRender {
35+
phase?: string
36+
count?: number
37+
time?: number
38+
unnecessary?: boolean
39+
}
40+
41+
type ScanFn = (options: {
42+
onRender?: (fiber: ScanFiber, renders: ScanRender | ScanRender[]) => void
43+
}) => void
44+
45+
function fiberName(fiber: ScanFiber | null | undefined): string | null {
46+
const type = fiber?.type
47+
if (!type) return null
48+
return type.displayName || type.name || null
49+
}
50+
51+
/** Returns the matching ancestor name if this fiber is within the copilot subtree. */
52+
function scopeOf(fiber: ScanFiber): string | null {
53+
let current: ScanFiber | null | undefined = fiber
54+
let depth = 0
55+
while (current && depth < CONFIG.MAX_DEPTH) {
56+
const name = fiberName(current)
57+
if (name && CONFIG.SCOPE.test(name)) return name
58+
current = current.return
59+
depth += 1
60+
}
61+
return null
62+
}
63+
64+
/**
65+
* Subscribes to React Scan's global render stream, filters to the copilot
66+
* panel subtree, and ships batched render telemetry to the dev-only collector
67+
* route. Rendered only when `isReactScanEnabled` (dev + `REACT_SCAN_ENABLED`),
68+
* so it is a no-op in production builds.
69+
*/
70+
export function ReactScanCollector() {
71+
useEffect(() => {
72+
let buffer: RenderEvent[] = []
73+
let timer: number | null = null
74+
let stopped = false
75+
76+
const flush = () => {
77+
timer = null
78+
if (buffer.length === 0) return
79+
const batch = buffer
80+
buffer = []
81+
// boundary-raw-fetch: dev-only debug telemetry, schema-less, must not run through a contract
82+
void fetch('/api/_dev/react-scan', {
83+
method: 'POST',
84+
headers: { 'content-type': 'application/json' },
85+
body: JSON.stringify({ events: batch }),
86+
keepalive: true,
87+
}).catch(() => {})
88+
}
89+
90+
const schedule = () => {
91+
if (buffer.length >= CONFIG.MAX_BUFFER) {
92+
if (timer !== null) window.clearTimeout(timer)
93+
flush()
94+
return
95+
}
96+
if (timer === null) timer = window.setTimeout(flush, CONFIG.FLUSH_MS)
97+
}
98+
99+
const onRender = (fiber: ScanFiber, renders: ScanRender | ScanRender[]) => {
100+
if (stopped) return
101+
const scope = scopeOf(fiber)
102+
if (!scope) return
103+
const name = fiberName(fiber) ?? 'unknown'
104+
const list = Array.isArray(renders) ? renders : [renders]
105+
for (const render of list) {
106+
buffer.push({
107+
t: Date.now(),
108+
scope,
109+
name,
110+
phase: render?.phase,
111+
count: render?.count ?? 1,
112+
time: typeof render?.time === 'number' ? Math.round(render.time * 100) / 100 : null,
113+
unnecessary: Boolean(render?.unnecessary),
114+
})
115+
}
116+
schedule()
117+
}
118+
119+
let attempts = 0
120+
const hook = () => {
121+
if (stopped) return
122+
const scan = (window as unknown as { reactScan?: ScanFn }).reactScan
123+
if (typeof scan !== 'function') {
124+
if (attempts < CONFIG.HOOK_RETRIES) {
125+
attempts += 1
126+
window.setTimeout(hook, CONFIG.HOOK_RETRY_MS)
127+
}
128+
return
129+
}
130+
scan({ onRender })
131+
}
132+
hook()
133+
134+
return () => {
135+
stopped = true
136+
if (timer !== null) window.clearTimeout(timer)
137+
flush()
138+
}
139+
}, [])
140+
141+
return null
142+
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import { promises as fs } from 'fs'
2+
import path from 'path'
3+
import { createLogger } from '@sim/logger'
4+
import type { NextRequest } from 'next/server'
5+
import { NextResponse } from 'next/server'
6+
import { isReactScanEnabled } from '@/lib/core/config/env-flags'
7+
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
8+
9+
const logger = createLogger('ReactScanCollector')
10+
11+
const OUT_DIR = path.join(process.cwd(), '.react-scan')
12+
const OUT_FILE = path.join(OUT_DIR, 'copilot-renders.jsonl')
13+
14+
/**
15+
* Dev-only sink for React Scan render telemetry collected from the browser.
16+
*
17+
* The client collector ({@link ReactScanCollector}) buffers per-render events
18+
* scoped to the copilot panel and POSTs them here; this handler appends them as
19+
* JSONL to `.react-scan/copilot-renders.jsonl` so the data can be inspected
20+
* directly from disk instead of the browser toolbar. Gated on
21+
* `isReactScanEnabled` (dev + `REACT_SCAN_ENABLED`); 404s otherwise.
22+
*/
23+
export const POST = withRouteHandler(async (request: NextRequest) => {
24+
if (!isReactScanEnabled) return new NextResponse(null, { status: 404 })
25+
26+
// boundary-raw-json: dev-only debug telemetry envelope, intentionally schema-less
27+
const body = (await request.json().catch(() => ({}))) as { events?: unknown[] }
28+
const events = Array.isArray(body.events) ? body.events : []
29+
if (events.length === 0) return NextResponse.json({ written: 0 })
30+
31+
await fs.mkdir(OUT_DIR, { recursive: true })
32+
const lines = `${events.map((event: unknown) => JSON.stringify(event)).join('\n')}\n`
33+
await fs.appendFile(OUT_FILE, lines, 'utf8')
34+
35+
logger.info('Appended React Scan events', { count: events.length })
36+
return NextResponse.json({ written: events.length })
37+
})
38+
39+
/** Reset the collection file so each investigation run starts clean. */
40+
export const DELETE = withRouteHandler(async () => {
41+
if (!isReactScanEnabled) return new NextResponse(null, { status: 404 })
42+
await fs.rm(OUT_FILE, { force: true })
43+
return NextResponse.json({ ok: true })
44+
})

0 commit comments

Comments
 (0)