Zone data and tenancy

The previous page triggered a run and followed it to a result. Real zones need more than one run at a time: a list of past requests, a queue of items awaiting approval, a dashboard. This page is about where that data lives and how to keep it scoped to the right people.

1. There is no list-runs API, and that is the design

The zone client has exactly six methods — runs.create, runs.get, runs.createAndWait, runs.createAndDownload, runs.completeWaitpoint, and credentials.get. Every one of them addresses a single run by id. Nothing lists runs, and nothing queries them.

That is not an oversight to work around. The platform stores JSONB per run and nothing else — it is not a database you can query on your domain's terms, and runs.output_data is not a table. Two consequences follow:

  • A zone that needs a list owns a table. Write your rows as the agent produces them, and render from your own database.
  • Do not stuff a collection into output_data so you can read it back with runs.get. It bloats every read of that run, the run detail page has to render it, and you still cannot filter or paginate.

The one-run-at-a-time shape is fine for a zone that has no history to show. As soon as it does, it needs storage of its own.

2. Reading your own database

The pattern is short and there is exactly one right shape for it.

ts
import { createClient } from '@supabase/supabase-js'

const url = process.env.SUPABASE_URL!
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY

export function db() {
  if (!serviceKey) throw new Error('SUPABASE_SERVICE_ROLE_KEY is not set')
  return createClient(url, serviceKey, { auth: { persistSession: false } })
}

Used from a server action, never from a component that runs in the browser:

ts
'use server'

import { db } from '@/lib/db'
import { getZoneSession } from '@/lib/zone-session'

export async function listRequests(scope: 'mine' | 'all' = 'mine') {
  const session = await getZoneSession()
  const sb = db()

  let q = sb
    .from('freight_requests')
    .select('id, subject, received_at, status')
    .order('received_at', { ascending: false })
    .limit(50)

  if (scope === 'mine') q = q.eq('user_id', session.claims.userId)

  const { data, error } = await q
  if (error) throw error
  return data
}

Render the first page from an async server component and let client components call the same action for pagination or filtering. The browser never talks to the database.

That query is complete only if this deployment owns its database outright. If several zone deployments share one, it also needs an org predicate — Tenancy explains which shape you are in and why it matters.

The rule, whatever your database is: the connection is privileged, and the scoping is yours. The platform's JWT is the only identity a zone has, and your database has never heard of it — there is no end-user database session to hang row-level security on. So the zone connects with full rights and every restriction has to be written into the query. That is safe only because the connection never leaves the server; the moment a database credential reaches a client component, it is public.

The example above is Supabase, where "full rights" means the service-role key and the mechanism it bypasses is row-level security. On Postgres directly it is your application role; on MySQL, Mongo or DynamoDB it is whatever credential the driver holds. The vocabulary changes, the rule does not: server-side only, and scope every query yourself — see Tenancy for exactly what "scope" has to cover.

Do not add a second authentication system. Identity comes from getSession() and nowhere else.

3. Environment variables

A database-backed zone needs more than the six variables in Register the zone. Yours will look like:

# Platform-facing — the canonical six
ZONE_ORG_SLUG=acme
ZONE_AGENT_SLUG=revenue-chat
ZONE_JWT_SECRET=
AUDIENCE_ID=cxpa-revenue-chat
PLATFORM_API_BASE_URL=https://your-platform.tld
PLATFORM_ORIGIN=https://your-platform.tld

# Your own infrastructure
SUPABASE_URL=
SUPABASE_SERVICE_ROLE_KEY=

The second block is invisible to the platform — nobody will hand you those, and no platform page lists them.

4. Tenancy: what is isolated for you, and what is not

This is the part worth reading twice, because how much filtering you owe depends on a choice the platform cannot see.

What the platform guarantees: the deployment. A zone deployment binds to exactly one (orgSlug, agentSlug) pair. The slugs are compiled into basePath at build time, the JWT is signed with that agent's own secret, the audience is that agent's audience, and the asset prefix is that pair's. Any one of those alone would stop a second organization's traffic; together they make it impossible. One zone deployment serves one organization, and serving a second customer means a second deployment with its own slugs, secret and audience.

What it does not guarantee: anything about your database. That binding is a property of the deployment, not of your storage. Two deployments can happily share one database, and then nothing separates their rows but the queries you write.

So before writing a query, decide which shape you are in.

Shape A — one database per deployment

Every row already belongs to the single org this deployment serves, so an org filter is redundant. You may not even have an org_id column. This is the simplest shape and it is what the example in §2 assumes.

It costs you a database per customer. For a handful of customers that is fine; for fifty it is not.

Shape B — one database shared by several deployments

This is the normal shape for a product sold to many customers, and it is the one that bites. Every deployment is bound to its own org, but they all read the same tables with the same privileged connection. Every query must carry an org predicate, and no infrastructure will remind you:

ts
const { data } = await sb
  .from('freight_requests')
  .select('id, subject, received_at, status')
  .eq('org_id', session.claims.orgId)     // ← not optional in this shape
  .order('received_at', { ascending: false })

Stamp org_id from claims.orgId on every insert, and filter on it in every read — including counts, aggregates and the "show everyone's" views. A single unfiltered query is a cross-customer data leak, and because each deployment looks correct in isolation, testing one customer will never reveal it.

If you are in Shape B, the safest habit is to make the org filter structural rather than remembered: a query helper that takes the session and applies it, or a database view per org. Relying on every future query to remember is how this fails.

Users are not isolated for you, in either shape. Every member of the bound org reaches the zone with a valid token. If one user's rows should not be visible to another, you write that filter — as scope does in the example above. Nothing upstream does it.

Roles are not enforced for you either. claims.role tells you whether the visitor is an owner, a member, or a platform admin, but the platform does not act on it inside your zone: it lets every member through the door. If an action should be restricted — deleting records, approving something, changing settings — check the claim yourself, server-side, in the action:

ts
const session = await getZoneSession()
if (session.claims.role === 'member') {
  return { error: 'Not permitted' }
}

Check it in the server action, not just in the component that renders the button. Hiding a control is presentation; the action is the boundary. And note that members are operators on this platform — they can trigger runs and connect credentials — so "member" does not mean "read-only". See Roles and permissions.

Run reads are scoped per agent, not per user. runs.get(runId) succeeds for any run belonging to the bound agent, whichever user started it. If a run id is sensitive in your domain, check ownership in your own table before rendering it.

A useful way to hold it: the platform guarantees you are talking to the right org and the right agent. Everything finer than that is yours.

5. Mirror identity, do not store it

The session carries userName and userEmail. Upsert them on each request rather than copying them once at signup — renames on the platform then propagate for free, and you never hold a stale name:

ts
await sb.from('user_profiles').upsert(
  {
    user_id: session.claims.userId,
    user_name: session.claims.userName ?? null,
    user_email: session.claims.userEmail,
  },
  { onConflict: 'user_id' },
)

claims.userId is the join key between the platform's identity and your rows. It is stable; the name and email are not.

Checklist

  • Domain data lives in your database, not in runs.output_data.
  • The database connection is constructed server-side only, and no database credential is reachable from the browser.
  • No second auth library is installed; identity comes only from getSession().
  • You know which tenancy shape you are in — one database per deployment, or one shared.
  • If shared: every read and every write carries claims.orgId, including counts and aggregates.
  • Every query that should be user-specific carries an explicit user_id filter.
  • Privileged actions check claims.role inside the server action, not just in the UI.
  • Ownership is checked before rendering a run fetched by id.

Next

Register the zone so the platform can reach it.