370 lines
14 KiB
Markdown
370 lines
14 KiB
Markdown
---
|
||
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.
|