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