Skip to main content
Docs
Worker & server

Server actions

Privileged worker-side functions that bypass user RBAC for orchestration and admin operations.

Server actions are app-defined functions called from the client with the user's JWT. They run as the app - RBAC checks are bypassed, so they can do things the user themselves can't, like updating two collections atomically or running owner-only operations.

Reach for server actions when you need to:

  • Orchestrate writes across multiple collections in one round-trip
  • Run admin operations (recompute analytics, send notifications, mass-update records)
  • Spend owner credits via an integration on behalf of the user
  • Wrap business logic that needs server-side validation

If the operation can be done with the caller's own RBAC, prefer useMutations on the client - keep server actions for cases that genuinely need escalation.

Define an action#

// src/actions/index.ts
import type { ActionHandler } from 'deepspace/worker'

interface EventData {
  attendeeIds?: string[]
}

export const actions: Record<string, ActionHandler<Env>> = {
  inviteAttendee: async ({ params, tools }) => {
    const eventId = params.eventId as string
    const attendeeId = params.attendeeId as string

    const event = await tools.get('events', eventId)
    if (!event.success) return event

    const { record } = event.data as { record: { data: EventData } }
    const current = record.data.attendeeIds ?? []
    const next = [...new Set([...current, attendeeId])]

    return tools.update('events', eventId, { attendeeIds: next })
  },
}

The action is automatically exposed at POST /api/actions/inviteAttendee. The caller's JWT is verified before the action runs.

Call from the client#

import { getAuthToken } from 'deepspace'

const res = await fetch('/api/actions/inviteAttendee', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    Authorization: `Bearer ${await getAuthToken()}`,
  },
  body: JSON.stringify({ eventId, attendeeId }),
})

const { success, data, error } = await res.json()

The action context#

Each action receives a context with the verified caller and a tools API:

type ActionContext<TEnv> = {
  userId: string              // caller (verified JWT subject)
  params: Record<string, unknown>  // request body
  tools: ActionTools
  env: TEnv
  callerJwt: string           // caller's raw Bearer token
}
callerJwt is the verified Bearer token the action was invoked with. Forward it on outbound requests that must run as the caller (not the app owner) - see Forwarding caller identity.

tools - RBAC-bypassing operations#

Every method returns ActionResult<T> - narrow with if (result.success) before reading result.data.

Method data shape on success Notes
tools.create(coll, data, recordId?) { recordId } Create a record. Pass recordId to upsert against a known key.
tools.update(coll, id, patch) { recordId } Patch an existing record.
tools.remove(coll, id) { recordId } Delete a record.
tools.get(coll, id) { record } Fetch one record (envelope: { recordId, data, createdAt, updatedAt, ... }).
tools.query(coll, opts?) { records, count } List records. opts accepts where, orderBy, orderDir, limit.
tools.integration(endpoint, data?) the integration's response body directly Call a third-party integration. Billing follows src/integrations.ts.
const r = await tools.query('items', { where: { status: 'pending' } })
if (r.success) {
  for (const item of r.data.records) {
    await tools.update('items', item.recordId, { status: 'processed' })
  }
}

Upsert by known id#

By default tools.create lets the DO mint the recordId. Pass an explicit id as the third argument to upsert against a known key - the canonical case is seeding the users row so its id matches the caller's auth user id, which is what makes tools.get('users', userId) resolve later.

export const ensureUserRow: ActionHandler<Env> = async ({ userId, tools }) => {
  return tools.create('users', { displayName: 'New player', score: 0 }, userId)
}

