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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 35 additions & 5 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -221,11 +221,14 @@ inputOrgId })`. It resolves from: route param slug → request
id is verified via `assertOrgMember(user_id, org_id)` before
return. No "current org" is read from session blob / cookie.
2. **Runtime contract in the seam** —
[editor/lib/ai/\_seam/core.ts](editor/lib/ai/_seam/core.ts)
[editor/lib/ai/server.ts](editor/lib/ai/server.ts)
`withTransaction` (and the AI SDK middleware that wraps it) throw
`MissingOrgIdError` if `organizationId` is missing, non-integer,
or non-positive. Defense-in-depth — a caller that forgets to
verify still cannot pass a garbage value.
or non-positive. This is **unconditional** on the billed path: the
former `NEXT_PUBLIC_GRIDA_LOCALDEV_SUPERUSER` exception (synthetic
`organizationId:0`, gate/ingest/auth skip) has been **removed** —
no code path skips this check while billing. The only intentional
bypass is the BYOK carve-out below, and it does not bill.
3. **Single seam entry point** —
[editor/lib/ai/server.ts](editor/lib/ai/server.ts) is the ONLY
file allowed to import `replicate`, `openai`, `@ai-sdk/*`,
Expand All @@ -235,12 +238,39 @@ inputOrgId })`. It resolves from: route param slug → request
([editor/scripts/audit-ai-seam.ts](editor/scripts/audit-ai-seam.ts)).
A new file that bypasses the seam fails at lint or CI.

**BYOK carve-out (intentional).** When a contributor sets a `BYOK_*`
key ([editor/lib/ai/models.ts](editor/lib/ai/models.ts) —
`BYOK_OPENROUTER_API_KEY`, `BYOK_AI_GATEWAY_API_KEY`), `grida`/`model`
return a **bare** provider so the **AI-SDK text/chat path** bypasses
the billing seam: no gate, no Metronome ingest, **and** the
`MissingOrgIdError` runtime contract above does not fire (a bare
provider has no middleware). The contributor's own provider key is
charged directly — there is no Grida balance, hence no victim to
drain, so the billing trust boundary is moot for that path. **Scope —
AI-SDK path only.** BYOK only swaps the AI-SDK provider; Replicate-
backed actions (`runPrediction`/`withTransaction` — audio, image) are
**not** bypassed and still gate + ingest under BYOK. Accordingly the
`withAiAuth` `balanceCents:0` short-circuit is opt-gated
(`byokBypass`, default `false`): only AI-SDK actions set it, so billed
actions still read the real balance and cannot silently drain credit
while reporting `0`. **BYOK bypasses billing only — never auth.** `requireOrganizationId` and
route/action auth always run, so a logged-in user with no resolvable
org is still rejected. Gated solely by server-only, non-`NEXT_PUBLIC_`
env vars never set in the hosted product (same trust model as
`OPENAI_API_KEY` / `REPLICATE_API_TOKEN`). Fail-closed: `byok` is
`null` unless a key env var is a non-empty string, so any ambiguity
falls back to the billed path. **Residual risk:** `byok` is resolved
once at module load with no per-request guard — an accidental `BYOK_*`
on a hosted/preview deploy would make every org bypass billing and the
org-id sanity gate (auth still holds). Acceptable only because it is a
contributor/self-host switch under the existing server-env trust model.

**Files bound by this id.** Run `grep -rn GRIDA-SEC-003 .` to enumerate.
Today:

- [editor/lib/auth/organization.ts](editor/lib/auth/organization.ts) — `requireOrganizationId`.
- [editor/lib/ai/server.ts](editor/lib/ai/server.ts) — single seam entry.
- [editor/lib/ai/\_seam/core.ts](editor/lib/ai/_seam/core.ts) — runtime gate.
- [editor/lib/ai/server.ts](editor/lib/ai/server.ts) — single seam entry; unconditional runtime gate; BYOK layer switch.
- [editor/lib/ai/models.ts](editor/lib/ai/models.ts) — BYOK layer (bare provider, bypasses billing).
- [editor/.oxlintrc.jsonc](editor/.oxlintrc.jsonc) — import lint rule.
- [editor/scripts/audit-ai-seam.ts](editor/scripts/audit-ai-seam.ts) — CI audit.

Expand Down
31 changes: 29 additions & 2 deletions docs/contributing/billing.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
---
format: md
---

# Contributing to Grida | Billing

Setup guide for contributors working on the billing surface. Two clouds to wire:
Expand All @@ -9,6 +13,26 @@ Setup guide for contributors working on the billing surface. Two clouds to wire:

---

## Just need AI to work? BYOK instead (no billing setup)

If you are **not** working on the billing surface and only need the **AI chat / canvas agent** (text) to run locally, skip the entire Metronome / Stripe / tunnel setup below. Set a contributor **BYOK** key and the **AI-SDK text path** routes through your own provider with billing bypassed — no credit gate, no metering, no Metronome.

```bash
# editor/.env.local (gitignored)
BYOK_OPENROUTER_API_KEY=sk-or-v1-... # https://openrouter.ai/keys
# …or, if you have one, a dedicated Vercel AI Gateway key:
# BYOK_AI_GATEWAY_API_KEY=...
```

- Bypasses **billing only — never auth.** Still sign in (`insider@grida.co` / `password`); a resolvable org is still required (an unauthenticated request still 401s).
- **Text/chat only** — BYOK swaps the AI-SDK provider, so only the text path is unbilled. Image/audio go through Replicate (`withTransaction`) and **still gate + bill even under BYOK** — those features need the full billing setup. (OpenRouter also exposes no image/audio models.) Catalog model IDs are unchanged; use IDs your provider accepts (edit `editor/lib/ai/models.ts` locally if one 404s).
- Precedence if both are set: OpenRouter, then AI Gateway. Fail-closed — an empty/unset (or whitespace-only) key falls back to the billed path.
- **Never set `BYOK_*` on a hosted or preview deploy.** It disables billing **and** the org-id sanity gate for every org. Contributor / self-host / local only. See [SECURITY.md](https://github.com/gridaco/grida/blob/main/SECURITY.md) (`GRIDA-SEC-003`, BYOK carve-out).

Working on billing itself? Ignore BYOK and continue with the full setup below.

---

## What you need

- Local Supabase running (`supabase start`).
Expand Down Expand Up @@ -82,7 +106,7 @@ cloudflared tunnel create grida-webhooks
cloudflared tunnel route dns grida-webhooks <hostname> # e.g. metronome-dev.yourdomain.co
```

Create `~/.cloudflared/grida-webhooks.yml` (path filter is the security boundary — see [SECURITY.md](../../SECURITY.md) `GRIDA-SEC-001`):
Create `~/.cloudflared/grida-webhooks.yml` (path filter is the security boundary — see [SECURITY.md](https://github.com/gridaco/grida/blob/main/SECURITY.md) `GRIDA-SEC-001`):

```yaml
tunnel: grida-webhooks
Expand Down Expand Up @@ -148,7 +172,7 @@ See the suite's own README for the contract.
- **Service module**: `editor/lib/billing/metronome.ts` — `provisionOrg`, `addStripeChargedCommit`, `setAutoReload`, `getEntitlement`, `ingestUsageEvent`.
- **`grida_billing.account.provisioning_uid`**: per-account UUID composed into Metronome aliases. `supabase db reset` produces fresh aliases — any orphan Metronome customers from previous instances are inert. No manual cleanup needed.

User-facing billing copy: [`docs/platform/billing.mdx`](../platform/billing.mdx). Design notes: [`docs/wg/platform/billing/`](../wg/platform/billing/) (AI credits master plan, Metronome integration, known issues). CLI guide: [`editor/scripts/billing/README.md`](../../editor/scripts/billing/README.md).
User-facing billing copy: [`docs/platform/billing.mdx`](../platform/billing.mdx). Design notes: [`docs/wg/platform/billing/`](../wg/platform/billing/) (AI credits master plan, Metronome integration, known issues). CLI guide: [`editor/scripts/billing/README.md`](https://github.com/gridaco/grida/blob/main/editor/scripts/billing/README.md).

---

Expand All @@ -162,6 +186,7 @@ User-facing billing copy: [`docs/platform/billing.mdx`](../platform/billing.mdx)
- **Tunnel returns 404** — `WEBHOOK_TUNNEL_HOSTNAME` doesn't match the routed hostname, or `cloudflared` isn't running. Re-run from `cli.ts smoke:webhook` to pinpoint which layer is broken.
- **Customer Portal "no Stripe customer"** — org hasn't subscribed or topped up yet. Stripe customer is lazy-created on first paid action.
- **AI credit shows "Out of credit" forever after a successful top-up** — Metronome webhook didn't reach the tunnel. Run `cli.ts smoke:webhook` to verify each layer.
- **AI returns a 402 / credit-gate error and you're _not_ testing billing** — you don't have Metronome wired. Set `BYOK_OPENROUTER_API_KEY` (see [Just need AI to work?](#just-need-ai-to-work-byok-instead-no-billing-setup)) — it bypasses the gate entirely. If it's set and you _still_ see billing behavior, the key is empty or AI is being called before sign-in (BYOK never bypasses auth).

---

Expand All @@ -177,3 +202,5 @@ User-facing billing copy: [`docs/platform/billing.mdx`](../platform/billing.mdx)
| `METRONOME_WEBHOOK_SECRET` | `editor/.env.test.local` |
| `WEBHOOK_TUNNEL_HOSTNAME` | `editor/.env.test.local` |
| `BILLING_E2E`, `BILLING_TEST_MODE`, `APP_URL` | `editor/.env.test` (committed) |

**Contributor BYOK (alternative — not required):** `BYOK_OPENROUTER_API_KEY` or `BYOK_AI_GATEWAY_API_KEY` in `editor/.env.local`. When set, the AI seam bypasses billing entirely and **none** of the Metronome rows above are needed. Auth is still required. See [Just need AI to work?](#just-need-ai-to-work-byok-instead-no-billing-setup).
11 changes: 9 additions & 2 deletions editor/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,6 @@ NEXT_PUBLIC_GRIDA_UNSAFE_DEVELOPER_SANDBOX='0'
NEXT_PUBLIC_GRIDA_WASM_VERBOSE='0' # set 1 to inspect (log) the wasm api
NEXT_PUBLIC_GRIDA_USE_INSIDERS_AUTH='1'
NEXT_PUBLIC_GRIDA_LOCALHOST_REGION="us-west-1"
NEXT_PUBLIC_GRIDA_LOCALDEV_SUPERUSER='0' # set 1 to byok and test features without rate limits. purely local dev with production build purpose.

# [optional]
# some features may require these keys
Expand All @@ -40,8 +39,16 @@ NEXT_PUBLIC_MAPBOX_ACCESS_TOKEN=...
# openai (used by provider-specific tools like webSearch; model selection is in lib/ai/models.ts)
OPENAI_API_KEY='sk-xxx'

# vercel ai gateway (optional; used when deployed on Vercel or with an API key)
# vercel ai gateway (optional; billed path — implicit AI_GATEWAY_API_KEY/OIDC)
# AI_GATEWAY_API_KEY=...
#
# byok (contributor-only; local testing). when ANY is set, AI calls
# route through that provider and BYPASS the billing layer (no credit
# gate, no metering). does NOT bypass auth (login + org still required).
# text/chat only; catalog model IDs unchanged (use IDs the provider
# accepts). precedence: openrouter, then ai-gateway.
# BYOK_OPENROUTER_API_KEY=sk-or-v1-xxx # https://openrouter.ai/keys
# BYOK_AI_GATEWAY_API_KEY=... # dedicated Vercel AI Gateway key

# resend
RESEND_API_KEY='re_123'
Expand Down
2 changes: 2 additions & 0 deletions editor/.oxlintrc.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@
"group": [
"@ai-sdk/openai",
"@ai-sdk/openai/**",
"@ai-sdk/openai-compatible",
"@ai-sdk/openai-compatible/**",
"@ai-sdk/anthropic",
"@ai-sdk/anthropic/**",
"@ai-sdk/google",
Expand Down
53 changes: 20 additions & 33 deletions editor/app/(api)/private/ai/chat/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import {
type UIMessage,
} from "ai";
import { canvasDesignAgent } from "@/grida-canvas-hosted/ai/agent/server-agent";
import { Env } from "@/env";
import { createClient } from "@/lib/supabase/server";
import { modelSpecById } from "@/lib/ai/models";
import { requireOrganizationId } from "@/lib/auth/organization";
Expand All @@ -26,25 +25,24 @@ type AgentChatRequestBody = {
export async function POST(req: NextRequest) {
try {
// GRIDA-SEC-003: resolve the calling org with verified membership.
let organizationId: number | null = null;
if (!Env.web.IS_LOCALDEV_SUPERUSER) {
const client = await createClient();
const { data: userdata, error: authError } = await client.auth.getUser();
if (authError || !userdata.user) {
return aiErrorResponse({
code: "unauthorized",
status: 401,
message: "login required",
});
}
try {
organizationId = await requireOrganizationId({
user_id: userdata.user.id,
request: req,
});
} catch (err) {
return aiErrorResponse(orgErrorToAiError(err));
}
// Auth is always enforced — BYOK bypasses billing only, never auth.
const client = await createClient();
const { data: userdata, error: authError } = await client.auth.getUser();
if (authError || !userdata.user) {
return aiErrorResponse({
code: "unauthorized",
status: 401,
message: "login required",
});
}
let organizationId: number;
try {
organizationId = await requireOrganizationId({
user_id: userdata.user.id,
request: req,
});
} catch (err) {
return aiErrorResponse(orgErrorToAiError(err));
}

const { messages } = (await req.json()) as AgentChatRequestBody;
Expand All @@ -54,23 +52,12 @@ export async function POST(req: NextRequest) {
let lastModelId: string | undefined;
let lastStepUsage: LanguageModelUsage | undefined;

// organizationId is required unless we're in the local-dev superuser
// mode (no auth/billing). The agent's `prepareCall` injects this into
// The agent's `prepareCall` injects organizationId into
// providerOptions.grida — see GRIDA-SEC-003.
if (organizationId === null && !Env.web.IS_LOCALDEV_SUPERUSER) {
return aiErrorResponse({
code: "no_organization",
status: 412,
message: "organizationId required",
});
}
return createAgentUIStreamResponse({
agent: canvasDesignAgent,
uiMessages: messages,
options:
organizationId !== null
? { organizationId, feature: "canvas/agent/chat" }
: ({} as { organizationId: number; feature?: string }),
options: { organizationId, feature: "canvas/agent/chat" },
sendReasoning: true,
messageMetadata: ({ part }): AgentMessageMetadata | undefined => {
if (part.type === "finish-step") {
Expand Down
4 changes: 2 additions & 2 deletions editor/app/(canvas)/canvas/tools/ai/generate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,8 @@ export async function generate({
* shell / route param). See GRIDA-SEC-003.
*
* Optional only to make the dev-tool harness compile without a
* workspace; when omitted in non-superuser mode the seam middleware
* throws `MissingOrgIdError` at the first AI call.
* workspace; when omitted the billed-path seam middleware throws
* `MissingOrgIdError` at the first AI call. See GRIDA-SEC-003.
*/
organizationId?: number;
system?: string;
Expand Down
Loading
Loading