--- 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 }`). ## 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: "

HTML version

", // 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.