---
name: tibi-hook-authoring
description: Write and debug server-side hooks for tibi-server (goja Go JS runtime). Covers IIFE structure, HookResponse/HookException types, context.filter Go-object quirk, single-item vs list retrieval, and MongoDB filter patterns. Use when creating or modifying files in api/hooks/.
---

# tibi-hook-authoring

Use this skill for **current tibi-server hook architecture**, not just simple CRUD filters. A real website project on this starter typically needs hooks for public filtering, SSR invalidation, action endpoints, validation, and editor safety.

## Source of truth

Use these sources when implementing or reviewing hooks:

- `tibi-server/docs/06-hooks.md`
- `tibi-server/docs/19-actions.md`
- `tibi-server/internal/models/eval_context.go`
- `tibi-server/internal/hook/context_*.go`
- `api/hooks/config.js`
- `api/hooks/filter_public.js`
- `api/hooks/clear_cache.js`
- `.agents/skills/tibi-ssr-caching/SKILL.md`

When hook examples and prose ever disagree about how helpers are exposed, trust the current implementation in `eval_context.go` plus the `context_*.go` registrations.

## First routing decision: collection hook or action

Before writing hook code, decide whether the workflow belongs to CRUD data or to an endpoint.

Use collection hooks when:

- the workflow is about reads or writes on a real collection
- publication filtering belongs to collection reads
- cache invalidation belongs to collection mutations

Use actions when:

- the workflow is endpoint-style business logic
- there is no durable CRUD collection behind it
- validation and side effects matter more than storage

Typical action use cases:

- contact forms
- newsletter signups
- quote or order requests
- webhook receivers
- helper endpoints

Do not implement fake empty collections just to gain a hook surface.

## Action hook context.data quirk

In **action hooks**, the body is NOT parsed before the `bind` step runs. `context.data` is only available starting from the `validate` step. The order is:

1. `bind` — runs, but `context.data` is undefined (body not yet parsed)
2. Body parsing — server parses JSON body into `context.data`
3. `validate` — `context.data` is now available
4. `handle` — `context.data` available, this is where main logic goes
5. `return` — final response

**Never access `context.data` in a bind hook** — it will be empty. Use `handle` for data access.

For action config, always use the `handle` step:

```yaml
hooks:
    post:
        handle:
            type: javascript
            file: hooks/actions/contact/handle.js
```

Also note: `context.data` can be an array or object depending on the request. Always guard:

```js
const data = (Array.isArray(context.data) ? {} : context.data) || {}
```

## Hook file structure

Wrap every hook in an IIFE:

```js
;(function () {
    /** @type {HookResponse} */
    const response = { status: 200 }

    // ... hook logic ...

    return response
})()
```

Always return a `HookResponse` or throw a `HookException`.

For many hooks, throwing is the normal control flow, especially in SSR hooks where HTML/status are returned via a thrown object.

## Type safety

- Use inline JSDoc type casting: `/** @type {TypeName} */ (value)`.
- Reference typed collection entries from `types/global.d.ts`.
- Avoid `@ts-ignore`; use proper casting instead.
- Use `const` and `let` instead of `var` — the goja runtime supports them.

## Hook API exposure model

In hook JavaScript, the server injects one top-level object: `context`.

That means runtime helpers and registered packages are accessed through `context`, for example:

- `context.request()`
- `context.db.find()`
- `context.http.fetch()`
- `context.smtp.sendMail()`
- `context.debug.dump()`
- `context.exec.command()`

Do not silently rewrite these to bare `request()`, `db.find()`, or `http.fetch()` when editing docs or examples for hook code.

## context.filter — Go object quirk

`context.filter` is a Go object, not a regular JS object. Even when empty, it is **truthy**.

Always check with `Object.keys()`:

```js
const requestedFilter =
    context.filter &&
    typeof context.filter === "object" &&
    !Array.isArray(context.filter) &&
    Object.keys(context.filter).length > 0
        ? context.filter
        : null
```

**Never** use `context.filter || null` — it is always truthy and produces an empty filter inside `$and`, which crashes the Go server.

## Single-item vs. list retrieval

For `GET /:collection/:id`, the Go server sets `_id` automatically from the URL parameter.

GET read hooks should **not** set their own `_id` filter for `req.param("id")`. Only add authorization filters (e.g. `{ userId: userId }`).


## Interne DB-Lookups in Hooks (Read & Write)

