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 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.
import type {
  ActionHandler, ActionContext, ActionTools, ActionResult,
  MutateActionData, GetActionData, QueryActionData,
} from 'deepspace/worker'

ActionHandler<TEnv>

type ActionHandler<TEnv = Record<string, unknown>> =
  (ctx: ActionContext<TEnv>) => Promise<ActionResult>
Export a record of named actions from src/actions/index.ts:
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>

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 in the guide.
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.

ActionTools

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:
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
}
MethodReturns (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)
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>

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

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