Track HiveGrid Cloud planning reference

This commit is contained in:
david committed 2026-06-26 20:48:01 +02:00
1 parent b3d5ea6764
commit e341c5938a
5784 files changed
+1104929 -3

No files matched your search

@@ -0,0 +1,112 @@
# Bundle Budgets
**Status:** active
**Owner:** web platform
**Related:** [`scripts/bundle-budgets.json`](../../scripts/bundle-budgets.json), [`scripts/verify-bundle-size.mjs`](../../scripts/verify-bundle-size.mjs), `docs/improvement-plan/10-compliance-polish/bundle-budget.md`
## Why
Web bundle growth is silent unless we measure it. The boilerplate tracks two complementary signals:
1. **Group budgets** (`BUDGETS` constant in `verify-bundle-size.mjs`): coarse caps for `root` / `initial_payload_js` / `vendor` / `route` / `other` / `css` / `total_js` / `manifest_js`. Hard CI gate — failure exits 1.
2. **Per-route + per-vendor-chunk budgets** (`scripts/bundle-budgets.json`): fine-grained gzip limits per route and per vendor chunk. **Non-blocking** — surfaces as a PR-comment with a Δ-from-main column. Reviewers decide whether growth is justified.
The per-route layer was added in task #71 (Phase 10) to make day-to-day creep visible without blocking legitimate feature work.
`initial_payload_js` is a dedicated hard signal for the boot asset aggregate the current parser can identify without reading Rollup/Vite dependency graphs: `entry.client-*`, `root-*`, and `manifest-*`. It intentionally excludes route, vendor, and component chunks so lazy feature payloads cannot hide a shell regression in `total_js`.
## Config schema
`scripts/bundle-budgets.json` validated by `scripts/bundle-budgets.schema.json`:
```jsonc
{
"default": { "limitKb": 200, "warningKb": 180 },
"routes": {
"app/$tenant/dashboard": { "limitKb": 250, "warningKb": 225 },
"auth/magic-link": { "limitKb": 80, "warningKb": 75 }
},
"vendorChunks": {
"vendor-react": { "limitKb": 150, "warningKb": 135 }
}
}
```
- All sizes are **gzip kilobytes** (the transport-relevant number).
- `limitKb` is the hard cap; assets above this report `[EXCEED]` in CI output and the PR comment.
- `warningKb` is the soft threshold; between `warningKb` and `limitKb` assets report `[WARN]`.
- Routes use React Router v7 route ids in either dot- or slash-form (`auth/magic-link` and `auth.magic-link` both match).
- Unmatched routes fall back to `default`.
- Unmatched vendor chunks (anything starting with `vendor-` or `chunk-`) are reported under the chunk basename and also fall back to `default`.
## Local workflow
```bash
# Build apps/web normally and print the per-route table:
pnpm --filter @toolctrl/web build
node scripts/verify-bundle-size.mjs
# Only the per-route table (skips group checks):
node scripts/verify-bundle-size.mjs --per-route-only
# Machine-readable JSON (used by the PR-comment workflow):
node scripts/verify-bundle-size.mjs --json > pr-bundle-report.json
```
`--json` emits only the non-blocking per-route/per-vendor report and exits with
code 0 when the report was produced. Keep the hard group-budget gate on the
plain `node scripts/verify-bundle-size.mjs` path so redirected JSON never mixes
machine-readable output with human diagnostics.
### Visualizer (treemap)
To inspect why a chunk is big:
```bash
pnpm --filter @toolctrl/web run bundle:visualize
open apps/web/build/stats.html
```
The visualizer is gated on `ANALYZE_BUNDLE=true` so the regular production build keeps its current footprint. It uses `rollup-plugin-visualizer` (treemap, gzip + brotli sizes per chunk).
**Security note:** `stats.html` contains source-map references and should never be deployed publicly. Treat it as a developer-only artifact.
## PR-comment workflow
`.github/workflows/bundle-budget.yml` (and `.gitea/workflows/bundle-budget.yml`) run on every PR that touches `apps/web/**`, `packages/**`, or the budget config / verifier. They:
1. Build `@toolctrl/web`.
2. Run `verify-bundle-size.mjs --json`.
3. Download the latest `bundle-baseline-main` artifact (captured by `bundle-budget-baseline.yml` on `main` pushes).
4. Render a sticky markdown comment via `scripts/render-bundle-budget-comment.mjs`.
5. Upsert the comment using its `<!-- bundle-budget-report -->` marker (GitHub: `marocchino/sticky-pull-request-comment`; Gitea: `scripts/post-bundle-budget-gitea-comment.mjs`).
The comment renders rows in severity order (`[EXCEED]` → `[WARN]` → unbudgeted → ok), with columns: status / kind / route or chunk key / current gzip size / configured limit / Δ from main. Δ is `—` if no baseline is available.
### When the baseline is missing
The first PR after a fresh checkout (or after a baseline-artifact expiry) sees `Δ from main = —` for every row. This is expected — the next push to `main` re-captures the baseline.
## When to raise a limit
1. Confirm the growth is intentional (a new feature shipped, a dependency landed) — not a regression from accidental imports.
2. Open the PR; the bundle-budget comment shows the Δ. Reviewers and the PR author should agree the new floor is acceptable.
3. Edit `scripts/bundle-budgets.json` in the **same PR** that ships the growth. Bump `warningKb` to ≈ (current gzip + 5 KB) and `limitKb` to ≈ (current gzip + 20 KB).
4. Call the change out in the PR description so the new floor is reviewable.
## Anti-patterns
- **Don't disable a budget by raising it to 9999 KB.** The point is to surface drift. If a route is legitimately huge, raise the limit to a tight new floor, not infinity.
- **Don't lazy-load critical-path code.** Bundle splits help non-critical routes, but the initial payload (`entry.client-*` / `root-*` / `manifest-*`) still has its own `initial_payload_js` hard group budget that must stay tight.
- **Don't bundle-split shared deps.** Splitting `react` into many chunks just shifts cost into chunk boot-up. Keep `vendor-react`, `vendor-core-utils` etc. as single chunks; the manualChunks mapping in `apps/web/app/utils/build/manualChunks.ts` is the source of truth.
## Troubleshooting
- **PR comment never shows up:** check the `Bundle Budget` workflow run — the `Build web` step may have failed (e.g. server-only import in a client export). The comment job only runs if all upstream steps succeed.
- **`vendor-monaco-...` reports as `vendor-monaco-...-` with default budget:** rollup occasionally emits chunk hashes ending in `-`; the baseline normalizer skips that suffix and the asset falls back to `default`. Either rename via `manualChunks.ts` or add an explicit budget entry.
- **CI run is much slower than `pnpm typecheck`:** the workflow does a full `pnpm --filter @toolctrl/web build`. That's the expected cost. If it ever exceeds ~30 minutes, profile with `ANALYZE_BUNDLE=true` locally first.
## Rollback
- If the per-route budget surfaces too many false alarms, lower the warning thresholds in `scripts/bundle-budgets.json` and ship a follow-up.
- If the entire PR-comment flow is broken, set `if: false` on the `measure` job in `bundle-budget.yml` — no other CI stage depends on it.
+121
View File
@@ -0,0 +1,121 @@
# CSRF — Single-Read Helper Contract
This document covers the runtime API contract of `validateCSRFToken` in
`apps/web/app/utils/session.server.ts`. The complementary route-level
inventory of which surfaces are protected lives in
`docs/security/csrf-inventory.md` — read that file when adding a new
mutating route.
## Background — the body-already-consumed bug
Before the May 2026 fix, `validateCSRFToken(request)` always tried to
locate the request token by either reading the `x-csrf-token` header or,
falling back, by cloning the request and re-parsing the body:
```ts
// historical implementation (broken when caller already drained the body)
const formData = await request.clone().formData();
```
Cloning a request whose body was already consumed throws
`TypeError: unusable` in undici. UI form-submits where the action handler
read `await request.formData()` first and validated afterwards crashed
in production with a 500. The bug was first surfaced during the B-009
lighthouse-bot session-cookie capture, where the workaround was to send
the token via the `x-csrf-token` header to bypass the body path.
## API contract
```ts
export type ValidateCSRFTokenOptions = {
/** Already-parsed form data. When set, the helper NEVER reads the body. */
formData?: FormData;
/** Skip body fallback entirely. Header must carry the token. */
headerOnly?: boolean;
};
export async function validateCSRFToken(
request: Request,
options?: ValidateCSRFTokenOptions,
): Promise<void>;
```
### Resolution order
1. `x-csrf-token` / `csrf-token` request header.
2. If `headerOnly` is `true`, stop here.
3. `options.formData?.get("csrf")` — used when the caller has already
parsed the body.
4. Body fallback: clone-and-parse the request as `FormData`,
`application/json`, or `URLSearchParams`. Reaching this branch on a
request whose body has already been consumed surfaces a CSRF-specific
error (`CSRF body already consumed; pass { formData } to validateCSRFToken.`)
instead of the opaque `TypeError: unusable` from undici.
### Calling patterns
```ts
// Preferred: validate before reading the body. Header-first short-circuit
// keeps this path zero-cost on header-bearing requests.
export async function action({ request }: ActionFunctionArgs) {
await validateCSRFToken(request);
const form = await request.formData();
// ...
}
```
```ts
// Required when the caller parses the body first (e.g. routing based on a
// form action discriminator). Inject the parsed FormData — never let the
// helper attempt to re-read the consumed body.
export async function action({ request }: ActionFunctionArgs) {
const form = await request.clone().formData();
await validateCSRFToken(request, { formData: form });
// ...
}
```
```ts
// Helpers / cross-file flows that receive a request plus pre-parsed
// FormData from the route action must forward the FormData.
async function loginFromRequest(request: Request, form: FormData) {
await validateCSRFToken(request, { formData: form });
// ...
}
```
```ts
// Bot / lighthouse / capture contexts where the body is reserved for a
// different purpose. The token must be sent in the x-csrf-token header.
await validateCSRFToken(request, { headerOnly: true });
```
## Migrated callsites (May 2026 fix)
- `apps/web/app/root.tsx` — the locale/theme action pre-parses form data
for routing and now hands the FormData to `validateCSRFToken`.
- `apps/web/app/modules/users/services/AuthService.ts` —
`loginFromRequest` receives `FormData` from `routes/login.tsx` and
`root.tsx`, and forwards it.
Any new helper that accepts `(request, formData)` from a caller MUST
forward the FormData to `validateCSRFToken`. The convention check is
covered by `apps/web/app/utils/session.csrf-regression.test.ts`.
## Verification
```bash
pnpm exec vitest run \
apps/web/app/utils/session.server.test.ts \
apps/web/app/utils/session.csrf-regression.test.ts \
apps/web/app/utils/session-csrf-sweep.test.ts
```
The regression file specifically asserts:
- Calling the helper after `await request.formData()` succeeds when the
parsed body is injected.
- Calling the helper after the body was consumed without injection raises
a CSRF-specific error — never the undici `TypeError: unusable`.
- The header-token fast path still skips the body even when the body has
already been read.
@@ -0,0 +1,141 @@
# Email Templates (React-Email)
**Package:** `packages/mail/src/templates-react/`
**Status:** active (Phase 10 task #75)
**Replaces:** `packages/mail/src/templates/transactional.ts` (deprecated)
## Overview
Transactional emails are React components. Every template is rendered
deterministically to `{ html, text }` via `react-dom/server` and
delivered through the standard Mailjet/queue provider chain.
We chose React components over the legacy string-template approach
for three reasons:
1. **XSS-safety by default** — React escapes interpolated values, so
we cannot accidentally forget an `escapeHtml` call when a new
prop is introduced. The old monolithic file had a recurring
audit finding around manual escaping.
2. **Component reuse** — header, footer, button, info-box, and
eyebrow are shared across templates. Brand-theming threads
through a single `MailBrand` prop instead of token-replacement
on raw HTML strings.
3. **Live preview** — `pnpm --filter @toolctrl/mail preview` boots
a local HTTP server that renders every template with realistic
sample data so designers can iterate without triggering live
email sends.
## Adding a new template
1. **Create the component** in `packages/mail/src/templates-react/transactional/<TemplateName>.tsx`.
Accept a `brand: MailBrand` prop plus the data the template needs.
Compose with `TransactionalScaffold` for the standard layout.
Avoid `dangerouslySetInnerHTML` — it is banned by code review.
2. **Validate URLs** — every CTA must go through `<Button>`. The
component throws `InvalidActionUrlError` for anything other than
`https://` or `mailto:` URLs.
3. **Register the template** in
`packages/mail/src/templates-react/render.ts`:
- Add the name to `ReactTransactionalTemplateName`
- Add the prop type to `ReactTemplatePropsMap`
- Add the component to the `COMPONENTS` map
4. **Add a snapshot test** at
`packages/mail/src/templates-react/transactional/<TemplateName>.test.tsx`
covering: default render (HTML + text snapshots), tenant brand
override, XSS-safety on at least one user-supplied prop, and
URL-validation error if the template takes an `actionUrl`.
5. **Add a preview entry** in `packages/mail/scripts/previews.ts`
so the new template renders in `pnpm preview`.
## Brand theming
Templates accept a `brand: MailBrand` prop with the following fields:
| Field | Source | Notes |
| --- | --- | --- |
| `productName` | `@toolctrl/brand` `BrandTokens.productName` | Defaults to "ToolCTRL". |
| `logoUrl` | `BrandTokens.assets.logoUrl` | Optional. Falls back to wordmark. |
| `primaryColor` | `BrandTokens.mail.colors.primary` | CTA background. |
| `primaryTextColor` | `BrandTokens.mail.colors.primaryText` | CTA foreground. |
| `tenantName` | derived from tenant `displayName` | Used in greeting/footer copy. |
| `supportEmail` / `supportFromName` | `BrandTokens.support.*` | Footer + sign-off. |
| `privacyUrl` / `termsUrl` / `imprintUrl` | `BrandTokens.legal.*` | DSGVO/CAN-SPAM compliance. |
`withBrandDefaults(override?)` merges a partial override into the
platform defaults — call it once per render so missing tenant fields
do not blank out the design.
## Plain-text fallback
`renderTransactionalEmail(template, props)` returns both `html` and a
plain-text `text` variant. The text is produced from the rendered
HTML by `htmlToPlainText` in `render-helpers.ts` — anchor URLs are
preserved as `label (url)` so text-mode clients keep functional
CTAs.
Mailjet forwards both parts in a MIME-multipart envelope; clients
choose which to display.
## Testing
- **Snapshot tests** are mandatory. Each template owns a
`__snapshots__/<TemplateName>.test.tsx.snap` capturing both the
HTML and the text output. Reviewers compare snapshot diffs against
the design intent.
- **XSS-safety tests** verify that React's default escaping turns
`<script>` into `&lt;script&gt;` for at least one user-supplied
prop per template.
- **URL-validation tests** verify that `javascript:`, `http://`,
`data:`, and malformed URLs are rejected when passed to `<Button>`.
- Run `pnpm --filter @toolctrl/mail test` to execute the suite.
## Live preview workflow
```
pnpm --filter @toolctrl/mail preview
```
Then open http://localhost:3001 — the index page lists every
template and links to the rendered HTML plus a `?format=text` link
for the plain-text fallback.
Sample props live in `packages/mail/scripts/previews.ts`. Update
them when adding a template; never commit real PII.
## Render pipeline
`renderTransactionalEmail(template, props)` from `@toolctrl/mail` is
the canonical entry point. The pre-Stripe-19 string-template renderer
(`renderTransactionalMailTemplate`) was retired in the legacy-cleanup
pass; the host application's wrapper at
`apps/web/app/modules/emails/utils/EmailTemplates.ts` adapts the
legacy template-name surface onto the React-Email pipeline.
| Legacy name | React template | Notes |
| --- | --- | --- |
| `welcome` | `welcome` | direct migration |
| `password-reset` | `password-reset` | direct migration |
| `user-invitation` | `invitation` | renamed for consistency |
| `email-verification` | `magic-link` | merged into magic-link flow (30-min expiry variant) |
| `account-setup` | `welcome` | unified onboarding (overridable subject/eyebrow/title/intro/actionLabel) |
| — | `magic-link` | new |
| — | `subscription-success` / `subscription-failed` | new (Stripe webhooks) |
| — | `invoice-sent` | new (Stripe invoices) |
| — | `tenant-notification` | new (in-app digest) |
## Security notes
- React's default escaping is the primary defense — manual
`escapeHtml` calls are no longer required and any new
template that performs string concatenation in HTML attributes
will be rejected in code review.
- `<Button>` enforces an allow-list of URL schemes
(`https:`, `mailto:`) to prevent magic-link phishing via
`javascript:` or `data:` URLs.
- `dangerouslySetInnerHTML` is forbidden in
`packages/mail/src/templates-react/`. If you need rich text,
build a structured prop shape and render it as JSX.
- Brand logo URLs come from `packages/brand` which enforces an
https-only allow-list; the React templates never load
arbitrary tenant-supplied URLs.
@@ -0,0 +1,121 @@
# Job Priority
Plan-based BullMQ job priority. Lets enterprise tenants jump the queue
over free-tier batches and pins internal/system jobs to the top.
## Why
Before this feature every BullMQ job ran at the default priority. A
free-tier tenant scheduling 10 000 mail jobs would block an enterprise
tenant's password-reset behind them. Cron and retention jobs competed
with user workload. There was no way to express "this auth mail must
run now" semantically.
## Priority levels
| Constant | Value | Use case |
| ------------------------ | ----- | --------------------------------------------------------- |
| `JOB_PRIORITY_CRITICAL` | `1` | Internal jobs, auth flows (password-reset, magic-link) |
| `JOB_PRIORITY_HIGH` | `3` | System-triggered workflows that aren't auth |
| `JOB_PRIORITY_NORMAL` | `5` | Default for tenant-scoped jobs without a plan lookup |
| `JOB_PRIORITY_LOW` | `10` | Marketing / bulk batches; free-tier when a lookup is set |
BullMQ treats `priority: 1` as highest. We intentionally use only 4
distinct levels — Grafana dashboards stay legible and the priority
sorted-set in Redis stays small.
## Resolution order (in `enqueueJob`)
```
1. options.priority (explicit numeric) → wins everything
2. options.priorityOverride ('critical'…) → mapped via priorityFromOverride
3. options.tenantId === null → CRITICAL (internal job)
4. options.tenantId set, lookup registered → resolveJobPriorityForTenant
5. options.tenantId set, no lookup → NORMAL
6. options absent → no priority field set
```
## Plan → priority mapping
| Plan slug | Priority |
| ------------ | ------------------ |
| `enterprise` | CRITICAL (1) |
| `pro` | NORMAL (5) |
| `free` | LOW (10) |
| unknown/null | NORMAL (5) |
The mapping lives in `packages/queue/src/priority.ts` in
`priorityFromPlan(plan)`.
## Producer guidelines
1. **Always pass `tenantId`** for tenant-scoped jobs. The descriptor
already carries the tenantId in most cases; pass the same value to
the enqueue options:
```ts
await enqueueJob(queue, descriptor, { tenantId });
```
2. **Pass `tenantId: null` for internal jobs** — retention, cleanup,
audit-compaction, backup triggers. They get CRITICAL automatically.
3. **Use `priorityOverride: 'critical'` for auth flows** regardless of
plan: password-reset, email-verification, magic-link, user
invitation. A free-tier user must still receive their reset mail
instantly.
4. **Use `priorityOverride: 'low'` for marketing batches.** Don't let
newsletter batches starve transactional mail.
5. **Never set `priorityOverride: 'critical'` from untrusted input.**
The override hook is server-only.
## Registering a tenant-plan lookup
The queue package keeps its plan-lookup pluggable so workers don't
have to import apps/web. Web boots the lookup during cache bootstrap:
```ts
// apps/web/app/utils/services/.server/tenantPlanLookup.server.ts
setSubscriptionToPlanTier((subscription) => {
// your classifier here — e.g. map stripe product ids to plan slugs
return subscription?.products[0]?.subscriptionProductId === "prod_enterprise"
? "enterprise"
: subscription?.products.length
? "pro"
: "free";
});
wireTenantPlanLookup();
```
Until a classifier is wired, the resolver returns NORMAL for every
tenant (CRITICAL still applies to internal jobs).
## Feature-flag
`JOB_PRIORITY_ENABLED=false` neutralises plan-based priority — every
tenant-scoped enqueue receives NORMAL (5). Internal jobs still get
CRITICAL because `tenantId: null` short-circuits before the flag is
checked. Use this for emergency rollback without a code change.
## Monitoring
The Counter `queue_jobs_enqueued_total{queue, priority}` increments
on every enqueue. The `priority` label is one of
`critical|high|normal|low|other` (low-cardinality).
Useful Grafana queries:
```promql
# Enqueue rate by priority for a queue
sum by (priority) (rate(queue_jobs_enqueued_total{queue="toolctrl.email"}[5m]))
# Share of critical vs total
sum(rate(queue_jobs_enqueued_total{priority="critical"}[5m]))
/
sum(rate(queue_jobs_enqueued_total[5m]))
```
## Rollback
1. Set `JOB_PRIORITY_ENABLED=false` in env → next enqueue runs with
NORMAL.
2. To remove the feature entirely: revert the producer-call-site
commits; the resolver helper stays but goes unused.
3. In-flight jobs keep the priority assigned at enqueue time.
@@ -0,0 +1,190 @@
# Lighthouse-CI audit and Web-Vitals reporting
This document describes how the Lighthouse-CI audit is wired into the
Toolctrl Boilerplate, how to run it locally when debugging a regression,
and how the Web-Vitals → Sentry bridge surfaces real-user metrics.
Spec: `docs/improvement-plan/10-compliance-polish/lighthouse.md`
(Task #70). Companion artefacts: `.lighthouserc.staging.json`,
`.lighthouserc.json`, `scripts/lighthouse-puppeteer-auth.cjs`,
`scripts/rotate-lighthouse-session-cookie.mjs`,
`.github/workflows/lighthouse.yml`, `.gitea/workflows/lighthouse.yml`,
`apps/web/app/lib/observability/web-vitals.ts`,
`apps/web/app/lib/pwa/registerServiceWorker.ts`,
`apps/web/public/manifest.webmanifest`, `apps/web/public/sw.js`.
## Where the audit runs
A meaningful Lighthouse audit needs a live server, a database and seeded
content. Reproducing that whole stack inside a CI job is brittle, so the
CI workflow audits the **deployed staging site**
(`https://boiler.saas-ctrl.com`) rather than a CI-local server.
That makes it a **post-deploy / monitoring audit, not a per-commit merge
gate** — staging always lags the commit under review. The workflow
(`.gitea/workflows/lighthouse.yml`, mirrored in `.github/`) runs:
- **nightly** at 03:00 UTC (`schedule`), and
- **on demand** (`workflow_dispatch`).
A red run means the deployed staging site fell below a threshold — treat
it as a production-quality alarm, not a blocked PR.
The Gitea runner image (`node:20-bookworm`) ships no browser, so the
workflow installs `chromium` via `apt-get` and points LHCI at it with
`CHROME_PATH=/usr/bin/chromium`. The GitHub runner (`ubuntu-latest`)
already bundles Chrome.
## Audit targets
`.lighthouserc.staging.json` audits the public staging routes — the
surface that renders without authentication:
| URL |
| -------------------------------------------- |
| `https://boiler.saas-ctrl.com/login` |
| `https://boiler.saas-ctrl.com/privacy-policy` |
| `https://boiler.saas-ctrl.com/terms-and-conditions` |
Only **Performance** is a hard gate (`error`). The product is a B2B
SaaS, not a search-discoverable marketing site, so the SEO category is
not asserted at all. Accessibility and Best-Practices stay visible as
`warn` — accessibility is already hard-gated separately by `a11y.yml`
(axe-core plus the `jsx-a11y` ESLint baseline, task #73), so a second
blocking gate here would be redundant.
| Category | Threshold | Severity |
| --------------- | --------- | ------------------------- |
| Performance | 0.95 | error |
| Accessibility | 0.95 | warn |
| Best Practices | 0.95 | warn |
| PWA | 0.90 | warn |
| SEO | — | not asserted (irrelevant) |
Numeric audits (FCP / LCP / TBT / CLS) stay at `warn` severity so trend
regressions remain visible without failing the run.
## Session cookie rotation
The workflow mints a fresh staging session before every LHCI run via
`scripts/rotate-lighthouse-session-cookie.mjs`. The script logs in as the
dedicated `lighthouse-bot` account, extracts the CSRF token from
`/login`, posts the login form with the CSRF header, then appends only
the resulting session cookie to the Actions environment. It never prints
the cookie or password.
Required workflow secrets:
| Secret | Purpose |
| ------ | ------- |
| `LIGHTHOUSE_LOGIN_EMAIL` | Email of the staging Lighthouse bot user. |
| `LIGHTHOUSE_LOGIN_PASSWORD` | Password of the staging Lighthouse bot user. |
The session cookie name is `RJ_session`; workflows also pass
`LIGHTHOUSE_SESSION_COOKIE_NAME=RJ_session` and
`LIGHTHOUSE_COOKIE_DOMAIN=boiler.saas-ctrl.com`.
During rollout the workflow still forwards the older
`LIGHTHOUSE_SESSION_COOKIE` secret as a fallback. If the login email or
password secret is absent, the script reuses that pre-provisioned cookie
without printing it. Permanent operation should use the login secrets so
each run mints a fresh cookie.
## Authenticated routes
Auditing `/app/$tenant/*` and `/admin/*` needs that freshly minted
`RJ_session` cookie. `scripts/lighthouse-puppeteer-auth.cjs` injects the
cookie for protected URLs and is already wired into
`.lighthouserc.staging.json`. The current staging target list still
audits only public routes; add protected URLs once the exact tenant/admin
coverage list is chosen. Without a protected URL in the config, the
cookie step is a readiness/rotation guard rather than an auth coverage
expansion.
## Running Lighthouse locally
`.lighthouserc.json` is the local-iteration config (URLs on
`http://localhost:3000`); `pnpm verify:lighthouse` runs it via
`scripts/run-lighthouse.mjs`. Bring the app up first:
```sh
# Build apps/web (writes hashed assets the runtime SW expects to cache)
pnpm --filter @toolctrl/web build
# Serve the build at http://localhost:3000.
pnpm --filter @toolctrl/web start &
# Run the full LHCI flow against the local server
pnpm verify:lighthouse
```
To reproduce a CI staging finding locally, point LHCI at the staging
config instead — no local server needed, just a Chrome install:
```sh
pnpm dlx @lhci/cli@0.13.x autorun --config=./.lighthouserc.staging.json
```
The run prints the temporary-public-storage report URLs in its output;
share those when debugging a regression.
## PWA manifest + service-worker
`apps/web/public/manifest.webmanifest` describes the installable app.
`apps/web/public/sw.js` is a minimal cache-first / network-first service
worker registered from `root.tsx` via `registerServiceWorker`.
Cache strategy:
- `/assets/...` (hashed by React-Router build) → **cache-first**
- `/api/...`, `/app/...`, `/admin/...` → **network-first** with cache fallback
- everything else (marketing routes, brand assets) → **network-first**
The `CACHE_VERSION` constant inside `sw.js` is the invalidation lever —
bump it on any deploy that changes the asset graph in a backward-
incompatible way. Old caches are dropped automatically in the activate
handler.
To debug a stuck SW, open DevTools → Application → Service Workers and
click "Unregister". The next page load picks up the new `sw.js`.
## Web-Vitals → Sentry
`apps/web/app/lib/observability/web-vitals.ts` registers callbacks for
CLS, LCP, INP, TTFB, and FCP via the `web-vitals` library. Each metric is
captured as a Sentry message named `web-vitals.<metric>` with tags:
```text
web.vital.name = CLS | LCP | INP | TTFB | FCP
web.vital.rating = good | needs-improvement | poor
```
In the Sentry dashboard:
1. Filter to `message:"web-vitals.LCP"` to view a metric in isolation.
2. Group by `tags.web.vital.rating` to see the share of "poor" loads.
3. Group by `transaction` (Sentry React-Router integration) to compare
metrics across routes.
The helper is SSR-safe (bails on `typeof window === "undefined"`) and
silently no-ops when Sentry isn't initialised, so the same code runs in
dev, preview, staging, and production without extra gating.
## Troubleshooting CI runs
| Symptom | Likely cause / fix |
| -------------------------------------------------- | ----------------------------------------------------------------------------- |
| `Chrome installation not found` / `Healthcheck failed!` | The runner has no browser. The Gitea workflow installs `chromium`; confirm the install step ran and `CHROME_PATH` is set. |
| Cookie rotation step fails with missing env vars | Add `LIGHTHOUSE_LOGIN_EMAIL` and `LIGHTHOUSE_LOGIN_PASSWORD` as repo Actions secrets. |
| Audit reports the `/login` page for a protected URL | Cookie injection failed or the bot no longer belongs to the tenant/admin surface being audited. |
| Performance score < 0.95 only on first run | Cold cache; LHCI reruns automatically (`numberOfRuns=3`). |
| Audit times out | Lower `numberOfRuns` to 1 temporarily while iterating; restore afterwards. |
| All URLs fail to load | Staging is down or redeploying — check the `web` container on staging. |
## Updating thresholds
Permanent relaxation requires a `.lighthouserc.staging.json` commit plus
a paragraph in this document explaining the trade-off. Prefer switching
a category from `error` to `warn` over dropping it, so regressions stay
visible — SEO is the one deliberate exception (not relevant to a B2B
SaaS, so not asserted at all).
@@ -0,0 +1,270 @@
# Transactional Outbox Pattern
The outbox pattern keeps side-effects (mail sends, webhooks, audit logs,
workflow triggers) **transactionally consistent** with the database writes
that triggered them. Without it, a crash between `DB COMMIT` and "call
mailer" leaves the DB updated but the side-effect lost.
This document describes how to publish and consume outbox events in this
codebase. See `docs/improvement-plan/03-scaling-infrastructure/outbox-pattern.md`
for the broader design rationale.
## When to publish
Publish **inside** a Prisma `$transaction` block whenever a successful DB
write should fan out an asynchronous side-effect.
```ts
import { publishOutboxEvent } from "@toolctrl/db/outbox";
await prisma.$transaction(async (tx) => {
const subscription = await tx.subscription.update({
where: { id },
data: { status: "active" },
});
await publishOutboxEvent(tx, {
tenantId: subscription.tenantId,
type: "billing.subscription.updated",
payload: { subscriptionId: subscription.id, status: "active" },
dispatchKey: `stripe:${stripeEvent.id}`, // optional idempotency key
});
});
```
`publishOutboxEvent` is type-constrained to require a
`Prisma.TransactionClient` — calling it with a top-level `PrismaClient`
fails to type-check.
## Event-type naming convention
Events follow `<domain>.<entity>.<verb>`, lowercase, dot-separated. The
dispatcher routes by the first segment by default:
| Prefix | Target BullMQ queue |
| ------------------------ | ------------------------------ |
| `tenant.subscription.` | `outbox.notification` |
| `mail.` | `outbox.mail` |
| `webhook.` | `outbox.webhook` |
| `notification.` | `outbox.notification` |
| `audit.` | `outbox.audit` |
| `workflow.` | `outbox.workflow` |
| `search.index.` | `outbox.search-index` |
| `rag.index.` | `outbox.rag-index` |
| `ai.cost.` | `outbox.ai-cost` |
| `marketing.lead.` | `outbox.marketing` |
| `form.submission.` | `outbox.form-submission-route` |
| `billing.` | `outbox.notification` |
| `plugin.hook.` | `outbox.plugin-hook` |
| `plugin.capability-use.` | `outbox.plugin-capability-use` |
Add a routing rule before introducing a new prefix — events with no
matching rule go straight to FAILED with a Sentry alert. See
`apps/worker/src/outbox/event-router.ts`.
Workflow routing has an active receiver/subscriber path when the worker is
configured for the `general` queue group with the workflow execution settings.
`apps/worker/src/jobs/workflow-trigger.ts` parses `workflow.*` outbox jobs,
applies receiver-side idempotency, fans out to matching live event-trigger
workflows, starts queued manual `workflow.execution.requested` executions, and
resumes queued `workflow.execution.continue.requested` continuations through the
selected executor. HTTP mode uses the signed internal bridge; hybrid/native mode
can resume worker-native safe-core continuations when the only native preflight
gap is the current `waitForInput` boundary. Candidates that fail preflight are
skipped or recorded as rejected.
`apps/worker/src/jobs/workflow-trigger-store.ts` provides the worker-native
Prisma lookup/preflight port. Application events publish
`workflow.trigger.event` through `EventsService`. The route-backed row mutation
producers for `row.created`, `row.updated`, and `row.deleted` record the row
write, application event, and workflow outbox event in the same transaction
before post-commit notifications/webhook callouts run. Internal mutations
without a request context remain intentionally eventless unless the caller opts
into an event context. Workflow row blocks pass a `workflow` event context so
requestless `rowCreate`, `rowUpdate`, and `rowDelete` blocks still publish row
events without enabling external webhook callouts. A source-inventory test guards the current execution
boundary: manual and tenant API runs must publish
`workflow.execution.requested` instead of executing inline, non-stream
continuations must publish `workflow.execution.continue.requested`, only the
internal workflow bridge may execute
`type: "trigger"` directly, and the legacy `WorkflowEngine.trigger(...)`
facade must stay absent.
The worker-native block contract is intentionally explicit. `email` is the
first-class mail block, `httpRequest` is the outbound/webhook-style block, and
conditions are represented by `if` plus `switch`. There is no separate
`mail`, `webhook`, `notification`, `audit`, `delay`, or `schedule` block id in
the registry today; adding any of those requires updating
`packages/foundation/src/workflows.ts`, its capability matrix tests, workflow
UI i18n, and worker-native execution/preflight behavior together.
Operationally, the outbox dispatcher and workflow subscriber are separate from
mail delivery workers. Production-like Coolify stacks need a persistent
`worker-general` (`WORKER_QUEUE_GROUP=general`) for the dispatcher and
`outbox.workflow`, plus a persistent `worker-mail`
(`WORKER_QUEUE_GROUP=mail`) for `outbox.mail`, `outbox.webhook`,
`outbox.marketing`, and `toolctrl.email` work. A green outbox row does not prove
that both services exist or that the downstream BullMQ job finished.
### Non-durable exception: stream runs
Workflow stream starts remain an explicit exception to the worker-durable
execution boundary. The stream route starts through
`WorkflowsExecutionsService.stream(...)`, creates a running execution row, and
then calls `execute(...)` in the web process so the existing SSE/emitter
progress contract can deliver local run updates to the browser. Web restarts can
interrupt an in-flight stream run before the worker can resume it. Stream
continuations are already queued through `workflow.execution.continue.requested`;
moving stream starts to the worker requires a distributed progress channel or
SSE broker rather than a partial outbox migration.
The worker bridge has an opt-in real-Postgres regression test at
`apps/worker/src/jobs/workflow-trigger-e2e.test.ts`. It skips without a real
database URL and runs with `WORKFLOW_TRIGGER_E2E_DATABASE_URL`, falling back to
`OUTBOX_E2E_DATABASE_URL` or any non-stub `DATABASE_URL`. The test publishes a
workflow trigger outbox event, claims and routes it through the production
dispatcher seam, then executes a live safe-core workflow through the native
worker runner and verifies idempotent re-delivery. It also verifies the
`workflow.executed` plugin hook path by routing `plugin.hook.workflow.executed`
through `outbox.plugin-hook` fanout into `toolctrl.plugin-invoke`. A paired
failure-path fixture forces an unsupported native block, verifies terminal
`status: "error"` persistence, then routes `plugin.hook.workflow.failed`
through the same fanout boundary into `toolctrl.plugin-invoke`.
The canonical non-browser readiness gate for this path is
`pnpm run verify:workflow-outbox-native`. It composes the existing focused
worker, foundation, and source-inventory tests for `outbox.workflow` routing,
workflow-trigger subscriber bootstrap, native/hybrid executor selection,
credential-free native execution boundaries, worker health fail-loud settings,
and queued manual/API/continuation producers. It is intentionally not a live
drain probe; use the opt-in real-Postgres regression or the operator-gated E2E
drain readiness check when an environment-level worker/Redis/Postgres proof is
needed.
## Dispatcher
`apps/worker/src/outbox/dispatcher.ts` polls every 1 second (configurable
via `OUTBOX_DISPATCH_INTERVAL_MS`). Each tick:
1. **Atomic claim**: `UPDATE … SELECT … FOR UPDATE SKIP LOCKED RETURNING *`
moves up to `OUTBOX_DISPATCH_BATCH_SIZE` (default 100) PENDING rows to
PROCESSING with `lockedUntil = now() + 5 min`. Two dispatchers can run
concurrently without ever claiming the same event.
2. **Crash recovery**: rows stuck in PROCESSING past `lockedUntil` are
re-claimed automatically.
3. **Routing**: each event's `eventType` is mapped to a BullMQ queue via
the event router.
4. **Enqueue + status update**: claimed rows are processed with bounded
concurrency (`OUTBOX_DISPATCH_CONCURRENCY`, default 10) so a large
batch does not serialize every Redis enqueue and status update.
The default BullMQ enqueue path adds receiver-level retries with exponential
backoff before marking the outbox row `DONE`; transient subscriber failures
are retried by the target queue instead of being lost after dispatch.
It also uses a deterministic BullMQ `jobId` of `outbox:<eventId>` so a
dispatcher crash after enqueue but before the Postgres `DONE` update does
not create a second receiver job when the same outbox row is re-claimed.
- success → `status = DONE`, `processedAt = now()`
- failure → increment `retries`, `scheduledAt = now() + backoff(retries)`
with backoff `[1, 2, 4, 8, 16, 32]` minutes.
- retries > `OUTBOX_MAX_RETRIES` (default 6) → `status = FAILED` (dead-
letter).
`DONE` means the dispatcher successfully handed the event to the target BullMQ
queue and recorded that handoff in Postgres. It does not mean the subscriber
processed the job, the provider call succeeded, or a domain state machine
reached a terminal success state. For workflow events, operators must also
watch the `outbox.workflow` BullMQ job state and the associated
`WorkflowExecution` status. For mail/webhook events, also watch the
`worker-mail` subscriber queues and the `toolctrl.email` job state.
The dispatcher is enabled by `OUTBOX_DISPATCHER_ENABLED=true`. Defaults to
**off** in dev so a missing Redis URL does not crash the worker.
## Retention
`worker-general` registers a daily `cleanup.run` maintenance job named
`outbox-done-retention`. It deletes only rows with `status = DONE` and a
non-null `processedAt` older than `OUTBOX_DONE_RETENTION_HOURS` (default 720
hours, 30 days). The deletion is bounded by `OUTBOX_DONE_RETENTION_BATCH_SIZE`
(default 1000, clamped to 1..10000) and uses `FOR UPDATE SKIP LOCKED`, so large
first runs drain gradually without blocking the dispatcher.
FAILED and CANCELLED rows are intentionally retained for audit and incident
review. Deleting DONE rows releases their `dispatchKey` uniqueness after the
retention window; producers that require longer idempotency windows must use a
domain-level idempotency key as well.
## Receiver-side idempotency
BullMQ delivery is at-least-once: a receiver crash between
"job processed" and "ack" causes a redelivery. Wrap your handler in
`withOutboxIdempotency` so duplicate deliveries are a no-op.
```ts
import { withOutboxIdempotency } from "@toolctrl/db/outbox-receiver";
import { createIdempotencyStore } from "@toolctrl/shared-store";
const idempotencyStore = createIdempotencyStore({
store: sharedStore,
prefix: "outbox",
ttlSeconds: 60 * 60 * 24, // 24h — longer than the longest retry window
});
await withOutboxIdempotency(idempotencyStore, job.data.outboxEventId, async () => {
await sendMail(job.data.payload);
});
```
`claim()` does an atomic `SET NX EX`. If the claim fails (another worker
already processed the event), the handler is skipped and the cached
result returned.
## Operational
### Pending count alert
`SELECT count(*) FROM "OutboxEvent" WHERE status = 'PENDING' AND scheduledAt < now() - interval '60 seconds'`
Alert when > 1000 for > 5 minutes — that means the dispatcher is behind.
### Downstream queue alert
Outbox `DONE` counts are not sufficient for worker health. Alert separately on
target BullMQ queue depth, failed jobs, and stalled jobs for `outbox.workflow`,
`outbox.mail`, `outbox.webhook`, `outbox.marketing`, and `toolctrl.email`.
Workflow incidents must include the `WorkflowExecution` terminal status because
`workflow.execution.requested` can be handed to BullMQ successfully while the
execution later fails, stalls, or remains queued behind a missing bridge.
### Re-queueing failed events
Use `toolctrl-cli outbox requeue` after fixing the underlying receiver.
The CLI supports single-row and filtered bulk requeues, `--dry-run`, JSON
output, and guardrails that avoid replaying `PENDING` / `PROCESSING`
rows. Prefer it over raw SQL so `processedAt`, `lastError`, retry state,
and replay warnings stay consistent.
Raw SQL is an emergency-only fallback for a DBA-led incident when the CLI
cannot run; record the exact statement and reason in the incident log.
### Environment
| Variable | Default | Purpose |
| ----------------------------- | ------- | ------------------------------------------------- |
| `OUTBOX_DISPATCHER_ENABLED` | `false` | Master switch for the dispatcher worker |
| `OUTBOX_DISPATCH_INTERVAL_MS` | `1000` | Polling cadence (ms) |
| `OUTBOX_DISPATCH_BATCH_SIZE` | `100` | Max events claimed per tick |
| `OUTBOX_DISPATCH_CONCURRENCY` | `10` | Max claimed events processed in parallel per tick |
| `OUTBOX_MAX_RETRIES` | `6` | Retries before dead-letter |
| `OUTBOX_DONE_RETENTION_HOURS` | `720` | DONE-row replay/debug retention window |
| `OUTBOX_DONE_RETENTION_BATCH_SIZE` | `1000` | Max DONE rows purged per maintenance run |
## Trade-offs
- **Latency**: ~1s polling tick. A LISTEN/NOTIFY variant could cut that
to <10ms; deferred until needed.
- **At-least-once delivery**: receivers MUST be idempotent
(`withOutboxIdempotency` is the cheap path).
- **Storage**: each successful event leaves a DONE row until the
`outbox-done-retention` job purges DONE rows older than
`OUTBOX_DONE_RETENTION_HOURS`. FAILED rows are kept for operator triage and
compliance review.
@@ -0,0 +1,196 @@
# Plugin Hooks & Lifecycle
**Phase 06 Task #22 foundation.** Builds on the WASM sandbox runtime from #21.
## What ships
This task delivers the _foundation_ for plugin host integration. Marketplace
browsing, hot-reload, per-instruction fuel, the full 15-20 hook-point catalogue
and broader marketplace workflows are deferred to follow-up work (`#23`
audit-docs + backlog/marketplace). Tenant plugin audit events are now exposed
through `apps/web/app/routes/app.$tenant/settings/plugins.$id.audit.tsx`.
### 1. Hook registry — `packages/plugin-runtime/src/hooks/registry.ts`
A central, immutable map of hook-name → zod payload schema. Foundation
hooks are pre-registered:
| Hook name | Outbox event type | When it fires |
| ------------------- | ------------------------------- | -------------------------------------------------------- |
| `tenant.created` | `plugin.hook.tenant.created` | `createTenant` row insert succeeded |
| `user.created` | `plugin.hook.user.created` | `register` user row insert succeeded |
| `entity.created` | `plugin.hook.entity.created` | `RowsApi.create` after row + log write |
| `entity.updated` | `plugin.hook.entity.updated` | `RowsApi.update` after row + log write |
| `entity.deleted` | `plugin.hook.entity.deleted` | `RowsApi.del` after hardDelete |
| `workflow.created` | `plugin.hook.workflow.created` | Tenant-scoped workflow definition is created through reviewed service/import paths |
| `workflow.updated` | `plugin.hook.workflow.updated` | Tenant-scoped workflow definition is updated through reviewed service paths |
| `workflow.deleted` | `plugin.hook.workflow.deleted` | Tenant-scoped workflow definition is deleted through reviewed service paths |
| `workflow.executed` | `plugin.hook.workflow.executed` | Tenant-scoped workflow execution reaches terminal status |
| `workflow.failed` | `plugin.hook.workflow.failed` | Tenant-scoped workflow execution reaches terminal error status |
Adding a new hook is a single `registerHook({ name, outboxEventType,
payloadSchema })` call at module load time. The registry validates that the
event type matches `plugin.hook.<entity>.<verb>` so the outbox dispatcher's
existing routing rules cover it without changes.
### 2. Hook emission — `packages/plugin-runtime/src/hooks/emit.ts`
`emitPluginHook(tx, def, payload, publish)` validates the payload and forwards
to the outbox publisher. The host-side wrapper
`apps/web/app/modules/plugins/services/.server/pluginHookPublisher.ts` owns a
one-statement Prisma transaction so callers stay a single line:
```ts
await emitPluginHookFireAndForget(PLUGIN_HOOK_TENANT_CREATED, {...}, {
tenantId,
dispatchKey: `tenant:${id}:created`,
});
```
The wrapper is intentionally **non-fatal to the surrounding business write**:
hook delivery failures are logged but never propagate into tenant signup, row
CRUD, or workflow service calls. It still returns a promise, and reviewed
workflow definition producers await it so the one-shot outbox transaction is
attempted before the service returns. A malformed payload triggers
`PluginHookValidationError`, which is also swallowed and logged.
### 3. Plugin lifecycle service — `apps/web/app/modules/plugins/services/.server/PluginLifecycleService.ts`
`installPlugin`, `enablePlugin`, `disablePlugin`, `listPlugins`. Each mutation
writes a hash-chained audit-event row through the existing
`createAuditEvent` helper. The service is permission-guarded at the route
layer: read-only list/settings views require `app.plugins.use`; capability-use
audit trails require `app.settings.auditTrails.view`; install, capability grant,
enable, and disable require `app.plugins.manage`.
### 4. Tenant admin UI — `apps/web/app/routes/app.$tenant/settings/plugins.tsx`
A minimal settings page that lists installed plugins with their capabilities,
manifest preview, and an enable/disable toggle. CSRF-validated form
submissions go through the lifecycle service.
### 5. Plugin migration applier — `packages/plugin-runtime/src/migrations.ts`
`applyPluginMigration({ pluginId, sql }, deps)` parses the SQL, enforces
`plugin_<sanitized-pluginId>_` table prefix, requires a `tenantId` column on
every CREATE TABLE, and rejects anything outside the `CREATE TABLE / CREATE
INDEX` allowlist. Forbidden tokens (GRANT, DROP DATABASE, COPY, pg\_\*, etc.)
are caught at validation time so the host never runs them.
### 6. Crash tracker — `packages/plugin-runtime/src/crash-tracker.ts`
SharedStore-backed sliding window per `(pluginId, tenantId)`. Failures
increment the counter (TTL = configurable window, default 10 minutes).
Successes decrement (floored at 0). Once the threshold (default 10) is
crossed the tracker calls the injected `disablePlugin` seam, calls the
optional `notify` seam, and resets the counter so a re-enable starts
fresh. Wired into `apps/worker/src/jobs/plugin-invoke.ts` via an optional
`recordOutcomeForCrashTracking` seam; `plugin_not_found` / `plugin_disabled`
outcomes are deliberately skipped so configuration glitches don't trigger
auto-disable.
### 7. Workflow definition hooks
`apps/web/app/modules/workflowEngine/services/WorkflowsService.ts` emits
metadata-only `workflow.created`, `workflow.updated`, and `workflow.deleted`
hooks for tenant-scoped service-path definition writes and reviewed template
imports. The reviewed create/update/delete/import paths await the publisher
attempt after the workflow write succeeds; publisher failures are recorded by
the hook publisher logger and remain non-fatal to the workflow mutation.
Platform-scoped definitions and writes that bypass those reviewed paths remain
outside current producer coverage.
### 8. Workflow execution hook — `workflow.executed`
`apps/web/app/modules/workflowEngine/services/WorkflowsExecutionsService.ts`
emits `workflow.executed` after `updateWorkflowExecution` returns a terminal
tenant-scoped `success` or `error` status. The payload intentionally carries
only execution metadata (`tenantId`, `workflowId`, `workflowExecutionId`,
`type`, `status`, `durationMs`, `errorCode`, `appliesToAllTenants`,
`occurredAt`). `errorCode` is null for `success` and required for `error`. Raw
error strings, workflow input, and workflow output bodies are not included
because they can contain provider responses, URLs, or secret-like values.
Failed executions also emit `workflow.failed` with the same metadata-only shape
but a fixed `status: "error"` and required `errorCode`. The separate failure
hook lets plugins subscribe only to failure automation while existing
`workflow.executed` subscribers continue to receive the full terminal-status
stream.
Current producer coverage for workflow lifecycle hooks:
| Lifecycle event | Hook(s) | Producer coverage | Payload boundary |
| --------------- | ------- | ----------------- | ---------------- |
| Tenant-scoped definition create | `workflow.created` | `WorkflowsService.create` and reviewed tenant template imports | Metadata only; no blocks, credentials, or raw input |
| Tenant-scoped definition update | `workflow.updated` | `WorkflowsService.update` | Metadata plus changed field names only |
| Tenant-scoped definition delete | `workflow.deleted` | `WorkflowsService.del` | Metadata only |
| Tenant-scoped web/API/manual execution success | `workflow.executed` | `WorkflowsExecutionsService` | Terminal metadata, `errorCode: null` |
| Tenant-scoped web/API/manual execution error | `workflow.executed`, `workflow.failed` | `WorkflowsExecutionsService` | Terminal metadata, stable `errorCode`; no raw error |
| Worker-native trigger execution | `workflow.executed`, `workflow.failed` | `apps/worker/src/jobs/workflow-trigger-native-executor.ts` | Terminal metadata only; failures use the stable `workflow_execution_failed` code |
### 9. Worker-side hook fanout — `apps/worker/src/jobs/plugin-hook-fanout.ts`
The outbox dispatcher routes `plugin.hook.*` events to
`outbox.plugin-hook` on the `general` worker group. The fanout consumer
loads enabled plugins for the tenant, filters manifests that declare the
hook, and enqueues one idempotent `plugin.invoke` job per subscriber on
the isolated `toolctrl.plugin-invoke` queue. Plugin execution itself stays
on `WORKER_QUEUE_GROUP=plugin`.
### 10. Per-user enablement — `prisma/schema/plugins.prisma`
Adds `PluginUserConfig { id, pluginId, userId, enabled, configJson }` with
unique `(pluginId, userId)` and FK cascade on Plugin delete. User-scoped hook
fanout loads `PluginUserConfig.enabled` when the validated hook payload includes
`userId`; `enabled === false` skips that user's plugin invocation while
tenant-default subscribers remain eligible.
### 11. Operator CLI — `packages/cli/src/commands/plugins/*`
```
toolctrl-cli plugins list [--tenantId X] [--enabled true|false]
toolctrl-cli plugins enable --tenantId X --pluginId com.acme.slack
toolctrl-cli plugins disable --tenantId X --pluginId com.acme.slack
toolctrl-cli plugins uninstall --tenantId X --pluginId com.acme.slack
```
## Remaining Slices
Current status and operator-facing caveats live in `docs/features/plugins.md`
and `docs/operations/plugin-lifecycle.md`. The historical implementation plan
in `docs/improvement-plan/06-plugin-system/hooks-lifecycle.md` remains useful
context, but it is not the current source of truth.
- The remaining hook points (`onWebhookOutbound`, `onEmailSend`,
`afterAuthLogin`, billing, integration-connected,
api-key-created, chatbot-message, export-run, etc.).
- Synchronous output hooks (`emitHookSync` with a 500ms wall-clock budget
for `transform` / `cancel` capabilities).
- Dedicated marketplace/install dialog UX. The operator-side install path runs
through manifest verification, rejects unknown hook names, and tenant
settings already approve missing manifest-declared grants via checkbox plus a
server-verified digest.
- Plugin secret storage and encrypted host bindings remain pending. The
reusable settings iframe shell already enforces opaque-origin sandboxing,
CSP, and a versioned postMessage protocol; manifest `ui.settings.entry`
metadata is routed through the server-side bundle loader, and non-secret
user config persists through `PluginUserConfig.configJson`.
- Marketplace browsing + plugin signing-key rotation UI.
- Hot-reload + per-instruction fuel limit.
- Global (cross-tenant) crash counter for operator-level auto-disable.
## Operational notes
- `PLUGIN_AUTO_DISABLE_THRESHOLD` (default 10) and
`PLUGIN_AUTO_DISABLE_WINDOW_MS` (default 600_000) are read by the
worker bootstrap when constructing the crash tracker.
- The outbox dispatcher routes `plugin.hook.*` events to
`outbox.plugin-hook`; that consumer fans out to `toolctrl.plugin-invoke`.
Keep the general worker and the separate plugin worker running in
production-like topology so health checks, queue wiring, and isolation are
exercised. Before external runtime signoff, the production `worker-plugin`
should remain idle/gated unless `PLUGIN_RUNTIME_PRODUCTION_ENABLED=true` is
intentionally enabled.
- Audit events use action codes `plugin.installed`, `plugin.enabled`,
`plugin.disabled` and `plugin.crash.auto_disabled` (the last fired by
the crash-tracker notifier).
@@ -0,0 +1,129 @@
# Plugin system (Phase 06 #21 foundation)
> **Runtime gate:** This is staging/foundation runtime documentation, not
> production authorization. Production third-party plugin execution remains
> blocked until the external WASM sandbox review is signed off in
> [Plugin Runtime Signoff](../security/plugin-runtime-signoff.md).
This document describes the WASM-sandbox foundation shipped under Phase 06 Task #21. It captures the runtime choice, manifest schema, signature trust model, capability gating, rate-limit, audit handling, and the work that is intentionally deferred to follow-up tasks.
## Scope
What the foundation ships:
- **`@toolctrl/plugin-runtime`** — host-side runtime:
- Manifest schema (Zod) with closed-enum capabilities + reverse-DNS plugin IDs + SemVer versions + hex-encoded Ed25519 signature fields.
- Ed25519 signature verifier against the operator-controlled trusted-keys allowlist (`PLUGIN_TRUSTED_PUBLIC_KEYS` CSV).
- WebAssembly sandbox wrapper (`createPluginSandbox`) with deterministic memory cap + host-fn fuel budget, invoked through a killable worker-thread boundary with a wall-clock budget.
- Capability gateway (`createCapabilityGateway`) that wraps host-fn maps and rejects undeclared capability calls.
- **`@toolctrl/plugin-sdk`** — TS types plugin authors build against (`HookHandler`, `HostApi`, `PluginContext`, `defineHook`) and a `hello-world` example.
- **`Plugin` Prisma model + migration** — one row per installed plugin per tenant.
- **`plugin` worker queue group** with the `toolctrl.plugin-invoke` BullMQ queue.
- **`handlePluginInvokeJob`** worker handler (`apps/worker/src/jobs/plugin-invoke.ts`) that wires Plugin lookup → binary load → signature verify → rate-limit → sandbox invoke → audit.
What is **deferred** (tracked in `docs/improvement-plan/06-plugin-system/wasm-sandbox.md` under `## Deviations from Plan` once that file is updated by the executing agent):
- Hot-reload — operators restart the `worker-plugin` pod to pick up new binaries.
- Marketplace integration — backlog (#24).
- CLI build/sign/publish is shipped; see [Plugin CLI Reference](../plugins/cli-reference.md).
- Full guided hook-lifecycle UI (#22) — install/guided consent dialogs remain
deferred; tenant settings now supports list-level approval for missing
manifest-declared capability grants with explicit review confirmation and
stale-manifest digest checks.
- Audit + docs deep-dive (#23) — author docs and the capability matrix have
landed; remaining gaps are external audit sign-off, richer toolchain notes,
and high-risk host bindings beyond the current fail-closed surface.
- E2E testcontainer harness — staging deployment + manual smoke tests in the meantime.
- Worker-thread pool with epoch interruption — the current worker bootstrap uses one killable worker thread per invocation; a pooled runtime and true per-instruction interruption remain deferred.
Operational lifecycle status, worker topology, and current production gaps are maintained in [Plugin Lifecycle Operations](../operations/plugin-lifecycle.md). Product-facing status and user/admin behavior are maintained in [Plugins](../features/plugins.md).
## Runtime choice
**Default: Node 22's built-in `WebAssembly` (V8) with host-fn-driven fuel charging.**
Rationale: native Wasmtime bindings for Node (`@bytecodealliance/jco`, `@bytecodealliance/preview2-shim`) are still pre-1.0 and add a ~40 MB build asset. For the foundation we ship a portable wrapper around Node's built-in `WebAssembly` runtime — sufficient for hard memory limits via `WebAssembly.Memory { maximum }`, a host-fn fuel budget on capability calls, and a worker-thread wall-clock kill for non-yielding code.
**Upgrade path:** `PluginSandbox` is an abstract surface — `invoke`, `close`, `fuelRemaining`. A Wasmer or Wasmtime-jco backend can be dropped in behind the same contract once either offers a stable Component-Model story on Node. This is the documented escape hatch when richer per-instruction fuel becomes a requirement.
## Manifest schema
A plugin ships a `plugin.toml` (or `plugin.json`) alongside the `.wasm` binary. The runtime parses it via `parsePluginManifest`, which returns a discriminated `Result`:
```ts
{
schemaVersion: 1,
id: "de.toolctrl.hello", // reverse-DNS, lowercase
version: "1.0.0", // SemVer
name: "Hello",
entry: "plugin.wasm",
wasmSha256: "<64 hex chars>", // SHA-256 of the .wasm at signing
signature: "<128 hex chars>", // Ed25519 sig over the .wasm bytes
signedBy: "<64 hex chars>", // Ed25519 public key (32 bytes)
allowedHosts: ["api.example.com"], // outbound HTTP allowlist
capabilities: [
{ name: "log" },
{ name: "http.outbound", scope: { allowedHosts: ["api.example.com"] } },
],
hooks: [{ name: "onHello", export: "handleHello" }],
}
```
The schema is `strict()` — unknown top-level fields are rejected. Unknown capability names are rejected against the closed enum in `packages/plugin-runtime/src/capabilities.ts`.
## Signature trust model
- Ed25519 chosen for deterministic signatures, audited Node `libcrypto` implementation, and short 64-byte signatures.
- Operators seed `PLUGIN_TRUSTED_PUBLIC_KEYS` (CSV of hex-encoded 32-byte public keys). Plugins signed by any key outside this list are rejected with `public_key_not_trusted`.
- `verifyPluginSignature`:
1. SHA-256-hashes the `.wasm` bytes and compares against `manifest.wasmSha256` — fail-closed on mismatch.
2. Confirms `manifest.signedBy` is in the trusted-keys allowlist — fail-closed otherwise.
3. Verifies `manifest.signature` over the `.wasm` bytes using `signedBy` — fail-closed otherwise.
- All three checks must pass; the function returns a discriminated reason on failure and never throws.
## Capability gating
The capability gateway wraps a host-fn record so that calls to capabilities the plugin did not declare in its manifest, or that the tenant did not approve for this invocation, throw `CapabilityDeniedError`. The default fuel cost is 100 units per host-fn call; configurable per gateway. Deny paths (`not_declared_in_manifest`, `not_granted_for_tenant`, `no_host_implementation`) fire the `onDenied` callback so the worker can audit them. The plugin worker currently exposes safe synchronous `env.toolctrl_log(i32)` and `env.toolctrl_audit_record(i32)` imports through `invokeSync(...)`; higher-risk host functions remain absent and fail closed.
## Rate limit
The worker handler consumes a per-tenant + per-plugin sliding-window rate limit before invoking the sandbox. The limit's parameters (window size, max calls) are owned by the worker bootstrap and read from env. The handler's `consumeRateLimit` seam returns `{ allowed, remaining }`; the handler short-circuits to `rate_limit_exceeded` on a deny.
## Audit
`handlePluginInvokeJob` always writes an audit row, even on failure. Invocation audit persistence is fail-closed: if the audit write fails, the handler logs `plugin.invoke.audit_failed` and throws so BullMQ can retry/dead-letter instead of acknowledging a plugin invocation with no forensic trail. The audit row carries the tuple `(tenantId, pluginId, version, hookName, outcome)`.
Capability-use telemetry is batched through `PluginCapabilityUseRecorder`. The production plugin worker enables the recorder's fail-closed mode; if the outbox sink is unavailable, the sandbox invocation fails and the plugin invocation is recorded as failed. Local/dev callers can keep the default soft mode when they need best-effort telemetry only.
## Deferred env vars
The full env-var matrix in `docs/improvement-plan/06-plugin-system/wasm-sandbox.md` is broader than the current worker bootstrap. The foundation currently wires:
| Env var | Default | Used by |
|---|---|---|
| `PLUGIN_TRUSTED_PUBLIC_KEYS` | (empty → fail-closed) | `parseTrustedPublicKeys` |
| `PLUGIN_MAX_FUEL` | 1000000 | sandbox fuel limit |
| `PLUGIN_MAX_MEMORY_MB` | 64 | sandbox memory limit |
| `PLUGIN_MAX_WALL_CLOCK_MS` | 5000 | worker-thread sandbox wall-clock kill |
| `PLUGIN_OUTPUT_SANITIZATION_STRICTNESS` | moderate | plugin output redaction strictness |
| `PLUGIN_BINARY_BASE_PATH` | `/app/var/plugins` | plugin binary lookup root for the worker bootstrap |
| `PLUGIN_RATE_LIMIT_PER_MINUTE` | 100 | worker sliding-window rate limit |
| `PLUGIN_RATE_LIMIT_WINDOW_SECONDS` | 60 | worker sliding-window rate limit |
| `PLUGIN_AUTO_DISABLE_THRESHOLD` | 10 | per-tenant/plugin crash auto-disable threshold |
| `PLUGIN_AUTO_DISABLE_WINDOW_MS` | 600000 | auto-disable sliding window |
| `PLUGIN_AUDIT_RETENTION_DAYS` | 90 | `PluginCapabilityUse` retention window |
| `PLUGIN_AUDIT_RETENTION_BATCH_SIZE` | 1000 | audit-retention cleanup batch size |
| `PLUGIN_AUDIT_RETENTION_MAX_BATCHES` | 100 | audit-retention cleanup max batches per run |
| `PLUGIN_AUDIT_RETENTION_CRON` | `0 3 * * *` | audit-retention scheduler pattern |
| `PLUGIN_INSTALL_RATE_LIMIT_PER_MIN` | 5 | tenant plugin-install route rate limit |
| `PLUGIN_SETTINGS_WRITE_RATE_LIMIT_PER_MIN` | 60 | per-user plugin settings write route rate limit |
| `PLUGIN_SETTINGS_FRAME_VIOLATION_RATE_LIMIT_PER_MIN` | 120 | client-reported plugin settings iframe violation route rate limit |
| `PLUGIN_MAX_INSTALLS_PER_TENANT` | 50 | tenant plugin-install cap |
| `WORKER_CONCURRENCY_PLUGIN` | 5 | `group-runtime.ts` |
| `WORKER_QUEUE_GROUP=plugin` | — | worker pod entry-point |
Richer host-binding controls remain runtime follow-up work. Do not treat undocumented knobs as production-enforced until the worker bootstrap reads them; the operator-facing status is tracked in [Plugin Lifecycle Operations](../operations/plugin-lifecycle.md).
## Why this is "foundation"
The full spec describes a 4–5 week effort covering 17 implementation steps and dual-track SDKs (AssemblyScript + Rust), per-instruction fuel via Wasmtime, multi-worker-thread pools, hot-reload, marketplace, signing-key rotation. The foundation shipped here is the **structural skeleton plus minimum-viable WASM execution** — every later step is the same `PluginSandbox` / `CapabilityGateway` / `handlePluginInvokeJob` contract with a richer implementation behind it.
@@ -0,0 +1,201 @@
# Read / Write Split (Prisma `$extends`)
> Phase 03 task #41. Status: code-complete; replica provisioning is operator-owned.
## TL;DR
```ts
import { db, dbRead } from "~/utils/db.server";
// Default — primary (write target). Use for all writes, auth, read-after-write.
await db.user.update({ where: { id }, data: { lastSeenAt: new Date() } });
// Explicit primary — same client, communicates intent.
import { getWriteDbClient } from "@toolctrl/db/client.server";
// Replica — eventually consistent. Use for listings, dashboards, public reads.
const users = await dbRead.user.findMany({ where: { tenantId } });
```
`dbRead` is wired to `DATABASE_READ_URL` when set. When unset (single-DB dev,
self-hosted setups) it returns the same client as `db`, so the code path is
identical and lag = 0.
## Convention: when to use which
| Use `dbRead` (replica) | Use `db` / `getWriteDbClient()` (primary) |
| ---------------------- | ----------------------------------------- |
| List endpoints / paginated grids | All mutations (`create`, `update`, `delete`, `upsert`) |
| Public marketing pages (sites, portals) | Auth-resolution paths (session → user → tenant) |
| Knowledge-base public reads + search | Stripe webhook handlers reading state they just wrote |
| Analytics dashboards & aggregations | Outbox dispatcher reading `PENDING` events (see #42) |
| Admin tenant / role enumerations (post-auth) | Anything inside a `db.$transaction(...)` callback |
| Dashboard counts (`count()` / `groupBy()`) | Idempotency-key lookups (request-scoped consistency) |
| Search-result rendering | Read-after-write inside the same request |
When in doubt: leave on `db` (= primary). The replica is an optimisation,
not a correctness requirement. **Auto-routing is intentionally not
implemented** — the spec rejects it because it makes read-after-write
hazards invisible.
## Read-after-write hazard
Streaming replication is **eventually consistent**:
- Typical lag: < 1 s
- Burst writes (bulk imports, k6 load tests): up to 5–10 s
- A query issued on the replica milliseconds after the write hits the
primary will read the **pre-write** state.
**Mitigation:**
1. Always use `db` inside transactions and for writes.
2. Never re-read on `dbRead` what you just wrote on `db` in the same
request. If you must, route the read through `db` too.
3. Auth-critical paths (`getUserAndTenants`, session resolution) stay on
`db` even though they're "reads" — they gate the rest of the
request, and a stale read can mis-route a tenant boundary check.
## Fallback semantics
`getReadDbClient()`:
```ts
export function getReadDbClient(): DbClient {
const readUrl = process.env.DATABASE_READ_URL;
if (!readUrl || readUrl.length === 0) {
return getDbClient(); // single instance, no double connection
}
// memoised replica singleton
if (!global.__dbRead) {
global.__dbRead = createDbClient({ connectionString: readUrl });
}
return global.__dbRead;
}
```
| State of `DATABASE_READ_URL` | `dbRead` returns |
| ---------------------------- | --------------------------------- |
| unset / empty | the write client (`db === dbRead`) |
| valid `postgres://...` | a distinct read-only client |
This means CI, local dev, and any deploy without a replica continue to
work without code changes. All migrations are zero-risk to roll out.
## Telemetry
`@toolctrl/db/telemetry.server` exposes `attachPrismaTelemetry()` — a
`$extends({ query })` wrapper that records every operation with
`{ client: 'read' | 'write', model, operation, durationMs, ok }`. Wire it
in the app bootstrap by adapting the recorder to your metrics sink
(prom-client `Histogram` / `Counter` or an OpenTelemetry `Meter`). The
canonical counter shape is `prisma.queries.total{client,model,operation}`.
## Coolify / staging setup (operator-owned)
Replica provisioning is **out of scope** for the application repo — these
steps live with the platform operator.
1. **Primary Postgres** (Coolify service `postgres`). `postgresql.conf`:
```
wal_level = replica
max_wal_senders = 3
wal_keep_size = 256MB
```
Then:
```sql
CREATE USER replicator WITH REPLICATION ENCRYPTED PASSWORD '...';
CREATE USER read_user WITH ENCRYPTED PASSWORD '...';
GRANT CONNECT ON DATABASE toolctrl TO read_user;
GRANT USAGE ON SCHEMA public TO read_user;
GRANT SELECT ON ALL TABLES IN SCHEMA public TO read_user;
ALTER DEFAULT PRIVILEGES IN SCHEMA public
GRANT SELECT ON TABLES TO read_user;
```
2. **Replica Postgres** (Coolify service `postgres-replica`,
`postgres:16-alpine`):
```bash
# Init from primary
until pg_basebackup -h postgres -D /var/lib/postgresql/data -U replicator -P -R; do
sleep 5
done
```
`postgresql.replica.conf`:
```
hot_standby = on
primary_conninfo = 'host=postgres port=5432 user=replicator password=...'
```
3. **App env**:
```sh
DATABASE_URL=postgres://app_user:...@pgbouncer:6432/toolctrl?pgbouncer=true&connection_limit=10
DATABASE_READ_URL=postgres://read_user:...@postgres-replica:5432/toolctrl?connection_limit=10
```
Optionally front the replica with its own PgBouncer for connection pooling.
4. **Verification:**
```sql
-- On primary
SELECT * FROM pg_stat_replication;
-- On replica
SELECT pg_last_xact_replay_timestamp(), now() - pg_last_xact_replay_timestamp() AS lag;
```
Grafana panel `prisma.queries.total{client="read"}` should rise on
dashboard / KB / portal traffic.
## Rollback
- **Per-call-site:** `git revert <migration-commit>` flips that module
back to `db` (primary). No data change.
- **Global:** unset `DATABASE_READ_URL` and restart the app. `dbRead === db`,
all traffic hits the primary.
- **Replica down:** Prisma's read client will throw connection errors.
Either unset `DATABASE_READ_URL` (degraded performance, no errors) or
add a circuit-breaker (future work, out of scope here).
## Migration recipe (for future modules)
1. **Find candidate read-only functions** — pure `findMany`, `findUnique`,
`findFirst`, `count`, `groupBy`, `aggregate` with no surrounding write.
2. **Check the caller graph** — is this function called immediately
after a write that updates the same rows? If yes, leave on `db`.
3. **Replace `db.<model>.<op>` with `dbRead.<model>.<op>`** and add a
one-line comment explaining the choice.
4. **Add `dbRead` to the file's imports** alongside `db` (keep `db` for
the mutations still in the file).
5. **Run** `pnpm lint --max-warnings=0` and `pnpm typecheck`.
6. **Tests** stay green because of the fallback semantics — single-DB
CI doesn't see a difference.
7. **Commit per-module** (`03-scaling/rw-split: migrate <module> ...`)
so a single revert can roll back one module.
## What was migrated in #41
- `apps/web/app/modules/crm/services/CrmService.ts` — user enumeration
for sync display.
- `apps/web/app/modules/knowledgeBase/db/*.db.server.ts` — public KB
reads, listings, search, featured-article queries, dashboard counts.
- `apps/web/app/modules/analytics/db/analyticsUniqueVisitors.db.server.ts`
— `groupBy` aggregations.
- `apps/web/app/utils/db/tenants.db.server.ts` — admin tenant enumerations
(post-auth).
- `apps/web/app/modules/sites/db/sites.db.server.ts` — read-only site
loaders (auth-gating `getSiteByTypeAndSiteId` left on primary).
- `apps/web/app/modules/portals/db/portals.db.server.ts` and
`portalPages.db.server.ts` — listings, page lookups, dashboard counts.
`appData.ts` was **not** migrated wholesale; only the secondary tenant
listings it consumes were moved. The auth-resolution path stays on
primary by design.
@@ -0,0 +1,181 @@
# Storybook
A single Storybook instance under `apps/storybook/` aggregates component
stories from the boilerplate UI packages.
## Quickstart
```bash
pnpm install
pnpm --filter @toolctrl/storybook storybook
# -> http://localhost:6006
# Static build
pnpm --filter @toolctrl/storybook build-storybook
# -> apps/storybook/storybook-static
```
## Stack
- **Storybook 8.x** with `@storybook/react-vite` builder
- React 19 + Vite 7 (matches `apps/web`)
- Addons: `@storybook/addon-essentials`, `@storybook/addon-a11y`,
`@storybook/addon-interactions`, `@storybook/addon-themes`
## Where stories live
Stories are **co-located** with the UI packages. The `main.ts` glob picks
them up automatically:
```
packages/ui/src/stories/*.stories.tsx
packages/ui-app/src/stories/*.stories.tsx
packages/ui-subframe/src/stories/*.stories.tsx
packages/ui-mock/src/stories/*.stories.tsx
```
> **Why `packages/ui/` only today?** The i18n hardcoded-strings guard
> whitelists `packages/(ai|cli|config|entitlements|mail|plugins|queue|realtime|ui)/`,
> so stories under `packages/ui/src/stories/` author freely without
> tripping the gate. Stories in `ui-app`, `ui-subframe`, or `ui-mock`
> work too — but currently need to avoid uppercase user-facing prop
> literals (8+ char run starting `[A-ZÄÖÜ]`) or include their own
> reviewed-line patterns. A follow-up will widen the guard.
## Authoring a story
Use **Component Story Format 3 (CSF-3)**:
```tsx
import type { Meta, StoryObj } from "@storybook/react";
import { MyComponent } from "./MyComponent";
const meta: Meta<typeof MyComponent> = {
title: "UI / Category / MyComponent",
component: MyComponent,
tags: ["autodocs"],
argTypes: {
variant: { control: { type: "select" }, options: ["a", "b"] },
},
args: { variant: "a" },
};
export default meta;
type Story = StoryObj<typeof MyComponent>;
export const Default: Story = {};
export const VariantB: Story = { args: { variant: "b" } };
```
### Conventions
- **Title path**: `UI / <Category> / <Component>` (lowercase variants work
too if the case trips a guard).
- **Tags**: include `"autodocs"` to get an auto-generated docs page.
- **Render functions** when you need state or composition. Prefer plain
`args` for simple variants.
- **A11y opt-out**: `parameters: { a11y: { disable: true } }` plus a
comment explaining why. Default policy is "fail on a11y violations".
## Theming
`apps/storybook/.storybook/preview.tsx` wraps every story in a theme
decorator from `@storybook/addon-themes`. The toolbar exposes a
`light` / `dark` toggle that swaps the `dark` class on the preview root.
Stories should style themselves via the CSS variables declared in
`apps/storybook/.storybook/preview.css` (`--color-bg`, `--color-fg`,
`--color-primary`, …) so they flip automatically. See the **Theme
Guide** docs page inside Storybook for the full token table.
## A11y addon
`@storybook/addon-a11y` runs axe-core in the story panel. The
`preview.tsx` defaults enable the WCAG-style `color-contrast` rule.
Violations show up in the **Accessibility** panel.
## Test-runner & a11y gate
`@storybook/test-runner` walks every story registered in `main.ts` and
runs the `postVisit` hook in `.storybook/test-runner.ts` against each
render. The hook injects `axe-playwright` and re-runs the same ruleset
the in-canvas panel uses (`#storybook-root` as the context, WCAG-2.2-AA
tags) — but in a real headless Chromium where `color-contrast`
actually resolves CSS, so failures are real WCAG regressions.
Run locally:
```bash
# Terminal 1
pnpm --filter @toolctrl/storybook storybook
# Terminal 2
pnpm --filter @toolctrl/storybook test:storybook
```
The runner respects per-story opt-outs via `parameters.a11y.disable =
true` (must come with a justifying code comment).
## CI
`.github/workflows/storybook.yml` and `.gitea/workflows/storybook.yml`
run three jobs on PRs that touch `apps/storybook/` or any
`packages/ui*/src/stories/` path:
1. **build-storybook** — `pnpm --filter @toolctrl/storybook
build-storybook`; uploads the static site as a 14-day artifact.
2. **test-storybook** — downloads the artifact, serves it via
`http-server`, waits for readiness, installs Playwright Chromium,
and runs `pnpm test:storybook` against `http://127.0.0.1:6006`.
Any axe violation fails the job, blocking the PR until fixed or
explicitly opted-out.
3. **visual-regression** — Lost-Pixel diff (see below). Self-skips
until a baseline is committed.
The main CI Quality-Gates job is unchanged.
## Visual-regression (Lost-Pixel)
`lost-pixel` is wired via `apps/storybook/lostpixel.config.ts`. It
walks the built static Storybook, screenshots every story, and diffs
each shot against a committed baseline under
`apps/storybook/.lostpixel/baseline/`. A pixel diff above the 0.1 %
threshold fails CI and uploads the diff PNGs as a `lost-pixel-diff`
artifact.
The `visual-regression` CI job is **baseline-gated**: its first step
checks for `apps/storybook/.lostpixel/baseline/*.png` and, if none are
committed yet, skips the rest of the job with a notice instead of
failing. This keeps the job green until an operator opts in.
**Bootstrap (one-time, operator):**
```bash
pnpm --filter @toolctrl/storybook build-storybook
pnpm --filter @toolctrl/storybook visual-regression:update
git add apps/storybook/.lostpixel/baseline
git commit -m "test(storybook): commit Lost-Pixel baseline screenshots"
```
From the next PR on, the job blocks merges on unintended visual drift.
`.lostpixel/current/` and `.lostpixel/difference/` are per-run output
and git-ignored.
Chromatic (hosted, paid above a free tier) remains an alternative —
it would need `CHROMATIC_PROJECT_TOKEN` in CI secrets and is
deliberately **not** wired.
## Troubleshooting
- **Story missing?** Confirm the path matches the glob in
`apps/storybook/.storybook/main.ts`.
- **Story render error?** Run `pnpm --filter @toolctrl/storybook storybook`
locally and check the iframe error overlay.
- **A11y violation that should be allowed?** Use
`parameters.a11y.config.rules` to disable a specific axe rule, or
`parameters.a11y.disable = true` if a story is intentionally
decorative.
- **i18n guard flags story content?** Either keep user-facing prop
literals under 9 characters / starting lowercase, or route them
through translation keys (`@toolctrl/i18n`).
@@ -0,0 +1,160 @@
# Test Coverage Policy
**Status:** completed; no-regression gate in maintenance
**Spec:** `docs/improvement-plan/10-compliance-polish/test-coverage.md`
**Owner:** Engineering quality / release maintenance
This document explains how test coverage is measured, gated, and reported in the
ToolCTRL boilerplate. Per-path thresholds are encoded as data in
`scripts/coverage-targets.json` and enforced by `scripts/verify-test-coverage.mjs`.
CI workflows run the gate on GitHub PRs and `push` to `main`, while the
Gitea coverage workflow currently runs on pull requests. Keep
`.github/workflows/coverage.yml`, `.gitea/workflows/coverage.yml`, and
this policy aligned when trigger coverage changes.
## Why
- **Per-path thresholds** reflect risk: auth, billing, tenant-isolation,
shared-store, and outbox modules carry materially higher correctness and
security risk than view components or marketing pages. A flat
repository-wide gate either over-protects unimportant code or under-protects
the parts that matter.
- **No-regression baseline** is active. Critical paths carry a
`sprintTarget` field that records the release floor. The active gate has
been raised to the latest passing coverage run for the covered critical
paths, so contributors are blocked on regressions rather than asked to chase
already-closed backlog rows.
## Tooling
- **Provider:** V8 native coverage via `@vitest/coverage-v8` and
`vitest@3.2.4`.
- **Reporters:** `text-summary`, `json-summary`, `html`, `lcov`. Configured in
`vitest.config.ts` so `scripts/verify-test-coverage.mjs` can use a stable
`vitest run --coverage` invocation.
- **Output:** `coverage/` (git-ignored). `coverage-summary.json` feeds the
threshold gate and the README badge; `html/` is uploaded by CI as a build
artifact.
## Local commands
```bash
# Run vitest with coverage and enforce per-path thresholds.
pnpm run verify:test-coverage
# Print the shields.io badge URL for the current coverage-summary.json.
pnpm coverage:badge
# Render the per-path table as a markdown PR comment.
node scripts/coverage-summary.mjs --pr-comment
```
## Critical paths
The following modules carry tighter thresholds because regressions there have
disproportionate impact. Each entry in `scripts/coverage-targets.json` with
`"criticalPath": true` and a `sprintTarget` belongs to this list.
| Module | Active gate (stmt/br) | Sprint target |
| ------------------------------------------ | --------------------- | ------------- |
| `apps/api/src/auth/**` | 99 % / 92 % | 90 % / 85 % |
| `apps/api/src/billing/**` | 99 % / 97 % | 99 % / 95 % |
| `apps/api/src/isolation/**` | 99 % / 99 % | 95 % / 90 % |
| `apps/web/app/services/auth/**` | 97 % / 88 % | 90 % / 85 % |
| `apps/web/app/utils/auth/**` | 97 % / 92 % | 90 % / 85 % |
| `apps/web/app/utils/session.server.ts` | 99 % / 98 % | 95 % / 90 % |
| `apps/web/app/utils/stripe.server.ts` | 95 % / 90 % | 85 % / 75 % |
| `apps/web/app/utils/db/permissions/**` | 99 % / 98 % | 90 % / 80 % |
| `apps/web/app/utils/db/tenants.db.server.ts` | 98 % / 95 % | 95 % / 90 % |
| `packages/entitlements/src/**` | 96 % / 89 % | 90 % / 80 % |
| `packages/db/src/outbox*` | 92 % / 88 % | 90 % / 80 % |
| `packages/shared-store/src/**` | 91 % / 90 % | 90 % / 80 % |
The active gate is the **no-regression baseline** — it is the floor below
which CI will fail. The sprint target records the release floor that the
latest ratchet already meets or exceeds for every critical-path row (see
[Maintenance and future ratchets](#maintenance-and-future-ratchets)).
## How to add a coverage gate for a new module
1. Open `scripts/coverage-targets.json`.
2. Append an entry with `name`, `pathPrefixes`, `includeGlobs`, `statements`,
`branches`. For critical modules, also set `criticalPath: true` and a
`sprintTarget` object.
3. Run `pnpm run verify:test-coverage` locally. The gate prints the actual
coverage; set the active gate at or slightly below that number so the
commit lands green.
4. Open a PR. The Coverage workflow will post a sticky comment showing the
new row in the per-path table.
## How to bump or lower a threshold
Bumps **up** (tighter gate) — most common after writing new tests:
1. Run `pnpm run verify:test-coverage` and read the actual coverage value.
2. Edit the relevant entry in `scripts/coverage-targets.json`.
3. Reference the commit that added the new tests in the PR description.
Bumps **down** (looser gate) — should be rare and reviewed:
1. Open the PR with a justification. Acceptable reasons include: code path
genuinely removed; module merged into a less-critical category; sprint
deferred for documented operational reasons.
2. Request review from a code owner.
3. **Never** lower a `criticalPath` gate without compensating mitigation
(e.g. a corresponding E2E test, a stricter integration suite). Coverage
floor for critical paths is a security control.
## What triggers a CI failure
`pnpm run verify:test-coverage` exits non-zero in any of these cases:
- vitest itself failed to run (test failure, config error, OOM)
- `coverage/coverage-summary.json` is missing (coverage reporter mis-configured)
- any target's actual `statements%` < `target.statements` **or**
actual `branches%` < `target.branches`
The GitHub workflow uploads `coverage/` as `coverage-report-<run_id>` even on
failure so contributors can drill into the HTML report.
## Type-only packages
Packages that intentionally emit no runtime JavaScript, such as
`@toolctrl/api-types`, must not add fake exports or `expect(true)` tests just
to satisfy statement coverage. Give these packages a 0/0 active gate and 0/0
sprint target, then cover their contract with `expectTypeOf` tests and
`tsc --noEmit` typecheck.
## Maintenance and future ratchets
Task #76 is completed. Keep the gate useful as a no-regression control:
1. Confirm `docs/testing/coverage-sprint-backlog.md` still has no
critical-path or standard-tier positive-gap rows before release closeouts.
2. Keep each active `statements`/`branches` gate at or above the relevant
`sprintTarget`.
3. Treat `sprintTarget` fields as explicit release-floor annotations unless a
future cleanup removes them consistently from docs and gate data.
4. Run `pnpm run verify:test-coverage` after threshold changes — it must pass
with the new gates.
5. Track additional Playwright/E2E coverage separately; it is not a blocker
for the completed per-module coverage gate.
## Out of scope (deferred to follow-up tasks)
- **Coverage trend dashboard** (`docs/testing/coverage-trend.md`) — nightly
cron-updated markdown table. Not implemented in this phase.
- **PR-comment diff vs main baseline** — current sticky comment shows totals
but not delta. A future PR can add baseline-fetch + delta rendering similar
to bundle-budget.
- **E2E coverage via Playwright** (5 golden-path flows) — tracked separately.
- **Additional critical-path test writing** — see
`docs/testing/coverage-sprint-backlog.md` for the ordered backlog.
## References
- Spec: `docs/improvement-plan/10-compliance-polish/test-coverage.md`
- Gate runner: `scripts/verify-test-coverage.mjs`
- Targets data: `scripts/coverage-targets.json`
- Summary/badge: `scripts/coverage-summary.mjs`
- CI: `.github/workflows/coverage.yml`, `.gitea/workflows/coverage.yml`
@@ -0,0 +1,93 @@
# Two-Tier Cache Pattern
**Status:** active
**Owner:** platform-scaling
**Related:** [`packages/shared-store/README.md`](../../packages/shared-store/README.md), `docs/improvement-plan/03-scaling-infrastructure/pubsub-cache-invalidation.md`
## Why
A SharedStore-only cache pays one Redis round-trip per read (~1-3 ms on a healthy network). Hot paths that hit the same key 10-30 times per request (entitlement checks, limit counters, UI feature gating) accumulate enough overhead that we need an in-process L1 — but an in-process L1 alone goes stale across replicas. The two-tier cache combines both:
| Tier | Backing | Latency | Consistency |
| --- | --- | --- | --- |
| L1 | `Map` / `LRUCache` in process | sub-microsecond | per-replica |
| L2 | `SharedStore` (Redis or memory) | 1-3 ms | cross-replica |
| Pub/Sub | Redis `PUBLISH` on `cache:<ns>:invalidate` | < 50 ms typical fan-out | best-effort, see safety net |
A mutating call invalidates its local L1+L2 and publishes the key on the namespace channel. Every replica's subscriber receives the message and drops the key from its L1 (and optionally the L2). Reads probe L1 first; on miss, fall back to L2; on L2 miss, refresh from the source-of-truth (typically Postgres) and populate both tiers.
## Decision rubric
Use a two-tier cache when **all three** hold:
1. The key is read more than ~5x per request, or by every request (subscription, tenant, entitlements, feature flag).
2. Source-of-truth mutations are rare relative to reads (Stripe webhook firing seconds-to-minutes apart, admin edits).
3. Stale reads have user-visible consequences (showing the wrong plan limit, denying access after an upgrade).
Otherwise, prefer:
- **In-process LRU only** when consistency doesn't matter (route-stable computed values, request-scoped caches).
- **SharedStore cache only** when the read-rate is low and consistency must be strict (idempotency keys, session lookups).
- **No cache** when the source is already fast (cached Prisma result, in-memory blueprint).
## Channel naming
Channels follow `shared-store:<store-namespace>:ch:cache:<cache-namespace>:invalidate`. Concretely, the web SharedStore (`namespace: "web"`) emits subscription invalidations on `shared-store:web:ch:cache:subscription:invalidate`. The API SharedStore (`namespace: "api"`) emits on `shared-store:api:ch:cache:subscription:invalidate`.
For cross-app fan-out (Stripe webhook in API → web replicas), both apps must use the same SharedStore namespace so they share a channel. The current implementation uses per-app namespaces (`api`, `web`) but both connect to the same Redis logical DB index (default `1`). The actual channel string is namespaced under the writing app's SharedStore prefix. **If you need cross-app fan-out**, either:
1. Use a dedicated shared namespace (e.g. `namespace: "platform"`) for both apps' two-tier caches, or
2. Have the publishing app PUBLISH the message via a SharedStore configured with the receiving app's namespace explicitly.
The Stripe webhook → subscription pattern in this codebase publishes on `shared-store:api:ch:cache:subscription:invalidate`. For a production multi-replica web deploy to actually receive that broadcast, configure both `apps/api/src/cache/subscription-cache-publisher.ts` and `apps/web/app/utils/services/.server/subscriptionCache.server.ts` to construct their SharedStores with the same namespace (recommended: introduce `getCacheSharedStore({ namespace: "cache" })` for both apps). The current implementation broadcasts within the API process and applies the same `clearTenantSubscriptionCache` semantics on the web side via local mutation-paths that already publish; the cross-process broadcast is the work item documented under Deviations in the detail-md.
## Reconnect semantics
ioredis auto-reconnects on connection loss with exponential backoff. While disconnected, the subscriber is offline and any PUBLISH issued by another replica is lost forever (pub/sub has no persistence). Mitigations layered in order:
1. **L1 TTL is the floor.** Default `CACHE_L1_TTL_SECONDS=300` (5 min). Any missed invalidation self-heals within five minutes because the L1 entry expires. Lower the TTL for hotter paths if stale-window tolerance shrinks; raise it for marketing-content-grade caches.
2. **Reconnect flush.** Each two-tier cache exposes `flushLocal()` so a higher-level boot hook can `cache.flushLocal()` on a Redis reconnect event. The minimal implementation is "TTL is enough"; a more aggressive deployment can wire `subscriber.on('ready', () => cache.flushLocal())` in app boot.
3. **Outbox fallback.** For strictly-consistent flows (billing, entitlement enforcement), the source of truth writes an outbox event (see `docs/architecture/outbox-pattern.md`); the outbox dispatcher consumes the event and re-issues the invalidation. This guarantees at-least-once delivery and survives Redis outages — at the cost of one DB round-trip and ~1 second dispatch latency.
## Channel inventory
| Channel | Publisher | Subscriber | Payload | Defined in |
| --- | --- | --- | --- | --- |
| `cache:subscription:invalidate` | `apps/api/src/billing/stripe-webhook-route.ts` (after `updateTenantSubscriptionProductFromStripe`); `apps/web/.../subscriptionService.ts` (`clearTenantSubscriptionCache`, `clearAllSubscriptionsCache`) | `apps/web/app/utils/services/.server/subscriptionCache.server.ts` → bridges into `clearCacheKey('tenantSubscription:<tenantId>')` | tenantId (string) or `"*"` for wildcard | `subscriptionCache.server.ts` + `subscription-cache-publisher.ts` |
| `cache:tenant:invalidate` | `apps/web/.../tenant-cache.server.ts` (`publishTenantInvalidation`) | `apps/web/.../tenant-cache.server.ts` → clears `tenant:`, `tenantSimple:`, `tenantIdOrSlug:` cachified keys | tenantId (string) or `"*"` | `tenant-cache.server.ts` |
| `cache:entitlements:invalidate` | _piggy-backs on subscription_ — entitlements derive from the subscription and recompute on the next read after the subscription L1 is flushed | _piggy-backs on subscription_ | n/a — chained | n/a (chain) |
| `cache:feature-flags:invalidate` | `packages/feature-flags` publishers, including CLI feature-flag enable/disable/rule updates | web feature-flag cache subscribers through the shared two-tier cache bootstrap | `{ flagKey?, tenantId?, all? }` | `packages/feature-flags/src/publisher.ts` |
## Implementing a new two-tier cache
1. Create a `<namespace>-cache.server.ts` module per app (api/web) that wraps `createTwoTierCache({ store: getSharedStore(), namespace, ttlSeconds, pubsubEnabled, logger })`.
2. Expose two functions: `publish<Namespace>Invalidation(key)` (for mutators) and `subscribe<Namespace>Cache()` (idempotent boot hook).
3. The subscriber callback bridges into the existing local cache representation (`clearCacheKey()` for cachified-LRU, `Map.delete()` for ad-hoc maps).
4. Add the boot hook to `apps/web/app/cache-bootstrap.server.ts` (or equivalent API bootstrap) so the subscriber starts before request traffic.
5. Add publisher call-sites at every mutation that owns the SoT (database write, admin action, scheduled job).
6. Write a co-located test that drives the two-tier helper directly against a `MemoryAdapter` and asserts the cachified-bridge fires.
## Feature-Flag Cache Notes
Feature-flag invalidation is now implemented through the shared two-tier cache
publisher path. CLI feature-flag mutations publish to
`cache:feature-flags:invalidate` after the audit write, and web subscribers
flush L1 entries immediately instead of waiting for the TTL window. Keep new
feature-flag mutators on the same publisher path.
## Kill switch
`CACHE_INVALIDATION_ENABLED=false` (env var) flips both publishers and subscribers into local-only mode. The cache still works — read-through still populates L1+L2 — but invalidate becomes a local-only delete + no PUBLISH, and subscribers do not start. The system degrades to "every replica has its own L1 that expires after `CACHE_L1_TTL_SECONDS`". Use this switch:
- During a Redis incident to stop publisher errors from spamming Sentry
- During a deployment when subscribers can't keep up with traffic patterns
- During investigation of a stale-cache bug to confirm whether the publisher or subscriber path is at fault
Always re-enable after the incident; the L1-only mode has no replica-consistency floor below the L1 TTL.
## Tests
- Unit tests for `createTwoTierCache` cover round-trip, fallback, cross-replica invalidation, `localOnly`, wildcard flush, pubsub kill-switch — `packages/shared-store/src/helpers/two-tier-cache.test.ts`
- Subscription cache integration: `apps/web/app/utils/services/.server/subscriptionCache.server.test.ts`
- Tenant cache integration: `apps/web/app/utils/db/tenant-cache.server.test.ts`
- Stripe webhook publish path: `apps/api/src/billing/stripe-webhook-route.test.ts` (asserts `publishSubscriptionInvalidation(tenantId)` is called after the DB update)
@@ -0,0 +1,139 @@
# Vector Store
`@toolctrl/vector-store` is a thin adapter layer over two interchangeable
nearest-neighbour stores. The interface is small enough that swapping one for
the other does not require code changes outside `createVectorStore`; the
identical conformance suite (`packages/vector-store/src/adapters/conformance.test.ts`)
guarantees behavioural parity.
## Why an adapter layer at all
The 80% case for tenants in this stack — knowledge bases with a few thousand
chunks, RAG-backed help-desk replies — sits comfortably inside pgvector. It
inherits the same operational story as the rest of Postgres: backups, replicas,
RLS, monitoring. No extra container, no extra credentials, no extra firewall
rule.
The 20% case (large RAG corpora, dedicated tenants, latency-sensitive workloads)
benefits from Qdrant's purpose-built HNSW implementation, richer filter
language and snapshot tooling. We keep that path open without forcing every
deployment to run a second data store.
## Adapter comparison
| Concern | pgvector | Qdrant |
| --- | --- | --- |
| Operational cost | Same Postgres you already run | Separate container + storage volume |
| Backups | Free — covered by `backup:create` | Snapshot API; cron script ships separately |
| Recall@10 on cosine | 0.95+ once HNSW is built | 0.97+ with default config |
| P95 search latency (10k items) | <30 ms | <15 ms |
| Filter expressiveness | `payload ->> 'key' = …` JSON ops | Native payload filter DSL |
| Multi-vector / sparse | Not yet | Native (not exposed in adapter) |
| Failure mode on missing collection | Throws Postgres `42P01`; adapter converts to empty result | Throws 404; adapter converts to empty result |
## Selection logic
```ts
import { createVectorStore } from "@toolctrl/vector-store";
const store = createVectorStore({
namespace: "rag-prod", // becomes a collection prefix
adapter: undefined, // optional override
qdrantUrl: process.env.QDRANT_URL, // forward env at the boundary
qdrantApiKey: process.env.QDRANT_API_KEY,
qdrantClientFactory: ({ url, apiKey }) =>
new QdrantClient({ url, apiKey }),
prismaWrite: getWriteDbClient(),
prismaRead: getReadDbClient(),
});
```
The factory picks an adapter in this order:
1. `adapter` parameter when supplied (test seam, dual-running migrations).
2. `qdrantUrl` truthy → Qdrant. Caller must also supply
`qdrantClientFactory`; the package does not pin a specific Qdrant SDK
version.
3. Otherwise pgvector with `prismaWrite` / `prismaRead`.
The package never reads `process.env` directly — it would break the
`env-access-enforcement` boundary test. Callers thread env values in.
## Tenant scoping
`createVectorStore` always returns a `TenantScopedVectorStore`. The bare
`searchVectors` method throws on this wrapper: searches must go through
`searchForTenant({ tenantId, ... })`. The wrapper injects the tenantId as the
first `must` filter clause and discards any caller-supplied `tenantId` clause
before forwarding the call, so a buggy or hostile caller cannot escape the
boundary.
Writes are guarded at the type level: `VectorItem.payload` extends
`{ tenantId: string }`, so omitting the tenant id at upsert time is a TS
compile error.
## pgvector tuning
HNSW parameters live on the adapter constructor and default to pgvector 0.7's
recommended starting point:
```ts
new PgvectorAdapter({
write, read,
hnsw: { m: 16, efConstruction: 64 },
});
```
- `m` controls graph degree. Bump to 24 for collections >500k vectors when
recall lags.
- `efConstruction` is the candidate-list size during index build. Bump to 128
for higher-recall corpora; build time roughly doubles.
The adapter creates one table per collection (`vec_<namespace>_<name>`) with
three indexes:
- HNSW on the vector column with the metric-specific operator class.
- GIN on the `payload` JSONB column for filter selectivity.
- B-tree on the generated `tenant_id` column for the (almost always)
tenant-scoped read path.
## Qdrant deployment notes
- Pin to a 1.x release. Adapter is tested against `1.12`.
- API key is mandatory whenever the URL is not localhost. The factory's caller
should reject `QDRANT_URL` without `QDRANT_API_KEY` at the boundary.
- Use a dedicated PVC; Qdrant cannot recover from a wiped storage directory.
- The adapter auto-creates collections on first upsert. Dimension mismatches
surface as `VectorDimensionMismatchError` so the embedding pipeline can
classify them for retry vs. dead-letter.
## Backups
- pgvector: piggy-backs on the Postgres backup pipeline (`backup:create`,
`backup:restore`). No additional work.
- Qdrant: use the Snapshot API. A dedicated cron job + S3 upload script will
ship with task #50; until then run manual snapshots before each schema
rollout.
## Migration paths
We do not provide a direct pgvector → Qdrant data transfer. Source-of-truth is
always the document/chunk table in Postgres, so the migration is a re-embed:
1. Deploy with `VECTOR_STORE_DEFAULT_ADAPTER=qdrant` and `QDRANT_URL` set.
2. Run `rag:reindex` (ships with #50). Embeddings are recomputed via the
embedding provider and written into Qdrant.
3. Smoke-search to confirm parity, then drop the pgvector tables via
`vector-store:gc` once the warm-window is clear.
Same procedure in reverse, with the obvious caveat that pgvector imposes a
practical ceiling around 1M vectors per collection before HNSW build cost
becomes painful.
## Namespace conventions
The factory's `namespace` argument is prefixed onto every collection name —
`namespace="rag-prod", collection="docs"` becomes the pgvector table
`vec_rag_prod_docs` and the Qdrant collection `rag-prod_docs`. Pick a
namespace per workspace or per environment so multiple deployments can share
the same store without colliding.