Server actions
Privileged worker-side functions that bypass user RBAC for orchestration and admin operations.
On this page
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.deleteWhere(coll, where, limit?) | { deleted } | Delete every record matching where, one bounded page per call (limit defaults to 100, caps at 500). Repeat until deleted is below the limit. where is required and must be non-empty - there is no "delete everything" call. |
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, notmanageEvent). 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
fetchendpoints. ThetoolsAPI gives you type-safe RBAC bypass; rolling your own endpoint loses that. - Use the caller's userId for audit logs.
ctx.userIdis the verified caller; record it alongside any privileged write so you can trace who initiated it.
Next steps#
- Server actions reference -
ActionHandler,ActionContext,ActionResulttypes. - Permissions - collection-level RBAC.
- External APIs - call third-party services from actions.