# Actions API

Actions provide custom endpoints beyond CRUD operations. They support input validation, access conditions, and multiple response types including streaming.

## Record-Based Actions

Record-based actions operate on a specific record identified by ID.

```
POST /api/v1/{resource}/:id/{action-name}
```

### Request

```bash
# With JSON body
curl -X POST /api/v1/applications/app_123/advance-stage \
  -H "Authorization: Bearer <token>" \
  -H "Content-Type: application/json" \
  -d '{ "notes": "Strong technical interview, moving to offer stage" }'
```

The action's input schema validates the request body using Zod. Invalid input returns a 400 error.

### Response

```json
{
  "success": true,
  "data": {
    "id": "app_123",
    "stage": "offer",
    "previousStage": "interview",
    "notes": "Strong technical interview, moving to offer stage"
  }
}
```

### Security Flow

1. **Authentication** — User must be logged in (401 if not)
2. **Firewall** — Record must exist and be accessible to the user (404 if not found or not owned)
3. **Access** — User must have the required role (403 if insufficient)
4. **Input validation** — Request body validated against the action's Zod schema (400 if invalid)
5. **Access conditions** — Record must match access conditions (400 if not met)
6. **Masking** — Applied to the response data

### Access Conditions

Actions can require the record to be in a specific state:

```typescript
// In actions.ts
reject: {
  access: {
    roles: ["owner", "hiring-manager", "recruiter"],
    record: { stage: { notEquals: "rejected" } },
  },
}
```

If the record doesn't match (`stage` is already `rejected`), the action returns 400.

## Standalone Actions

Standalone actions don't require a record ID. They're for operations that aren't tied to a specific record.

```
POST /api/v1/{resource}/{action-name}
```

### Request

```bash
curl -X POST /api/v1/candidates/bulk-import \
  -H "Authorization: Bearer <token>" \
  -H "Content-Type: application/json" \
  -d '{ "source": "LinkedIn", "candidates": [{"name": "Alice Johnson", "email": "alice@example.com"}, {"name": "Bob Martinez", "email": "bob@example.com"}] }'
```

### Response

Same format as record-based actions. The difference is no record lookup or firewall check.

### Security Flow

1. **Authentication** — Required (401)
2. **Organization check** — For org-scoped resources, user must have an active organization (403)
3. **Access** — Role-based check (403)
4. **Scoped DB** — The `db` passed to the handler is auto-scoped based on table columns (org isolation, owner filtering, soft delete)
5. **Input validation** — Zod schema validation (400)

### Scoped Database

All actions (both standalone and record-based) receive a **scoped `db`** instance that automatically enforces security based on the table's columns:

- Tables with `organizationId` — org isolation (`WHERE organizationId = ?`)
- Tables with `ownerId` — owner isolation (`WHERE ownerId = ?`)
- Tables with `deletedAt` — soft delete filter (`WHERE deletedAt IS NULL`)
- `INSERT` operations auto-inject `organizationId` and `ownerId` from context

#### `userId` vs `ownerId` on feature tables

Use `ownerId` (not `userId`) for the row-owner column on a feature table. The compiler:

- Auto-firewalls reads on `ownerId` (`WHERE ownerId = ctx.userId`).
- Auto-stamps `ownerId` on insert from `ctx.userId`.
- Treats a `userId` column on a feature table as **informational** — almost always "a user this row references" (member, contact, invitee), not "the row owner."

A `userId` column on a feature table emits a compile-time warning. To resolve it:

- **Rename to `ownerId`** if the column controls access (most common case).
- **Rename to `linkedUserId`** (or similar) if the column is informational — explicit naming prevents future confusion.
- **Declare explicit firewall** if `userId` really is the access column on that table:
  ```ts
  firewall: [{ field: 'userId', equals: 'ctx.userId' }]
  ```

Better Auth's own internal tables (`session`, `account`, `member`, `passkey`, `subscriptions`) use `userId` by convention. Those are not feature tables and don't pass through the auto-firewall — the warning only fires on resources you define in your `quickback/` folder.

```typescript
// Your handler receives a scoped db — no manual filtering needed
execute: async ({ db, ctx, input }) => {
  // This query automatically includes WHERE organizationId = ? AND ownerId = ? AND deletedAt IS NULL
  const items = await db.select().from(applications).where(eq(applications.stage, 'interview'));

  // Inserts automatically include organizationId and ownerId
  await db.insert(applications).values({ candidateId: input.candidateId, jobId: input.jobId, stage: 'applied' });

  return items;
}
```

If you need unscoped access (for example, audited platform admin operations), declare `unsafe` on the action:

```typescript
adminReport: {
  unsafe: {
    reason: 'Platform support report',
    adminOnly: true,
    crossTenant: true,
    targetScope: 'all',
  },
  execute: async ({ db, unsafeDb, ctx, input }) => {
    // unsafeDb bypasses scoped filters
    const allOrgs = await unsafeDb.select().from(applications);
    return allOrgs;
  },
}
```

Cross-tenant unsafe actions are generated with:

- Better Auth authentication required
- platform admin gate (`ctx.userRole === 'admin'`)
- mandatory audit logging on denial/success/error

Without unsafe mode, `unsafeDb` is `undefined`.

Raw SQL in action code is blocked by default at compile time. If you need it for a specific action, set `allowRawSql: true` on that action explicitly.

### Explicit scope with `unsafeDb.scopeTo`

