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

> `ActionHandler`, `ActionContext`, `ActionTools`, and `ActionResult`.

Server actions are defined in `src/actions/index.ts` and exposed at `POST /api/actions/:name`. They run as the app - RBAC checks are bypassed via the `X-App-Action` header - and are the right tool for cross-collection orchestration or owner-gated operations.

```ts theme={null}
import type {
  ActionHandler, ActionContext, ActionTools, ActionResult,
  MutateActionData, GetActionData, QueryActionData,
} from 'deepspace/worker'
```

## `ActionHandler<TEnv>`

```ts theme={null}
type ActionHandler<TEnv = Record<string, unknown>> =
  (ctx: ActionContext<TEnv>) => Promise<ActionResult>
```

Export a record of named actions from `src/actions/index.ts`:

```ts theme={null}
export const actions: Record<string, ActionHandler<Env>> = {
  inviteAttendee: async ({ params, tools, userId }) => {
    // ...
    return { success: true, data: { added: 1 } }
  },
}
```

The handler name (the key) becomes the endpoint path: `/api/actions/inviteAttendee`.

## `ActionContext<TEnv>`

```ts theme={null}
interface ActionContext<TEnv = Record<string, unknown>> {
  userId: string                       // verified JWT subject
  params: Record<string, unknown>      // request body
  tools: ActionTools
  env: TEnv                            // worker bindings, typed
  callerJwt: string                    // caller's raw Bearer token
}
```

`userId` is the caller - not the app owner. Use it for audit logs and per-caller logic. For owner-only actions, gate on `userId === env.OWNER_USER_ID`.

`callerJwt` is the raw, already-verified Bearer token the action was invoked with. Forward it on outbound platform requests that need to act as the caller rather than as the app owner - for example, deploy-worker `/api/apps` ownership checks, or any `apiWorkerFetch` / `platformWorkerFetch` call where the upstream bills or authorizes the JWT subject. See [Forwarding caller identity](/guides/server-actions#forwarding-caller-identity) in the guide.

<Warning>
  Never log `callerJwt` or echo it into response bodies. It's a live credential for the caller's session - treat it the same way you'd treat a password. Pass it to upstream workers via an `Authorization: Bearer …` header and nothing else.
</Warning>

## `ActionTools`

```ts theme={null}
interface ActionTools {
  create<T extends Record<string, unknown> = Record<string, unknown>>(
    collection: string,
    data: T,
    recordId?: string,
  ): Promise<ActionResult<MutateActionData>>

  update<T extends Record<string, unknown> = Record<string, unknown>>(
    collection: string,
    recordId: string,
    data: Partial<T>,
  ): Promise<ActionResult<MutateActionData>>

  remove(
    collection: string,
    recordId: string,
  ): Promise<ActionResult<MutateActionData>>

  get<T extends Record<string, unknown> = Record<string, unknown>>(
    collection: string,
    recordId: string,
  ): Promise<ActionResult<GetActionData<T>>>

  query<T extends Record<string, unknown> = Record<string, unknown>>(
    collection: string,
    options?: {
      where?: Record<string, unknown>
      orderBy?: string
      orderDir?: 'asc' | 'desc'
      limit?: number
    },
  ): Promise<ActionResult<QueryActionData<T>>>

  integration<T = unknown>(
    endpoint: string,
    data?: unknown,
  ): Promise<ActionResult<T>>
}
```

The per-operation data shapes:

```ts theme={null}
interface MutateActionData { recordId: string }

interface GetActionData<T = Record<string, unknown>> {
  record: RecordResult & { data: T }
}

interface QueryActionData<T = Record<string, unknown>> {
  records: Array<RecordResult & { data: T }>
  count: number
}
```

| Method                                | Returns (under `.data`)                                                                                                              |
| ------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ |
| `tools.get(coll, id)`                 | `{ record }` - full envelope with `data` typed as `T`                                                                                |
| `tools.query(coll, opts?)`            | `{ records, count }`                                                                                                                 |
| `tools.create(coll, data, recordId?)` | `{ recordId }` - pass `recordId` to upsert against a known key (see [Upsert by known id](/guides/server-actions#upsert-by-known-id)) |
| `tools.update(coll, id, patch)`       | `{ recordId }`                                                                                                                       |
| `tools.remove(coll, id)`              | `{ recordId }`                                                                                                                       |
| `tools.integration(endpoint, body)`   | The integration's response body directly (typed as `T`)                                                                              |

`tools.integration` does **not** wrap the response in `{ response }` - on success, `result.data` IS the integration's body. An OpenAI chat call yields `result.data.choices`; a Freepik image call yields `result.data.images`.

All operations bypass caller RBAC. **`tools.query` sees every record in the collection** regardless of the caller's role - pass a `where` clause to scope.

## `ActionResult<T>`

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

Narrow with `if (result.success)` before reading `result.data`:

```ts theme={null}
const r = await tools.get<EventData>('events', eventId)
if (!r.success) return r
const event = r.data.record   // typed as RecordResult & { data: EventData }
```

## Integration billing

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

| `billing` setting | Who pays                          |
| ----------------- | --------------------------------- |
| `'developer'`     | The app owner via `APP_OWNER_JWT` |
| `'user'`          | The signed-in caller              |

The api-worker reads the JWT subject - there's no client-supplied override (`X-Billing-User-Id` is ignored).

## Owner-only pattern

Gate actions that spend owner resources:

```ts theme={null}
export const recomputeAnalytics: ActionHandler<Env> = 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 and is the canonical trust anchor for owner-only operations.

## Calling 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 result = await res.json() as ActionResult
```

The action's `success` / `data` / `error` shape is forwarded verbatim in the HTTP response body. HTTP status is 200 on success, 401 if unauthenticated, 404 if the action name doesn't exist, 500 on uncaught throws.

## Type tip - fetching the post-write envelope

`tools.create` / `update` / `remove` resolve to `ActionResult<MutateActionData>` - only `{ recordId }` is returned, never the full envelope. To inspect the row after a mutation, re-fetch it:

```ts theme={null}
const r = await tools.update<EventData>('events', id, patch)
if (r.success) {
  const got = await tools.get<EventData>('events', r.data.recordId)
  if (got.success) {
    const event = got.data.record
  }
}
```

## See also

* [Server actions guide](/guides/server-actions) - patterns and worked examples
* [Permissions concepts](/concepts/permissions) - what RBAC bypass means
* [External APIs guide](/guides/external-apis) - billing routing for `tools.integration`
