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

> 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](/concepts/permissions) 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](/concepts/data-model) in one round-trip
* Run admin operations (recompute analytics, send notifications, mass-update records)
* Spend owner credits via an [integration](/guides/external-apis) 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`](/sdk-reference/client/records#usemutations%3Ct%3E-collection) on the client - keep server actions for cases that genuinely need escalation.

## Define an action

```ts theme={null}
// 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

```ts theme={null}
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:

```ts theme={null}
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](#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`.        |

<Note>
  **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)`.
</Note>

```ts theme={null}
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' })
  }
}
```

<Note>
  `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.
</Note>

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

```ts theme={null}
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>`:

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

Return a typed payload on success:

```ts theme={null}
return { success: true, data: { invitedCount: 3 } }
```

Or an error message on failure:

```ts theme={null}
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`:

```ts theme={null}
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.

```ts theme={null}
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() }
}
```

<Warning>
  `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.
</Warning>

## Integration calls - billing routing

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

```ts theme={null}
// 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](/guides/scheduled-jobs)) |

## Testing server actions

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

```ts theme={null}
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

* [Server actions reference](/sdk-reference/worker/server-actions) - `ActionHandler`, `ActionContext`, `ActionResult` types.
* [Permissions](/concepts/permissions) - collection-level RBAC.
* [External APIs](/guides/external-apis) - call third-party services from actions.