Unsafe handlers often need to derive the tenant from somewhere other than the ambient request context — an inbound webhook reads it from a mailbox record, a cron job iterates over every organization, a background worker acts on behalf of a specific user. `unsafeDb.scopeTo({ organizationId?, userId?, teamId? })` returns a scoped db that applies the firewall with your explicit scope instead of `ctx`:

```typescript
// Inbound mail webhook — PUBLIC + unsafe:true
ingestInbound: {
  standalone: true,
  access: { roles: ['PUBLIC'] },
  unsafe: {
    reason: 'Inbound mail webhook — tenant derived from mailbox record',
    targetScope: 'organization',
  },
  input: z.object({ mailboxId: z.string(), body: z.string() }),
  execute: async ({ unsafeDb, input }) => {
    // Look up the mailbox without a tenant in ctx (unsafe handler, unscoped client)
    const [mailbox] = await unsafeDb.select().from(mailboxes)
      .where(eq(mailboxes.id, input.mailboxId));
    if (!mailbox) return { ok: false };

    // Pin scope to the mailbox's organization — every downstream query gets
    // WHERE organizationId = mailbox.organizationId, inserts get it injected.
    const db = unsafeDb.scopeTo({ organizationId: mailbox.organizationId });
    await db.insert(messages).values({ mailboxId: mailbox.id, body: input.body });
    return { ok: true };
  },
}
```

Why this is safer than hand-rolling `organizationId` on every query:

- The firewall still runs. `scopeTo` just tells it which scope to use.
- `insert().values()` auto-injects `organizationId` / `ownerId` / `teamId` per the scope.
- Soft-delete and team-isolation filters continue to apply unchanged.
- No chance of forgetting a `WHERE organizationId = ?` clause on a single query.

`x-unsafe: true` is emitted on each unsafe operation in `openapi.json`, and MCP tool lists prefix the description with `[UNSAFE]` and set `annotations.destructiveHint` so agents and reviewers can see at a glance which endpoints cross trust boundaries.

## Input Validation

Actions use Zod schemas for input validation. The schema is defined in your `actions.ts` file:

```typescript
import { z } from "zod";
import { defineActions } from "@quickback/compiler";

export default defineActions("applications", {
  "advance-stage": {
    type: "record",
    input: z.object({
      notes: z.string().optional(),
    }),
    access: {
      roles: ["hiring-manager", "recruiter"],
      record: { stage: { notEquals: "hired" } },
    },
  },
  reject: {
    type: "record",
    input: z.object({
      reason: z.string().min(1),
    }),
    access: {
      roles: ["hiring-manager", "recruiter"],
      record: { stage: { notEquals: "rejected" } },
    },
  },
});
```

When validation fails:

```json
{
  "error": "Validation failed",
  "layer": "guards",
  "code": "GUARD_VALIDATION_FAILED",
  "details": {
    "issues": [
      { "path": ["reason"], "message": "String must contain at least 1 character(s)" }
    ]
  }
}
```

## Response Types

Actions support three response types:

### JSON (Default)

Returns a JSON object wrapped in `{ success: true, data: ... }`. Masking is applied to the data.

### Streaming

For long-running operations, actions can return a streaming response:

```typescript
generateReport: {
  type: "standalone",
  responseType: "stream",
  // ...
}
```

The action handler returns a `ReadableStream` directly — no JSON wrapper.

### File

For file downloads, actions can return a raw `Response` object:

```typescript
exportCsv: {
  type: "record",
  responseType: "file",
  // ...
}
```

## Action Handler

The compiler generates a handler skeleton. Your action receives:

```typescript
{
  db,           // Scoped database (auto-enforces org/owner/soft-delete filters)
  unsafeDb,     // Raw database (only available when unsafe mode is enabled)
  ctx,          // Auth context (user, session, org)
  record,       // The existing record (record-based only, undefined for standalone)
  input,        // Validated input from Zod schema
  services,     // Injected services
  c,            // Hono context
  env,          // Runtime bindings (Cloudflare env / process env). Alias for c.env.
}
```

## HTTP Methods

Actions default to `POST` but can use any HTTP method:

```typescript
getStatus: {
  type: "record",
  method: "GET",
  // GET actions receive input from query parameters instead of body
}
```

When using `GET`, input is parsed from query parameters instead of the request body.

## Cascading Soft Delete

When soft-deleting a parent record, Quickback automatically soft-deletes related records in child/junction tables **within the same feature**. The compiler detects foreign key references at build time.

For example, if your feature has `jobs` and `applications` tables where `applications` has a `.references(() => jobs.id)`, deleting a job will also soft-delete all its applications:

```
DELETE /api/v1/jobs/job_123
```

Generated code:
```typescript
// Soft delete parent
await db.update(jobs).set({ deletedAt: now, modifiedAt: now })
  .where(and(buildFirewallConditions(ctx), eq(jobs.id, id)));

// Cascade soft delete to child tables
await db.update(applications).set({ deletedAt: now, modifiedAt: now })
  .where(eq(applications.jobId, id));
```

**Cascade rules:**
- **Soft delete (default)**: Application-level cascade via generated UPDATE statements
- **Hard delete (`mode: 'hard'`)**: No compiler cascade — relies on DB-level `ON DELETE CASCADE`
- **Cross-feature**: No cascade — only within the same feature's tables

## See Also

- [Defining Actions](/compiler/definitions/actions) — How to define actions in your feature
- [Guards](/compiler/definitions/guards) — Guard conditions reference
- [Error Responses](/compiler/targets/hono-api/using-the-api/errors) — Error format reference