Innerhalb von goja-Hooks hast du über die `context.db`-API Vollzugriff auf die lokale MongoDB. Dies ist essenziell für komplexe Prüfungen (z. B. "Gehört der angemeldete User wirklich zur ID im Foreign-Key des Objekts?").

**Wichtige Konzepte für DB-Calls in Hooks:**
1. **Keine Automatik-Lookups (`_lookup`) in Hook-Queries:** Der Go-Befehl `context.db.find` liefert nur die flachen Datenbank-Dokumente als Array. Die in der REST-API verfügbare `lookup`-Automatik für Foreign-Keys wird in den internen Backend-Hooks *nicht* angewendet. Du musst die verknüpften Collections ggf. manuell nachladen.
2. **Immer Arrays:** `context.db.find` gibt **immer** ein Array zurück, auch wenn du `limit: 1` setzt.
3. **Rechte ignorierend:** Die `context.db.*`-Methoden umgehen alle `permissions` der YAML-Rollen. Du lädst als System-Benutzer!

**Beispiel: Datensatz validieren / verknüpftes Element prüfen**

```javascript
// hooks/my_action/post.before
(function() {
    var userId = context.auth().id;
    var submittedRefId = context.data.refId;

    // 1. Manuell nachladen
    var targetList = context.db.find("target_collection", {
        filter: { id: submittedRefId },
        limit: 1 // Begrenzen für Performance
    });

    if (targetList.length === 0) {
        throw { status: 404, json: { error: "Ziel nicht gefunden" } };
    }
    
    var target = targetList[0];

    // 2. Custom Security Check
    if (target.ownerId !== userId) {
        throw { status: 403, json: { error: "Keine Berechtigung für dieses Ziel" } };
    }

    // ... Hook fortsetzen
})();
```

**Verfügbare DB-Methoden in `context.db`:**
* `context.db.find(collection, { filter: {}, selector: {}, sort: [], limit: 10 })`
* `context.db.count(collection, { filter: {} })`
* `context.db.create(collection, { field: "value" })`
* `context.db.update(collection, "id_string", { field: "new_value" })` (bzw. mit Mongo-Operatoren `$set`, `$inc`, etc.)
* `context.db.delete(collection, "id_string")`

## Current hook surfaces that matter for website projects

- Collection CRUD hooks under `get`, `post`, `put`, `delete`
- Bulk hooks for optimized bulk operations
- `audit.return` hooks for stripping sensitive data from audit output
- `actions:` hook chains for endpoint-like behavior without a backing CRUD collection

For website builds on this starter, do not force everything into collections. Contact forms, newsletter signups, webhook receivers, import jobs, calculators, or other endpoint-style logic often belong into `actions:` instead.

## HookResponse fields (GET hooks)

| Field         | Purpose                                                             |
| ------------- | ------------------------------------------------------------------- |
| `filter`      | MongoDB filter (list retrieval, or restrict single-item)            |
| `selector`    | MongoDB projection (`{ field: 0 }` exclude, `{ field: 1 }` include) |
| `offset`      | Pagination offset                                                   |
| `limit`       | Pagination limit                                                    |
| `sort`        | Sort specification                                                  |
| `pipelineMod` | Function to manipulate the aggregation pipeline                     |

## context.data for write hooks

- `context.data` can be an array for bulk operations — always guard with `!Array.isArray(context.data)`.
- For POST hooks, `context.data.id` may contain the new entry ID.
- For PUT, `req.param("id")` gives the entry ID.

## Bulk and optimized paths

- tibi-server supports optimized bulk paths.
- In bulk scenarios, `bind` still runs once at the start.
- Per-document validation/update/delete hooks may be skipped depending on the chosen bulk path.

If a website feature depends on per-entry logic, do not assume a bulk update behaves exactly like N single updates. Check whether a dedicated bulk hook exists or whether the optimized path changes the behavior you rely on.

## Action hooks

Actions are first-class endpoints and should be part of the skill set for complete website builds.

Typical action steps:

- `post.bind`
- `post.validate`
- `post.handle`
- `post.return`
- `get.handle`
- `get.return`

Use actions when the website needs business logic without a CRUD collection.

Typical website use cases:

- contact forms
- newsletter signups
- quote/order requests
- webhook receivers
- utility endpoints
- AI-assisted helper endpoints

## Practical hook patterns for this starter family

- public read filtering for `active`/publication state
- SSR cache invalidation after writes
- route-level SSR validation
- mutation safeguards for readonly/system-managed fields
- custom form/action validation
- audit-output sanitizing for sensitive fields