If a record with that id already exists, the incoming data is merged on top of it (existing fields you don't pass are preserved), so the same call works for both first-time seed and subsequent refreshes.

Action return shape#

Actions must return ActionResult<T>:

type ActionResult<TData> =
  | { success: true; data: TData; error?: never }
  | { success: false; data?: never; error: string }

Return a typed payload on success:

return { success: true, data: { invitedCount: 3 } }

Or an error message on failure:

return { success: false, error: 'Event not found' }

The HTTP response wraps the result in { success, data, error } matching this shape.

Owner-only actions#

When an action burns owner resources (credits, owner-billed integrations, sensitive owner-state mutations), gate it explicitly using OWNER_USER_ID:

import type { ActionHandler } from 'deepspace/worker'

interface OwnerEnv { OWNER_USER_ID?: string }

export const recomputeAnalytics: ActionHandler<OwnerEnv> = async (ctx) => {
  if (ctx.env.OWNER_USER_ID && ctx.userId !== ctx.env.OWNER_USER_ID) {
    return { success: false, error: 'Forbidden: owner only' }
  }

  // ...privileged work...

  return { success: true, data: {} }
}
OWNER_USER_ID is set on every deployed app to the user who owns it. Use it as the trust anchor for owner-only operations.

Forwarding caller identity#

tools.integration already routes the right JWT for you (owner or caller, depending on src/integrations.ts). If you need to call a platform endpoint directly - for example a platformWorkerFetch or apiWorkerFetch where the upstream authorizes the JWT subject as the user, not the app - use ctx.callerJwt to forward the same Bearer token the action was invoked with.

import type { ActionHandler } from 'deepspace/worker'
import { platformWorkerFetch } from 'deepspace/worker'

export const listMyApps: ActionHandler<Env> = async ({ callerJwt, env }) => {
  // The deploy worker's /api/apps endpoint scopes results by JWT subject,
  // so the caller - not the app owner - must be the authenticated user.
  const res = await platformWorkerFetch(env, '/api/apps', {
    headers: { Authorization: `Bearer ${callerJwt}` },
  })
  if (!res.ok) return { success: false, error: `Upstream ${res.status}` }
  return { success: true, data: await res.json() }
}

Integration calls - billing routing#

tools.integration(endpoint, body) proxies through the api-worker. Billing depends on src/integrations.ts:

// src/integrations.ts
export const integrations = {
  openai: { billing: 'developer' },   // owner pays
  google: { billing: 'user' },        // caller pays
}
billing setting Who pays
'developer' The app owner. Anonymous callers allowed.
'user' The signed-in caller. Anonymous callers get 401.

The api-worker reads the JWT subject to bill - there's no client-supplied override.

When to use actions vs other patterns#

Need Use
Single-collection mutation the user can do useMutations
Multi-collection orchestration Server action
Owner-billed integration call Server action with owner gate, or cron
Admin operation (mass update, recompute) Server action
Streaming response Custom Hono route (actions don't stream)
Scheduled work Cron (see Scheduled jobs)

Testing server actions#

A server action is one POST endpoint; cover it in api.spec.ts:

test('inviteAttendee adds attendee', async ({ request }) => {
  const token = await signInAndGetToken(request, 'alice@deepspace.test')
  const res = await request.post('/api/actions/inviteAttendee', {
    headers: { Authorization: `Bearer ${token}` },
    data: { eventId: 'evt_1', attendeeId: 'usr_2' },
  })
  expect(res.status()).toBe(200)
  expect(await res.json()).toMatchObject({ success: true })
})

test('inviteAttendee requires auth', async ({ request }) => {
  const res = await request.post('/api/actions/inviteAttendee', {
    data: { eventId: 'evt_1', attendeeId: 'usr_2' },
  })
  expect(res.status()).toBe(401)
})

Tips#

  • Keep actions focused. One verb per action (inviteAttendee, not manageEvent). Easier to test, easier to reason about.
  • Don't put RBAC logic inside actions. That's what the DO's collection permissions are for. Actions should be for orchestration and owner-gating.
  • Prefer actions over ad-hoc fetch endpoints. The tools API gives you type-safe RBAC bypass; rolling your own endpoint loses that.
  • Use the caller's userId for audit logs. ctx.userId is the verified caller; record it alongside any privileged write so you can trace who initiated it.

Next steps#