Skip to main content

Documentation Index

Fetch the complete documentation index at: https://docs.deep.space/llms.txt

Use this file to discover all available pages before exploring further.

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.
Methoddata shape on successNotes
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 directlyCall a third-party integration. Billing follows src/integrations.ts.
Type tip. tools.create/update/remove all resolve to ActionResult<MutateActionData> where MutateActionData is just { recordId: string } - there is no record field on the result. To read the resulting row after a mutation, follow up with tools.get(coll, recordId).
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' })
  }
}
tools.query bypasses caller RBAC - your action sees every record in the collection, not just records the caller could read. If you want caller-scoped reads, do them client-side with useQuery, or pass a where clause that scopes by caller.

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() }
}
callerJwt is a live credential. Never log it, never return it in a response body, and never embed it in URLs. The only safe destination is an outbound Authorization: Bearer … header to a trusted upstream.

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 settingWho 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

NeedUse
Single-collection mutation the user can douseMutations
Multi-collection orchestrationServer action
Owner-billed integration callServer action with owner gate, or cron
Admin operation (mass update, recompute)Server action
Streaming responseCustom Hono route (actions don’t stream)
Scheduled workCron (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