## Public filter and publication contract

For website projects, `filter_public.js` and `publishedFilter` are not optional examples. They are part of the public-delivery contract.

Later agents should validate all of these deliberately:

- anonymous public reads see only the intended active/published records
- token-backed or admin-backed reads can still reach records needed for cleanup or operator workflows
- collections that feed navigation or pages do not silently disappear because `active: true` was forgotten

If the public site depends on a collection, a broken public filter is a delivery bug, not only a hook bug.

## Mutation-side SSR invalidation

If a mutation can change rendered HTML, the invalidation belongs in hooks.

Typical SSR-critical mutation domains:

- content
- navigation
- medialib or page-critical referenced media
- publication-relevant fields

For these collections, later agents should verify:

- which mutation steps call cache-clearing behavior
- whether post/put/delete are all covered when needed
- whether a representative mutation actually changes the next SSR response

## Public filter: token bypass for testdata cleanup

`filter_public.js` applies `publishedFilter` (active=true + publication window) to all unauthenticated GET requests. This works well for public traffic but causes a problem: **Playwright test cleanup can't see inactive `_testdata` entries** because `context.user.auth()` returns false even when a static `Token:` header is present. The filter runs, inactive entries are hidden from the API response, and the cleanup never deletes them. Over multiple test runs, stale entries accumulate in MongoDB.

**Fix:** check for any auth header in `filter_public.js` and skip the filter when present:

```js
const req = context.request()
const hasToken =
    req.header &&
    (req.header("Token") || req.header("X-Admin-Token") || req.header("X-Auth-Token") || req.header("Authorization"))

if (!context.user.auth() && !hasToken) {
    // apply publishedFilter
}
```

This way:

- **Anonymous requests** → public filter applies (only active entries visible)
- **Requests with Token header** → no filter → all entries visible → cleanup works

The same fix applies to any collection that uses a public filter hook and also receives testdata writes from Playwright.

## Common pitfalls

- Do not assume browser/Node APIs in hooks. The runtime is goja-based server-side JS.
- Do not treat actions as fake collections unless there is a good reason.
- Do not assume bulk hooks run per document.
- Do not build SSR/cache logic into frontend code when the invalidation belongs in hooks.

## Verification checklist

After hook-related changes, verify all of these:

1. the hook is attached to the right lifecycle step
2. actions are used for endpoint workflows instead of fake collections
3. anonymous vs token-backed reads behave correctly where public filtering exists
4. representative valid and invalid action submissions behave as designed
5. representative SSR-critical mutations invalidate or preserve cache as intended
6. bulk behavior is understood when the workflow depends on per-document logic

## Sending emails from hooks (`context.smtp.sendMail()`)

The `sendMail()` function is registered on `context.smtp` (NOT as a global). Always call via:

```js
context.smtp.sendMail({
    to: "recipient@example.com", // string or string[]
    cc: "cc@example.com", // optional
    bcc: "bcc@example.com", // optional
    from: "sender@example.com", // required
    fromName: "Sender Name", // optional
    replyTo: "reply@example.com", // optional
    subject: "Subject line",
    plain: "Plain text version",
    html: "<h1>HTML version</h1>", // optional
})
```

The SMTP host is configured via:

- Server-level `config.yml`: `mail.host`
- Environment variable: `MAIL_HOST` (e.g. `maildev:1025`)

MailDev (dev SMTP server) runs in the Docker stack at `maildev:25` (SMTP) with a web UI at `:1080`.

## publishedFilter: `active: true` erforderlich

Der `publishedFilter` in `api/hooks/config.js` filtert nach `active: true`:

```js
const publishedFilter = {
    active: true,
    $or: [ ... ]
}
```

Einträge OHNE `active`-Feld werden bei öffentlichen API-Calls UNSICHTBAR. Das betrifft besonders:

- **Navigationseinträge** – werden via `getCachedEntries("navigation", ...)` geladen. Fehlt `active: true`, bleibt `navItems` leer.
- **Manuell via API/MongoDB angelegte Einträge** – das `active`-Feld muss explizit gesetzt werden.

Der `filter_public.js`-Hook überspringt den Filter nur wenn ein Token-Header gesetzt ist. Bei öffentlichen API-Calls (z.B. aus dem SPA ohne Token) greift der Filter immer. Daher: alle Einträge in allen Collections müssen `active: true` haben, sonst sind sie auf der Website nicht sichtbar.
