# Externa documentation (full) Documented core version: 1.2.0 Site: https://docs.externa.qiick.io --- # Introduction Source: https://docs.externa.qiick.io Externa is an admin application for managing structured content, hierarchical files, and team access — with an optional in-app AI assistant and a headless **Public CMS API** (`/api/v1`) for websites and integrations. - [Quick Start](/docs/quick-start): Herd .env, seed roles, and the minimal commands to run locally. - [Architecture](/docs/architecture): How Laravel, Inertia, routes, services, and the React shell fit together. - [Permissions](/docs/permissions): Spatie RBAC with group-inherited effective permissions and built-in roles. - [Public CMS API](/docs/public-cms-api): Headless /api/v1 — public role, collection access matrix, API keys, and setup. > **Related packages** > [externa-core](https://github.com/qiick-io/externa-core) (app) · [externa-bruno](https://github.com/qiick-io/externa-bruno) (runnable API + GraphQL collection). Endpoint request/response examples are in [Public CMS API](/docs/public-cms-api) and [GraphQL](/docs/graphql); Bruno is the companion to execute them. ## What Externa is Externa ships as the **externa-core** repository: a [Laravel 13](https://laravel.com) backend with an [Inertia](https://inertiajs.com) + [React 19](https://react.dev) frontend. Operators use the full admin UI; external clients use the Public CMS API when you grant collection access: - **Dynamic collections** — define collections and fields, then create and edit items with typed, often translatable data. - **Public CMS API** — headless `/api/v1` for anonymous (`public` role) and Bearer API-key access, gated by a per-collection create/read/update/delete matrix ([guide](/docs/public-cms-api)); GraphQL at [`/api/graphql`](/docs/graphql). - **Hierarchical files** — a tree of folders and files with upload, versioning-related flows, duplicate, and async zip download. - **Access control** — [Spatie Laravel Permission](https://spatie.be/docs/laravel-permission) roles and permissions for the admin UI, extended with **user groups** so effective permissions can be inherited. - **Project settings** — content locales, sidebar modules, registration/password policy, transform presets, outbound webhooks, and branding ([guide](/docs/project-settings)). - **In-app AI** — an assistant built on [laravel/ai](https://github.com/laravel/ai) that exposes tools (collections, files, users, imports, activity logs, and more) while respecting the signed-in user’s effective permissions. - **Outbound webhooks** — HMAC-signed domain events to a project URL ([guide](/docs/webhooks)). - **Auditability** — activity logging (Spatie Activitylog) and in-app notifications for long-running work. > **Why the name Externa?** > From Latin _externa_ (the neuter plural of _externus_, used by Cicero and Livy in contrast to _interna_): everything that faces the outside world. Born from a real-world client need, Externa projects your structured content outward from the backoffice to websites, apps, APIs, and AI agents—reflected in its outward-facing bow-tie mark. The default local **minimal** stack uses **SQLite**, **database** drivers for session / cache, and Vite for the React bundle. The documented **full** stack (Herd Redis) uses `QUEUE_CONNECTION=redis` + Horizon, Reverb, and Pulse — see [Minimal vs full stack](/docs/minimal-vs-full-stack). Production databases: [Supported databases](/docs/supported-databases) (Postgres recommended; MySQL 8+ / MariaDB 10.6+ supported). Async features always need a queue worker. ## What's new (ops) Aligned with **externa-core [`v1.0.0`](https://github.com/qiick-io/externa-core/releases/tag/v1.0.0)** (docs site `1.1.1`). Full per-version history: **[Changelog](/docs/changelog)**. - **Install / Packagist** — [Packagist create-project](/docs/packagist) (`qiick/externa-core` published) → [`externa:install`](/docs/installation) (#93); [Upgrade](/docs/upgrade) — create-project is install-only; stay current via git tags / `upstream` / optional `--sync-upstream`, then `externa:upgrade` - **Security (beta.4 → GA)** — private files `private_assets` (#84), block SVG/HTML uploads (#85), AI webhook HMAC (#86), outbound empty secret refused (#88), [AuthZ map](/docs/authz-map) (#90) - **Ops / Docker** — [Deploy with Docker](/docs/deploy-with-docker) GHCR quick Compose (#56), lefthook `prepare` no-op without `.git` (#94), phpredis pin, multi-arch [Deployment](/docs/deployment) - **Release** — maintainers ship ([Releasing](/docs/releasing) #92); [API 1.x compatibility](/docs/api-compatibility); [GA readiness](/docs/ga-readiness) - Earlier: [Backup](/docs/backup-restore), [Reverse proxy](/docs/reverse-proxy), [Horizon](/docs/horizon), [Minimal vs full](/docs/minimal-vs-full-stack) ## Who it is for | Audience | Why Externa fits | | --------------------------------- | ------------------------------------------------------------------------------------------------------------ | | Product / content operators | Manage collections and items without writing migrations for every schema change. | | Internal platform teams | Ship a branded admin with users, groups, roles, and a file tree out of the box. | | Developers extending externa-core | Clear split of Actions, Services, Jobs, AI tools, and Inertia pages under `resources/js`. | | Teams experimenting with AI ops | Point `LOCAL_AI_*` at LM Studio, vLLM, or another OpenAI-compatible gateway and keep tools permission-gated. | > **Note** > Externa assumes you control the deployment and the AI gateway. It is designed for trusted operators (admin users), not as a public multi-tenant SaaS shell without further hardening. ## Feature overview ### Collections and items Collections are first-class models (`Collection`, `CollectionField`, `CollectionItem`, `CollectionItemValue`). Field behavior is driven by enums and validation on the server; the React UI under `resources/js/pages/collections` mirrors those capabilities. Content locales for translatable field data live in **project settings** (seeded from `config/collections.php`, defaults `en` / `it`), separate from the admin UI locale in `config/i18n.php`. HTTP surface lives in `routes/collections.php` and controllers under `App\Http\Controllers\Collections`. ### Hierarchical file manager Files form a tree (`File`, related upload/version models). Controllers under `App\Http\Controllers\Admin\FileController` expose browse/upload/move/duplicate/zip flows. Large duplicates and multi-file zips are handled asynchronously: - `DuplicateFilesJob` - `PrepareFilesZipJob` Thresholds and zip retention come from `config/files.php` / `FILES_*` environment variables. Public disk URLs require `php artisan storage:link`. ### Users, groups, roles, and permissions Built-in roles (from `App\Enums\RoleEnum`): | Role | Seeded meaning | | ------------- | ------------------------------------------------------------- | | `super-admin` | All permissions | | `admin` | All permissions | | `reader` | Only `can-show-*` permissions | | `public` | System role for anonymous `/api/v1` (not assignable to users) | Permissions are seeded from `PermissionEnum` via `PermissionSeeder` and attached in `RoleSeeder`. Beyond direct Spatie assignments, **user groups** contribute permissions; `App\Services\Authorization\EffectivePermissionResolver` computes the union used by the UI and AI tool checks. Headless API grants live in **Collection access** on each role ([Public CMS API](/docs/public-cms-api)). Admin CRUD routes live in `routes/admin.php` (users, groups, roles, permissions, activity logs, files, API keys). ### AI assistant The default agent is `App\Ai\Agents\AppAssistant`, with tools under `app/Ai/Tools` such as: - Collection and item management / query / export - CSV and remote JSON import (plus job status) - Files, users, groups, roles - Activity log query and rollback of the last AI turn - PDF text extraction and AI sync sources Default provider is **local** (`AI_DEFAULT_PROVIDER=local`), talking to an OpenAI-compatible gateway at `LOCAL_AI_URL` (default `http://127.0.0.1:1234/v1`). Chat UI and API routes are under `routes/ai.php` and `resources/js/pages/ai`. Imports that can take time use `ImportCollectionJob` and the database queue. ### Activity and notifications Spatie Activitylog records notable changes (`ACTIVITYLOG_*`). The admin activity log UI and notification endpoints (unread count, mark read) help operators follow async work without watching the queue terminal. ## Technology stack (externa-core) | Layer | Choice | | -------------- | ----------------------------------------------------- | | PHP | 8.3+ (repo pin often 8.4) | | Framework | Laravel 13 | | Auth | Laravel Fortify | | SPA bridge | Inertia Laravel 2 + React 19 | | Typed routes | Laravel Wayfinder (generated TS under `resources/js`) | | Permissions | spatie/laravel-permission | | Activity | spatie/laravel-activitylog | | AI | laravel/ai | | Frontend build | Vite, Tailwind CSS 4 | | Tests | Pest 4 (Feature / Unit / Browser) | ## Repository layout at a glance You will spend most of your time in: - `app/` — Actions, Ai, Enums, Http, Jobs, Models, Services, … - `routes/` — `web.php`, `api.php` (`/api/v1`), `admin.php`, `settings.php`, `collections.php`, `ai.php`, `console.php` - `resources/js/` — `pages`, `components`, `hooks`, `lib`, Wayfinder output - `config/` — notably `ai.php`, `collections.php`, `files.php`, `super_admin.php`, `permission.php`, `fortify.php` Full directory guidance is on the [Project layout](/docs/project-layout) page. Environment keys are listed under [Environment variables](/docs/environment-variables). ## Next steps 1. **Install locally** — preferably with [Laravel Herd](https://herd.laravel.com). Start with [Quick Start](/docs/quick-start); deeper detail in [Installation](/docs/installation). 2. **Sign in** — default super admin from `config/super_admin.php` is `superadmin@example.com` / `password` (override with `INITIAL_SUPER_ADMIN_*` before seeding). 3. **Keep a queue worker running** — minimal: `composer run dev` (`queue:listen`). Full Redis stack: `composer run dev:full` (Horizon + Reverb + Pulse). Zip, duplicate, and import features need a worker. 4. **Optional: AI** — start a local OpenAI-compatible server, confirm `LOCAL_AI_URL` / `LOCAL_AI_MODEL`, then open the AI chat in the admin. 5. **Learn the model** — read [Architecture](/docs/architecture), [Effective permissions](/docs/permissions), and [AI assistant model](/docs/ai-assistant) before extending tools or policies. > Do not use the default seeded password outside local development. Set `INITIAL_SUPER_ADMIN_EMAIL` and `INITIAL_SUPER_ADMIN_PASSWORD` (and related name fields) before `php artisan db:seed` in shared or production environments. --- # Quick Start Source: https://docs.externa.qiick.io/docs/quick-start Get **externa-core** running on Laravel Herd in a few minutes. For troubleshooting, AI gateway setup, and SSR, use the full [Installation](/docs/installation) guide. ## Prerequisites | Requirement | Notes | | -------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **PHP 8.4.27+** (or **8.5.12+**) | Pinned major in `.php-version` / `composer.json` (`^8.4`). Use a patched Herd PHP CLI — [security notice](https://github.com/qiick-io/externa-core/discussions/247). | | **Composer 2** | PHP dependencies and `composer setup` / `composer run dev`. | | **Node.js 24** | Pinned in `.nvmrc` and `package.json` `engines`. Run `nvm use` (or equivalent). | | **Laravel Herd** | Serves the site at a `.test` host (e.g. `http://externa-core.test`). | | **Postgres** (this example) | Or switch to SQLite — see the commented block in the `.env` below. | | **Redis** (optional) | Needed for Horizon, Reverb-friendly realtime, and Pulse redis ingest. Enable in Herd. | Optional: [Herd Pro](https://herd.laravel.com) Reverb on `:8080` for live admin notifications. ## 1. Get the code and enter **Operators (preferred):** ```bash composer create-project qiick/externa-core externa-core cd externa-core nvm use # Node 24 php artisan externa:install ``` `create-project` does **not** migrate or assume SQLite — next step is always the installer. Details: [Packagist & create-project](/docs/packagist). **Not an update channel:** after you push this tree to your own git remote, stay current via git tags / `upstream` merge or `externa:upgrade --sync-upstream=…` (or Docker image pull), then `php artisan externa:upgrade` — [Upgrade](/docs/upgrade). Then skip to [Start](#4-start-the-stack) if the installer already seeded and built. Otherwise continue with a hand-edited `.env` below (Herd Postgres example). **Contributors:** ```bash git clone externa-core cd externa-core nvm use # Node 24 ``` Park or link the folder in Herd so `http://externa-core.test` resolves (or set `APP_URL` to your Herd host). HTTP is enough for password / TOTP / CMS; passkeys on a `.test` host need `herd secure` + `https://…` — [Passkeys](/docs/passkeys#requirements). ## 1b. Interactive installer only (clone / existing tree) ```bash php artisan externa:install ``` Same wizard as after create-project: minimal profile needs no Redis; keeps a valid existing `APP_KEY` or generates one; re-run with `--force` when intentional ([#249](https://github.com/qiick-io/externa-core/pull/249)). Skip to [Start](#4-start-the-stack) when done. ## 2. Environment Either copy the example and edit: ```bash cp .env.example .env ``` Or paste the Herd-oriented block below into `.env` (safe local defaults — no production secrets). Create the Postgres database `externa_core` in Herd (or your local Postgres) before migrate. For zero-config SQLite instead, use the commented `DB_*` lines and `touch database/database.sqlite`. Matrix and MySQL/MariaDB notes: [Supported databases](/docs/supported-databases). ```env APP_NAME=Laravel APP_ENV=local APP_KEY= APP_DEBUG=true # HTTP OK for password/TOTP/CMS. Passkeys need HTTPS (or localhost) — see /docs/passkeys APP_URL=http://externa-core.test APP_LOCALE=en APP_FALLBACK_LOCALE=en APP_FAKER_LOCALE=en_US APP_MAINTENANCE_DRIVER=file BCRYPT_ROUNDS=12 LOG_CHANNEL=stack LOG_STACK=single LOG_DEPRECATIONS_CHANNEL=null LOG_LEVEL=debug # Postgres (typical Herd local DB). For SQLite instead: # DB_CONNECTION=sqlite # (and: touch database/database.sqlite) DB_CONNECTION=pgsql DB_HOST=127.0.0.1 DB_PORT=5432 DB_DATABASE=externa_core DB_USERNAME=root DB_PASSWORD= SESSION_DRIVER=database SESSION_LIFETIME=120 SESSION_ENCRYPT=false SESSION_PATH=/ SESSION_DOMAIN=null FILESYSTEM_DISK=local CACHE_STORE=database MEMCACHED_HOST=127.0.0.1 # Redis — enable in Herd. Without Redis: QUEUE_CONNECTION=database, BROADCAST_CONNECTION=log REDIS_CLIENT=phpredis REDIS_HOST=127.0.0.1 REDIS_PASSWORD=null REDIS_PORT=6379 QUEUE_CONNECTION=redis # QUEUE_CONNECTION=database BROADCAST_CONNECTION=reverb # BROADCAST_CONNECTION=log # Herd Pro Reverb defaults (Services → Reverb). Host must be 127.0.0.1 / localhost for Echo. REVERB_APP_ID=1001 REVERB_APP_KEY=laravel-herd REVERB_APP_SECRET=secret REVERB_HOST=127.0.0.1 REVERB_PORT=8080 REVERB_SCHEME=http VITE_REVERB_APP_KEY="${REVERB_APP_KEY}" VITE_REVERB_HOST="${REVERB_HOST}" VITE_REVERB_PORT="${REVERB_PORT}" VITE_REVERB_SCHEME="${REVERB_SCHEME}" PULSE_ENABLED=true PULSE_INGEST_DRIVER=redis PULSE_REDIS_CONNECTION=pulse PULSE_STORAGE_KEEP="2 days" PULSE_INGEST_KEEP="2 days" MAIL_MAILER=log MAIL_SCHEME=null MAIL_HOST=127.0.0.1 MAIL_PORT=2525 MAIL_USERNAME=null MAIL_PASSWORD=null MAIL_FROM_ADDRESS="hello@example.com" MAIL_FROM_NAME="${APP_NAME}" AWS_ACCESS_KEY_ID= AWS_SECRET_ACCESS_KEY= AWS_DEFAULT_REGION=us-east-1 AWS_BUCKET= AWS_USE_PATH_STYLE_ENDPOINT=false VITE_APP_NAME="${APP_NAME}" FILES_DUPLICATE_SYNC_MAX_BYTES=52428800 FILES_ZIP_TTL_MINUTES=60 FILES_ZIP_MAX_BYTES=104857600 AI_DEFAULT_PROVIDER=local LOCAL_AI_URL=http://127.0.0.1:1234/v1 LOCAL_AI_API_KEY= LOCAL_AI_MODEL=openai/gpt-oss-20b AI_REMOTE_IMPORT_HOSTS= AI_WEBHOOK_TOKEN= AI_DAILY_PROMPT_LIMIT=0 AI_EMBEDDINGS_ENABLED=false AI_MCP_ENABLED=false ``` > **MySQL / MariaDB instead** > Create an empty database first, then swap the `DB_*` block: > > ```env > DB_CONNECTION=mysql > DB_HOST=127.0.0.1 > DB_PORT=3306 > DB_DATABASE=externa_core > DB_USERNAME=root > DB_PASSWORD= > DB_CHARSET=utf8mb4 > DB_COLLATION=utf8mb4_unicode_ci > ``` > > For MariaDB use `DB_CONNECTION=mariadb` (same host/port pattern; Herd MariaDB often listens on `3307`). Full matrix: [Supported databases](/docs/supported-databases). Full key reference: [Environment variables](/docs/environment-variables). Minimal vs Redis/Reverb/Pulse: [Minimal vs full stack](/docs/minimal-vs-full-stack). ## 3. Install, migrate, seed ```bash composer install php artisan key:generate php artisan migrate --seed php artisan storage:link php artisan wayfinder:generate --with-form --no-interaction npm ci # or: npm install npm run build # or skip and use `npm run dev` / Vite HMR below ``` **CI / non-interactive shortcut:** `composer setup` runs install → copy `.env` if missing → `key:generate` → migrate → `npm install` → `npm run build`. It does **not** seed, link storage, or generate Wayfinder — still run those (or let Vite generate routes on first `npm run dev`). Operators after create-project should use `externa:install`, not `composer setup`. ## What seeding creates `php artisan migrate --seed` (or `db:seed`) runs `DatabaseSeeder`: 1. **`PermissionSeeder`** — syncs Spatie permissions from `PermissionEnum` via `php artisan permissions:sync` 2. **`RoleSeeder`** — roles: | Role | Notes | | ------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | | `super-admin` | Every Spatie permission | | `admin` | Every Spatie permission | | `reader` | Only `can-show-*` permissions | | `public` | System role (`is_system`, not assignable); **no** Spatie admin permissions — used for anonymous Public CMS API; grant collection/file access in the role UI | 3. **`CreateSuperAdminSeeder`** — first user from `config/super_admin.php` | Field | Default (local only) | | -------- | ------------------------ | | Email | `superadmin@example.com` | | Password | `password` | Override **before** seeding: ```env INITIAL_SUPER_ADMIN_FIRST_NAME=Super INITIAL_SUPER_ADMIN_LAST_NAME=Admin INITIAL_SUPER_ADMIN_EMAIL=superadmin@example.com INITIAL_SUPER_ADMIN_PASSWORD=password ``` > Change or remove default credentials before any shared or production deploy. Do not commit real secrets. After upgrades, re-sync permission definitions: ```bash php artisan permissions:sync # optional: php artisan permissions:sync --prune ``` More on the `public` role matrix: [Roles & permissions](/docs/roles-and-permissions) · [Public CMS API](/docs/public-cms-api). ## 4. Run Herd already serves HTTP at `APP_URL`. You still need Vite and a queue path. **Minimal** (no Redis — set `QUEUE_CONNECTION=database` and `BROADCAST_CONNECTION=log`): ```bash composer run dev # serve + queue:listen + pail + vite ``` With Herd serving the site you can skip `artisan serve` and run Vite + a worker yourself (`npm run dev` and `php artisan queue:listen --tries=1 --timeout=0`). **Full stack** (matches the `.env` above — Redis + Herd Reverb + Pulse): ```bash php artisan horizon php artisan pulse:work npm run dev ``` Do **not** also run `php artisan reverb:start` if Herd Pro already binds `:8080`. Standalone all-in-one (starts its own Reverb — conflicts with Herd Reverb on the same port): ```bash composer run dev:full ``` Open `http://externa-core.test` (or your `APP_URL`). Unauthenticated `/` redirects to login — use the seeded super admin. ## Optional services | Need | One-liner | Docs | | ------------------ | -------------------------------------------------- | ---------------------------------------- | | Redis | Enable in Herd; keep `REDIS_*` as above | [Redis](/docs/redis-prerequisite) | | Horizon | `php artisan horizon` | [Horizon](/docs/horizon) | | Reverb + Echo | Herd Pro on `:8080` + `REVERB_*` / `VITE_REVERB_*` | [Reverb & Echo](/docs/reverb-and-echo) | | Pulse | `php artisan pulse:work` (redis ingest) | [Pulse & Health](/docs/pulse-and-health) | | Queues / scheduler | Ops overview | [Operations](/docs/operations) | ## Related packages | Repo | Role | | ---------------------------------------------------------- | ----------------------------------------------------------------------------------- | | [externa-core](https://github.com/qiick-io/externa-core) | Laravel + Inertia app (this Quick Start) | | [externa-bruno](https://github.com/qiick-io/externa-bruno) | Runnable Bruno collection for Public CMS API (`/api/v1`) + GraphQL (`/api/graphql`) | Clone **[externa-bruno](https://github.com/qiick-io/externa-bruno)**, open that folder in [Bruno](https://www.usebruno.com/), select env **Local**, copy `.env.sample` → `.env` and set `EXTERNA_API_KEY` (and `base_url` if not on `http://externa-core.test`). For staging, duplicate **Local** and change `base_url` + key. Endpoint examples live in the docs ([Public CMS API](/docs/public-cms-api), [GraphQL](/docs/graphql)); Bruno executes them. ## Next steps - Deeper local setup & troubleshooting: [Installation](/docs/installation) - Tune every key: [Environment variables](/docs/environment-variables) - Minimal vs Redis stack: [Minimal vs full stack](/docs/minimal-vs-full-stack) - Code map: [Project layout](/docs/project-layout) - Access control: [Effective permissions](/docs/permissions) - Headless API: [Public CMS API](/docs/public-cms-api) · [GraphQL](/docs/graphql) - Try requests: [externa-bruno](https://github.com/qiick-io/externa-bruno) --- # Installation Source: https://docs.externa.qiick.io/docs/installation > **Interactive installer** > **Human happy path:** `composer create-project` → `php artisan externa:install` → run app. The wizard walks APP_NAME, APP_URL, DB profile, minimal vs full stack, optional seed / LOCAL_AI_URL, then migrate + npm build. Create-project itself only copies `.env` (if missing) and runs `key:generate` — **no** migrate/seed/SQLite assumption. > **CI / non-interactive** > `composer setup` remains the scripted path (install → `.env` → key → migrate → npm). Distinct from the human create-project handoff. Details: [Packagist & create-project](/docs/packagist#ci--non-interactive-not-the-human-path). > **Faster path** > **Operators:** [Packagist `create-project`](/docs/packagist) then `php artisan externa:install`. Copy-paste Herd `.env` without the wizard: [Quick Start](/docs/quick-start). This page is the fuller install reference. This guide gets **externa-core** running on your machine: PHP dependencies, database of your choice, frontend build, seeded roles and super admin, and the concurrent dev processes (HTTP, queue, logs, Vite). ## Requirements > Externa requires PHP **`^8.4.27`** from externa-core **1.2.2** ([`composer.json`](https://github.com/qiick-io/externa-core/blob/main/composer.json), [#248](https://github.com/qiick-io/externa-core/pull/248)). After the 21 Oct 2026 PHP emergency releases, the minimum safe patch versions are **8.4.27** or **8.5.12** (CVEs in PHP itself, not Externa). Full notice: [externa-core discussion #247](https://github.com/qiick-io/externa-core/discussions/247). | Requirement | Notes | | ----------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | | PHP **8.4.27+** (or **8.5.12+**) | Matches `composer.json` (`^8.4.27` from 1.2.2) and the repo `.php-version` pin. See security callout above for CVE context and the full notice. | | Composer 2 | Used for `composer setup` and `composer run dev`. | | Node.js + npm | Frontend via Vite; Node 24 is pinned in `.nvmrc` when present. | | SQLite (default) | Or [MySQL / MariaDB / Postgres](/docs/supported-databases) via `DB_*` after copying `.env`. | | Laravel Herd (recommended on macOS) | PHP, nginx, `.test` hosts; Pro adds shared Reverb on `:8080`. | | **Docker** (optional) | Official Compose full stack — see [Docker local](#docker-compose-local-full-stack). Sail remains `require-dev` only. | Optional for AI features: - An OpenAI-compatible local gateway (e.g. [LM Studio](https://lmstudio.ai)) listening where `LOCAL_AI_URL` points (default `http://127.0.0.1:1234/v1`). - A model that supports tool calling reliably — tiny models often hallucinate successful tool results. > **Note** > You need a **queue worker** for zip downloads, larger/bulk file duplicates, and collection imports. The `composer run dev` script starts `php artisan queue:listen` for you. If you only run `php artisan serve`, start a worker in a second terminal. ## Get the code ### Operators (Packagist) ```bash composer create-project qiick/externa-core my-externa cd my-externa nvm use php artisan externa:install ``` Details: [Packagist & create-project](/docs/packagist). Skip to [Start the development stack](#start-the-development-stack) when the installer already migrated, seeded, and built. > **After install — staying current** > `create-project` is **install only**. Pushing the tree to your own git remote does **not** auto-pull Externa updates. Recommended: track release tags or add `upstream` and merge `vX.Y.Z` (or `php artisan externa:upgrade --sync-upstream=vX.Y.Z`), then run `php artisan externa:upgrade` (migrate / permissions / cache). Full checklist: [Upgrade](/docs/upgrade). ### Contributors (git clone) ```bash git clone https://github.com/qiick-io/externa-core.git cd externa-core nvm use ``` ## Recommended: Laravel Herd 1. Install [Laravel Herd](https://herd.laravel.com) and ensure the CLI PHP version is 8.4+. 2. Place the project (create-project or clone) in a directory Herd serves (or use `herd link` / park). 3. Open a terminal in the project root for the steps below. Herd is optional — any local PHP 8.4+ + Composer + Node toolchain works. Prefer **Herd** for macOS `.test` hosts, or **Docker Compose** for a full container stack — pick one path per working tree ([Herd vs Docker](#choose-a-local-path)). ## Step-by-step install ### Interactive installer (`externa:install`) Preferred after create-project (or on a fresh clone): ```bash php artisan externa:install ``` | Prompt | Effect | | ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | APP_NAME / APP_URL | Written to `.env` | | Database | `sqlite` (creates `database/database.sqlite` if missing), `pgsql`, `mysql`, or `mariadb` | | Queue / broadcast profile | **Minimal** (database queue/cache/session, log broadcast — **no Redis**) or **Full** (redis queue/cache, reverb — needs Redis) | | Seed | Optional `db:seed` (roles + super admin) | | LOCAL_AI_URL | Optional; blank skips | | Then | Preserve a valid existing `APP_KEY` or generate one, apply the selected stack to runtime config, `migrate`, `storage:link`, `npm install` / `npm run build` (unless `--no-npm`) | Flags: `--force` (allow production-looking env / overwrite `.env` without an extra confirm when re-running), `--no-npm`, `--no-seed`. Without `--force`, re-run asks before overwriting an existing `.env` (decline = leave env untouched) and refuses a production-looking `.env`. From **1.2.2** ([#249](https://github.com/qiick-io/externa-core/pull/249)): the installer keeps or generates `APP_KEY` after rewriting `.env`, and the minimal profile migrates without Redis. ### Manual / CI: Composer setup The `setup` script in `composer.json` is the **non-interactive** bootstrap (CI, scripted environments): ```bash composer setup ``` That runs, in order: 1. `composer install` 2. Copy `.env.example` → `.env` if `.env` is missing 3. `php artisan key:generate` 4. `php artisan migrate --force` 5. `npm install` 6. `npm run build` > **Note** > `composer setup` **does** migrate against whatever `DB_*` is in `.env`. For SQLite, create the file first if missing: `touch database/database.sqlite`. Human operators should prefer `externa:install` after create-project instead. ```bash # Only if using SQLite and the file is missing (composer setup / manual migrate) touch database/database.sqlite ``` ### Review environment defaults Open `.env` and confirm at least: ```env APP_URL=http://localhost DB_CONNECTION=sqlite SESSION_DRIVER=database QUEUE_CONNECTION=database CACHE_STORE=database ``` For Herd with a custom host, set `APP_URL` to that URL (including scheme). **HTTP is fine** for the rest of Externa (password login, TOTP, CMS, and **password** confirm on `/user/confirm-password`). Passkeys (sign-in, register, **Confirm with passkey**) need a secure context — `http://externa-core.test` hides those CTAs (browser rule). Local passkey testing: `herd secure …`, `APP_URL=https://…`, and Vite `detectTls` for the site host — [Passkeys — Local HTTPS](/docs/passkeys#local-https-herd-valet-for-passkey-testing); confirm-password details: [Confirm password with a passkey](/docs/passkeys#confirm-password-with-a-passkey). Database matrix: [Supported databases](/docs/supported-databases). File and AI tunables: [Environment variables](/docs/environment-variables). ### Link public storage Required for publicly served uploads and related assets: ```bash php artisan storage:link ``` ### Seed roles, permissions, and super admin ```bash php artisan db:seed ``` `DatabaseSeeder` calls: 1. `PermissionSeeder` — permissions from `App\Enums\PermissionEnum` via `permissions:sync` 2. `RoleSeeder` — roles `super-admin`, `admin`, `reader`, and system role `public` 3. `CreateSuperAdminSeeder` — first login user from `config/super_admin.php` Override credentials **before** seeding if needed: ```env INITIAL_SUPER_ADMIN_FIRST_NAME=Super INITIAL_SUPER_ADMIN_LAST_NAME=Admin INITIAL_SUPER_ADMIN_EMAIL=superadmin@example.com INITIAL_SUPER_ADMIN_PASSWORD=password ``` Defaults when unset: email `superadmin@example.com`, password `password`. > Seeding is meant for local (or carefully controlled) environments. Re-running seeders may conflict with existing users or permission rows depending on seeder idempotency. Prefer fresh databases for demos. ### Start the development stack ```bash composer run dev ``` This uses `concurrently` to run: | Process | Command | | ------- | ------------------------------------------------ | | server | `php artisan serve` | | queue | `php artisan queue:listen --tries=1 --timeout=0` | | logs | `php artisan pail --timeout=0` | | vite | `npm run dev` | Open the URL shown by `artisan serve` (or your Herd host). Unauthenticated visits to `/` redirect to login; authenticated users go to the dashboard. ### Full stack (Herd Redis + Reverb + Pulse) When Redis and Herd Pro Reverb are enabled (see [Minimal vs full stack](/docs/minimal-vs-full-stack)): 1. Copy `BROADCAST_*` / `REVERB_*` / `VITE_REVERB_*` / Pulse keys from `.env.example` (Herd defaults on `:8080`). 2. Restart Vite after any `VITE_REVERB_*` change. 3. Run the two long-lived workers (Herd Reverb is already up — do not also `reverb:start`): ```bash php84 artisan horizon php84 artisan pulse:work ``` Dashboard **Health** then shows native Pulse/Horizon cards. Echo powers notifications, presence, and the avatar connection dot ([Reverb & Echo](/docs/reverb-and-echo)). ## Dev Container (VS Code / Codespaces) Third official local path alongside Herd and Docker Compose: 1. Open externa-core in VS Code or Codespaces. 2. **Reopen in Container** (uses `.devcontainer/devcontainer.json` + root `compose.yaml`, service `app`). 3. Wait for post-create (`composer install`, `npm ci`, key, migrate, seed). 4. Browse forwarded port **8000** (Compose app publish). Does not replace Herd or bare Compose — pick one path per working tree. ## Docker Compose (local full stack) Official container path is **Compose** (`compose.yaml` in externa-core) — **not Sail**. Sail stays `require-dev` only; do not use it for day-to-day local or production. ### Choose a local path | Path | When | How | | ------------------ | --------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------- | | **Herd** (macOS) | Native PHP/nginx + optional Pro Redis/Reverb | [Recommended: Laravel Herd](#recommended-laravel-herd) + `composer setup` | | **Bare metal** | Any OS with PHP 8.4 + Composer + Node | Same as Herd steps without Herd hosts | | **Docker Compose** | Full stack in containers (Postgres + Redis + Horizon + Reverb + scheduler + Pulse + Mailpit + Vite) | This section | Do **not** mix Herd-served HTTP with Compose `app` on the same checkout unless you know what you are doing. Prefer one path per working tree. Compose can share a host `.env` safely because containers override DB hosts / `APP_URL` via `COMPOSE_*` (see below). ### Quick start ```bash cd /path/to/externa-core cp .env.docker.example .env docker compose up --build ``` Open **`http://localhost:8000`** (host port **`8000` → container `:80`** — not `:80` on the host). Cold first boot can take several minutes (`composer install` into the named `vendor` volume, then migrate). ### Default services and ports | Service | Role | Host ports (defaults) | | ----------- | ---------------------------------------- | ------------------------------------ | | `app` | nginx + php-fpm (`development` target) | **`8000`→80** (`APP_PORT`) | | `vite` | Vite HMR (PHP+Node image) | `5173` (`VITE_PORT`) | | `pgsql` | PostgreSQL 16 (default DB) | `5432` | | `redis` | Queue / cache / session / Pulse / Reverb | `6379` | | `horizon` | `artisan horizon` | — | | `reverb` | WebSockets | **`8081`→8080** (`REVERB_HOST_PORT`) | | `scheduler` | `schedule:work` | — | | `pulse` | `pulse:work` | — | | `mailpit` | Local mail UI + SMTP | UI `8025` / SMTP `1025` | | `minio` | Optional S3 (`--profile minio`) | API `9000` / console `9001` | | `mysql` | Optional (`--profile mysql` + overlay) | `3306` | | `mariadb` | Optional (`--profile mariadb` + overlay) | **`3307`→3306** (Herd-like) | > **Ports vs Herd** > **App:** Compose publishes HTTP on host **`:8000`**, not `:80`. **Reverb:** host **`:8081`** (container `8080`) so it does not clash with Herd Pro on **`:8080`**. `.env.docker.example` already sets `REVERB_PORT` / `VITE_REVERB_PORT=8081`. ### Env: COMPOSE overrides (Herd-safe) Compose loads `.env` via `env_file`, then **overrides** critical keys in `environment:`: | Knob | Default | Purpose | | --------------------------------------------------------------------- | -------------------------------- | -------------------------------------------------------------------------------------------------------------------- | | `COMPOSE_APP_URL` | `http://localhost:8000` | Forces container `APP_URL` so session/CSRF match the published URL even if host `.env` still has a Herd `*.test` URL | | `COMPOSE_DB_CONNECTION` | `pgsql` | DB driver inside containers | | `COMPOSE_DB_HOST` | `pgsql` | Service DNS name (never `127.0.0.1` from a Herd `.env`) | | `COMPOSE_DB_PORT` | `5432` | Port **inside** the network | | `COMPOSE_DB_DATABASE` / `COMPOSE_DB_USERNAME` / `COMPOSE_DB_PASSWORD` | `externa` / `externa` / `secret` | Shared by app services and the DB container | Keep host Herd `.env` values like `DB_HOST=127.0.0.1` if you still run PHP on the host sometimes — Compose environment wins **inside** containers. Prefer starting from `.env.docker.example` for a Docker-only tree. Also set (or keep from the sample): ```env APP_PORT=8000 REVERB_HOST_PORT=8081 VITE_PORT=5173 RUN_MIGRATIONS=true ``` ### Profiles and DB overlays **MinIO (S3):** ```bash docker compose --profile minio up --build ``` Then set `FILES_DISK=s3`, `AWS_ENDPOINT=http://minio:9000`, `AWS_USE_PATH_STYLE_ENDPOINT=true`, and matching keys — [Files configuration](/docs/files-configuration). `minio-init` creates the bucket. **MySQL 8** (stops default Postgres via overlay — puts `pgsql` behind a non-default profile): ```bash docker compose --profile mysql -f compose.yaml -f compose.mysql.yaml up --build ``` **MariaDB** (host **`:3307`** like Herd): ```bash docker compose --profile mariadb -f compose.yaml -f compose.mariadb.yaml up --build ``` If an old Postgres container still owns host `:5432`: ```bash docker compose stop pgsql ``` Database matrix: [Supported databases](/docs/supported-databases). ### Vite service and Wayfinder The `vite` service builds from Dockerfile target **`vite`**: **PHP + Node** in one image. Wayfinder (and related Artisan) need PHP available while `npm run dev` runs. Vite waits until `app` is **healthy** (vendor volume ready). Do not replace this with a Node-only image. ### First boot: migrate, seed, login 1. `RUN_MIGRATIONS=true` (default) runs `php artisan migrate --force` in the `app` entrypoint after DB is reachable. 2. **Seed is not automatic.** After the stack is healthy: ```bash docker compose exec app php artisan db:seed --force ``` 3. Open `http://localhost:8000` and sign in with the seeded super admin (`config/super_admin.php`): | | Default | | -------- | ------------------------ | | Email | `superadmin@example.com` | | Password | `password` | Override with `INITIAL_SUPER_ADMIN_EMAIL` / `INITIAL_SUPER_ADMIN_PASSWORD` **before** seeding. Local/dev only. Entrypoint also generates `APP_KEY` when empty, waits for DB, and runs `storage:link`. Sibling workers (`horizon`, `reverb`, …) wait for `vendor/autoload.php` so parallel `composer install` cannot corrupt the vendor volume. ### Health probes (local) Compose `app` healthcheck: ```yaml healthcheck: test: ['CMD-SHELL', 'curl -fsS http://127.0.0.1/health/ready || exit 1'] interval: 10s timeout: 5s retries: 12 start_period: 300s # cold vendor install can be long ``` | Endpoint | Meaning | | ------------------- | ------------------------------------------------- | | `GET /health/live` | Process up | | `GET /health/ready` | DB (+ Redis when required) — **503** if not ready | | `GET /up` | Laravel framework liveness | Details: [Deployment — Health probes](/docs/deployment#health-probes) · [Operations](/docs/operations). ### Troubleshooting | Symptom | Likely cause | Fix | | -------------------------------------------------------------- | ---------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Browser **connection refused** on `:8000` during first minutes | Old images without early nginx bind; or port not published | Pull latest `docker/entrypoint.sh` (binds `:80` early so TCP accepts while composer/migrate run — mid-boot may be **502**, not refused). Confirm `APP_PORT=8000` and `docker compose ps` | | Session / CSRF / redirects to wrong host | Host Herd `APP_URL` leaking into containers | Set `COMPOSE_APP_URL=http://localhost:8000` (Compose already overrides `APP_URL`) | | Echo / Reverb fails from browser | Port mismatch (Herd `:8080` vs Compose `:8081`) | Use `VITE_REVERB_PORT=8081`; restart `vite` after env changes | | Blank UI / missing assets | `vite` stopped or never healthy | `docker compose logs vite`; ensure `app` became healthy first | | DB connection errors inside app | `DB_HOST=127.0.0.1` from Herd `.env` | Rely on `COMPOSE_DB_*` / service names (`pgsql`, `mysql`, `mariadb`) | | MySQL/MariaDB profile still starts Postgres | Overlay not applied | Pass both `-f compose.yaml -f compose.mysql.yaml` (or `.mariadb.yaml`) **and** `--profile mysql` / `mariadb` | Useful: ```bash docker compose ps docker compose logs -f app curl -fsS http://localhost:8000/health/ready ``` Production image + Compose: [Deploy with Docker](/docs/deploy-with-docker) · [Deployment — Docker](/docs/deployment#docker-production). Samples: `.env.docker.example`, `.env.docker.quick.example`, `.env.docker.prod.example`. Core README Docker section mirrors this. ### Alternative: SSR-oriented dev If you need the Inertia SSR path: ```bash composer run dev:ssr ``` That builds SSR assets and runs serve, queue, pail, and `inertia:start-ssr` instead of the Vite HMR process used by `composer run dev`. ## First login 1. Go to the login page (Fortify). 2. Sign in with the seeded super admin (default `superadmin@example.com` / `password`). 3. Confirm you can open **Dashboard**, **Users**, **Collections**, and **Files** according to `super-admin` permissions. Built-in roles after seed: | Role | Access summary | | ------------- | ----------------------------------------------------------------------------------------------------------------- | | `super-admin` | Every permission | | `admin` | Every permission | | `reader` | Read/show permissions only (`can-show-*`) | | `public` | System / not assignable; no Spatie admin permissions — Public CMS API actor (collection/file matrices in role UI) | Create additional users from the admin UI or tinker; assign roles and/or groups so [effective permissions](/docs/permissions) resolve as expected. ## Queue worker (required for background work) With `QUEUE_CONNECTION=database`, jobs are stored in the `jobs` table. Keep a worker running whenever you use: - Multi-file / folder **zip** preparation (`PrepareFilesZipJob`) - **Duplicate** of folders, bulk selections, or files above `FILES_DUPLICATE_SYNC_MAX_BYTES` (`DuplicateFilesJob`) - Collection **imports** (`ImportCollectionJob`) `composer run dev` already includes `queue:listen`. Standalone: ```bash php artisan queue:listen --tries=1 --timeout=0 ``` > If zips stay “preparing”, imports never finish, or large duplicates hang, the usual cause is no worker (or a worker pointed at a different `.env` / database). ## Optional: local AI gateway AI chat works without a remote cloud key when the **local** provider is configured (default). 1. Start LM Studio, vLLM, or another OpenAI-compatible server. 2. Align `.env` with the gateway: ```env AI_DEFAULT_PROVIDER=local LOCAL_AI_URL=http://127.0.0.1:1234/v1 LOCAL_AI_API_KEY= LOCAL_AI_MODEL=openai/gpt-oss-20b ``` 3. Prefer a model with reliable **tool calling**. The `.env.example` comment warns that tiny models often hallucinate success. 4. Optional hardening / features: ```env AI_REMOTE_IMPORT_HOSTS= AI_WEBHOOK_TOKEN= AI_DAILY_PROMPT_LIMIT=0 AI_EMBEDDINGS_ENABLED=false AI_MCP_ENABLED=false ``` `AI_DAILY_PROMPT_LIMIT=0` means no daily cap (see `config/ai.php`). Remote JSON import hosts must be allowlisted via `AI_REMOTE_IMPORT_HOSTS` when you use those tools. Open the AI section in the admin UI once the app and gateway are up. Deeper behavior is covered in [AI assistant model](/docs/ai-assistant) and [AI configuration](/docs/ai-configuration). ## Useful Artisan follow-ups ```bash # Re-sync permission definitions from the enum (ops / upgrades) php artisan permissions:sync # Optional: drop DB permissions no longer present on PermissionEnum php artisan permissions:sync --prune # Cleanup helpers (zips, uploads, AI) php artisan files:cleanup-zips php artisan files:cleanup-uploads php artisan list | grep -E 'files:|ai:|permissions:' ``` Other operational commands (AI sync sources, AI attachment cleanup, chat verification) live under `app/Console/Commands` — run `php artisan list` for the full set. ## Verify the install Smoke checklist: 1. Login with the seeded super admin. 2. Create a collection and a field; create an item. 3. Upload a small file in the file manager; confirm it appears after `storage:link`. 4. Trigger a zip of multiple files and confirm the queue worker processes `PrepareFilesZipJob`. 5. (Optional) Send an AI prompt that lists collections — confirms gateway + tools. Automated tests: ```bash php artisan test # or composer test ``` ## Troubleshooting ### Create-project fails with Connection refused / migrate errors Modern `post-create-project-cmd` only runs `key:generate` and prints the `externa:install` handoff — it must **not** migrate. If an older package still migrates during create-project and the DB is down, create-project aborts before the wizard. **Recovery:** keep the project directory if Composer left it; `cd` in and run `php artisan externa:install`. Or recreate from a release that includes the minimal post-create scripts ([Packagist](/docs/packagist#troubleshooting-create-project)). ### `APP_KEY` / cipher errors `.env` is missing or empty `APP_KEY`. From **1.2.2**, `php artisan externa:install` keeps a valid existing key or generates one when rewriting `.env` ([#249](https://github.com/qiick-io/externa-core/pull/249)). Otherwise run: ```bash php artisan key:generate ``` ### Migrate fails on SQLite Ensure `database/database.sqlite` exists and `DB_CONNECTION=sqlite`. Remove stale `DB_DATABASE` host settings left over from MySQL examples if they confuse your tooling. ### Frontend is blank or outdated Run Vite (`composer run dev` or `npm run dev`), or rebuild: ```bash npm run build ``` If the site is on **Herd HTTPS** (`herd secure`) and the admin is black/blank, check that Vite `detectTls` uses the **site host** (so `public/hot` is `https://your-site.test:5173`, not `127.0.0.1`) — [Passkeys — Local HTTPS](/docs/passkeys#local-https-herd-valet-for-passkey-testing). ### 404 on uploaded file URLs Run `php artisan storage:link` and confirm `FILESYSTEM_DISK` / public disk configuration. ### Jobs never run Confirm `QUEUE_CONNECTION=database`, that migrations created queue tables, and that `queue:listen` or `queue:work` is running against the same database as the web process. ### AI tools “succeed” but nothing changes Usually a weak local model inventing tool results. Switch `LOCAL_AI_MODEL` to a tool-capable model and watch `pail` / Laravel logs for real tool exceptions. ### Permission cache after seeding or role edits Spatie caches permissions. Seeders call `forgetCachedPermissions`; if you edited the DB by hand: ```bash php artisan permission:cache-reset ``` (or the equivalent provided by your installed spatie/laravel-permission version). ### Port already in use Change the serve port (`php artisan serve --port=8001`) or stop the other process. Update `APP_URL` to match. ## Next steps - Prefer the short path next time: [Quick Start](/docs/quick-start) - Map the codebase: [Project layout](/docs/project-layout) - Tune `.env`: [Environment variables](/docs/environment-variables) - Understand access control: [Effective permissions](/docs/permissions) - Hit the Public API / GraphQL: [externa-bruno](https://github.com/qiick-io/externa-bruno) (open in Bruno, env **Local**) · [Public CMS API](/docs/public-cms-api) - Run background work in production: [Queues & scheduler](/docs/operations) --- # Headless starter Source: https://docs.externa.qiick.io/docs/headless-starter Official **Next.js App Router** and **Nuxt 3** starters that read collection items from Externa’s headless `/api/v1` API. List + detail pages, env-based config, server-side fetches only. > **Related** > [Public CMS API](/docs/public-cms-api) · [Public CMS API types](/docs/public-cms-api-types) · [GraphQL](/docs/graphql) · [Quick Start](/docs/quick-start) · [Installation](/docs/installation) · Conventions: [core `docs/starters.md`](https://github.com/qiick-io/externa-core/blob/develop/docs/starters.md) · Marketing: [externa.qiick.io](https://externa.qiick.io/en) ## Repos | Framework | Repo | | ------------------- | --------------------------------------------------------------------------------- | | Next.js (reference) | [qiick-io/externa-next-starter](https://github.com/qiick-io/externa-next-starter) | | Nuxt | [qiick-io/externa-nuxt-starter](https://github.com/qiick-io/externa-nuxt-starter) | Shared bar (TypeScript, env vars, CI, Dependabot): core [#156](https://github.com/qiick-io/externa-core/issues/156) / [`docs/starters.md`](https://github.com/qiick-io/externa-core/blob/develop/docs/starters.md). ## What you get | Piece | Notes | | -------------- | ------------------------------------------------------------------------------------------ | | `/` | Lists items for one collection slug | | `/items/[id]` | Shows field values for one item | | Typed helpers | `lib/externa/*` (Next) / `server/utils/externa/*` (Nuxt) — fetch client, OpenAPI probe | | `.env.example` | `EXTERNA_API_URL`, `EXTERNA_API_KEY`, `EXTERNA_COLLECTION` (optional; auto-picks if empty) | **Not included:** marketing theme, Kitchen Sink seed, OpenAPI codegen client (see [Public CMS API types](/docs/public-cms-api-types) and core [#25](https://github.com/qiick-io/externa-core/issues/25)). ## 1. Run Externa Follow [Quick Start](/docs/quick-start) or [Installation](/docs/installation) until the admin is up (e.g. `http://externa-core.test`). Create a collection (slug `posts` is a common default) and at least one item. Grant **Read** on that collection for the `public` role (or create an API key whose role has Read) — [Public CMS API quick start](/docs/public-cms-api#quick-start-typical-website-read). ## 2a. Next.js starter ```bash git clone https://github.com/qiick-io/externa-next-starter.git cd externa-next-starter cp .env.example .env.local ``` Edit `.env.local`: ```env EXTERNA_API_URL=http://externa-core.test # optional — omit to use the public role EXTERNA_API_KEY= EXTERNA_COLLECTION=posts ``` ```bash npm install npm run dev ``` Open `http://localhost:3002`. > `EXTERNA_API_KEY` is read only in Server Components / server `fetch`. Do not prefix it with `NEXT_PUBLIC_`. ## 2b. Nuxt starter ```bash git clone https://github.com/qiick-io/externa-nuxt-starter.git cd externa-nuxt-starter cp .env.example .env ``` Same env vars as Next. Fetches run in Nitro `/api/*` handlers with private `runtimeConfig` — never put the key under `runtimeConfig.public` or a `NUXT_PUBLIC_` prefix. ```bash npm install npm run dev ``` Open `http://localhost:3003`. ## 3. Verify the API without a frontend ```bash curl -sS 'http://externa-core.test/api/v1/collections/posts/items?page=1&per_page=5' \ -H 'Accept: application/json' # optional: -H "Authorization: Bearer ek_YOUR_SECRET" ``` Ready-made requests: [externa-bruno](https://github.com/qiick-io/externa-bruno). ## GraphQL Both starters use REST. The same Collection access rules apply at `/api/graphql` — [GraphQL](/docs/graphql). ## Rebuild when content changes Point Externa [outbound webhooks](/docs/webhooks) at your host’s deploy hook (or a Make/n8n scenario) so publishes trigger a rebuild. ## Optional: full-text search For on-site search beyond API `filter[...]`, enable Externa’s [Meilisearch sync](/docs/meilisearch) and query the index from a **server** route with a search-only key (filter by `collection_slug`). --- # Packagist & create-project Source: https://docs.externa.qiick.io/docs/packagist Operators install **externa-core** with Composer — no git clone required. Contributors still clone the GitHub repo. > **Related** > [Installation](/docs/installation) · [Quick Start](/docs/quick-start) · [Upgrade](/docs/upgrade) · [Releasing](/docs/releasing) ## Happy path (operators) ```bash composer create-project qiick/externa-core my-externa cd my-externa nvm use # Node 24 pinned in .nvmrc php artisan externa:install ``` | Step | What runs | | ----------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `composer create-project` | Installs deps; `post-root-package-install` copies `.env.example` → `.env` if missing; `post-create-project-cmd` runs `key:generate` and prints **Next step: `php artisan externa:install`** | | `php artisan externa:install` | Interactive wizard: APP_NAME, APP_URL, database, minimal (no Redis) vs full stack, optional seed / `LOCAL_AI_URL`; keeps or generates `APP_KEY`, migrate, `storage:link`, npm build. Re-run with `--force` when intentional ([#249](https://github.com/qiick-io/externa-core/pull/249)) | **`post-create-project-cmd` does not** touch SQLite, run migrate, seed, or assume Redis/Horizon (#93). Create-project must succeed even when no database is reachable yet. > **Install only — not the update channel** > `create-project` copies the app **once**. After you push that tree to **your** git remote, Packagist will **not** deliver newer Externa releases into it. Stay current with git tags / `upstream` merge (or opt-in `externa:upgrade --sync-upstream=vX.Y.Z`, or Docker image pull), then `php artisan externa:upgrade` for migrate / permissions / cache — [Upgrade](/docs/upgrade). | Flag / stability | When | | --------------------------------------------- | -------------------------------------------------------------- | | Stable (omit constraint, or `^1.0` / `1.0.0`) | GA and later | | `@beta` / `@RC` | Pre-1.0 tags only (`1.0.0-beta.*`) — keep for historical notes | | `composer create-project … --stability=beta` | Alternative when intentionally pulling a beta tag | Package: [packagist.org/packages/qiick/externa-core](https://packagist.org/packages/qiick/externa-core). Full wizard details: [Installation — externa:install](/docs/installation#interactive-installer-externa-install). **Docker** remains a parallel path: [Installation — Docker](/docs/installation#docker-compose-local-full-stack) · [Deploy with Docker](/docs/deploy-with-docker). Prefer **one** local path per checkout (Herd **or** Compose). ## CI / non-interactive (not the human path) Automation and contributor bootstrap that already has a working DB use: ```bash composer setup ``` That runs `composer install` → copy `.env` if missing → `key:generate` → `migrate --force` → `npm install` → `npm run build`. Still run `db:seed` / `storage:link` when needed. Do **not** document `composer setup` as the create-project happy path for operators. ## Contributors ```bash git clone https://github.com/qiick-io/externa-core.git cd externa-core composer setup # or: php artisan externa:install for a guided first run ``` Use clone when you need to open PRs, run the full test suite against `develop`, or customize before upstreaming. ## Packagist status Package name: **`qiick/externa-core`** (`type: project`). Source: [github.com/qiick-io/externa-core](https://github.com/qiick-io/externa-core). Listing: [packagist.org/packages/qiick/externa-core](https://packagist.org/packages/qiick/externa-core). **Published** — `create-project` resolves from Packagist. Confirm the desired tag (stable `1.0.0` or a beta) under **Versions** after each release. VCS fallback only if Packagist lags: ```bash composer create-project qiick/externa-core my-externa \ --repository='{"type":"vcs","url":"https://github.com/qiick-io/externa-core.git"}' ``` ## Maintainer: Packagist / smoke 1. Confirm tagged releases appear under Versions on Packagist. 2. Smoke from a clean machine: ```bash composer clear-cache composer create-project qiick/externa-core:^1.0 /tmp/externa-smoke --no-interaction cd /tmp/externa-smoke # Must print Next step: php artisan externa:install — no migrate hard-fail php artisan externa:install ``` 3. Note the Packagist URL in the GitHub Release ([Releasing](/docs/releasing)). ## Troubleshooting (create-project) | Symptom | Cause | Fix | | ------------------------------------------------------------------ | ----------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | Create-project aborts with **Connection refused** / migrate errors | Old package still runs migrate in `post-create-project-cmd` | Upgrade to a release that keeps post-create minimal (#93); or use VCS create-project from `develop` / the fix tag | | Project directory exists but DB empty | Expected after modern create-project | Run `php artisan externa:install` | | Re-run of `externa:install` | Wizard asks before overwriting `.env` (unless `--force`) | Decline overwrite to keep current env; or pass `--force` when intentional. A valid existing `APP_KEY` is preserved ([#249](https://github.com/qiick-io/externa-core/pull/249)) | | Need scripted install (CI) | Human path is interactive | Use `composer setup` (documented above), not create-project post-scripts | --- # Supported databases Source: https://docs.externa.qiick.io/docs/supported-databases Externa supports four first-class Laravel drivers. CI runs Pest on all of them; production prefers PostgreSQL. ## Compatibility matrix | Database | Min version | Preferred | Role | | -------------- | ------------- | ---------- | -------------------------------------------------------------- | | **SQLite** | 3.x (PHP PDO) | — | Local / CI / demos. **Not** for multi-writer production. | | **PostgreSQL** | 14+ | **16+** | **Production recommended** (jsonb + GIN on collection values). | | **MySQL** | **8.0+** | 8.4 | Production supported. | | **MariaDB** | **10.6+** | **10.11+** | Production supported (`DB_CONNECTION=mariadb`). | Out of scope: MySQL 5.7, MariaDB < 10.6, SQL Server, Oracle. ## Charset (MySQL / MariaDB) Use **`utf8mb4`** with collation **`utf8mb4_unicode_ci`** (Laravel `config/database.php` defaults via `DB_CHARSET` / `DB_COLLATION`). Do not use `utf8` / `utf8mb3`. ## CI coverage GitHub Actions (`.github/workflows/tests.yml` in externa-core): | Job | Driver | | ------------ | -------------------------------- | | `ci` | SQLite in-memory (PHP 8.4 + 8.5) | | `ci-mysql` | MySQL 8.4 | | `ci-mariadb` | MariaDB 11.4 | | `ci-pgsql` | PostgreSQL 16 | Browser tests stay on SQLite. ## Postgres-only features Collection value indexes use **GIN** (`jsonb_path_ops`) only on PostgreSQL. MySQL/MariaDB use standard Laravel `json` columns without a GIN equivalent — heavy JSON filter workloads prefer Postgres. MySQL/MariaDB **JSON** columns may reorder object keys on write; Externa treats JSON as unordered maps (tests use equality, not identity). ## Driver selection Set `DB_CONNECTION` to one of: `sqlite` | `pgsql` | `mysql` | `mariadb`. ```env # MySQL 8 example DB_CONNECTION=mysql DB_HOST=127.0.0.1 DB_PORT=3306 DB_DATABASE=externa DB_USERNAME=externa DB_PASSWORD=secret DB_CHARSET=utf8mb4 DB_COLLATION=utf8mb4_unicode_ci ``` ```env # MariaDB (Laravel mariadb driver) DB_CONNECTION=mariadb DB_HOST=127.0.0.1 DB_PORT=3306 DB_DATABASE=externa DB_USERNAME=externa DB_PASSWORD=secret ``` Create the empty database (and user) before `php artisan migrate`. Full env catalog: [Environment variables](/docs/environment-variables). Local examples: [Quick Start](/docs/quick-start), [Installation](/docs/installation). Production swap: [Deployment](/docs/deployment#production-database-leave-sqlite). ## Related - [Environment variables](/docs/environment-variables) — `DB_*` - [Quick Start](/docs/quick-start) — Herd Postgres / MySQL snippets - [Installation](/docs/installation) · [Deployment](/docs/deployment) - [Testing](/docs/testing) — Pest + RefreshDatabase --- # Project layout Source: https://docs.externa.qiick.io/docs/project-layout externa-core follows a conventional Laravel application layout with domain folders for Actions, AI, Jobs, and Services, plus an Inertia React UI under `resources/js`. This page is a map of where to look when you change behavior. ## Top-level tree ```bash externa-core/ ├── app/ # PHP application code ├── bootstrap/ # app.php, providers, cache ├── config/ # Laravel + Externa config ├── database/ # migrations, factories, seeders ├── public/ # web root, built assets, storage link ├── resources/ │ ├── css/ │ ├── js/ # React / Inertia / Wayfinder │ └── views/ # Blade shell for Inertia ├── routes/ # web + domain route files ├── storage/ # logs, app files, frameworks, zips ├── tests/ # Pest Feature / Unit / Browser ├── artisan ├── composer.json ├── package.json └── vite.config.* # Vite + Wayfinder plugin ``` Ignore vendor tooling directories (`.agents`, `.cursor`, etc.) unless you are working on AI coding assistants for the repo. ## `app/` — backend domains | Path | Role | | -------------------- | ------------------------------------------------------------------------------------- | | `app/Actions/` | Single-purpose action classes (e.g. Fortify-related actions under `Actions/Fortify`). | | `app/Ai/` | Agent, tools, and AI support code for laravel/ai. | | `app/Concerns/` | Shared PHP traits/concerns used across layers. | | `app/Console/` | Artisan commands (cleanup, sync, AI sync sources, …). | | `app/Enums/` | `PermissionEnum`, `RoleEnum`, `FieldTypeEnum`, `FileTypeEnum`, etc. | | `app/Http/` | Controllers, middleware, form requests, API resources. | | `app/Jobs/` | Queued work: duplicate files, prepare zips, import collections. | | `app/Models/` | Eloquent models. | | `app/Notifications/` | In-app / mail notifications. | | `app/Providers/` | Service providers. | | `app/Services/` | Domain services (files, collections, authorization). | | `app/Support/` | Small helpers and support types. | | `app/Traits/` | Model or cross-cutting traits. | ### Models (`app/Models`) Core domain models include: - `User`, `UserGroup` - `Collection`, `CollectionField`, `CollectionItem`, `CollectionItemValue` - `File`, `FileUpload`, `FileVersion` - `AiChatAttachment`, `AiSyncSource` Spatie models (roles, permissions, activity, tags) are provided by packages; application code references them via config and enums. ### HTTP layer (`app/Http`) Controllers are grouped by product area: ```bash app/Http/Controllers/ ├── DashboardController.php ├── NotificationController.php ├── Admin/ │ ├── UserController.php │ ├── UserGroupController.php │ ├── RoleController.php │ ├── PermissionController.php │ ├── ActivityLogController.php │ └── FileController.php ├── Collections/ │ ├── ContentCollectionController.php │ ├── FieldController.php │ └── ItemController.php ├── Ai/ │ ├── AiPageController.php │ ├── AiChatController.php │ ├── AiConversationController.php │ ├── AiChatAttachmentController.php │ ├── AiStatusController.php │ ├── ImportJobStatusController.php │ └── CollectionImportWebhookController.php └── Settings/ ├── ProfileController.php └── SecurityController.php ``` Form requests mirror those areas under `app/Http/Requests/{Admin,Ai,Collections,Settings,Notifications}`. JSON/Inertia resources live under `app/Http/Resources`. Middleware of note: - `HandleInertiaRequests` — shared props for the React shell - `EnsureUserHasPermission` / `EnsureCanManageFiles` — permission gates beyond route middleware - `HandleAppearance` — UI appearance preferences Route middleware also uses Spatie’s `permission:` alias extensively in the route files. ### Services (`app/Services`) | Path | Typical responsibility | | -------------------------------------------------------- | -------------------------------------------------------- | | `Services/FileService.php` | File tree operations used by controllers/jobs. | | `Services/FileTransformService.php` | Transforms / derived file handling. | | `Services/Authorization/EffectivePermissionResolver.php` | Merges direct + group-inherited permissions/roles. | | `Services/Collections/` | Collection/item domain logic extracted from controllers. | Prefer extending a service or action over bloating controllers when adding behavior. ### Jobs (`app/Jobs`) | Job | Why it is queued | | --------------------- | ---------------------------------------- | | `DuplicateFilesJob` | Folder/bulk/large duplicates. | | `PrepareFilesZipJob` | Async zip under `storage/app/zips`. | | `ImportCollectionJob` | CSV/remote imports driven by AI or HTTP. | Requires `QUEUE_CONNECTION=database` (default) and a running worker — see [Installation](/docs/installation). ### AI (`app/Ai`) ```bash app/Ai/ ├── Agents/ │ └── AppAssistant.php # Primary in-app assistant ├── Tools/ # Tool classes invoked by the agent │ ├── ManageCollections.php │ ├── ManageCollectionItems.php │ ├── QueryCollectionItems.php │ ├── SearchSimilarCollectionItems.php │ ├── ExportCollection.php │ ├── ImportCollectionCsv.php │ ├── ImportRemoteJson.php │ ├── GetImportJobStatus.php │ ├── ManageFiles.php │ ├── ManageUsers.php │ ├── ManageGroups.php │ ├── ManageRoles.php │ ├── ManageAiSyncSources.php │ ├── QueryActivityLogs.php │ ├── RollbackLastAiTurn.php │ └── ExtractPdfText.php ├── Concerns/ # Permissions, import helpers, logging └── Support/ # URL validation, JSON decode, activity helpers ``` Tools should keep using `ChecksAiPermissions` (and related concerns) so the assistant cannot outrank the signed-in user. Product docs: [AI assistant model](/docs/ai-assistant). ### Enums (`app/Enums`) Source of truth for seeded roles/permissions and field/file typing. Changing `PermissionEnum` usually means running the sync/seed path so the database matches — see console commands under `app/Console/Commands` (e.g. permission sync). ## Routes (`routes/`) Application HTTP routes are split by domain and composed from `web.php`: | File | Contents | | ------------------------ | ---------------------------------------------------------------------------- | | `routes/web.php` | Home redirect, dashboard, notifications; `require`s the files below. | | `routes/admin.php` | Users, groups, roles, permissions, activity logs, file manager. | | `routes/settings.php` | Profile / security (Fortify-backed settings UI). | | `routes/collections.php` | Collections, fields, items, **item chat aliases**. | | `routes/chat.php` | Team chat hub pages + JSON (item + direct threads). | | `routes/ai.php` | AI pages, chat, conversations, attachments, status, webhooks, import status. | | `routes/console.php` | Schedule / console route definitions. | Authenticated groups typically use `auth` + `verified`, then per-route `permission:…` middleware with `PermissionEnum` values. > **Note** > When adding a feature, add the route in the matching domain file, a controller method, a form request if input is non-trivial, and an Inertia page under `resources/js/pages/…`. Generate or refresh Wayfinder types so the frontend can call named routes safely. ## Config (`config/`) Externa-specific and heavily used package configs: | File | Purpose | | ------------------------ | ------------------------------------------------------------------------------------------------------- | | `config/ai.php` | Default provider, local gateway, embeddings/MCP flags, remote import hosts, daily limit, webhook token. | | `config/collections.php` | Seed/fallback for content locales until project settings are saved. | | `config/files.php` | Duplicate sync threshold, zip TTL and max size. | | `config/super_admin.php` | Initial super admin for `CreateSuperAdminSeeder`. | | `config/permission.php` | Spatie permission package. | | `config/fortify.php` | Auth features / Fortify. | | `config/activitylog.php` | Spatie activity log enablement and buffer. | Standard Laravel configs (`app.php`, `database.php`, `queue.php`, `session.php`, `cache.php`, `mail.php`, `filesystems.php`, …) behave as usual. ## Frontend (`resources/js/`) | Path | Role | | ------------------------ | ---------------------------------------------------------------- | | `pages/` | Inertia page components (one route → one page module). | | `components/` | Shared UI (shell, sidebar, admin/collections/ai widgets, `ui/`). | | `hooks/` | React hooks. | | `lib/` | Client utilities. | | `layouts/` | App layouts. | | `interfaces/` / `types/` | TypeScript types. | | `enums/` | Frontend enum mirrors where needed. | | `actions/` | Client-side action helpers when used by the starter patterns. | | `routes/` + `wayfinder/` | **Generated** Wayfinder TypeScript for Laravel routes/actions. | | `app.tsx` / `ssr.tsx` | Inertia app entry (CSR / SSR). | ### Page map ```bash resources/js/pages/ ├── dashboard.tsx ├── welcome.tsx ├── auth/ # Login, register, passwords, 2FA, … ├── settings/ # Profile, security, appearance ├── admin/ │ ├── users/ │ ├── groups/ │ ├── roles/ │ ├── permissions/ │ ├── files/ │ └── activity-logs/ ├── collections/ │ ├── collections/ │ └── items/ └── ai/ # Chat / assistant UI ``` Component folders often mirror these domains (`components/admin`, `components/collections`, `components/ai`, …). ### Wayfinder Externa uses **Laravel Wayfinder** with `@laravel/vite-plugin-wayfinder`. Named routes and controller actions are exposed as typed helpers under `resources/js` (commonly `resources/js/routes` and `resources/js/wayfinder`, generated on build/dev). Workflow: 1. Define or change a route in `routes/*.php`. 2. Run Vite (`npm run dev` / `composer run dev`) or the project’s Wayfinder generation step so TS clients update. 3. Import the generated helper in the page/component instead of hard-coding URL strings. > Do not hand-edit generated Wayfinder output. Change PHP routes/controllers and regenerate. ## Database (`database/`) ```bash database/ ├── migrations/ # Schema (users, permissions, collections, files, AI, jobs, …) ├── factories/ # Model factories for tests └── seeders/ ├── DatabaseSeeder.php ├── PermissionSeeder.php ├── RoleSeeder.php └── CreateSuperAdminSeeder.php ``` Seed order: permissions → roles → super admin. See [Installation](/docs/installation). ## Storage and public assets | Location | Use | | -------------------- | ------------------------------------------------------------------------------- | | `storage/app/` | Private app files; prepared zips under a zips directory per `config/files.php`. | | `storage/app/public` | Public disk files; must be linked via `php artisan storage:link`. | | `public/build` | Vite-built assets after `npm run build`. | ## Tests (`tests/`) Pest 4 layout: ```bash tests/ ├── Pest.php ├── TestCase.php ├── Feature/ │ ├── Admin/ │ ├── Ai/ │ ├── Auth/ │ ├── Authorization/ │ ├── Collections/ │ ├── Settings/ │ ├── DashboardTest.php │ ├── NotificationsTest.php │ └── … ├── Unit/ │ └── Collections/ # and other unit suites └── Browser/ └── FileManagerBrowserTest.php # Playwright-backed browser tests ``` Run: ```bash php artisan test composer test # clears config, runs Pint check, then tests composer ci:check # lint/format/types + tests (heavier) ``` Mirror feature folders when adding coverage (e.g. new AI tool → `tests/Feature/Ai/…`). ## Console commands (`app/Console/Commands`) Operational commands include (names may be prefixed — check `php artisan list`): - Permission sync from enums - Cleanup expired file zips - Cleanup stale file uploads - Cleanup AI chat attachments - Run AI sync sources Scheduling, if any, is wired through `routes/console.php` / the application scheduler — see [Queues & scheduler](/docs/operations). ## Where to change what (cheat sheet) | You want to… | Start here | | ---------------------------- | ---------------------------------------------------------------- | | Add an admin HTTP endpoint | `routes/admin.php` → `Http/Controllers/Admin` → `pages/admin/…` | | Change collection validation | `Http/Requests/Collections` + enums/services | | Adjust effective permissions | `EffectivePermissionResolver` + policies/middleware + seed enums | | Add an AI capability | New class in `app/Ai/Tools` registered on `AppAssistant` | | Tune zip/duplicate limits | `config/files.php` / `FILES_*` env | | Change default AI provider | `config/ai.php` / `AI_*` and `LOCAL_AI_*` | | Alter first seeded login | `config/super_admin.php` / `INITIAL_SUPER_ADMIN_*` | | Fix shared Inertia props | `HandleInertiaRequests` | | Add a queued workflow | `app/Jobs` + ensure worker in [Installation](/docs/installation) | ## Related docs - [Architecture](/docs/architecture) — request lifecycle and layering - [Environment variables](/docs/environment-variables) — `.env` reference - [Routing overview](/docs/routing) — HTTP surface summary - [Testing](/docs/testing) — conventions for Pest and browser tests --- # Environment variables Source: https://docs.externa.qiick.io/docs/environment-variables Externa reads configuration from `.env` (copied from `.env.example` during `composer setup`). Most keys map into `config/*.php` via `env()`. This page groups the variables you will actually set for externa-core and documents defaults. > **Note** > After changing env values that are cached in production, run `php artisan config:clear` or rebuild config cache. Locally, `composer test` already clears config before Pest runs. ## App Standard Laravel application identity and runtime flags from `.env.example`: | Variable | Default (example) | Purpose | | -------------------------- | ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `APP_NAME` | `Laravel` | Application name. Used as mail from-name interpolation and as the fallback display name when Project settings → name is empty. Transactional mail branding (logo/color/header) comes from Appearance / Project settings — see [Project settings](/docs/project-settings#transactional-email-branding). | | `APP_ENV` | `local` | Environment name (`local`, `production`, …). | | `APP_KEY` | _(empty until generated)_ | Encryption key; `composer setup` runs `key:generate`. | | `APP_DEBUG` | `true` | Detailed errors when true — **disable in production**. | | `APP_URL` | `http://localhost` | Canonical URL (scheme + host + port). Must match how you browse. App (password, TOTP, CMS) works on HTTP; **passkeys** need a secure context (HTTPS, or `http://localhost` / `*.localhost` — not `http://*.test`). Drives Fortify RP ID / `allowed_origins`. See [Passkeys — Requirements](/docs/passkeys#requirements). | | `APP_LOCALE` | `en` | Default locale. | | `APP_FALLBACK_LOCALE` | `en` | Fallback locale. | | `APP_FAKER_LOCALE` | `en_US` | Faker locale for factories/seed demos. | | `APP_MAINTENANCE_DRIVER` | `file` | Maintenance mode driver. | | `APP_MAINTENANCE_STORE` | _(commented)_ | Optional store when using database maintenance driver. | | `BCRYPT_ROUNDS` | `12` | Password hashing rounds. | | `LOG_CHANNEL` | `stack` | Default log channel. | | `LOG_STACK` | `single` | Channels in the stack. | | `LOG_DEPRECATIONS_CHANNEL` | `null` | Deprecation log target. | | `LOG_LEVEL` | `debug` | Minimum log level. | | `VITE_APP_NAME` | `"${APP_NAME}"` | Exposed to the Vite/React build as the app name. | ```env APP_NAME=Laravel APP_ENV=local APP_KEY= APP_DEBUG=true APP_URL=http://localhost APP_LOCALE=en APP_FALLBACK_LOCALE=en APP_FAKER_LOCALE=en_US APP_MAINTENANCE_DRIVER=file BCRYPT_ROUNDS=12 LOG_CHANNEL=stack LOG_STACK=single LOG_DEPRECATIONS_CHANNEL=null LOG_LEVEL=debug VITE_APP_NAME="${APP_NAME}" ``` ## Database, session, cache, and queue Externa’s `.env.example` defaults to **SQLite**, **database** sessions, and **Redis** for queue + cache (full stack). For light local without Redis, set `QUEUE_CONNECTION=database` and `CACHE_STORE=database`. Supported engines and versions: [Supported databases](/docs/supported-databases). | Variable | Default | Purpose | | ---------------------- | ----------------------------- | ------------------------------------------------------------------------------------------------------------ | | `DB_CONNECTION` | `sqlite` | Driver: `sqlite` \| `pgsql` \| `mysql` \| `mariadb`. See [Supported databases](/docs/supported-databases). | | `DB_HOST` | `127.0.0.1` (commented) | Host for MySQL/Postgres. | | `DB_PORT` | `3306` (commented) | Port for non-SQLite drivers. | | `DB_DATABASE` | `laravel` (commented) | Database name / path depending on driver. | | `DB_USERNAME` | `root` (commented) | DB user. | | `DB_PASSWORD` | _(empty, commented)_ | DB password. | | `SESSION_DRIVER` | `database` | Session storage. | | `SESSION_LIFETIME` | `120` | Minutes. | | `SESSION_ENCRYPT` | `false` | Encrypt session payload. | | `SESSION_PATH` | `/` | Cookie path. | | `SESSION_DOMAIN` | `null` | Cookie domain. | | `QUEUE_CONNECTION` | `redis` (fallback `database`) | Full stack uses Redis + [Horizon](/docs/horizon). Minimal: `database` + `queue:listen`. | | `CACHE_STORE` | `redis` | Prefer Redis for app / public API cache; `database` is fine for light local. | | `CACHE_PREFIX` | _(commented)_ | Optional key prefix. | | `BROADCAST_CONNECTION` | `reverb` (fallback `log`) | Full: [Reverb](/docs/reverb-and-echo). Minimal: `log` (60s notification poll **only when realtime is off**). | | `FILESYSTEM_DISK` | `local` | Framework default disk (private/local). | | `FILES_DISK` | `assets` | File manager disk (`assets` or `s3`). See [Files configuration](/docs/files-configuration). | ```env DB_CONNECTION=sqlite # DB_HOST=127.0.0.1 # DB_PORT=3306 # DB_DATABASE=laravel # DB_USERNAME=root # DB_PASSWORD= SESSION_DRIVER=database SESSION_LIFETIME=120 SESSION_ENCRYPT=false SESSION_PATH=/ SESSION_DOMAIN=null # Full stack defaults in .env.example — switch to database/log for minimal: BROADCAST_CONNECTION=reverb FILESYSTEM_DISK=local QUEUE_CONNECTION=redis CACHE_STORE=redis # CACHE_STORE=database # ok for light local without Redis cache ``` ### Redis (Herd / full stack) Required for Horizon, recommended for app cache / public API performance, plus Reverb-friendly ops and Pulse redis ingest — see [Redis prerequisite](/docs/redis-prerequisite). | Variable | Default | | ---------------- | ---------------------------------- | | `REDIS_CLIENT` | `phpredis` | | `REDIS_HOST` | `127.0.0.1` | | `REDIS_PASSWORD` | `null` | | `REDIS_PORT` | `6379` | | `REDIS_CACHE_DB` | `1` (app cache; keep off queue DB) | | `REDIS_PULSE_DB` | `2` (optional; connection `pulse`) | | `MEMCACHED_HOST` | `127.0.0.1` | ### Reverb (Herd Pro defaults) Match **Herd → Services → Reverb** (`:8080`). Host for Echo must be `127.0.0.1` or `localhost` (not `0.0.0.0`). Restart Vite after changing `VITE_REVERB_*`. | Variable | Default | Notes | | --------------------- | --------------------- | ------------------- | | `REVERB_APP_ID` | `1001` | Herd shared app id | | `REVERB_APP_KEY` | `laravel-herd` | Shared with Vite | | `REVERB_APP_SECRET` | `secret` | Server secret | | `REVERB_HOST` | `127.0.0.1` | WS host for clients | | `REVERB_PORT` | `8080` | Herd Reverb port | | `REVERB_SCHEME` | `http` | `http` / `https` | | `VITE_REVERB_APP_KEY` | `"${REVERB_APP_KEY}"` | Echo key | | `VITE_REVERB_HOST` | `"${REVERB_HOST}"` | Echo host | | `VITE_REVERB_PORT` | `"${REVERB_PORT}"` | Echo port | | `VITE_REVERB_SCHEME` | `"${REVERB_SCHEME}"` | Echo TLS flag | Standalone (no Herd Reverb): set your own `REVERB_APP_*` and run `php artisan reverb:start`. ```env BROADCAST_CONNECTION=reverb REVERB_APP_ID=1001 REVERB_APP_KEY=laravel-herd REVERB_APP_SECRET=secret REVERB_HOST=127.0.0.1 REVERB_PORT=8080 REVERB_SCHEME=http VITE_REVERB_APP_KEY="${REVERB_APP_KEY}" VITE_REVERB_HOST="${REVERB_HOST}" VITE_REVERB_PORT="${REVERB_PORT}" VITE_REVERB_SCHEME="${REVERB_SCHEME}" ``` ### Pulse | Variable | Default | Notes | | ------------------------ | -------- | --------------------------------- | | `PULSE_ENABLED` | `true` | Master switch | | `PULSE_INGEST_DRIVER` | `redis` | Or `storage` without Redis worker | | `PULSE_REDIS_CONNECTION` | `pulse` | Dedicated Redis connection | | `PULSE_STORAGE_KEEP` | `2 days` | Trim window | | `PULSE_INGEST_KEEP` | `2 days` | Ingest trim | > With **Herd Reverb** already on `:8080`, full local ops need `horizon` + `pulse:work` (do not also `reverb:start`). Standalone: `composer run dev:full` starts all three. Minimal: `composer run dev` + `queue:listen`. See [Minimal vs full stack](/docs/minimal-vs-full-stack). ## Mail Local default is the `log` mailer (messages go to the log, not SMTP). | Variable | Default | Purpose | | ------------------- | ------------------- | --------------------------------------- | | `MAIL_MAILER` | `log` | Mail transport. | | `MAIL_SCHEME` | `null` | Scheme when using SMTP-like transports. | | `MAIL_HOST` | `127.0.0.1` | SMTP host. | | `MAIL_PORT` | `2525` | SMTP port. | | `MAIL_USERNAME` | `null` | SMTP user. | | `MAIL_PASSWORD` | `null` | SMTP password. | | `MAIL_FROM_ADDRESS` | `hello@example.com` | From address. | | `MAIL_FROM_NAME` | `"${APP_NAME}"` | From display name. | Logo/color/header branding for password reset and similar mail is **not** controlled by `MAIL_*` — see [Project settings → Transactional email branding](/docs/project-settings#transactional-email-branding). Ensure logo files are publicly reachable (`FILE_PUBLIC_URL_BASE` / assets disk / CDN). Production provider `.env` examples: [Mail providers](/docs/mail-providers). ```env MAIL_MAILER=log MAIL_SCHEME=null MAIL_HOST=127.0.0.1 MAIL_PORT=2525 MAIL_USERNAME=null MAIL_PASSWORD=null MAIL_FROM_ADDRESS="hello@example.com" MAIL_FROM_NAME="${APP_NAME}" ``` ## AWS / S3 / MinIO (optional) For file-manager object storage set `FILES_DISK=s3` (keep `FILESYSTEM_DISK=local` unless you intentionally move everything): | Variable | Default | Notes | | ----------------------------- | ----------- | ------------------------------ | | `AWS_ACCESS_KEY_ID` | _(empty)_ | | | `AWS_SECRET_ACCESS_KEY` | _(empty)_ | | | `AWS_DEFAULT_REGION` | `us-east-1` | | | `AWS_BUCKET` | _(empty)_ | | | `AWS_URL` | _(empty)_ | Public/base URL for the bucket | | `AWS_ENDPOINT` | _(empty)_ | MinIO/R2 endpoint | | `AWS_USE_PATH_STYLE_ENDPOINT` | `false` | `true` for MinIO | ```env FILES_DISK=s3 AWS_ENDPOINT=http://minio:9000 AWS_USE_PATH_STYLE_ENDPOINT=true ``` Compose MinIO profile: [Installation — Docker](/docs/installation#docker-compose-local-full-stack). ## Docker Compose knobs Used by `compose.yaml` / samples (`.env.docker.example`, `.env.docker.prod.example`). Compose `environment:` entries override host Herd `.env` values **inside containers** so `DB_HOST=127.0.0.1` cannot poison the stack. | Variable | Default (local sample) | Purpose | | ----------------------- | -------------------------------------- | --------------------------------------------------------------------------------- | | `COMPOSE_APP_URL` | `http://localhost:8000` | Overrides container `APP_URL` (session/CSRF must match published URL) | | `COMPOSE_DB_CONNECTION` | `pgsql` | Driver for app services | | `COMPOSE_DB_HOST` | `pgsql` | Service DNS (`mysql` / `mariadb` with overlays) | | `COMPOSE_DB_PORT` | `5432` | Port on the Compose network | | `COMPOSE_DB_DATABASE` | `externa` | Shared with DB container | | `COMPOSE_DB_USERNAME` | `externa` | Shared with DB container | | `COMPOSE_DB_PASSWORD` | `secret` | Shared with DB container | | `APP_PORT` | `8000` local / `8080` prod sample | Host publish for nginx | | `REVERB_HOST_PORT` | `8081` | Host publish for Reverb (avoids Herd `:8080`) | | `VITE_PORT` | `5173` | Host publish for Vite (local only) | | `RUN_MIGRATIONS` | `true` | Entrypoint runs `migrate --force` when `true` (default off in entrypoint) | | `RUN_SEED` | `false` | Entrypoint runs `db:seed --force` when `true` (admin via `INITIAL_SUPER_ADMIN_*`) | | `CACHE_CONFIG` | `false` local / `true` prod | Entrypoint config/route/view/event cache | | `TRUSTED_PROXIES` | _(unset)_ | Set behind TLS-terminating proxy (`*` or CIDRs) — prod | | `EXTERNA_IMAGE` | `ghcr.io/qiick-io/externa-core:latest` | Override image for `compose.quick.yaml` | Full walkthrough: [Deploy with Docker](/docs/deploy-with-docker) · [Installation — Docker](/docs/installation#docker-compose-local-full-stack) · [Deployment — Docker](/docs/deployment#docker-production). ## Files Mapped in `config/files.php`: | Variable | Default | Purpose | | -------------------------------- | -------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | | `FILES_DISK` | `assets` | File manager disk name (`assets` or `s3`). | | `FILES_DUPLICATE_SYNC_MAX_BYTES` | `52428800` (50 MB) | Single non-folder files at or below this size duplicate **synchronously**. Folders, bulk selections, and larger files use `DuplicateFilesJob`. | | `FILES_ZIP_TTL_MINUTES` | `60` | Prepared zip lifetime under `storage/app/zips` before cleanup (`files:cleanup-zips` / related command). | | `FILES_ZIP_MAX_BYTES` | `104857600` (100 MB) | Maximum zip payload size enforced by application config. | | `FILE_PUBLIC_URL_BASE` | _(optional)_ | Override base URL for public/asset disks (`config/filesystems.php`). Also documented under [Files & storage](/docs/files-configuration). | ```env FILES_DISK=assets FILES_DUPLICATE_SYNC_MAX_BYTES=52428800 FILES_ZIP_TTL_MINUTES=60 FILES_ZIP_MAX_BYTES=104857600 # FILE_PUBLIC_URL_BASE= ``` > **Note** > Public URLs for the public disk still require `php artisan storage:link` regardless of these variables. ## Outbound webhooks / Slack (1.3.0+) | Variable | Default | Notes | | ------------------------------ | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `WEBHOOKS_ALLOW_PRIVATE_HOSTS` | `false` | When `false`, reject loopback / RFC1918 / link-local / reserved hosts for webhook + Slack notifier URLs (save + delivery). Set `true` only for trusted LAN SSRF opt-in — [Outbound webhooks](/docs/webhooks#ssrf-guard). | ```env WEBHOOKS_ALLOW_PRIVATE_HOSTS=false ``` ## AI Mapped primarily in `config/ai.php`. Externa defaults to a **local** OpenAI-compatible provider (LM Studio, vLLM, etc.). | Variable | Default | Purpose | | -------------------------- | ------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `AI_DEFAULT_PROVIDER` | `local` | Provider key under `config('ai.providers')`. | | `LOCAL_AI_URL` | `http://127.0.0.1:1234/v1` | Base URL for the local OpenAI-compatible API. | | `LOCAL_AI_API_KEY` | _(empty)_ | Optional bearer key if your gateway requires one. | | `LOCAL_AI_MODEL` | `openai/gpt-oss-20b` in `.env.example` (`local-model` if unset in config) | Default text model for the local provider. Prefer tool-calling-capable models. | | `LOCAL_AI_TRANSLATE_MODEL` | _(empty — falls back to `LOCAL_AI_MODEL`)_ | Optional faster non-thinking model for AI field translation (`POST /ai/translate-fields`), so the blocking request does not hit an nginx 504. Example: `phi-4-mini-instruct`. | | `AI_REMOTE_IMPORT_HOSTS` | _(empty)_ | Comma-separated host allowlist for remote JSON import tools. Empty means no remote hosts allowed. | | `AI_WEBHOOK_TOKEN` | _(empty)_ | HMAC-SHA256 key for collection import webhook (`X-AI-Webhook-Signature` over the **raw body** only; required outside `local` from 1.2.2). | | `AI_DAILY_PROMPT_LIMIT` | `0` | Max prompts per day; **`0` disables the limit**. | | `AI_EMBEDDINGS_ENABLED` | `false` | When `true`, `SearchSimilarCollectionItems` uses stored vectors (cosine); queues embed jobs on item save; falls back to semantic-lite without vectors / on provider failure. | | `AI_MCP_ENABLED` | `false` | Toggles MCP-related features. | ```env AI_DEFAULT_PROVIDER=local LOCAL_AI_URL=http://127.0.0.1:1234/v1 LOCAL_AI_API_KEY= LOCAL_AI_MODEL=openai/gpt-oss-20b # LOCAL_AI_TRANSLATE_MODEL=phi-4-mini-instruct AI_REMOTE_IMPORT_HOSTS= AI_WEBHOOK_TOKEN= AI_DAILY_PROMPT_LIMIT=0 AI_EMBEDDINGS_ENABLED=false AI_MCP_ENABLED=false ``` ### Meilisearch (optional site search) See [Meilisearch sync](/docs/meilisearch). | Variable | Default | Notes | | -------------------------------- | ----------- | -------------------------------------------------------------------- | | `MEILISEARCH_ENABLED` | `false` | Master switch | | `MEILISEARCH_HOST` | _(empty)_ | e.g. `http://127.0.0.1:7700` or `http://meilisearch:7700` in Compose | | `MEILISEARCH_API_KEY` | _(empty)_ | Master/admin key for indexing | | `MEILISEARCH_INDEX` | `externa` | Index uid; created with `primaryKey=id` if missing | | `MEILISEARCH_INDEXABLE_STATUSES` | `published` | Comma-separated; empty = ignore `status` field | ```env MEILISEARCH_ENABLED=false MEILISEARCH_HOST= MEILISEARCH_API_KEY= MEILISEARCH_INDEX=externa MEILISEARCH_INDEXABLE_STATUSES=published ``` ### Git export (optional content-as-code) See [Git export sync](/docs/git-export). Prefer [webhooks](/docs/webhooks) for static rebuilds. | Variable | Default | Notes | | ----------------------------- | --------- | ------------------------------------------------- | | `GIT_EXPORT_ENABLED` | `false` | Master switch | | `GIT_EXPORT_REMOTE_URL` | _(empty)_ | SSH remote, e.g. `git@github.com:org/content.git` | | `GIT_EXPORT_BRANCH` | `main` | Push target branch | | `GIT_EXPORT_SSH_KEY_PATH` | _(empty)_ | Deploy private key on disk | | `GIT_EXPORT_FORMAT` | `json` | `json` or `markdown` | | `GIT_EXPORT_DRY_RUN` | `false` | Commit locally, skip push | | `GIT_EXPORT_COLLECTION_SLUGS` | `*` | Comma-separated slugs or `*` | ```env GIT_EXPORT_ENABLED=false GIT_EXPORT_REMOTE_URL= GIT_EXPORT_BRANCH=main GIT_EXPORT_SSH_KEY_PATH= GIT_EXPORT_FORMAT=json GIT_EXPORT_DRY_RUN=false GIT_EXPORT_COLLECTION_SLUGS=* ``` ### Other AI providers (optional) `config/ai.php` also documents keys for cloud providers when you switch `AI_DEFAULT_PROVIDER` (or call a named provider explicitly). These are **not** all listed in `.env.example`, but are read when present: | Variable | Used by | | ---------------------------------------------------------------------------------------------------------------------------------- | --------------------------------- | | `ANTHROPIC_API_KEY`, `ANTHROPIC_URL` | Anthropic | | `AZURE_OPENAI_API_KEY`, `AZURE_OPENAI_URL`, `AZURE_OPENAI_API_VERSION`, `AZURE_OPENAI_DEPLOYMENT`, embedding/image deployment vars | Azure OpenAI | | `AWS_BEDROCK_REGION`, `AWS_BEARER_TOKEN_BEDROCK`, `AWS_SESSION_TOKEN`, `AWS_USE_DEFAULT_CREDENTIALS` | Bedrock (plus standard AWS keys) | | `COHERE_API_KEY` | Cohere | | `DEEPSEEK_API_KEY` | DeepSeek | | `ELEVENLABS_API_KEY` | ElevenLabs | | `GEMINI_API_KEY`, `GEMINI_URL` | Gemini | | `GROQ_API_KEY` | Groq | | `JINA_API_KEY` | Jina | | `MISTRAL_API_KEY` | Mistral | | `OLLAMA_API_KEY`, `OLLAMA_URL` | Ollama (`http://localhost:11434`) | | `OPENAI_API_KEY`, `OPENAI_URL` | OpenAI | | `OPENROUTER_API_KEY` | OpenRouter | | `VOYAGEAI_API_KEY` | Voyage | | `XAI_API_KEY` | xAI | The **local** provider uses the `openai` driver with a custom `url` — laravel/ai accepts that pattern for OpenAI-compatible gateways. > Never commit real `AI_WEBHOOK_TOKEN`, cloud API keys, or production `LOCAL_AI_API_KEY` values. Rotate the HMAC secret if it leaks; remote import without a tight `AI_REMOTE_IMPORT_HOSTS` allowlist is unsafe. ## Seeding (initial super admin) Read by `config/super_admin.php` and used only when `CreateSuperAdminSeeder` runs (via `php artisan db:seed`). | Variable | Default | Purpose | | -------------------------------- | ------------------------ | -------------------------------- | | `INITIAL_SUPER_ADMIN_FIRST_NAME` | `Super` | First name. | | `INITIAL_SUPER_ADMIN_LAST_NAME` | `Admin` | Last name. | | `INITIAL_SUPER_ADMIN_EMAIL` | `superadmin@example.com` | Login email. | | `INITIAL_SUPER_ADMIN_PASSWORD` | `password` | Login password (**local only**). | ```env INITIAL_SUPER_ADMIN_FIRST_NAME=Super INITIAL_SUPER_ADMIN_LAST_NAME=Admin INITIAL_SUPER_ADMIN_EMAIL=superadmin@example.com INITIAL_SUPER_ADMIN_PASSWORD=password ``` These keys may be omitted from `.env.example` because config defaults already match local development; set them explicitly before seeding shared environments. Built-in roles created by seeders (not env vars): `super-admin`, `admin`, `reader`, `public`. ## Public CMS API | Variable | Default | Purpose | | ------------------------------- | --------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `API_KEY_RATE_LIMIT` | `60` | Default requests/minute per API key when the key has no explicit limit (`config/api.php`). | | `CORS_ALLOWED_ORIGINS` | `*` | Comma-separated origins for `api/*` when project `public_api_allowed_origins` is empty (`config/cors.php`). Prefer the project allowlist in production; see [Origin allowlist](/docs/public-cms-api-types#origin-allowlist). | | `LIGHTHOUSE_QUERY_CACHE_ENABLE` | `true` | Cache parsed GraphQL query strings (`config/lighthouse.php`). | | `LIGHTHOUSE_QUERY_CACHE_MODE` | `opcache` | Prefer `opcache` (default) over `store` when `CACHE_STORE=database` — avoids serializing AST into the DB cache. | ```env API_KEY_RATE_LIMIT=60 CORS_ALLOWED_ORIGINS=* ``` Setup and auth model: [Public CMS API](/docs/public-cms-api). ## Activity log From `config/activitylog.php` (Spatie Laravel Activitylog): | Variable | Default | Purpose | | ---------------------------- | ------- | ----------------------------------------------------------------------------------------------------------- | | `ACTIVITYLOG_ENABLED` | `true` | When false, activities are not persisted. | | `ACTIVITYLOG_BUFFER_ENABLED` | `false` | Buffer activities in memory and flush after the response — useful only for high-volume logging per request. | ```env ACTIVITYLOG_ENABLED=true ACTIVITYLOG_BUFFER_ENABLED=false ``` Other activity log options (retention days, model class, soft-deleted subjects) are config-file defaults rather than `.env` keys — see `config/activitylog.php` (`clean_after_days` defaults to `365`). ## Optional integrations (1.2.0+) All of these are **off** by default. When disabled, or when the host/remote is empty, the queue jobs do nothing. ### OIDC / SSO OIDC is configured in **Project settings** (DB), not in `.env`. The only env keys PHP reads are the Docker QA helpers below (`config/services.php`): | Variable | Default | Notes | | --------------------- | ------- | ---------------------------------------------------------------------------------------------------------------- | | `OIDC_DISCOVERY_BASE` | — | Container-internal URL used to fetch discovery when the browser issuer is `localhost` (e.g. Keycloak in Compose) | | `OIDC_SERVER_ORIGIN` | — | Rewrites the server-side token/userinfo endpoints to this origin | Redirect URI to register at the IdP: `{APP_URL}/auth/oidc/callback`. See [SSO / OIDC](/docs/sso-oidc). ### Meilisearch | Variable | Default | Notes | | -------------------------------- | ----------- | --------------------------------------------------------------------------- | | `MEILISEARCH_ENABLED` | `false` | Turns on the sync jobs | | `MEILISEARCH_HOST` | — | e.g. `http://meilisearch:7700` (Compose `--profile meilisearch`) | | `MEILISEARCH_API_KEY` | — | Master or admin key (server-side only) | | `MEILISEARCH_INDEX` | `externa` | Index uid; created with `primaryKey=id` if missing | | `MEILISEARCH_INDEXABLE_STATUSES` | `published` | Comma list. Applies only when the item has a `status` field; empty = ignore | See [Meilisearch sync](/docs/meilisearch). ### Git export | Variable | Default | Notes | | ----------------------------- | -------------------------- | ----------------------------------------------------- | | `GIT_EXPORT_ENABLED` | `false` | Export on publish | | `GIT_EXPORT_REMOTE_URL` | — | SSH remote, e.g. `git@github.com:org/content.git` | | `GIT_EXPORT_BRANCH` | `main` | | | `GIT_EXPORT_SSH_KEY_PATH` | — | Deploy-key path on disk. Never commit the private key | | `GIT_EXPORT_FORMAT` | `json` | `json` or `markdown` | | `GIT_EXPORT_DRY_RUN` | `false` | Commit locally, skip push | | `GIT_EXPORT_COLLECTION_SLUGS` | `*` | Comma list of slugs, or `*` | | `GIT_EXPORT_WORK_DIR` | `storage/app/git-export` | Local clone | | `GIT_EXPORT_COMMIT_NAME` | `Externa Git Export` | Commit author name | | `GIT_EXPORT_COMMIT_EMAIL` | `git-export@externa.local` | Commit author email | See [Git export sync](/docs/git-export). ## Quick reference: externa-specific block Copy-paste block of the Externa-focused keys as they appear conceptually for a local AI-ready install: ```env # Files FILES_DUPLICATE_SYNC_MAX_BYTES=52428800 FILES_ZIP_TTL_MINUTES=60 FILES_ZIP_MAX_BYTES=104857600 # Outbound webhooks / Slack SSRF (1.3.0+) WEBHOOKS_ALLOW_PRIVATE_HOSTS=false # FILE_PUBLIC_URL_BASE= # AI (LM Studio / OpenAI-compatible local gateway) AI_DEFAULT_PROVIDER=local LOCAL_AI_URL=http://127.0.0.1:1234/v1 LOCAL_AI_API_KEY= LOCAL_AI_MODEL=openai/gpt-oss-20b # LOCAL_AI_TRANSLATE_MODEL=phi-4-mini-instruct AI_REMOTE_IMPORT_HOSTS= AI_WEBHOOK_TOKEN= AI_DAILY_PROMPT_LIMIT=0 AI_EMBEDDINGS_ENABLED=false AI_MCP_ENABLED=false # Seeding (optional overrides — config defaults apply if omitted) INITIAL_SUPER_ADMIN_EMAIL=superadmin@example.com INITIAL_SUPER_ADMIN_PASSWORD=password # Activity log ACTIVITYLOG_ENABLED=true ACTIVITYLOG_BUFFER_ENABLED=false # Public CMS API API_KEY_RATE_LIMIT=60 CORS_ALLOWED_ORIGINS=* ``` ## Related docs - [Quick Start](/docs/quick-start) — Herd-oriented `.env` and minimal commands - [Installation](/docs/installation) — when `.env` is created and seeded; Docker Compose local - [Deployment](/docs/deployment) — Docker production, health probes, `TRUSTED_PROXIES` - [Public CMS API](/docs/public-cms-api) — `/api/v1`, keys, collection access - [Files & storage](/docs/files-configuration) — runtime file behavior - [AI configuration](/docs/ai-configuration) — providers and tool safety - [Application config](/docs/configuration) — non-env config surfaces --- # Using Externa with AI coding tools Source: https://docs.externa.qiick.io/docs/ai-coding-tools Give coding agents current Externa docs instead of stale training data. Use [Context7](https://context7.com) for MCP-backed lookup, paste or fetch [`llms.txt`](https://docs.externa.qiick.io/llms.txt) / [`llms-full.txt`](https://docs.externa.qiick.io/llms-full.txt), or ask on this site with **Ask the docs**. > **API key** > Context7 recommends a free API key from [context7.com/dashboard](https://context7.com/dashboard) for higher rate limits. Replace `YOUR_API_KEY` in the snippets below. Config below matches Context7’s [MCP Clients](https://context7.com/docs/resources/all-clients) docs. ## Context7 [Context7](https://context7.com) is a service that serves up-to-date library documentation to AI coding assistants through an MCP server. Your agent calls Context7 tools instead of guessing from training data. Externa is indexed under two library IDs: | Library ID | Source | When to use | | ---------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- | | [`/qiick-io/externa-core`](https://context7.com/qiick-io/externa-core) | externa-core package source and README | Implementing or extending the Laravel app — routes, install, admin features, package internals | | [`/llmstxt/externa_qiick_io_llms_txt`](https://context7.com/llmstxt/externa_qiick_io_llms_txt) | This docs site, indexed from [`https://docs.externa.qiick.io/llms.txt`](https://docs.externa.qiick.io/llms.txt) | Operator and integration guides — Public CMS API, deployment, field types, headless starters | Prefer `/qiick-io/externa-core` for app/source questions. Prefer `/llmstxt/externa_qiick_io_llms_txt` for published docs pages and how-tos. Either works for many tasks; pin an ID when you already know which corpus you want. ### Quick setup Fastest path (Node.js 22.13+): ```bash npx ctx7 setup ``` Use `--cursor`, `--claude`, or see [Context7 install](https://context7.com/install) for other clients. Manual MCP configs follow. ### Cursor Go to **Cursor Settings → Tools & MCP → New MCP Server**, or edit `~/.cursor/mcp.json` (or `.cursor/mcp.json` in a project). **Remote server** (recommended): ```json { "mcpServers": { "context7": { "url": "https://mcp.context7.com/mcp", "headers": { "Authorization": "Bearer YOUR_API_KEY" } } } } ``` **Local server:** ```json { "mcpServers": { "context7": { "command": "npx", "args": ["-y", "@upstash/context7-mcp", "--api-key", "YOUR_API_KEY"] } } } ``` ### Claude Code **Local server:** ```bash claude mcp add --scope user context7 -- npx -y @upstash/context7-mcp --api-key YOUR_API_KEY ``` **Remote server:** ```bash claude mcp add --scope user --header "Authorization: Bearer YOUR_API_KEY" --transport http context7 https://mcp.context7.com/mcp ``` Drop `--scope user` to install for the current project only. Or run `npx ctx7 setup --claude`. ### VS Code Add this to `.vscode/mcp.json` (see [VS Code MCP docs](https://code.visualstudio.com/docs/copilot/chat/mcp-servers)). **Remote server:** ```json { "servers": { "context7": { "type": "http", "url": "https://mcp.context7.com/mcp", "headers": { "Authorization": "Bearer YOUR_API_KEY" } } } } ``` **Local server:** ```json { "servers": { "context7": { "type": "stdio", "command": "npx", "args": ["-y", "@upstash/context7-mcp", "--api-key", "YOUR_API_KEY"] } } } ``` Context7 also ships a [VS Code extension](https://context7.com/docs/resources/all-clients) that registers the server and accepts an API key via `context7.apiKey`. ### Sample prompts Add `use context7` so the agent fetches docs: ```txt Create an Externa collection for blog posts and fetch it from Next.js. use context7 ``` Pin a library ID to skip resolution: ```txt Create an Externa collection for blog posts and fetch it from Next.js. use library /qiick-io/externa-core ``` Docs-site corpus: ```txt How do I grant the public role Read on a collection for the Public CMS API? use library /llmstxt/externa_qiick_io_llms_txt ``` ## llms.txt and llms-full.txt This site publishes LLM-oriented Markdown at build time (`npm run build:llms`): | File | URL | Role | | ----------------- | ------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------- | | **llms.txt** | [https://docs.externa.qiick.io/llms.txt](https://docs.externa.qiick.io/llms.txt) | Index of every public docs page with title, short description, and link to a per-page `.md` mirror | | **llms-full.txt** | [https://docs.externa.qiick.io/llms-full.txt](https://docs.externa.qiick.io/llms-full.txt) | Full concatenated docs body — paste into an LLM or feed tools that ingest a single corpus | Individual page mirrors live at `https://docs.externa.qiick.io/docs/.md` (home: [`/index.md`](https://docs.externa.qiick.io/index.md)). Context7’s `/llmstxt/externa_qiick_io_llms_txt` library is built from the `llms.txt` index. > **Tip** > Prefer `llms.txt` when the tool can follow links. Use `llms-full.txt` when you need one pasteable dump or a tool that only reads a single file. ## Ask the docs (on this site) The header includes an **Ask the docs** control next to search (shown on browsers that support on-device generation). It answers from this documentation in the browser — no server-side chat backend. Use it for quick questions while reading; use Context7 or `llms.txt` when your coding agent needs Externa context in the IDE. --- # Architecture Source: https://docs.externa.qiick.io/docs/architecture externa-core is a **Laravel + Inertia React** single-page application: Blade only boots the shell, all product UI lives in React pages, and domain logic sits in controllers, form requests, services, and queued jobs. > **Scope** > This page describes the application architecture inside `externa-core`. For a directory map, see [Project layout](/docs/project-layout). For HTTP surfaces, see [Routing overview](/docs/routing). ## Stack | Layer | Choice | | ------------- | --------------------------------------------------------------- | | PHP | 8.3+ (development often on 8.4) | | Framework | Laravel 13 | | Auth | Laravel Fortify (session `web` guard) + optional OIDC/SSO | | SPA bridge | Inertia Laravel + `@inertiajs/react` | | UI | React 19, Vite, TypeScript | | Route helpers | Laravel Wayfinder (generated `resources/js/routes` / `actions`) | | Permissions | Spatie Permission + `EffectivePermissionResolver` | | AI | `laravel/ai` agent (`AppAssistant`) | Web middleware stack (see `bootstrap/app.php`) appends `HandleAppearance`, `HandleInertiaRequests`, and `AddLinkHeadersForPreloadedAssets` after the default `web` group. Frontend entry is `resources/js/app.tsx` (`createInertiaApp`, pages resolved as `./pages/${name}.tsx`). The root Blade view is `resources/views/app.blade.php`. ## Domain modules There is no separate `domains/` package. Product areas are conventional folders under `app/`, `routes/`, and `resources/js/`, composed from `routes/web.php` via sibling route files. ### Admin | Layer | Location | | ----------- | -------------------------------------------------------------------------------- | | Routes | `routes/admin.php` | | Controllers | `app/Http/Controllers/Admin/*` | | Pages | `resources/js/pages/admin/{users,groups,roles,permissions,activity-logs,files}/` | Covers users, groups, roles, permissions, activity logs, and the **file manager** (mounted under `/files/*` with `can.manage.files` middleware). Controllers include `UserController`, `UserGroupController`, `RoleController`, `PermissionController`, `ActivityLogController`, and `FileController`. ### Collections | Layer | Location | | ----------- | ------------------------------------------------------------------------ | | Routes | `routes/collections.php` | | Controllers | `app/Http/Controllers/Collections/*` | | Services | `app/Services/Collections/*` | | Models | `Collection`, `CollectionField`, `CollectionItem`, `CollectionItemValue` | | Pages | `resources/js/pages/collections/{collections,items}/` | Dynamic content schema (EAV). See [Collections data model](/docs/collections-data-model) and [Field types](/docs/field-types). > Collections HTTP routes use `auth` + `verified` plus Spatie `permission:can-*-collections` on each action (bulk authorize in FormRequest). Item ACL / `item_filter` also run through `CollectionPermissionEnforcer`. Details: [Effective permissions](/docs/permissions) · [Collections API](/docs/collections-api). ### Files The file manager is part of the admin route file, not a separate `routes/files.php`. Domain logic lives in `FileService`, `FileTransformService`, models `File` / `FileVersion` / `FileUpload`, and jobs for zip/duplicate work. See [File tree model](/docs/file-tree-model). ### AI | Layer | Location | | ------------- | -------------------------------------------------- | | Routes | `routes/ai.php` | | Controllers | `app/Http/Controllers/Ai/*` | | Agent / tools | `app/Ai/Agents/AppAssistant.php`, `app/Ai/Tools/*` | | Pages | `resources/js/pages/ai/` | Chat, conversations, attachments, imports, and sync sources. Routes require `permission:can-use-ai` (plus tighter checks inside tools). See [AI assistant model](/docs/ai-assistant). ### Team chat | Layer | Location | | ----------- | ------------------------------------------------------------ | | Routes | `routes/chat.php` (+ item aliases in `collections.php`) | | Controllers | `app/Http/Controllers/Chat/*`, `ItemChatController` | | Services | `app/Services/Chat/*` | | Pages / UI | `resources/js/pages/chat/`, `components/chat/*`, item drawer | Unified item + DM threads, Echo presence, attachments. Permission `can-show-chat` for the hub. See [Chat (hub & items)](/docs/chat). ### Settings | Layer | Location | | ----------- | -------------------------------------------------------------------------------------------------------------------------- | | Routes | `routes/settings.php` | | Controllers | `ProfileController`, `SecurityController`, `LocaleController`, `ProjectSettingsController`, `AppearanceSettingsController` | | Pages | `resources/js/pages/settings/{profile,security,project,appearance}.tsx` | Personal profile/security/locale need session auth only. **Project** and **project appearance (branding)** require `can-manage-project-settings`. Personal light/dark theme still uses the `appearance` cookie via `HandleAppearance` / `useAppearance` (user menu), separate from project branding. See [Account settings](/docs/account-settings) and [Project settings](/docs/project-settings). ### Shell - Dashboard: `DashboardController` → `dashboard` - Notifications: `NotificationController` - Auth pages: Fortify → Inertia views under `resources/js/pages/auth/*` ## Controllers → services → jobs The codebase favors a thin HTTP layer and fat domain services: 1. **Controller** — authorize (where middleware/FormRequest does), call a service, return Inertia or a JSON/resource response. 2. **Form request** — validation and, for admin resources, permission checks via `AuthorizesWithPermission`. 3. **Service** — business rules and persistence (`app/Services/…`). 4. **Job** — work that must not block the request (`app/Jobs/…`, `ShouldQueue`). ### Actions PHP **Actions** exist mainly for Fortify contracts under `app/Actions/Fortify/`: - `CreateNewUser` - `ResetUserPassword` Wired in `FortifyServiceProvider`. Client-side `resources/js/actions/` are **Wayfinder-generated** helpers from Laravel routes, not domain Action classes. ### Jobs | Job | Role | | --------------------- | --------------------------------------------------------------- | | `ImportCollectionJob` | CSV / Excel / remote JSON import; cache status; used by AI sync | | `DuplicateFilesJob` | Bulk / large file copy + user notification | | `PrepareFilesZipJob` | Build a download zip + notify when ready | Default queue connection is `config/queue.php` → `env('QUEUE_CONNECTION', 'database')`. Local `composer` `dev` typically runs `queue:listen` alongside the HTTP server and Vite. ### Services (examples) | Service | Role | | ------------------------------------------- | ------------------------------------------------------------- | | `Authorization\EffectivePermissionResolver` | Effective roles/permissions for Inertia and middleware | | `FileService` | File tree CRUD, uploads, attach/detach, tags, favorites, zips | | `FileTransformService` | Image thumbnails | | `Collections\CollectionItemValuesWriter` | Persist EAV value rows | | `Collections\CollectionItemValuesAssembler` | Rebuild item `data` from value rows | | `Collections\CollectionItemDataNormalizer` | Coerce payloads by field type | | `Collections\CollectionItemDataRuleBuilder` | Build Laravel validation rules for item data | AI capabilities are tool classes under `app/Ai/Tools`, not Services. ## Queue and schedule There is no classic `app/Console/Kernel.php`. The schedule lives in `routes/console.php` (Laravel 11+ style). | Cadence | Command | Purpose | | --------------- | ------------------------ | ------------------------------------------- | | `daily()` | `activitylog:clean` | Spatie activity log retention | | `hourly()` | `files:cleanup-uploads` | Stale chunked upload sessions | | `hourly()` | `files:cleanup-zips` | Expired prepared zip archives | | `daily()` | `ai:cleanup-attachments` | Expired AI chat attachments | | `everyMinute()` | `ai:run-sync-sources` | Due AI sync sources → `ImportCollectionJob` | Unscheduled but important: `permissions:sync` (upsert DB permissions from `PermissionEnum` and regenerate the frontend enum). > **Operations** > Worker and scheduler runbooks live under [Queues & scheduler](/docs/operations). ## Shared Inertia props `App\Http\Middleware\HandleInertiaRequests::share` exposes props to every page. Frontend types live in `resources/js/types/global.d.ts` / `auth.ts`. | Key | Contents | | ---------------------- | ----------------------------------------------------------------------- | | `errors` | Validation / Inertia error bag (from parent share) | | `name` | `config('app.name')` | | `auth.user` | Authenticated user or `null` | | `auth.permissions` | Effective permission name list (`string[]`) | | `auth.roleNames` | Effective role names (`string[]`) | | `auth.isSuperAdmin` | Whether the user holds `super-admin` (direct or via group) | | `sidebarOpen` | From cookie `sidebar_state` (defaults open when missing) | | `collectionLocales` | Project settings content locales (seeded from `config/collections.php`) | | `collectionLocaleMeta` | Catalog metadata for enabled content locales | | `defaultContentLocale` | Default content locale from project settings | | `notifications` | `{ unread_count: number }` | `HandleAppearance` applies the personal theme cookie for Blade; it is not an Inertia shared prop. Project branding is loaded separately for the SPA — see [Project settings](/docs/project-settings). ```tsx // Typical page usage const { auth, collectionLocales } = usePage().props ``` Permission checks in React go through `useCan()` reading `auth.permissions` / `auth.isSuperAdmin` — see [Effective permissions](/docs/permissions). ## Request flow (mental model) ```text Browser (React / Inertia) │ ▼ routes/{admin,collections,ai,settings}.php │ ▼ Middleware (auth, verified, permission:, can.manage.files, …) │ ▼ Controller + FormRequest │ ├──► Service (sync work) │ └──► Job (async work) │ ▼ Queue worker ``` ## Related pages - [Project layout](/docs/project-layout) — where files live - [Effective permissions](/docs/permissions) — authz model - [Collections data model](/docs/collections-data-model) — EAV schema - [File tree model](/docs/file-tree-model) — files domain - [AI assistant model](/docs/ai-assistant) — agent and tools --- # Effective permissions Source: https://docs.externa.qiick.io/docs/permissions Externa authorizes with **session auth (Fortify)**, **Spatie roles/permissions**, and a custom **effective permission** layer that unions direct roles with roles inherited from user groups. Super-admins bypass ability checks via `Gate::before`. > **Two permission layers** > | Layer | What it gates | Storage | > | ------------------------------------------------- | ------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------- | > | **Spatie / `PermissionEnum`** | Admin UI capability (who can open collections, files, roles, AI, …) | Spatie `roles` / `permissions` + `EffectivePermissionResolver` | > | **`collection_permissions` / `file_permissions`** | Headless `/api/v1` (and field ACL / `item_filter` / file visibility on that surface) | Role × collection × action (+ optional `rules`), and role × file action (`create` / `read` / `read_private` / `update` / `delete`) | > > Spatie does **not** replace Collection access. A user with `can-show-collections` still needs the role’s **Collection access** matrix (and optional field/`item_filter` rules) for Public CMS API and for enforcer-aware surfaces. **`read_private`** unlocks effective-private files on `/api/v1/files*` — see [Public CMS API](/docs/public-cms-api#public-vs-private-files) and [Roles & permissions](/docs/roles-and-permissions#files-access-public-cms-api). ## Session authentication (Fortify) | Item | Detail | | ----------- | -------------------------------------------------------------------------------------------------------------------------------- | | Package | `laravel/fortify` | | Guard | `web` (session) → `App\Models\User` | | Provider | `App\Providers\FortifyServiceProvider` | | Username | `email` (`lowercase_usernames`) | | Home | `/dashboard` | | Features | Registration, password reset, email verification, two-factor auth (confirm + confirm password), **passkeys** (`confirmPassword`) | | Actions | `CreateNewUser`, `ResetUserPassword` | | Views | Inertia pages under `resources/js/pages/auth/*` | | Rate limits | `login`, `two-factor`, `passkeys` | User model traits include Spatie `HasRoles`, Fortify `TwoFactorAuthenticatable` + `PasskeyAuthenticatable` (`PasskeyUser`), and soft deletes. Passkeys guide: [Passkeys](/docs/passkeys). ## Spatie Permission - Package: `spatie/laravel-permission` - Config: `config/permission.php` - Canonical permission **names** live in `App\Enums\PermissionEnum` - Sync command: `php artisan permissions:sync` (`SyncPermissionsCommand`) - Upserts DB permissions from the enum - Regenerates `resources/js/enums/permission-enum.ts` - Seeders: `PermissionSeeder` runs sync; `RoleSeeder` creates built-in roles and `syncPermissions` Admin FormRequests typically call `AuthorizesWithPermission::authorizePermission()`, which delegates to `EffectivePermissionResolver::hasPermission()`. ## PermissionEnum All values (kebab-case strings): | Area | Permissions | | ------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Dashboard | `can-show-dashboard` | | Users | `can-show-users`, `can-create-users`, `can-edit-users`, `can-delete-users`, `can-restore-users`, `can-force-delete-users` | | Groups | `can-show-groups`, `can-create-groups`, `can-edit-groups`, `can-delete-groups`, `can-restore-groups`, `can-force-delete-groups` | | Roles | `can-show-roles`, `can-create-roles`, `can-edit-roles`, `can-delete-roles` | | Permissions | `can-show-permissions`, `can-create-permissions`, `can-edit-permissions`, `can-delete-permissions` | | Files | `can-show-files`, `can-create-files`, `can-edit-files`, `can-delete-files`, `can-restore-files`, `can-force-delete-files`, `can-download-files`, `can-favorite-files`, `can-copy-files`, `can-replace-files`, `can-tag-files`, `can-update-file-metadata` | | Collections | `can-show-collections`, `can-create-collections`, `can-edit-collections`, `can-delete-collections`, `can-restore-collections`, `can-force-delete-collections` | | Activity | `can-show-activity-logs` | | Chat hub | `can-show-chat` (item drawer still uses collection item read ACL) | | Jobs | `can-show-jobs`, `can-manage-jobs` | | AI | `can-use-ai` | | Project | `can-manage-project-settings` | | API keys (admin UI) | `can-show-api-keys`, `can-manage-api-keys` | Source: `app/Enums/PermissionEnum.php`. Helper: `PermissionEnum::values()`. > **Public CMS API is separate** > Spatie permissions above gate the **admin UI**. Headless `/api/v1` access uses `collection_permissions` (role × collection × create/read/update/delete, optional field ACL / `item_filter`) and `file_permissions` (including `read_private`). See [Public CMS API](/docs/public-cms-api) and the callout at the top of this page. ## RoleEnum Built-in roles (`app/Enums/RoleEnum.php`), seeded in `RoleSeeder`: | Role | Value | Seeded permissions | | ----------- | ------------- | ---------------------------------------------------------------------- | | Super admin | `super-admin` | All permissions in the DB for the guard | | Admin | `admin` | All permissions (same set as super-admin at seed time) | | Reader | `reader` | Only permissions whose name starts with `can-show-` | | Public | `public` | **None** (system, not assignable) — anonymous actor for `/api/v1` only | Reader show set: every permission whose name starts with `can-show-` (dashboard, users, groups, roles, permissions, files, collections, activity logs, api keys, **jobs**, …). Reader does **not** get `can-use-ai`, `can-manage-project-settings`, or mutating file/collection permissions. The **`public`** role cannot be assigned to users/groups, renamed, or deleted. Configure its **Collection access** matrix for anonymous API traffic. > **Super-admin vs admin** > Both roles may hold the same permission rows after seeding. Runtime difference: **super-admin** also bypasses Laravel Gates via `Gate::before` and is treated as all-powerful in `EffectivePermissionResolver` / `useCan`. **Admin** must actually possess each permission through roles/groups. ## EffectivePermissionResolver Class: `App\Services\Authorization\EffectivePermissionResolver` (singleton in `AppServiceProvider`). ### API | Method | Returns | | ----------------------------------------------- | ---------------------------------------------- | | `permissionsFor(User $user)` | Effective permission name list | | `hasPermission(User $user, string $permission)` | Boolean | | `roleNamesFor(User $user)` | Effective role names | | `effectiveRoleIds(User $user)` | Effective role ids (direct ∪ via groups) | | `isSuperAdmin(User $user)` | Whether `super-admin` is among effective roles | | `forget(?User $user = null)` | Clear per-request cache | ### Resolution algorithm 1. **Role names / ids** = unique union of: - Direct: `$user->roles` - Via groups: roles attached to each of the user’s groups 2. If `super-admin` ∈ role names → permissions = full `PermissionEnum::values()` 3. Else permissions = unique union of: - Spatie `$user->getAllPermissions()` (direct + via assigned roles) - Permissions from roles attached through the user’s groups (group → role → permission) 4. Results are cached for the request keyed by user id `hasPermission` returns `true` immediately for super-admins; otherwise checks membership in `permissionsFor`. > **Groups scale roles, not a separate ACL matrix** > Attach Spatie roles to a group → members inherit **admin Spatie permissions** and any **`collection_permissions` / `file_permissions`** configured on those roles. There is no group-level collection ACL table. Admin field/`item_filter` rules (`CollectionPermissionGuard::rulesForUser`) use `effectiveRoleIds()`, the same set as Spatie effective roles. Headless `/api/v1` still authenticates as a single role (public or API key). `HomePath` (post-login redirect) also uses `EffectivePermissionResolver::hasPermission`, so group-inherited `can-show-*` permissions affect where users land after login. ## Gate::before Configured in `AppServiceProvider::configureAuthorization()`: ```php Gate::before(function (?User $user): ?bool { if ($user === null) { return null; } if (app(EffectivePermissionResolver::class)->isSuperAdmin($user)) { return true; } return null; }); ``` Super-admins therefore pass any `Gate` / `authorize()` ability check, including abilities that are not registered. Covered by `tests/Feature/Authorization/SuperAdminGateTest.php`. ## Middleware Aliases in `bootstrap/app.php`: | Alias | Class | Behavior | | ------------------ | ------------------------- | -------------------------------------------------------------------------------------------- | | `permission` | `EnsureUserHasPermission` | `permission:{name}` — 403 unless `EffectivePermissionResolver::hasPermission` | | `can.manage.files` | `EnsureCanManageFiles` | Maps route name / HTTP method / bulk `action` to a file `PermissionEnum`, then same resolver | These are **custom** aliases (not Spatie’s default `role` / `permission` middleware package aliases). ### Where they are applied **Admin** (`routes/admin.php`): individual resources use `->middleware('permission:…')` with the matching `PermissionEnum` value. All `/files/*` routes sit inside `Route::middleware('can.manage.files')`. **AI** (`routes/ai.php`): authenticated AI group uses `auth` + `verified` + `permission:can-use-ai` (some import status routes add further checks). Per-tool Spatie checks via `ChecksAiPermissions`; item tools also apply `CollectionPermissionEnforcer` (field ACL / `item_filter`). **Team chat** (`routes/chat.php`): `auth` + `verified` + `permission:can-show-chat`. Item-form aliases stay on collection item read ACL — [Chat (hub & items)](/docs/chat). **Collections** (`routes/collections.php`): `auth` + `verified` plus Spatie `permission:` per action (same pattern as admin). See [Collections API](/docs/collections-api). ## Frontend: `useCan` and `auth.permissions` Shared by `HandleInertiaRequests`: ```php 'auth' => [ 'user' => $user, 'permissions' => $user ? $permissionResolver->permissionsFor($user) : [], 'roleNames' => $user ? $permissionResolver->roleNamesFor($user) : [], 'isSuperAdmin' => $user ? $permissionResolver->isSuperAdmin($user) : false, ], ``` Hook: `resources/js/hooks/use-can.ts` ```ts const { can, auth } = useCan() if (can(PermissionEnum.CanEditCollections)) { // show edit UI } ``` Helper `userHasPermission` (`resources/js/lib/permissions.ts`): 1. No user → `false` 2. `auth.isSuperAdmin` → `true` 3. Else `auth.permissions.includes(permission)` Used by sidebar nav filtering, collections destructive actions, AI FAB visibility, admin screens, and similar UI gates. ## Enforcement matrix | Surface | Server middleware | Additional enforcement | | -------------------------- | ---------------------------------------------- | --------------------------------------------------------------------------- | | Admin CRUD | `permission:` per route | FormRequests + Gates | | Files | `can.manage.files` | Action → permission mapping | | AI chat / tools | `permission:can-use-ai` | Per-tool `requirePermission` + `CollectionPermissionEnforcer` on item tools | | Team chat hub (`/chat/*`) | `permission:can-show-chat` | Item aliases use collection item read ACL — [Chat](/docs/chat) | | Collections HTTP (session) | `auth` + `verified` + `permission:` per action | FormRequests; items also use `CollectionPermissionEnforcer` | | Public CMS API `/api/v1` | `ResolveApiAccess` | `collection_permissions` / `file_permissions` via guards + enforcer | | Settings / dashboard | `auth` (+ verified where applied) | Feature-specific | | Project / appearance | `permission:can-manage-project-settings` | [Project settings](/docs/project-settings) | | Jobs monitor | `permission:can-show-jobs` / `can-manage-jobs` | [Operations](/docs/operations#jobs-monitor-admin-ui) | ## Related pages - [Public CMS API](/docs/public-cms-api) — headless `/api/v1`, public role, API keys - [Roles & permissions](/docs/roles-and-permissions) — product feature guide - [Architecture](/docs/architecture) — shared props and middleware stack - [AI assistant model](/docs/ai-assistant) — tool-level permission gates - [Admin & files API](/docs/admin-api) — route-level permission strings --- # Collections data model Source: https://docs.externa.qiick.io/docs/collections-data-model Collections store structured content with a flexible **entity–attribute–value (EAV)** model: a collection defines fields, items are rows, and typed values live in a separate values table with locale and position. > **Related** > Field type identifiers and settings JSON are documented in [Field types](/docs/field-types). HTTP endpoints are covered in [Collections API](/docs/collections-api). ## Schema overview ```text collections └── collections_fields (schema: name, type, settings, …) └── collections_items (rows; soft-deletable) └── collections_items_values (EAV slots: locale, position, JSON value) ``` Migration: `database/migrations/2026_03_29_123848_create_collections_tables.php`. ### `collections` | Column | Notes | | ------------------------- | ----------------------------------------------------------------------- | | `id` | Primary key | | `name` | Display name | | `slug` | Unique | | `is_singleton` | Boolean, default `false` | | `form_layout` | Nullable JSON: tabs + sections grouping `field_ids` (presentation only) | | `sort_order` | Ordering among collections | | timestamps / soft deletes | Yes | Model: `App\Models\Collection`. ### `collections_fields` | Column | Notes | | --------------- | --------------------------------------------------------------------------------------------------------- | | `collection_id` | FK → collections (cascade) | | `name` | Unique per collection `(collection_id, name)` | | `type` | `FieldTypeEnum` string value | | `translatable` | Boolean, default `false`. Not allowed for `hash` or relation types — see [Field types](/docs/field-types) | | `settings` | JSON / jsonb bag (required, layout, type options, …) | | `sort_order` | Field order in forms | **No soft deletes** on fields. Model: `App\Models\CollectionField`. ### `collections_items` | Column | Notes | | ------------------------- | -------------------------- | | `collection_id` | FK → collections (cascade) | | timestamps / soft deletes | Yes | Model: `App\Models\CollectionItem`. Route binding can resolve trashed items for restore / force-delete flows. ### `collections_items_values` (EAV) | Column | Notes | | ---------- | ----------------------------------------------------------------------- | | `item_id` | FK → items (cascade) | | `field_id` | FK → fields (cascade) | | `locale` | Nullable string (max 16). Set for translatable fields; `null` otherwise | | `position` | Unsigned smallint, default `0`. Array-valued fields use `0..n-1` | | `value` | JSON / jsonb (cast to `array`/`mixed` on the model) | Unique slot: `(item_id, field_id, locale, position)` as `collections_items_values_unique_slot`. **EAV write behavior** (`CollectionItemValuesWriter`): delete all value rows for the item, then insert fresh rows; null values are skipped. ## Singleton vs multi-item | Mode | `is_singleton` | Behavior | | --------- | ----------------- | --------------------------------------------------- | | Multi | `false` (default) | Collection has many items; UI lists items | | Singleton | `true` | Exactly one content row; create seeds an empty item | Details: - Creating a singleton collection seeds one empty item (`ContentCollectionController::store`). - Show route: singleton opens the collection content editor; multi redirects to the items index. - Upsert endpoint: `PUT collections/{collection}/singleton-content`. - `is_singleton` is **immutable** after create (`UpdateContentCollectionRequest`). - Creating a second item on a singleton is rejected (HTTP tests + AI `ManageCollectionItems`). ## Soft deletes | Entity | Soft delete? | Cascade notes | | ------------------- | ------------ | ---------------------------------------------------------------------------------------------------------------------------- | | Collection | Yes | Soft-delete soft-deletes items; force-delete force-deletes items; restore restores only-trashed items (`Collection::booted`) | | CollectionItem | Yes | Restore / force-delete routes | | CollectionField | No | Hard-deleted with collection via FK | | CollectionItemValue | No | Cascades with item/field; writer replaces all rows on sync | ## Locales **Source of truth:** project settings (`content_locales`, `default_content_locale`, `fallback_content_locales`), seeded from `config/collections.php` until configured. Shared to the SPA as `collectionLocales` / `collectionLocaleMeta` / `defaultContentLocale`. Active locale resolution (`CollectionLocaleResolver`) considers query `locale` (must be enabled or **422**), `Accept-Language`, default content locale, then `app.locale`. Used by the normalizer, rule builder, and translated `display_name` / `note` helpers on fields. > **Translatable fields** > When `collections_fields.translatable` is true, item data for that field is a locale map (e.g. `{ en: "…", it: "…" }`), stored as separate value rows with `locale` set. Non-translatable fields store a single slot with `locale = null`. Disabling a content locale does not delete orphan rows. ### Locale fallback When a reader asks for one locale, `CollectionItemDataAccessor::getTranslated` (used by Public CMS `flattenForLocale`) walks `CollectionLocaleResolver::fallbackChain`: 1. Requested / resolved locale 2. Enabled entries from `fallback_content_locales` (duplicates skipped) 3. `APP_FALLBACK_LOCALE` if that locale is enabled 4. Else `null` `''` counts as missing. Editing Content languages in admin rewrites the stored chain; an empty list is rebuilt on save. Full detail and example: [Configuration — Locale fallback](/docs/configuration#locale-fallback). ## Services All under `app/Services/Collections/`: | Service | Responsibility | | ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `CollectionItemValuesWriter` | `sync($item, $collection, $normalizedData)` — wipe and rewrite EAV rows (per-locale and per-position for arrays) | | `CollectionItemValuesAssembler` | `assemble($item)` — rebuild the in-memory `data` map from value rows for forms/API | | `CollectionItemDataNormalizer` | `normalize($collection, $data, $creating)` — coerce types (strings, numbers, bools, map GeoJSON Point/MultiPoint with legacy `{lat,lng}` compat, file/relation IDs, arrays, M2A blocks, hash auto-generation); apply transforms (`trim`, `slugify`, `lowercase`, `alphabetize`, leaf combining); fill `default_value` on create | | `CollectionItemDataRuleBuilder` | Laravel rules for `data.*`; skips `hidden_in_form`; required from settings or validation operators; `assertKnownKeysOnly` | | `FieldValidationRuleEvaluator` | Maps `settings.validation_rules` operators to Laravel/custom rules and localized messages | | `CollectionItemOptionsService` | Paginated id/label options for relation pickers | | `CollectionItemQueryService` | Field-based filters for item listing (locale-aware for translatable fields) | Support helpers (not under Services): - `App\Support\Collections\CollectionItemDataAccessor` — locale fallback reads via assembler - `App\Support\Collections\CollectionLocaleResolver` — active locale - `App\Support\Collections\UniqueCollectionSlugGenerator` — unique slugs ## Data round-trip ```text HTTP / Inertia payload (data: { fieldName: … }) │ ▼ CollectionItemDataNormalizer │ ▼ CollectionItemDataRuleBuilder (validation) │ ▼ CollectionItemValuesWriter (EAV rows) │ ▼ CollectionItemValuesAssembler (read path → data map) ``` ## Related pages - [Field types](/docs/field-types) - [Collections](/docs/collections) / [Collection items](/docs/collection-items) - [Collections API](/docs/collections-api) --- # Field types Source: https://docs.externa.qiick.io/docs/field-types Each collection field has a `type` from `App\Enums\FieldTypeEnum`. The type drives validation, normalization, EAV storage shape, and which React input renders in the item form. > **Sources of truth** > Enum: `app/Enums/FieldTypeEnum.php`. Settings validation: `ValidatesCollectionFieldSettings`. UI catalog: `resources/js/lib/collection-field-types.ts`. Item inputs: `dynamic-item-fields.tsx`. > Changing a field's type after it has stored values can lead to data loss or validation errors. See [Schema changes](/docs/schema-changes) for current behaviour, safe migration paths, and planned fixes. ## Common settings Available on (almost) every field via `collections_fields.settings` JSON: | Key | Shape | Effect | | ----------------------- | ------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | | `required` | flag (`true` / `1` / `'true'` / …) | Value required unless overridden by validation operators | | `readonly` | flag | Not editable in the item form | | `hidden_in_form` | flag | Hidden in form; skipped by the rule builder | | `display_name` | locale map (enabled content locales) | Localized label override | | `note` | locale map | Localized helper text | | `default_value` | mixed | Applied on create when empty (not for hash / files / relations / blocks / m2a in UI) | | `validation_rules` | `[{ operator, value? }]` | Extra operators: `required`, `unique`, `min_length`, `max_length`, `min`, `max`, `regex`, `contains`, `not_contains`, `equals`, `not_equals` | | `validation_message` | locale map | Localized message for custom rules | | `layout_width` | `half` \| `full` \| `fill` (default `full`) | Form column width | | `layout_starts_new_row` | flag | Force new row in the field layout UI | | `placeholder` | locale map | Where the input supports it | | `conditions` | see below | Dynamic hide / readonly / required | Flags are normalized with a truthy check (boolean / `1` / `'1'` / `'true'` / `'on'`). Localized string maps are validated against the project’s **enabled content locales** (not hard-coded `en`/`it`) — see [Project settings](/docs/project-settings). ## Field packs Deterministic field bundles you can apply in one shot onto an **existing** collection from the **Fields** UI (**Add field pack…**) or via AI (`ManageCollections` `apply_field_pack`). Existing field names are **skipped**; missing ones are created through the same settings pipeline as HTTP create field. | Pack key | Label | Fields | | ------------ | ------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `seo_inline` | SEO (inline) | `seo_title` (string, translatable), `seo_description` (textarea, translatable), `seo_keywords` (string, translatable), `seo_alternate` (string, translatable), `seo_canonical` (string), `seo_robots` (select), `seo_noindex` (boolean), `seo_og_image` / `seo_facebook_image` / `seo_twitter_image` (image). Prefer the **SEO collection pack** + M2O for Articles/Pages/Products. | | `publishing` | Publishing | `status` (select draft/published/archived), `published_at` (date), `featured` (boolean) | | `contact` | Contact | `email`, `phone`, `address`, `city`, `postal_code`, `country` | | `social` | Social | `facebook_url`, `instagram_url`, `linkedin_url`, `twitter_url`, `youtube_url` | HTTP: `POST /collections/{collection}/field-packs/{pack}` (`can-edit-collections`). Source: `app/Support/Collections/FieldPacks/`. ### How to add a field pack 1. Add a `*FieldPack` class with a static `definition()` (see `SeoInlineFieldPack`). 2. Register it in `FieldPackRegistry::all()`. 3. Ship docs + a Pest apply/skip test. ## Collection packs Starter collections. Prefer these for SEO entity / Articles / Pages / Products / Categories. Apply from the collections list (**Create from pack…**) or AI (`apply_collection_pack`). Missing dependencies are **auto-created**. Existing collection slugs are **reused**; missing fields/relations are added. | Pack key | Creates | Requires | Relations | | ------------ | ------------------------------------------------------------------------------------------------------- | ------------------- | ------------------------- | | `seo` | `seo` — short names (`title`, `description`, …) not `seo_*` | — | — | | `categories` | `categories` — name (tr), slug, description (tr) | — | — | | `pages` | `pages` — title, slug, status, published_at, `body` (blocks: `section`, `rich_text`) | `seo` | M2O `seo` → seo | | `articles` | `articles` — title, slug, excerpt, `body` (blocks: `section`, `rich_text`), status, published_at, cover | `seo`, `categories` | M2O `category`, M2O `seo` | | `products` | `products` — title, slug, description, price, images, status | `seo`, `categories` | M2O `category`, M2O `seo` | HTTP: `POST /collections/packs/{pack}` (`can-create-collections`), optional body `name` / `slug`. Source: `app/Support/Collections/CollectionPacks/`. ### Pack block schema (articles, pages) Both `articles.body` and `pages.body` use `MinimalBlocksBody` (`app/Support/Collections/CollectionPacks/MinimalBlocksBody.php`) with two block types: | Block type | Fields | Notes | | ----------- | -------------------------------- | --------------------- | | `section` | `heading` (string, translatable) | Section heading | | `rich_text` | `body` (wysiwyg, translatable) | TipTap WYSIWYG editor | Example Public CMS API response (`articles.body` with `?locale=it`): ```json [ { "id": "...", "type": "section", "data": { "heading": { "en": "Intro", "it": "Introduzione" } } }, { "id": "...", "type": "rich_text", "data": { "body": { "en": "

Hello

", "it": "

Ciao

" } } } ] ``` **Note:** The `?locale=` query parameter and `include_all_translations` only apply to **top-level** translatable fields. Nested block fields always return their per-locale maps (`{ "en": "...", "it": "..." }`). Frontends should pick the locale from each block's field data using the same fallback order as the server (see [Locale fallback](/docs/configuration#locale-fallback), or [Live Preview — Locale resolution](/docs/live-preview#locale-resolution) for active-locale resolution). ### How to add a collection pack 1. Add a `*CollectionPack` class with `definition()` (`key`, `label`, `collection`, `fields`, `requires`, `relations`). 2. Register it in `CollectionPackRegistry::all()`. 3. Ship docs + Pest (deps + relations + idempotent apply). **Model note:** SEO is a **collection**; Articles/Pages/Products link via **many_to_one**. Use `seo_inline` only when you need denormalized `seo_*` fields on an existing collection. ## Field conditions Optional per-field rules evaluated against other field values on the item form (client) and for **required** / **readonly** on the server. Full operator table + examples: [Field conditions](/docs/field-conditions). ```json { "logic": "or", "rules": [ { "field": "kind", "operator": "equals", "value": "custom" }, { "field": "status", "operator": "in", "value": "draft, review" } ], "hidden": false, "readonly": false, "required": true } ``` Operators: `equals`, `not_equals`, `contains`, `empty`, `not_empty`, `gt` / `gte` / `lt` / `lte`, `in` / `not_in`. Logic is flat **AND** or **OR** (no nested groups). When rules match, any of `hidden` / `readonly` / `required` present in the object override the base settings for that request. ## Collection form layout Presentation metadata on `collections.form_layout` (JSON), **not** a field type. Fields stay leaves; layout groups them into optional tabs + collapsible sections: ```json { "version": 1, "tabs": [{ "id": "main", "label": { "en": "Main" } }], "sections": [ { "id": "details", "tab_id": "main", "label": { "en": "Details" }, "collapsible": true, "collapsed": false, "field_ids": [1, 2] } ] } ``` `layout_width` / `layout_starts_new_row` still apply inside each section. Unplaced fields render in an “Other” group. Endpoint: `PUT /collections/{collection}/form-layout`. ## Storage helpers on the enum | Method | Meaning | | -------------------------- | ----------------------------------------------------------------------------------------------------- | | `isArrayStorage()` | Value is stored as multiple EAV rows (`position` 0..n-1) or as a JSON array assembled from those rows | | `isRelationType()` | Points at another collection’s items | | `isMultipleRelationType()` | Relation that stores **many** related item IDs | | `supportsTranslatable()` | Whether the field may be marked per-locale (`collections_fields.translatable`) | > **Translatable is not universal** > `hash`, `blocks`, and all relation types (`many_to_one`, `many_to_many`, `one_to_many`, `m2a`, `relation_tree`, legacy `relation` / `relation_many`) do **not** support `translatable`. The admin UI hides the toggle; create/update (and the AI tool) force `translatable=false` for those types. For `blocks`, localization lives inside nested block fields instead. Text, media, selects, boolean/number/date/color/map/slider, and similar scalars still can. > `image` is **not** in `FieldTypeEnum::isArrayStorage()`, but `CollectionField::usesArrayStorage()` returns true when `settings.allow_multiple` is enabled. Treat image-with-multiple as array storage at runtime. ## Array storage types These return `true` from `isArrayStorage()`: `multiselect`, `checkbox_group`, `checkbox_group_tree`, `tag`, `files`, `one_to_many`, `many_to_many`, `blocks`, `m2a`, `relation_many`. ## Relation types These return `true` from `isRelationType()`: `relation`, `many_to_one`, `one_to_many`, `many_to_many`, `relation_tree`, `relation_many`. Multiple relation IDs (`isMultipleRelationType()`): `one_to_many`, `many_to_many`, `relation_many`. > **M2A exception** > `m2a` uses array storage of `{ related_collection_id, related_item_id }` blocks but is **not** classified as `isRelationType()` in the enum. ## Every field type ### Text and scalars | Type | Value | Purpose | Array? | Relation? | Notable settings | | ---------------- | ------------------ | ---------------------------------------------------------------- | ------ | --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | String | `string` | Single-line text | no | no | `input_type` (`string`/`text`/`integer`/`bigInteger`/`float`/`decimal`/`uuid`), `max_length`, `placeholder`, `icon_left`/`icon_right`, `trim`, `slugify`, `masked` | | Autocomplete | `autocomplete` | Combobox from static options | no | no | `options[{value,label}]`, `allow_other`, `placeholder` | | API autocomplete | `api_autocomplete` | Remote suggestions | no | no | `url` (may include `{{value}}`), `results_path`, `text_path`, `value_path`, `trigger` (`debounce`\|`throttle`), `rate`, `placeholder`, icons | | Number | `number` | Numeric input | no | no | `min`, `max`, `step`, `placeholder` | | Boolean | `boolean` | Toggle | no | no | `label_on`, `label_off` (translated) | | Textarea | `textarea` | Multi-line plain text | no | no | `placeholder`, `rows` (default 6), `max_length` | | WYSIWYG | `wysiwyg` | Rich HTML (TipTap editor) | no | no | `rows`, `max_length`, `placeholder`; server strips scripts/unsafe tags on save | | Markdown | `markdown` | Markdown source | no | no | same textarea-like options | | Code | `code` | Code editor | no | no | `language` (default `javascript`), `line_numbers`, `line_wrapping`, `template` | | Hash | `hash` | Auto fingerprint ID (`sha256` of UUID) — **not** a password hash | no | no | `masked`; always nullable in rules | | Slider | `slider` | Numeric slider | no | no | `min` (0), `max` (100), `step`, `default_value`, `show_value` | ### Choice | Type | Value | Purpose | Array? | Relation? | Notable settings | | ------------------- | --------------------- | --------------------- | ------- | --------- | ------------------------------------------------------------------------------- | | Select | `select` | Single choice | no | no | `options`, `allow_none`, `allow_other` (UI + validation) | | Multiselect | `multiselect` | Multi choice dropdown | **yes** | no | `options`, `allow_other` | | Checkbox group | `checkbox_group` | Multi checkboxes | **yes** | no | `options`, `allow_other` | | Checkbox group tree | `checkbox_group_tree` | Nested multi select | **yes** | no | Tree `options` (+ `children`), `value_combining` (`all`\|`leaf`) | | Radio group | `radio_group` | Single radio | no | no | `options`, `allow_other` | | Tag | `tag` | Tag list | **yes** | no | `presets`, `separator` (default `,`), `allow_other`, `lowercase`, `alphabetize` | ### Date, map, color | Type | Value | Purpose | Array? | Relation? | Notable settings / storage | | ----- | ------- | --------------------------- | ------ | --------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Date | `date` | Date / time / datetime | no | no | `date_mode` (`date`\|`time`\|`datetime`, default `datetime`), `include_seconds`. Dead `use_24h` removed (native inputs). | | Map | `map` | GeoJSON Point or MultiPoint | no | no | Stored as GeoJSON (`Point` / `MultiPoint`, coordinates `[lng, lat]`); legacy `{ lat, lng }` accepted on write and converted. Settings: `geometry_mode` (`point`\|`multipoint`, default `point`), `default_lat`, `default_lng`, `default_zoom`. Leaflet OSM picker in admin; respects `readonly`. No LineString/Polygon yet. | | Color | `color` | Color picker | no | no | `opacity`, `preset_colors` | ### Files and media | Type | Value | Purpose | Array? | Relation? | Notable settings | | ----- | ------- | ------------------------- | ------------- | --------- | ---------------------------------------------------------------------------------------- | | Image | `image` | File-manager image picker | conditional\* | no | `allow_multiple` → list of file IDs; else single id; `allowed_mime_types`, `crop_to_fit` | | File | `file` | Single file (legacy) | no | no | `allowed_mime_types` | | Files | `files` | Multiple files | **yes** | no | `allowed_mime_types` | \* See callout above for `allow_multiple`. ### Relations Shared relation settings (where applicable): - `related_collection_id` — target collection - `display_field` — field name used as label - `display_template` — optional label template - `filter` — option list filter - `allow_duplicates` — whether the same related id may appear more than once - `junction_fields` — **many_to_many only**: `[{ "name": "sort", "type": "number" }]` mini-schema for per-link `meta` (`string` \| `number` \| `boolean`) | Type | Value | Purpose | Array? | Relation? | Extra | | ------------- | --------------- | --------------------------------------------------------------------- | ------- | --------- | ---------------------------------------------------------------------------------------------------- | | Relation | `relation` | Legacy many-to-one alias (hidden from type picker) | no | **yes** | Shared relation settings | | Many to one | `many_to_one` | Link one related item | no | **yes** | Shared relation settings | | One to many | `one_to_many` | Many related IDs | **yes** | **yes** | + `layout` (`list`\|`table`) | | Many to many | `many_to_many` | Junction objects `{ related_item_id, meta }` (ints accepted on write) | **yes** | **yes** | Shared relation settings + optional `junction_fields` | | Relation tree | `relation_tree` | **Alias of M2O** — no tree UI yet (hidden from picker; TODO) | no | **yes** | Shared relation settings | | Relation many | `relation_many` | Legacy multi-relation (hidden from picker) | **yes** | **yes** | Shared relation settings | | Blocks | `blocks` | Inline page-builder blocks | **yes** | no | `block_types[]`; value `[{ id, type, data }]` | | M2A | `m2a` | Many-to-any builder | **yes** | no† | `allowed_collection_ids[]`, `allow_duplicates`; value `[{ related_collection_id, related_item_id }]` | † Not in `isRelationType()`. ## Normalized storage shapes | Shape | Types | | ----------------------------- | ----------------------------------------------------------------------------------------------------------- | | string / null | string, autocomplete, api_autocomplete, textarea, wysiwyg, markdown, code, select, radio_group, date, color | | number / null | number, slider | | bool / null | boolean | | string[] | multiselect, checkbox_group, checkbox_group_tree, tag | | GeoJSON Point / MultiPoint | map (legacy `{ lat, lng }` still accepted on write) | | int (file id) | file; image (single) | | int[] (file ids) | files; image (multiple) | | int (item id) | relation, many_to_one, relation_tree | | int[] (item ids) | one_to_many, relation_many | | `{ related_item_id, meta }[]` | many_to_many (breaking vs bare ints; ints still accepted on write) | | block[] `{ id, type, data }` | blocks | | M2A block[] | m2a | | string (hash) | hash | ## Blocks field `blocks` defines its schema inline on the field itself instead of pointing to another collection like `m2a`. ### Settings ```json { "max_blocks_depth": 3, "block_types": [ { "key": "rich_text", "label": "Rich text", "fields": [ { "name": "title", "type": "string", "translatable": true, "settings": {} }, { "name": "body", "type": "wysiwyg", "translatable": true, "settings": {} } ] }, { "key": "media", "label": "Media", "fields": [ { "name": "image", "type": "image", "settings": {} }, { "name": "caption", "type": "string", "translatable": true, "settings": {} } ] } ] } ``` `max_blocks_depth` is per field (default **3**, absolute ceiling **5**). Nested `blocks` fields inherit the root field’s max; deeper `blocks` types are stripped on save. ### Value shape ```json [ { "id": "11111111-1111-1111-1111-111111111111", "type": "rich_text", "data": { "title": { "en": "Hello", "it": "Ciao" }, "body": { "en": "

Body

" } } } ] ``` ### Notes - `wysiwyg` uses TipTap in the admin UI (toolbar: bold/italic/strike/headings/lists/blockquote/link/undo/redo). Paste from Word/HTML is accepted; `WysiwygHtmlSanitizer` strips scripts and non-allowlisted tags on normalize/save. - The `blocks` field itself is not translatable; nested fields may be. - **Nested translatable fields are not flattened by `?locale=` or `include_all_translations`** — they always return per-locale maps in API responses. Frontends must pick the locale per block themselves (see [Pack block schema](#pack-block-schema-articles-pages) above or [Locale fallback](/docs/configuration#locale-fallback) for the resolution order). - Nested file fields (`image`, `file`, `files`) expand on the public API just like top-level file fields (including inside nested blocks). - Nested relations (`m2a`, `many_to_many`, `one_to_many`) are **JSON-embedded** inside `block.data` — same link shapes as top-level (`m2a` `{ related_collection_id, related_item_id }`, m2m `{ related_item_id, meta }`). There is **no** junction table for nested paths; SQL filter/query on nested m2m is out of scope. - Nested field `settings.conditions` use the same AND / equals / empty operators as collection fields, but evaluate against **sibling keys in `block.data`**, not top-level item fields. - Builder UX: drag-and-drop, duplicate, delete, collapse/expand, text summary in the header, move up/down. No live frontend/site preview. - `blocks` is distinct from top-level `m2a`: `blocks` stores inline typed fragments; top-level `m2a` links out to items. You can also put an `m2a` field _inside_ a block type. - Golden path: an **Articles** collection with a body `blocks` field — for example, a richer custom design beyond the pack default might have `section` → nested `blocks` → leaf (`rich_text` / `media`); optional `related_modules` (`m2a`) and `related_articles` (`many_to_many`) block types; conditional nested fields (e.g. show `video_url` when `media_kind === video`). ### Nested blocks example ```json { "max_blocks_depth": 3, "block_types": [ { "key": "rich_text", "label": "Rich text", "fields": [ { "name": "title", "type": "string", "translatable": true, "settings": {} }, { "name": "body", "type": "wysiwyg", "translatable": true, "settings": {} } ] }, { "key": "section", "label": "Section", "fields": [ { "name": "heading", "type": "string", "translatable": true, "settings": {} }, { "name": "blocks", "type": "blocks", "translatable": false, "settings": { "block_types": [ { "key": "rich_text", "label": "Rich text", "fields": [ { "name": "body", "type": "wysiwyg", "translatable": true, "settings": {} } ] }, { "key": "media", "label": "Media", "fields": [{ "name": "image", "type": "image", "settings": {} }] } ] } } ] }, { "key": "related_modules", "label": "Related modules", "fields": [ { "name": "modules", "type": "m2a", "settings": { "allowed_collection_ids": [2] } } ] }, { "key": "related_articles", "label": "Related articles", "fields": [ { "name": "articles", "type": "many_to_many", "settings": { "related_collection_id": 1, "display_field": "title" } } ] } ] } ``` ### Nested conditions example Inside a block type, conditions reference sibling field names: ```json { "name": "video_url", "type": "string", "settings": { "conditions": { "logic": "and", "rules": [ { "field": "media_kind", "operator": "equals", "value": "video" } ], "required": true } } } ``` ## Related pages - [Collections data model](/docs/collections-data-model) - [Collection items](/docs/collection-items) - [AI assistant model](/docs/ai-assistant) — tools that create/edit fields and items --- # Field conditions Source: https://docs.externa.qiick.io/docs/field-conditions **Field conditions** let a collection field change visibility, readonly, or required based on **other field values** on the same item. Form UX (client) and server enforcement for `required` / `readonly` stay in sync. > **Sources of truth** > PHP: `App\Services\Collections\FieldConditionEvaluator`. TS: `resources/js/lib/field-conditions.ts`. Settings UI: field settings → Conditions. Stored under `collections_fields.settings.conditions`. ## Shape ```json { "logic": "and", "rules": [{ "field": "status", "operator": "equals", "value": "published" }], "hidden": false, "readonly": true, "required": true } ``` | Key | Meaning | | ---------- | -------------------------------------------------------------------------------- | | `logic` | `and` (default) — all rules must match; `or` — any rule matches | | `rules` | Flat list of `{ field, operator, value? }` (no nested groups) | | `hidden` | When matched: hide field. Explicit `false` = **show-when** (hide unless matched) | | `readonly` | When matched: lock field (server strips writes) | | `required` | When matched: require a value (server validation) | Existing AND / `equals` rules keep working unchanged. ## Operators | Operator | Value | Match when… | | --------------------------- | ------------------------- | ---------------------------------------------------------------------------- | | `equals` | any | Loose string equality (arrays: contains expected); booleans tolerate `0`/`1` | | `not_equals` | any | Negation of `equals` | | `contains` | string / number | String substring, or array membership | | `empty` | — | `null`, blank string, or `[]` | | `not_empty` | — | Negation of `empty` | | `gt` / `gte` / `lt` / `lte` | number or ISO date prefix | Numeric compare, or lexicographic ISO `YYYY-MM-DD…` | | `in` | comma list or array | Actual (or any array element) is in the list | | `not_in` | comma list or array | Negation of `in` | ## Examples **Show notes only when kind is custom (show-when):** ```json { "logic": "and", "rules": [{ "field": "kind", "operator": "equals", "value": "custom" }], "hidden": false } ``` **Require price when status is draft or review (OR + in):** ```json { "logic": "or", "rules": [ { "field": "status", "operator": "equals", "value": "draft" }, { "field": "status", "operator": "in", "value": "review, pending" } ], "required": true } ``` **Lock title after publish date is set (gte):** ```json { "logic": "and", "rules": [ { "field": "published_at", "operator": "gte", "value": "2020-01-01" } ], "readonly": true } ``` ## Kitchen Sink sketch On a demo collection with `kind` (select), `status` (select), `amount` (number), `notes` (string): 1. Hide `notes` unless `kind` equals `custom`. 2. Require `amount` when `status` is `in` `draft, review` **or** `amount` `gt` `0` is not enough alone — prefer one rule set per effect. 3. Readonly `status` when `published_at` is `not_empty`. ## Out of scope No Flow builder, no HTTP / external conditions, no nested rule groups. Extend via [Extending](/docs/extending) if you need that. > **Related** > [Field types](/docs/field-types) · [Collections](/docs/collections) · [Collection items](/docs/collection-items) --- # Schema changes Source: https://docs.externa.qiick.io/docs/schema-changes Renaming fields, changing field types, or toggling the translatable flag on collections with existing content requires careful handling to avoid data loss. This page documents behaviour as of externa-core **1.2.1+**, the repair command for older drafts, and the type-change risks that remain open. > Field renames are safe from externa-core **1.2.1** ([#218](https://github.com/qiick-io/externa-core/issues/218), [#223](https://github.com/qiick-io/externa-core/pull/223)). If you renamed fields on an older version, run the repair command in [Repairing older drafts](#repairing-older-drafts) right after upgrading, **before** the scheduler runs or anyone publishes. --- ## Renaming fields ### Current behaviour Field values are stored internally by **field ID**, not by name (`app/Services/Collections/CollectionItemValuesWriter.php` in externa-core). This means: - **Rename without pending drafts:** Content is preserved. The published API immediately reflects the new field name. - **Rename with pending drafts (versioned collections):** From **1.2.1**, pending drafts and older revisions keep the renamed field's value, so publishing (manual or scheduled) and restoring revisions preserve content. On **1.2.0 and earlier**, the value was dropped because drafts and revisions stored a snapshot keyed by the old field name. Fixed in [v1.2.1](https://github.com/qiick-io/externa-core/releases/tag/v1.2.1) ([#218](https://github.com/qiick-io/externa-core/issues/218), [#223](https://github.com/qiick-io/externa-core/pull/223)). ### Repairing older drafts Drafts and revisions created before 1.2.1 may still hold a pre-rename field key. Right after deploying 1.2.1, **before** the scheduler runs or anyone publishes: 1. Dry run (default, no writes): `php artisan externa:repair-renamed-field-drafts` 2. Apply: `php artisan externa:repair-renamed-field-drafts --apply` Mappings come from the CollectionField activity-log rename history. For orphaned keys with no history, pass `--map=collection:old=new` (collection slug or id; repeatable). The command never overwrites a value already stored under the new key, and publish-time behaviour is unchanged. Keys reported as **unmappable** belong to deleted fields and can be ignored. If values were already lost by publishing on an older version, restore from a database backup taken before the rename, or re-enter them via the admin UI or [Collections API](/docs/collections-api). ### API breaking change Renaming a field is a **breaking change** for API consumers. The Public CMS API, Collections API, and GraphQL all use the current field name as the key. Update your frontend or client code after renaming. --- ## Changing field types ### Current behaviour Changing a field's type **does not convert stored values**. Until [core#219](https://github.com/qiick-io/externa-core/issues/219) ships: - The admin UI **prevents** type changes once a field exists (the type dropdown is disabled). - The server endpoint and AI assistant (`ManageCollections.updateField`) can still change it, leaving values unconverted and potentially breaking validation or rendering. **Do not change field types** if the field has content, unless you are prepared to manually convert all values. ### Safe path 1. **Create a new field** with the desired type. 2. **Migrate content** from the old field to the new field: - Export the collection, transform the values, and re-import; or - Use the [Collections API](/docs/collections-api) or [Admin API](/docs/admin-api) to update each item programmatically. 3. **Delete the old field** after verifying the migration. ### Planned fix [qiick-io/externa-core#219](https://github.com/qiick-io/externa-core/issues/219) will reject type changes on fields with stored values (admin + AI tool), returning a validation error. Type conversion tooling is tracked separately in [core#220](https://github.com/qiick-io/externa-core/issues/220). --- ## Toggling the translatable flag ### Current behaviour Toggling `translatable` on a field with existing content **does not convert stored values**. Values keep their original shape: - **Non-translatable → translatable:** Values remain scalars (e.g., `"Hello"`). The API will not return a locale map until you re-save the item. - **Translatable → non-translatable:** Values remain locale maps (e.g., `{ "en": "Hello", "it": "Ciao" }`). The API and admin UI may break or show the raw map. This is the same root cause as type changes: no automatic conversion. ### Safe path Follow the same migration strategy as [type changes](#safe-path) above: 1. Create a new field with the desired `translatable` setting. 2. Migrate and transform values (flatten or expand locale maps as needed). 3. Delete the old field. ### Planned fix [qiick-io/externa-core#219](https://github.com/qiick-io/externa-core/issues/219) will reject `translatable` changes on fields with stored values. --- ## Rename/convert tooling [qiick-io/externa-core#220](https://github.com/qiick-io/externa-core/issues/220) tracks **safe type conversion with dry run** for fields with existing content (e.g., `text` → `textarea`, `string` → `number`, translatable toggle with automatic map creation/flattening). This is planned for externa-core **1.4.0**. Until then, follow the manual migration paths above. --- ## Related pages - [Collections](/docs/collections) — Collection and field CRUD - [Field types](/docs/field-types) — Field type reference and validation - [Content versioning](/docs/content-versioning) — Drafts, approvals, and revisions - [Collections data model](/docs/collections-data-model) — EAV storage and field IDs - [Collections API](/docs/collections-api) — Programmatic item updates for migration - [Upgrade](/docs/upgrade) — Version-specific upgrade notes --- # File tree model Source: https://docs.externa.qiick.io/docs/file-tree-model The file manager stores a hierarchical tree of **file nodes** on the `assets` disk, with content-addressed **versions**, optional **chunked uploads**, and async work for large duplicates and multi-file zips. > **Related** > Product UI: [File manager](/docs/file-manager). Config keys: [Files & storage](/docs/files-configuration). HTTP: [Admin & files API](/docs/admin-api). ## File node types Enum: `App\Enums\FileTypeEnum` (`app/Enums/FileTypeEnum.php`). | Case | Value | Notes | | ---------- | ------------ | ---------------------------------------------- | | File | `file` | Regular uploaded blob (current UI create path) | | Folder | `folder` | Container node (current UI create path) | | Link | `link` | Reserved in enum | | External | `external` | Reserved in enum | | Generated | `generated` | Reserved in enum | | Collection | `collection` | Reserved in enum | Model: `App\Models\File` — hierarchical via `parent_id` / `children`, soft deletes, Spatie `HasTags`. > **Current create paths** > `FileService` / controller create flows and the TypeScript `AdminFileRow.type` currently use **`file` and `folder` only**. The other enum values exist for forward compatibility but are not wired into today’s create UI. ## Versions | Piece | Detail | | -------- | --------------------------------------------------------------------------------------------------- | | Model | `App\Models\FileVersion` | | Table | `file_versions` | | Pointer | `files.current_version_id` → `File::currentVersion()` | | Relation | `File::versions()` | | Dedup | `FileService::resolveOrCreateVersion()` reuses a version with the same content `hash` when possible | Version fields include `disk`, `storage_path`, `hash`, `mime_type`, `size`, `width`, `height`, and `meta`. Upload and replace flows update `current_version_id` to the resolved version. > **Dangerous extensions** > Upload, replace, chunked init/complete, and rename reject a fixed denylist of dangerous extensions (`ForbiddenUploadExtension`). Display name / metadata / tags are stripped of HTML via `PlainTextSanitizer`. Details: [File manager — Upload security](/docs/file-manager#upload-security). ## Chunked uploads Large uploads go through a session model rather than a single request body. | Piece | Detail | | ------------ | ------------------------------------------------------------------- | | Model | `App\Models\FileUpload` | | Table | `file_uploads` | | Service | `FileService::initChunkUpload` / `uploadChunk` / complete-and-merge | | Default disk | `assets` | | Session TTL | 24 hours from init | | Cleanup | `files:cleanup-uploads` (hourly schedule) | Routes (under `can.manage.files` in `routes/admin.php`): | Method | Path name | Purpose | | ------ | ------------------------ | ---------------------------- | | POST | `files.uploads.init` | Start session | | POST | `files.uploads.chunk` | Upload a chunk | | POST | `files.uploads.complete` | Merge and create/update file | | GET | `files.uploads.status` | Poll session status | ## fileables / HasFiles Polymorphic attachments let other models own files without owning the blob tree. | Piece | Path / fact | | ---------- | -------------------------------------------------------------------------------------------------- | | Trait | `App\Traits\HasFiles` | | Pivot | `fileables` (`file_id`, `fileable_id`, `fileable_type`, `role`, `order`) | | Trait API | `files()`, `attachFile`, `detachFile`, `updateFile`, `syncFiles`, `filesByRole`, `firstFileByRole` | | HTTP | `POST files/{file}/attach`, `POST files/{file}/detach` | | Validation | `FileService` attach/detach requires the target class to use `HasFiles` | > No Eloquent model currently `use HasFiles`. The pivot, trait, and service endpoints are ready; domain models must opt in to become attach targets. ## Tags - Spatie tags via `HasTags` on `File` - Sync: `FileService::syncTags()` - Route: `PUT files/{file}/tags` (`files.tags`) - Permission: `can-tag-files` - Middleware maps `files.tags` and bulk `tag` / `untag` actions in `EnsureCanManageFiles` ## Favorites - Pivot: `file_favorites` (`user_id`, `file_id`) - Relations / service: `File::favoritedBy()`, `FileService::favorite` / `unfavorite` / `bulkFavorite` - Routes: `POST` / `DELETE` `files/{file}/favorite` - Permission: `can-favorite-files` - API resource exposes `is_favorited` ## Disks (`assets`) Configured in `config/filesystems.php` as disk `assets`: | Setting | Value | | ---------- | ----------------------------------- | | Driver | `local` | | Root | `storage/app/public/assets` | | URL | `{FILE_PUBLIC_BASE}/storage/assets` | | Visibility | `public` | Default for `files.disk`, `file_uploads.disk`, and `FileService` create/upload paths. ## Thumbnails — FileTransformService Class: `App\Services\FileTransformService`. | Behavior | Value | | ------------- | --------------------------------------------------------------------------------- | | Default size | `128` px | | Max size | `512` px (or higher if project transform presets define a larger size) | | Cleanup sizes | `64`, `128`, `256`, `512` | | Eligible | `type = file`, `mime_type` starts with `image/`, not SVG | | Fit | Contain-fit resize (GD) | | Format | WebP q82 if available, else JPEG q82 | | Cache path | `{dirname(storage_path)}/transforms/{sha256}.webp\|jpg` on the **same** file disk | | HTTP | `GET files/{file}/thumbnail` → `files.thumbnail` | | Resource | `thumbnail_url` when `isImage()` | ## Zip and duplicate thresholds From `config/files.php`: | Config key | Env | Default | Meaning | | -------------------------------- | -------------------------------- | --------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | | `files.duplicate_sync_max_bytes` | `FILES_DUPLICATE_SYNC_MAX_BYTES` | `52428800` (50 MiB) | Single non-folder files at or below this size duplicate **synchronously**; folders, bulk selections, and larger files queue `DuplicateFilesJob` | | `files.zip_ttl_minutes` | `FILES_ZIP_TTL_MINUTES` | `60` | Prepared zips under `storage/app/zips`; cleanup / 410 after TTL (`files:cleanup-zips`) | | `files.zip_max_bytes` | `FILES_ZIP_MAX_BYTES` | `104857600` (100 MiB) | Hard max archive size in `FileService::buildZipArchive` | > **Zip is always async for multi-download** > Multi-file / folder download always queues `PrepareFilesZipJob` (HTTP 202). `zip_max_bytes` is a **size cap**, not a sync/async threshold. Duplicate sync vs async is the size-based split described above (`FileController::shouldDuplicateSynchronously()`). ## Jobs and cleanup | Job / command | Role | | ----------------------- | --------------------------------- | | `DuplicateFilesJob` | Async copy + notification | | `PrepareFilesZipJob` | Build zip + notify download ready | | `files:cleanup-uploads` | Stale chunk sessions (hourly) | | `files:cleanup-zips` | Expired zip archives (hourly) | ## Related pages - [Architecture](/docs/architecture) — services and jobs - [Effective permissions](/docs/permissions) — `can.manage.files` - [Files & storage](/docs/files-configuration) - [AI assistant model](/docs/ai-assistant) — `ManageFiles` tool --- # AI assistant model Source: https://docs.externa.qiick.io/docs/ai-assistant The in-app assistant is `App\Ai\Agents\AppAssistant`: a conversational, tool-using agent (laravel/ai) whose **tool list is filtered by the user’s effective permissions**. Chat history, attachments, imports, and sync sources sit around that agent. > **Related** > Product guides: [AI chat](/docs/ai-chat), [AI imports & sync](/docs/ai-imports-and-sync). Config: [AI configuration](/docs/ai-configuration). HTTP: [AI API & webhooks](/docs/ai-api). ## AppAssistant | Item | Detail | | ------------------ | --------------------------------------------------- | | Class | `App\Ai\Agents\AppAssistant` | | Path | `app/Ai/Agents/AppAssistant.php` | | Contracts | `Agent`, `Conversational`, `HasTools` | | Provider attribute | `#[Provider('local')]` | | Traits | `Promptable`, `RemembersConversations` | | Steps | `maxSteps()` = `count(FieldTypeEnum::cases()) + 16` | | Timeout | `300` seconds | | Entry | `AiChatController` → `new AppAssistant($user)` | | Route gate | `permission:can-use-ai` on `routes/ai.php` | Tools are registered in `AppAssistant::tools()` only when the user holds the required permission(s) via `EffectivePermissionResolver`. Each tool then re-checks action-level permissions through `ChecksAiPermissions::requirePermission()`. ## Language policy - The assistant **replies in the same language as the user’s latest message**. If it cannot confidently use that language, it uses **English**. It must **never assume Italian**. - Tool errors, fallback turn summaries, and AI HTTP messages are **English machine strings**; the model translates them for the user. - Prompt chips / AI UI chrome in `resources/js` ship English literals (admin UI i18n elsewhere is unchanged). ## Field-type knowledge (no MCP yet) | Piece | Detail | | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | Cookbook | `App\Ai\Support\FieldTypeCookbook` — curated purpose / required keys / `settings_json` examples / nested rules (aligned with [Field types](/docs/field-types) + `BlocksFieldSchema`) | | Tool | `DescribeFieldTypes` — input `types` = list, `all`, or `complex` | | Gate | Registered with collection tools; needs show/create/edit collections | | Prompt rule | Before `create_field` / `update_field` on complex types, **MUST** call `DescribeFieldTypes`; treat `ManageCollections` `get` as source of truth for existing schema | | Validation | `create_field` / `update_field` run `CollectionFieldSettingsPipeline` (same normalize + rules as HTTP Store/Update field) | | MCP | `ai.mcp.enabled` stays off — custom tools are the knowledge plane for now | ## Permission-gated tools ### Registration (tool available in the agent) | Tool class | Registered when the user has | | ------------------------------ | ----------------------------------------------------------------------------------- | | `ManageCollections` | Any of: show / create / edit / delete / restore / force-delete **collections** | | `ManageCollectionItems` | Same collection permission set | | `DescribeFieldTypes` | Same collection permission set (action also accepts show **or** create **or** edit) | | `QueryCollectionItems` | `can-show-collections` | | `ExportCollection` | `can-show-collections` | | `SearchSimilarCollectionItems` | `can-show-collections` | | `ImportCollectionCsv` | `can-create-collections` | | `ImportRemoteJson` | `can-create-collections` | | `ExtractPdfText` | `can-create-collections` | | `GetImportJobStatus` | `can-create-collections` | | `ManageAiSyncSources` | `can-create-collections` | | `RollbackLastAiTurn` | `can-delete-collections` | | `ManageFiles` | Any of: show / create / edit / delete / restore / force-delete **files** | | `ManageUsers` | Any of: show / create / edit / delete / restore / force-delete **users** | | `ManageRoles` | Any of: show / create / edit / delete **roles** | | `ManageGroups` | Any of: show / create / edit / delete / restore / force-delete **groups** | | `QueryActivityLogs` | `can-show-activity-logs` | Paths: `app/Ai/Tools/{ToolName}.php`. ### Action-level gates (high level) **ManageCollections** | Actions | Permission | | ---------------------------------------------------------------------------- | ------------------------------ | | `list`, `get`, `list_field_packs` | `can-show-collections` | | `create`, `duplicate` | `can-create-collections` | | `update`, `create_field`, `update_field`, `delete_field`, `apply_field_pack` | `can-edit-collections` | | `delete` | `can-delete-collections` | | `restore` | `can-restore-collections` | | `force_delete` | `can-force-delete-collections` | **Collection packs:** for SEO entity / Articles / Pages / Products / Categories, the assistant **MUST** call `apply_collection_pack` (`pack`, e.g. `seo` or `articles`). Dependencies auto-create. UI: Collections **Create from pack…**. Presets: “Apply SEO collection”, “Scaffold Articles”, “Scaffold Products”. **Field packs:** for denormalized SEO (`seo_inline`) or publishing/contact/social on an existing collection, **MUST** call `apply_field_pack` (`collection_id` + `pack`). Use `seo_inline` only when the user wants inline `seo_*` fields. UI: Fields page **Add field pack…**. Registry: [Field types — Field packs](/docs/field-types#field-packs). **ManageCollectionItems** | Actions | Permission | | -------------------------------------- | ------------------------------ | | `list`, `get`, `list_relation_options` | `can-show-collections` | | `create` | `can-create-collections` | | `update`, `bulk_update` | `can-edit-collections` | | `delete`, `bulk_delete` | `can-delete-collections` | | `restore` | `can-restore-collections` | | `force_delete` | `can-force-delete-collections` | **Single-permission tools** | Tool | Permission | | ------------------------------ | ------------------------ | | `QueryCollectionItems` | `can-show-collections` | | `ExportCollection` | `can-show-collections` | | `SearchSimilarCollectionItems` | `can-show-collections` | | `ImportCollectionCsv` | `can-create-collections` | | `ImportRemoteJson` | `can-create-collections` | | `ExtractPdfText` | `can-create-collections` | | `GetImportJobStatus` | `can-create-collections` | | `RollbackLastAiTurn` | `can-delete-collections` | | `QueryActivityLogs` | `can-show-activity-logs` | **ManageAiSyncSources** | Actions | Permission | | --------------------------------------- | ------------------------ | | `list`, `create` | `can-create-collections` | | `update`, `enable`, `disable`, `delete` | `can-edit-collections` | **ManageFiles** | Actions | Permission | | --------------------------------------------- | ------------------------ | | `list`, `search` | `can-show-files` | | `create` / `create_folder`, `save_attachment` | `can-create-files` | | `rename`, `move`, `move_many` | `can-edit-files` | | `delete` | `can-delete-files` | | `restore` | `can-restore-files` | | `force_delete` | `can-force-delete-files` | **ManageUsers** / **ManageGroups** — show / create / edit / delete / restore / force-delete mapped to the matching `can-*-users` or `can-*-groups` permissions. **ManageRoles** — `list`/`get` → `can-show-roles`; `create` / `duplicate` → `can-create-roles` (+ show for duplicate); `update` / `delete` → edit/delete roles; `list_permissions` → `can-show-permissions` **or** create/edit roles. **Duplicate** clones Spatie permissions and collection/file matrices into a new named role (no user/group memberships). Cannot duplicate `super-admin` or locked system roles (same as the UI). > **Same gates as HTTP** > Collections HTTP routes use Spatie `permission:can-*-collections` (and FormRequest checks on bulk). The assistant re-checks the same permission names per tool action, plus `CollectionPermissionEnforcer` for item ACL / `item_filter`. See [Effective permissions](/docs/permissions) · [Collections API](/docs/collections-api). ## Conversations Configured under `config/ai.php` → `ai.conversations`: | Key | Value | | ---------------- | ---------------------------------------------------------------------- | | Tables | `agent_conversations`, `agent_conversation_messages` | | `generate_title` | `false` (title derived from the first prompt without an extra LM call) | Controllers: `AiConversationController`, `AiPageController`. Routes cover list/create/update/destroy, pin, truncate, bulk-destroy, and deep-link `GET /ai/{conversation}`. ## Attachments | Piece | Detail | | ------------ | ------------------------------------------- | | Model | `App\Models\AiChatAttachment` | | Disk | `local` (`AiChatAttachment::DISK`) | | Max size | 5 MiB | | TTL | 24 hours | | Extensions | `csv`, `txt`, `xlsx`, `pdf` | | Upload route | `POST /ai/attachments` (throttle 30/min) | | Chat usage | Up to 5 `attachment_ids` on `POST /ai/chat` | | Cleanup | `ai:cleanup-attachments` (daily schedule) | ## Daily prompt limit | Config | Env | Default | | ----------------------- | ----------------------- | -------------- | | `ai.daily_prompt_limit` | `AI_DAILY_PROMPT_LIMIT` | `0` (disabled) | When the limit is greater than `0`, `AiChatController` counts today’s Spatie activity rows (`log_name = ai`, `event = ai_prompt`, causer = user). At or above the limit the chat endpoint returns **429** with message `Daily AI prompt limit reached.` ## Embeddings and similarity search | Config | Env | Default | | ----------------------- | ----------------------- | ------- | | `ai.embeddings.enabled` | `AI_EMBEDDINGS_ENABLED` | `false` | `SearchSimilarCollectionItems` (`can-show-collections`) respects the flag: | Mode | When | Response `mode` | | ----------------- | --------------------------------------------------------------------------------------------------------- | ----------------- | | **Embeddings** | Flag `true`, query embedding succeeds, and stored `CollectionItemEmbedding` rows exist for the collection | `"embeddings"` | | **Semantic-lite** | Flag `false`, provider failure, or no stored vectors yet | `"semantic-lite"` | **Embeddings path:** generate a query vector via Laravel AI `Embeddings`, score stored vectors (cosine over JSON float arrays; pgvector later), return scored items. Item saves queue `GenerateCollectionItemEmbeddingJob`. If the collection has **zero** stored embeddings, the tool dispatches generation for up to **50** recent items and falls back this request. **Semantic-lite path:** load up to **500** recent items (permission-filtered), case-insensitive `str_contains` over lowercased JSON of assembled field values. Tool description text also switches when the flag is on. Config details: [AI configuration — Embeddings](/docs/ai-configuration#embeddings-and-mcp). ## MCP flag | Config | Env | Default | | ---------------- | ---------------- | ------- | | `ai.mcp.enabled` | `AI_MCP_ENABLED` | `false` | Referenced as an opt-in placeholder in `AppAssistant::tools()` comments. There is **no** active MCP wiring until laravel/ai exposes stable agent MCP integration. Leaving the flag `false` is correct for production today. ## Surrounding jobs and schedule | Piece | Role | | ------------------------ | ------------------------------------------------------------------ | | `ImportCollectionJob` | CSV / Excel / remote JSON imports; status in cache | | `ai:run-sync-sources` | Every minute — due `AiSyncSource` rows enqueue imports | | `ai:cleanup-attachments` | Daily — purge expired chat attachments | | Webhook | `POST /ai/webhooks/collection-import` (HMAC-signed, CSRF-excepted) | ## Related pages - [Effective permissions](/docs/permissions) - [Architecture](/docs/architecture) - [Collections data model](/docs/collections-data-model) - [AI configuration](/docs/ai-configuration) --- # Dashboard Source: https://docs.externa.qiick.io/docs/dashboard The dashboard is the Fortify home page: `DashboardController` renders Inertia `dashboard` with cached activity, storage, upload-health, Insights MVP widgets, and a **Health** tab fed by `DashboardHealthMetrics`. Access requires `can-show-dashboard`. > **Related** > Authorization: [Effective permissions](/docs/permissions). Activity: [Activity & notifications](/docs/activity-and-notifications). Ops stack: [Pulse & Health](/docs/pulse-and-health) · [Horizon](/docs/horizon) · [Reverb & Echo](/docs/reverb-and-echo). ## Access | Item | Detail | | ---------- | --------------------------------------------------------- | | Controller | `App\Http\Controllers\DashboardController` | | Method | `index` | | Route | `GET /dashboard` (`dashboard`) | | Middleware | `auth`, `verified`, `permission:can-show-dashboard` | | Permission | `can-show-dashboard` (`PermissionEnum::CanShowDashboard`) | | Frontend | `resources/js/pages/dashboard.tsx` | | Defined in | `routes/web.php` | Unauthenticated visitors never reach this page; Fortify’s home path is `/dashboard`. ## Tabs: Overview | Health Compact inline tabs (underline, same row as the description) switch content. There are **no** primary “Open Pulse” / “Open Horizon” CTAs on the Health tab — metrics render as native Externa cards/charts. Livewire UIs at `/pulse` and `/horizon` remain for super-admin deep dives only (bookmark / type URL). | Tab | Contents | | ------------ | -------------------------------------------------------------------------------------------- | | **Overview** | Insights / activity / storage / upload-health widgets (below) | | **Health** | Native cards from `DashboardHealthMetrics` (system + Pulse aggregates + Horizon/Redis queue) | Deep-link: `/dashboard?tab=health` opens the Health tab. Switching tabs updates the query via `history.replaceState`. Authenticated Inertia pages still share `healthBadge` (`ok` / `warn` / `fail` + label) from `DashboardHealthMetrics::badge()` (cached ~60s) for clients that need it; there is no sidebar chrome entry — open Health from the Dashboard tab. Fail when Redis is down or Horizon masters are stopped; warn when failed jobs / exceptions / Horizon failed counts are non-zero. See also [Pulse & Health tab](/docs/pulse-and-health). ## Overview widgets `DashboardController` builds Inertia props from Eloquent / Spatie Activity queries wrapped in `Cache::remember`. There is no dedicated dashboard service class for Overview. There is **no Insights panel builder** — fixed MVP panels only. ### Insights MVP (~60s cache) | Prop | Cache key | Contents | | ----------------------- | --------------------------------------- | ---------------------------------------------------------------------------- | | `collectionCounts` | `dashboard:collection-counts` | Each collection’s `items_count` via `withCount('items')` | | `activityOverTime` | `dashboard:activity-over-time:30d` | Daily activity counts for the last 30 days | | `contentEventBreakdown` | `dashboard:content-event-breakdown:30d` | `created` / `updated` / `deleted` counts for `CollectionItem` subjects (30d) | Charts use **Recharts** (last 7 days of `activityOverTime` + event breakdown bars). ### ~60 second cache (ops widgets) | Prop | Cache key | Contents | | -------------------- | --------------------------------- | ----------------------------------------------------------------------------------------- | | `latestActivity` | `dashboard:latest-activity` | Last 10 `Spatie\Activitylog\Models\Activity` rows with causer/subject | | `fileStats` | `dashboard:file-stats` | `files_count`, `folders_count`, `files_size_sum` from `App\Models\File` by `FileTypeEnum` | | `uploadHealth` | `dashboard:upload-health` | `in_progress_count`, `stale_count` from `App\Models\FileUpload` | | `mostActiveUsers` | `dashboard:most-active-users:24h` | Top 5 activity causers in the last 24 hours | | `suspiciousEvents` | `dashboard:suspicious-events:1h` | Last 25 activities with `event = 'failed'` in the last hour | | `stuckOrphanUploads` | `dashboard:stuck-orphan-uploads` | Up to 10 orphan / expired / stale uploads | | `largestFiles` | `dashboard:largest-files` | Top 10 files by size | | `fileStatsByDisk` | `dashboard:file-stats-by-disk` | Per-disk stats, loaded with `Inertia::defer` (group `storage`) | TTL for these keys is **60 seconds**. ### Longer cache | Prop | Cache key | TTL | Contents | | --- | --- | --- | | `storageTrend` | `dashboard:storage-trend:14d` | **1 hour** | 14-day series of bytes added | > Product copy often says “~60s widgets.” That matches activity, Insights, file counts/size, and upload health. The storage trend series is intentionally cached for one hour. This is **not** the notifications unread poll — that poll runs only when realtime broadcasting is off ([Reverb & Echo](/docs/reverb-and-echo)). ## Health tab (native metrics) Prop `health` comes from `App\Services\Dashboard\DashboardHealthMetrics::summary()` (not Livewire). Empty / unavailable states show when Pulse tables or Horizon Redis APIs are missing. ### System strip | Field | Meaning | | ---------------------- | ----------------------------------------------------- | | `queue_connection` | `config('queue.default')` | | `broadcast_connection` | `config('broadcasting.default')` | | `redis_ok` | `Redis::connection()->ping()` | | `pending_jobs` | Database `jobs` count or Redis `queues:{name}` length | | `failed_jobs` | `failed_jobs` table count | ### Pulse card + chart (24h aggregates) Requires `PULSE_ENABLED` and `pulse_aggregates` table (`pulse_available`). | Field | Pulse type | | ------------------ | ------------ | | `exceptions_24h` | `exception` | | `slow_jobs_24h` | `slow_job` | | `slow_queries_24h` | `slow_query` | Also surfaces `pulse_enabled` / `pulse_ingest` for the card description. Quiet empty state when all three counts are zero. ### Horizon card + workloads table | Field | Meaning | | ------------------------------------------ | ----------------------------------------------------- | | `horizon.available` | Horizon contracts resolvable | | `horizon.status` | `running` if master supervisors exist, else `stopped` | | `horizon.pending` / `failed` / `processes` | From workload + job repos | | `horizon.jobs_per_minute` / `throughput` | Metrics repository | | `horizon.workloads[]` | Per-queue `name`, `length`, `wait`, `processes` | ## What each area answers - **Insights** — collection size; activity volume; create/update/delete mix on content items. - **Activity** — recent events and causers; failed auth under suspicious events. - **File counts / size** — files, folders, bytes, largest files, optional per-disk breakdown. - **Upload health** — in-progress and stale chunked uploads; stuck/orphan rows. - **Health** — queue/broadcast/Redis status; Pulse exception/slow-job/slow-query volume; Horizon pending/failed/throughput/workloads. ## Processes needed for Health data | Process | Why | | ------------------------------- | --------------------------------------------------------------------------- | | `php artisan horizon` | Redis queue worker + Horizon metrics | | `php artisan pulse:work` | Drain Pulse redis ingest into aggregates | | Herd Reverb (or `reverb:start`) | Not required for Health metrics, but needed for Echo notifications/presence | See [Minimal vs full stack](/docs/minimal-vs-full-stack). ## Source map | Concern | Location | | --------------- | --------------------------------------------------- | | Controller | `app/Http/Controllers/DashboardController.php` | | Health metrics | `app/Services/Dashboard/DashboardHealthMetrics.php` | | Page | `resources/js/pages/dashboard.tsx` | | Permission enum | `app/Enums/PermissionEnum.php` → `CanShowDashboard` | | File model | `app/Models/File.php` | | Upload model | `app/Models/FileUpload.php` | --- # Users & groups Source: https://docs.externa.qiick.io/docs/users-and-groups Admin **Users** and **Groups** manage who can sign in and how they inherit roles. Both support soft-delete, restore, force-delete, and bulk actions. Groups attach members and Spatie roles so permissions (and role-tied collection/file ACL) flow through `EffectivePermissionResolver`. > **Related** > How group roles become effective permissions: [Effective permissions](/docs/permissions). Role assignment UI: [Roles & permissions](/docs/roles-and-permissions). Drawer URLs: [Routing — deep-links](/docs/routing#admin-ui-deep-links-drawers--panels) (`?edit=` / `?new=1`). ## Permissions Exact `PermissionEnum` strings (do not invent others): | Area | Permissions | | ------ | ------------------------------------------------------------------------------------------------------------------------------- | | Users | `can-show-users`, `can-create-users`, `can-edit-users`, `can-delete-users`, `can-restore-users`, `can-force-delete-users` | | Groups | `can-show-groups`, `can-create-groups`, `can-edit-groups`, `can-delete-groups`, `can-restore-groups`, `can-force-delete-groups` | Route middleware uses `permission:…` for most endpoints. Bulk actions authorize inside `BulkUserActionRequest` / `BulkUserGroupActionRequest` (maps `delete` / `restore` / `force_delete` to the matching permission). ## Users ### Controller & page | Item | Detail | | ---------- | ------------------------------------------------------------------------------------------------------------------- | | Controller | `App\Http\Controllers\Admin\UserController` | | Model | `App\Models\User` (`SoftDeletes`, Spatie `HasRoles`, Fortify `TwoFactorAuthenticatable` + `PasskeyAuthenticatable`) | | Frontend | `resources/js/pages/admin/users/index.tsx` | | Routes | `routes/admin.php` under `auth` + `verified` | ### Lifecycle | Action | Route | Permission | | ---------------------------- | ---------------------------- | ------------------------- | | List / search / trash filter | `GET /users` | `can-show-users` | | Create | `POST /users` | `can-create-users` | | Update | `PUT /users/{user}` | `can-edit-users` | | Soft-delete | `DELETE /users/{user}` | `can-delete-users` | | Restore | `POST /users/{user}/restore` | `can-restore-users` | | Force-delete | `DELETE /users/{user}/force` | `can-force-delete-users` | | Bulk | `POST /users/bulk-actions` | Per action in FormRequest | Route binding uses `withTrashed()` so restore/force-delete can target soft-deleted users. **Bulk actions** (`BulkUserActionRequest`): `action` ∈ `delete` | `restore` | `force_delete`, plus `ids[]`. ### Create / update fields Validated by `StoreUserRequest` / `UpdateUserRequest`: | Field | Notes | | ------------ | ------------------------------------------- | | `first_name` | Required | | `last_name` | Optional | | `email` | Required, unique | | `username` | Optional | | `password` | Required on create; optional on update | | `is_active` | Optional boolean | | `role_ids` | Optional array — synced via Spatie | | `group_ids` | Optional array — synced on `groups()` pivot | ### last_login On `Illuminate\Auth\Events\Login`, `AppServiceProvider::registerAuthActivityListeners` updates: - `last_login_at` - `last_login_ip` via `forceFill(...)->saveQuietly()` on the user. > Those columns exist and are maintained on login. They are **not** currently exposed in `UserResource` or the admin users table (not listed/sortable in the UI). ## Groups ### Why groups Groups are a thin inheritance layer for large user bases: attach Spatie roles to a group, add members, and every member inherits: 1. **Admin Spatie permissions** from those roles (via `EffectivePermissionResolver`) 2. **Collection / file ACL** tied to the same roles (`collection_permissions` / `file_permissions` matrices on the role) There is **no** separate group → collection_permissions matrix. Configure access on the role; scale membership via the group. ### Controller & page | Item | Detail | | ---------- | ---------------------------------------------------------------------------- | | Controller | `App\Http\Controllers\Admin\UserGroupController` | | Model | `App\Models\UserGroup` (`SoftDeletes`, `resolveRouteBinding` includes trash) | | Pivots | `user_group_user` (members), `user_group_role` (roles) | | Frontend | `resources/js/pages/admin/groups/index.tsx` | ### Routes | Action | Route | Permission | | ---------------------------- | ------------------------------ | ------------------------- | | List / search / trash filter | `GET /groups` | `can-show-groups` | | Create | `POST /groups` | `can-create-groups` | | Update | `PUT /groups/{group}` | `can-edit-groups` | | Soft-delete | `DELETE /groups/{group}` | `can-delete-groups` | | Restore | `POST /groups/{group}/restore` | `can-restore-groups` | | Force-delete | `DELETE /groups/{group}/force` | `can-force-delete-groups` | | Bulk | `POST /groups/bulk-actions` | Per action in FormRequest | **Bulk actions** (`BulkUserGroupActionRequest`): `action` ∈ `delete` | `restore` | `force_delete`, plus `ids[]`. ### Members + role attachment Store/update accept: | Field | Role | | ------------- | ------------------------------------------------------------------------------------------------ | | `name` | Required | | `description` | Optional | | `user_ids` | Optional — sync group members when present (omit to leave members unchanged; empty array clears) | | `role_ids` | Optional — sync Spatie roles onto the group when present | Index payloads include `user_ids` and `users` (id / name / email) so the edit drawer can hydrate the member multi-select. **Permission inheritance:** `EffectivePermissionResolver` unions: 1. Roles assigned directly on the user 2. Roles attached to each of the user’s groups `effectiveRoleIds()` feeds the same set into admin collection field ACL (`CollectionPermissionGuard::rulesForUser`). See [Effective permissions](/docs/permissions). ## Source map | Concern | Location | | -------------------- | ---------------------------------------------------------------- | | Users controller | `app/Http/Controllers/Admin/UserController.php` | | Groups controller | `app/Http/Controllers/Admin/UserGroupController.php` | | Admin routes | `routes/admin.php` | | Client route helpers | `resources/js/lib/admin-routes.ts` | | Auth login timestamp | `AppServiceProvider::registerAuthActivityListeners` | | Post-login home | `App\Support\Auth\HomePath` (uses `EffectivePermissionResolver`) | --- # Roles & permissions Source: https://docs.externa.qiick.io/docs/roles-and-permissions Roles and permissions are managed in admin UI backed by Spatie Permission. Canonical permission **names** live in `PermissionEnum`; `permissions:sync` (and the UI sync button) upsert them into the database. Seeded roles are `super-admin`, `admin`, `reader`, and the system role **`public`** (anonymous Public CMS API actor). > **AuthZ map** > Surface → permission / ACL → Pest coverage (admin, Public CMS API, GraphQL, AI, webhooks, private files): [AuthZ map](/docs/authz-map). Resolution rules: [Effective permissions](/docs/permissions). > **Scale with groups** > For large memberships, attach roles to a **[group](/docs/users-and-groups)** instead of assigning roles user-by-user. Members inherit admin Spatie permissions and the role’s collection/file access matrices. There is no separate group→collection ACL. > Only use values from `App\Enums\PermissionEnum`. Creating ad-hoc names in the DB that are not in the enum will drift from the TypeScript mirror (`resources/js/enums/permission-enum.ts`) and from `permissions:sync --prune`. Full resolution rules: [Effective permissions](/docs/permissions). ## Permissions (admin CRUD area) These gate the roles/permissions screens themselves: | Area | Exact strings | | ----------- | -------------------------------------------------------------------------------------------------- | | Roles | `can-show-roles`, `can-create-roles`, `can-edit-roles`, `can-delete-roles` | | Permissions | `can-show-permissions`, `can-create-permissions`, `can-edit-permissions`, `can-delete-permissions` | ## Roles ### Controller & pages | Item | Detail | | ---------- | ---------------------------------------------------------------------------------- | | Controller | `App\Http\Controllers\Admin\RoleController` | | Helper | `App\Support\Authorization\PermissionGrouper` (groups permissions for the form UI) | | Pages | `resources/js/pages/admin/roles/index.tsx`, `form.tsx` | Role create/edit shows **Unsaved** + **Discard** in the form header while dirty (Discard resets the form in place). **Delete** on the roles index opens a confirm dialog. **Duplicate** (icon action, requires `can-create-roles`) opens a name dialog and clones Spatie permissions plus collection/file matrices into a new non-system role; user/group memberships are **not** copied. Bulk deletes elsewhere (users, groups, collections) also confirm with a selected count. ### Routes | Action | Route | Permission | | ----------- | --------------------------------------- | ------------------------- | | Index | `GET /settings/roles` | `can-show-roles` | | Create form | `GET /settings/roles/create` | `can-create-roles` | | Store | `POST /settings/roles` | `can-create-roles` | | Duplicate | `POST /settings/roles/{role}/duplicate` | `can-create-roles` | | Edit form | `GET /settings/roles/{role}/edit` | `can-edit-roles` | | Update | `PUT /settings/roles/{role}` | `can-edit-roles` | | Destroy | `DELETE /settings/roles/{role}` | `can-delete-roles` | | Bulk | `POST /settings/roles/bulk-actions` | Authorized in FormRequest | Store/update validate `name`, optional `permission_ids[]`, optional `collection_permissions` (per-collection create/read/update/delete), and optional `file_permissions` (global create/read/`read_private`/update/delete for the [Public CMS API](/docs/public-cms-api)), then sync Spatie permissions / collection / file grants and flush caches. **Duplicate:** body `{ name }` (unique). Copies Spatie permission IDs and collection/file matrices from the source. Redirects to the clone’s edit page. Sources rejected with **403**: locked system roles (`is_system` / `public`) and `super-admin`. Allowed sources include seeded `admin`, `reader`, and custom roles. **Bulk:** action `delete` only. `BulkRoleActionRequest::deletableIds()` skips `super-admin`. > **Super-admin & public protection** > `super-admin` cannot be deleted, renamed, or **duplicated** (403 on destroy/duplicate; bulk skips it). `public` is system / not assignable: cannot rename, delete, or duplicate; Spatie permission checkboxes are hidden — only **Collection access** and **Files access** are editable. ## Collection access (Public CMS API) On role create/edit, the **Collection access** matrix grants headless `/api/v1` actions independently of Spatie admin permissions: | UI | Meaning | | --------- | -------------------------------------------- | | ✓ (green) | Action **allowed** for that collection | | ✕ (red) | Action **denied** (default if never granted) | Actions: **Create**, **Read**, **Update**, **Delete**. Missing grant = deny. Use this on: - **`public`** — anonymous callers (no Bearer token) - **Custom roles** — then bind an [API key](/docs/public-cms-api) to that role for partners/backends Full flow, endpoints, and auth resolution: [Public CMS API](/docs/public-cms-api). ## Files access (Public CMS API) Same role form → **Files access** matrix. Grants are **global** (not per folder), stored in `file_permissions`: | UI action | Stored `action` | Meaning | | ------------ | --------------- | ------------------------------------------------------------------------------------------------------- | | Create | `create` | Upload via `POST /api/v1/files` | | Read | `read` | List / show / content / transforms for **public** (effective) files; expand public file fields on items | | Read private | `read_private` | Same endpoints for files whose effective visibility is **private** | | Update | `update` | Patch metadata | | Delete | `delete` | Soft delete | File visibility (`files.access`): `public`, `private`, or `null` (inherit from nearest ancestor). Effective private without `read_private` → Files API **403** (or omitted from list); item expanders return `null` / omit the entry (no id leak). Details: [Public CMS API — Public vs private files](/docs/public-cms-api#public-vs-private-files). > **Typical public website pattern** > On the **`public`** role: grant **Read** (✓) if the site needs images/files; leave **Read private** as denied (✕). Use a custom API-key role with **Read private** only when a backend must fetch private assets. ## Permissions (Spatie rows) ### Controller & page | Item | Detail | | ---------- | ------------------------------------------------- | | Controller | `App\Http\Controllers\Admin\PermissionController` | | Model | `Spatie\Permission\Models\Permission` | | Page | `resources/js/pages/admin/permissions/index.tsx` | ### Routes | Action | Route | Permission | | ------- | ------------------------------------------- | ------------------------ | | Index | `GET /settings/permissions` | `can-show-permissions` | | Store | `POST /settings/permissions` | `can-create-permissions` | | Update | `PUT /settings/permissions/{permission}` | `can-edit-permissions` | | Destroy | `DELETE /settings/permissions/{permission}` | `can-delete-permissions` | | Sync | `POST /settings/permissions/sync` | `can-edit-permissions` | The index UI is primarily a read-only list plus **Sync from enum** (gated by `can-edit-permissions`). Prefer sync over hand-editing names. ## Sync from PermissionEnum ### Artisan ```bash php artisan permissions:sync php artisan permissions:sync --prune ``` | Item | Detail | | ----------- | -------------------------------------------------------------- | | Command | `App\Console\Commands\SyncPermissionsCommand` | | Signature | `permissions:sync {--prune}` | | Behavior | Upserts every `PermissionEnum::values()` for the default guard | | `--prune` | Deletes DB permissions whose names are **not** in the enum | | Side effect | Regenerates `resources/js/enums/permission-enum.ts` | `PermissionSeeder` only runs `Artisan::call('permissions:sync')` (no prune). ### UI sync `PermissionController::sync` calls the same command with `'--prune' => $request->boolean('prune')`. The permissions page currently POSTs `{}`, so **prune defaults to false**. ## Seeded roles `RoleEnum` + `Database\Seeders\RoleSeeder` (after `PermissionSeeder`): | Role value | Seeded permissions | | ------------- | ---------------------------------------------------------------------------------- | | `super-admin` | All permissions in the DB for the guard | | `admin` | All permissions in the DB for the guard | | `reader` | Only enum values whose name starts with `can-show-` | | `public` | None — system role for anonymous `/api/v1` (Collection access + Files access only) | Reader show set (exact): ``` can-show-dashboard can-show-users can-show-groups can-show-roles can-show-permissions can-show-files can-show-collections can-show-activity-logs ``` `can-use-ai` is **not** in the reader set. Runtime difference between `super-admin` and `admin`: super-admin also bypasses Gates via `Gate::before` / `EffectivePermissionResolver`. `DatabaseSeeder` order: `PermissionSeeder` → `RoleSeeder` → `CreateSuperAdminSeeder`. ## Full PermissionEnum catalog Do not invent strings outside this list: ``` can-show-dashboard can-show-users can-create-users can-edit-users can-delete-users can-restore-users can-force-delete-users can-show-groups can-create-groups can-edit-groups can-delete-groups can-restore-groups can-force-delete-groups can-show-roles can-create-roles can-edit-roles can-delete-roles can-show-permissions can-create-permissions can-edit-permissions can-delete-permissions can-show-files can-create-files can-edit-files can-delete-files can-restore-files can-force-delete-files can-download-files can-favorite-files can-copy-files can-replace-files can-tag-files can-update-file-metadata can-show-collections can-create-collections can-edit-collections can-delete-collections can-restore-collections can-force-delete-collections can-show-activity-logs can-use-ai can-show-api-keys can-manage-api-keys can-show-jobs can-manage-jobs can-manage-project-settings ``` Source: `app/Enums/PermissionEnum.php`. Helper: `PermissionEnum::values()`. ## API Keys (admin) | Action | Route | Permission | | ----------------- | ------------------------------------ | --------------------- | | Index / create UI | `GET /settings/api-keys` | `can-show-api-keys` | | Store | `POST /settings/api-keys` | `can-manage-api-keys` | | Revoke / destroy | `DELETE /settings/api-keys/{apiKey}` | `can-manage-api-keys` | Keys authenticate the Public CMS API (`Authorization: Bearer ek_…`). Permissions for API calls come from the key’s **role** Collection access and Files access matrices — not from Spatie permissions on the creating user. Details: [Public CMS API](/docs/public-cms-api). ## Source map | Concern | Location | | -------------------------- | -------------------------------------------------------------------------------------------- | | Role controller | `app/Http/Controllers/Admin/RoleController.php` | | Permission controller | `app/Http/Controllers/Admin/PermissionController.php` | | API keys | `app/Http/Controllers/Admin/ApiKeyController.php` | | Collection permission sync | `app/Services/Api/CollectionPermissionSync.php` | | File permission sync | `app/Services/Api/FilePermissionSync.php` | | Sync command | `app/Console/Commands/SyncPermissionsCommand.php` | | Enums | `app/Enums/PermissionEnum.php`, `RoleEnum.php`, `FilePermissionAction.php`, `FileAccess.php` | | Seeders | `database/seeders/PermissionSeeder.php`, `RoleSeeder.php` | ## Collection access rules Beyond the CRUD matrix, each collection grant may include `rules.fields` and `rules.item_filter`. Configure under **Settings → Roles → Collection access → Fields & item filter**. Full dialect and API effects: [Public CMS API — Field-level rules](/docs/public-cms-api#field-level-rules--item-filters). These field flags also drive the **admin item form** UI: no `read` hides the field; missing `create`/`update` (for new vs edit) makes it read-only. Super-admins and Spatie-only admins without a collection grant stay unrestricted in the form (`fieldGrants` is null). Details: [Collection items — Field ACL](/docs/collection-items#field-acl-in-the-item-form). ## Preview as role Editors can simulate how a role (including **public**) sees an item without switching accounts. The item form **Preview as…** action calls `collections.items.preview-as-role` and shows the redacted JSON payload (same enforcer + file expander as the public API). See [Collection items — Preview as role](/docs/collection-items#preview-as-role--public). ## Jobs monitor | Permission | Purpose | | ----------------- | ---------------------------------------------------- | | `can-show-jobs` | View `/settings/jobs` (pending sample + failed jobs) | | `can-manage-jobs` | Retry / delete failed jobs | Details: [Operations — Jobs monitor](/docs/operations#jobs-monitor-admin-ui). ## Project settings | Permission | Purpose | | ----------------------------- | ----------------------------------------------------------------------- | | `can-manage-project-settings` | Edit `/settings/project`, branding `/settings/appearance`, webhook test | Details: [Project settings](/docs/project-settings). --- # Collections Source: https://docs.externa.qiick.io/docs/collections Collections define content schemas (EAV). `ContentCollectionController` manages collection metadata; `FieldController` drives the fields builder (reorder, duplicate, form visibility, layout width, **form layout** tabs/sections). Pages live under `resources/js/pages/collections/collections/`. > **Authorization** > `routes/collections.php` uses **`auth` + `verified`** plus Spatie **`permission:`** middleware on every action (`can-*-collections`; bulk authorize in FormRequest). The React UI (`useCan`) and AI tools share the same names. Item read/write also applies **`CollectionPermissionEnforcer`** (role `collection_permissions` field ACL / `item_filter`). Details: [Effective permissions](/docs/permissions), [Collections API](/docs/collections-api). Data shape: [Collections data model](/docs/collections-data-model). Field types: [Field types](/docs/field-types). Schema changes: [Schema changes](/docs/schema-changes). ## Controllers & pages | Layer | Location | | --------------------- | -------------------------------------------------------------------------------- | | Collection controller | `App\Http\Controllers\Collections\ContentCollectionController` | | Field controller | `App\Http\Controllers\Collections\FieldController` | | Model | `App\Models\Collection` (Spatie `Sortable`, soft deletes) | | Field model | `App\Models\CollectionField` (sortable, scoped per collection) | | Routes | `routes/collections.php` | | Pages | `resources/js/pages/collections/collections/index.tsx`, `show.tsx`, `fields.tsx` | ## Collection CRUD | Action | Route | Inertia / behavior | | ---------------- | ---------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Index | `GET /collections` | `collections/collections/index`; toolbar `search` (name/slug), `sort` (`name`/`slug`/`updated_at`), `direction` (`asc`/`desc`); `?trashed=1` → `onlyTrashed()`; drawer deep-links `?edit={id}` / `?new=1` | | Store | `POST /collections` | Creates collection; if `is_singleton`, seeds an empty item | | Show | `GET /collections/{collection}` | Non-singleton → redirect to items index; singleton → editor (`show`) | | Update | `PUT /collections/{collection}` | Metadata via `UpdateContentCollectionRequest` | | Bulk | `POST /collections/bulk` | `action`: `delete` \| `restore` \| `force_delete` + `ids` | | Create from pack | `POST /collections/packs/{pack}` | Collection packs (seo, articles, …) — see [Field types](/docs/field-types#collection-packs) | | Soft-delete | `DELETE /collections/{collection}` | Soft-delete | | Restore | `POST /collections/{collection}/restore` | Restore | | Force-delete | `DELETE /collections/{collection}/force` | Permanent delete | ### Slug - On create (`StoreContentCollectionRequest`): `slug` optional; empty → `Str::slug(name)`; uniqueness via `UniqueCollectionSlugGenerator`. - On update: slug regenerated uniquely from name/slug as needed. ### `is_singleton` - Boolean fillable on `Collection`. - Store coerces with `$request->boolean('is_singleton')`. - **Cannot change after create** (422 on update attempts). - Singleton create seeds `$collection->items()->create([])`. - Singleton show uses assembled `singletonRawData` + `relatedCollections`. Content upsert is documented under [Collection items](/docs/collection-items). ### Sortable `Collection` implements Spatie’s sortable trait: - Order column: `sort_order` - `sort_when_creating` = `true` - Listing uses `Collection::ordered()` ### Soft-delete cascade to items `Collection::booted()`: | Collection event | Items | | ---------------- | ---------------------------------------------------- | | Soft-delete | `$collection->items()->delete()` | | Force-delete | `$collection->items()->withTrashed()->forceDelete()` | | Restore | `$collection->items()->onlyTrashed()->restore()` | ## Fields builder Route prefix under each collection: | Action | Route name | Behavior | | ---------------------- | ---------------------------------------------- | --------------------------------------------------------------------------------------------------------- | | Builder UI | `GET …/fields` → `collections.fields.index` | Inertia `collections/collections/fields`; deep-links `?field={id}` / `?newField=1` | | Create field | `POST …/fields` | Includes `translatable` boolean | | Update field | `PATCH …/fields/{field}` | Definition update | | Delete field | `DELETE …/fields/{field}` | Hard delete | | Reorder | `POST …/fields/reorder` | `CollectionField::setNewOrder($ids)`; persists `settings.layout_starts_new_row` from `starts_new_row_ids` | | Duplicate | `POST …/fields/{field}/duplicate` | `replicate` without `sort_order`; name `{base}_copy` / `_copy_N` | | Toggle form visibility | `POST …/fields/{field}/toggle-form-visibility` | Toggles `settings.hidden_in_form` | | Layout width | `POST …/fields/{field}/layout-width` | Sets `settings.layout_width` | | Form layout | `PUT …/form-layout` | Tabs/sections JSON | | Field pack | `POST …/field-packs/{pack}` | Apply pack (skip existing names) — [Field packs](/docs/field-types#field-packs) | UI: **Add field pack…** on the fields page; **Create from pack…** on the collections index. ## Collection permissions (enum) ``` can-show-collections can-create-collections can-edit-collections can-delete-collections can-restore-collections can-force-delete-collections ``` Sidebar show is gated by `can-show-collections`. Index trash actions use delete/restore/force-delete checks. Field and item pages currently do not wrap every control in `useCan`. ## Source map | Concern | Location | | --------------------- | ------------------------------------------------------------------ | | Collection controller | `app/Http/Controllers/Collections/ContentCollectionController.php` | | Field controller | `app/Http/Controllers/Collections/FieldController.php` | | Routes | `routes/collections.php` | | Pages | `resources/js/pages/collections/collections/*` | --- # Collection items Source: https://docs.externa.qiick.io/docs/collection-items Collection items hold the EAV values for a collection. `ItemController` covers list/create/edit/soft-delete lifecycle; singletons upsert through `PUT /collections/{id}/singleton-content`. Validation is built by `CollectionItemDataRuleBuilder`. > **Related** > Schema & cascade: [Collections](/docs/collections). Field type catalog: [Field types](/docs/field-types). Model internals: [Collections data model](/docs/collections-data-model). HTTP table: [Collections API](/docs/collections-api). Item discussion threads: [Chat (hub & items)](/docs/chat). Collaborative presence / soft lock: [Collaborative editing](/docs/collaborative-editing). Locales & translation: [Translation workspace](/docs/translation-workspace). Kanban/calendar: [Collection layouts](/docs/collection-layouts). > **Authorization** > All collection/item routes use Spatie `permission:` middleware (`can-show-collections`, `can-create-collections`, `can-edit-collections`, …). Super-admin bypasses via `Gate::before`. See [Security checklist](/docs/security-checklist). ## Controller & pages | Item | Detail | | ---------------- | ----------------------------------------------------------------------------- | | Controller | `App\Http\Controllers\Collections\ItemController` | | Revisions | `App\Http\Controllers\Collections\ItemRevisionController` | | Singleton upsert | `ContentCollectionController::upsertSingletonContent` | | Validation | `App\Services\Collections\CollectionItemDataRuleBuilder` | | Query / filters | `App\Services\Collections\CollectionItemQueryService` | | Pages | `resources/js/pages/collections/items/index.tsx`, `form.tsx`, `revisions.tsx` | ## Item CRUD | Action | Route | Permission | | --------------- | ------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ | | Index | `GET /collections/{collection}/items` | `can-show-collections` | | Export CSV/JSON | `GET /collections/{collection}/items/export?format=csv\|json` | `can-show-collections` | | List columns | `PUT /collections/{collection}/list-columns` | `can-edit-collections` | | Ask AI | Toolbar / row actions | Opens FAB with seeded prompt (`AskAiButton`, `can-use-ai`) — see [AI chat](/docs/ai-chat#ask-ai) | | Item chat | Drawer on item form | Shared item thread — [Chat (hub & items)](/docs/chat) | | New form | `GET /collections/{collection}/items/new` | `can-create-collections` | | Store | `POST /collections/{collection}/items` | `can-create-collections` | | Show / edit | `GET /collections/{collection}/items/{item}` | `can-show-collections` | | Update | `PUT …/items/{item}` | `can-edit-collections` (**PUT only**) | | Bulk | `POST …/items/bulk` | FormRequest per `action`: `delete` \| `restore` \| `force_delete` | | Soft-delete | `DELETE …/items/{item}` | `can-delete-collections` | | Restore | `POST …/items/{item}/restore` | `can-restore-collections` | | Force-delete | `DELETE …/items/{item}/force` | `can-force-delete-collections` | Admin field-level rules / `item_filter` (from the role’s Collection access matrix) also apply when the user is not a super-admin — same dialect as the Public CMS API. ### Field ACL in the item form When the signed-in admin’s effective roles include collection field rules, `ItemController` (and singleton show) pass a `fieldGrants` map to the form: | Grant | UI | | ----------------------------------------------------- | --------------------------------------------------------- | | `read: false` (or field absent from the map) | Field hidden | | `create: false` on **new** item | Field read-only (`No write access`) | | `update: false` on **edit** | Field read-only | | Super-admin / Spatie-only (no collection grant rules) | `fieldGrants: null` — all fields unrestricted in the form | Server-side write checks (`assertWritableFields`) still apply on save. See [Roles & permissions — Collection access rules](/docs/roles-and-permissions#collection-access-rules). ## Advanced list filters The items index toolbar has: 1. **Title search** — debounced contains on `title` (`filter[title][_contains]=…`) 2. **Filters** popover — AND multi-field builder writing the same query dialect as the Public API / GraphQL | Query | Meaning | | --------------------------------- | -------------------------------------------------- | | `?filter[title]=hello` | Contains (LIKE), backward compatible | | `?filter[status][_eq]=published` | Exact match | | `?filter[status][_neq]=draft` | Not equal | | `?filter[title][_contains]=hello` | Explicit contains | | `?filter[status][_in]=a,b,c` | In list | | `?filter[count][_gte]=1` | Numeric / comparable ranges (`_gt`, `_lt`, `_lte`) | | `?filter[body][_null]=1` | Empty / missing | | `?filter[body][_nnull]=1` | Present | Multiple fields are **AND**-combined (UI: Field filters popover → Add rule → Apply). Service: `CollectionItemQueryService`. **Clear all** resets the URL filters (and leaves sort/direction alone). Pest coverage: `tests/Feature/Collections/AdvancedFiltersTest.php` (operators + multi-rule) and `FieldCreateMatrixTest.php` (thin field-type create matrix). ## Return to list (filters & page) Edit / New item links from the items index append a `return` query param with the current list path + query (filters, sort, page). - The form **Items** breadcrumb uses `return` when it is a same-origin path under `/collections/…`. - Invalid or off-site `return` values are ignored; the breadcrumb falls back to the plain items index. - Drawer-based admin screens (collections / users / groups) do not need `return` — closing the drawer keeps the list URL. ## Export CSV / JSON From the items index header, **Export** downloads the current filtered list (same `filter` / `sort` / `direction` query params, plus role `item_filter` SQL pushdown) as CSV or JSON. | Detail | Value | | ----------- | ------------------------------------------------------------- | | Route | `GET /collections/{collection}/items/export?format=csv\|json` | | Cap | 5 000 rows (`CollectionItemExportService::MAX_ROWS`) | | Shared with | AI tool `ExportCollection` | Response headers: `X-Export-Rows`, `X-Export-Total-Matched`, `X-Export-Truncated`. ## Item History (revisions) From the item form, **History** opens: | Action | Route | Permission | | -------------- | -------------------------------------------------- | ---------------------- | | List / compare | `GET …/items/{item}/revisions` | `can-show-collections` | | Restore | `POST …/items/{item}/revisions/{revision}/restore` | `can-edit-collections` | - Snapshots are stored in `collection_item_revisions` on every `CollectionItemValuesWriter::sync` (create / update / restore). - UI: select two revisions to diff field JSON; **Restore** writes the snapshot as a new update (and records another revision). - Soft-deleted items keep history. Activity log (`/activity-logs`) remains separate — actor/event audit, not version store. ## Unsaved changes & destructive confirms Item create/edit (and singleton show) register with the admin leave guard: - **Unsaved** badge + **Discard** appear in the header toolbar while the form is dirty. Discard opens Keep editing / Discard changes, remounts the form with original values, and stays on the page. - Navigating away via sidebar/links while dirty shows the same dialog; Discard then continues navigation. - **Delete** on the item form (and History **Restore**) opens a confirm dialog — no bare submits or `window.confirm`. List views (items index) confirm soft-delete, bulk delete, and permanent delete with a count when multiple rows are selected. See also: [Roles & permissions](/docs/roles-and-permissions) (role form Discard), [Project settings](/docs/project-settings) (settings Save bar Discard). ## Browser draft autosave While editing an item, dirty field values are debounced into **localStorage** (`externa:item-draft:{collectionId}:{itemId|new}`). Reloading the form shows **Restored unsaved draft** with **Discard draft** (clears storage and remounts server values). Successful Save/Create clears the draft. This is browser-only — not a server draft status and not shared across devices. ## Preview as role / public On an existing item, **Preview as…** opens a dialog to pick any role (including **public**). `GET /collections/{collection}/items/{item}/preview-as-role` (`collections.items.preview-as-role`) runs the same ACL + file expand pipeline as the public CMS API and returns read-only JSON (redacted fields omitted; public files without Files → Read appear as `{ id, access: "denied" }`). If the role lacks collection Read or the item fails the item filter, `readable` is false. See [Roles & permissions](/docs/roles-and-permissions#preview-as-role) and [Public CMS API — `access: denied`](/docs/public-cms-api#public-vs-private-files). ## Activity strip Below the item form header: last updated by/at, links to **Revisions** and **Activity** (filtered `/activity-logs?subject_type=App\Models\CollectionItem&subject_id={id}`), plus the last few Spatie activity rows for the item subject (row-level events — field diffs live in revisions). ## Singleton upsert | Item | Detail | | ------- | ------------------------------------------------- | | Route | `PUT /collections/{collection}/singleton-content` | | Name | `collections.singleton-content` | | Request | `UpsertSingletonCollectionItemRequest` | | Guard | 404 unless `$collection->is_singleton` | Behavior: 1. Create the singleton item if missing. 2. Otherwise merge arrays field-wise, strip readonly fields, normalize, sync values. ## Field options endpoint | Item | Detail | | ------- | ------------------------------------------------ | | Route | `GET /collections/{collection}/items/options` | | Name | `collections.items.field-options` | | Service | `CollectionItemOptionsService::paginateForField` | Query parameters: `field_id` (required), `related_collection_id`, `search`, `page`, `per_page` (1–100, default 20). Response pages of `{ id, label }` for relation pickers. ## Relational / M2M editing | Field family | UI / payload | | ----------------------------------- | ------------------------------------------------------------------------ | | Relation / ManyToOne / RelationTree | Scalar related item id | | RelationMany / OneToMany | Array of ids | | ManyToMany | Array of `{ related_item_id, meta }` (bare ints still accepted on write) | | M2A | Array of `{ related_collection_id, related_item_id }` | ### Junction metadata (M2M) Optional `settings.junction_fields` on the field (JSON mini-schema in field settings): ```json [{ "name": "sort", "type": "number" }] ``` Allowed types: `string`, `number`, `boolean`. The item form shows meta inputs per linked item. API consumers should send: ```json { "related_item_id": 12, "meta": { "sort": 1 } } ``` ## Source map | Concern | Location | | --------------- | ------------------------------------------------------------- | | Item controller | `app/Http/Controllers/Collections/ItemController.php` | | Revisions | `app/Http/Controllers/Collections/ItemRevisionController.php` | | Rule builder | `app/Services/Collections/CollectionItemDataRuleBuilder.php` | | Query / filters | `app/Services/Collections/CollectionItemQueryService.php` | | Routes | `routes/collections.php` | | Pages | `resources/js/pages/collections/items/*` | --- # Collection layouts Source: https://docs.externa.qiick.io/docs/collection-layouts Optional **kanban** and **calendar** layouts on the collection items index. **Table** stays the default. > **Related** > [Collection items](/docs/collection-items) · [Field types](/docs/field-types) (select / date) · [Roles & permissions](/docs/roles-and-permissions) ## Switch layout On the items list toolbar, use the layout toggle: | Layout | Needs | Behavior | | -------- | ------------------------------- | --------------------------------------------------- | | Table | — | Default paginated grid | | Kanban | `select` or `radio_group` field | Columns from options; drag moves and reorders cards | | Calendar | `date` field | Month / Week / Day / List views; drag to reschedule | Preference is stored **per user** with list columns (`PUT /collections/{id}/list-columns`: `layout`, `kanban_field`, `calendar_field`). Toolbar filters on the items list apply to table, kanban, and calendar alike. ## Kanban 1. Pick the status field (select / radio). 2. Drag a card to another column — runs the same item `PUT` + field ACL as the form (`board_move`). 3. Reorder within a column — card order persists in `collections_items.board_sort`. 4. After save, redirect returns to the list (`return` safe path). Each kanban column loads lazily (**Load more**) via `GET /collections/{collection}/items/kanban-column`. On **versioned** collections, board moves write the moved field to **published** values (calendar/kanban read published buckets). When `draft_data` is present, that field is patched there too. ## Calendar 1. Pick a date field (`date` or `datetime` mode). 2. Switch between **Month**, **Week**, **Day**, and **List** views. 3. Items load per range via `GET /collections/{collection}/items/calendar-month`. 4. Drag an item to another day to update its date field (`calendar_move` — same ACL as kanban). Datetime fields keep the existing clock when dropped onto a bare day. Large months show “Showing X of Y items in this month.” On **versioned** collections, calendar moves update **published** date values the same way as kanban; `draft_data` is patched when present. ## Out of scope Map layout, gantt, and create-on-empty-slot are not in v1. --- # Collaborative item editing Source: https://docs.externa.qiick.io/docs/collaborative-editing When two editors open the same collection item, Externa shows **who else is on the form**, optional **soft lock** (read-only for others until takeover), and a **stale-save warning** if the row changed on the server. This is a Directus-style collaboration signal — not CRDT field sync. > **Requires Reverb** > Presence and live lock hints use **Laravel Echo** on presence channel `collection-item.{collectionId}.{itemId}`. Configure [Reverb & Echo](/docs/reverb-and-echo) (`BROADCAST_CONNECTION=reverb`, `reverb:start`, and Vite `VITE_REVERB_*`). When realtime is off, the item form still sends `expected_updated_at` on save so the API can reject stale overwrites; avatars and lock whispers are degraded. > **Related** > Item CRUD: [Collection items](/docs/collection-items). Team chat (separate channel): [Chat](/docs/chat). Global online users: [Reverb & Echo](/docs/reverb-and-echo). ## Behavior | Feature | Realtime on | Realtime off (degraded) | | ---------------------- | ---------------------------------------------------------- | ------------------------------------------------------ | | Other editors’ avatars | Echo `here` / `joining` / `leaving` on `collection-item.*` | Hidden; short banner notes limited mode | | Soft lock | Whisper `item-lock` + heartbeat (~30s TTL) | Not enforced client-side; server ACL unchanged | | Stale overwrite | Whisper `item-updated-at` + dialog before submit | `expected_updated_at` hidden field + server validation | Editors with collection access join the presence channel when the item form mounts. The first active editor claims a soft lock; others see read-only fields until **Take over editing**. Saving after a remote change prompts **Reload** or **Save anyway**; matching `expected_updated_at` is required unless the user confirms. ## Authorization Channel auth: `routes/channels.php` — active user, `can-show-collections`, item exists, `CollectionPermissionEnforcer::isItemReadable`. Same read gate as opening the item editor. ## Manual test checklist - [ ] Two browsers, same item: each sees the other’s avatar in the form header. - [ ] Second browser: fields read-only until takeover; takeover restores edit. - [ ] Browser A saves; browser B save shows stale dialog (with Reverb) or validation error (without). - [ ] User without collection access cannot authorize the presence channel (403 on `/broadcasting/auth`). ## Code map | Area | Location | | --------------- | ------------------------------------------------------------------------------------------------------ | | Channel | `routes/channels.php` (`collection-item.{collectionId}.{itemId}`) | | Client presence | `resources/js/lib/item-presence.ts`, `hooks/use-item-presence.ts` | | UI | `components/collections/item-editing-presence.tsx`, `pages/collections/items/form.tsx` | | Stale guard | `ItemController::update` + `expected_updated_at` on `UpdateCollectionItemRequest` | | Tests | `tests/Feature/Collections/CollectionItemPresenceChannelTest.php`, `CollectionItemStaleUpdateTest.php` | --- # Meilisearch sync Source: https://docs.externa.qiick.io/docs/meilisearch Externa can optionally sync **published** collection items into **Meilisearch** for public site search. Admin SQL filters and AI embeddings stay unchanged. > **Related** > [Environment variables](/docs/environment-variables) · [Deploy with Docker](/docs/deploy-with-docker) · [Headless starter](/docs/headless-starter) · [Outbound webhooks](/docs/webhooks) ## Enable ```ini MEILISEARCH_ENABLED=true MEILISEARCH_HOST=http://127.0.0.1:7700 MEILISEARCH_API_KEY=masterKey MEILISEARCH_INDEX=externa MEILISEARCH_INDEXABLE_STATUSES=published ``` When `MEILISEARCH_ENABLED` is false or `MEILISEARCH_HOST` is empty, sync is a no-op. Run a **queue worker** (`composer run dev` locally, or `php artisan queue:work` / Horizon). Indexing is queued after published value writes. > If you enabled Meilisearch on a pre-1.2.0 build, the index may exist without a primary key and stay empty. Upgrade to 1.2.0, delete the `externa` index, and republish or resync items; Externa now creates it with `primaryKey=id` ([#179](https://github.com/qiick-io/externa-core/issues/179)). ## What gets indexed | Event | Behavior | | -------------------------------------------- | ---------------------------------------------------------- | | Item create / update of **published** values | Upsert document | | Draft-only save (`draft_data`) | **Not** indexed | | Item soft-delete / force-delete | Delete document | | Item restore | Re-upsert published values | | Bulk AI import | Suppressed (`withoutMeilisearch`); reindex later if needed | If the item `data` bag has a `status` field, only values listed in `MEILISEARCH_INDEXABLE_STATUSES` stay indexed (default `published`). Missing `status` field → always eligible. Document shape (single index, filter by `collection_slug`): ```json { "id": "12", "collection_id": 3, "collection_slug": "posts", "updated_at": "2026-10-02T12:00:00+00:00", "searchable_text": "Hello world", "title": "Hello world", "status": "published" } ``` ## Docker Compose ```bash docker compose --profile meilisearch up --build ``` Service listens on host **`:7700`**. Inside the Compose network set: ```ini MEILISEARCH_HOST=http://meilisearch:7700 MEILISEARCH_API_KEY=masterKey ``` ## Query from a headless site Talk to Meilisearch **directly** from your Next.js (or other) server with a **search-only** API key — do not expose the master key in the browser. ```ts // server-only example const res = await fetch( `${process.env.MEILISEARCH_HOST}/indexes/externa/search`, { method: 'POST', headers: { Authorization: `Bearer ${process.env.MEILISEARCH_SEARCH_KEY}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ q: 'hello', filter: 'collection_slug = posts', }), }, ) ``` See also [Headless starter](/docs/headless-starter) for the Public CMS API path; Meilisearch is an optional parallel index for full-text UX. ## Out of scope - Algolia - Laravel Scout on EAV items - CRDT / collaborative index merge - Replacing admin / Public API `filter[...]` SQL lists --- # Git export sync Source: https://docs.externa.qiick.io/docs/git-export Optional **content-as-code** path: on **publish**, Externa can write published item files into a git worktree and push with a deploy key. For most headless sites, prefer [outbound webhooks](/docs/webhooks) → CI rebuild instead. > **Related** > [Webhook rebuild & CDN purge](/docs/webhook-rebuild-purge) · [Content versioning](/docs/content-versioning) · [Environment variables](/docs/environment-variables) · [Threat model](/docs/threat-model) ## When to use | Need | Prefer | | ---------------------------- | --------------------------------------- | | Rebuild Next/Nuxt/Astro | Webhooks → deploy hook | | Files in a docs/content repo | **Git export** (this page) | | One-off dump | `php artisan git-export:sync --dry-run` | ## Enable ```ini GIT_EXPORT_ENABLED=true GIT_EXPORT_REMOTE_URL=git@github.com:org/content.git GIT_EXPORT_BRANCH=main GIT_EXPORT_SSH_KEY_PATH=/var/externa/keys/content_deploy GIT_EXPORT_FORMAT=json GIT_EXPORT_DRY_RUN=false GIT_EXPORT_COLLECTION_SLUGS=* ``` Queue a worker. Publishing a versioned item (`item.published`) queues `ExportCollectionItemToGitJob`. The remote may be an empty bare repository: on first export Externa initializes the branch and pushes it ([#182](https://github.com/qiick-io/externa-core/issues/182)). Clones use `--template=`, so locked or sandboxed filesystems work ([#179](https://github.com/qiick-io/externa-core/issues/179)). Full sync: ```bash php artisan git-export:sync php artisan git-export:sync --dry-run --collection=posts ``` ## Layout ```text content/{collection_slug}/{item_id}.json # or .md when GIT_EXPORT_FORMAT=markdown ``` ## Threat notes - Use a **write-limited deploy key** for the content repo only — not your app monorepo. - Keep the private key **on disk** (`GIT_EXPORT_SSH_KEY_PATH`); do not paste keys into Project settings or git. - Start with `GIT_EXPORT_DRY_RUN=true` to verify commits without push. - The worker host can push to the remote — treat that host as a secrets boundary. - Bulk AI imports do **not** auto-export every row; run `git-export:sync` after large imports if needed. ## Out of scope - Bidirectional sync / pull-from-git CMS - PR-per-publish workflows - Replacing webhooks for static host rebuilds --- # Content versioning & revisions Source: https://docs.externa.qiick.io/docs/content-versioning Externa uses a **lean content versioning** model: one published bag of field values plus optional `draft_data`. It is **not** Directus-style named version trees. > **Related** > [Collection items](/docs/collection-items) · [Editorial approvals](/docs/approvals) · [Live Preview](/docs/live-preview) · [Project settings](/docs/project-settings) > Renaming a field while items have pending drafts can lead to data loss when those drafts are published. See [Schema changes](/docs/schema-changes) for safe workarounds and planned fixes. ## Enable On the collection edit drawer, turn on **Versioning**. Editors then get Draft / Published workspaces on the item form. ## Draft vs published | Workspace | What you edit | Save | | ------------- | --------------------- | --------------------------------------------------------- | | **Draft** | `draft_data` JSON bag | Saves draft only; published values unchanged | | **Published** | Live field values | Read-only when versioning is on — switch to Draft to edit | Badges on the item form (and a **Draft** chip on the items list) show whether an unpublished draft exists. ### Publish (promote) **Publish** copies `draft_data` into published field values, then clears the draft. Confirm before promote. Requires `can-edit-collections` and item write access. When **Approvals required** is on, Publish is blocked until the item is **approved** — see [Editorial approvals](/docs/approvals). ### Discard draft **Discard draft** drops `draft_data` without touching published values. Confirm before discard. Same permission gate as Publish. Browser localStorage “unsaved draft” (autosave while typing) is separate — use **Discard browser draft** on that banner. ## List filter On versioned collections, **Has draft** filters the items list to rows with unpublished `draft_data`. ## Revisions Every save / draft save / publish / discard / hard restore can record a revision snapshot. Open **History** on the item toolbar (or the full revisions page). Soft-apply compares fields; **Hard** restore writes immediately and clears any draft. Retention (count / days) is configured per collection or as a project default — it prunes old rows; it does not hide the History entry point. ## Scheduled publish / unpublish On a versioned item form, set **Publish at** / **Unpublish at** (app timezone), then **Save schedule**. Badge shows **Scheduled** when a future `publish_at` is set. **Cancel schedule** clears both timestamps. - Due drafts are promoted by `collections:process-schedules` (every minute via Laravel scheduler). - Due `unpublish_at` soft-deletes the item (same as trash). - Requires a running scheduler (`schedule:run` / Compose `scheduler`) — see [Operations](/docs/operations). - Emit webhook `item.published` with `scheduled: true` on scheduled promote ([Outbound webhooks](/docs/webhooks)). ## Ops note Versioning is editorial only. Public CMS API returns published values unless you use [Live Preview](/docs/live-preview) with `version=draft`. Scheduled publish needs the scheduler process running. --- # Editorial approvals Source: https://docs.externa.qiick.io/docs/approvals Optional **editorial approvals** for versioned collections: editor submits a draft → reviewer approves or rejects → publish (promote) only when approved. > **Related** > [Content versioning](/docs/content-versioning) · [Roles & permissions](/docs/roles-and-permissions) · [Outbound webhooks](/docs/webhooks) ## Enable 1. Turn on **Content versioning** on the collection. 2. Turn on **Approvals required** (same collection drawer). Approvals without versioning are rejected. ## States ```text draft ──submit──► in_review ──approve──► approved ──Publish──► published (+ approval resets) │ └──reject(+note)──► rejected ──(edit / resubmit)──► in_review ``` - Saving a draft while `in_review`, `approved`, or `rejected` **invalidates** approval and returns to `draft`. - Discarding a draft resets approval state. - Scheduled publish also requires `approved` when approvals are on. ## Permissions | Permission | Who | | ------------------------- | ------------------------------------------- | | `can-submit-collections` | Submit draft for review | | `can-approve-collections` | Approve or reject (note required on reject) | | `can-edit-collections` | Publish / discard / edit (unchanged) | Assign via **Roles & permissions**. Super Admin / Admin get both submit and approve after `permissions:sync` + role seed / upgrade. ## Review queue On collections with approvals, the items list has an **In review** filter for the awaiting queue. Item form shows status badge + Submit / Approve / Reject actions. ## Webhooks Reserved outbound events (same catalog as [Outbound webhooks](/docs/webhooks)): | Type | When | | ---------------- | ----------------------------------------------------- | | `item.submitted` | Draft submitted for review | | `item.approved` | In-review item approved | | `item.rejected` | In-review item rejected (`rejection_note` in payload) | In-app notifications go to users with `can-approve-collections` on submit, and to the submitter on approve/reject. ## Out of scope Multi-step BPM graphs, e-sign, and Slack/Telegram fan-out (see outbound notifiers) are separate. --- # Translation workspace Source: https://docs.externa.qiick.io/docs/translation-workspace **Translation workspace** is the multi-locale editing UI on the collection item form in externa-core **1.2.1**: one active locale at a time, toolbar completeness, copy/apply helpers, and optional AI translate. It is not a separate form mode — there is no **Translate** toggle and no side-by-side Source/Target panes. > **Related** > [Project settings](/docs/project-settings) (content locales) · [Collection items](/docs/collection-items) · [Field types](/docs/field-types) · [AI configuration](/docs/ai-configuration) · [Public CMS API](/docs/public-cms-api) (`locale` / `include_all_translations`) ## Prerequisites 1. Configure **content locales** under Project settings (at least two codes, e.g. `en` + `it`). 2. Mark fields **Translatable** on the collection schema. The toolbar locale control appears when the item form has fields and more than one content locale. Completeness scores appear when there is also at least one translatable field. ## Toolbar locale In the item form toolbar (`resources/js/pages/collections/items/form.tsx`): - A locale dropdown lists each content locale (flag + name). - When completeness is available, each row shows **filled/total** next to the locale (`data-test="translation-completeness"`). - Scores come from saved form defaults (`contentDefaults`): a locale slot counts as filled only when the value is non-null and not an empty/whitespace string. - Choosing a locale sets the shared content locale so every translatable field switches to that locale (`ContentLocaleProvider` via `defaultLocale`). When the form is editable, the same menu also offers: | Control (UI label) | `en.json` key | Effect | | -------------------------- | ---------------------------------- | -------------------------------------------------------- | | **Apply to all locales** | `collections.localized.applyAll` | Copy the current locale’s values into every other locale | | **Apply to empty locales** | `collections.localized.applyEmpty` | Same, but only where the target locale slot is empty | Saving still writes the usual EAV locale slots — no separate storage path. ## Per-field locale menu Each translatable field is wrapped by `LocalizedField` (`resources/js/components/collections/localized-field.tsx`), rendered from `resources/js/components/collections/dynamic-item-fields.tsx`. Only the active locale’s control is visible; other locale inputs stay in the DOM (hidden) so they still submit with the form. | Control (UI label) | `en.json` key | When | | ----------------------------------- | ----------------------------------------------- | ------------------------------------------- | | **Copy from {locale}** | `collections.localized.copyFrom` | Exactly one other locale | | **Copy from…** | `collections.localized.copyFromMenu` | Two or more other locales (opens a submenu) | | **Apply to all locales** | `collections.localized.applyAll` | Copy actions enabled (not readonly) | | **Apply to empty locales** | `collections.localized.applyEmpty` | Copy actions enabled (not readonly) | | **Translate with AI from {locale}** | `collections.localized.translateWithAiFrom` | AI allowed and exactly one other locale | | **Translate with AI from…** | `collections.localized.translateWithAiFromMenu` | AI allowed and two or more other locales | `{locale}` is the **display name** of the source locale (e.g. “English”), not the code. Copy and AI write into the **active** field locale. Nothing is persisted until you save the item. Busy / feedback strings from the same block: **Translating…**, **Translated**, **No source text to translate**, **AI translation failed**, **AI is offline**. ## AI translate Per-field AI actions show only when the editor has `can-use-ai` and the field is not readonly. Before translating, the client checks AI status; if offline it shows **AI is offline**. Externa posts to `POST /ai/translate-fields` (`resources/js/lib/ai-translate.ts` → `AiTranslateFieldsController`), throttled to **20 requests per minute**, up to **50** fields per call (the item form sends **one** field per request). `ContentTranslator` is locked to the **`local`** provider (`#[Provider('local')]`); configure `LOCAL_AI_*` (optional `LOCAL_AI_TRANSLATE_MODEL` for a faster instruct model). TipTap and markdown fields remount with the translated value. Empty source text stays empty and is not sent. The request is blocking (no streaming). For local thinking models, set `LOCAL_AI_TRANSLATE_MODEL` and keep nginx `fastcgi_read_timeout` high (the Docker image uses **600s**) — see [AI configuration](/docs/ai-configuration). ## Out of scope - A **Translate** toggle, Source/Target locale pickers, side-by-side locked source + editable target, and **Copy all from {locale}** — those existed briefly in core and were removed before v1.2.0; they are not in 1.2.0 / 1.2.1. - Form-wide “translate every field with AI” from the toolbar locale menu — AI translate is per-field only. - Machine translation vendors (DeepL, Google Translate) and Crowdin/Lokalise sync; AI translate uses the configured **local** Externa AI provider, not cloud providers via `AI_DEFAULT_PROVIDER`. --- # Live Preview Source: https://docs.externa.qiick.io/docs/live-preview **Live Preview** opens your headless frontend for an item (draft or published) from the admin item form. It is **not** [Preview as role](/docs/collection-items) (RBAC JSON). > **Related** > [Collection items](/docs/collection-items) · [Content versioning](/docs/content-versioning) · [Public CMS API](/docs/public-cms-api) · [Headless starter](/docs/headless-starter) · [Outbound webhooks](/docs/webhooks) ## Configure 1. **Collection** (edit drawer) → **Live Preview URL**, or 2. **Settings → Project → Live Preview URL (default)** as fallback when the collection field is empty. Template must be `http(s)://…` and may include tokens: | Token | Value | | ----------------------------- | ------------------------------------------------------- | | `{{id}}` | Item id | | `{{slug}}` / `{{collection}}` | Collection slug | | `{{locale}}` | Optional content locale (omitted from query when empty) | | `{{version}}` | `draft` or `published` | | `{{token}}` | Short-lived HMAC preview token (~15 min) | Example: ```text https://www.example.com/preview/{{collection}}/{{id}}?version={{version}}&token={{token}} ``` ## Admin UX On an existing item, the toolbar **Live Preview** button (external-link icon) calls `GET /collections/{collection}/items/{item}/live-preview-url?version=draft|published` (requires `can-show-collections`) and opens the expanded URL in a new tab. Requires a configured template (collection or project default). Version follows the Draft/Published workspace when [content versioning](/docs/collection-items) is on. ## Frontend contract Your site should: 1. Read `token` (and optional `version` / `locale`) from the query 2. `GET {EXTERNA_API_URL}/api/v1/preview?token=…` 3. Render `data` (see response shapes below) ### Auth with origin allowlist The preview endpoint sits behind the Public API origin gate: - **No allowlist** (Settings → Project → Public API → Allowed origins is empty): no key required. - **With an allowlist**: browser fetches from an allowlisted origin work without a key; **server-side fetches** (SSR, API routes, curl) have no `Origin` header and must send `Authorization: Bearer ` (any active key). See [Client types — Origin allowlist](/docs/public-cms-api-types#origin-allowlist). Do **not** put API keys in the preview URL template — keep `{{token}}` in the URL; add the Bearer key to server fetch headers only when an allowlist is set. ### Draft vs published response shape **Published version** (or no draft): same shape as a Public CMS item ([Public CMS API](/docs/public-cms-api)). Translatable fields are flattened to the resolved locale; `?include=` expands files and relations. **Draft version** (versioned collection with a draft): returns raw `draft_data` without flattening or expansion: - `data` is keyed by field name - **Translatable fields are per-locale maps** (`{ "en": "…", "it": "…" }`); the `locale` claim and `meta.locale` do **not** flatten them - No file or relation expansion; no `include` support - No extra item attributes (status, published_at, etc.) Frontend authors must **normalize draft data** themselves: pick the locale from the per-field map, then resolve file/relation ids through the Public CMS API. ### Locale resolution The admin toolbar button sends `version` but **no locale** (`{{locale}}` expands to empty and is dropped from the URL). The preview API resolves the locale like any Public CMS request: 1. Token claim `locale` (if set) 2. Request query `?locale=` (frontend can add this) 3. First `Accept-Language` language (if enabled) 4. Project `default_content_locale` 5. `APP_LOCALE` (if enabled) 6. First enabled locale An explicit `?locale=` that is not enabled → **422**. `meta.locale` in the response shows the resolved locale. For **published** responses, this flattens translatable fields (value fallback: requested → `fallback_content_locales` → `APP_FALLBACK_LOCALE` if enabled → `null`; see [Locale fallback](/docs/configuration#locale-fallback)). For **draft** responses, translatable fields stay as per-locale maps; the frontend should pick the locale using the same order or pass `?locale=` on the preview API call. ```bash curl -sS "http://externa-core.test/api/v1/preview?token=TOKEN" \ -H 'Accept: application/json' # optional: -H "Authorization: Bearer ek_YOUR_SECRET" ``` #### Published response (or no draft) ```json { "data": { "id": 1, "collection_id": 3, "slug": "my-item", "data": { "title": "Hello World", "body": "

Content

" }, "status": "published", "published_at": "2026-09-24T10:00:00Z", "created_at": "2026-09-20T08:00:00Z", "updated_at": "2026-09-24T09:00:00Z" }, "meta": { "preview": true, "version": "published", "locale": "en", "expires_at": "2026-09-24T12:00:00Z" } } ``` #### Draft response (versioned collection, has draft) ```json { "data": { "id": 1, "collection_id": 3, "data": { "title": { "en": "Draft Title", "it": "Titolo Bozza" }, "cover_image": 42, "related_items": [10, 15] }, "created_at": "2026-09-20T08:00:00Z", "updated_at": "2026-09-24T11:30:00Z" }, "meta": { "preview": true, "version": "draft", "locale": "en", "expires_at": "2026-09-24T12:00:00Z" } } ``` ### Errors - **422** `token query parameter is required.` — missing `?token=` - **403** `Invalid or expired preview token.` — token malformed, expired, or bad HMAC - **403** `Origin required or API key.` — origin allowlist is set; server fetch needs Bearer key - **403** `Origin not allowed.` — browser `Origin` not in allowlist - **422** `The locale :locale is not enabled for this project.` — explicit `?locale=` (or token claim) is not enabled - **404** `Preview target not found.` — item/collection deleted or id mismatch > Tokens are HMAC-signed with `APP_KEY` and expire quickly. Anyone with a live token can read that item’s preview payload until expiry — treat preview URLs like temporary share links. Rotate `APP_KEY` only with full awareness of invalidated tokens. ## vs Preview as role | | Live Preview | Preview as role | | -------- | ------------------ | ---------------------------- | | Purpose | Visual frontend | RBAC field visibility (JSON) | | Secret | Signed `{{token}}` | Admin session | | Audience | Editors + frontend | Admins checking permissions | ## Next starter Wire `?token=` into [externa-next-starter](https://github.com/qiick-io/externa-next-starter) or your app; see [Headless starter](/docs/headless-starter). --- # File manager Source: https://docs.externa.qiick.io/docs/file-manager The file manager is the admin UI and JSON API under `/files/*`. `FileController` delegates to `FileService` (and transform/jobs for thumbnails, zip, and duplication). Every request passes through `can.manage.files`, which maps the route (and bulk action) to a specific file permission. > **Related** > Tree / versions / uploads model: [File tree model](/docs/file-tree-model). Disks & limits: [Files & storage](/docs/files-configuration). HTTP tables: [Admin & files API](/docs/admin-api). Async outcomes land in the [notifications bell](/docs/activity-and-notifications). ## Access & UI | Item | Detail | | ---------------- | ----------------------------------------------------------------------- | | Controller | `App\Http\Controllers\Admin\FileController` | | Service | `App\Services\FileService` (plus `FileTransformService` for thumbnails) | | Middleware alias | `can.manage.files` → `App\Http\Middleware\EnsureCanManageFiles` | | Routes | `routes/admin.php` (prefix `/files`) | | Frontend | `resources/js/pages/admin/files/index.tsx` | | Sidebar gate | `can-show-files` | | UI i18n | `files.*` keys in `resources/js/locales/{en,it,de}.json` (shell locale) | The single Inertia page handles browse, selection, uploads, and bulk actions; many operations also expose JSON endpoints for the same UI. Labels follow the signed-in user’s **admin UI locale** (`en` / `it` / `de`) — see [Account settings — Admin UI locale](/docs/account-settings#admin-ui-locale). ## Upload security Uploads run **two** gates (from core **1.3.0**, [#118](https://github.com/qiick-io/externa-core/issues/118) / [PR #267](https://github.com/qiick-io/externa-core/pull/267)): 1. **Extension denylist** — `App\Support\Uploads\ForbiddenUploadExtension` (last path extension wins) on direct upload / replace, chunked init / complete (`file_name`), and rename. 2. **MIME allowlist + magic bytes** — `App\Support\Uploads\MimeTypeValidator` against `config/files.php` `upload_allowed_mimes` / `upload_magic_signatures` (first 16 bytes). Applied on File Manager upload/replace, chunked assembly, Public API `POST /api/v1/files`, chat→Files save, and AI chat attachments. Forbidden extensions include executable / script families such as `php`, `phtml`, `phar`, `exe`, `bat`, `cmd`, `js`, `mjs`, `vbs`, `asp`/`aspx`, `jsp`, `sh`, `ps1`, `dll`, `so`, and related aliases, plus **`svg` / `html` / `htm`** (stored XSS / content-sniff when served from a public disk). Rejection is a validation error (`This file type is not allowed.`). **Breaking:** **SVG, JSON, and XML** are not on the MIME allowlist (SVG/HTML also stay extension-denied). Operators who previously uploaded those types need another pipeline or a deliberate allowlist edit in `config/files.php`. Plain-text fields (display **name**, metadata string values, **tags**) pass through `App\Support\Security\PlainTextSanitizer` (`trim` + `strip_tags`) before persist — stored XSS mitigation for the Files UI, not a full HTML sanitizer. > **Other upload surfaces** > **Team chat** attachments still accept **any** file type at upload time ([Chat](/docs/chat#attachments--limits)). Saving a chat attachment into the Files pool goes through `FileService::uploadFile`, so both gates (including SVG/HTML deny + MIME allowlist) apply then. **AI chat** attachments also go through `MimeTypeValidator` (CSV/TXT/XLSX/PDF remain the practical set) — [AI chat](/docs/ai-chat). ## Public vs private visibility Each file/folder has an `access` field (`files.access`): `public`, `private`, or `null` (inherit from the nearest ancestor). The detail panel **Visibility** select sets Inherit / Public / Private; folders marked private apply to children unless a child overrides. Admin users always see every file. **Bytes:** effective-private files on the local `assets` family live on the **`private_assets`** disk (`storage/app/private/assets`) — **not** under `storage:link` / `/storage/assets/...`. `FileTransformService::publicUrl()` returns `null` for private files; the admin File Manager uses the session-auth `GET /files/{file}/download` (and thumbnail) path. Public files keep `{FILE_PUBLIC_URL_BASE}/storage/assets/...`. After upgrading, run `php artisan files:migrate-private-disk` once to move any legacy private bytes still on the public disk — [Files configuration](/docs/files-configuration). Public CMS API (role **Files access**): `read` covers public files; `read_private` unlocks effective-private files. Without `read_private`, get/content/transform return **403**, list omits private rows, and item expanders return `null` / omit (no id leak). Without `read` on a **public** file field expand (`include=files`), the payload is `{ "id": N, "access": "denied" }` — grant **Files → Read** on the role. Typical **`public`** role: Read ✓, Read private ✕ — see [Public CMS API](/docs/public-cms-api#public-vs-private-files) and [Roles & permissions](/docs/roles-and-permissions#files-access-public-cms-api). ## Leave guard during upload While any upload is `pending` / `uploading`, the files page registers with the admin leave guard. Navigating away shows **Upload in progress** — **Leave anyway** lets uploads finish in the background (global in-memory store); Keep editing stays on the page. Soft/force delete and bulk delete open a confirm dialog before running. Open a file’s detail panel via `?file={id}` on `/files` (synced when opening/closing the panel). See [Routing — Admin UI deep-links](/docs/routing#admin-ui-deep-links-drawers--panels). ## Where used (collection references) The detail panel **Where used** section calls `GET /files/{file}/where-used` (`files.where-used`). `FileWhereUsedScanner` scans `collections_items_values` for image / file / files / blocks fields that store this file id (on-demand; ~30s cache; no dedicated DB index in v1). Each hit links to the item editor (`/collections/{id}/items/{item_id}`) and shows the field name. Soft-deleted items are ignored. Soft/force delete confirms still allow **Delete anyway** when references exist — the dialog shows a reference count when the selected files can be scanned quickly (≤10 files). Open the detail panel for the full list of links. > **Limits** > Where-used is an on-demand LIKE prefilter + exact id match. Nested block file fields are supported; very large catalogs may feel slow until a dedicated index lands. ## Capabilities | Capability | How | | ------------------- | ---------------------------------------------------------------------------------------------- | | Browse | `GET /files/{folder?}` (Inertia) and `GET /files/list` (JSON, page size 50) | | Folders | `POST /files/folders` → `FileService::createFolder` | | Direct upload | `POST /files/upload` → `uploadFile` | | Chunked upload | `POST /files/uploads/init`, `…/chunk`, `…/complete`; `GET …/status` | | Replace / versions | `POST /files/{file}/replace` → `replaceFile` / version resolution | | Copy | `POST /files/{file}/copy` — sync or queued (see below) | | Move / rename | `PATCH …/move`, `PATCH …/rename` | | Metadata | `PATCH /files/{file}` → `updateMetadata` (includes visibility `access`) | | Tags | `GET /files/tags`, `PUT /files/{file}/tags` (Spatie tags) | | Favorites | `POST/DELETE /files/{file}/favorite` | | Soft / force delete | `DELETE /files/{file}`, `POST …/restore`, `DELETE …/force` | | Where used | `GET /files/{file}/where-used` → collection item references | | Attach / detach | `POST …/attach`, `POST …/detach` (polymorphic links) | | Thumbnails | `GET /files/{file}/thumbnail` → `FileTransformService::ensureThumbnail` (images) | | Bulk | `POST /files/bulk` — move, delete, restore, force_delete, favorite/unfavorite, tag/untag, copy | | Single download | `GET /files/{file}/download` | | Zip (async) | `POST /files/download` → `PrepareFilesZipJob`; download `GET /files/zips/{jobId}` | ### Duplicate: sync vs queue `FileController` copy behavior: - **Sync** when the source is **not** a folder and `size <= config('files.duplicate_sync_max_bytes', 52428800)` (~50 MiB). - **Queue** `DuplicateFilesJob` otherwise (folders, larger files, or **bulk** `action=copy`) → HTTP **202** with `{ queued: true, job_id }`. Completed/failed duplication notifies via `FileDuplicationCompletedNotification` / `FileDuplicationFailedNotification`. ### Zip async + notifications `PrepareFilesZipJob` builds the archive; the user is notified with: - `FileZipReadyNotification` (`type`: `file_zip_ready`) — download via `files.zips.download` - `FileZipFailedNotification` (`type`: `file_zip_failed`) ## `can.manage.files` mapping `EnsureCanManageFiles` resolves the required permission with `EffectivePermissionResolver`, then checks it. ### Named route overrides | Route name(s) | Permission | | -------------------------------------------------------------- | -------------------------- | | `files.restore` | `can-restore-files` | | `files.force-delete` | `can-force-delete-files` | | `files.download`, `files.download-many`, `files.zips.download` | `can-download-files` | | `files.favorite`, `files.unfavorite` | `can-favorite-files` | | `files.copy` | `can-copy-files` | | `files.replace` | `can-replace-files` | | `files.tags` | `can-tag-files` | | `files.update` | `can-update-file-metadata` | ### Bulk `action` → permission | `action` | Permission | | ------------------------- | ------------------------ | | `restore` | `can-restore-files` | | `force_delete` | `can-force-delete-files` | | `favorite` / `unfavorite` | `can-favorite-files` | | `tag` / `untag` | `can-tag-files` | | `copy` | `can-copy-files` | | `delete` | `can-delete-files` | | `move` / default | `can-edit-files` | ### Default by HTTP method If no override matches: | Method | Permission | | --------------- | ------------------ | | `GET` / `HEAD` | `can-show-files` | | `POST` | `can-create-files` | | `PUT` / `PATCH` | `can-edit-files` | | `DELETE` | `can-delete-files` | ## File PermissionEnum values ``` can-show-files can-create-files can-edit-files can-delete-files can-restore-files can-force-delete-files can-download-files can-favorite-files can-copy-files can-replace-files can-tag-files can-update-file-metadata ``` ## Route checklist ``` GET /files/list GET /files/tags POST /files/folders POST /files/upload POST /files/bulk POST /files/download GET /files/zips/{jobId} PATCH /files/{file} POST /files/{file}/replace POST /files/{file}/copy POST /files/{file}/favorite DELETE /files/{file}/favorite PUT /files/{file}/tags GET /files/{file}/download GET /files/{file}/thumbnail PATCH /files/{file}/move PATCH /files/{file}/rename DELETE /files/{file} POST /files/{file}/restore DELETE /files/{file}/force POST /files/{file}/attach POST /files/{file}/detach POST /files/uploads/init POST /files/uploads/chunk POST /files/uploads/complete GET /files/uploads/status GET /files/{folder?} # Inertia index (whereNumber folder) ``` ## Jobs | Job | Notifications | | ----------------------------- | --------------------------------------------------------------------------- | | `App\Jobs\PrepareFilesZipJob` | `FileZipReadyNotification`, `FileZipFailedNotification` | | `App\Jobs\DuplicateFilesJob` | `FileDuplicationCompletedNotification`, `FileDuplicationFailedNotification` | ## Source map | Concern | Location | | ---------------- | ---------------------------------------------------------------- | | Controller | `app/Http/Controllers/Admin/FileController.php` | | Middleware | `app/Http/Middleware/EnsureCanManageFiles.php` | | Service | `app/Services/FileService.php` | | Extension deny | `app/Support/Uploads/ForbiddenUploadExtension.php` | | MIME / magic | `app/Support/Uploads/MimeTypeValidator.php` + `config/files.php` | | Plain-text strip | `app/Support/Security/PlainTextSanitizer.php` | | Alias | `bootstrap/app.php` → `can.manage.files` | | Page | `resources/js/pages/admin/files/index.tsx` | | Locale strings | `resources/js/locales/{en,it,de}.json` → `files.*` | --- # AI chat Source: https://docs.externa.qiick.io/docs/ai-chat AI chat is the product surface for `AppAssistant`: a dedicated `/ai` page, a global FAB drawer, streaming replies, conversation pin/truncate/bulk delete, and short-lived file attachments. The whole `/ai/*` authenticated group requires `can-use-ai`. > **Related** > Agent & tool model: [AI assistant model](/docs/ai-assistant). Imports, sync, webhook: [AI imports & sync](/docs/ai-imports-and-sync). Env/provider keys: [AI configuration](/docs/ai-configuration). HTTP: [AI API & webhooks](/docs/ai-api). > Human-to-human threads (item + DM hub at `/chat`) are documented in [Chat (hub & items)](/docs/chat). They use Echo, not SSE. ## Access | Item | Detail | | --------------- | ------------------------------------------------------------------------ | | Permission | `can-use-ai` (`PermissionEnum::CanUseAi`) | | Routes | `routes/ai.php` — middleware `auth`, `verified`, `permission:can-use-ai` | | Page controller | `App\Http\Controllers\Ai\AiPageController` | | Frontend page | `resources/js/pages/ai/index.tsx` | | FAB | `resources/js/components/ai/ai-fab.tsx` in `app-sidebar-layout` | ### AI page | Route | Name | Behavior | | ------------------------ | ----------- | ------------------------------------------------------------ | | `GET /ai` | `ai.index` | Conversation list (25, pinned first) + optional selection | | `GET /ai/{conversation}` | `ai.show` | Same page with selected conversation (UUID) | | `GET /ai/status` | `ai.status` | `AiStatusController` — cached local provider `/models` probe | Props include normalized messages (content, tools, attachments). Empty assistant tool turns get `AiToolTurnSummary`. ### FAB - Exported as `AiFab`, mounted in the app sidebar layout. - Shown only when the user has `can-use-ai`. - Hidden on `/ai` routes. - Drawer: status, presets, attachments, SSE chat via `streamAiChat` → `POST /ai/chat`. ## Ask AI List pages (users, groups, files, collections, items, activity logs) expose **Ask AI** via `AskAiButton` (`resources/js/components/ai/ask-ai-button.tsx`): | Behavior | Detail | | ---------- | ----------------------------------------------------------------------------------------------------- | | Permission | Shown only when `can-use-ai` | | Modes | `icon` (row) or `labeled` (bulk toolbar) | | Action | Opens the FAB composer with a **seeded prompt** (`openAiWithPrompt` in `resources/js/lib/ai-open.ts`) | | Send | **Never auto-sends** — the user reviews/edits and submits | Bulk selection can seed multi-entity prompts (see `tests/Browser/AiContextBulkBrowserTest.php`). ## Conversations Controller: `App\Http\Controllers\Ai\AiConversationController`. Models use laravel/ai conversation tables (`agent_conversations` / `agent_conversation_messages` via `config/ai.php`). | Action | Route | Behavior | | ------------ | ------------------------------------------------ | ------------------------------------------------------------------------- | | Index | `GET /ai/conversations` | List owned conversations | | Store | `POST /ai/conversations` | Create | | Show | `GET /ai/conversations/{conversation}` | Detail | | Destroy | `DELETE /ai/conversations/{conversation}` | Delete one | | Pin | `POST /ai/conversations/{conversation}/pin` | Toggle `pinned_at` null ↔ `now()` | | Truncate | `POST /ai/conversations/{conversation}/truncate` | Body `from_message_id` (UUID); deletes that message and all later | | Bulk destroy | `POST /ai/conversations/bulk-destroy` | Body `ids` (1–100 UUIDs); must be owned; deletes messages + conversations | ## Chat streaming | Item | Detail | | ---------- | ------------------------------------------------------ | | Controller | `App\Http\Controllers\Ai\AiChatController` (invokable) | | Route | `POST /ai/chat` (`ai.chat`) | | Throttle | `throttle:30,1` (**30 requests / minute**) | | Agent | `App\Ai\Agents\AppAssistant` | | Response | SSE `text/event-stream`, ends with `[DONE]` | Validated body: | Field | Rules | | ----------------- | --------------------- | | `message` | Required, max 20000 | | `conversation_id` | Optional UUID | | `attachment_ids` | Optional, max 5 UUIDs | Optional daily cap: `config('ai.daily_prompt_limit')` ← `AI_DAILY_PROMPT_LIMIT` (`0` = off), counted from Spatie activity `log_name=ai`, `event=ai_prompt`. Events may include `{ type: 'conversation', conversation_id }` and `{ type: 'error', … }`. Logging goes through `App\Ai\Support\AiActivityLogger`. ## Attachments | Item | Detail | | ---------- | ---------------------------------------------------- | | Controller | `App\Http\Controllers\Ai\AiChatAttachmentController` | | Route | `POST /ai/attachments` (`ai.attachments.store`) | | Throttle | `throttle:30,1` | | Model | `App\Models\AiChatAttachment` | Allowed extensions: **`csv`**, **`txt`**, **`xlsx`**, **`pdf`** (MIME allowlist matches those families). | Limit | Value | | -------- | ------------------------------------- | | Max size | `MAX_BYTES = 5 * 1024 * 1024` (5 MiB) | | TTL | `TTL_HOURS = 24` | | Disk | `local` | Cleanup: scheduled `ai:cleanup-attachments` daily (`routes/console.php`). ## Presets Presets are **static frontend prompt templates**, not backend entities. | Item | Detail | | ---------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Definition | `resources/js/lib/ai-action-presets.ts` | | Types | `AiActionPreset`, categories `comuni` \| `dati` \| `file` \| `admin` \| `avanzate` | | List | `AI_ACTION_PRESETS` (~25 templates: CSV/Excel/URL import, dry-run, upsert, export, PDF→schema, files, roles/users/groups, audit, async import, scheduled sync, rollback, …) | | Filter | `filterAiActionPresets()` — show when `anyOf` is empty or the user has any listed permission | | UI | `resources/js/components/ai/ai-action-presets-drawer.tsx` | Related suggested-action chips (tool results): `resources/js/lib/ai-suggested-actions.ts`. ## Client helpers | Helper | Path | | ----------------- | ------------------------------ | | Stream / chat API | `resources/js/lib/ai-chat.ts` | | Components | `resources/js/components/ai/*` | ## Source map | Concern | Location | | ------------- | ---------------------------------------------------------------------------------------------------- | | Page | `app/Http/Controllers/Ai/AiPageController.php` | | Chat | `app/Http/Controllers/Ai/AiChatController.php` | | Conversations | `app/Http/Controllers/Ai/AiConversationController.php` | | Attachments | `app/Http/Controllers/Ai/AiChatAttachmentController.php` | | Routes | `routes/ai.php` | | UI | `resources/js/pages/ai/index.tsx`, `components/ai/ai-fab.tsx`, `ask-ai-button.tsx`, `lib/ai-open.ts` | --- # Chat (hub & item threads) Source: https://docs.externa.qiick.io/docs/chat Externa’s **team chat** is separate from [AI chat](/docs/ai-chat). One `chats` model backs **collection item threads** and **direct (private) threads**. Operators use a dedicated hub at `/chat`; editors also open the same thread from an item form drawer. > **Related** > Realtime: [Reverb & Echo](/docs/reverb-and-echo). Unread vs bell: [Activity & notifications](/docs/activity-and-notifications). Privacy / plaintext bodies: [Threat model & hosting](/docs/threat-model). Upload cap: project setting `chat_max_upload_bytes` on `/settings/project` (null = unlimited aside from PHP). Permissions: [Effective permissions](/docs/permissions). > `/ai` and the FAB talk to `AppAssistant` over **SSE**. Team chat uses **JSON + Echo presence** on channel `chat.{uuid}`. Do not confuse the two. ## Product surfaces | Surface | Route / UI | Who | | --------------- | ----------------------------------------------------------------------------- | ------------------------------------------------------------ | | **Chat hub** | Inertia `GET /chat`, `GET /chat/{uuid}` — `resources/js/pages/chat/index.tsx` | Users with `can-show-chat` | | **Item drawer** | Chat panel on collection item form (`item-chat-drawer.tsx`) | Users who can **read** that collection item (collection ACL) | | **Sidebar** | Chat nav entry + unread badge from Inertia `page.props.chat` | `can-show-chat` | Both surfaces talk to the same message/attachment APIs. Item routes under `collections/.../chat` are **aliases** that resolve/create the item thread, then delegate to `ChatMessageController` / `ChatHubController`. ## Data model | Table / model | Role | | --------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `chats` (`App\Models\Chat`) | UUID PK. `kind`: `item` \| `direct`. Item chats link `collection_id` + `collection_item_id`. Soft deletes. | | `chat_participants` | Direct threads: users and/or groups. Optional per-user `archived_at` (hide from inbox). Item threads do not require an explicit participant row for every reader. | | `chat_messages` (`CollectionItemChatMessage`) | Body, mentions JSON, optional `reply_to_id`, pin timestamp, soft deletes. | | `chat_attachments` (`CollectionItemChatAttachment`) | Files on a message (or pending until message create). Optional reduced **preview** for images. | | `chat_reactions` | Emoji reactions per user/message. | | `chat_reads` | Per-user last-read cursor for unread math. | Migrations: create-only chat schema in `database/migrations/2026_08_18_000030_create_chats_tables.php` (and related upload/reads tables). Fresh installs do not need separate “unify” alters. ### Thread kinds **Item (`kind=item`)** - One shared thread per collection item (`ChatService::findOrCreateItemChat`). - Access = collection **item read** ACL (`CollectionPermissionEnforcer` / guard), not only `can-show-chat`. - Hub lists item threads the user can see; drawer opens from the item editor. **Direct (`kind=direct`)** - Private thread among selected users and/or groups (`ChatService::findOrCreateDirectChat`). - Creator is always included. Deduped by sorted participant set. - Access = membership (participant sync via `ChatParticipantSync`). - Creating a DM broadcasts `ChatThreadUpserted` to peers so their hub list updates live. ## Permissions | Concern | Gate | | -------------------------- | ------------------------------------------------------------------------------------ | | Hub pages + `/chat/*` JSON | `permission:can-show-chat` (`PermissionEnum::CanShowChat`) on `routes/chat.php` | | Item alias routes | Collection item read (same as opening the item); defined in `routes/collections.php` | | Inertia shared unread | Only computed when the user has `can-show-chat` (`HandleInertiaRequests`) | A user can participate in an **item** thread via collection ACL without hub access; they will not get the `/chat` shell or sidebar chat entry until `can-show-chat` is granted. ## Hub UI Controller: `App\Http\Controllers\Chat\ChatPageController`. | Route | Name | Behavior | | ------------------ | ------------ | ----------------------------------------------------- | | `GET /chat` | `chat.index` | Split shell; tabs `collection` \| `private` (`?tab=`) | | `GET /chat/{chat}` | `chat.show` | Same shell with selected thread (UUID); marks read | Thread list / create / options: | Route | Name | Purpose | | ------------------------------- | -------------------------- | ----------------------------- | | `GET /chat/threads` | `chat.threads.index` | Paginated hub threads | | `POST /chat/threads` | `chat.threads.store` | Start item or direct thread | | `GET /chat/unread-count` | `chat.unread-count` | Unread totals | | `GET /chat/options/users` | `chat.options.users` | DM user picker | | `GET /chat/options/groups` | `chat.options.groups` | DM group picker | | `GET /chat/options/directory` | `chat.options.directory` | Combined directory | | `GET /chat/options/collections` | `chat.options.collections` | Item-thread collection filter | | `GET /chat/options/items` | `chat.options.items` | Items in a collection | | `GET /chat/options/item-picker` | `chat.options.item-picker` | Combined item picker | Open thread actions (UUID chat): messages CRUD, pin, react, mentions, notify toggle (item), read / stop-viewing, attachments (single + chunked), save-to-files / add-to-field, participants (DM), **archive / unarchive** (direct only), leave/delete thread. ### Private archive (per-user) Archive is **not** a global soft-delete of the chat. It hides the thread from **your** private inbox while you remain a participant. Collection/item threads have **no** archive action (API returns `422`). | Concern | Detail | | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | | Storage | `chat_participants.archived_at` for the viewing user | | Hide list | Default private hub list excludes archived rows | | Toggle UI | Icon-only control next to hub search (private tab): show archived ↔ show inbox (`data-test="chat-filter-archived"`) | | Thread menu | **Archive** / **Unarchive** on direct threads (`ChatDirectThreadMenu`) | | API | `POST /chat/{chat}/archive`, `POST /chat/{chat}/unarchive` (`chat.archive` / `chat.unarchive`) | | List query | `GET /chat/threads?archived=1` (private tab) lists only archived DMs | | Inbound message | A new message to an archived DM **clears** `archived_at` for recipients so the thread resurfaces | | Leave vs archive | **Leave/Delete** removes your participant row (and may soft-delete the chat when no user participants remain). **Archive** keeps membership | ## Messages Controller: `App\Http\Controllers\Chat\ChatMessageController` (also used by `ItemChatController` aliases). | Capability | Detail | | --------------- | ------------------------------------------------------------------------------------------------------------------------------ | | Create | Body (optional if attachments), `@` mentions (item + multi-party DM only; **not** 1:1), optional `reply_to_id`, attachment IDs | | Edit / delete | Author only | | Pin | Toggle `pinned_at` | | Reactions | Toggle emoji; broadcast `ReactionToggled` | | Pagination | Cursor / page load older; FE merges without wiping history on soft refresh | | Optimistic send | Pending bubbles with local previews + upload progress; retry on failure | ### Mentions - Mentions resolved against users (and collection mention syntax where applicable). - `mentioned_user_ids` stored on the message. - **Bell notification** (`ItemChatNotification`): recipients = **@mentioned** users **or** users who opted into collection notify (see below). Not every participant on every message. ### Collection “notify me” - Per-user setting group `item_chat` / key `notify` — list of collection IDs. - Toggle: `PUT .../chat/notify` (hub or item alias). - Opt-in → unread hub updates + optional bell “sent a message”; **default campana already mention-first**. ## Attachments Service: `App\Services\Chat\ChatAttachmentUploadService`. | Path | When | | ------------------------------- | ------------------------------------------------ | | Single POST | Smaller files | | Chunked init → chunk → complete | Large files (bypasses single-request PHP limits) | Limits: | Setting | Scope | | -------------------------------- | ----------------------------------------------------------- | | `project.chat_max_upload_bytes` | Nullable = unlimited (still capped by PHP / chunk pipeline) | | `project.files_max_upload_bytes` | File manager pool (separate) | **Types:** chat accepts **any** file type (no mime/extension allowlist or File Manager denylist). AI chat attachments remain a separate, stricter pipeline. **Save to Files** runs through `FileService::uploadFile`, so the File Manager extension denylist applies at that step — [File manager — Upload security](/docs/file-manager#upload-security). **Images:** server may store `preview_path` / `preview_mime` for list thumbnails; lightbox loads full resolution (with download progress UI). **UX:** - Full-thread dropzone (message list + composer), not only the textarea. - Send-attachments dialog (Telegram-style): media album grid or file list + caption (`@` + emoji). - Shared collage component `ChatMediaAlbum` for modal, optimistic pending, and sent bubbles (flush tiles, no parallel CSS grids). - Incoming bubbles with author label use a minimum width so multi-image albums do not collapse. **Downstream:** save attachment into Files, or add to an item file field (item threads). ## Realtime (Echo) Requires full stack broadcasting — [Reverb & Echo](/docs/reverb-and-echo). | Channel | Auth | Events (broadcastAs) | | ------------------------------ | ------------------------------- | -------------------------------------------------------------------------------- | | Presence `chat.{chatId}` | `ChatService::assertAccessible` | `MessageCreated`, `MessageUpdated`, `MessageDeleted`, `ReactionToggled` | | Private `App.Models.User.{id}` | Same user | `ChatUnreadUpdated`, `ThreadUpserted` (new DM for peer), plus bell notifications | Typing / presence members come from the presence channel authorization payload (`id`, `name`). Frontend store: Zustand chat modules under `resources/js/stores/chat/`; Echo wiring in the drawer / hub page. ## Unread & sounds Service: `App\Services\Chat\ChatUnreadService`. | Kind | Who gets unread | | ------ | --------------------------------------------------------------------------------- | | Direct | Other participants (not author) | | Item | Mentions + collection notify subscribers + users who already posted in the thread | If the user is **actively viewing** that thread (short cache window), unread stays 0 but the payload may still set `play_sound`. Profile prefs (default off): `sound_chat_enabled`, `sound_notifications_enabled`. Shared Inertia prop shape (when permitted): `chat.unread_*` for sidebar badge. ## Activity log On message **create** / **delete**, Spatie activity (`log_name=chat`): | Event | Properties (meta) | | ---------------------- | --------------------------------------------------------------------------------- | | `chat_message` | `chat_id`, `body_preview` (≤ 120 chars), attachment/mention counts, `reply_to_id` | | `chat_message_deleted` | `chat_id`, `message_id`, `body_preview`, `attachment_count` | Bodies are **not** encrypted. Admin UI expands `properties.meta` when Spatie `attribute_changes` is empty — [Activity & notifications](/docs/activity-and-notifications) · [Threat model](/docs/threat-model). ## Source map | Concern | Location | | ------------------ | ------------------------------------------------------------------------------ | | Hub routes | `routes/chat.php` | | Item aliases | `routes/collections.php` (chat group) | | Presence auth | `routes/channels.php` → `chat.{chatId}` | | Pages | `ChatPageController`, `resources/js/pages/chat/index.tsx` | | Messages / uploads | `ChatMessageController`, `ChatAttachmentUploadService` | | Item facade | `ItemChatController` | | ACL / create | `ChatService`, `ChatParticipantSync` | | Unread | `ChatUnreadService` | | UI shell | `item-chat-drawer.tsx`, `components/chat/*` | | Events | `app/Events/ItemChatMessage*.php`, `ChatUnreadUpdated`, `ChatThreadUpserted` | | Tests | `tests/Feature/Chat/*`, `tests/Feature/Collections/CollectionItemChatTest.php` | ## Operator checklist - [ ] Grant `can-show-chat` to roles that need the hub / sidebar. - [ ] Run migrations (`chat_*` create tables on fresh install). - [ ] Enable Reverb + matching `VITE_REVERB_*` for live messages / typing / unread push. - [ ] Know archive is private-DM only; item threads stay visible while the collection item exists. - [ ] Set `chat_max_upload_bytes` in project settings if you want a soft cap (null = unlimited aside from PHP). - [ ] Scheduler/queue healthy if you rely on related file jobs after “save to files”. - [ ] Restrict `can-show-activity-logs` — chat previews appear in the audit UI. --- # AI imports & sync Source: https://docs.externa.qiick.io/docs/ai-imports-and-sync Beyond chat, the assistant can import collection rows from attachments or remote JSON, export data, extract PDF text, schedule sync sources, and roll back the last AI mutation turn. Async work is tracked as import jobs; an external webhook can enqueue the same pipeline with HMAC + `AI_WEBHOOK_TOKEN`. > **Related** > Chat UI & attachments: [AI chat](/docs/ai-chat). Tool registration gates: [AI assistant model](/docs/ai-assistant). Env keys: [AI configuration](/docs/ai-configuration). Routes: [AI API & webhooks](/docs/ai-api). ## Tools Registered on `App\Ai\Agents\AppAssistant` when the user holds the matching permission (then re-checked inside the tool). | Tool class | Gate | Role | | --------------------- | ------------------------------- | ---------------------------------------------------------------------------------------------- | | `ImportCollectionCsv` | `can-create-collections` | CSV/TXT/XLSX attachment → collection; max 500 rows sync; async if `async` or rows > 200 | | `ImportRemoteJson` | `can-create-collections` | Remote JSON URL via `SafeRemoteUrlValidator`; limit default 200, max 500; same async threshold | | `ExportCollection` | `can-show-collections` | Export up to 500 rows as `csv` or `json` | | `ExtractPdfText` | `can-create-collections` | PDF attachment text, max 20 000 characters | | `RollbackLastAiTurn` | `can-delete-collections` | Soft-delete creations from the last AI turn (`ai_prompt` → following `ai_mutation`) | | `GetImportJobStatus` | `can-create-collections` | Poll job status (mirror of HTTP status) | | `ManageAiSyncSources` | create vs edit collection perms | List/create/update/enable/disable/delete sync sources | Paths: `app/Ai/Tools/{ToolName}.php`. ### `dry_run` and `upsert_key` Both `ImportCollectionCsv` and `ImportRemoteJson` accept: | Option | Meaning | | ------------ | ----------------------------------------------- | | `dry_run` | Preview inferred schema / sample; **no writes** | | `upsert_key` | Field name used to choose update vs create | Also accepted on the collection-import webhook and stored on `AiSyncSource` for scheduled runs. `ImportCollectionJob` carries `upsertKey` and `dryRun` in its constructor. ### CMS connector profiles Optional `profile` on `ImportRemoteJson` (and queued jobs): | Value | Effect | | ----------- | --------------------------------------------------------------------------------------- | | `directus` | Strip Directus system audit fields; promote first `translations[]` scalars onto the row | | `wordpress` | Unwrap `*.rendered` envelopes before flatten | Migration guide: [CMS import connectors](/docs/cms-import). ## Import job status | Item | Detail | | ---------------- | --------------------------------------------------------------------------------- | | Job | `App\Jobs\ImportCollectionJob` | | HTTP | `GET /ai/import-jobs/{jobId}` → `ImportJobStatusController` | | Name | `ai.import-jobs.show` | | Extra middleware | `permission:can-create-collections` (in addition to `can-use-ai` on the group) | | Status source | `ImportCollectionJob::status($jobId)` — cache key `ai:import-job:{uuid}`, TTL 24h | Status payload includes `job_id`, `status` (`queued` \| `running` \| `done` \| `failed`), `processed`, `total`, `message`, and `result` (user id stripped for the response). ## Webhook | Item | Detail | | ------------ | ----------------------------------------------------------- | | Route | `POST /ai/webhooks/collection-import` | | Controller | `App\Http\Controllers\Ai\CollectionImportWebhookController` | | Name | `ai.webhooks.collection-import` | | Middleware | **Only** `throttle:30,1` (no session auth) | | Token config | `config('ai.webhook_token')` ← **`AI_WEBHOOK_TOKEN`** | Auth (from **1.2.2**, [#214](https://github.com/qiick-io/externa-core/pull/214)): header **`X-AI-Webhook-Signature`** = `sha256=` + HMAC-SHA256(**raw body only**, `AI_WEBHOOK_TOKEN`). Empty configured token outside `local` → **401**; mismatch → **401**. Bearer alone rejected. See [AI API](/docs/ai-api#webhook-hmac-auth). Body (`CollectionImportWebhookRequest`): | Field | Rules | | --------------- | ------------ | | `collection_id` | Required | | `records` | Array, 1–500 | | `upsert_key` | Optional | Uses `ImportsCollectionRecords`; webhook path does not write AI mutation logs (`logAiMutation` no-op). ## Scheduled sync (`AiSyncSource`) | Item | Detail | | -------- | --------------------------------------------------------------------------------------------------------------------------------------- | | Model | `App\Models\AiSyncSource` | | Fillable | `user_id`, `collection_id`, `url`, `upsert_key`, `interval_minutes`, `auth_bearer` (encrypted), `last_run_at`, `last_status`, `enabled` | | Command | `ai:run-sync-sources` → `App\Console\Commands\RunAiSyncSourcesCommand` | | Schedule | `Schedule::command('ai:run-sync-sources')->everyMinute()` in `routes/console.php` | Eligible sources enqueue `ImportCollectionJob` with `sourceType: 'remote_json'` and `syncSourceId`. ## Remote URL safety | Item | Detail | | --------- | ------------------------------------------------------------------------------------------------ | | Validator | `App\Ai\Support\SafeRemoteUrlValidator` | | Used by | `ImportRemoteJson`, `ManageAiSyncSources` | | Rules | http/https only; no URL credentials; block localhost / metadata / private IPs; DNS resolve check | | Allowlist | `config('ai.remote_import_hosts')` ← **`AI_REMOTE_IMPORT_HOSTS`** (comma-separated) | If the allowlist is non-empty, the host must be listed. If empty, any non-blocked host is allowed. ## Related env vars | Env | Config key | | ------------------------ | ------------------------ | | `AI_REMOTE_IMPORT_HOSTS` | `ai.remote_import_hosts` | | `AI_WEBHOOK_TOKEN` | `ai.webhook_token` | | `AI_DAILY_PROMPT_LIMIT` | `ai.daily_prompt_limit` | (Plus provider keys documented under [AI configuration](/docs/ai-configuration).) ## Source map | Concern | Location | | ----------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | | Tools | `app/Ai/Tools/ImportCollectionCsv.php`, `ImportRemoteJson.php`, `ExportCollection.php`, `ExtractPdfText.php`, `RollbackLastAiTurn.php`, … | | Job | `app/Jobs/ImportCollectionJob.php` | | Webhook | `app/Http/Controllers/Ai/CollectionImportWebhookController.php` | | Job status | `app/Http/Controllers/Ai/ImportJobStatusController.php` | | Sync command | `app/Console/Commands/RunAiSyncSourcesCommand.php` | | Validator | `app/Ai/Support/SafeRemoteUrlValidator.php` | | Routes / schedule | `routes/ai.php`, `routes/console.php` | --- # CMS import connectors Source: https://docs.externa.qiick.io/docs/cms-import Guided migration from an external CMS into Externa collections using the existing AI remote JSON import path — not a second sync engine. > **Related** > Pipeline details: [AI imports & sync](/docs/ai-imports-and-sync). Host allowlist: [AI configuration](/docs/ai-configuration) / [Environment variables](/docs/environment-variables). Webhook alternative: [AI API](/docs/ai-api). ## Profiles `ImportRemoteJson` accepts optional `profile`: | Profile | Reshape before flatten | | ----------- | ------------------------------------------------------------------------------------------------------------------------------ | | `directus` | Drop Directus system fields (`user_created`, `date_*`, …); promote first translation scalars (`title`, `body`, …) onto the row | | `wordpress` | Unwrap `{ rendered: "…" }` envelopes on first-level keys (`title`, `content`, `excerpt`) | Without `profile`, behavior is unchanged: extract list → flatten (nested → JSON string). ## Recipe (Directus) 1. Allowlist the CMS host in `AI_REMOTE_IMPORT_HOSTS`. 2. Dry-run: ```text ImportRemoteJson url=https://cms.example/items/pages profile=directus dry_run=true upsert_key=slug auth_bearer=… ``` 3. Confirm proposed fields / preview rows (zero writes). 4. Re-run with `dry_run=false` (or omit). Large payloads queue via `ImportCollectionJob`. AI chat presets: **Import from Directus URL** / **Import from WordPress REST**. ## Recipe (WordPress REST) Same flow with `profile=wordpress` and a `/wp-json/wp/v2/posts` (or custom post type) URL. Application Passwords: pass `auth_header` as `Basic …` when Bearer is not used. ## Limits & gaps | Topic | Detail | | ---------- | ----------------------------------------------------------------------------------------- | | Row caps | Sync/async thresholds (~200 queue, max 500 per call) — chunk URLs or repeat | | Media | Featured images / file UUIDs are **not** auto-downloaded into Externa `File` rows in v1 | | Relations | Stored as JSON strings unless you reshape upstream | | Locales | Directus `translations[]` → first locale scalars only; full locale matrix is out of scope | | Write-back | One-way import only | Prefer [outbound webhooks](/docs/webhooks) + CI when the goal is rebuild/purge, not content migration. --- # Activity & notifications Source: https://docs.externa.qiick.io/docs/activity-and-notifications Externa records domain and auth events with Spatie Activitylog, exposes an Activity Log admin page (and an AI query tool), and delivers file job outcomes through database notifications in the sidebar bell. > **Related** > Privacy / hosting: [Threat model & hosting](/docs/threat-model). Team chat events: [Chat (hub & items)](/docs/chat). Dashboard widgets that read activity: [Dashboard](/docs/dashboard). File jobs that emit notifications: [File manager](/docs/file-manager). Realtime bell / presence: [Reverb & Echo](/docs/reverb-and-echo). AI logging helpers: [AI assistant model](/docs/ai-assistant). ## Spatie Activitylog | Item | Detail | | ---------------- | ------------------------------------------------- | | Package model | `Spatie\Activitylog\Models\Activity` | | Config | `config/activitylog.php` | | Env toggle | `ACTIVITYLOG_ENABLED` | | Retention | `clean_after_days` = 365 | | Default log name | `default` | | Cleanup | `Schedule::command('activitylog:clean')->daily()` | ### LogsApplicationActivity Trait: `App\Concerns\LogsApplicationActivity` (wraps Spatie `LogsActivity`). Options: `logAll()`, `logOnlyDirty()`, `dontLogEmptyChanges()`, `useLogName('default')`. Used on: `User`, `UserGroup`, `Role`, `Collection`, `CollectionField`, `CollectionItem`, `File`, `FileVersion`, `FileUpload`, `ApiKey` (with secret exclusions where applicable). Item editors surface a compact [activity strip](/docs/collection-items#activity-strip) (last update + revisions/activity links). Field-level history remains in [Item History](/docs/collection-items#item-history-revisions), not Spatie attribute diffs. ### Privacy defaults Activity properties and chat bodies are **plaintext** in the database (no column encryption). Chat create/delete events store a lean `body_preview` (≤ 120 characters) plus counts — not a full encrypted transcript. Restrict who has `can-show-activity-logs`. Operators should encrypt DB volumes and backups; see [Threat model & hosting](/docs/threat-model). ### Auth login / logout / failed Registered in `AppServiceProvider::registerAuthActivityListeners()`: | Event | Log name | Activity event | Description / notes | | ------------------------------ | -------- | -------------- | -------------------------------------------------------------------- | | `Illuminate\Auth\Events\Login` | `auth` | `login` | `User logged in`; also updates `last_login_at` / `last_login_ip` | | `Logout` | `auth` | `logout` | `User logged out` | | `Failed` | `auth` | `failed` | `Failed login attempt`; `causedByAnonymous()`, property `identifier` | ### AI activity `App\Ai\Support\AiActivityLogger` writes `log_name=ai` with events `ai_prompt`, `ai_response`, `ai_tool`, `ai_mutation`. ## Activity Log UI | Item | Detail | | ---------- | ------------------------------------------------------- | | Controller | `App\Http\Controllers\Admin\ActivityLogController` | | Route | `GET /activity-logs` (`activity-logs.index`) | | Middleware | `auth`, `verified`, `permission:can-show-activity-logs` | | Permission | `can-show-activity-logs` | | Page | `resources/js/pages/admin/activity-logs/index.tsx` | Filters: search, `user_ids[]` (multi; legacy `user_id` still accepted), `event`, `log_name`, `date_from`, `date_to`, optional `subject_type` + `subject_id` (used by the item form **Activity** link). ### Export CSV / JSON Same permission and filters as the index. Toolbar **Export CSV** / **Export JSON** hit: `GET /activity-logs/export?format=csv|json&…filters` Streamed download (UTF-8 CSV with BOM for Excel). Cap: **10 000** newest matching rows (sync stream — queue/async if you need larger dumps). Columns: `id`, `created_at`, `log_name`, `event`, `description`, `causer_id`, `causer_email`, `subject_type`, `subject_id`, `properties` (JSON string). Log names include `default`, `auth`, `ai`, `chat`, `settings`. Filterable events (`App\Support\Activity\FilterableActivityEvents`): ``` created | updated | deleted | restored login | logout | failed ai_prompt | ai_response | ai_tool | ai_mutation settings_updated chat_message | chat_message_deleted permissions_synced | roles_synced | groups_synced | members_synced collection_permissions_synced | file_permissions_synced ``` Expanded rows show Spatie `attribute_changes` when present, otherwise manual `properties.meta` (e.g. chat `body_preview`). ## AI QueryActivityLogs | Item | Detail | | ------ | -------------------------------- | | Tool | `App\Ai\Tools\QueryActivityLogs` | | Gate | `can-show-activity-logs` | | Limits | Default 25, max 50 | | Dates | `YYYY-MM-DD` only | | Events | Same set as the UI filters | Registered on `AppAssistant` when the user has `can-show-activity-logs`. ## Notifications (database + bell) Most use trait `BroadcastsWithDatabase`: `via(['database'])`, plus `broadcast` when `BROADCAST_CONNECTION` is not `log`/`null`. | Class | `data.type` | Emitted from | | -------------------------------------------------------- | ---------------------------- | ------------------------------ | | `App\Notifications\FileZipReadyNotification` | `file_zip_ready` | `PrepareFilesZipJob` | | `App\Notifications\FileZipFailedNotification` | `file_zip_failed` | `PrepareFilesZipJob` | | `App\Notifications\FileDuplicationCompletedNotification` | `file_duplication_completed` | `DuplicateFilesJob` | | `App\Notifications\FileDuplicationFailedNotification` | `file_duplication_failed` | `DuplicateFilesJob` | | `App\Notifications\ItemChatNotification` | `chat` | Team chat mentions / notify-me | Zip-ready notifications point at download route `files.zips.download`. Chat bell rows deep-link into the hub / item thread. Realtime delivery (Echo private channel `App.Models.User.{id}`) is documented in [Reverb & Echo](/docs/reverb-and-echo). When `BROADCAST_CONNECTION` is `log`/`null`, the sidebar bell keeps its **60s unread poll**; with Reverb enabled there is **no** periodic poll — Echo `.notification()` updates the badge. ## Bell API | Item | Detail | | ---------- | ---------------------------------------------------------- | | Controller | `App\Http\Controllers\NotificationController` | | Routes | `routes/web.php` | | Middleware | `auth` + `verified` only — **no** activity-log permission | | UI | `NotificationsBell` + `NotificationsDrawer` in the sidebar | | Client | `resources/js/lib/notifications-api.ts` | | Method | Path | Name | Behavior | | --- | --- | --- | | `GET` | `/notifications` | `notifications.index` | Paginate 20 | | `GET` | `/notifications/unread-count` | `notifications.unread-count` | Unread count | | `POST` | `/notifications/read` | `notifications.read` | Mark read (`ids` and/or all unread) | | `POST` | `/notifications/unread` | `notifications.unread` | Mark unread (`ids` required, non-empty) | ### Drawer UX - Opening the drawer **does not** auto-mark page-1 notifications as read (badge stays until the user acts). - **Mark all as read** is an explicit header action (`data-test="notifications-mark-all-read"`). - **Mark as unread** appears on already-read rows (`data-test="notifications-mark-unread"`) and restores the badge count from the API response. There is no dedicated notification permission in `PermissionEnum`; any authenticated verified user can use the bell API for their own notifications. ## Permissions summary | Surface | Permission | | --------------------------------------- | --------------------------------------- | | Activity Log page + `QueryActivityLogs` | `can-show-activity-logs` | | Notifications bell / API | Authenticated + verified (no enum gate) | ## Source map | Concern | Location | | ------------------- | ------------------------------------------------------------------------------------------------------------ | | Activity controller | `app/Http/Controllers/Admin/ActivityLogController.php` | | Logging trait | `app/Concerns/LogsApplicationActivity.php` | | Auth listeners | `AppServiceProvider::registerAuthActivityListeners` | | AI query tool | `app/Ai/Tools/QueryActivityLogs.php` | | Notifications | `app/Notifications/FileZip*Notification.php`, `FileDuplication*Notification.php`, `ItemChatNotification.php` | | Bell API | `app/Http/Controllers/NotificationController.php` | --- # Account settings Source: https://docs.externa.qiick.io/docs/account-settings Account settings let the signed-in user manage profile fields, password, Fortify two-factor authentication / passkeys, and admin UI locale. Routes live under `/settings/*` with session auth. Project branding and content locales are **not** here — see [Project settings](/docs/project-settings). > **Related** > Auth stack & Fortify features: [Effective permissions](/docs/permissions) and [Architecture](/docs/architecture). Passkeys (register, sign-in, MFA): [Passkeys](/docs/passkeys). Soft-delete of the account uses the same `User` model as [Users & groups](/docs/users-and-groups). ## Layout & routes | Item | Detail | | ---------- | -------------------------------------------- | | Route file | `routes/settings.php` | | Layout | `resources/js/layouts/settings/layout.tsx` | | Entry | `/settings` redirects to `/settings/profile` | | Method | Path | Name | Middleware | | -------- | -------------------- | ---------------------- | -------------------------------------------------- | | `GET` | `/settings/profile` | `profile.edit` | `auth` | | `PATCH` | `/settings/profile` | `profile.update` | `auth` | | `PATCH` | `/settings/locale` | `locale.update` | `auth` | | `DELETE` | `/settings/profile` | `profile.destroy` | `auth`, `verified` | | `GET` | `/settings/security` | `security.edit` | `auth`, `verified` (+ optional `password.confirm`) | | `PUT` | `/settings/password` | `user-password.update` | `auth`, `verified`, `throttle:6,1` | Project / branding routes (`/settings/project`, `/settings/appearance`) require `can-manage-project-settings` — documented under [Project settings](/docs/project-settings). ## Profile | Item | Detail | | ---------- | ------------------------------------------------- | | Controller | `App\Http\Controllers\Settings\ProfileController` | | Page | `resources/js/pages/settings/profile.tsx` | ### Edit / update `edit` renders Inertia `settings/profile` with props `mustVerifyEmail` and `status`. `update` uses `ProfileUpdateRequest` / `ProfileValidationRules`: | Field | Rules | | ------------ | ------------------------------ | | `first_name` | Required | | `last_name` | Required | | `email` | Required, unique ignoring self | If the email changes, `email_verified_at` is set to `null`. ### Delete account `destroy` uses `ProfileDeleteRequest` (`password` must be `current_password`), then logs out, soft-deletes the user, invalidates the session, and redirects to `/`. ## Admin UI locale | Item | Detail | | ---------- | ------------------------------------------------------- | | Route | `PATCH /settings/locale` (`locale.update`) | | Controller | `LocaleController` | | Cookie | `locale` (not encrypted — see [Routing](/docs/routing)) | This changes the **shell language**. Supported admin UI locales (same key set in `resources/js/locales/{en,it,de}.json`, loaded via `resources/js/lib/i18n.ts` / `config/i18n.php`): | Code | Language | | ---- | -------------------------------------------- | | `en` | English (default / source of truth for keys) | | `it` | Italian | | `de` | German | Missing keys fail a Pest parity test (`I18nLocaleParityTest`). It does **not** change project **content locales**. File Manager copy lives under the `files.*` namespace — see [File manager](/docs/file-manager). ## Password, 2FA & passkeys (Fortify) | Item | Detail | | -------------- | -------------------------------------------------- | | Controller | `App\Http\Controllers\Settings\SecurityController` | | Page | `resources/js/pages/settings/security.tsx` | | Password route | `PUT /settings/password` | ### Password `PasswordUpdateRequest`: `current_password`, `password` + `confirmed` + `Password::default()`. Throttled at **6/minute**. ### Two-factor authentication Configured in `config/fortify.php`: ```php Features::twoFactorAuthentication([ 'confirm' => true, 'confirmPassword' => true, ]); ``` `SecurityController` implements `HasMiddleware`: when Fortify 2FA or passkeys have `confirmPassword` enabled, `password.confirm` applies to `edit` only. The security page surfaces Fortify 2FA props when the feature is on; enable/disable flows use Fortify’s two-factor routes (`@/routes/two-factor`). User model trait: `TwoFactorAuthenticatable`. ### Passkeys Full guide: [Passkeys](/docs/passkeys) (secure context / HTTP vs HTTPS, register, sign-in, [confirm password with passkey](/docs/passkeys#confirm-password-with-a-passkey), `two_factor_required`, `APP_URL` / RP, Herd TLS, migration). Configured in `config/fortify.php`: ```php Features::passkeys([ 'confirmPassword' => true, ]); ``` Plus the `passkeys` config block (`relying_party_id`, `allowed_origins`, `user_handle_secret`, `timeout`) derived from `APP_URL` / `APP_KEY`. User implements `PasskeyUser` and uses `PasskeyAuthenticatable` (alongside `TwoFactorAuthenticatable`). **Register / manage** on **Settings → Security**: confirm password or **Confirm with passkey** (when enrolled and `confirmPassword` is on) → name the device → **Add passkey** → platform authenticator prompt → list shows name / created / last used; **Delete** removes that credential only for the signed-in user (`@laravel/passkeys` + Fortify `passkey.*` routes). **Sign in** on `auth/login` via **Sign in with passkey** (`usePasskeyVerify`) — passwordless, **no TOTP challenge** after a successful assertion. `PasskeyLoginResponse` redirects with the same `HomePath` resolver as password login. Email + password remains available; if the user has confirmed TOTP, the Fortify challenge still applies on that path. **Confirm password** (sensitive actions / `password.confirm`): password form is always available; **Confirm with passkey** only when enrolled + feature on + secure context — full behavior, abort, and HTTP fallback: [Confirm password with a passkey](/docs/passkeys#confirm-password-with-a-passkey). **MFA:** project `two_factor_required` is satisfied by **confirmed TOTP or ≥1 passkey**. When the setting is on and the user has neither, `/settings/security` shows banner `twoFactorEnforcedForUser` — see [Project settings](/docs/project-settings#require-two-factor-authentication) and the [decision table](/docs/passkeys#relationship-with-2fa--two_factor_required). ## Personal theme (light / dark) Light/dark preference is **client-driven** via `localStorage` and the `appearance` cookie. `HandleAppearance` applies it on subsequent requests. UI: `AppearanceTabs` / `useAppearance` (typically in the user menu). This is **not** the Settings → Appearance page (that is project branding and requires `can-manage-project-settings`). High contrast / reduce motion live in the same user menu under **Accessibility** — see [Accessibility](/docs/accessibility). ## Email verification | Item | Detail | | --------------- | ------------------------------------------------------- | | Fortify feature | `Features::emailVerification()` in `config/fortify.php` | | Verify UI | `resources/js/pages/auth/verify-email.tsx` | | App routes | Many product routes require `verified` | > The `User` model may not implement `MustVerifyEmail` even when Fortify’s feature is enabled — many routes still use the `verified` middleware. Confirm product expectations before relying on verification alone. ## Source map | Concern | Location | | --------------------------- | ------------------------------------------------------------------------ | | Routes | `routes/settings.php` | | Profile / security / locale | `app/Http/Controllers/Settings/{Profile,Security,Locale}Controller.php` | | Passkeys (full guide) | [Passkeys](/docs/passkeys) | | Theme hook | `resources/js/hooks/use-appearance.ts`, `components/appearance-tabs.tsx` | | Appearance middleware | `HandleAppearance` (web stack in `bootstrap/app.php`) | | Project branding | [Project settings](/docs/project-settings) | --- # Passkeys Source: https://docs.externa.qiick.io/docs/passkeys Externa supports **passkeys** (WebAuthn) via official **Laravel Fortify** + **`@laravel/passkeys`**. A passkey is passwordless sign-in and also counts as MFA when the project requires two-factor authentication. > **Related** > Manage UI: [Account settings](/docs/account-settings#password-2fa--passkeys-fortify). Re-auth before Security: [Confirm password with a passkey](#confirm-password-with-a-passkey). Project MFA gate: [Project settings — Require 2FA](/docs/project-settings#require-two-factor-authentication). Pre-ship gates: [Security checklist](/docs/security-checklist). Fortify config: [Application config](/docs/configuration#fortify-configfortifyphp). Env: [Environment variables — `APP_URL`](/docs/environment-variables). > **HTTP vs passkeys** > **Externa itself works on HTTP** — login with password, Fortify TOTP 2FA, password confirm (typed password), admin UI, APIs, everything **except** WebAuthn/passkeys. Passkeys need a [secure context](https://developer.mozilla.org/en-US/docs/Web/Security/Secure_Contexts) (browser rule, not an Externa or Brave bug). See [Requirements](#requirements). ## What passkeys are in Externa | Concern | Behavior | | ---------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Package stack | `laravel/fortify` feature `Features::passkeys()` + `@laravel/passkeys` (React hooks `usePasskeyRegister` / `usePasskeyVerify`) | | Sign-in | Passwordless: assert a registered credential on the login page — **no email/password and no TOTP challenge** after a successful assertion | | Password confirm | `/user/confirm-password`: password form always; **Confirm with passkey** only if ≥1 passkey + feature on + secure context (`/passkeys/confirm/*`) — same session `passwordConfirmed()` + intended redirect; see [below](#confirm-password-with-a-passkey) | | MFA for projects | Satisfies project setting `two_factor_required` the same way confirmed TOTP does (`hasPasskeysEnabled()` → ≥1 passkey) | | Account password | Still required for password login, password changes, and (by default) opening Security settings (`confirmPassword`) — passkey is an alternate confirm path when enrolled | | Vendor policy | Use **only** official Fortify / `@laravel/passkeys` — no forks or local patches of those packages | User model: implements `PasskeyUser`, uses `PasskeyAuthenticatable` (alongside `TwoFactorAuthenticatable`). Successful passkey login uses `PasskeyLoginResponse`, which redirects via the same `HomePath` resolver as password login. ## Register a passkey (Security settings) | Item | Detail | | ------------ | --------------------------------------------------------------------------------------------------- | | Page | **Settings → Security** (`GET /settings/security`, `security.edit`) | | UI | `resources/js/pages/settings/security.tsx` (`data-test="passkeys-section"`) | | Feature flag | `Features::canManagePasskeys()` — section hidden when passkeys are disabled in `config/fortify.php` | ### Steps 1. Sign in (email + password, or an existing passkey). 2. Open **Settings → Security**. With default Fortify options, `password.confirm` runs first — confirm your account password **or use Confirm with passkey** if you already have a passkey ([details](#confirm-password-with-a-passkey)), then the security page loads. 3. In **Passkeys**, enter a **device name** (e.g. “MacBook Touch ID”, “Phone”). 4. Click **Add passkey**. The browser/platform authenticator prompt appears (Touch ID, Windows Hello, security key, etc.). 5. Complete the prompt. The list reloads with the new passkey (`id`, `name`, `created_at`, `last_used_at`). ### List and delete - Registered passkeys are listed on the same page. - **Delete** calls the Fortify passkey destroy route (`passkey.destroy`). Users can delete only their own passkeys. - If the project has `two_factor_required` on and this was the user’s only MFA factor (no confirmed TOTP and no remaining passkeys), `EnsureTwoFactorIsEnabled` will send them back to Security until they enroll again. > The UI checks platform support (`isSupported` from `@laravel/passkeys/react`). That is false on insecure HTTP origins (e.g. `http://externa-core.test`) and on browsers without WebAuthn — CTAs hide or show an unsupported message. Use HTTPS (or `localhost` / `*.localhost`), plus a current browser with a platform authenticator or hardware key. See [Requirements](#requirements). ## Sign in ### Passkey (passwordless) 1. Open the login page (`resources/js/pages/auth/login.tsx`). 2. If passkeys are enabled and supported, a **Sign in with passkey** button appears (`data-test="passkey-login-button"`). Sign-in is **click-only** (no WebAuthn autofill on mount — avoids hung `isLoading` on some browsers). 3. Click the button. Complete the authenticator prompt. 4. On success, the client follows `PasskeyLoginResponse`’s redirect (JSON `redirect` URL or Inertia visit) — same home as password login. There is **no** Fortify TOTP challenge after a successful passkey assertion. The credential _is_ the second factor. ### Email + password (+ TOTP when required) 1. Submit email and password as usual. 2. If the user has **confirmed Fortify TOTP** enabled, Fortify’s two-factor challenge runs next (unless this session already satisfied 2FA). 3. If the user has **only passkeys** (no TOTP) and signs in with password, password login does **not** automatically use the passkey — they still need a password (and TOTP if TOTP is enabled). Passkey is an alternate login path, not a replacement for the password field on that form. | Login method | Password needed? | TOTP challenge? | | --------------------------------------------- | ---------------- | ----------------------- | | Passkey assertion | No | No | | Email + password, TOTP off, may have passkeys | Yes | No | | Email + password, TOTP confirmed | Yes | Yes (Fortify challenge) | ## Confirm password with a passkey Fortify’s `password.confirm` middleware sends the user to `/user/confirm-password` before **sensitive actions** that opt into recent re-auth — notably **Settings → Security** when `confirmPassword` is on for 2FA and/or passkeys (`SecurityController` + `config/fortify.php`). UI: `resources/js/pages/auth/confirm-password.tsx`; props from `FortifyServiceProvider` (`canManagePasskeys`, `hasPasskeys`). | Path | When available | Behavior | | ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------- | | Password form | Always | Primary path — type password → **Confirm password** (`POST /user/confirm-password`). Works on **HTTP and HTTPS**; no WebAuthn required. | | **Confirm with passkey** | Only when **all** of: passkeys feature on (`canManagePasskeys`), user has ≥1 passkey (`hasPasskeys` / `hasPasskeysEnabled()`), and browser secure context + WebAuthn (`isSupported`) | Outline CTA (`data-test="passkey-confirm-button"`) under an “or confirm with passkey” divider | Ceremony details (passkey path): 1. **Click-only** — `usePasskeyVerify({ autofill: false })` against `GET /passkeys/confirm/options` + `POST /passkeys/confirm`. No WebAuthn autofill on mount (same hung-`isLoading` lesson as login). 2. Success marks the session password-confirmed (`passwordConfirmed()`) and follows the same intended redirect as typing the password (JSON `redirect`, else `/`). 3. **Abort** (dismiss authenticator) → cancelled message (`UserCancelledError`); other errors surface as-is. The password form stays usable while a ceremony runs and after cancel. > **HTTP fallback** > On insecure HTTP (e.g. `http://externa-core.test`), the passkey CTA is hidden — expected. Operators still clear `password.confirm` by typing the account password. Prefer [local HTTPS](#local-https-herd-valet-for-passkey-testing) only when you need to exercise the passkey confirm path. Account-settings summary: [Password, 2FA & passkeys](/docs/account-settings#password-2fa--passkeys-fortify). ## Relationship with 2FA / `two_factor_required` Project setting `two_factor_required` is enforced by `EnsureTwoFactorIsEnabled` on the `web` stack. A user is “MFA complete” when **either**: - `hasEnabledTwoFactorAuthentication()` (confirmed Fortify TOTP), **or** - passkeys feature on **and** `hasPasskeysEnabled()` (≥1 registered passkey). ### Decision table | `two_factor_required` | User has confirmed TOTP | User has ≥1 passkey | Result | | ------------------------------------ | ----------------------- | ------------------- | ------------------------------------------------------------------------------------------------ | | Off | — | — | No MFA gate. Personal TOTP/passkeys optional. | | On | Yes | — | Allowed through (passkeys optional). | | On | No | Yes | Allowed through (TOTP optional). | | On | Yes | Yes | Allowed through. | | On | No | No | Redirect to `/settings/security`; banner `twoFactorEnforcedForUser` until enrollment. | | On, but Fortify 2FA feature disabled | — | — | Enforcement is a **no-op** (middleware requires `Features::canManageTwoFactorAuthentication()`). | | On, passkeys feature disabled | No | N/A | Only confirmed TOTP satisfies the gate. | Allowlisted routes while enforced (so enrollment works): security / password / profile / locale settings, Fortify 2FA + passkey registration/store/destroy, password-confirm, and logout. See [Project settings](/docs/project-settings#require-two-factor-authentication). ## Requirements ### Secure context (passkeys only) WebAuthn / `PublicKeyCredential` is available only in a **secure context**: | Origin | Passkeys? | Rest of Externa (password, TOTP, CMS, …)? | | -------------------------------------------------------------------- | ------------------------------------------------ | ----------------------------------------- | | `https://…` (any host) | Yes | Yes | | `http://localhost` / `http://*.localhost` | Yes | Yes | | `http://externa-core.test` (or other custom `.test` / LAN HTTP host) | **No** — UI shows unsupported / insecure-context | **Yes** — expected; not a Brave bug | Do **not** expect HTTP passkeys on custom Herd/Valet `.test` hosts — browsers treat those as insecure. Use password + TOTP on HTTP, or enable local HTTPS below. Production: passkeys **require HTTPS**. The app can run on HTTP for non-passkey features, but **HTTPS is recommended** in general (sessions, cookies, proxies). ### Config checklist | Requirement | Detail | | ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `APP_URL` alignment | `config/fortify.php` → `passkeys.relying_party_id` = host of `APP_URL`; `allowed_origins` = `[config('app.url')]`. Browse at that exact origin (scheme + host + port). | | `APP_KEY` | Used as `user_handle_secret` — keep stable; rotating the key can break existing user handles depending on package behavior. | | Browser / authenticator | Platform authenticator (Touch ID, Face ID, Windows Hello) or roaming authenticator (security key). | | Feature enabled | `Features::passkeys(['confirmPassword' => true])` in `config/fortify.php` `features` array. | | Migrations | `passkeys` table must exist — see [Migration](#migration). | ```php // config/fortify.php (excerpt) 'passkeys' => [ 'relying_party_id' => parse_url(config('app.url'), PHP_URL_HOST), 'allowed_origins' => [config('app.url')], 'user_handle_secret' => config('app.key'), 'timeout' => 60000, ], Features::passkeys([ 'confirmPassword' => true, ]), ``` After changing `APP_URL` on a config-cached host, rebuild config (`php artisan config:cache` / `config:clear`). > If users hit `https://www.example.com` while `APP_URL` is `https://example.com` (or HTTP vs HTTPS, or a different port), registration and assertion fail. Keep proxy/`APP_URL`/browser URL aligned — same rule as [Deployment](/docs/deployment). ### Local HTTPS (Herd / Valet) for passkey testing When you want passkeys on a `.test` host: 1. Secure the site: `herd secure externa-core` (Valet: `valet secure …`). 2. Set `APP_URL=https://externa-core.test` (must match the browser origin — RP ID / `allowed_origins`). 3. **Vite / Laravel `detectTls`:** Externa enables `detectTls` only when Herd cert files exist for the site. After `herd secure`, restart `npm run dev` so `public/hot` is `https://externa-core.test:5173` (not `127.0.0.1` — that causes a black/blank screen). After `herd unsecure`, restart Vite again so it falls back to HTTP without looking for missing certs. 4. Restart Vite (`npm run dev` / `composer run dev`). Hard-refresh the browser. If the session looks wrong after flipping HTTP↔HTTPS, clear cookies for both schemes for that host. Optional local path without Herd TLS: browse `http://localhost` (or `*.localhost`) with matching `APP_URL` — that is a secure context for WebAuthn. ## Migration Passkeys persist in the `passkeys` table (migration `create_passkeys_table`). ```bash php artisan migrate ``` Schema (summary): | Column | Purpose | | --------------- | ------------------------------------ | | `user_id` | FK to users, cascade on delete | | `name` | Operator-facing device label | | `credential_id` | Unique WebAuthn credential id | | `credential` | JSON credential payload | | `last_used_at` | Updated on successful use (nullable) | Fresh installs that already run `composer setup` / full migrate get this table automatically. Existing environments need one migrate after pulling the passkeys work. ## Security notes - **No vendor forks** — stay on official Fortify and `@laravel/passkeys`. Do not patch RP validation, credential storage, or challenge handling in `vendor/`. - **`confirmPassword` on manage** — default `Features::passkeys(['confirmPassword' => true])` so opening Security (and thus register/delete) requires a recent password confirmation. Keep this on unless you have a deliberate, reviewed exception. - **Do not disable RP checks** — `relying_party_id` and `allowed_origins` exist to bind credentials to your site. Do not widen them to `*` or unrelated hosts. - **Throttle** — Fortify registers a `passkeys` rate limiter alongside `login` / `two-factor`. - **Ownership** — destroy is scoped to the authenticated user’s passkeys; deleting another user’s passkey returns an error and leaves the row intact. - **MFA equivalence** — treat passkeys as a full MFA factor for `two_factor_required`, not as a weaker “remember me”. Prefer ≥2 authenticators (e.g. laptop + phone) so losing one device does not lock the account when the project requires MFA and TOTP is not enabled. ## Source map | Concern | Location | | -------------------------------- | ------------------------------------------------------------------------------------------ | | Fortify feature + RP config | `config/fortify.php` | | User traits | `App\Models\User` (`PasskeyUser`, `PasskeyAuthenticatable`) | | Login redirect | `App\Http\Responses\PasskeyLoginResponse` (bound in `FortifyServiceProvider`) | | Security page props | `App\Http\Controllers\Settings\SecurityController` | | MFA gate | `App\Http\Middleware\EnsureTwoFactorIsEnabled` | | Login UI | `resources/js/pages/auth/login.tsx` | | Confirm-password UI | `resources/js/pages/auth/confirm-password.tsx` (passkey confirm via `/passkeys/confirm/*`) | | Security UI | `resources/js/pages/settings/security.tsx` | | Vite TLS / hot URL (Herd secure) | `vite.config.ts` — Laravel plugin `detectTls: '.test'` | | Migration | `database/migrations/*_create_passkeys_table.php` | | Feature / browser tests | `tests/Feature/Auth/PasskeysTest.php`, `tests/Browser/PasskeysBrowserTest.php` | --- # SSO / OIDC Source: https://docs.externa.qiick.io/docs/sso-oidc Externa supports optional **OIDC/SSO** alongside Fortify password and passkey login. Configure a single standards-compliant identity provider (Keycloak, Azure AD OIDC, Google Workspace OIDC, …) in Project settings. > **Related** > Project settings form: [Project settings](/docs/project-settings). Local auth stack: [Passkeys](/docs/passkeys) · [Account settings](/docs/account-settings). Env / `APP_URL`: [Environment variables](/docs/environment-variables). Pre-ship gates: [Security checklist](/docs/security-checklist). ## What ships in v1 | Concern | Behavior | | ---------- | -------------------------------------------------------------------------------------------------------- | | Protocol | OpenID Connect authorization code via issuer discovery (`/.well-known/openid-configuration`) | | Package | `laravel/socialite` + in-app generic OIDC driver | | Login UX | Optional secondary button on `/login` when OIDC is fully configured | | Linking | Match IdP `sub` first; else link by **verified** email to an existing active user and store `oidc_sub` | | JIT | Off by default. When on, create users from verified email; honor `allowed_domains` + `default_user_role` | | Secrets | Client secret stored encrypted; edit payloads expose `oidc_client_secret_configured` only | | Local auth | Password + passkey paths stay available | ## Out of scope (v1) SAML, SCIM, multi-IdP, claim→role matrices, IdP-initiated SSO, treating OIDC as an MFA substitute, forcing SSO-only (disabling password/passkey). ## Setup 1. In the IdP, create a confidential OIDC client. 2. Set redirect URI to `{APP_URL}/auth/oidc/callback` (same class of footgun as passkeys / `APP_URL`). 3. In Externa **Settings → Project → SSO / OIDC**: - Enable OIDC - Issuer URL (base; discovery is appended) - Client ID + client secret - Optional button label - Optional JIT provisioning 4. Ensure target users already exist (JIT off) **or** turn JIT on and configure allowed domains / default role. | Route | Name | Notes | | ------------------------- | -------------------- | --------------------------------------------- | | `GET /auth/oidc/redirect` | `auth.oidc.redirect` | Starts authorize redirect (404 when disabled) | | `GET /auth/oidc/callback` | `auth.oidc.callback` | Token + userinfo → session login | If the callback fails validation or the IdP rejects the login, you are redirected to `/login` with the error shown — no redirect loop back to the IdP. For local QA, a Keycloak profile ships in `compose.oidc.yaml` (not for production): ```bash docker compose -f compose.yaml -f compose.oidc.yaml --profile oidc up --build ``` ## Security notes - **Never link on unverified email** — account takeover risk. Externa requires IdP `email_verified`. - After first successful link, prefer `oidc_sub` over email. - Inactive / soft-deleted users are rejected. - OIDC login does **not** satisfy `two_factor_required`. SSO-only cohorts still need TOTP/passkey enrollment or the project MFA gate off. - Do not trust arbitrary `roles` / `groups` claims without an allowlist (not implemented in v1). ## Operator checklist - [ ] Redirect URI matches production `APP_URL` - [ ] Client secret saved (configured flag true); plaintext never returned on edit - [ ] Link-only path verified with an existing user - [ ] If JIT on: allowed domains + default role verified; unknown domains rejected - [ ] Password and passkey login still work --- # Accessibility Source: https://docs.externa.qiick.io/docs/accessibility Externa ships user-first accessibility in the authenticated shell: high contrast and reduce motion in the user menu, OS media queries honored without a toggle, and a skip link into `main#main-content`. Target is **WCAG 2.2 AA** on P0 flows (login, shell, collections/items/files, drawers, settings, AI FAB) — not a full axe CI suite. > **Related** > Personal theme (light/dark): [Account settings](/docs/account-settings). Shell layout: [Architecture](/docs/architecture). ## User preferences | Preference | Effect | Storage | | ----------------- | ---------------------------------------------------------------------------------------------- | ------------------------------------------------------- | | **High contrast** | Stronger semantic shadcn tokens (`--foreground`, `--muted-foreground`, `--border`, sidebar, …) | `localStorage` + cookie `accessibility_high_contrast=1` | | **Reduce motion** | Forces reduced animations/transitions when the OS does not already ask | `localStorage` + cookie `accessibility_reduce_motion=1` | UI: user menu → **Accessibility** submenu (summary `Off` / `N on`), same checkmark pattern as Appearance/Language. Hook: `resources/js/hooks/use-accessibility-preferences.tsx` (sibling of `useAppearance`). Blade boot via `HandleAppearance` + `app.blade.php` stamps `high-contrast` / `reduce-motion` on `` before paint (cookies are not encrypted — see `bootstrap/app.php`). ## OS media queries (always on) | Query | Behavior | | -------------------------------- | -------------------------------------------------------------------- | | `prefers-reduced-motion: reduce` | Motion kill in CSS — no user toggle required | | `prefers-contrast: more` | Same token bumps as `.high-contrast` without forcing the menu toggle | The user **Reduce motion** class only adds force-on when the OS preference is off. High contrast from the OS does not require the menu class. ## Skip link & landmark Authenticated layouts (`app-sidebar-layout`, `app-header-layout`) and the auth simple layout: 1. `SkipToContent` — first focusable control, visible on `:focus` 2. Target `main#main-content` (`tabIndex={-1}` for programmatic focus) Sidebar navigation uses `role="navigation"` with an accessible name. ## P0 scope vs follow-ups **In scope (shipped):** preferences, CSS, skip/landmark, high-impact aria/labels on P0 surfaces (login/2FA, shared search toolbars, file picker/upload/import, field-condition / form-layout icon controls, appearance color hex), Pest smoke (cookie classes + browser menu toggles + axe `assertNoAccessibilityIssues(1)` on login and profile), this doc. **Known limits / follow-up:** font size / dyslexia fonts; jest-axe / eslint-jsx-a11y zero-warnings CI; automated contrast CI against custom brand primary; every admin drawer/table cell. Brand colors from Project settings can fail WCAG AA on some combinations — prefer high-contrast mode or adjust tokens. ## Source map | Concern | Location | | --------------- | --------------------------------------------------------------------------------------------------------- | | Hook | `resources/js/hooks/use-accessibility-preferences.tsx` | | Menu | `resources/js/components/user-menu-content.tsx` | | Skip link | `resources/js/components/skip-to-content.tsx` | | CSS | `resources/css/app.css` (`.high-contrast`, `@media`) | | Blade / cookies | `resources/views/app.blade.php`, `HandleAppearance` | | Tests | `tests/Feature/AccessibilityPreferencesTest.php`, `tests/Browser/AccessibilityPreferencesBrowserTest.php` | --- # Project settings Source: https://docs.externa.qiick.io/docs/project-settings Project settings are the operator-facing configuration for the whole Externa install: content languages, sidebar modules, security/registration defaults, Public API origin allowlist, image transform presets, outbound webhooks, Slack/Telegram notifiers, SSO (OIDC), and branding. They require **`can-manage-project-settings`**. > **Related** > Personal profile/password/passkeys: [Account settings](/docs/account-settings) · [Passkeys](/docs/passkeys) · [SSO / OIDC](/docs/sso-oidc). Content locale resolution: [Configuration](/docs/configuration) · [Collections data model](/docs/collections-data-model). Public API origins: [Client types — Origin allowlist](/docs/public-cms-api-types#origin-allowlist). Outbound delivery: [Outbound webhooks](/docs/webhooks) · [Slack & Telegram notifiers](/docs/integrations). HTTP overview: [Routing](/docs/routing). ## Access | Item | Detail | | ----------- | --------------------------------------------------------------- | | Permission | `can-manage-project-settings` | | Routes | `routes/settings.php` (`auth` + `verified` + `permission:…`) | | Controllers | `ProjectSettingsController`, `AppearanceSettingsController` | | Pages | `resources/js/pages/settings/project.tsx`, `appearance.tsx` | | Storage | `SettingsRepository` project scope (`config/settings.php` keys) | The same `can-manage-project-settings` permission also grants access to **Settings → Performance** (`/settings/performance`) — cache ops status and targeted flush actions. See [Operations](/docs/operations). ## HTTP | Method | Path | Name | Purpose | | ------ | ------------------------------------------ | -------------------------------- | ---------------------------- | | `GET` | `/settings/project` | `project.edit` | Project settings form | | `PUT` | `/settings/project` | `project.update` | Persist project keys | | `POST` | `/settings/project/webhook-test` | `project.webhook-test` | Queue a signed `ping` event | | `POST` | `/settings/project/slack-notifier-test` | `project.slack-notifier-test` | Send a test Slack message | | `POST` | `/settings/project/telegram-notifier-test` | `project.telegram-notifier-test` | Send a test Telegram message | | `GET` | `/settings/appearance` | `appearance.edit` | Branding form | | `PUT` | `/settings/appearance` | `appearance.update` | Persist appearance keys | ## Unsaved changes Project and Appearance forms show an **Unsaved** badge and **Discard** on the sticky Save bar while dirty. Discard confirms, resets fields to the last loaded values, and stays on the page. Leaving via navigation while dirty uses the same Keep editing / Discard dialog. ## Project keys Configured under **Settings → Project** (`ProjectSettings::forEdit()`). Important groups: | Area | Keys (selection) | | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | Identity | `name`, `description`, `url`, `default_language` | | Content locales | `content_locales`, `default_content_locale`, `fallback_content_locales` | | Sidebar | `sidebar_modules` (ordered modules; `ai` / `dashboard` locked) | | Security / registration | `password_policy`, `login_max_attempts`, `two_factor_required`, `registration_enabled`, `default_user_role`, `email_verification_required`, `allowed_domains` | | SSO / OIDC | `oidc_enabled`, `oidc_issuer`, `oidc_client_id`, `oidc_client_secret` (encrypted), `oidc_button_label`, `oidc_jit_provisioning` — see [SSO / OIDC](/docs/sso-oidc) | | Public API | `public_api_allowed_origins` (one origin per line in the UI; empty = no origin gate, CORS from `CORS_ALLOWED_ORIGINS`) | | Files | `allowed_transformations`, `preset_transformations` | | Reporting | `report_issue_url`, `report_bug_url`, `report_error_url` | | Webhooks | `webhook_url`, `webhook_secret` (encrypted at rest; never shared on Inertia) | | Notifiers | `notifier_slack_webhook_url`, `notifier_telegram_bot_token` (encrypted), `notifier_telegram_chat_id` | Content locales are the **source of truth** for translatable collection field values and labels once saved. Until then, seed/fallback comes from `config/collections.php`. Catalog codes live in `resources/data/content-locales-catalog.json` (`ContentLocaleCatalog`). Disabling a content locale does **not** delete existing translation rows. Editing **Content languages** rewrites `fallback_content_locales` to `[default, …other enabled locales in list order]` before save — see [Locale fallback](/docs/configuration#locale-fallback) for the read-time chain (`fallback_content_locales` → `APP_FALLBACK_LOCALE` → `null`) and empty-list normalization. On the item form, editors switch locales from the toolbar, copy or apply values across locales, and optionally translate with AI — see [Translation workspace](/docs/translation-workspace). ### Require two-factor authentication When `two_factor_required` is enabled, `EnsureTwoFactorIsEnabled` (web middleware) redirects authenticated users who have neither confirmed Fortify TOTP (`hasEnabledTwoFactorAuthentication()`) nor at least one registered passkey (`hasPasskeysEnabled()`) to **Settings → Security** (`security.edit`). That page passes `twoFactorRequired` / `twoFactorEnforcedForUser` and shows an alert banner until the user finishes enrollment (TOTP **or** a passkey). | Satisfies the gate? | Condition | | ------------------- | ---------------------------------------------------------------------------------- | | Yes | Confirmed Fortify TOTP | | Yes | ≥1 registered passkey (`Features::canManagePasskeys()` and `hasPasskeysEnabled()`) | | Yes | Both of the above | | No | Neither — forced to Security with the required-MFA banner | | N/A (gate off) | `two_factor_required` off — personal TOTP/passkeys stay optional | | N/A (no-op) | Fortify two-factor feature disabled — middleware does not enforce | Allowlisted so enrollment works: security/password/profile/locale settings, Fortify 2FA + passkey registration/store/destroy + password-confirm routes, and logout. Deleting a user’s last passkey while TOTP is off and the setting is on returns them to the enrollment gate. Passkey register / passwordless login / RP config: [Passkeys](/docs/passkeys). Pre-release checks: [Security checklist](/docs/security-checklist). ### Public API allowed origins When `public_api_allowed_origins` is non-empty, Externa: - Sets CORS `allowed_origins` for `api/*` to that list - Enforces `EnforcePublicApiOrigin` on REST and GraphQL (matching `Origin`, or a valid Bearer API key when `Origin` is absent) Format: `https://host` or `http://host[:port]` — no path. Details and security limits: [Origin allowlist](/docs/public-cms-api-types#origin-allowlist). ## Project appearance (branding) **Settings → Appearance** is **project branding**, not the personal light/dark toggle: | Key | Purpose | | -------------------------------------- | -------------------------------------------- | | `project_color` / `project_color_dark` | Brand colors | | `project_logo` / `project_logo_dark` | Logos | | `public_favicon` | Favicon | | `default_appearance` | Default theme hint (`system` / light / dark) | Personal light/dark preference still uses the `appearance` cookie + `useAppearance` / `AppearanceTabs` in the user menu (`HandleAppearance` middleware). That path has **no** Spatie permission gate. ### Transactional email branding Password reset, email verification, and other Markdown / `MailMessage` emails use the same project branding: - **Logo** — light `project_logo` (emails use a light theme). If unset, the header shows the project name as text (never the default Laravel logo). - **Primary color** — `project_color` for buttons and links (button text uses contrasting black/white). - **Name** — Project settings name when set; otherwise `APP_NAME`. The logo must be reachable by email clients as an **absolute public URL** (assets disk / `FILE_PUBLIC_URL_BASE` / CDN / S3). Localhost URLs work only for local mail testing; production mailboxes cannot load them. ## Notifiers (Slack, Telegram) Outbound **chat notifications** for domain events (1.2.0+). They run in parallel with [outbound webhooks](/docs/webhooks), which stay the machine-to-machine channel. | Channel | Configure | | -------- | ------------------------------------------------------------------------------------------------------ | | Slack | `notifier_slack_webhook_url` — an Incoming Webhook URL | | Telegram | `notifier_telegram_bot_token` (stored encrypted) + `notifier_telegram_chat_id` — Bot API `sendMessage` | - **Event types:** `notifier_event_types` picks which events are sent; an empty choice (`null`) means all selectable events. `ping` (test) is always allowed. - Deliveries are queued jobs (`DeliverSlackOutboundNotifierJob`, `DeliverTelegramOutboundNotifierJob`) dispatched after commit, so a queue worker must be running. - **Test** buttons send a sample message; Telegram errors are shown in the UI. - Messages are formatted per channel (Telegram uses HTML parse mode) with item/collection context. See [Slack & Telegram notifiers](/docs/integrations). ## SSO (OIDC) Optional single sign-on through any OpenID Connect provider (Keycloak, Entra ID, Okta, Google, …). Full guide: [SSO / OIDC](/docs/sso-oidc). | Key | Purpose | | ----------------------- | ----------------------------------------------------------------------------- | | `oidc_enabled` | Show the SSO button on the login page | | `oidc_issuer` | Issuer URL (discovery at `/.well-known/openid-configuration`) | | `oidc_client_id` | Client id | | `oidc_client_secret` | Stored encrypted; never shared on Inertia | | `oidc_button_label` | Custom button text (optional) | | `oidc_jit_provisioning` | Create users on first login (default **off**: only existing users are linked) | ## Webhook test `POST /settings/project/webhook-test` requires a configured URL; otherwise redirects with an error. Dispatches `OutboundWebhookDispatcher::dispatchPing()` onto the queue (needs a worker). Project settings also shows **last success / last error** and a recent delivery ring buffer after jobs finish. For configure / HMAC / Make-n8n recipes / try steps (webhook.site, Bruno helper placeholder), see [Outbound webhooks](/docs/webhooks). ## Source map | Concern | Location | | ---------------------- | ---------------------------------------------------------------------------------------------- | | Routes | `routes/settings.php` | | Project controller | `app/Http/Controllers/Settings/ProjectSettingsController.php` | | Appearance controller | `app/Http/Controllers/Settings/AppearanceSettingsController.php` | | Config keys / defaults | `config/settings.php` | | Settings store | `app/Services/Settings/SettingsRepository.php`, `ProjectSettings.php`, `ProjectAppearance.php` | | UI | `resources/js/pages/settings/project.tsx`, `appearance.tsx` | --- # Configuration Source: https://docs.externa.qiick.io/docs/configuration Externa’s runtime settings live in `externa-core/config/*.php` and are driven by `.env` (see [Environment variables](/docs/environment-variables)). This page covers the application defaults you should understand before going to production. > **Related pages** > Files: [Files configuration](/docs/files-configuration). AI: [AI configuration](/docs/ai-configuration). Permissions model: [Effective permissions](/docs/permissions). ## Application (`config/app.php`) | Key / env | Default | Notes | | --------------------- | ------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `APP_NAME` | `Laravel` | Display name in mail/UI strings that read `config('app.name')`. Shared to Inertia as `name`. | | `APP_ENV` | `production` in config; `.env.example` uses `local` | Environment label. | | `APP_DEBUG` | `false` in config; `true` in `.env.example` | **Disable in production.** | | `APP_URL` | `http://localhost` | Canonical URL for Artisan, mail links, public disk URLs, and Fortify passkeys RP. Must match how browsers reach the app. App works on HTTP; passkeys need HTTPS / localhost — [Passkeys](/docs/passkeys#requirements). | | `APP_KEY` | _(required)_ | Encryption key; generated by `composer setup` / `key:generate`. | | `APP_LOCALE` | `en` | Default Laravel locale. | | `APP_FALLBACK_LOCALE` | `en` | Fallback when a translation key is missing. | | `APP_FAKER_LOCALE` | `en_US` | Faker locale for factories/seed demos. | | `timezone` | `UTC` | Hard-coded in config (not env-driven). | | Maintenance | `APP_MAINTENANCE_DRIVER=file`, store default `database` | Standard Laravel maintenance mode. | ```env APP_NAME=Laravel APP_ENV=local APP_DEBUG=true APP_URL=http://localhost APP_LOCALE=en APP_FALLBACK_LOCALE=en APP_FAKER_LOCALE=en_US ``` > 1. **Admin UI locale** — `config/i18n.php` + user preference / cookie (`APP_LOCALE` is the default). Controls the shell language (en/it/de). > 2. **Content locales** — **project settings** (`content_locales`, `default_content_locale`, `fallback_content_locales`). Source of truth for translatable collection field values and labels. Seeded from `config/collections.php` until saved. UI: [Project settings](/docs/project-settings). > 3. **Laravel `APP_LOCALE` / `APP_FALLBACK_LOCALE`** — framework translator defaults; also used as a last-resort content resolve hint when nothing else matches. > > Changing the admin UI language does **not** change which content locales are enabled. ## Fortify (`config/fortify.php`) Laravel Fortify provides session authentication for the Inertia SPA. | Setting | Value | | ---------------------- | ----------------------------------------------------------------------- | | Guard | `web` | | Password broker | `users` | | Username / email field | `email` | | `lowercase_usernames` | `true` | | Home path | `/dashboard` | | Route prefix / domain | empty / `null` | | Middleware | `['web']` | | Views | enabled (`true`) — Inertia auth pages under `resources/js/pages/auth/*` | | Login throttle | limiter `login` (5/min) | | Two-factor throttle | limiter `two-factor` (5/min) | | Passkeys throttle | limiter `passkeys` | ### Enabled features ```php Features::registration(), Features::resetPasswords(), Features::emailVerification(), Features::twoFactorAuthentication([ 'confirm' => true, 'confirmPassword' => true, 'window' => 1, // ±30s TOTP skew ]), Features::passkeys([ 'confirmPassword' => true, ]), ``` Passkeys also use the `passkeys` config block (`relying_party_id`, `allowed_origins`, `user_handle_secret`, `timeout`) derived from `APP_URL` / `APP_KEY`. Operator guide: [Passkeys](/docs/passkeys). To disable a feature, remove it from the `features` array in `config/fortify.php` and adjust the UI accordingly. Authenticated product routes also require the `verified` middleware (see [Routing overview](/docs/routing)). ## Session, cache, and queue defaults Externa’s `.env.example` uses **database** drivers for all three. That keeps local setup simple and matches the queue-backed file zip/duplicate and AI import jobs. | Concern | Config file | Env | Default | | ----------------------- | ------------------------ | ------------------ | ------------- | | Session driver | `config/session.php` | `SESSION_DRIVER` | `database` | | Session lifetime | | `SESSION_LIFETIME` | `120` minutes | | Session encrypt | | `SESSION_ENCRYPT` | `false` | | Cache store | `config/cache.php` | `CACHE_STORE` | `database` | | Queue connection | `config/queue.php` | `QUEUE_CONNECTION` | `database` | | Default filesystem disk | `config/filesystems.php` | `FILESYSTEM_DISK` | `local` | ```env SESSION_DRIVER=database SESSION_LIFETIME=120 CACHE_STORE=database QUEUE_CONNECTION=database FILESYSTEM_DISK=local ``` > With `QUEUE_CONNECTION=database` (or Redis/etc.), you must run a worker. `composer run dev` starts `php artisan queue:listen`. Production needs a supervised worker plus the scheduler — see [Operations](/docs/operations) and [Deployment](/docs/deployment). Optional Redis variables are present in `.env.example` if you switch drivers later (`REDIS_*`, `MEMCACHED_HOST`). ## Super-admin seeding (`config/super_admin.php`) Used only by `CreateSuperAdminSeeder` when you run `php artisan db:seed` (via `DatabaseSeeder`). | Config key | Env | Default | | ------------ | -------------------------------- | ------------------------ | | `first_name` | `INITIAL_SUPER_ADMIN_FIRST_NAME` | `Super` | | `last_name` | `INITIAL_SUPER_ADMIN_LAST_NAME` | `Admin` | | `email` | `INITIAL_SUPER_ADMIN_EMAIL` | `superadmin@example.com` | | `password` | `INITIAL_SUPER_ADMIN_PASSWORD` | `password` | `DatabaseSeeder` order: 1. `PermissionSeeder` → `php artisan permissions:sync` 2. `RoleSeeder` → built-in roles (`super-admin`, `admin`, `reader`, **`public`**) and permission assignments 3. `CreateSuperAdminSeeder` → upserts the user by email and assigns the super-admin role > Override `INITIAL_SUPER_ADMIN_*` **before** seeding shared or production environments. Never leave the default password outside local development. ## Permission configuration overview | Piece | Location | | -------------------------- | -------------------------------------------------------------------------------- | | Spatie package config | `config/permission.php` | | Canonical permission names | `App\Enums\PermissionEnum` | | Sync command | `php artisan permissions:sync` (`--prune` removes DB rows no longer in the enum) | | TS mirror | Regenerated at `resources/js/enums/permission-enum.ts` | | Teams | **disabled** (`teams => false`) | | Permission cache | 24 hours, key `spatie.permission.cache`, store `default` | | Effective resolution | `App\Services\Authorization\EffectivePermissionResolver` | | Middleware aliases | `permission`, `can.manage.files` in `bootstrap/app.php` | Key Spatie settings that matter for Externa: - Models: default Spatie `Permission` / `Role` - `register_permission_check_method` → `true` - `display_permission_in_exception` / `display_role_in_exception` → `false` (avoid leaking names) - Wildcard permissions → disabled Full authorization walkthrough: [Effective permissions](/docs/permissions). After deploying enum changes, run: ```bash php artisan permissions:sync # optional: php artisan permissions:sync --prune ``` ## Content locales (project settings) **Source of truth:** Project settings → `content_locales` (ordered, min 1), `default_content_locale`, optional `fallback_content_locales`. `config/collections.php` (`locales` / `fallback_locales`) is only the **seed / fallback** when settings have not been saved yet (defaults `['en', 'it']`). | Key | Role | | -------------------------- | ------------------------------------------------------------------------------------- | | `content_locales` | Enabled BCP-47 (or language-only) codes for item `data` translations and field labels | | `default_content_locale` | Must be ∈ `content_locales`; used when no `?locale=` / Accept-Language match | | `fallback_content_locales` | Ordered chain when a translation is missing | Configure under **Settings → Project → Content languages** (searchable catalog + sortable list). Catalog codes/names/flags live in `resources/data/content-locales-catalog.json` (`ContentLocaleCatalog`). Disabling a locale does **not** delete existing translation rows in the DB (orphans stay until rewritten). Shared to the frontend as Inertia props `collectionLocales`, `collectionLocaleMeta`, and `defaultContentLocale` (see [Routing overview](/docs/routing)). Admin UI language remains separate (`config/i18n.php`). ### Locale fallback Two separate steps: pick the **active locale**, then walk a **value fallback** chain when a translatable field has no usable string for that locale. Applies to **top-level** translatable fields only (nested block maps are not flattened — see [Field types](/docs/field-types#pack-block-schema-articles-pages)). **Active locale** (`CollectionLocaleResolver::resolve`): explicit override / `?locale=` (must be enabled or **422**), then first `Accept-Language` tag that is enabled, then `default_content_locale`, then `APP_LOCALE` if enabled, then the first enabled content locale. Same rules for the Public CMS API and Live Preview (published flatten path). **Value fallback** (`fallbackChain` → `getTranslated` / `flattenForLocale`): 1. The requested (or resolved) locale 2. Each entry in `fallback_content_locales` that is still enabled (duplicates skipped) 3. `APP_FALLBACK_LOCALE` (`config('app.fallback_locale')`, default `en`) **if that code is enabled** and not already in the chain 4. If every candidate is missing → the field is **`null`** in the response Empty strings count as missing: `getTranslated` skips a locale slot when the value is `null` or `''`, then tries the next locale in the chain. ```text content_locales = [en, it, de] fallback_content_locales = [en, it, de] # typical after Content languages save ?locale=de title = { en: "Hello", it: "" } → "Hello" # de missing → it empty → en ``` **Admin rewrite:** editing **Settings → Project → Content languages** resets `fallback_content_locales` in the form to `[default_content_locale, …other enabled locales in list order]`. Saving that section persists that chain (any earlier custom order is replaced). There is no separate reorder UI; the value is submitted as a hidden field. A custom order sent via the project settings update API is kept until Content languages are edited again. **Empty list on the server:** when normalizing locales, entries not in `content_locales` are dropped. If `fallback_content_locales` ends up empty, `ProjectSettings` rebuilds `[default_content_locale, …seed codes from config/collections.php fallback_locales that are still enabled, …remaining enabled locales]`. ## Mail and logging (brief) | Area | Default in `.env.example` | | ------ | -------------------------------------------- | | Mailer | `MAIL_MAILER=log` (no SMTP required locally) | | Log | `LOG_CHANNEL=stack`, `LOG_LEVEL=debug` | Configure real SMTP (or another mailer) before relying on password-reset or verification email in non-local environments. ## Checklist after changing config 1. Update `.env` (and secrets store in production). 2. `php artisan config:clear` or rebuild `config:cache` in production. 3. If permissions/enums changed: `permissions:sync` (+ seed roles if needed). 4. If content locales changed: smoke-test item forms and API `?locale=` for each enabled locale. 5. Confirm `APP_URL` matches the public host (affects storage URLs and redirects). --- # Files configuration Source: https://docs.externa.qiick.io/docs/files-configuration File manager storage and async thresholds are configured in `config/files.php` and `config/filesystems.php`. Env keys are documented under [Environment variables](/docs/environment-variables); this page explains how disks, URLs, and cleanup interact. ## Thresholds (`config/files.php`) | Config key | Env | Default | Meaning | | -------------------------- | -------------------------------- | -------------------- | ------------------------------------------------------------------------------------------------------------------------------- | | `disk` | `FILES_DISK` | `assets` | File manager upload disk (`assets` local or `s3` for S3/MinIO/R2). | | `duplicate_sync_max_bytes` | `FILES_DUPLICATE_SYNC_MAX_BYTES` | `52428800` (50 MB) | Single non-folder files at or below this size are duplicated **inline**. Folders, bulk selections, and larger files are queued. | | `zip_ttl_minutes` | `FILES_ZIP_TTL_MINUTES` | `60` | Prepared zip archives under `storage/app/zips` expire after this many minutes. | | `zip_max_bytes` | `FILES_ZIP_MAX_BYTES` | `104857600` (100 MB) | Maximum zip payload size enforced by the application. | ```env FILES_DISK=assets FILES_DUPLICATE_SYNC_MAX_BYTES=52428800 FILES_ZIP_TTL_MINUTES=60 FILES_ZIP_MAX_BYTES=104857600 ``` > **Queue dependency** > Queued duplicates and zip builds require a running queue worker (`queue:listen` / `queue:work`). See [Operations](/docs/operations). ## Filesystem disks (`config/filesystems.php`) | Disk | Driver | Root / notes | | ---------------- | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | | `local` | `local` | `storage/app/private` — default private storage (`FILESYSTEM_DISK` defaults to `local`). `serve` enabled for authenticated streaming patterns. | | `public` | `local` | `storage/app/public` — URL `{APP_URL}/storage`. Visibility `public`. | | `assets` | `local` | `storage/app/public/assets` — **public** file-manager bytes. URL uses `FILE_PUBLIC_URL_BASE` (see below). | | `private_assets` | `local` | `storage/app/private/assets` — **effective-private** file-manager bytes. Not in `storage:link`; no public URL. | | `s3` | `s3` | Optional cloud disk; scaffolded with standard `AWS_*` env vars. | Default disk: ```env FILESYSTEM_DISK=local ``` ### Symbolic link ```php 'links' => [ public_path('storage') => storage_path('app/public'), ], ``` Create the link once per environment: ```bash php artisan storage:link ``` Without this link, browsers cannot resolve `/storage/...` URLs for the `public` / `assets` disks. The `private_assets` disk is intentionally **not** linked. ### Private vs public local bytes When `FILES_DISK=assets` (default), Externa maps effective privacy onto the local pair: | Effective access | Disk | Browser URL | | ---------------- | ---------------- | -------------------------------------------------------- | | public | `assets` | `{FILE_PUBLIC_URL_BASE}/storage/assets/{storage_path}` | | private | `private_assets` | none — admin download / API `/api/v1/files/{id}/content` | Visibility changes (`updateMetadata` / move into a private folder) move bytes between the two disks. One-shot migration for legacy private rows still on `assets`: ```bash php artisan files:migrate-private-disk ``` When `FILES_DISK=s3`, objects stay on S3; private files still omit a static public URL and use auth'd paths (bucket ACL is the operator's responsibility). ## `FILE_PUBLIC_URL_BASE` At the top of `config/filesystems.php`: ```php $filePublicBase = rtrim((string) env('FILE_PUBLIC_URL_BASE', env('APP_URL', 'http://localhost')), '/'); ``` The `assets` disk URL is: ```text {FILE_PUBLIC_URL_BASE}/storage/assets ``` | Variable | Fallback | When to set | | ---------------------- | --------- | --------------------------------------------------------------------------------------- | | `FILE_PUBLIC_URL_BASE` | `APP_URL` | CDN or different public origin for asset URLs while the app still runs on another host. | ```env # Optional — omit to use APP_URL FILE_PUBLIC_URL_BASE=https://cdn.example.com APP_URL=https://app.example.com ``` The `public` disk URL still uses `APP_URL` directly (`{APP_URL}/storage`). Keep both consistent unless you intentionally split origins. ## Optional S3 / MinIO Set the **file manager** disk with `FILES_DISK` (default `assets`). Framework default `FILESYSTEM_DISK` stays `local` for private/local attachments. ```env FILES_DISK=s3 FILESYSTEM_DISK=local AWS_ACCESS_KEY_ID=externa AWS_SECRET_ACCESS_KEY=externasecret AWS_DEFAULT_REGION=us-east-1 AWS_BUCKET=externa AWS_ENDPOINT=http://minio:9000 AWS_USE_PATH_STYLE_ENDPOINT=true AWS_URL=http://localhost:9000/externa ``` Local Compose MinIO: `docker compose --profile minio up` ([Installation — Docker](/docs/installation#docker-compose-local-full-stack)). Upload, download, where-used, thumbnails/transforms, and zip **source reads** go through the Storage disk API (S3-safe). Chat/AI temporary attachments default to the `local` disk; `absolutePath()` materializes remote objects to a temp file when needed. ### Remaining limits (precise) | Still local / ephemeral | Why | | ---------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Prepared zip archives under `storage/app/zips` | Built on a shared volume between `app` and Horizon; not sticky to one host bind for **user files**, but zips themselves are local ephemeral artifacts (TTL + `files:cleanup-zips`) | | Chunk upload scratch on the chosen disk | Chunks live on `FILES_DISK` (S3 OK); session metadata is in DB | | `storage:link` for `assets` | Only required when `FILES_DISK=assets` (local public assets) | > **Multi-container** > With `FILES_DISK=s3`, app replicas do **not** need a sticky host bind for CMS user files. Still share a `storage` volume (or accept zip loss) so prepared downloads and local chat temps work across Horizon workers. ## Cleanup commands Scheduled in `routes/console.php` (see [Operations](/docs/operations)): | Command | Schedule | Purpose | | ---------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | | `files:cleanup-uploads` | hourly | Removes expired **partial/chunked** upload sessions and their chunk storage (`CleanupStaleFileUploadsCommand` → `FileService::cleanupStaleUploads()`). | | `files:cleanup-zips` | hourly | Removes prepared zip downloads older than `zip_ttl_minutes` (`CleanupExpiredFileZipsCommand` → `FileService::cleanupExpiredZips()`). | | `files:migrate-private-disk` | manual | Moves effective-private local files from `assets` → `private_assets` (upgrade / one-shot). | Run manually: ```bash php artisan files:cleanup-uploads php artisan files:cleanup-zips php artisan files:migrate-private-disk ``` ## Upload policy (app code, not env) Thresholds above do **not** configure which file types are accepted. From core **1.3.0**, uploads use an **extension denylist** (`ForbiddenUploadExtension`, including `svg` / `html` / `htm`) **plus** a **MIME allowlist + magic-byte check** (`MimeTypeValidator`, `config/files.php`). **SVG, JSON, and XML are rejected.** See [File manager — Upload security](/docs/file-manager#upload-security). Team chat uploads remain any-type until saved into Files (then both gates apply). ## Related HTTP surface JSON and Inertia file-manager endpoints live under `/files/*` with the `can.manage.files` middleware. Full route table: [Admin API](/docs/admin-api). Data model notes: [File tree model](/docs/file-tree-model). --- # AI configuration Source: https://docs.externa.qiick.io/docs/ai-configuration AI behaviour is configured in `config/ai.php` and consumed by `laravel/ai` plus Externa’s `AppAssistant` agent. Defaults target a **local OpenAI-compatible gateway** (LM Studio, vLLM, etc.). > **Related** > Env reference: [Environment variables](/docs/environment-variables). HTTP routes: [AI API](/docs/ai-api). Product overview: [AI assistant](/docs/ai-assistant). ## Default provider | Config key | Env | Default | | --------------------------- | --------------------- | -------- | | `default` | `AI_DEFAULT_PROVIDER` | `local` | | `default_for_images` | _(hard-coded)_ | `gemini` | | `default_for_audio` | _(hard-coded)_ | `openai` | | `default_for_transcription` | _(hard-coded)_ | `openai` | | `default_for_embeddings` | _(hard-coded)_ | `openai` | | `default_for_reranking` | _(hard-coded)_ | `cohere` | `AppAssistant` is annotated with `#[Provider('local')]`, so chat uses the local provider entry unless you change that attribute / default. ```env AI_DEFAULT_PROVIDER=local ``` ## Local provider (`LOCAL_AI_*`) Under `providers.local`: | Setting | Env | Default | | ------------------ | -------------------------- | ---------------------------------------------------------------------- | | Driver | _(fixed)_ | `openai` (OpenAI-compatible API shape) | | URL | `LOCAL_AI_URL` | `http://127.0.0.1:1234/v1` | | API key | `LOCAL_AI_API_KEY` | empty string | | Default text model | `LOCAL_AI_MODEL` | config default `local-model`; `.env.example` sets `openai/gpt-oss-20b` | | Translate model | `LOCAL_AI_TRANSLATE_MODEL` | _(empty — falls back to `LOCAL_AI_MODEL`)_ | ```env LOCAL_AI_URL=http://127.0.0.1:1234/v1 LOCAL_AI_API_KEY= LOCAL_AI_MODEL=openai/gpt-oss-20b # Optional: faster non-thinking model for POST /ai/translate-fields (avoids nginx 504) # LOCAL_AI_TRANSLATE_MODEL=phi-4-mini-instruct ``` `LOCAL_AI_TRANSLATE_MODEL` is an optional faster non-thinking model for AI field translation (`POST /ai/translate-fields`), so the blocking request does not hit an nginx 504. Example: `phi-4-mini-instruct`. See [Translation workspace — AI translate](/docs/translation-workspace#ai-translate). > Prefer a model that supports tool calling reliably. Tiny models often hallucinate successful tool results without calling tools — Externa’s instructions tell the agent never to invent success, but a weak model still wastes time. `AppAssistant` sets a **300s** HTTP timeout and a high `maxSteps()` budget (field-type count + 16) for long tool loops. > **Local gateway / Herd nginx** > LM Studio may emit `response.reasoning_text.*` SSE events; Externa remaps them to OpenAI-shaped `response.reasoning_summary_text.*` so `laravel/ai` keeps the stream alive. If the browser still shows a mid-stream “network error”, raise nginx `fastcgi_read_timeout` (Herd default is often **60s**) to at least **600s** while the model thinks without emitting tokens. ## Cloud / other providers `config/ai.php` registers many providers. Set the matching env keys when you switch `AI_DEFAULT_PROVIDER` or call a named provider: | Provider key | Typical env vars | | ------------ | ------------------------------------------------------------------------------------------------- | | `anthropic` | `ANTHROPIC_API_KEY`, `ANTHROPIC_URL` | | `azure` | `AZURE_OPENAI_API_KEY`, `AZURE_OPENAI_URL`, `AZURE_OPENAI_API_VERSION`, deployment names | | `bedrock` | `AWS_BEDROCK_REGION`, `AWS_BEARER_TOKEN_BEDROCK`, AWS credentials / `AWS_USE_DEFAULT_CREDENTIALS` | | `cohere` | `COHERE_API_KEY` | | `deepseek` | `DEEPSEEK_API_KEY` | | `eleven` | `ELEVENLABS_API_KEY` | | `gemini` | `GEMINI_API_KEY`, `GEMINI_URL` | | `groq` | `GROQ_API_KEY` | | `jina` | `JINA_API_KEY` | | `mistral` | `MISTRAL_API_KEY` | | `ollama` | `OLLAMA_API_KEY`, `OLLAMA_URL` (default `http://localhost:11434`) | | `openai` | `OPENAI_API_KEY`, `OPENAI_URL` | | `openrouter` | `OPENROUTER_API_KEY` | | `voyageai` | `VOYAGEAI_API_KEY` | | `xai` | `XAI_API_KEY` | These are **not** all listed in `.env.example`; they are read when present. ## Remote import hosts ```php 'remote_import_hosts' => array_values(array_filter(array_map( 'trim', explode(',', (string) env('AI_REMOTE_IMPORT_HOSTS', '')), ))), ``` | Env | Default | Meaning | | ------------------------ | ------- | -------------------------------------------------------------------------------------------------------------------- | | `AI_REMOTE_IMPORT_HOSTS` | empty | Comma-separated **host allowlist** for `ImportRemoteJson` / related remote fetches. Empty → no remote hosts allowed. | ```env AI_REMOTE_IMPORT_HOSTS=api.example.com,data.partner.test ``` > Never leave remote import open to arbitrary hosts in production. Pair allowlists with short-lived Bearer tokens when the remote API requires auth. ## Webhook HMAC secret | Config | Env | Purpose | | --------------- | ------------------ | ------------------------------------------------------------------------ | | `webhook_token` | `AI_WEBHOOK_TOKEN` | HMAC-SHA256 key for `POST /ai/webhooks/collection-import` (not a Bearer) | Clients must send `X-AI-Webhook-Signature: sha256=` where the digest is HMAC-SHA256 over the **raw request body only** (from **1.2.2**, [#214](https://github.com/qiick-io/externa-core/pull/214)). Empty `AI_WEBHOOK_TOKEN` outside `local` → **401**; mismatch → **401**. Bearer / `X-AI-Webhook-Token` alone is not enough. CSRF stays excepted for this path (no session). Details + PHP snippet: [AI API](/docs/ai-api). ```env AI_WEBHOOK_TOKEN=generate-a-long-random-secret ``` ## Daily prompt limit | Config | Env | Default | | -------------------- | ----------------------- | ------- | | `daily_prompt_limit` | `AI_DAILY_PROMPT_LIMIT` | `0` | `0` means **unlimited**. When set to a positive integer, `AiChatController` enforces a per-user daily cap. ```env AI_DAILY_PROMPT_LIMIT=0 ``` ## Embeddings and MCP | Config | Env | Default | Role | | -------------------- | ----------------------- | ------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `embeddings.enabled` | `AI_EMBEDDINGS_ENABLED` | `false` | When `true`, `SearchSimilarCollectionItems` uses stored vectors (cosine over JSON float arrays; pgvector later). Item saves queue `GenerateCollectionItemEmbeddingJob`. Without stored vectors / on provider failure, text fallback (`semantic-lite`) still runs. | | `mcp.enabled` | `AI_MCP_ENABLED` | `false` | Toggles MCP-related features. | ```env AI_EMBEDDINGS_ENABLED=false AI_MCP_ENABLED=false ``` Embedding **caching** block (separate from the feature flag): ```php 'caching' => [ 'embeddings' => [ 'cache' => false, 'store' => env('CACHE_STORE', 'database'), ], ], ``` ## Conversations tables ```php 'conversations' => [ 'connection' => env('DB_CONNECTION'), 'tables' => [ 'conversations' => 'agent_conversations', 'messages' => 'agent_conversation_messages', ], 'generate_title' => false, // titles from first prompt via Str::limit ], ``` | Setting | Meaning | | ---------------- | ------------------------------------------------------------------------------------------------------------------------------ | | Tables | Laravel AI conversation persistence uses `agent_conversations` and `agent_conversation_messages` on the default DB connection. | | `generate_title` | `false` — Externa skips an extra LM call for titles; the first prompt is truncated instead. | HTTP CRUD for conversations is under `/ai/conversations/*` (session auth + `can-use-ai`). See [AI API](/docs/ai-api). ## Attachments cleanup Expired chat attachments are purged by `ai:cleanup-attachments` (scheduled daily). Configure retention via attachment `expires_at` in application code; the command deletes expired `AiChatAttachment` rows and their files. ## Minimal local AI `.env` ```env AI_DEFAULT_PROVIDER=local LOCAL_AI_URL=http://127.0.0.1:1234/v1 LOCAL_AI_API_KEY= LOCAL_AI_MODEL=openai/gpt-oss-20b # LOCAL_AI_TRANSLATE_MODEL=phi-4-mini-instruct AI_REMOTE_IMPORT_HOSTS= AI_WEBHOOK_TOKEN= AI_DAILY_PROMPT_LIMIT=0 AI_EMBEDDINGS_ENABLED=false AI_MCP_ENABLED=false ``` --- # Routing overview Source: https://docs.externa.qiick.io/docs/routing Externa exposes a **session-cookie** HTTP surface for the Inertia SPA and same-origin JSON endpoints, plus a separate **stateless Public CMS API** under `/api/v1` (Bearer API keys or anonymous `public` role). > **Route files** > Entry: `routes/web.php` (session/Inertia). API: `routes/api.php` → `/api/v1/*`. Domain modules: `admin.php`, `settings.php`, `collections.php`, `ai.php`. Console/schedule: `routes/console.php`. Health: `/up` via `bootstrap/app.php`. ## Composition (`routes/web.php`) 1. **`/` (`home`)** — redirects to `dashboard` if authenticated, otherwise `login`. 2. **Authenticated shell** (`auth` + `verified`): - `GET /dashboard` — also requires `permission:can-show-dashboard` - Notification JSON helpers: `notifications`, `notifications/unread-count`, `notifications/read` 3. **`require`** sibling files: - `admin.php` — users, groups, roles, permissions, activity logs, files - `settings.php` — account / profile / 2FA settings pages - `collections.php` — collections, fields, items - `ai.php` — AI webhook + authenticated AI UI/API Fortify registers login/register/password/2FA routes separately (prefix empty, `web` middleware). ## Middleware layers Configured in `bootstrap/app.php`: ### Routing registration ```php ->withRouting( web: __DIR__.'/../routes/web.php', api: __DIR__.'/../routes/api.php', commands: __DIR__.'/../routes/console.php', health: '/up', apiPrefix: 'api', ) ``` - **`web`** — Inertia SPA, Fortify, admin/collections/AI session routes (cookies, CSRF). - **`api`** — Public CMS API (`/api/v1/...`), no session; `ResolveApiAccess` + collection permission guard. See [Public CMS API](/docs/public-cms-api). ### Aliases | Alias | Class | Use | | ------------------ | ------------------------- | -------------------------------------------- | | `permission` | `EnsureUserHasPermission` | `permission:{PermissionEnum value}` | | `can.manage.files` | `EnsureCanManageFiles` | File-manager route → file permission mapping | ### Appended to `web` | Middleware | Role | | ---------------------------------- | -------------------------- | | `HandleAppearance` | Appearance cookie / theme | | `HandleInertiaRequests` | Shared Inertia props | | `AddLinkHeadersForPreloadedAssets` | Asset preload link headers | ### Cookie / CSRF exceptions - Cookies **not** encrypted: `appearance`, `sidebar_state`, `locale` - CSRF **except**: `ai/webhooks/collection-import`, `api/graphql`, and bare `graphql` (no `/graphql` route is registered — clients use `/api/graphql`) ### Typical stacks by area | Area | Middleware | | ----------------------------- | ------------------------------------------------------------------------------------------------------------------------ | | Dashboard / notifications | `auth`, `verified` (+ permission on dashboard) | | Admin resources | `auth`, `verified`, `permission:…` per action | | Files | `auth`, `verified`, `can.manage.files` | | Collections | `auth`, `verified`, `permission:can-*-collections` (bulk via FormRequest — see [Collections API](/docs/collections-api)) | | Project / appearance settings | `auth`, `verified`, `permission:can-manage-project-settings` | | AI authenticated | `auth`, `verified`, `permission:can-use-ai` (+ extra on some routes) | | AI webhook | `throttle:30,1` + HMAC `X-AI-Webhook-Signature` over raw body (no session; 401 on fail from 1.2.2) | | Public CMS API / GraphQL | `throttle:api` + `ResolveApiAccess` (no session/CSRF) | ## Client surfaces Clients are expected to be: 1. The **Inertia React** frontend (same origin, session cookie, CSRF). 2. Server-side jobs / tools using the same auth context. 3. The **collection import webhook** with HMAC over the raw body (not a user session). 4. **External sites / apps** via the [Public CMS API](/docs/public-cms-api) (`/api/v1`, anonymous `public` role and/or Bearer `ek_…` API keys). Treat JSON from `/files/*`, `/ai/*`, and session `/collections/*` as **first-party SPA APIs**. Use `/api/v1` for headless integrations. ## Inertia shared props `HandleInertiaRequests::share()` merges: | Prop | Source / meaning | | ---------------------------- | --------------------------------------------------------------------------------------- | | `name` | `config('app.name')` | | `auth.user` | Current user model or `null` | | `auth.permissions` | Effective permission **name list** from `EffectivePermissionResolver::permissionsFor()` | | `auth.roleNames` | Effective role names (direct + via groups) | | `auth.isSuperAdmin` | Whether effective roles include `super-admin` | | `sidebarOpen` | From `sidebar_state` cookie (`true` when missing) | | `collectionLocales` | Project settings `content_locales` (seeded from `config/collections.php`) | | `collectionLocaleMeta` | `{ code, name, flag }[]` for enabled content locales | | `defaultContentLocale` | Project settings default content locale | | `notifications.unread_count` | Count of unread database notifications for the user | Frontend permission checks use `useCan()` against `auth.permissions` / `auth.isSuperAdmin` (see [Effective permissions](/docs/permissions)). ## Wayfinder Named Laravel routes and controller actions are exposed to TypeScript via **Laravel Wayfinder** (`@laravel/vite-plugin-wayfinder`). Prefer generated helpers under `resources/js/routes` / actions instead of hard-coding path strings in React. ## Deep-link route ordering Static path segments are registered **before** parameterized catch-alls so reserved names are not captured as IDs: - Files: `/files/list`, `/files/upload`, … before `GET /files/{folder?}` - AI: `/ai/status`, `/ai/conversations`, … before `GET /ai/{conversation}` (UUID) ## Admin UI deep-links (drawers / panels) List screens sync selected drawers into the query string (Inertia `replace` + `preserveState`) so a URL can reopen the same surface: | Surface | Query | Behavior | | ----------------- | ----------------------------- | ---------------------------------------------------------------------- | | Collections index | `?edit={id}` / `?new=1` | Opens create/edit collection drawer | | Users / Groups | `?edit={id}` / `?new=1` | Opens user or group drawer | | Collection fields | `?field={id}` / `?newField=1` | Opens edit field drawer or add-field type picker | | Files | `?file={id}` | Opens the file detail panel (fetches by id if not on the current page) | Closing the drawer/panel removes the param without dropping other filters (search, trash, tags, …). Nested field-type multi-step beyond `newField` is out of scope for this sync. Item **full-page** edit uses a separate [`return`](/docs/collection-items#return-to-list-filters--page) query to restore list filters — not a drawer deep-link. ## Next | Surface | Docs | | -------------------------- | ------------------------------------------ | | Admin + files JSON | [Admin API](/docs/admin-api) | | Collections (session) | [Collections API](/docs/collections-api) | | Public CMS API (`/api/v1`) | [Public CMS API](/docs/public-cms-api) | | GraphQL | [GraphQL](/docs/graphql) | | AI | [AI API](/docs/ai-api) | | Project settings | [Project settings](/docs/project-settings) | --- # Admin API Source: https://docs.externa.qiick.io/docs/admin-api Admin routes live in `routes/admin.php`. All require `auth` + `verified`. Resource actions use `permission:{PermissionEnum}`; the file manager uses `can.manage.files`, which maps route name / HTTP method / bulk `action` to a file permission. > **Client** > These are same-origin session endpoints for the Inertia SPA (and occasional JSON fetches), not a public API. See [Routing overview](/docs/routing). ## Users | Method | Path | Route name | Permission | | -------- | ----------------------- | -------------------- | ----------------------------------------------------------------- | | `GET` | `/users` | `users.index` | `can-show-users` | | `POST` | `/users` | `users.store` | `can-create-users` | | `PUT` | `/users/{user}` | `users.update` | `can-edit-users` | | `DELETE` | `/users/{user}` | `users.destroy` | `can-delete-users` | | `POST` | `/users/{user}/restore` | `users.restore` | `can-restore-users` | | `DELETE` | `/users/{user}/force` | `users.force-delete` | `can-force-delete-users` | | `POST` | `/users/bulk-actions` | `users.bulk-actions` | Enforced in controller per action (not a single route middleware) | Controller: `App\Http\Controllers\Admin\UserController`. ## Groups | Method | Path | Route name | Permission | | -------- | ------------------------- | --------------------- | -------------------------------------------------------------------------- | | `GET` | `/groups` | `groups.index` | `can-show-groups` | | `POST` | `/groups` | `groups.store` | `can-create-groups` | | `PUT` | `/groups/{group}` | `groups.update` | `can-edit-groups` | | `DELETE` | `/groups/{group}` | `groups.destroy` | `can-delete-groups` | | `POST` | `/groups/{group}/restore` | `groups.restore` | `can-restore-groups` | | `DELETE` | `/groups/{group}/force` | `groups.force-delete` | `can-force-delete-groups` | | `POST` | `/groups/bulk-actions` | `groups.bulk-actions` | Enforced in FormRequest per action (`delete` / `restore` / `force_delete`) | Controller: `UserGroupController`. Soft-delete lifecycle matches users (trashed filter on index, restore, force-delete, bulk). Groups inherit role permissions and role-tied collection/file ACL — see [Users & groups](/docs/users-and-groups). ## Roles | Method | Path | Route name | Permission | | -------- | ------------------------------ | -------------------- | --------------------------------- | | `GET` | `/settings/roles` | `roles.index` | `can-show-roles` | | `GET` | `/settings/roles/create` | `roles.create` | `can-create-roles` | | `POST` | `/settings/roles` | `roles.store` | `can-create-roles` | | `GET` | `/settings/roles/{role}/edit` | `roles.edit` | `can-edit-roles` | | `PUT` | `/settings/roles/{role}` | `roles.update` | `can-edit-roles` | | `DELETE` | `/settings/roles/{role}` | `roles.destroy` | `can-delete-roles` | | `POST` | `/settings/roles/bulk-actions` | `roles.bulk-actions` | Enforced in controller per action | Controller: `RoleController`. Create/edit are Inertia form pages; index/store/update/destroy also serve the SPA. Role forms also sync **Collection access** for the [Public CMS API](/docs/public-cms-api). ## API keys | Method | Path | Route name | Permission | | -------- | ----------------------------- | ------------------ | --------------------- | | `GET` | `/settings/api-keys` | `api-keys.index` | `can-show-api-keys` | | `POST` | `/settings/api-keys` | `api-keys.store` | `can-manage-api-keys` | | `DELETE` | `/settings/api-keys/{apiKey}` | `api-keys.destroy` | `can-manage-api-keys` | Controller: `ApiKeyController`. Secrets (`ek_…`) are shown once after create. Keys authenticate `/api/v1` — see [Public CMS API](/docs/public-cms-api). ## Permissions (+ sync) | Method | Path | Route name | Permission | | -------- | ------------------------------------ | --------------------- | ------------------------ | | `GET` | `/settings/permissions` | `permissions.index` | `can-show-permissions` | | `POST` | `/settings/permissions` | `permissions.store` | `can-create-permissions` | | `PUT` | `/settings/permissions/{permission}` | `permissions.update` | `can-edit-permissions` | | `DELETE` | `/settings/permissions/{permission}` | `permissions.destroy` | `can-delete-permissions` | | `POST` | `/settings/permissions/sync` | `permissions.sync` | `can-edit-permissions` | `POST /settings/permissions/sync` runs `php artisan permissions:sync` (optional prune from the request body / controller). Prefer enum-driven sync over ad-hoc DB permission rows — see [Effective permissions](/docs/permissions) and [Extending Externa](/docs/extending). ## Activity logs | Method | Path | Route name | Permission | | ------ | ---------------- | --------------------- | ------------------------ | | `GET` | `/activity-logs` | `activity-logs.index` | `can-show-activity-logs` | Controller: `ActivityLogController` (Spatie activity log UI). ## Jobs monitor Defined in `routes/admin.php` under `/settings/jobs`: | Method | Path | Route name | Permission | | -------- | ----------------------------- | -------------- | ----------------- | | `GET` | `/settings/jobs` | `jobs.index` | `can-show-jobs` | | `POST` | `/settings/jobs/{uuid}/retry` | `jobs.retry` | `can-manage-jobs` | | `DELETE` | `/settings/jobs/{uuid}` | `jobs.destroy` | `can-manage-jobs` | Product notes: [Operations — Jobs monitor](/docs/operations#jobs-monitor-admin-ui). ## Project settings (related) Project + branding HTTP live in `routes/settings.php` (not `admin.php`) and require `can-manage-project-settings`: | Method | Path | Name | | ----------- | -------------------------------- | --------------------------------------- | | `GET`/`PUT` | `/settings/project` | `project.edit` / `project.update` | | `POST` | `/settings/project/webhook-test` | `project.webhook-test` | | `GET`/`PUT` | `/settings/appearance` | `appearance.edit` / `appearance.update` | Full guide: [Project settings](/docs/project-settings). --- ## Files (`can.manage.files`) All routes below sit in: ```php Route::middleware(['auth', 'verified'])->group(...) Route::middleware('can.manage.files')->group(...) ``` Prefix: `/files`, name prefix: `files.`. ### How permissions are chosen `EnsureCanManageFiles` resolves a `PermissionEnum` then checks `EffectivePermissionResolver`: | Route name / case | Permission | | -------------------------------------------------------------- | ------------------------------------ | | `files.restore` | `can-restore-files` | | `files.force-delete` | `can-force-delete-files` | | `files.download`, `files.download-many`, `files.zips.download` | `can-download-files` | | `files.favorite`, `files.unfavorite` | `can-favorite-files` | | `files.copy` | `can-copy-files` | | `files.replace` | `can-replace-files` | | `files.tags` | `can-tag-files` | | `files.update` | `can-update-file-metadata` | | `files.bulk` | Depends on JSON `action` (see below) | | Other `GET`/`HEAD` | `can-show-files` | | Other `POST` | `can-create-files` | | Other `PUT`/`PATCH` | `can-edit-files` | | Other `DELETE` | `can-delete-files` | **Bulk `action` → permission:** | `action` | Permission | | ------------------------ | ------------------------ | | `restore` | `can-restore-files` | | `force_delete` | `can-force-delete-files` | | `favorite`, `unfavorite` | `can-favorite-files` | | `tag`, `untag` | `can-tag-files` | | `copy` | `can-copy-files` | | `delete` | `can-delete-files` | | `move` | `can-edit-files` | | _(default)_ | `can-edit-files` | ### List, browse, tags | Method | Path | Route name | Typical permission | | ------ | ------------------ | ------------------ | ----------------------------------------- | | `GET` | `/files/list` | `files.list` | `can-show-files` | | `GET` | `/files/tags` | `files.tags.index` | `can-show-files` | | `GET` | `/files/{folder?}` | `files.index` | `can-show-files` (folder must be numeric) | `files.index` is the Inertia file-manager page. Static `/files/*` paths are registered **before** `{folder}` so names like `list` are not treated as folder IDs. ### Upload and chunked upload | Method | Path | Route name | Typical permission | | ------ | ------------------------- | ------------------------ | ------------------ | | `POST` | `/files/upload` | `files.upload` | `can-create-files` | | `POST` | `/files/folders` | `files.folders.store` | `can-create-files` | | `POST` | `/files/uploads/init` | `files.uploads.init` | `can-create-files` | | `POST` | `/files/uploads/chunk` | `files.uploads.chunk` | `can-create-files` | | `POST` | `/files/uploads/complete` | `files.uploads.complete` | `can-create-files` | | `GET` | `/files/uploads/status` | `files.uploads.status` | `can-show-files` | Stale incomplete uploads are cleaned by `files:cleanup-uploads` ([Files configuration](/docs/files-configuration)). Upload / replace / chunked init+complete / rename reject dangerous extensions via `ForbiddenUploadExtension` (validation error). Name, metadata strings, and tags are HTML-stripped (`PlainTextSanitizer`). See [File manager — Upload security](/docs/file-manager#upload-security). ### Bulk, download, zip | Method | Path | Route name | Permission notes | | ------ | --------------------- | --------------------- | -------------------------- | | `POST` | `/files/bulk` | `files.bulk` | Per `action` (table above) | | `POST` | `/files/download` | `files.download-many` | `can-download-files` | | `GET` | `/files/zips/{jobId}` | `files.zips.download` | `can-download-files` | Multi-file/folder zips are prepared asynchronously; expired archives are removed by `files:cleanup-zips`. ### Single-file mutations | Method | Path | Route name | Permission | | -------- | -------------------------- | -------------------- | --------------------------------- | | `PATCH` | `/files/{file}` | `files.update` | `can-update-file-metadata` | | `POST` | `/files/{file}/replace` | `files.replace` | `can-replace-files` | | `POST` | `/files/{file}/copy` | `files.copy` | `can-copy-files` | | `POST` | `/files/{file}/favorite` | `files.favorite` | `can-favorite-files` | | `DELETE` | `/files/{file}/favorite` | `files.unfavorite` | `can-favorite-files` | | `PUT` | `/files/{file}/tags` | `files.tags` | `can-tag-files` | | `GET` | `/files/{file}/download` | `files.download` | `can-download-files` | | `GET` | `/files/{file}/thumbnail` | `files.thumbnail` | `can-show-files` | | `GET` | `/files/{file}/where-used` | `files.where-used` | `can-show-files` | | `PATCH` | `/files/{file}/move` | `files.move` | `can-edit-files` | | `PATCH` | `/files/{file}/rename` | `files.rename` | `can-edit-files` | | `DELETE` | `/files/{file}` | `files.destroy` | `can-delete-files` | | `POST` | `/files/{file}/restore` | `files.restore` | `can-restore-files` | | `DELETE` | `/files/{file}/force` | `files.force-delete` | `can-force-delete-files` | | `POST` | `/files/{file}/attach` | `files.attach` | `can-create-files` (POST default) | | `POST` | `/files/{file}/detach` | `files.detach` | `can-create-files` (POST default) | Controller: `App\Http\Controllers\Admin\FileController`. Storage/threshold config: [Files configuration](/docs/files-configuration). Tree model: [File tree model](/docs/file-tree-model). --- # Collections API Source: https://docs.externa.qiick.io/docs/collections-api Collection routes are defined explicitly in `routes/collections.php` (not a Laravel `Route::resource`). Every route requires **`auth` + `verified`**. Most actions also use Spatie **`permission:`** middleware; bulk delete/restore/force-delete authorize in the FormRequest. Super-admin bypasses via `Gate::before`. > Collection schema and item CRUD are gated server-side with `PermissionEnum` (`can-show-collections`, `can-create-collections`, `can-edit-collections`, `can-delete-collections`, restore/force-delete). The React UI (`useCan`) and AI tools share the same names. Item read/write also applies **`CollectionPermissionEnforcer`** (role `collection_permissions` field ACL / `item_filter`). See [Effective permissions](/docs/permissions). > **Headless Public CMS API** > For external websites and integrations, use **`/api/v1/collections...`** (not these session routes). Auth is anonymous `public` role or Bearer API keys, gated by the Collection access matrix. See [Public CMS API](/docs/public-cms-api) and [GraphQL](/docs/graphql). Runnable companion: [externa-bruno](https://github.com/qiick-io/externa-bruno) (PublicApi + GraphQL). Session pack helpers also live under Bruno `Admin/` (not public API). Relevant permission names: - `can-show-collections` - `can-create-collections` - `can-edit-collections` - `can-delete-collections` - `can-restore-collections` - `can-force-delete-collections` ## Collections | Method | Path | Route name | Permission / notes | | -------- | --------------------------------------------- | ------------------------------- | ---------------------------------------------------------------------------------- | | `GET` | `/collections` | `collections.index` | `can-show-collections` (`search`, `sort`, `direction`, `trashed`) | | `POST` | `/collections` | `collections.store` | `can-create-collections` | | `POST` | `/collections/bulk` | `collections.bulk` | FormRequest: `delete` → delete; `restore` → restore; `force_delete` → force-delete | | `POST` | `/collections/packs/{pack}` | `collections.packs.apply` | `can-create-collections` (collection pack; auto-create deps) | | `GET` | `/collections/{collection}` | `collections.show` | `can-show-collections` | | `PUT` | `/collections/{collection}` | `collections.update` | `can-edit-collections` (**PUT only**) | | `DELETE` | `/collections/{collection}` | `collections.destroy` | `can-delete-collections` (soft delete) | | `POST` | `/collections/{collection}/restore` | `collections.restore` | `can-restore-collections` | | `DELETE` | `/collections/{collection}/force` | `collections.force-delete` | `can-force-delete-collections` | | `PUT` | `/collections/{collection}/singleton-content` | `collections.singleton-content` | `can-edit-collections` | Controller: `ContentCollectionController`. Bulk body: `{ action, ids }` with `action` ∈ `delete` \| `restore` \| `force_delete`. ## Fields | Method | Path | Route name | Permission | | -------- | ----------------------------------------------------------------- | ------------------------------------------- | ---------------------- | | `GET` | `/collections/{collection}/fields` | `collections.fields.index` | `can-show-collections` | | `POST` | `/collections/{collection}/fields` | `collections.fields.store` | `can-edit-collections` | | `POST` | `/collections/{collection}/field-packs/{pack}` | `collections.field-packs.apply` | `can-edit-collections` | | `PATCH` | `/collections/{collection}/fields/{field}` | `collections.fields.update` | `can-edit-collections` | | `DELETE` | `/collections/{collection}/fields/{field}` | `collections.fields.destroy` | `can-edit-collections` | | `POST` | `/collections/{collection}/fields/reorder` | `collections.fields.reorder` | `can-edit-collections` | | `POST` | `/collections/{collection}/fields/{field}/duplicate` | `collections.fields.duplicate` | `can-edit-collections` | | `POST` | `/collections/{collection}/fields/{field}/toggle-form-visibility` | `collections.fields.toggle-form-visibility` | `can-edit-collections` | | `POST` | `/collections/{collection}/fields/{field}/layout-width` | `collections.fields.update-layout-width` | `can-edit-collections` | | `PUT` | `/collections/{collection}/form-layout` | `collections.form-layout.update` | `can-edit-collections` | Controller: `FieldController`. Types: [Field types](/docs/field-types). Packs: [Field types — Field packs](/docs/field-types#field-packs). Locales for labels: project **content locales** ([Project settings](/docs/project-settings)). ## Items Helpers registered before `{item}` so `new` / `options` / `export` are not captured as IDs: | Method | Path | Route name | Permission | | -------- | ------------------------------------------------ | --------------------------------------- | -------------------------------------------------- | | `GET` | `/collections/{collection}/items/new` | `collections.items.new` | `can-create-collections` | | `GET` | `/collections/{collection}/items/options` | `collections.items.field-options` | `can-show-collections` | | `GET` | `/collections/{collection}/items/export` | `collections.items.export` | `can-show-collections` (`?format=csv\|json`) | | `PUT` | `/collections/{collection}/list-columns` | `collections.items.list-columns.update` | `can-edit-collections` | | `POST` | `/collections/{collection}/items/{item}/restore` | `collections.items.restore` | `can-restore-collections` | | `DELETE` | `/collections/{collection}/items/{item}/force` | `collections.items.force-delete` | `can-force-delete-collections` | | `GET` | `/collections/{collection}/items` | `collections.items.index` | `can-show-collections` | | `POST` | `/collections/{collection}/items` | `collections.items.store` | `can-create-collections` | | `POST` | `/collections/{collection}/items/bulk` | `collections.items.bulk` | FormRequest per `action` (same as collection bulk) | | `GET` | `/collections/{collection}/items/{item}` | `collections.items.show` | `can-show-collections` | | `PUT` | `/collections/{collection}/items/{item}` | `collections.items.update` | `can-edit-collections` (**PUT only**) | | `DELETE` | `/collections/{collection}/items/{item}` | `collections.items.destroy` | `can-delete-collections` | Controller: `ItemController`. Data model: [Collections data model](/docs/collections-data-model). Product UX (filters, History, export): [Collection items](/docs/collection-items). Item bulk also runs `CollectionPermissionEnforcer::assertItemWritable` for delete/force-delete (and skips restore). IDs from another collection are rejected. ## Item revisions | Method | Path | Route name | Permission | | ------ | --------------------------------------------------------------------- | ------------------------------------- | ---------------------- | | `GET` | `/collections/{collection}/items/{item}/revisions` | `collections.items.revisions.index` | `can-show-collections` | | `POST` | `/collections/{collection}/items/{item}/revisions/{revision}/restore` | `collections.items.revisions.restore` | `can-edit-collections` | | `GET` | `/collections/{collection}/items/{item}/preview-as-role` | `collections.items.preview-as-role` | `can-show-collections` | Snapshots are written on every `CollectionItemValuesWriter::sync` (create/update/restore). Soft-deleted items keep history. Preview-as-role is JSON (`role_id` or `as_public=1`) — see [Collection items](/docs/collection-items#preview-as-role--public). ## Imports via AI / webhook Bulk import does **not** live on these REST routes. Use: - Authenticated AI tools (`ImportCollectionCsv`, `ImportRemoteJson`, …) under [AI API](/docs/ai-api) - HMAC webhook `POST /ai/webhooks/collection-import` Import job status: `GET /ai/import-jobs/{jobId}` (requires `can-use-ai` **and** `can-create-collections`). ## Authorization Verified users without the matching `can-*-collections` permission receive **403**. Pest: `tests/Feature/Collections/CollectionAuthorizationTest.php`, `tests/Feature/Collections/BulkActionsTest.php`. ## Advanced list filters Admin items index: **Search title** + **Filters** popover (AND multi-field) write this dialect into the URL. Same operators for Public API + GraphQL `filter`: - `?filter[title]=hello` — contains (LIKE), backward compatible - `?filter[status][_eq]=published` - `?filter[status][_neq]=draft` - `?filter[tags][_in]=a,b` - `?filter[body][_null]=1` / `[_nnull]=1` - `?filter[price][_gte]=10` / `[_lte]` / `[_gt]` / `[_lt]` - `?filter[title][_contains]=hello` See also [Public CMS API](/docs/public-cms-api#advanced-list-filters) and [GraphQL](/docs/graphql). --- # Public CMS API Source: https://docs.externa.qiick.io/docs/public-cms-api Externa exposes a **headless Public CMS API** under `/api/v1` so websites, apps, and integrations can read (and optionally write) collection data **without** an admin session. Access is controlled by a separate permission model from Spatie admin permissions. > | Surface | What it controls | How | > | --------------------------------------------- | ------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------- | > | **Admin UI** (`/settings/roles`, `/users`, …) | Who can use the backoffice | Spatie `PermissionEnum` + `EffectivePermissionResolver` | > | **Public CMS API** (`/api/v1/...`) | Who can create/read/update/delete **collection items** and **files** over HTTP | Tables `collection_permissions` (role × collection × action) and `file_permissions` (role × action, global) | > > Having `can-edit-collections` or `can-show-files` in the admin does **not** grant `/api/v1` access. You must configure **Collection access** and/or **Files access** on a role (usually `public` or a custom role bound to an API key). Full setup walkthrough is below. Endpoint tables **with request/response examples** and the [Bruno](https://github.com/qiick-io/externa-bruno) companion are at the end. Copyable TypeScript / OpenAPI / JSON Schema: [Public CMS API client types](/docs/public-cms-api-types). Minimal frontends: [Headless starter](/docs/headless-starter) (Next.js + Nuxt). Ops: [Performance tuning](/docs/performance-tuning). Related admin concepts: [Roles & permissions](/docs/roles-and-permissions), [Effective permissions](/docs/permissions). **1.x stability / deprecation:** [API & GraphQL compatibility](/docs/api-compatibility). > **Bruno collection** > Ready-to-run requests for every Public CMS + GraphQL path: **[github.com/qiick-io/externa-bruno](https://github.com/qiick-io/externa-bruno)**. Install [Bruno](https://www.usebruno.com/), open the repo folder as a collection, select env **Local**, copy `.env.sample` → `.env` (`EXTERNA_API_KEY`). Point `base_url` at local or staging. Docs below remain the source of example payloads; Bruno executes them. ## Mental model ``` Request to /api/v1/... │ ▼ ┌───────────────────────┐ │ ResolveApiAccess │ │ • Bearer ek_… ? │──yes──► validate key → role on api_keys.role_id │ • no Bearer │──no───► actor = public role └───────────────────────┘ │ ▼ ┌───────────────────────┐ │ CollectionPermission │ Missing grant = DENY │ Guard / FilePermission│ Actions: create | read | read_private | update | delete │ Guard (cached) │ Files are global; private visibility is per-file └───────────────────────┘ │ ▼ Controller runs (or 403) ``` | Actor | How you authenticate | Permissions come from | | ---------------------- | ---------------------------- | ------------------------------------------------------- | | **Anonymous / public** | No `Authorization` header | System role `public` | | **API key** | `Authorization: Bearer ek_…` | The **role** assigned to that key (never `super-admin`) | Default deny: if there is **no** row in `collection_permissions` for `(role_id, collection_id, action)`, or in `file_permissions` for `(role_id, action)`, the action is forbidden. ## Quick start (typical website read) Goal: a public site can `GET` collections/items for `posts`, but cannot create/update/delete. 1. Sign in as an admin who can edit roles (`can-edit-roles`). 2. Open **Roles** → edit the **`public`** role (system role; cannot be deleted, renamed, or assigned to users). 3. In **Collection access**, set **Read** to allowed (✓) for the collections you want public. Leave Create / Update / Delete as denied (✕). 4. Optionally open **Files access** and grant **Read** if the site needs `/api/v1/files*` or expanded image URLs on items. Grant **Read private** only for roles that should see files marked private in the file manager. 5. Save. 6. Call without a key: ```bash curl -sS http://externa-core.test/api/v1/collections \ -H 'Accept: application/json' curl -sS http://externa-core.test/api/v1/collections/posts/items \ -H 'Accept: application/json' ``` 7. Confirm mutations fail: ```bash curl -sS -X POST http://externa-core.test/api/v1/collections/posts/items \ -H 'Accept: application/json' \ -H 'Content-Type: application/json' \ -d '{"data":{}}' # → 403 ``` ## Setup guide (full) ### 1. Ensure migrations and seeds ran You need: - Role `public` (`is_system`, `is_assignable = false`) from `RoleSeeder` - Tables `collection_permissions`, `file_permissions`, and `api_keys` - Spatie permissions `can-show-api-keys` / `can-manage-api-keys` (via `permissions:sync`) ```bash php artisan migrate php artisan permissions:sync php artisan db:seed --class=RoleSeeder # if public role is missing ``` ### 2. Configure anonymous (public) access | Step | Where | What | | ----------------- | --------------------------------------------------------- | ------------------------------------------------------------------------------------------------- | | Edit role | Admin → **Roles** → **public** | **Collection access** and **Files access** are editable (no Spatie permission checkboxes) | | Collection matrix | Per collection columns Create / Read / Update / Delete | ✓ = allowed, ✕ = denied | | Files row | Global Create / Read / **Read private** / Update / Delete | Applies to all `/api/v1/files*`; `read_private` unlocks files with effective visibility `private` | | Save | Persist `collection_permissions` / `file_permissions` | Guard caches are flushed on sync | > **Public role locks** > Public role cannot be renamed or deleted, assigned to users or groups, or given Spatie admin permissions. It is only the anonymous API actor. ### 3. Create a role for partners / backends (optional) For write access or private read without opening the internet to everyone: 1. **Roles** → Create a normal role (e.g. `api-website`, `api-partner`). 2. You may leave Spatie admin permissions empty — they do not affect `/api/v1`. 3. Set **Collection access** for the collections/actions that key should have. 4. Set **Files access** if the key should list/upload/stream files. 5. Save. ### 4. Issue an API key | Step | Detail | | ----------- | --------------------------------------------------------------------------------------- | | UI | Settings → Access → **API Keys** (`/settings/api-keys`) | | Permissions | View list: `can-show-api-keys`. Create/revoke: `can-manage-api-keys` | | Fields | Name, **role** (not `super-admin`), optional IP allowlist, optional rate limit / expiry | | Secret | Shown **once** as `ek_…` — store it; Externa only keeps a hash | ```bash curl -sS http://externa-core.test/api/v1/collections \ -H 'Accept: application/json' \ -H "Authorization: Bearer ek_YOUR_SECRET" ``` ### 5. Point your client / Bruno - Base URL: your app URL (e.g. `http://externa-core.test` or staging) - Paths always under `/api/v1/...` - Clone **[externa-bruno](https://github.com/qiick-io/externa-bruno)**: open the folder in Bruno, env **Local** (or a copy for staging), `.env` → `EXTERNA_API_KEY`, then set `collection_slug` / `item_id` / `file_id` on requests ## How authentication is resolved Middleware: `App\Http\Middleware\ResolveApiAccess` on all `routes/api.php` v1 routes. ### No Bearer token → public - Loads role named `public` - If missing → **503** (`Public API role is not configured.`) - Binds `ApiAccess` with `actor = public` ### Bearer token → API key 1. Hash the secret and look up `api_keys` 2. Reject if missing, revoked, or expired → **401** 3. Enforce optional **IP allowlist** → **401** if client IP not listed 4. Enforce **rate limit** (key override or `API_KEY_RATE_LIMIT`, default 60/min) → **429** 5. Reject if linked role is `super-admin` → **401** 6. Bind `ApiAccess` with `actor = api_key` and that role 7. Touch `last_used_at` Route group also uses Laravel `throttle:api` for coarse traffic shaping. ## Collection access matrix Stored in `collection_permissions`: | Column | Meaning | | --------------- | -------------------------------------------------------- | | `role_id` | Spatie/`roles` id (`public` or custom) | | `collection_id` | Target collection | | `action` | `create` \| `read` \| `update` \| `delete` | | `allowed` | Must be true to grant (rows are created only for grants) | Service: `CollectionPermissionGuard` (cached per role; flushed when the role matrix is saved via `CollectionPermissionSync`). UI: role create/edit → **Collection access** table. Toggle buttons show **✓** (allowed) or **✕** (denied). ## Files access matrix Stored in `file_permissions` (global per role — **not** per folder): | Column | Meaning | | --------- | ------------------------------------------------------------ | | `role_id` | Spatie/`roles` id (`public` or custom) | | `action` | `create` \| `read` \| `read_private` \| `update` \| `delete` | | `allowed` | Must be true to grant (rows are created only for grants) | Service: `FilePermissionGuard` / `FilePermissionSync`. UI: role create/edit → **Files access**. > **Public role pattern** > For a typical marketing site on the **`public`** role: **Read** = ✓, **Read private** = ✕. Public assets stream from `/api/v1/files*`; on items they expand only with `?include=files`. Private files return **403** on `/api/v1/files/{id}` (and content/transform) and are omitted from list / item expanders. Grant **Read private** only on a custom API-key role when a trusted backend needs private assets. ### Public vs private files Each file/folder has an `access` override (`files.access`): `public`, `private`, or `null` (inherit from nearest ancestor). Effective visibility walks parents; root with no override is **public**. | Role grants | Public file | Private file (effective) | | ----------------------- | -------------------------------------------------------- | ------------------------------------------------------------------------- | | no `read` | Files API 403; item field `{ "id", "access": "denied" }` | Files API 403; item field **`null`** / omitted (no id leak) | | `read` only | List / show / content / full expand | Filtered from list; show/content **403**; item field **`null`** / omitted | | `read` + `read_private` | Same as public | Full access + expand | Admin file manager (Spatie `can.manage.files`) always sees all files; use the **Visibility** control on the detail panel (Inherit / Public / Private). Private folders apply to children unless a child overrides. **Storage:** effective-private local files use the `private_assets` disk (not `/storage/assets/...`). Public CMS clients always receive gated `/api/v1/files/{id}/content` URLs (never a static public object URL for private files). Operators: run `php artisan files:migrate-private-disk` after upgrade if legacy private bytes remain on `assets` — [Files configuration](/docs/files-configuration). ## Endpoint reference Base path: `/api/v1`. Controllers under `App\Http\Controllers\Api\V1\`. Examples use `http://externa-core.test` and a sanitized key `ek_YOUR_SECRET` — replace with your Herd host and real key. Auth header when using an API key (omit for anonymous / `public` role): ```http Authorization: Bearer ek_YOUR_SECRET Accept: application/json ``` ### Collections | Method | Path | Required action | Notes | | ------ | ---------------------------- | ----------------- | ----------------------------------------------------- | | `GET` | `/api/v1/collections` | _(implicit read)_ | Returns only collections where the actor has **read** | | `GET` | `/api/v1/collections/{slug}` | `read` | Includes field schema summary | #### List collections ```bash curl -sS http://externa-core.test/api/v1/collections \ -H 'Accept: application/json' # optional: -H "Authorization: Bearer ek_YOUR_SECRET" ``` ```json { "data": [ { "id": 1, "name": "Posts", "slug": "posts", "is_singleton": false } ] } ``` With no read grants: `"data": []` (still **200**, not 403). #### Get collection ```bash curl -sS http://externa-core.test/api/v1/collections/posts \ -H 'Accept: application/json' \ -H "Authorization: Bearer ek_YOUR_SECRET" ``` ```json { "data": { "id": 1, "name": "Posts", "slug": "posts", "is_singleton": false, "fields": [ { "id": 10, "name": "title", "type": "text", "settings": {} }, { "id": 11, "name": "status", "type": "select", "settings": {} } ] } } ``` ### Items | Method | Path | Required action | Success | | -------- | --------------------------------------- | --------------- | -------------------------------------------- | | `GET` | `/api/v1/collections/{slug}/items` | `read` | `200` + `data` + pagination `meta` | | `GET` | `/api/v1/collections/{slug}/items/{id}` | `read` | `200` | | `POST` | `/api/v1/collections/{slug}/items` | `create` | `201` — body `{ "data": { …field values } }` | | `PATCH` | `/api/v1/collections/{slug}/items/{id}` | `update` | `200` — body `{ "data": { … } }` | | `DELETE` | `/api/v1/collections/{slug}/items/{id}` | `delete` | `204` | #### List items ```bash curl -sS 'http://externa-core.test/api/v1/collections/posts/items?page=1&per_page=15' \ -H 'Accept: application/json' \ -H "Authorization: Bearer ek_YOUR_SECRET" ``` Advanced filter (same dialect as admin / GraphQL): ```bash curl -sS 'http://externa-core.test/api/v1/collections/posts/items?filter[status][_eq]=published&page=1&per_page=15' \ -H 'Accept: application/json' \ -H "Authorization: Bearer ek_YOUR_SECRET" ``` Also: `filter[title][_contains]=hello`, `filter[status][_in]=published,draft`, `filter[body][_nnull]=1`. See [Advanced list filters](#advanced-list-filters). ```json { "data": [ { "id": 1, "collection_id": 3, "data": { "title": "Hello", "status": "published" }, "created_at": "2026-07-20T12:00:00+00:00", "updated_at": "2026-07-20T12:00:00+00:00" } ], "meta": { "current_page": 1, "last_page": 1, "per_page": 15, "total": 1 } } ``` #### Get item ```bash curl -sS http://externa-core.test/api/v1/collections/posts/items/1 \ -H 'Accept: application/json' \ -H "Authorization: Bearer ek_YOUR_SECRET" ``` ```json { "data": { "id": 1, "collection_id": 3, "data": { "title": "Hello", "status": "published" }, "created_at": "2026-07-20T12:00:00+00:00", "updated_at": "2026-07-20T12:00:00+00:00" } } ``` Optional query: `locale`, `include_all_translations`, `include` (see [Expand with `include`](#expand-with-include) and the query table below). #### Create item ```bash curl -sS -X POST http://externa-core.test/api/v1/collections/posts/items \ -H 'Accept: application/json' \ -H 'Content-Type: application/json' \ -H "Authorization: Bearer ek_YOUR_SECRET" \ -d '{"data":{"title":"New post","status":"draft"}}' ``` ```json { "data": { "id": 42, "collection_id": 3, "data": { "title": "New post", "status": "draft" }, "created_at": "2026-07-20T12:00:00+00:00", "updated_at": "2026-07-20T12:00:00+00:00" } } ``` Status **201**. M2M junction values use `{ "related_item_id": 12, "meta": { "sort": 1 } }` — see [M2M junction metadata](#m2m-junction-metadata). Nested blocks payloads: [Field types — Blocks](/docs/field-types#blocks-field). #### Update item ```bash curl -sS -X PATCH http://externa-core.test/api/v1/collections/posts/items/42 \ -H 'Accept: application/json' \ -H 'Content-Type: application/json' \ -H "Authorization: Bearer ek_YOUR_SECRET" \ -d '{"data":{"title":"Updated title","status":"published"}}' ``` Response **200** — same item shape as Get item. #### Delete item ```bash curl -sS -X DELETE http://externa-core.test/api/v1/collections/posts/items/42 \ -H 'Accept: application/json' \ -H "Authorization: Bearer ek_YOUR_SECRET" ``` Response **204** — empty body (soft-delete). ### Files | Method | Path | Required action | Notes | | -------- | ------------------------------------- | ------------------------------------ | --------------------------------------------------------------------------------------------- | | `GET` | `/api/v1/files` | `read` | List: `parent_id`, `search`, `page`, `per_page`. Private files omitted without `read_private` | | `GET` | `/api/v1/files/{id}` | `read` (+ `read_private` if private) | Metadata + content/transform URLs | | `GET` | `/api/v1/files/{id}/content` | `read` (+ `read_private` if private) | Stream original bytes | | `GET` | `/api/v1/files/{id}/transforms/{key}` | `read` (+ `read_private` if private) | Project preset or `size-{n}` | | `POST` | `/api/v1/files` | `create` | Multipart upload (`file`) | | `PATCH` | `/api/v1/files/{id}` | `update` | Metadata / focal / rename | | `DELETE` | `/api/v1/files/{id}` | `delete` | Soft delete | #### List files ```bash curl -sS 'http://externa-core.test/api/v1/files?page=1&per_page=15' \ -H 'Accept: application/json' \ -H "Authorization: Bearer ek_YOUR_SECRET" ``` ```json { "data": [ { "id": 12, "parent_id": null, "type": "file", "access": "public", "filename": "hero.jpg", "title": "Hero", "description": null, "mime_type": "image/jpeg", "extension": "jpg", "filesize": 204800, "width": 1920, "height": 1080, "focal_point_x": 0.5, "focal_point_y": 0.5, "url": "http://externa-core.test/api/v1/files/12/content", "transforms": { "thumb": "http://externa-core.test/api/v1/files/12/transforms/thumb" }, "created_at": "2026-07-20T12:00:00+00:00", "updated_at": "2026-07-20T12:00:00+00:00" } ], "meta": { "current_page": 1, "last_page": 1, "per_page": 15, "total": 1 } } ``` Query: `parent_id`, `search`, `page`, `per_page` (1–100). Private rows are **omitted** without `read_private` (still **200**). #### Get file ```bash curl -sS http://externa-core.test/api/v1/files/12 \ -H 'Accept: application/json' \ -H "Authorization: Bearer ek_YOUR_SECRET" ``` Response **200** — `{ "data": { … } }` same shape as a list row. Private file without `read_private` → **403**. #### Get file content / transform ```bash curl -sS -OJ http://externa-core.test/api/v1/files/12/content \ -H "Authorization: Bearer ek_YOUR_SECRET" curl -sS -OJ http://externa-core.test/api/v1/files/12/transforms/thumb \ -H "Authorization: Bearer ek_YOUR_SECRET" ``` Streams bytes (`Content-Type` from the file / transform). Same auth rules as Get file. #### Upload file ```bash curl -sS -X POST http://externa-core.test/api/v1/files \ -H 'Accept: application/json' \ -H "Authorization: Bearer ek_YOUR_SECRET" \ -F 'file=@/path/to/image.jpg' \ -F 'parent_id=' \ -F 'name=' ``` Multipart field `file` (required). Optional `parent_id`, `name`. Response **201** — `{ "data": { …FileResource } }` (same shape as Get file). #### Update file ```bash curl -sS -X PATCH http://externa-core.test/api/v1/files/12 \ -H 'Accept: application/json' \ -H 'Content-Type: application/json' \ -H "Authorization: Bearer ek_YOUR_SECRET" \ -d '{"title":"Hero","description":"Updated via Public CMS API","focal_point_x":0.35,"focal_point_y":0.6}' ``` Allowed metadata: `title`, `description`, `location`, `download_name`, `focal_point_x` / `focal_point_y` (0–1), `name`. Response **200** — updated FileResource. #### Delete file ```bash curl -sS -X DELETE http://externa-core.test/api/v1/files/12 \ -H 'Accept: application/json' \ -H "Authorization: Bearer ek_YOUR_SECRET" ``` Response **204** — empty body (soft delete). ### Expand with `include` Public REST item list/show responses are **lean by default**. File objects, mini-users, and related items are expanded only when you opt in with `?include=…` (CSV, case-sensitive). GraphQL is unchanged (still expands files). Admin Inertia keeps raw IDs. > Clients that previously relied on automatic file expansion on `/api/v1/.../items` must add **`include=files`**. Without it, `image` / `file` / `files` values stay as stored IDs (or arrays of IDs). ```http GET /api/v1/collections/{slug}/items?include=files,author,categories GET /api/v1/collections/{slug}/items/{id}?include=files ``` | Token / value | Effect | | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `files` | Expand all `image` / `file` / `files` fields (and file slots inside `blocks`) via `FileFieldExpander` | | `users` | Populate `user_created` / `user_updated` mini-objects (otherwise those keys are `null`; `user_*_id` always present) | | _field name_ | For relation fields (`relation`, `many_to_one`, `one_to_many`, `many_to_many`, `relation_many`, `relation_tree`, `m2a`): hydrate related items at **depth 1** | Unknown tokens and non-relation field names are **ignored** (no `400`). Nested hydrated items do **not** re-apply relation includes; they may still apply `files` / `users` if those tokens are in the same `include` list. #### Default shape (no `include`) - `data`: raw stored values — files = id or `[id, …]`; relations = int / `[{ related_item_id, meta }]` / m2a links - `user_created` / `user_updated`: `null` #### With `include` | `include` | Result | | -------------- | ------------------------------------------------------------------------------------------ | | `files` | File fields become objects (same rules as below) | | `author` (M2O) | `data.author` becomes a nested item resource (no further relation expand) | | `tags` (M2M) | Each entry becomes `{ "item": { … }, "meta": { … } }` (unreadable related → link only) | | `m2a_field` | Each entry keeps `related_collection_id` / `related_item_id` and adds `item` when readable | **File expansion** (only with `include=files`): - With **file:read** on a **public** file: object(s) with `id`, `filename`, `title`, `mime_type`, `width`, `height`, `filesize`, `url`, `transforms` - Without **file:read** on a **public** file: `{ "id": N, "access": "denied" }` (no URLs) — **breaking** vs older `{ "id": N }` only - **Private** file without **file:read_private**: `null` (single) or omitted from `files` arrays — **no id or url** - Soft-deleted / missing files → `null` (or omitted from `files` arrays) > If a public file field expands to `{ "id": N, "access": "denied" }`, the actor’s role is missing **Files → Read**. Grant that action on the role (Settings → Roles) and retry. Private files still redact to `null` without **Read private** — they never return an id. **Permissions:** related items hydrate only when readable for the actor (`CollectionPermissionEnforcer`). Unreadable → leave raw id / junction / m2a link (no nested payload). #### Examples ```bash # Lean list (file fields are IDs) curl -sS 'http://externa-core.test/api/v1/collections/posts/items?page=1&per_page=15' \ -H 'Accept: application/json' \ -H "Authorization: Bearer ek_YOUR_SECRET" # Expand files + author relation + mini-users curl -sS 'http://externa-core.test/api/v1/collections/posts/items/1?include=files,author,users' \ -H 'Accept: application/json' \ -H "Authorization: Bearer ek_YOUR_SECRET" ``` ```json { "data": { "id": 1, "collection_id": 3, "data": { "title": "Hello", "cover": { "id": 12, "filename": "hero.jpg", "title": "Hero", "mime_type": "image/jpeg", "width": 1920, "height": 1080, "filesize": 204800, "url": "http://externa-core.test/api/v1/files/12/content", "transforms": { "thumb": "http://externa-core.test/api/v1/files/12/transforms/thumb" } }, "author": { "id": 7, "collection_id": 2, "data": { "name": "Ada" }, "created_at": "2026-07-20T12:00:00+00:00", "updated_at": "2026-07-20T12:00:00+00:00", "user_created_id": null, "user_updated_id": null, "user_created": null, "user_updated": null } }, "created_at": "2026-07-20T12:00:00+00:00", "updated_at": "2026-07-20T12:00:00+00:00", "user_created_id": 1, "user_updated_id": 1, "user_created": { "id": 1, "name": "Admin", "email": "admin@example.com" }, "user_updated": { "id": 1, "name": "Admin", "email": "admin@example.com" } } } ``` TypeScript / OpenAPI shapes: [Public CMS API client types](/docs/public-cms-api-types#include-query). Bruno: List / Get Item with `include` query. ### Query parameters (items) | Param | Used on | Purpose | | -------------------------- | ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `page`, `per_page` | list | Pagination (`per_page` clamped 1–100, default 15) | | `filter[...]` | list | Field filters (same idea as admin item query service) | | `locale` | list / show | Locale for flattened translatable fields. Must be an **enabled** project content locale; otherwise **422**. Missing/empty slots walk the [locale fallback chain](/docs/configuration#locale-fallback) | | `include_all_translations` | list / show | When true, include enabled locales only in `data` (orphans for disabled locales are omitted from the response but kept in DB); no value fallback walk | | `include` | list / show | CSV opt-in expand: `files`, `users`, and/or relation **field names** (depth 1). See [Expand with `include`](#expand-with-include) | With `?locale=` (or the resolved active locale) and `include_all_translations` unset/false, each top-level translatable field is a single scalar: first usable value along requested → enabled `fallback_content_locales` → `APP_FALLBACK_LOCALE` (if enabled) → `null`. Empty strings are treated as missing. Nested block fields stay as per-locale maps. ### Permission → behaviour cheat sheet | Grants on role | List collections | Get collection / items | Create | Update | Delete | | ----------------- | ------------------- | ---------------------- | ------- | ------- | ------- | | _(none)_ | `[]` empty list | **403** | **403** | **403** | **403** | | `read` only | includes collection | **200** | **403** | **403** | **403** | | `read` + `create` | yes | yes | **201** | **403** | **403** | | `read` + `update` | yes | yes | **403** | **200** | **403** | | `read` + `delete` | yes | yes | **403** | **403** | **204** | | all four | full CRUD | yes | yes | yes | yes | > **List vs show** > `GET /collections` never 403s for missing read: it simply omits collections you cannot read. `GET /collections/{slug}` and item routes **do** 403 when read is missing. ## HTTP errors | Status | Typical cause | | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `401` | Bad/revoked/expired key, IP not allowlisted, key on `super-admin` | | `403` | Role lacks the required collection or file action; or origin gate (`Origin not allowed.` / `Origin required or API key.`) when `public_api_allowed_origins` is set | | `404` | Unknown collection slug or item id | | `422` | Validation failed on create/update payload | | `429` | API key rate limit exceeded | | `503` | `public` role missing from DB | ## CORS and config | Item | Detail | | -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | | Routes | `routes/api.php`, prefix `api` (`bootstrap/app.php`) | | CORS (env) | `config/cors.php` — paths `api/*`; origins from `CORS_ALLOWED_ORIGINS` (default `*`) when project allowlist is empty | | CORS (project) | Non-empty `public_api_allowed_origins` → effective CORS for `api/*` = that list (`ConfigurePublicApiCors`) | | Origin gate | Non-empty allowlist → `EnforcePublicApiOrigin` on `/api/v1/*` and `/api/graphql` (browser Origin must match; no Origin → valid API key required) | | Rate default | `config/api.php` ← `API_KEY_RATE_LIMIT` (default `60`) | ```env API_KEY_RATE_LIMIT=60 CORS_ALLOWED_ORIGINS=https://www.example.com,https://staging.example.com ``` Configure the project allowlist in **Settings → Project → Public API** (not only via env). Full behavior, Bruno notes, and security limits: [Client types — Origin allowlist](/docs/public-cms-api-types#origin-allowlist). ## Admin UI surfaces | Screen | Path | Purpose | | ----------------------------- | --------------------------- | --------------------------------------------------------------------------------------------------- | | Role edit → Files access | `/settings/roles/{id}/edit` | Global file API create/read/`read_private`/update/delete | | Role edit → Collection access | `/settings/roles/{id}/edit` | Grant/deny create/read/update/delete per collection | | API Keys | `/settings/api-keys` | Create keys (secret once), revoke | | File manager search | `/files` | Search is **server-side only** (`GET /files/list?search=`); load-more stays enabled while searching | | File picker | drawer | Same BE search; supports load-more pagination | Admin Spatie permissions for the API Keys screen: ``` can-show-api-keys can-manage-api-keys ``` ## Bruno collection (externa-bruno) Ready-to-use companion: **[https://github.com/qiick-io/externa-bruno](https://github.com/qiick-io/externa-bruno)** — classic Bruno layout (`bruno.json` + folders). Docs above are the source of example payloads; Bruno executes them. 1. Install [Bruno](https://www.usebruno.com/), clone / open the `externa-bruno` folder as a collection 2. Select environment **Local** (`environments/Local.bru`; default `base_url` = `http://externa-core.test`) 3. Copy `.env.sample` → `.env`, set `EXTERNA_API_KEY` (and `EXTERNA_WEBHOOK_SECRET` if needed) 4. Set request vars `collection_slug` / `item_id` / `file_id` as needed 5. **Staging / remote:** duplicate **Local** in Bruno, set `base_url` (no trailing slash) and the matching key in `.env` 6. Folder **PublicApi** inherits Bearer auth from `folder.bru` (except **List Collections Anonymous**, which uses `auth: none`) 7. If **Allowed origins** is set in project settings, Bruno calls without an Origin header **require** a valid `api_key` (anonymous requests will 403) 8. Subfolders: **Files**, **GraphQL**, **Webhooks** (`Verify Webhook Signature` — sample payload / signing helper; default `__webhook_receiver_docs_only__` URL is a placeholder and **404s** on externa-core; see [Outbound webhooks](/docs/webhooks)) Each request includes a **docs** panel describing method, auth, required permission, and expected status codes. Full open notes: [externa-bruno README](https://github.com/qiick-io/externa-bruno#open-in-bruno). ## Tests - Pest feature suites: `PublicCollectionApiTest`, `PublicFilesApiTest`, `PublicApiOriginGateTest` - Cover anonymous deny/allow per action, API key gating, origin allowlist, file content/transform 403 without read, item `?include=` (files / relations / users), upload warm job, and focal cover. ```bash # Use PHP 8.4 if your shell `php` is older (Herd): php84 artisan test --filter=PublicCollectionApiTest php84 artisan test --filter=PublicFilesApiTest php84 artisan test --filter=PublicApiOriginGateTest ``` ## Source map | Concern | Location | | --------------- | ------------------------------------------------------------------------------------------------------ | | Routes | `routes/api.php` | | Auth middleware | `app/Http/Middleware/ResolveApiAccess.php` | | Origin / CORS | `EnforcePublicApiOrigin`, `ConfigurePublicApiCors`, `Support\Api\PublicApiOrigin` | | Guard / sync | `CollectionPermissionGuard` / `CollectionPermissionSync`, `FilePermissionGuard` / `FilePermissionSync` | | Controllers | `app/Http/Controllers/Api/V1/*` | | Models | `ApiKey`, `CollectionPermission`, `FilePermission`, `Role` (`isPublic`, system flags) | | Admin API keys | `app/Http/Controllers/Admin/ApiKeyController.php` | | Config | `config/api.php`, `config/cors.php`; project key `public_api_allowed_origins` | | Bruno | [github.com/qiick-io/externa-bruno](https://github.com/qiick-io/externa-bruno) | | Pest | `tests/Feature/Api/PublicApiOriginGateTest.php` | ## Related pages - [Public CMS API client types](/docs/public-cms-api-types) — TypeScript, OpenAPI 3, JSON Schema downloads - [Roles & permissions](/docs/roles-and-permissions) — editing roles and the Collection access matrix - [Effective permissions](/docs/permissions) — admin Spatie model (separate from this API) - [Outbound webhooks](/docs/webhooks) — Externa → your URL (HMAC); not the same as API keys - [Collections API](/docs/collections-api) — **session** admin routes under `/collections` (not `/api/v1`) - [Routing overview](/docs/routing) — how `api.php` is registered - [Testing](/docs/testing) — Pest layout including `Feature/Api` ## Field-level rules & item filters `collection_permissions.rules` (JSON) on each Collection access grant (admin Roles UI → **Fields & item filter**): ```json { "fields": { "title": { "read": true, "create": true, "update": true }, "status": { "read": true, "create": false, "update": false } }, "item_filter": { "logic": "and", "rules": [{ "field": "status", "operator": "equals", "value": "published" }] } } ``` | Rule | Effect | | ----------------------------------- | -------------------------------------------------------------------------- | | Empty `fields` map | All fields allowed (backward compatible) | | `fields.*.read: false` | Stripped from list/show responses | | `fields.*.create` / `update: false` | Rejected on write (403/422 path via guard) | | `item_filter` | Hides non-matching items on list/show (404) and blocks update/delete (403) | Operators for `item_filter`: `equals`, `not_equals`, `empty`, `not_empty` (AND logic only). Applied for Public REST, GraphQL, and non–super-admin admin item access via `CollectionPermissionGuard` / `CollectionPermissionRules`. Pest: `tests/Feature/Api/CollectionPermissionRulesTest.php`. ## GraphQL (`/api/graphql`) Dedicated guide: **[GraphQL](/docs/graphql)**. Summary: Lighthouse endpoint at **`/api/graphql` only** (route name `graphql`; no `/graphql` app route). Same `ResolveApiAccess` + collection guards as REST. Queries: `collections`, `collection(slug)`, `items`, `item`. Mutations: `createItem`, `updateItem`, `deleteItem` (soft-delete). Filter dialect matches REST/admin (`perPage` camelCase). Bruno: [externa-bruno](https://github.com/qiick-io/externa-bruno) `PublicApi/GraphQL/`. Pest: `tests/Feature/Api/GraphqlCollectionApiTest.php`. ## M2M junction metadata Many-to-many values are objects (breaking change from bare ints): ```json { "related_item_id": 12, "meta": { "sort": 1 } } ``` Bare ints are still accepted on write and normalized to `{ related_item_id, meta: {} }`. Optional `settings.junction_fields` on the field defines the meta mini-schema (admin field settings → Junction fields JSON). See [Field types](/docs/field-types) and [Collection items](/docs/collection-items). ## Advanced list filters Same dialect as admin items index / GraphQL `filter` arg — see [Collections API](/docs/collections-api#advanced-list-filters). Example: `?filter[status][_eq]=published`. Bruno: [externa-bruno](https://github.com/qiick-io/externa-bruno) `List Items Advanced Filter`. --- # Public CMS API client types Source: https://docs.externa.qiick.io/docs/public-cms-api-types TypeScript interfaces, OpenAPI 3, and JSON Schema for integrating with the headless **Public CMS API** (`/api/v1`). Shapes match `CollectionItemResource`, `Api\V1\FileResource`, and the V1 collection/item/file controllers. > **Related** > Setup, auth, and endpoint examples: [Public CMS API](/docs/public-cms-api). GraphQL: [GraphQL](/docs/graphql). Runnable requests: [externa-bruno](https://github.com/qiick-io/externa-bruno). ## Downloads Static files (raw / downloadable from this docs site): | Artifact | Path | Use | | ----------- | ---------------------------------------------------------- | ----------------------------------------- | | TypeScript | [`/client-types/types.ts`](/client-types/types.ts) | Copy into your app or generate clients | | OpenAPI 3 | [`/client-types/openapi.yaml`](/client-types/openapi.yaml) | Import into Postman, Insomnia, codegen | | JSON Schema | [`/client-types/schema.json`](/client-types/schema.json) | Validate create/update item & file bodies | **Runtime (your Externa instance):** `GET /api/v1/openapi.json` — same OpenAPI 3 document as JSON, no API key (and outside the Public API origin allowlist). Prefer this when generating clients against a specific install. **Sync policy:** keep `externa-core` `resources/openapi/public-cms-v1.yaml` identical to docs `public/client-types/openapi.yaml` whenever Public CMS API shapes change ([API 1.x compatibility](/docs/api-compatibility)). Behaviour SoT remains the [Public CMS API](/docs/public-cms-api) guide and core controllers. ## Covered types | Area | Types / schemas | | ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | | Collections | `CollectionSummary`, `CollectionResource`, `CollectionFieldSummary` | | Items | `CollectionItemResource`, `CreateCollectionItemRequest`, `UpdateCollectionItemRequest`, list `meta` | | Files | `FileResource`, `UpdateFileRequest`, expanded item file fields (`ExpandedFileField`) | | Envelopes | `DataEnvelope`, `PaginatedEnvelope`, `ApiErrorEnvelope` | | Helpers | `PaginationMeta`, `MiniUser`, `M2mJunctionValue`, `FileFieldValue`, nested include shapes (`ExpandedM2mRelationEntry`, `ExpandedM2aRelationEntry`) | Item `data` is `Record` / `additionalProperties` because field schemas are per-collection. Tighten locally from your collection field definitions. ## TypeScript (copy-paste) ```ts /** * Externa Public CMS API (`/api/v1`) — client TypeScript types. * Full file: /client-types/types.ts */ export interface PaginationMeta { current_page: number last_page: number per_page: number total: number } export interface MiniUser { id: number name: string email: string | null } export interface CollectionSummary { id: number name: string slug: string is_singleton: boolean } export interface CollectionFieldSummary { id: number name: string type: string settings: Record | null } export interface CollectionResource { id: number name: string slug: string is_singleton: boolean fields: CollectionFieldSummary[] } export interface M2mJunctionValue { related_item_id: number meta?: Record } /** Nested item payload when a relation field is listed in `?include=` (depth 1). */ export interface NestedCollectionItemResource { id: number collection_id: number data: Record created_at: string | null updated_at: string | null user_created_id: number | null user_updated_id: number | null user_created: MiniUser | null user_updated: MiniUser | null } /** M2M entry after `include=` when the related item is readable. */ export interface ExpandedM2mRelationEntry { item: NestedCollectionItemResource meta: Record } /** M2A entry after `include=` when the related item is readable. */ export interface ExpandedM2aRelationEntry { related_collection_id: number related_item_id: number item: NestedCollectionItemResource } export interface ExpandedFileField { id: number filename: string title: string | null mime_type: string | null width: number | null height: number | null filesize: number | null url: string transforms: Record } /** Public file expanded without file:read — machine-readable denial (no URLs). */ export type FileFieldAccessDenied = { id: number; access: 'denied' } /** @deprecated Prefer FileFieldAccessDenied — legacy id-only shape. */ export type FileFieldIdOnly = FileFieldAccessDenied /** * Value of an image/file field on a Public API item response. * With `include=files`: ExpandedFileField | FileFieldAccessDenied | null. * Without `include=files`: raw stored id(s) (`number` / `number[]`). * Private files without read_private are `null` (or omitted from `files` arrays). * If you see `access: "denied"`, grant Files → Read on the role. */ export type FileFieldValue = ExpandedFileField | FileFieldAccessDenied | number | number[] | null /** * Collection item resource (`CollectionItemResource`). * `data` keys match collection field names. * Lean by default on REST; expand with `?include=files|users|`. * Without `include=users`, `user_created` / `user_updated` are `null`. */ export interface CollectionItemResource { id: number collection_id: number data: Record created_at: string | null updated_at: string | null user_created_id: number | null user_updated_id: number | null user_created: MiniUser | null user_updated: MiniUser | null } export interface CreateCollectionItemRequest { data: Record } export interface UpdateCollectionItemRequest { data: Record } export interface FileResource { id: number parent_id: number | null type: 'file' | 'folder' access: 'public' | 'private' filename: string title: string | null description: string | null mime_type: string | null extension: string | null filesize: number | null width: number | null height: number | null focal_point_x: number | null focal_point_y: number | null url: string | null transforms: Record created_at: string | null updated_at: string | null } export interface UpdateFileRequest { title?: string | null description?: string | null location?: string | null download_name?: string | null focal_point_x?: number | null focal_point_y?: number | null name?: string } export interface DataEnvelope { data: T } export interface PaginatedEnvelope { data: T[] meta: PaginationMeta } export type CollectionsListResponse = DataEnvelope export type CollectionShowResponse = DataEnvelope export type ItemsListResponse = PaginatedEnvelope export type ItemShowResponse = DataEnvelope export type FilesListResponse = PaginatedEnvelope export type FileShowResponse = DataEnvelope export interface ApiErrorEnvelope { message: string errors?: Record } ``` ## Create / update payloads ### Create item — `POST /api/v1/collections/{slug}/items` ```json { "data": { "title": "New post", "status": "draft" } } ``` ### Update item — `PATCH /api/v1/collections/{slug}/items/{id}` ```json { "data": { "title": "Updated title", "status": "published" } } ``` M2M fields accept junction objects (bare ints still work on write): ```json { "data": { "related_posts": [{ "related_item_id": 12, "meta": { "sort": 1 } }] } } ``` ### Update file metadata — `PATCH /api/v1/files/{id}` ```json { "title": "Hero", "description": "Updated via Public CMS API", "focal_point_x": 0.35, "focal_point_y": 0.6 } ``` Uploads use `multipart/form-data` (`file` required; optional `parent_id`, `name`) — see OpenAPI. JSON Schema definitions: [`/client-types/schema.json`](/client-types/schema.json) (`CreateCollectionItemRequest`, `UpdateCollectionItemRequest`, `UpdateFileRequest`, plus response `$defs`). ## Main response envelopes **List collections** ```ts type CollectionsListResponse = { data: CollectionSummary[] } ``` **Get collection** ```ts type CollectionShowResponse = { data: CollectionResource } ``` **List items / files** ```ts type ItemsListResponse = { data: CollectionItemResource[] meta: PaginationMeta } type FilesListResponse = { data: FileResource[]; meta: PaginationMeta } ``` **Show / create / update item or file** ```ts type ItemShowResponse = { data: CollectionItemResource } type FileShowResponse = { data: FileResource } ``` **Delete** — HTTP `204`, empty body. ## `include` query Opt-in expand on item **list** and **show** (`GET …/items`, `GET …/items/{id}`): ``` ?include=files,users,author,categories ``` | Token | Effect | | ------------ | -------------------------------------------------------- | | `files` | Expand `image` / `file` / `files` (and block file slots) | | `users` | Fill `user_created` / `user_updated` mini-users | | _field name_ | Depth-1 hydrate for relation / m2a fields with that name | Without `include`, file fields stay as IDs and mini-users are `null`. Nested relation shapes: `NestedCollectionItemResource`, `ExpandedM2mRelationEntry` (`{ item, meta }`), `ExpandedM2aRelationEntry` (`{ related_collection_id, related_item_id, item }`). Unknown tokens ignored. Full behaviour: [Public CMS API — Expand with `include`](/docs/public-cms-api#expand-with-include). **Errors** (Laravel-style): ```json { "message": "…" } ``` Validation (`422`) may include `"errors": { "field": ["…"] }`. ## OpenAPI Full path coverage for collections, items, and files (including content/transforms): [`/client-types/openapi.yaml`](/client-types/openapi.yaml). Same document from Core: `GET /api/v1/openapi.json` ([core #25](https://github.com/qiick-io/externa-core/issues/25)). Import examples: ```bash # From your Externa Core instance (preferred for codegen against that install) curl -fsSL https://YOUR_EXTERNA_ORIGIN/api/v1/openapi.json -o externa-public-cms.openapi.json # From this docs site (YAML) curl -fsSL https://YOUR_DOCS_HOST/client-types/openapi.yaml -o externa-public-cms.openapi.yaml ``` ## Origin allowlist Project setting **`public_api_allowed_origins`** (Settings → Project → Public API) gates browser access to `/api/v1/*` and `/api/graphql`, and drives effective CORS when the list is non-empty. ### UI setup 1. Open **Settings → Project** (`can-manage-project-settings`). 2. Under **Public API → Allowed origins**, enter one origin per line (or comma/whitespace-separated), e.g.: ```text https://www.example.com https://app.example.com http://localhost:3000 ``` 3. Save. Origins must be `http`/`https` with host (and optional non-default port). **No path, query, fragment, or credentials** — `https://www.example.com/blog` is rejected. Leave the field **empty** to disable the gate (typical local/OSS DX). CORS then falls back to env `CORS_ALLOWED_ORIGINS` (default `*`). See [Project settings](/docs/project-settings) and [Public CMS API — CORS](/docs/public-cms-api#cors-and-config). ### Behavior Applies only when the allowlist is **non-empty**. Middleware: `EnforcePublicApiOrigin` (REST + GraphQL). Matching Origin alone does **not** bypass collection/file grants — those still apply. | Client | Origin header | Outcome when allowlist is set | | -------------------------------- | ----------------------- | -------------------------------------------------------------------------------------------------------- | | Browser from an allowed site | Matches allowlist | Continues (anonymous `public` role or Bearer key as usual) | | Browser / tool with wrong Origin | Present but not in list | **403** `{ "message": "Origin not allowed." }` | | Bruno, curl, SSR, server jobs | Absent | **403** `{ "message": "Origin required or API key." }` unless a **valid, active** Bearer API key is sent | | Same, with valid API key | Absent | Continues (then IP allowlist / role grants on the key still apply) | When the allowlist is non-empty, `ConfigurePublicApiCors` overrides `cors.allowed_origins` for `api/*` to that list (preflight only echoes an allowed `Origin`). When empty, env `CORS_ALLOWED_ORIGINS` applies unchanged. ### Browser vs Bruno / API key ```ts // Browser (Origin sent automatically by the browser) const res = await fetch(`${baseUrl}/api/v1/collections`, { credentials: 'omit', }) // Server / Bruno / curl — no Origin → send API key when allowlist is on const res = await fetch(`${baseUrl}/api/v1/collections`, { headers: { Authorization: `Bearer ${process.env.EXTERNA_API_KEY}` }, }) ``` In [externa-bruno](https://github.com/qiick-io/externa-bruno), set `api_key` via collection `.env` (`EXTERNA_API_KEY`). Folder Bearer auth covers most Public API requests; **List Collections Anonymous** uses `auth: none` and will **403** when the allowlist is active unless you add a key or send a matching `Origin` header for experiments. ### Security honesty Origin and CORS are **browser policy** tools. A scraper or script can spoof the `Origin` header. The gate raises the bar against “any random website can call your Public API from a visitor’s browser,” but it is **not** strong authentication. For stronger protection, combine: - A non-public role + **API key** (required for non-browser clients when the allowlist is on) - Optional **IP allowlist** on the API key - Collection/file grants (and field ACL / `item_filter`) as usual Do not treat an empty allowlist + `CORS_ALLOWED_ORIGINS=*` as production-hardened CORS. ## Related - [Public CMS API](/docs/public-cms-api) — auth, matrices, endpoint reference, CORS - [GraphQL](/docs/graphql) — same origin gate + guards, different transport - [Field types](/docs/field-types) — value shapes for `data` - [Project settings](/docs/project-settings) — `public_api_allowed_origins`, locales, transforms --- # GraphQL Source: https://docs.externa.qiick.io/docs/graphql Externa exposes a Lighthouse GraphQL endpoint at **`/api/graphql`** for the same headless surface as REST `/api/v1`. Auth, Collection access, field ACL, and `item_filter` match the Public CMS API. > **Related** > REST setup and matrices: [Public CMS API](/docs/public-cms-api). **1.x compatibility:** [API & GraphQL compatibility](/docs/api-compatibility). Filter dialect: [Collections API — Advanced list filters](/docs/collections-api#advanced-list-filters). Ready-to-run collection: **[externa-bruno](https://github.com/qiick-io/externa-bruno)** (`PublicApi/GraphQL/` — open folder in Bruno, env **Local**, or duplicate for staging). Ops cache: [Operations](/docs/operations) · [Environment variables](/docs/environment-variables). ## Endpoint | Item | Detail | | ---------- | -------------------------------------------------------------------------------------------- | | URI | **`/api/graphql` only** (route name `graphql`) | | Schema | `graphql/schema.graphql` | | Config | `config/lighthouse.php` | | Middleware | `ResolveApiAccess` + `throttle:api` | | CSRF | Excepted for `api/graphql` (and a bare `graphql` path that is **not** registered as a route) | There is **no** `/graphql` application route — clients must post to `/api/graphql`. ```bash curl -sS http://externa-core.test/api/graphql \ -H 'Accept: application/json' \ -H 'Content-Type: application/json' \ -H "Authorization: Bearer ek_YOUR_SECRET" \ -d '{"query":"{ collections { slug name } }"}' ``` Omit `Authorization` for anonymous access via the **`public`** role matrix. ## Auth Same as REST: | Style | How | | --------- | ------------------------------------------ | | Anonymous | Role **`public`** Collection access matrix | | API key | `Authorization: Bearer ek_…` → key’s role | No session cookies. Super-admin is not available via API keys. ## Queries | Field | Args | Returns | | ------------- | --------------------------------------------------------------------------- | ---------------- | | `collections` | — | `[Collection!]!` | | `collection` | `slug: String!` | `Collection` | | `items` | `collection: String!`, `filter: JSON`, `page: Int = 1`, `perPage: Int = 15` | `ItemPage!` | | `item` | `collection: String!`, `id: ID!` | `Item` | `Collection` fields: `id`, `name`, `slug`, `is_singleton`. `Item` fields: `id`, `collection_id`, `data`, `created_at`, `updated_at`, `user_created_id`, `user_updated_id`. `ItemPage`: `data`, `meta { total, current_page, … }`. Note: GraphQL uses camelCase `perPage` (REST uses `per_page`). Locale flattening for translatable fields follows the same resolver rules as REST when applicable — prefer REST `?locale=` when you need explicit locale control. ### Query collections ```json { "query": "{ collections { id slug name is_singleton } }" } ``` ```json { "data": { "collections": [ { "id": "1", "slug": "posts", "name": "Posts", "is_singleton": false } ] } } ``` ### Query items (+ collection) ```graphql query ($slug: String!, $filter: JSON) { collection(slug: $slug) { id name is_singleton } items(collection: $slug, filter: $filter, perPage: 15) { data { id data user_created_id } meta { total current_page } } } ``` ```json { "query": "query ($slug: String!, $filter: JSON) { items(collection: $slug, filter: $filter) { data { id data } meta { total current_page } } }", "variables": { "slug": "posts", "filter": { "status": { "_eq": "published" } } } } ``` ```json { "data": { "items": { "data": [ { "id": "1", "data": { "title": "Hello", "status": "published" } } ], "meta": { "total": 1, "current_page": 1 } } } } ``` ### Query item ```json { "query": "query ($slug: String!, $id: ID!) { item(collection: $slug, id: $id) { id collection_id data created_at updated_at } }", "variables": { "slug": "posts", "id": "1" } } ``` ## Mutations | Field | Args | Notes | | ------------ | -------------------------- | ------------------------------------------------- | | `createItem` | `collection`, `data` | Same create semantics as REST | | `updateItem` | `collection`, `id`, `data` | Same update semantics as REST | | `deleteItem` | `collection`, `id` | Soft-delete (boolean); no force-delete on GraphQL | ### createItem ```json { "query": "mutation ($slug: String!, $data: JSON!) { createItem(collection: $slug, data: $data) { id data } }", "variables": { "slug": "posts", "data": { "title": "GraphQL create", "status": "draft" } } } ``` ```json { "data": { "createItem": { "id": "42", "data": { "title": "GraphQL create", "status": "draft" } } } } ``` Requires `create` on the collection for the actor’s role. ### updateItem ```json { "query": "mutation ($slug: String!, $id: ID!, $data: JSON!) { updateItem(collection: $slug, id: $id, data: $data) { id data } }", "variables": { "slug": "posts", "id": "42", "data": { "status": "published" } } } ``` Requires `update`. Field rules + `item_filter` apply like REST `PATCH`. ### deleteItem ```json { "query": "mutation ($slug: String!, $id: ID!) { deleteItem(collection: $slug, id: $id) }", "variables": { "slug": "posts", "id": "42" } } ``` ```json { "data": { "deleteItem": true } } ``` Requires `delete`. Soft-delete semantics match REST. ## Ops notes - Query cache defaults to **opcache** (`LIGHTHOUSE_QUERY_CACHE_MODE`) so parsed ASTs are not stored in the database cache driver. - Bruno: [externa-bruno](https://github.com/qiick-io/externa-bruno) `PublicApi/GraphQL/`. Pest: `tests/Feature/Api/GraphqlCollectionApiTest.php`. ## Source map | Concern | Location | | --------------- | ------------------------------------------------------------------------------ | | Schema | `graphql/schema.graphql` | | Resolvers | `app/GraphQL/Queries/*`, `app/GraphQL/Mutations/*` | | Config | `config/lighthouse.php` | | Auth middleware | `ResolveApiAccess` + `EnforcePublicApiOrigin` (same as `/api/v1`) | | Bruno | [github.com/qiick-io/externa-bruno](https://github.com/qiick-io/externa-bruno) | --- # API & GraphQL 1.x compatibility Source: https://docs.externa.qiick.io/docs/api-compatibility This page is the **contract for API consumers** of Externa **1.x**: Public CMS REST (`/api/v1`) and GraphQL (`/api/graphql`). Package semver follows the same spirit (Release process / issue #22). > **Related** > [Public CMS API](/docs/public-cms-api) · [GraphQL](/docs/graphql) · [Public CMS API types](/docs/public-cms-api-types) · [`CHANGELOG.md`](https://github.com/qiick-io/externa-core/blob/develop/CHANGELOG.md) (once present) ## Versioning surface | Surface | Stability in 1.x | | ------------------ | ------------------------------------------------------------------------------------------------------------- | | **`/api/v1/*`** | Stable base path for the entire 1.x line. No `/api/v2` required for compatible evolution. | | **`/api/graphql`** | Same auth, collection guards, and field ACL as REST. Schema evolves under the rules below. | | Auth | Anonymous via `public` role matrix, or `Authorization: Bearer ek_…` API keys bound to a non–super-admin role. | Admin session routes, Inertia props, and internal jobs are **not** covered by this public-API policy. ## What may change in a minor (1.y → 1.y+1) **Additive** changes are allowed without a major bump: - New REST endpoints under `/api/v1` - New optional request/query fields (defaults preserve prior behaviour) - New response fields (clients must ignore unknown keys) - New GraphQL types, fields, arguments that are optional or additive - New enum values where clients treat unknowns safely - Performance / docs / non-contract bug fixes ## What requires deprecation or a major (2.0) Treat as **breaking** (major release, or announced deprecation then removal): - Removing or renaming REST paths, required fields, or response fields consumers rely on - Changing auth semantics (how keys / `public` resolve) in an incompatible way - Removing GraphQL fields/types/arguments without a deprecation window - Tightening validation so previously valid requests fail (unless fixing a security hole — then document as security fix) Security fixes that change behaviour may ship in a patch/minor with a clear CHANGELOG **Security** note. ## Deprecation window 1. Mark the behaviour deprecated in **CHANGELOG** (`Deprecated` section) and in this docs site. 2. Prefer keeping the old path/field working through at least **one minor** after announcement (longer when practical). 3. Optional later: response headers or GraphQL `@deprecated` directives — not required for 1.x start. 4. Removal happens in a **major** (or at end of the announced window called out in release notes). ## Auth & roles (unchanged intent) - API keys never grant `super-admin`. - Collection/file access still comes from the key’s role (or `public`) matrices — not Spatie admin permissions. - See [Public CMS API](/docs/public-cms-api) for setup. ## Client guidance - Pin to a documented Externa version for production. - Tolerate additive JSON/GraphQL fields. - Watch CHANGELOG + GitHub Releases when upgrading minors. - Prefer Bruno collection / published types for contract smoke tests. ## Alignment with package semver | Package bump | Typical API impact | | ------------ | ------------------------------------------------------------ | | **Patch** | Fixes; no intentional public contract break | | **Minor** | Additive API/GraphQL; deprecations may be announced | | **Major** | Breaking removals/renames after (or with) deprecation notice | App version lives in `composer.json` (mirrored in `package.json`). Release checklist: issue #22 / Releasing docs. --- # AI API Source: https://docs.externa.qiick.io/docs/ai-api AI HTTP routes live in `routes/ai.php`: one **HMAC-signed webhook** (no session) and an authenticated `/ai/*` group gated by `can-use-ai`. Config: [AI configuration](/docs/ai-configuration). Product behaviour: [AI assistant](/docs/ai-assistant). ## Webhook (HMAC auth) | Method | Path | Route name | Middleware | | ------ | -------------------------------- | ------------------------------- | --------------- | | `POST` | `/ai/webhooks/collection-import` | `ai.webhooks.collection-import` | `throttle:30,1` | CSRF stays excepted for this path in `bootstrap/app.php` (machine clients have no session CSRF cookie). Auth is **HMAC only** — not a bare Bearer token. ### Authentication Verified in `CollectionImportWebhookRequest` before validation (controller helper: `CollectionImportWebhookController::sign`). 1. Read configured secret: `config('ai.webhook_token')` ← `AI_WEBHOOK_TOKEN`. 2. Read header `X-AI-Webhook-Signature` (`sha256=` + hex digest). 3. Compute HMAC-SHA256 over the **raw request body only**, keyed by `AI_WEBHOOK_TOKEN` (`hash_equals`). 4. Abort **401** if the secret is empty outside `local`, or if `hash_equals` fails. In `local` with an empty token, unsigned requests are allowed for development only. Changing any body byte (including `collection_id` inside the JSON) invalidates the digest. Bearer / `X-AI-Webhook-Token` alone is rejected. ### Body (validated) Expects a collection id, associative `records` array, and optional `upsert_key`. On success returns JSON with `ok`, `collection_id`, `url`, and import summary fields. Validation failures / import errors return 4xx JSON. > From externa-core **1.2.2** ([#214](https://github.com/qiick-io/externa-core/pull/214)): sign the **raw request body only** (`sha256=` + HMAC-SHA256). The previous body + newline + `collection_id` payload from [#86](https://github.com/qiick-io/externa-core/issues/86) is no longer accepted. Invalid or missing signatures return **401** (was 403). Set `AI_WEBHOOK_TOKEN` in every non-local environment. > The webhook has no interactive user; AI mutation logging is skipped for this path. Protect `AI_WEBHOOK_TOKEN` like a production secret. ```http POST /ai/webhooks/collection-import HTTP/1.1 Host: app.example.com X-AI-Webhook-Signature: sha256=… Content-Type: application/json ``` ```php $rawBody = json_encode($payload, JSON_THROW_ON_ERROR); $signature = 'sha256='.hash_hmac( 'sha256', $rawBody, env('AI_WEBHOOK_TOKEN'), ); ``` --- ## Authenticated group Middleware stack for all routes below: ```text auth + verified + permission:can-use-ai ``` Prefix: `/ai`, name prefix: `ai.`. ### Status and shell | Method | Path | Route name | Notes | | ------ | ------------ | ----------- | ------------------------------------------------ | | `GET` | `/ai` | `ai.index` | Inertia AI page shell | | `GET` | `/ai/status` | `ai.status` | Provider / readiness JSON (`AiStatusController`) | ### Conversations list / create / show / destroy / pin / truncate / bulk-destroy | Method | Path | Route name | Purpose | | -------- | ------------------------------------------- | ------------------------------- | ---------------------------- | | `GET` | `/ai/conversations` | `ai.conversations.index` | List conversations | | `POST` | `/ai/conversations` | `ai.conversations.store` | Create conversation | | `POST` | `/ai/conversations/bulk-destroy` | `ai.conversations.bulk-destroy` | Delete many | | `GET` | `/ai/conversations/{conversation}` | `ai.conversations.show` | Show conversation + messages | | `DELETE` | `/ai/conversations/{conversation}` | `ai.conversations.destroy` | Delete one | | `POST` | `/ai/conversations/{conversation}/pin` | `ai.conversations.pin` | Toggle pin | | `POST` | `/ai/conversations/{conversation}/truncate` | `ai.conversations.truncate` | Truncate message history | Persistence tables: `agent_conversations`, `agent_conversation_messages` ([AI configuration](/docs/ai-configuration)). Controller: `AiConversationController`. ### Attachments | Method | Path | Route name | Throttle | | ------ | ----------------- | ---------------------- | --------------- | | `POST` | `/ai/attachments` | `ai.attachments.store` | `throttle:30,1` | Uploads chat attachments (CSV/TXT/XLSX/PDF per assistant instructions). Expired files are cleaned by `ai:cleanup-attachments`. Controller: `AiChatAttachmentController`. ### Chat | Method | Path | Route name | Throttle | | ------ | ---------- | ---------- | --------------- | | `POST` | `/ai/chat` | `ai.chat` | `throttle:30,1` | Invokes `AppAssistant` with tools filtered by the user’s effective permissions. Optional daily cap: `AI_DAILY_PROMPT_LIMIT` (`0` = unlimited). Controller: `AiChatController`. ### Import job status | Method | Path | Route name | Extra permission | | ------ | ------------------------- | --------------------- | ------------------------ | | `GET` | `/ai/import-jobs/{jobId}` | `ai.import-jobs.show` | `can-create-collections` | `jobId` must be a **UUID**. Used to poll async collection import jobs started from AI tools. Controller: `ImportJobStatusController`. ### Conversation deep link Registered **last** so static `/ai/*` paths are not captured: | Method | Path | Route name | Constraint | | ------ | -------------------- | ---------- | --------------------- | | `GET` | `/ai/{conversation}` | `ai.show` | `{conversation}` UUID | Inertia page for opening a specific conversation (`AiPageController@show`). ## Throttles summary | Endpoint | Limit | | ----------------- | -------------------- | | Webhook import | 30 requests / minute | | Attachments store | 30 / minute | | Chat | 30 / minute | Other authenticated AI routes rely on session auth + Fortify login throttles unless additional limiters are added. ## Tool permissions (not HTTP middleware) Even with `can-use-ai`, individual tools still call `ChecksAiPermissions::requirePermission()` for domain actions (files, collections, users, …). Users without matching effective permissions get English machine-error strings from tools (the model should translate them into the user’s language) instead of silent success. See [Extending Externa](/docs/extending). --- # Outbound webhooks Source: https://docs.externa.qiick.io/docs/webhooks Externa **pushes** domain events to a single HTTPS endpoint you configure in **Project settings**. Delivery is queued and HMAC-signed with a project webhook secret (not an API key). Disabled when the URL is empty; **refused** when a URL is set without a signing secret. For human-readable Slack/Telegram alerts on the same events, see [Integrations](/docs/integrations). > **Related** > [Project settings](/docs/project-settings) · [Operations (queue worker)](/docs/operations) · [Public CMS API](/docs/public-cms-api) · [Security checklist](/docs/security-checklist) · [externa-bruno](https://github.com/qiick-io/externa-bruno) `Verify Webhook Signature` > Outbound webhooks are **Externa → your URL**. There is no Externa route you `POST` to in order to “receive” them. Do not confuse this with the [AI collection-import webhook](/docs/ai-api) (`POST /ai/webhooks/collection-import`), which is an inbound HMAC-signed endpoint. ## Configure 1. Admin → **Settings** → **Project** 2. Set **Webhook URL** (e.g. `https://example.com/webhooks/externa` or a temporary [webhook.site](https://webhook.site) URL) 3. **Generate secret** (or paste your own) and save — copy the secret into your receiver; Externa encrypts it at rest and never re-shows it in Inertia props 4. Optionally **Send test event** (`type: ping`) — requires a queue worker Leave the URL empty to disable outbound events. Empty secret on save keeps the existing secret. > A URL **without** a signing secret does **not** deliver. The dispatcher refuses to queue, and `DeliverOutboundWebhookJob` refuses to POST (no empty-key HMAC). Configure both URL and secret, then use **Send test event**. > Delivery uses `DeliverOutboundWebhookJob`. Run a queue worker (`composer run dev` locally, or `php artisan queue:work` in production). See [Operations](/docs/operations). ## SSRF guard From core **1.3.0** ([#118](https://github.com/qiick-io/externa-core/issues/118) / [PR #267](https://github.com/qiick-io/externa-core/pull/267)), webhook and Slack notifier URLs are validated with `SafeRemoteUrlValidator` **on save** and again **at delivery**. Delivery uses `Http::withoutRedirecting()`. | Setting | Default | Effect | | ------------------------------------------------------ | ------- | -------------------------------------------------------------- | | `WEBHOOKS_ALLOW_PRIVATE_HOSTS` (`config/webhooks.php`) | `false` | Reject loopback, RFC1918, link-local, and other reserved hosts | **Breaking for LAN targets:** a previously saved `http://192.168.x.x/...` or `http://10.x.x.x/...` URL fails validation / delivery until you set: ```bash WEBHOOKS_ALLOW_PRIVATE_HOSTS=true php artisan config:clear # or config:cache after deploy ``` Public `https://` receivers need no change. Telegram notifiers keep a fixed host (`api.telegram.org`) and still disable redirects. > `WEBHOOKS_ALLOW_PRIVATE_HOSTS=true` disables the SSRF block for private ranges. Use only when Externa must reach an internal automation host you control. ## Envelope ```json { "id": "evt_01h…", "type": "item.updated", "created_at": "2026-07-24T12:00:00Z", "data": { "collection_id": 1, "collection_slug": "posts", "item_id": 42 } } ``` Bodies are **minimal** (ids / slug only). Full record dumps are out of scope for v1. Empty `data` is encoded as `{}` (object), not `[]`. ### Event types (v1) | Type | When | | ------------------------------------------------------------------ | ------------------------------------------------------------------------------------ | | `item.created` / `item.updated` | After item field sync | | `item.published` | Draft promoted (manual or scheduled; payload may include `scheduled: true`) | | `item.submitted` / `item.approved` / `item.rejected` | Editorial approvals transitions ([Approvals](/docs/approvals); reject includes note) | | `item.deleted` | Soft **or** force delete (same event type — consumers cannot distinguish) | | `item.restored` | Item restore | | `file.created` / `file.updated` / `file.deleted` | File manager create/update/delete (**no** `file.restored`) | | `collection.created` / `collection.updated` / `collection.deleted` | Collection lifecycle (**no** `collection.restored`; soft/force both emit `deleted`) | | `ping` | Settings “Send test event” | > The same domain events can also go to Slack or Telegram as human-readable messages, without HMAC. See [Slack & Telegram notifiers](/docs/integrations) or [Project settings — Notifiers](/docs/project-settings#notifiers-slack-telegram). > Only **items** emit `*.restored`. File restore (`FileService::restore`) and collection restore do not dispatch outbound webhook events. ### Bulk import CSV / remote JSON imports wrap row writes in `OutboundWebhookDispatcher::withoutWebhooks()` so a large import does not flood your endpoint. ## Headers | Header | Value | | --------------------- | ---------------------------------------- | | `Content-Type` | `application/json` | | `X-Externa-Signature` | `sha256=` | | `X-Externa-Event-Id` | Same as payload `id` | | `X-Externa-Timestamp` | Unix seconds when the job ran | | `User-Agent` | `Externa-Webhooks/1.0` | Algorithm: **HMAC-SHA256** over the **exact raw JSON body bytes**, using the project webhook secret. Prefix the hex digest with `sha256=`. Timeouts: connect 3s, total 5s. Failed deliveries retry (3 tries, backoff 10s / 30s / 60s). Exhausted failures are logged; they do not break the admin/API write path. ## Delivery status (admin) Project settings → **Outbound webhooks** shows: - **Last successful delivery** and **last failed delivery** (type, time, attempts, HTTP status / error message) - A **recent** ring buffer of the last 10 outcomes (best-effort under concurrent workers) Use **Send test event** after fixing a receiver; the cards update once the queue worker finishes the job. ## Verify HMAC (on your receiver) Sign the **raw request body** (not a re-encoded JSON object). Compare with a constant-time equality check. ### Node ```js import crypto from 'node:crypto' function verify(rawBody, secret, signatureHeader) { const expected = 'sha256=' + crypto.createHmac('sha256', secret).update(rawBody).digest('hex') const a = Buffer.from(expected) const b = Buffer.from(signatureHeader || '') return a.length === b.length && crypto.timingSafeEqual(a, b) } ``` ### PHP ```php $expected = 'sha256='.hash_hmac('sha256', $rawBody, $secret); hash_equals($expected, $signatureHeader); ``` ## Try / test ### End-to-end (recommended) 1. Open [webhook.site](https://webhook.site) (or run a local receiver) and copy its unique URL 2. Admin → **Settings** → **Project** → paste as **Webhook URL**, generate/save secret, keep a copy of the secret 3. Start a queue worker 4. Click **Send test event**, or create/update an item in the admin 5. Inspect the POST on webhook.site: JSON envelope + `X-Externa-*` headers 6. Point the URL at your real receiver and verify HMAC with the snippets above ### Practice signing with Bruno (optional) The [externa-bruno](https://github.com/qiick-io/externa-bruno) collection includes **`PublicApi/Webhooks/Verify Webhook Signature`**. > That request is a **docs helper / sample payload**, not a live Externa API. Its default URL is `{{base_url}}/__webhook_receiver_docs_only__` — an intentional placeholder. Sending it against externa-core **404s**. Replace the URL with **your** receiver (or webhook.site) if you want to POST the sample body yourself. Use it to: - See the envelope shape and header names Externa sends - Compute `sha256=` locally (same secret as Project settings → `webhook_secret` / `EXTERNA_WEBHOOK_SECRET` in Bruno `.env`) - Set `X-Externa-Signature` on the request and `POST` **to your receiver** to exercise verification code Bruno is acting as a **client toward your webhook endpoint**, not as a caller of Externa’s Public API. Full Bruno notes: [externa-bruno README — Webhooks helper](https://github.com/qiick-io/externa-bruno#webhooks-helper). ## Recipes (Make / n8n / Zapier / build hooks) Externa POSTs to **your** HTTPS URL. Point the project webhook at an automation “Custom webhook” / “Webhook” trigger, verify HMAC (or skip in a private network), then branch on `type`. ### Make (Integromat) 1. Create a scenario → **Webhooks → Custom webhook** → copy the URL 2. Paste into Externa Project settings → generate/save secret → **Send test event** 3. In Make, add a **Router** (or filter) on `type` — e.g. `item.updated` / `item.created` 4. Downstream modules: Slack, HTTP to rebuild, CRM, etc. 5. Optional: verify `X-Externa-Signature` in a Code module with the Node snippet above ### n8n 1. **Webhook** node (POST) → Production URL into Externa 2. **IF** / **Switch** on `{{$json.type}}` (e.g. equals `item.updated`) 3. Next nodes: HTTP Request (Vercel/Netlify deploy hook), Slack, database, … 4. For HMAC: **Crypto** node or Code node comparing `sha256=` + HMAC of the **raw body** to `X-Externa-Signature` ### Zapier 1. **Webhooks by Zapier → Catch Hook** → copy URL into Externa 2. Filter step: `type` is one of `item.created`, `item.updated`, … 3. Action: Slack / email / HTTP — same pattern as Make > Use the generic webhook trigger. Externa does not ship a marketplace connector; the signed HTTP envelope is the integration surface. ### Static host rebuild (Vercel / Netlify) Treat the platform **Deploy Hook / Build Hook** URL as the webhook consumer when you only need “content changed → rebuild”: 1. Create a Deploy Hook in Vercel (or Build Hook in Netlify) 2. Paste that URL as the Externa webhook URL and set a signing secret 3. Every matching domain event triggers a rebuild > Vercel/Netlify deploy hooks usually **ignore** `X-Externa-Signature`. Anyone with the hook URL can trigger a build. Prefer a thin receiver (Make/n8n/your API) that verifies HMAC, filters `type`, then calls the deploy hook. Direct hook URL is fine for private staging only. Narrower CDN purge / rebuild cookbooks: [Webhook rebuild & CDN purge](/docs/webhook-rebuild-purge). ## vs inbound API keys | | Outbound webhooks | Public CMS API keys | | --------- | ------------------------------------ | ---------------------------- | | Direction | Externa → your URL | Your app → Externa `/api/v1` | | Secret | Project `webhook_secret` (encrypted) | `ek_…` key (hashed at rest) | | Auth | HMAC on body | `Authorization: Bearer` | Do **not** reuse an API key as the webhook signing secret. --- # Integrations — Slack & Telegram notifiers Source: https://docs.externa.qiick.io/docs/integrations Externa can fan out **human-readable** alerts to **Slack** (incoming webhook) and/or **Telegram** (bot `sendMessage`) for the same domain events as [outbound webhooks](/docs/webhooks). This is **outbound only** — Externa is not a Slack or Telegram chat backend. > **Related** > [Project settings](/docs/project-settings) · [Outbound webhooks](/docs/webhooks) · [Operations (queue worker)](/docs/operations) · [Security checklist](/docs/security-checklist) ## Configure 1. Admin → **Settings** → **Project** 2. Under **Outbound notifiers (Slack & Telegram)**: - **Slack:** paste an [Incoming Webhook](https://api.slack.com/messaging/webhooks) URL, save, then **Send Slack test** - **Telegram:** create a bot via [@BotFather](https://t.me/BotFather), add the bot to your channel/group, set **Chat ID** and **bot token**, save, then **Send Telegram test** Leave fields empty to disable each channel. Telegram requires **both** chat ID and token. The bot token must be the BotFather format (`123456:ABC…`), not the bot username. **Send Telegram test** runs synchronously and shows the Bot API error (for example `HTTP 404 — Not Found`) if the token or chat ID is wrong. The bot token is stored encrypted. > Delivery uses `DeliverSlackOutboundNotifierJob` and `DeliverTelegramOutboundNotifierJob`. Run a queue worker (`composer run dev` locally, or `php artisan queue:work` in production). See [Operations](/docs/operations). ## Events Notifiers subscribe to the same event bus as outbound webhooks (see the webhook [event catalog](/docs/webhooks#event-types-v1)). Under **Notify on these events**, choose which types fan out to Slack/Telegram; unchecked types stay silent there. Outbound webhooks are **not** filtered by this list. Bulk imports still use `OutboundWebhookDispatcher::withoutWebhooks()`, which suppresses notifier fan-out as well. **Send test** (`ping`) always works. Messages are short human summaries (headline, description, item/file title when known, collection name + slug, approval status, actor, timestamp, admin deep link) — not full record dumps. Outbound webhook JSON payloads stay minimal (ids/slug only). ## Privacy When enabled, event metadata leaves your self-hosted Externa instance and is processed by Slack or Telegram under their terms. Review provider policies before enabling in regulated environments. --- # Webhook rebuild & CDN purge Source: https://docs.externa.qiick.io/docs/webhook-rebuild-purge Use [outbound webhooks](/docs/webhooks) when headless content changes should **rebuild a static host** or **purge a CDN**. Prefer a thin receiver that verifies HMAC, then calls the platform API — do not expose unsigned deploy hooks on the public internet. > **Related** > [Outbound webhooks](/docs/webhooks) · [Automation without Flows](/docs/automation-without-flows) · [Deployment](/docs/deployment) · [Content versioning](/docs/content-versioning) ## Event picks | Goal | Listen for | | ----------------------------- | -------------------------------------- | | Content changed (draft saved) | `item.created` / `item.updated` | | Only when live | `item.published` (manual or scheduled) | | Collection schema change | `collection.updated` | | Smoke test | `ping` from Project settings | Filter in Make/n8n/your code on JSON `type`. ## Pattern A — HMAC receiver → deploy hook 1. Externa → **your** HTTPS URL (secret set in Project settings). 2. Receiver verifies `X-Externa-Signature` (see [Webhooks](/docs/webhooks)). 3. If `type` matches, `POST` the platform **Deploy Hook / Build Hook** (no body required for Vercel/Netlify hooks). ### Vercel Deploy Hook ```bash # After HMAC OK: curl -fsS -X POST "$VERCEL_DEPLOY_HOOK_URL" ``` Create the hook in Vercel → Project → Settings → Git → Deploy Hooks. ### Netlify Build Hook ```bash curl -fsS -X POST "$NETLIFY_BUILD_HOOK_URL" ``` Site settings → Build & deploy → Build hooks. > Deploy hooks usually **ignore** Externa’s HMAC. Anyone with the URL can trigger builds. Staging-only exception is OK; production should verify signature first. ### Minimal Node receiver sketch ```js import crypto from 'node:crypto' import http from 'node:http' const secret = process.env.EXTERNA_WEBHOOK_SECRET const deployHook = process.env.VERCEL_DEPLOY_HOOK_URL http .createServer(async (req, res) => { const chunks = [] for await (const c of req) chunks.push(c) const raw = Buffer.concat(chunks) const expected = 'sha256=' + crypto.createHmac('sha256', secret).update(raw).digest('hex') if (req.headers['x-externa-signature'] !== expected) { res.writeHead(401) return res.end() } const body = JSON.parse(raw.toString('utf8')) if (['item.published', 'item.updated'].includes(body.type)) { await fetch(deployHook, { method: 'POST' }) } res.writeHead(204) res.end() }) .listen(8787) ``` ## Pattern B — Cloudflare cache purge After publish (or rebuild), purge paths or tags via Cloudflare API (token with **Cache Purge**). ```bash curl -fsS -X POST \ "https://api.cloudflare.com/client/v4/zones/${CF_ZONE_ID}/purge_cache" \ -H "Authorization: Bearer ${CF_API_TOKEN}" \ -H "Content-Type: application/json" \ --data '{"files":["https://www.example.com/","https://www.example.com/blog/"]}' ``` Purge everything (use sparingly): ```bash curl -fsS -X POST \ "https://api.cloudflare.com/client/v4/zones/${CF_ZONE_ID}/purge_cache" \ -H "Authorization: Bearer ${CF_API_TOKEN}" \ -H "Content-Type: application/json" \ --data '{"purge_everything":true}' ``` Wire this in the same receiver **after** HMAC + `type` filter (often only `item.published`). Map `collection` / item slug from the webhook payload to URLs your frontend uses. ## Make / n8n shortcut 1. Webhook trigger URL → Externa Project settings + secret. 2. Filter: `type` equals `item.published` (or list). 3. HTTP Request → Deploy Hook URL **or** Cloudflare purge endpoint with Bearer token. Same as [Webhooks recipes](/docs/webhooks#recipes-make--n8n--zapier--build-hooks), with explicit purge step. --- # Operations Source: https://docs.externa.qiick.io/docs/operations Externa depends on a **long-running queue worker** and Laravel’s **scheduler** for file zips/uploads, AI attachments, activity-log retention, and AI sync sources. Without them, async features stall and temporary storage grows. For product automation (listeners / jobs / webhooks vs Directus Flows), see [Automation without Flows](/docs/automation-without-flows). ## Queue worker (required) Default in `.env.example` for the **full** stack: `QUEUE_CONNECTION=redis` with [Horizon](/docs/horizon). Cloners without Redis should use `QUEUE_CONNECTION=database` (see [Minimal vs full stack](/docs/minimal-vs-full-stack), [Production without Redis](/docs/production-without-redis), and [Configuration](/docs/configuration)). Upgrades: [Upgrade](/docs/upgrade). Jobs that expect a worker include: - Multi-file / folder **zip** preparation - Large / bulk **file duplicates** - **Collection import** jobs started from AI tools - **Outbound webhook** delivery (`DeliverOutboundWebhookJob`) — see [Outbound webhooks](/docs/webhooks) With Redis + Horizon, those jobs are consumed by `php artisan horizon` instead of `queue:listen`. Queue health lives on the dashboard **Health** tab (native cards — no primary “Open Horizon” CTA); failed/retry deep-dive: `/horizon` (super-admin). ### Local Minimal (`composer run dev`) starts: ```bash php artisan queue:listen --tries=1 --timeout=0 ``` Full stack with **Herd Redis + Herd Reverb** (recommended): ```bash php84 artisan horizon php84 artisan pulse:work ``` Herd Reverb is already on `:8080` — match `REVERB_*` / `VITE_REVERB_*` and skip `reverb:start`. Standalone all-in-one: `composer run dev:full` (includes `reverb:start`; conflicts if Herd already binds `:8080`). See [Redis prerequisite](/docs/redis-prerequisite) and [Minimal vs full stack](/docs/minimal-vs-full-stack). **Docker Compose:** Horizon / Reverb / scheduler / Pulse are separate services — `docker compose up --build` ([Installation — Docker](/docs/installation#docker-compose-local-full-stack)). ### Production Prefer Horizon when using Redis: ```bash php artisan horizon ``` Also run a Reverb process (or managed WebSocket) and `pulse:work` when Pulse redis ingest is enabled. Or a plain worker: ```bash php artisan queue:work --tries=3 --timeout=300 ``` Use your process manager (Supervisor, systemd, Forge, Cloud Run sidecars, etc.). Restart workers after each deploy so they pick up new code. > `QUEUE_CONNECTION=sync` runs jobs inline and will block HTTP requests on large zips/imports. Keep a real queue driver and a worker. ## Scheduler Defined in `routes/console.php`: | Command | Frequency | Purpose | | ------------------------------- | ----------- | ----------------------------------------------------- | | `activitylog:clean` | daily | Purge old Spatie activity log rows per package config | | `files:cleanup-uploads` | hourly | Remove expired partial/chunked uploads | | `files:cleanup-zips` | hourly | Remove prepared zips past `FILES_ZIP_TTL_MINUTES` | | `ai:cleanup-attachments` | daily | Delete expired AI chat attachments + files | | `ai:run-sync-sources` | everyMinute | Run due AI collection sync sources | | `collections:process-schedules` | everyMinute | Promote due drafts / soft-delete due unpublish_at | The scheduler only fires when something invokes Laravel’s schedule runner every minute: ```bash * * * * * cd /path/to/externa-core && php artisan schedule:run >> /dev/null 2>&1 ``` On platforms with a native scheduler (Laravel Cloud, Forge, etc.), enable the equivalent cron / schedule feature. ### Manual runs ```bash php artisan activitylog:clean php artisan files:cleanup-uploads php artisan files:cleanup-zips php artisan ai:cleanup-attachments php artisan ai:run-sync-sources php artisan schedule:run # runs due events once ``` ## Permissions sync When `PermissionEnum` changes (or after deploy of permission-related code): ```bash php artisan permissions:sync # remove DB permissions no longer in the enum: php artisan permissions:sync --prune ``` Also available from the admin UI via `POST /settings/permissions/sync` (`can-edit-permissions`). Sync upserts Spatie permission rows and regenerates `resources/js/enums/permission-enum.ts`. After first install / empty DB, prefer `php artisan db:seed` (permissions → roles → super admin). See [Configuration](/docs/configuration). ## Health check Auth-free probes (not the admin Pulse/Health UI): | Endpoint | Meaning | | ------------------- | ---------------------------------------------------------------------------------- | | `GET /up` | Laravel framework liveness | | `GET /health/live` | Process up — JSON `{ "status": "ok" }` | | `GET /health/ready` | DB (+ Redis when required) — JSON `{ "status", "checks" }`; **503** when not ready | Use `/health/ready` for Compose/Kubernetes readiness and load balancers. Details and Compose `healthcheck` example: [Deployment — Health probes](/docs/deployment#health-probes). ## Jobs monitor (admin UI) Settings → **Jobs** (`/settings/jobs`): | Permission | Capability | | ----------------- | -------------------------------------- | | `can-show-jobs` | View pending sample + failed jobs list | | `can-manage-jobs` | Retry / delete failed jobs | Reads Laravel `jobs` and `failed_jobs` (database queue). Domain status UIs (AI import job UUID, zip preparation) stay in their existing screens — this page is queue ops only, not a workflow builder. Routes: `jobs.index`, `jobs.retry`, `jobs.destroy` in `routes/admin.php`. Controller: `JobMonitorController`. ## Performance settings Settings → **Performance** (`/settings/performance`): | Permission | Capability | | ----------------------------- | ---------------------------------- | | `can-manage-project-settings` | View status, flush targeted caches | Operators see nested ops status (cache store, Redis reachable, queue connection, app env/debug), bootstrap cache read-only (config/routes/events/packages cached yes/no), public API cache parameters (TTL 120s safety net, epoch counter), and observability quick links when permitted (Jobs, Pulse, Horizon). Four targeted flush actions let you invalidate specific subsystems without a full `artisan optimize:clear`: 1. **Public API cache** — bumps the global epoch counter so all collection responses miss immediately (next hit rebuilds with fresh permission matrix). 2. **Permission matrices** — forgets cached collection/file permission matrices for every role (Forces lazy rebuild on next role check). 3. **Spatie permissions** — flushes Spatie's permission registrar cache (when `PermissionEnum` changes or roles/permissions are mutated). 4. **Dashboard metrics** — forgets the 13 dashboard metric keys (fresh metrics on next dashboard load). Bootstrap cache status is **read-only** — no artisan optimize buttons from the UI; prefer `php artisan optimize` / `php artisan optimize:clear` in your deploy script or CI (or do not cache at all in local). Observability links appear when the user has the relevant permission: - **Jobs** — `can-show-jobs` → `/settings/jobs` - **Pulse** — `viewPulse` gate (super-admin) → `/pulse` - **Horizon** — `viewHorizon` gate (super-admin) → `/horizon` Routes: `performance.edit`, `performance.flushPublicApi`, `performance.flushPermissionMatrices`, `performance.flushSpatiePermissions`, `performance.flushDashboardMetrics` in `routes/admin.php`. Controller: `PerformanceSettingsController`. ## Observability ### Laravel Pail Dev script includes: ```bash php artisan pail --timeout=0 ``` Tail application logs in real time during local development. In production, rely on your log stack (`LOG_CHANNEL`, centralized logging). ### Activity log Spatie Laravel Activitylog records auth and domain mutations (configurable via `config/activitylog.php`, env `ACTIVITYLOG_ENABLED`). - UI: `/activity-logs` (`can-show-activity-logs`) - Cleanup: `activitylog:clean` (daily schedule) - AI: `QueryActivityLogs` tool for users with show permission ### Notifications Unread counts are shared on every Inertia page (`notifications.unread_count`). With Reverb + Echo, the sidebar bell listens on the private user channel; the **60s unread poll runs only when** `BROADCAST_CONNECTION` is `log`/`null`. JSON helpers: - `GET /notifications` - `GET /notifications/unread-count` - `POST /notifications/read` (all `auth` + `verified`). Details: [Reverb & Echo](/docs/reverb-and-echo) · [Activity & notifications](/docs/activity-and-notifications). ## Dependency vulnerability CI Maintainers: CI runs Composer + npm production audits on every PR (see [Contributing — Dependency audits](/docs/contributing#dependency-audits-ci)). Treat red audit jobs like red tests — upgrade or document an accepted risk before merge. Host/app reporting: [`SECURITY.md`](https://github.com/qiick-io/externa-core/blob/develop/SECURITY.md). ## Code scanning & secret scanning For maintainers of the public GitHub repo: | Control | Where | | ----------------------------------------- | ---------------------------------------------------------------------- | | CodeQL (JS/TS; PHP when pack supports it) | Actions workflow `codeql`; alerts under **Security → Code scanning** | | Secret scanning + push protection | Repo **Settings → Code security** (enabled on `qiick-io/externa-core`) | Triage CodeQL on first land and after noisy upgrades. Push protection blocks commits that contain known secret patterns — rotate if anything leaked. Details: [Security checklist](/docs/security-checklist#code-scanning--secrets) · [`SECURITY.md`](https://github.com/qiick-io/externa-core/blob/develop/SECURITY.md). ## Operational checklist 1. Worker process up and processing the `default` queue. 2. Cron / platform scheduler running `schedule:run` every minute. 3. `storage:link` present if serving public/assets URLs. 4. `permissions:sync` after permission enum deploys. 5. `/up` monitored. 6. Disk space watched under `storage/app` (uploads, zips, AI attachments, logs). 7. Run pending migrations before relying on revisions / M2M junction shape (`collection_item_revisions`, junction object migration). 8. Prefer `LIGHTHOUSE_QUERY_CACHE_MODE=opcache` (default) so GraphQL parse cache does not store AST objects in the database cache driver. 9. Walk the [Security checklist](/docs/security-checklist) before production. 10. After deploy, smoke the Public API / GraphQL with [externa-bruno](https://github.com/qiick-io/externa-bruno) (duplicate **Local**, set staging `base_url` + key) — see [Public CMS API](/docs/public-cms-api#bruno-collection-externa-bruno). --- # Automation without Flows Source: https://docs.externa.qiick.io/docs/automation-without-flows Externa will **not** ship a visual Flow / no-code automation canvas. Operators automate with **Laravel** (listeners, jobs, schedule) and **outbound webhooks**. This page is the Directus-migrant map. > **Related** > [Extending](/docs/extending) · [Outbound webhooks](/docs/webhooks) · [Operations](/docs/operations) · [Queues & scheduler](/docs/operations) · [Scheduled publish](/docs/content-versioning) · [Rebuild / CDN purge](/docs/webhook-rebuild-purge) ## Choose a tool | Need | Prefer | Why | | ------------------------------------------------------------ | ------------------------------------------ | ---------------------------------------------------------------------------- | | React inside the same app (side effects, sync another table) | **Eloquent model hook** or `Event::listen` | Same request / same deploy; full model access | | Slow / unreliable / external HTTP | **Queued Job** | Retries, Horizon, isolation from the admin request | | Cron / recurring | **`routes/console.php` Schedule** | Already runs `collections:process-schedules`, cleanups, sync | | Agency / Make / n8n / static rebuild | **Outbound webhook** | Signed HTTP; no custom PHP on the CMS host | | “Publish at 09:00” / unpublish | **Built-in scheduled publish** | `publish_at` / `unpublish_at` + `collections:process-schedules` — not a Flow | **Non-goal:** a drag-and-drop Flow builder. Fork or wrap Externa if you need one. ## Out of the box - **Outbound webhooks** — `item.*`, `collection.*`, `file.*`, `ping` via `OutboundWebhookDispatcher` / `DeliverOutboundWebhookJob` ([catalog](/docs/webhooks)). - **Scheduled publish** — versioned items; admin UI + every-minute schedule. - **AI / import jobs** — e.g. `ImportCollectionJob` on the queue. For “when item updates → Slack / rebuild / CRM”, start with [webhooks](/docs/webhooks) before writing PHP. ## Skeleton: model `saved` listener Core emits webhooks from writers (`CollectionItemValuesWriter`, `CollectionItemPublisher`), not a dedicated `ItemUpdated` domain event. For in-process automation, observe the model (or listen after you define your own event): ```php // app/Providers/AppServiceProvider.php (boot) use App\Models\CollectionItem; CollectionItem::saved(function (CollectionItem $item): void { if ($item->wasRecentlyCreated) { return; // or handle create separately } // Fast, safe work only — queue anything slow: \App\Jobs\SyncItemToLegacyCrm::dispatch($item->id); }); ``` Or a class-based listener on a **custom** event you fire from an observer — same pattern as Laravel docs. Keep CMS upgrades clean: put custom code in `app/` (or a package), not in vendor. ## Skeleton: queued job ```php find($this->itemId); if ($item === null) { return; } // HTTP / SDK call… } } ``` Require a worker (`queue:listen` / Horizon) — see [Operations](/docs/operations). Outbound webhook delivery already uses `DeliverOutboundWebhookJob` the same way. ## Skeleton: schedule `routes/console.php` already schedules Externa maintenance. Add your own command next to it: ```php use Illuminate\Support\Facades\Schedule; Schedule::command('collections:process-schedules')->everyMinute(); // Your nightly digest, etc. Schedule::command('app:send-editorial-digest')->dailyAt('07:30'); ``` Production: cron → `php artisan schedule:run` every minute (or Laravel’s scheduler systemd unit). ## Webhooks instead of Flows Point Project settings → Webhook URL at Make / n8n / Zapier / your API. Filter on `type` (`item.updated`, `item.published`, …). Verify `X-Externa-Signature`. Recipes: [Webhooks](/docs/webhooks) · [Rebuild / purge](/docs/webhook-rebuild-purge). ## vs Directus Flows (cheat sheet) | Directus Flow idea | Externa | | ------------------- | -------------------------------------------- | | Filter → webhook | Outbound webhook + Make/n8n filter on `type` | | Filter → run script | Job / model hook in `app/` | | Schedule trigger | `Schedule::` in `routes/console.php` | | Publish later | Built-in `publish_at` / `unpublish_at` | | Nested Flow UI | Not provided — code or external automation | --- # Mail providers Source: https://docs.externa.qiick.io/docs/mail-providers Externa sends mail through Laravel’s mailer (`config/mail.php`). Pick a transport with `MAIL_MAILER`, set provider credentials, and keep a real `MAIL_FROM_*`. Logo/colors for transactional templates come from **Project settings**, not `MAIL_*`. > **Related** > [Environment variables](/docs/environment-variables) · [Project settings](/docs/project-settings#transactional-email-branding) · [Deployment](/docs/deployment) · [Operations](/docs/operations) (queue mail) ## Defaults Local / CI often use: ```env MAIL_MAILER=log MAIL_FROM_ADDRESS="hello@example.com" MAIL_FROM_NAME="${APP_NAME}" ``` `log` writes messages to the Laravel log — fine for smoke tests, **not** production. For production, prefer a real provider and **queue** mail so admin requests do not wait on SMTP (`QUEUE_CONNECTION` + worker / Horizon). ## SMTP (generic) Any SMTP relay (workspace Google/Microsoft, Mailtrap, corporate relay): ```env MAIL_MAILER=smtp MAIL_HOST=smtp.example.com MAIL_PORT=587 MAIL_USERNAME=apikey_or_user MAIL_PASSWORD=secret MAIL_FROM_ADDRESS="noreply@example.com" MAIL_FROM_NAME="Externa" # Optional when the server expects a scheme: # MAIL_SCHEME=null ``` ## Amazon SES ```env MAIL_MAILER=ses MAIL_FROM_ADDRESS="noreply@example.com" MAIL_FROM_NAME="Externa" AWS_ACCESS_KEY_ID=AKIA… AWS_SECRET_ACCESS_KEY=… AWS_DEFAULT_REGION=eu-west-1 ``` Uses Laravel’s `ses` transport (`config/mail.php`). Verify the from-domain in SES and move out of sandbox for arbitrary recipients. ## Postmark ```env MAIL_MAILER=postmark MAIL_FROM_ADDRESS="noreply@example.com" MAIL_FROM_NAME="Externa" POSTMARK_TOKEN=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx ``` Token is read by Laravel’s Postmark transport / Symfony bridge (see Laravel mail docs for your framework version). ## Mailgun ```env MAIL_MAILER=mailgun MAIL_FROM_ADDRESS="noreply@example.com" MAIL_FROM_NAME="Externa" MAILGUN_DOMAIN=mg.example.com MAILGUN_SECRET=key-… # MAILGUN_ENDPOINT=api.mailgun.net # or api.eu.mailgun.net ``` ## Checklist 1. Set `MAIL_FROM_ADDRESS` to a domain you control (SPF / DKIM at the provider). 2. Smoke: password reset or notification → real inbox (not only `log`). 3. Branding: Project settings → transactional email (logo URL must be publicly reachable). 4. Queue worker running if you use `ShouldQueue` notifications / mailables. 5. Failures: Horizon / `failed_jobs` — see [Operations](/docs/operations). > Laravel also ships a `resend` mailer and failover arrays in `config/mail.php`. Same idea: set `MAIL_MAILER` + provider env keys from Laravel’s docs. --- # Redis prerequisite Source: https://docs.externa.qiick.io/docs/redis-prerequisite Horizon, app cache (including public API response cache), Reverb (presence + fan-out / scaling), and Pulse **redis ingest** all expect a reachable Redis. Without it, run the **minimal** stack (database queue / cache + log broadcast) and skip Horizon / Pulse redis ingest / Echo. > **Related** > Full vs minimal: [Minimal vs full stack](/docs/minimal-vs-full-stack). Queues: [Operations](/docs/operations). Official: [Redis](https://laravel.com/docs/13.x/redis). ## What Externa uses Redis for | Feature | Why Redis | | -------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | | [Horizon](/docs/horizon) | Queue backend + Horizon meta (`QUEUE_CONNECTION=redis`) | | App cache / performance | `CACHE_STORE=redis` on `REDIS_CACHE_DB=1` (distinct from queue DB `0`) — preferred for public API caching; `database` is fine for light local | | [Reverb + Echo](/docs/reverb-and-echo) | Scaling / presence pub-sub (Herd Reverb still expects Redis for multi-node; local single-node benefits from Redis for the rest of the stack) | | [Pulse](/docs/pulse-and-health) | `PULSE_INGEST_DRIVER=redis` stream + `pulse:work` | ## Herd 1. Enable **Redis** in Laravel Herd. 2. Confirm: `redis-cli ping` → `PONG`. 3. PHP needs `ext-redis` (Herd PHP 8.4 includes it). 4. Optional: enable **Reverb** under Herd Services (shared `:8080`) — see [Reverb & Echo](/docs/reverb-and-echo). Operators can check Redis reachability from Settings → **Performance** (`/settings/performance`) — the **Status** section shows whether Redis responds to `ping()`. See [Operations](/docs/operations). ## Docker Compose `compose.yaml` / `compose.prod.yaml` run a `redis` service. App env uses `REDIS_HOST=redis`. See [Installation — Docker](/docs/installation#docker-compose-local-full-stack). ## Env ```ini REDIS_CLIENT=phpredis REDIS_HOST=127.0.0.1 REDIS_PASSWORD=null REDIS_PORT=6379 CACHE_STORE=redis # Dedicated DBs: # REDIS_DB=0 # default / Horizon / queues REDIS_CACHE_DB=1 # app cache (keep separate from queues) # REDIS_PULSE_DB=2 # Pulse ingest (config/database.php connection "pulse") ``` Externa registers a dedicated Redis connection named **`pulse`** so Pulse ingest does not share Horizon/queue keys ([Pulse Redis ingest](https://laravel.com/docs/13.x/pulse#redis-ingest)). Use `REDIS_CACHE_DB=1` so Laravel cache keys do not collide with Horizon/queue data on DB `0`. ## Without Redis Set: ```ini QUEUE_CONNECTION=database CACHE_STORE=database BROADCAST_CONNECTION=log PULSE_ENABLED=false # or PULSE_INGEST_DRIVER=storage ``` Then use `composer run dev` (`queue:listen`) instead of Horizon + Pulse workers. Notifications fall back to the **60s unread poll** only in this mode. **Production hosts without Redis:** capability matrix + supervisor — [Production without Redis](/docs/production-without-redis). --- # Horizon Source: https://docs.externa.qiick.io/docs/horizon Externa ships **Laravel Horizon** for Redis-backed queues (zip, duplicate, imports, webhooks). Use it when Redis is available; otherwise keep `QUEUE_CONNECTION=database` and `queue:listen` / `queue:work`. > **Upstream** > [Laravel Horizon 13.x](https://laravel.com/docs/13.x/horizon) · Redis: [Redis prerequisite](/docs/redis-prerequisite) · Jobs: [Operations](/docs/operations) · UI: [Dashboard](/docs/dashboard) ## What Externa does | Item | Detail | | ----------- | ------------------------------------------------------------------------------------ | | Package | `laravel/horizon` | | Config | `config/horizon.php` | | Provider | `App\Providers\HorizonServiceProvider` | | Primary UI | Dashboard **Health** tab — native status / pending / failed / throughput / workloads | | Advanced UI | `/horizon` (super-admin deep dive; **no** primary “Open Horizon” CTA on Health) | | Gate | `viewHorizon` — super-admin (`EffectivePermissionResolver::isSuperAdmin`) | ## Env / commands ```ini QUEUE_CONNECTION=redis REDIS_CLIENT=phpredis REDIS_HOST=127.0.0.1 REDIS_PORT=6379 ``` ```bash # Prefer PHP 8.4 (Herd) php84 artisan horizon ``` With Herd Redis + Herd Reverb already up, pair Horizon with `pulse:work` (see [Minimal vs full stack](/docs/minimal-vs-full-stack)). Standalone all-in-one: `composer run dev:full` (also starts `reverb:start` — skip that process if Herd Reverb owns `:8080`). Docker Compose runs Horizon as the `horizon` service — [Installation — Docker](/docs/installation#docker-compose-local-full-stack). Graceful restart after deploy: `php artisan horizon:terminate`. ## Supervisor (production) Run `php artisan horizon` under Supervisor/systemd (not `queue:work` when Horizon is the chosen worker). See [Horizon — deploying](https://laravel.com/docs/13.x/horizon#deploying-horizon). ## Fail / retry UI Queue health appears on the Inertia dashboard **Health** tab. Open `/horizon` as a super-admin only when you need deep job inspection, failures, and retries. The Inertia admin **Jobs** page remains a separate app-level monitor (`CanShowJobs`) over `jobs` / `failed_jobs`. Super-admins with `viewHorizon` can also navigate to `/horizon` from Settings → **Performance** (`/settings/performance`) — the **Observability** section shows quick links when permitted. See [Operations](/docs/operations). --- # Reverb & Echo Source: https://docs.externa.qiick.io/docs/reverb-and-echo Externa uses **Reverb** + **Laravel Echo** (`laravel-echo` + `pusher-js`) for admin realtime notifications and presence. Locally, prefer **Herd Pro Reverb** on `:8080` (already running as a shared service). AI chat SSE is unchanged. > **Upstream** > [Reverb](https://laravel.com/docs/13.x/reverb) · [Broadcasting / presence](https://laravel.com/docs/13.x/broadcasting) · Fallback: [Minimal vs full](/docs/minimal-vs-full-stack) ## What websockets cover | Covered by WebSockets | Not covered (other transport) | | ------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------- | | Private user notifications (file zip / duplication outcomes) | **AI chat** — browser **SSE** (`POST /ai/chat`), not Echo | | Presence channel `online` (green dots on Users list) | **Activity log flood** — Spatie rows stay poll/Inertia; not broadcast per row | | Presence channel `chat.{uuid}` (team chat messages / typing) | **Public CMS / GraphQL / API keys** — no realtime fan-out | | Presence `collection-item.{collectionId}.{itemId}` (editors) | **CRDT field sync** — collab is presence + soft lock only ([Collaborative editing](/docs/collaborative-editing)) | | Connection status indicator (avatar `ConnectionDot`) | Unread count when `BROADCAST_CONNECTION` is `log`/`null` — **60s poll only then** | | Auth via `/broadcasting/auth` (session cookie) | | | Private user events `ChatUnreadUpdated` / `ThreadUpserted` | | When realtime is **on**, the notifications bell does **not** poll every 60s. Poll is the fallback for the minimal stack only. Team chat product docs: [Chat (hub & items)](/docs/chat). ## Events (notification classes that broadcast) All four use trait `App\Notifications\Concerns\BroadcastsWithDatabase`: - Always `database` - Plus `broadcast` when `BROADCAST_CONNECTION` is **not** `log` / `null` / empty | Class | `data.type` | Emitted from | | -------------------------------------- | ---------------------------- | -------------------- | | `FileZipReadyNotification` | `file_zip_ready` | `PrepareFilesZipJob` | | `FileZipFailedNotification` | `file_zip_failed` | `PrepareFilesZipJob` | | `FileDuplicationCompletedNotification` | `file_duplication_completed` | `DuplicateFilesJob` | | `FileDuplicationFailedNotification` | `file_duplication_failed` | `DuplicateFilesJob` | `toBroadcast()` wraps the same `toArray()` payload as the database notification (`BroadcastMessage`). ## Channels | Name in `routes/channels.php` | Wire / Echo name | Auth | | ----------------------------------------- | -------------------------------------------------- | -------------------------------------------------------------------- | | `App.Models.User.{id}` | Private `App.Models.User.{id}` | Owner only (`(int) $user->id === (int) $id`) | | `online` | Presence `presence-online` (Echo `join('online')`) | Active `User`; returns `{ id, name }` | | `collection-item.{collectionId}.{itemId}` | Presence `presence-collection-item.…` | Active user + `can-show-collections` + item readable; `{ id, name }` | ## Echo listen points (UI) | Location | Behavior | | ------------------------------------- | ------------------------------------------------------------------------------------------------------------------- | | `resources/js/lib/echo.ts` | `ensureEcho()` — boots Reverb client when Inertia `realtime.enabled` is true | | `resources/js/app.tsx` | Eager `ensureEcho(true)` on boot when enabled | | `NotificationsBell` | `echo.private('App.Models.User.{id}').notification(...)` — bumps unread + `NOTIFICATIONS_UPDATED_EVENT` | | `useOnlineUsers` | `echo.join('online')` — `.here` / `.joining` / `.leaving` → Users list green dots | | `useEchoConnection` + `ConnectionDot` | Tracks Pusher connection state; rendered on sidebar avatar via `UserInfo` (`nav-user.tsx` → `showConnectionStatus`) | Connection dot tones: connected (green), connecting (amber pulse), disabled (muted), else destructive. ## Herd Reverb (recommended local) Herd Pro runs a **shared Reverb** on port **8080**. Match credentials from **Herd → Services → Reverb** (defaults in `externa-core/.env.example`): ```ini BROADCAST_CONNECTION=reverb REVERB_APP_ID=1001 REVERB_APP_KEY=laravel-herd REVERB_APP_SECRET=secret REVERB_HOST=127.0.0.1 REVERB_PORT=8080 REVERB_SCHEME=http VITE_REVERB_APP_KEY="${REVERB_APP_KEY}" VITE_REVERB_HOST="${REVERB_HOST}" VITE_REVERB_PORT="${REVERB_PORT}" VITE_REVERB_SCHEME="${REVERB_SCHEME}" ``` - Host for Echo must be `127.0.0.1` or `localhost` (not `0.0.0.0`). - After changing `VITE_REVERB_*`, restart `npm run dev` (or rebuild). - **Do not** also run `php artisan reverb:start` while Herd Reverb owns `:8080` — port conflict. - Auth endpoint: `/broadcasting/auth` (web middleware / session cookie). ## Docker Compose `compose.yaml` runs a dedicated `reverb` service. Host port defaults to **`8081`** (container `8080`) so it does not clash with Herd Pro on **`:8080`**. Use `.env.docker.example` (`REVERB_PORT` / `VITE_REVERB_PORT=8081`). Inside the Compose network the app talks to `REVERB_HOST=reverb` on port `8080`; browsers use the **published** host port. Production: proxy WebSocket upgrades to the Reverb service — [Deployment — Docker](/docs/deployment#docker-production). ### Standalone Reverb (no Herd service) ```bash php84 artisan reverb:start ``` Use your own `REVERB_APP_*` values on both the app and the Reverb process. `composer run dev:full` starts `reverb:start` for that path. ## Feature detect `HandleInertiaRequests` shares: ```ts realtime: { enabled: boolean broadcaster: string } ``` `enabled` is false when `BROADCAST_CONNECTION` is `log` or `null` — UI skips Echo; bell keeps the **60s unread poll**. --- # Pulse & Health tab Source: https://docs.externa.qiick.io/docs/pulse-and-health Externa embeds **Laravel Pulse** for exceptions, queues, and slow jobs/queries. The Inertia dashboard **Health** tab shows native Externa-styled aggregates and charts — **not** an embedded Livewire Pulse UI and **not** a primary “Open Pulse” button. The full Livewire dashboard remains at `/pulse` for super-admin deep dives. > **Upstream** > [Laravel Pulse](https://laravel.com/docs/13.x/pulse) (Redis ingest + trimming) · UI: [Dashboard](/docs/dashboard) · Stack: [Minimal vs full](/docs/minimal-vs-full-stack) · Tuning: [Performance tuning](/docs/performance-tuning) · Machine probes: [Deployment — Health probes](/docs/deployment#health-probes) (`/health/live`, `/health/ready`) ## What Externa does | Item | Detail | | ------------------------ | -------------------------------------------------------------------- | | Config | `config/pulse.php` | | Ingest | Prefer `PULSE_INGEST_DRIVER=redis` + connection `pulse` | | Trim | Default keep **2 days** (`PULSE_STORAGE_KEEP` / `PULSE_INGEST_KEEP`) | | Recorders on by default | Exceptions, Queues, SlowJobs, SlowQueries | | Recorders off by default | Cache interactions, slow requests/outgoing, user jobs/requests | | Gate | `viewPulse` — super-admin (full `/pulse` UI only) | | Primary UI | Dashboard → **Health** tab (`DashboardHealthMetrics`) | | Advanced UI | `/pulse` (optional; no primary CTA from Health) | ## Env / commands ```ini PULSE_ENABLED=true PULSE_INGEST_DRIVER=redis PULSE_REDIS_CONNECTION=pulse PULSE_STORAGE_KEEP="2 days" PULSE_INGEST_KEEP="2 days" ``` ```bash php84 artisan pulse:work # After deploy: php84 artisan pulse:restart ``` Without Redis, set `PULSE_INGEST_DRIVER=storage` (or `PULSE_ENABLED=false`) and skip `pulse:work`. ## Health tab vs `/pulse` | Surface | Contents | | -------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Dashboard **Health** | Queue/broadcast driver, Redis OK, pending/failed jobs; Pulse exception / slow-job / slow-query 24h counts + bar chart; Horizon status / pending / failed / throughput / workloads | | `/pulse` | Full Livewire Pulse dashboard (power users; bookmark) | Do **not** mount Pulse Livewire inside Inertia — Health reads Pulse tables / Horizon Redis APIs and renders Externa cards instead. Empty states appear when Pulse is off or `pulse_aggregates` is missing. ## Native Pulse fields on Health From `DashboardHealthMetrics::summary()`: | Prop | Source | | ------------------ | ----------------------------------------------------------- | | `pulse_enabled` | `config('pulse.enabled')` | | `pulse_ingest` | ingest driver name | | `pulse_available` | enabled **and** `pulse_aggregates` table exists | | `exceptions_24h` | sum of Pulse aggregates `type=exception` (last 24h buckets) | | `slow_jobs_24h` | `type=slow_job` | | `slow_queries_24h` | `type=slow_query` | Horizon fields on the same tab are documented on [Horizon](/docs/horizon) and [Dashboard](/docs/dashboard). ## Health badge prop `HandleInertiaRequests` shares `healthBadge` from `DashboardHealthMetrics::badge()` (`ok` / `warn` / `fail`). Open Health via Dashboard → Health (`/dashboard?tab=health`); there is no sidebar badge. See [Dashboard](/docs/dashboard). ## Performance Settings quick link Settings → **Performance** (`/settings/performance`) shows an **Observability** section with quick links to Jobs, Pulse, and Horizon (when the user has the relevant permissions). Super-admins with `viewPulse` can navigate to `/pulse` from there. See [Operations](/docs/operations). --- # Performance tuning Source: https://docs.externa.qiick.io/docs/performance-tuning Operator guide for keeping Public CMS API and admin paths fast: response cache, lean `include`s, queue workers, and Pulse/Health signals. > **Related** > [Public CMS API](/docs/public-cms-api) · [Pulse & Health](/docs/pulse-and-health) · [Horizon](/docs/horizon) · [Operations](/docs/operations) · [GA readiness](/docs/ga-readiness) ## Public API response cache Externa caches successful Public CMS API reads (REST and GraphQL) via `PublicApiResponseCache`. | Fact | Detail | | ------------ | --------------------------------------------------------------------------------------------------------------- | | Freshness | Synchronous collection version bump on write — cache misses immediately after publish/edit | | TTL | Safety net only (`PublicApiResponseCache::TTL_SECONDS` = **120**) for orphaned payload keys | | Global flush | Settings → **Performance** → flush Public API cache (bumps a global epoch folded into every collection version) | | Status | Same Performance page shows cache store, Redis reachability, queue driver, and public API epoch | When debugging stale public reads: confirm the write path bumped the collection, then flush Public API cache from Performance if you force-edited data outside normal writers. ## `include` costs Public item list/show responses are **lean by default**. Expanding files, users, or relations costs extra queries and payload size. | Practice | Why | | ------------------------------------------------------- | ------------------------------------------------------------ | | Prefer no `include` for list cards | Keep IDs; hydrate detail pages only | | Add `include=files` only when the UI needs file objects | Without it, image/file fields stay as stored IDs | | Limit relation field names in `include` | Depth 1 only; nested items do not re-apply relation includes | | Avoid spraying CSV tokens “just in case” | Unknown tokens are ignored, but known ones still hydrate | See [Public CMS API — Expand with `include`](/docs/public-cms-api#expand-with-include). > **RelationInclude list prewarm** > Core [#112](https://github.com/qiick-io/externa-core/issues/112) tracks list-level batch prewarm in `RelationIncludeHydrator` to kill N+1 when many items include the same relations. **Not shipped yet** — until it lands, prefer fewer includes on large list pages or fetch detail pages with includes instead of hydrating every row. ## Queues & Horizon Async work (webhook delivery, zips, imports, AI attachments, Meilisearch / git export jobs) needs a real queue worker. Without it, HTTP stays snappy while background features stall and storage grows. | Stack | Worker | | ------------ | ------------------------------------------------------------------------------------------------------------ | | Full (Redis) | `php artisan horizon` + [Horizon](/docs/horizon) | | Minimal | `QUEUE_CONNECTION=database` + `queue:listen` / `queue:work` — [Minimal vs full](/docs/minimal-vs-full-stack) | Do **not** use `QUEUE_CONNECTION=sync` in production — large zips/imports block requests. Scheduler must run every minute for scheduled publish and cleanup ([Operations](/docs/operations)). ## Reading Pulse for CMS hotspots Dashboard → **Health** shows Pulse aggregates without embedding Livewire Pulse: | Signal | What to check | | ------------------ | ---------------------------------------------------------- | | `exceptions_24h` | Spikes after deploys or bad public clients | | `slow_jobs_24h` | Webhook / import / zip jobs timing out | | `slow_queries_24h` | Heavy list filters, missing indexes, over-broad `include`s | Deep dive: `/pulse` (super-admin). Settings → Performance links Jobs / Pulse / Horizon when permitted. Full config: [Pulse & Health](/docs/pulse-and-health). ## Quick checklist 1. Redis (or database queue) + Horizon/worker running in prod 2. Scheduler every minute 3. Public list endpoints without unnecessary `include` 4. Performance flush after unusual cache suspicion 5. Watch Health tab after traffic or content spikes --- # Minimal vs full stack Source: https://docs.externa.qiick.io/docs/minimal-vs-full-stack Cloners can run a **minimal** stack without Redis, a **full** stack with Herd Redis/Reverb/Horizon/Pulse, or the same full stack via **Docker Compose** (no Herd). | | Minimal | Full (Herd) | Full (Docker Compose) | | ------------------------ | ----------------------------------------- | --------------------------------------------------------------- | ------------------------------------------------------------------------ | | Redis | Optional / off | Required ([guide](/docs/redis-prerequisite)) | `redis` service in `compose.yaml` | | Queue | `QUEUE_CONNECTION=database` | `QUEUE_CONNECTION=redis` + [Horizon](/docs/horizon) | `horizon` service | | Broadcast | `BROADCAST_CONNECTION=log` | `BROADCAST_CONNECTION=reverb` + [Reverb](/docs/reverb-and-echo) | `reverb` on host **`:8081`** (avoids Herd `:8080`) | | Notifications UI | **60s poll only** (realtime off) | Echo push on private channel; **no** 60s poll while connected | Same as full | | Presence / avatar WS dot | Offline / “Realtime off” | Presence `online` + avatar `ConnectionDot` | Same as full | | Pulse | Off or storage ingest | Redis ingest + `pulse:work` ([Pulse](/docs/pulse-and-health)) | `pulse` service | | Dashboard Health | Empty / unavailable Pulse & Horizon cards | Native Pulse + Horizon metrics | Same as full | | How to start | `composer run dev` | Herd + Horizon + Pulse (below) | `cp .env.docker.example .env && docker compose up --build` → **`:8000`** | Prefer **one** local path per checkout: Herd/bare-metal **or** Docker Compose. Compose overrides `APP_URL` / DB hosts via `COMPOSE_*` so a leftover Herd `.env` does not break containers — [Installation — Docker](/docs/installation#docker-compose-local-full-stack). Bare-metal / VPS production without Compose: [Deployment](/docs/deployment). Production without Redis: [Production without Redis](/docs/production-without-redis). Docker production image: [Deployment — Docker](/docs/deployment#docker-production). ## Processes (full local with Herd) Herd already serves the site and (with Pro) **Reverb on `:8080`**. Besides Vite / Pail / optional `artisan serve`: | Process | Required? | | -------------------------- | -------------------------------------------------------------------------------- | | `php84 artisan horizon` | Yes — Redis queues + Health Horizon cards | | `php84 artisan pulse:work` | Yes — Pulse aggregates for Health | | Herd Reverb service | Yes for Echo — **already running**; match `.env` credentials | | `php artisan reverb:start` | Only if **not** using Herd Reverb (standalone). Conflicts on `:8080` if both run | ```bash php84 artisan horizon php84 artisan pulse:work ``` `composer run dev:full` also starts `reverb:start` for non-Herd setups. With Herd Reverb up, run Horizon + Pulse yourself (or stop Herd’s Reverb before using `dev:full`). ## `.env.example` blocks See commented sections in `externa-core/.env.example` and [Environment variables](/docs/environment-variables). Herd Reverb defaults: `REVERB_APP_ID=1001`, `REVERB_APP_KEY=laravel-herd`, `REVERB_APP_SECRET=secret`, host `127.0.0.1`, port `8080`. --- # Deployment Source: https://docs.externa.qiick.io/docs/deployment Deploy **externa-core** like a standard Laravel + Vite app, then ensure Externa-specific workers, scheduler jobs, and AI/file storage are wired. Local setup details remain in [Installation](/docs/installation). Upgrading an existing host: [Upgrade](/docs/upgrade). Backups: [Backup & restore](/docs/backup-restore). ## Deploy checklist ### 1. Environment secrets Copy `.env.example` → production env store and set at least: | Variable | Production note | | ----------------------------------------------------- | -------------------------------------------------------------------------------------------------------- | | `APP_ENV` | `production` | | `APP_DEBUG` | `false` | | `APP_KEY` | Unique; never reuse from another env | | `APP_URL` | Canonical URL users browse (prefer **HTTPS**; required for passkeys). No trailing-slash / proxy mismatch | | `DB_*` | Production database (see SQLite swap below) | | `SESSION_DRIVER` / `CACHE_STORE` / `QUEUE_CONNECTION` | Prefer `database` or Redis — not `array`/`sync` | | `INITIAL_SUPER_ADMIN_*` | Strong password **before** first seed | | `AI_WEBHOOK_TOKEN` | Long random HMAC secret if collection-import webhooks are used | | `LOCAL_AI_*` / cloud provider keys | Point at a reachable gateway; do not commit keys | | `AI_REMOTE_IMPORT_HOSTS` | Tight allowlist if remote import is enabled | | Mail | Real `MAIL_*` if verification / password reset emails matter | Full catalog: [Environment variables](/docs/environment-variables). After changing env on a config-cached host: ```bash php artisan config:cache # or config:clear during debugging ``` ### 2. Build & migrate ```bash composer install --no-dev --optimize-autoloader npm ci npm run build php artisan migrate --force ``` Use your platform’s release process (zero-downtime, maintenance mode, etc.) as appropriate. ### 3. Seed / sync permissions **Fresh database:** ```bash php artisan db:seed --force ``` Runs `PermissionSeeder` → `RoleSeeder` → `CreateSuperAdminSeeder`. **Existing database after permission enum changes:** ```bash php artisan permissions:sync # optional cleanup: php artisan permissions:sync --prune ``` Do **not** re-seed blindly in production if it would reset roles or recreate a known super-admin password unexpectedly. Prefer sync + controlled role admin UI. ### 4. Storage link ```bash php artisan storage:link ``` Required for `public` / `assets` disk URLs (`/storage/...`). See [Files configuration](/docs/files-configuration). Ensure `storage/` and `bootstrap/cache/` are writable by the app user. ### 5. Queue worker (+ optional Reverb / Pulse) Prefer Redis + Horizon when available: ```bash php artisan horizon ``` Or a plain worker: ```bash php artisan queue:work --tries=3 --timeout=300 ``` If using the full realtime/observability stack, also supervise: - A Reverb (or compatible) WebSocket server with matching `BROADCAST_*` / `REVERB_*` / built `VITE_REVERB_*` - `php artisan pulse:work` when `PULSE_INGEST_DRIVER=redis` Dashboard **Health** surfaces native Pulse/Horizon metrics (no primary Open Pulse/Horizon CTAs). Restart workers on every deploy. Details: [Operations](/docs/operations) · [Minimal vs full stack](/docs/minimal-vs-full-stack). ### 6. Scheduler crontab ```cron * * * * * cd /path/to/externa-core && php artisan schedule:run >> /dev/null 2>&1 ``` This runs: - `activitylog:clean` (daily) - `files:cleanup-uploads` / `files:cleanup-zips` (hourly) - `ai:cleanup-attachments` (daily) - `ai:run-sync-sources` (every minute) ### 7. AI gateway If the assistant is enabled in production: 1. Set `AI_DEFAULT_PROVIDER` (default `local`) and the matching provider keys/URL. 2. Ensure the app server can reach `LOCAL_AI_URL` (or the cloud provider) with enough timeout for tool loops (`AppAssistant` uses 300s). Behind nginx/Herd, also raise `fastcgi_read_timeout` (often ≥ 600s) so long local-model reasoning does not drop the browser SSE. 3. Prefer tool-calling-capable models. 4. Set `AI_WEBHOOK_TOKEN` if using collection import webhooks; callers must HMAC-sign the **raw request body** only (`X-AI-Webhook-Signature`; **401** on failure from 1.2.2). Keep CSRF exception only for that path (machine clients). See [AI configuration](/docs/ai-configuration). ### 8. `APP_URL` and proxies - Match the public scheme/host (Herd, reverse proxy, CDN). - Set trusted proxies if TLS terminates upstream. - Optionally set `FILE_PUBLIC_URL_BASE` when asset URLs should use a CDN origin while the app stays on another host. - The CMS (password, TOTP, APIs) can run on HTTP, but **prefer HTTPS** in production. - **Passkeys:** require HTTPS in production; `APP_URL` must match Fortify WebAuthn `relying_party_id` / `allowed_origins`. See [Passkeys — Requirements](/docs/passkeys#requirements). ### 9. Production database (leave SQLite) `.env.example` defaults to `DB_CONNECTION=sqlite` for local ease. For production, pick from the [Supported databases](/docs/supported-databases) matrix (**PostgreSQL recommended**; MySQL 8+ / MariaDB 10.6+ also supported). 1. Provision Postgres 14+ (16 preferred), MySQL 8+, or MariaDB 10.6+. 2. Set `DB_CONNECTION` (`pgsql` | `mysql` | `mariadb`), `DB_HOST`, `DB_PORT`, `DB_DATABASE`, `DB_USERNAME`, `DB_PASSWORD`. 3. MySQL/MariaDB: keep `utf8mb4` / `utf8mb4_unicode_ci`. 4. Run migrations against the new database. 5. Keep session/cache/queue tables on a durable store (database or Redis). > SQLite is fine for demos and single-user local work. Concurrent writers, large activity logs, and multi-worker queues push you toward Postgres/MySQL/MariaDB quickly. ## Docker production Prefer the copy-paste operator guide: **[Deploy with Docker](/docs/deploy-with-docker)** (GHCR quick Compose, gated first-boot, healthchecks). > `ghcr.io/qiick-io/externa-core:1.2.2` and `latest` ship **PHP 8.4.27** (Composer requires `^8.4.27` from 1.2.2). Re-pull and recreate containers. Building from the Dockerfile: use `--pull --no-cache`. See [Deploy with Docker](/docs/deploy-with-docker) and [discussion #247](https://github.com/qiick-io/externa-core/discussions/247). Official build-from-source path uses the multi-stage **`Dockerfile`** and **`compose.prod.yaml`**. **Sail is not for production** (and not required locally either). ### Image stages | Target | Role | | ------------- | ------------------------------------------------------------------------------------- | | `php-base` | PHP 8.4-fpm + extensions (pgsql/mysql, redis, gd, zip, …) | | `build` | `composer install --no-dev` + `npm ci` + `npm run build` (Wayfinder needs vendor/PHP) | | `vite` | Local Compose only — PHP + Node for HMR | | `development` | nginx + php-fpm; source bind-mounted | | `production` | Baked app from `build` + nginx + php-fpm + prod opcache | Roles via `docker/entrypoint.sh`: `app`, `horizon`, `reverb`, `scheduler`, `pulse`, `queue`. ### Multi-arch images (amd64 + arm64) Production and development targets build for **`linux/amd64`** and **`linux/arm64`** (Apple Silicon + typical VPS). ```bash # Bake both platforms (Buildx). Fails if either arch fails. docker buildx bake -f docker-bake.hcl production # Helper wrapper (creates builder `externa-multiarch` if needed): ./scripts/docker-buildx.sh production # Optional push to a registry (set TAG first): # TAG=ghcr.io/qiick-io/externa-core:1.0.0 PUSH=1 ./scripts/docker-buildx.sh production ``` CI: on pushes to `develop` / `main`, the `multiarch-bake` job runs QEMU + Buildx for both targets. Pull requests keep the faster single-arch `image-build` job. **GHCR publish** runs on `v*` tags (and `workflow_dispatch`) — image `ghcr.io/qiick-io/externa-core`. Quick pull path: [Deploy with Docker](/docs/deploy-with-docker). **Docker build notes (beta.4+):** `php-base` pins **phpredis 6.3.0** from the GitHub tarball (avoids flaky `pecl install redis` on multi-arch bake). npm `prepare` no-ops Lefthook when `.git` is absent so `image-build` stays green (#94). Compose on Apple Silicon usually pulls/builds the matching arch automatically. To pin: ```yaml services: app: platform: linux/arm64 # or linux/amd64 ``` ### Bring up ```bash cp .env.docker.prod.example .env # Set APP_KEY, strong DB_PASSWORD / REVERB_*, real mail, TRUSTED_PROXIES # Recommended: FILES_DISK=s3 + AWS_* docker compose -f compose.prod.yaml up --build -d ``` Default published **app** port is host **`8080`→80** (`APP_PORT` in the prod sample — different from local `:8000`). Reverb still defaults to host **`8081`→8080**. | Piece | Notes | | ------------ | ------------------------------------------------------------------------------------------------------ | | Services | `app`, `horizon`, `reverb`, `scheduler`, `pulse`, `pgsql`, `redis` — **no** Vite, Mailpit, or MinIO | | Volumes | Named `externa_prod_storage` for `storage/` (zips + local disks); DB/Redis volumes when Compose-hosted | | Migrations | `RUN_MIGRATIONS=true` (sample default) → `migrate --force` on app boot | | Config cache | `CACHE_CONFIG=true` → `config` / `route` / `view` / `event:cache` | | Healthcheck | `GET /health/ready` — `start_period: 60s` (image already baked) | ### Proxy, TLS, WebSockets Copy-paste Caddy / Traefik / nginx: [Reverse proxy cookbook](/docs/reverse-proxy). Put TLS in front (Caddy / Traefik / nginx): 1. Set `APP_URL=https://your.domain` and `TRUSTED_PROXIES=*` (or your proxy CIDRs). Prefer `SESSION_SECURE_COOKIE=true` behind HTTPS. 2. Proxy HTTP to `app:80` (or published `APP_PORT`). 3. Proxy WebSocket **`Upgrade`** / `Connection` to the Reverb service (`reverb:8080` on the Compose network, or published `REVERB_HOST_PORT`). 4. Point public `REVERB_HOST` / `REVERB_PORT` / `REVERB_SCHEME` (and matching `VITE_REVERB_*`) at the **browser-facing** WebSocket URL. Rebuild the production image when Vite Reverb env baked into assets changes. ### Storage / S3 Prefer `FILES_DISK=s3` in production so app replicas do not need sticky local binds for CMS files. Prepared zips under `storage/app/zips` remain **local ephemeral** — share the `storage` volume with Horizon (Compose already does) or accept zip loss across hosts. See [Files configuration](/docs/files-configuration). ### Managed DB / Redis (Compose overlay) When Postgres and Redis run outside Compose (RDS, Upstash, etc.): ```bash cp .env.docker.prod.managed.example .env # Set APP_KEY, DB_HOST, REDIS_HOST, secrets, TRUSTED_PROXIES, browser-facing REVERB_* docker compose -f compose.prod.yaml -f compose.prod.managed.yaml up --build -d ``` - Starts **app + horizon + reverb + scheduler + pulse** only. - Bundled `pgsql` / `redis` services are behind profiles (`bundled-db` / `bundled-redis`) and do **not** start. - `GET /health/ready` still probes the external DB and Redis when drivers require them. - Network/TLS: allow the app containers to reach managed endpoints (security groups / VPC / TLS redis as required by the provider). Full bundled stack remains: `docker compose -f compose.prod.yaml up --build -d`. Local Docker full stack: [Installation — Docker Compose](/docs/installation#docker-compose-local-full-stack). ## Health probes Auth-free machine probes (not the admin Pulse/Health UI): | Endpoint | Meaning | Response | | ------------------- | ----------------- | ---------------------------------------------------------------------- | | `GET /up` | Laravel liveness | 200 when the app boots | | `GET /health/live` | Process up | JSON `{ "status": "ok" }` | | `GET /health/ready` | Ready for traffic | JSON `{ "status", "checks" }`; **503** if DB (or required Redis) fails | Redis is checked when `QUEUE_CONNECTION`, `CACHE_STORE`, `SESSION_DRIVER`, or `BROADCAST_CONNECTION` is `redis`. Compose example (local cold boot — long `start_period`): ```yaml healthcheck: test: ['CMD-SHELL', 'curl -fsS http://127.0.0.1/health/ready || exit 1'] interval: 10s timeout: 5s retries: 12 start_period: 300s ``` Production Compose uses a shorter `start_period` (baked image, typically `60s`). ## Smoke test after deploy 1. `GET /up` and `GET /health/ready` → healthy JSON. 2. Login as super admin (or your provisioned admin) — password and, if enabled, **Sign in with passkey**. 3. Open dashboard, files, collections (permissions permitting). 4. Trigger a small queued job (e.g. zip) and confirm Horizon/worker processes it. 5. If AI is enabled: `GET /ai/status` and a short chat with a read-only tool. 6. Confirm scheduled cleanups appear in logs after the next hour/day. ## Related - [Operations](/docs/operations) — ongoing worker/scheduler/observability - [Horizon](/docs/horizon) · [Reverb & Echo](/docs/reverb-and-echo) · [Pulse & Health](/docs/pulse-and-health) - [Configuration](/docs/configuration) — app/Fortify/session defaults - [Passkeys](/docs/passkeys) · [Threat model & hosting](/docs/threat-model) · [Security checklist](/docs/security-checklist) - [Environment variables](/docs/environment-variables) · [Minimal vs full stack](/docs/minimal-vs-full-stack) - [Supported databases](/docs/supported-databases) — versions, utf8mb4, CI jobs - [Deploy with Docker](/docs/deploy-with-docker) — GHCR quick Compose, gated first-boot - [Installation](/docs/installation) — Herd / `composer setup` / Docker local - [Files configuration](/docs/files-configuration) — `FILES_DISK` / S3 / MinIO --- # Deploy with Docker Source: https://docs.externa.qiick.io/docs/deploy-with-docker Operator path for self-hosted Externa: pull a published image (or build once), set a small `.env`, `docker compose up`, healthy stack. Mirrors the Directus-style friction goal. > **Related** > [Deployment](/docs/deployment) (bare metal + full Docker notes) · [Installation — Docker local](/docs/installation#docker-compose-local-full-stack) · [Reverse proxy](/docs/reverse-proxy) · [Environment variables](/docs/environment-variables) > Official `ghcr.io/qiick-io/externa-core:1.2.2` and `latest` ship **PHP 8.4.27** (Composer `php: ^8.4.27` from [#248](https://github.com/qiick-io/externa-core/pull/248)). Re-pull and recreate containers (`docker compose pull && docker compose up -d`), then check with `docker compose exec php -v`. Source builds: rebuild with `--pull --no-cache` so the `php:8.4.27-fpm-bookworm` base updates. Details: [externa-core discussion #247](https://github.com/qiick-io/externa-core/discussions/247). ## Quick deploy (pull GHCR) Published multi-arch image: **`ghcr.io/qiick-io/externa-core`** (`linux/amd64` + `linux/arm64`). CI pushes on version tags (`v*`) and optional workflow dispatch. ```bash # From an externa-core checkout (Compose + env sample live in the repo) cp .env.docker.quick.example .env # Required: APP_KEY, strong DB_PASSWORD, APP_URL php -r "copy('.env', '.env.bak'); file_put_contents('.env', preg_replace('/^APP_KEY=.*/m', 'APP_KEY=base64:'.base64_encode(random_bytes(32)), file_get_contents('.env')));" # First boot only — create roles + super admin (then set RUN_SEED=false) # RUN_SEED=true # INITIAL_SUPER_ADMIN_EMAIL=you@example.com # INITIAL_SUPER_ADMIN_PASSWORD='…strong…' docker compose -f compose.quick.yaml up -d ``` Open **`http://localhost:8080`** (host `APP_PORT`, default **8080→80**). Reverb publishes on host **8081**. | Piece | Default | | ----------- | --------------------------------------------------------------------- | | Image | `ghcr.io/qiick-io/externa-core:latest` (`EXTERNA_IMAGE` override) | | Services | `app`, `horizon`, `reverb`, `scheduler`, `pulse`, `pgsql`, `redis` | | Volumes | `externa_quick_storage`, `externa_quick_pgsql`, `externa_quick_redis` | | Healthcheck | `GET /health/ready` on `app` (`start_period: 90s`) | Pin a release: ```bash EXTERNA_IMAGE=ghcr.io/qiick-io/externa-core:1.2.2 \ docker compose -f compose.quick.yaml up -d ``` **Upgrades:** bump `EXTERNA_IMAGE` (or rebuild from a newer git tag), keep named volumes, then migrate / `externa:upgrade` inside the app container when `RUN_MIGRATIONS` is off — [Upgrade — Path C (Docker)](/docs/upgrade#path-c--docker-ghcr--compose). Local smoke without GHCR pull (bake once, then quick Compose): ```bash docker build --target production -t externa:prod . EXTERNA_IMAGE=externa:prod docker compose -f compose.quick.yaml up -d ``` ## Minimal `.env` | Variable | Required | Notes | | ----------------------- | ---------------- | ------------------------------------------------------- | | `APP_KEY` | **yes** | `base64:…` from `key:generate` / random 32 bytes | | `APP_URL` | **yes** | Public URL browsers hit (match proxy / port) | | `DB_PASSWORD` | **yes** | Shared with Compose `pgsql` | | `DB_*` | defaults | `pgsql` / `externa` / host `pgsql` inside Compose | | `INITIAL_SUPER_ADMIN_*` | when seeding | Email + **strong** password before `RUN_SEED=true` | | `RUN_MIGRATIONS` | sample `true` | Entrypoint runs `migrate --force` only when `true` | | `RUN_SEED` | sample `false` | Opt-in `db:seed --force` (roles + super admin) | | `REVERB_*` | sample | Change app key/secret for anything beyond laptop demos | | `TRUSTED_PROXIES` | behind TLS proxy | `*` or CIDRs — see [Reverse proxy](/docs/reverse-proxy) | Full catalog: [Environment variables — Docker Compose knobs](/docs/environment-variables#docker-compose-knobs). ## First boot (gated) Entrypoint defaults **`RUN_MIGRATIONS=false`** and **`RUN_SEED=false`** — restarts do not surprise-mutate the database unless Compose/env opts in. 1. First bring-up: keep `RUN_MIGRATIONS=true` (quick/prod samples). 2. Optional admin bootstrap: set strong `INITIAL_SUPER_ADMIN_*`, then `RUN_SEED=true` for **one** start. 3. After login works: set **`RUN_SEED=false`** (and optionally `RUN_MIGRATIONS=false` once schema is stable). 4. Seeders are idempotent on permissions/roles/super-admin email — still treat seed-on-boot as a bootstrap flag, not a forever default. No automatic destructive wipe. `migrate --force` only applies pending migrations. ## Healthchecks Compose `app` probes **`GET /health/ready`** (DB + required Redis). Also available: | Endpoint | Role | | ------------------- | ---------------------------------------- | | `GET /up` | Laravel liveness | | `GET /health/live` | Process up | | `GET /health/ready` | Ready for traffic (503 when checks fail) | Details: [Deployment — Health probes](/docs/deployment#health-probes). ## Publish / multi-arch path ```bash # Bake amd64+arm64 (no push) docker buildx bake -f docker-bake.hcl production # or ./scripts/docker-buildx.sh production # Push to GHCR (needs docker login ghcr.io) VERSION=1.0.0 PUSH=1 ./scripts/docker-buildx.sh production ``` CI: workflow **`docker`** — `compose-config` (includes `compose.quick.yaml`), single-arch `image-build`, `multiarch-bake` on PR/push, **`ghcr-publish`** on `v*` tags (+ `workflow_dispatch`). Build notes: phpredis **6.3.0** pinned from GitHub tarball (not flaky pecl); npm `prepare` skips Lefthook without `.git` (#94). Details: [Deployment — multi-arch](/docs/deployment#multi-arch-images-amd64--arm64). ## Build-from-source production When you need to bake assets yourself (custom Vite Reverb public URL, patches): ```bash cp .env.docker.prod.example .env # APP_KEY, secrets, APP_URL, TRUSTED_PROXIES, browser-facing REVERB_* docker compose -f compose.prod.yaml up --build -d ``` Same healthchecks and gated `RUN_MIGRATIONS` / `RUN_SEED` as quick deploy. ## Follow-through checklist | Path | When | Link | | ------------------- | ------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------- | | Managed DB / Redis | Postgres/Redis outside Compose (RDS, Upstash, …) | `compose.prod.yaml` + `compose.prod.managed.yaml` — [Deployment — Managed](/docs/deployment#managed-db--redis-compose-overlay) · core #15 | | Reverse proxy / TLS | Public HTTPS + WebSocket Upgrade to Reverb | [Reverse proxy cookbook](/docs/reverse-proxy) · core #17 | | Local full stack | Dev with Vite/Mailpit/MinIO | [Installation — Docker](/docs/installation#docker-compose-local-full-stack) | | Bare metal | No Compose | [Deployment](/docs/deployment) | ## Smoke after `up` 1. `curl -fsS http://localhost:8080/health/ready` → `"status":"ok"`. 2. Login with `INITIAL_SUPER_ADMIN_*` (if seeded) or your provisioned admin. 3. Confirm Horizon/scheduler containers are up; trigger a small queued job if useful. ## Related - Core README Docker sections · samples: `.env.docker.quick.example`, `.env.docker.prod.example`, `.env.docker.prod.managed.example` - [Upgrade](/docs/upgrade) · [Backup & restore](/docs/backup-restore) · [Operations](/docs/operations) --- # Reverse proxy cookbook Source: https://docs.externa.qiick.io/docs/reverse-proxy Operator cookbook for TLS termination and WebSocket upgrades in front of Externa (Docker Compose or bare metal). > **Related** > [Deploy with Docker](/docs/deploy-with-docker) · [Deployment](/docs/deployment) · [Reverb & Echo](/docs/reverb-and-echo) · [Passkeys](/docs/passkeys) · [Environment variables](/docs/environment-variables) ## App env (always) ```env APP_URL=https://externa.example.com TRUSTED_PROXIES=* # or comma-separated proxy CIDRs SESSION_SECURE_COOKIE=true ``` Browser-facing Reverb (baked into the Vite bundle — rebuild image after changes): ```env REVERB_HOST=externa.example.com REVERB_PORT=443 REVERB_SCHEME=https VITE_REVERB_HOST="${REVERB_HOST}" VITE_REVERB_PORT="${REVERB_PORT}" VITE_REVERB_SCHEME="${REVERB_SCHEME}" ``` **Passkeys** need a secure context: HTTPS `APP_URL` matching Fortify WebAuthn relying party — [Passkeys](/docs/passkeys#requirements). Compose defaults: app published **`APP_PORT`→80** (prod sample often `8080`), Reverb **`REVERB_HOST_PORT`→8080** (often host `8081`). Proxy to those upstreams (or to service DNS `app:80` / `reverb:8080` on the Compose network). ## Caddy ```caddyfile externa.example.com { encode gzip @websockets { header Connection *Upgrade* header Upgrade websocket } # Reverb WebSockets (path depends on your Echo setup — default Laravel Reverb) handle /app/* { reverse_proxy reverb:8080 } handle { reverse_proxy app:80 } } ``` If Echo connects on a dedicated host/port, point a second site block at `reverb:8080` with TLS. ## Traefik (Docker labels sketch) ```yaml services: app: labels: - traefik.enable=true - traefik.http.routers.externa.rule=Host(`externa.example.com`) - traefik.http.routers.externa.entrypoints=websecure - traefik.http.routers.externa.tls.certresolver=le - traefik.http.services.externa.loadbalancer.server.port=80 reverb: labels: - traefik.enable=true - traefik.http.routers.externa-ws.rule=Host(`externa.example.com`) && PathPrefix(`/app`) - traefik.http.routers.externa-ws.entrypoints=websecure - traefik.http.routers.externa-ws.tls.certresolver=le - traefik.http.services.externa-ws.loadbalancer.server.port=8080 ``` Ensure Traefik forwards `X-Forwarded-*` (default) so Laravel trusted proxies work with `TRUSTED_PROXIES`. ## nginx ```nginx map $http_upgrade $connection_upgrade { default upgrade; '' close; } server { listen 443 ssl http2; server_name externa.example.com; # ssl_certificate …; # ssl_certificate_key …; location /app/ { proxy_pass http://127.0.0.1:8081; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection $connection_upgrade; proxy_set_header Host $host; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; proxy_read_timeout 60s; } location / { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; proxy_read_timeout 600s; # long AI / SSE if needed } } ``` Adjust upstream ports to your `APP_PORT` / `REVERB_HOST_PORT`. ## Checklist - [ ] `APP_URL` is HTTPS and matches the browser host - [ ] `TRUSTED_PROXIES` set; login/CSRF works behind the proxy - [ ] WebSocket upgrade reaches Reverb; Echo connects (avatar / notifications) - [ ] Production image rebuilt after `VITE_REVERB_*` changes - [ ] Passkeys tested on the public HTTPS origin --- # Upgrade Source: https://docs.externa.qiick.io/docs/upgrade Stay on a newer Externa release after first install. **Code** comes from Git (or a Docker image tag). **`php artisan externa:upgrade`** runs migrate / permissions sync / cache clear by default — it does **not** pull code unless you pass **opt-in** `--sync-upstream`. > **Related** > [Packagist & create-project](/docs/packagist) · [Installation](/docs/installation) · [Quick Start](/docs/quick-start) · [Deploy with Docker](/docs/deploy-with-docker) · [Backup & restore](/docs/backup-restore) · [Deployment](/docs/deployment) · [Operations](/docs/operations) · [core README](https://github.com/qiick-io/externa-core#quick-start) ## Mental model (read this first) | Fact | Meaning | | ----------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `composer create-project` | **Install once.** Copies the app tree into a new directory. It is **not** how you receive later Externa releases. | | Your git remote | After you `git init` / push that tree to **your** repo, that remote tracks **your** commits — it does **not** auto-update from `qiick-io/externa-core`. | | Packagist `type: project` | Same scaffold story: Composer does **not** replace your app on later `composer update`. | | Recommended update channel | **Git:** clone (or add `upstream`) and checkout / merge a release tag `vX.Y.Z`. | | `php artisan externa:upgrade` | Default: after code is already on the new release — `migrate --force`, `permissions:sync`, `optimize:clear`. **No** composer/npm. Opt-in: `--sync-upstream=vX.Y.Z` can merge the official tag first. | | Docker | Pull / pin a newer **GHCR** image tag; keep named volumes; then migrate inside the container (or rely on `RUN_MIGRATIONS`). | > **create-project ≠ update channel** > Operators who started with Packagist and then pushed to their own GitHub/GitLab still need a deliberate code update (git tag / `upstream` merge / `--sync-upstream`, or Docker image bump). Point them here from [Packagist](/docs/packagist), [Installation](/docs/installation), [Quick Start](/docs/quick-start), and the [core README](https://github.com/qiick-io/externa-core#quick-start). > Upgrade the host PHP runtime to **8.4.27+** or **8.5.12+** (CVEs in PHP itself, not Externa). From externa-core **1.2.2**, Composer requires `php: ^8.4.27` ([#248](https://github.com/qiick-io/externa-core/pull/248)). Official `ghcr.io/qiick-io/externa-core:1.2.2` and `latest` ship PHP 8.4.27 — re-pull and recreate containers; source builds use `--pull --no-cache`. Full notice: [externa-core discussion #247](https://github.com/qiick-io/externa-core/discussions/247). ## Version-specific notes ### 1.2.x → 1.3.0 (Unreleased) - **Breaking — outbound webhooks / Slack SSRF** ([core#118](https://github.com/qiick-io/externa-core/issues/118) / [PR #267](https://github.com/qiick-io/externa-core/pull/267)): loopback, RFC1918, link-local, and other reserved hosts are **rejected on save and at delivery** unless you opt in. Delivery also refuses HTTP redirects. For LAN receivers (e.g. n8n on a private IP), set `WEBHOOKS_ALLOW_PRIVATE_HOSTS=true` and refresh config cache. Public HTTPS endpoints need no change — [Outbound webhooks](/docs/webhooks#ssrf-guard). - **Breaking — upload MIME allowlist** (same PR): after the extension denylist, uploads must sniff to a type in `config/files.php` `upload_allowed_mimes`, with magic-byte checks for claimed formats. **SVG, JSON, and XML are not allowed.** Extend the allowlist only if you accept the risk — [File manager — Upload security](/docs/file-manager#upload-security). - Also in 1.3.0 (non-breaking defaults): optional API cost / GraphQL / payload caps ([core#113](https://github.com/qiick-io/externa-core/issues/113)), API key scopes ([core#116](https://github.com/qiick-io/externa-core/issues/116)), `ai:rotate-webhook-token` ([core#207](https://github.com/qiick-io/externa-core/issues/207)). See core `CHANGELOG.md` `[Unreleased]`. ### 1.2.1 → 1.2.2 - Security patch ([#250](https://github.com/qiick-io/externa-core/pull/250)). Composer `php` constraint is now **`^8.4.27`** ([#248](https://github.com/qiick-io/externa-core/pull/248)). No new migrations. See the PHP security notice above and [discussion #247](https://github.com/qiick-io/externa-core/discussions/247) — do not treat older 8.4.x as enough. - AI collection-import webhook: sign the **raw request body only** (`sha256=` + HMAC-SHA256). Invalid or missing signatures return **401** (was 403). Set `AI_WEBHOOK_TOKEN` in every non-local environment ([#214](https://github.com/qiick-io/externa-core/pull/214)) — [AI API](/docs/ai-api#webhook-hmac-auth). - `externa:install` keeps an existing `APP_KEY` or generates one, runs the minimal profile without Redis, and can be re-run with `--force` ([#249](https://github.com/qiick-io/externa-core/pull/249)) — [Installation](/docs/installation#interactive-installer-externa-install). - Docker: pin `ghcr.io/qiick-io/externa-core:1.2.2`. ### 1.2.0 → 1.2.1 - Hotfix for field renames dropping values from pending drafts and older revisions ([#218](https://github.com/qiick-io/externa-core/issues/218), [#223](https://github.com/qiick-io/externa-core/pull/223)). No new migrations. - **Right after deploying, before the scheduler runs or anyone publishes**, repair drafts/revisions that still hold a pre-rename key: `php artisan externa:repair-renamed-field-drafts` (dry run), then `--apply`. See [Schema changes](/docs/schema-changes#repairing-older-drafts). - Run `npm ci && npm run build` (the `vite` range is now `^8.3.5`). - Docker: pin `ghcr.io/qiick-io/externa-core:1.2.1` (or jump straight to **1.2.2** if you are catching up). ### 1.0.x → 1.1.0 - **AI chat history schema** (`laravel/ai` ^1.0): conversations use a polymorphic participant, and messages store `steps` + `status` instead of `tool_calls` / `tool_results`. You **must** run `php artisan migrate` (included in `externa:upgrade`) before opening existing AI conversations. Back up first: [Backup & restore](/docs/backup-restore). - Destructive AI tool actions (delete, force-delete, bulk delete, delete field, AI turn rollback) now wait for **Approve / Reject** in the chat. - Scheduled publish: make sure `collections:process-schedules` runs on the scheduler (`php artisan schedule:work` or cron). ### 1.1.x → 1.2.0 - Run `composer install` (adds `laravel/socialite` `^5.31`), then `npm ci && npm run build`, **before** `externa:upgrade`. - Migrations add editorial approval columns, `collections_items.board_sort` / `published_at`, and `users.oidc_sub`, and make **`users.password` nullable** (SSO-only users). Project settings also gain notifier mute list `notifier_event_types` (null = all events). - Run `permissions:sync` (included in `externa:upgrade`) to create `can-submit-collections` / `can-approve-collections`. - Restart Horizon and Reverb after deploy (presence, Meilisearch sync, and git-export jobs). - Docker: pin `ghcr.io/qiick-io/externa-core:1.2.0`. - All new integrations (Meilisearch, git export, Slack/Telegram, OIDC) are off until configured — [Environment variables](/docs/environment-variables#optional-integrations-1-2-0). ## Before you start 1. **Backup** DB + `.env` + `storage/` — [Backup & restore](/docs/backup-restore). 2. Put the site in maintenance mode if operators share the host: ```bash php artisan down ``` 3. Note current version (`composer.json` → `version` or Git tag). ## Path A — Git tag / upstream (recommended) ### A1. You cloned `qiick-io/externa-core` (or keep `origin` = Externa) ```bash cd /path/to/externa-core git fetch --tags origin git checkout v1.0.0 # example — use the target release tag composer install --no-dev --optimize-autoloader ``` Then continue at [Shared steps](#shared-steps-after-code-is-on-the-new-release). ### A2. You create-project’d, then pushed to **your** remote `origin` is yours. Add Externa as `upstream` and merge the release tag: ```bash cd /path/to/my-externa git remote add upstream https://github.com/qiick-io/externa-core.git # once git fetch --tags upstream git merge v1.0.0 # example — resolve conflicts if you customized app code composer install --no-dev --optimize-autoloader ``` Vanilla trees (few or no app edits) usually merge cleanly. Heavy forks: merge/rebase carefully, keep `.env` / `storage` / DB. Or use the assisted CLI path below instead of the manual `git` steps. Contributors on `develop`: merge/rebase onto the target tag or release branch, then the same composer / migrate / build steps. ## Assisted sync (`--sync-upstream`) **Opt-in only.** Plain `externa:upgrade` still does **not** pull from GitHub. Use this when you want the Artisan command to fetch the official remote and merge a release tag, then run the usual migrate / permissions / cache steps. ### What it does 1. Requires a **git** working tree (fails clearly if not). 2. Ensures remote `upstream` → `https://github.com/qiick-io/externa-core.git` (adds it if missing). 3. `git fetch --tags upstream`, verifies `refs/tags/vX.Y.Z`. 4. Lists files that would change (`git diff --name-status HEAD vX.Y.Z`). 5. Asks for confirm (“N files will be overwritten / changed — proceed with caution?”) unless `--yes`. 6. `git merge --no-edit vX.Y.Z` — on conflict, **stops** (no migrate); resolve by hand. 7. On success (or if you skipped sync): migrate + `permissions:sync` + `optimize:clear`. ### Risks / when to use - **May overwrite customized app files** and can fail on heavy forks. Prefer Path A by hand if you have large local diffs. - Refuses a dirty tree unless `--force` (not recommended with sync). - Does **not** run `composer` / `npm` — install deps after code lands. - Prefer **dry-run first**. ```bash # Preview only — no merge, no migrate php artisan externa:upgrade --sync-upstream=v1.0.0 --dry-run # Apply sync (confirm prompt) then migrate / permissions / cache php artisan externa:upgrade --sync-upstream=v1.0.0 # Non-interactive apply (CI / scripts) — still fails on conflicts php artisan externa:upgrade --sync-upstream=v1.0.0 --yes # Production hosts php artisan externa:upgrade --sync-upstream=v1.0.0 --force --yes ``` > **Warning** > Assisted sync is a convenience for mostly-vanilla trees. Customized forks should merge carefully, keep `.env`, and never blind-commit conflict resolutions. ## AI-assisted upgrade When merge conflicts or a large diff make a human review painful, paste context into your coding agent. **Keep `.env` and secrets out of commits.** Do **not** ask the agent to force-push or blind-commit without review. ### Template prompt (per version) Copy and fill `vOLD` / `vNEW` (example: `v1.0.0` → `v1.1.0`): ```text I am upgrading Externa (Laravel app from qiick-io/externa-core) from vOLD to vNEW. Context to use (paste one or more): 1) GitHub compare: https://github.com/qiick-io/externa-core/compare/vOLD...vNEW 2) And/or CHANGELOG + release notes for vNEW 3) My conflicted / customized files (paste `git status` and the conflict hunks) Goals: - Resolve conflicts preserving my intentional customizations - Keep .env, storage/, and local secrets untouched — never commit them - Prefer upstream for framework/app defaults I did not customize - After a clean tree: composer install, npm ci && npm run build, then `php artisan externa:upgrade` (or migrate + permissions:sync + optimize:clear) Constraints: - Do not blind-commit; show the plan and diffs first - Do not rewrite git history or force-push - Call out breaking changes from the compare/CHANGELOG ``` After the agent proposes a resolution: review, run tests / smoke checklist, then commit yourself. ## Path B — Replace tree (vanilla create-project, no useful git history) When you never put the site under git, or history is disposable: 1. Backup `.env`, `storage/`, and the database. 2. `composer create-project qiick/externa-core:X.Y.Z /tmp/externa-new` (pin the target version). 3. Copy `.env` (and any local customizations) into the new tree; keep the same DB and storage volume/mount. 4. Run [Shared steps](#shared-steps-after-code-is-on-the-new-release) in the new tree, then cut over the web root. Do **not** expect `composer update qiick/externa-core` to refresh application source — the package was copied once at create-project time. ## Path C — Docker (GHCR / Compose) Keep named volumes (`externa_*_storage`, DB volumes). Bump the image tag, then bring the stack back up: ```bash # Quick deploy sample — pin the release you want (example: 1.2.2) EXTERNA_IMAGE=ghcr.io/qiick-io/externa-core:1.2.2 \ docker compose -f compose.quick.yaml pull EXTERNA_IMAGE=ghcr.io/qiick-io/externa-core:1.2.2 \ docker compose -f compose.quick.yaml up -d ``` Build-from-source Compose: checkout / merge the matching tag in the repo that owns `compose.yaml`, then `docker compose … up --build -d`. If `RUN_MIGRATIONS` is off, run migrate (+ permissions) in the app container: ```bash docker compose exec app php artisan migrate --force docker compose exec app php artisan permissions:sync # or: docker compose exec app php artisan externa:upgrade --force ``` Details: [Deploy with Docker](/docs/deploy-with-docker) · volumes note below. ## Shared steps (after code is on the new release) ```bash php artisan migrate --force php artisan permissions:sync # Only if PermissionEnum removed names and you intend cleanup: # php artisan permissions:sync --prune npm ci npm run build php artisan optimize:clear # production hosts that cache config/routes: # php artisan config:cache && php artisan route:cache && php artisan view:cache php artisan up ``` Restart queue workers / Horizon / Reverb / `pulse:work` / scheduler after deploy so they load new code. ### `php artisan externa:upgrade` Runs the **safe automated subset** by default: `migrate --force`, `permissions:sync`, `optimize:clear`. ```bash php artisan externa:upgrade # production or dirty git tree: php artisan externa:upgrade --force ``` **Does not** (by default) fetch git tags, pull from GitHub, run `composer`, or run `npm`. Get the new code first (Path A/B/C), **or** use opt-in `--sync-upstream` above. Refuses `APP_ENV=production` and a dirty git working tree unless `--force`. | Flag | Meaning | | ----------------------------------------------------------- | ------------------------------------------------------------------------------------------ | | `--sync-upstream=vX.Y.Z` | Opt-in: fetch `upstream` + merge tag (see [Assisted sync](#assisted-sync---sync-upstream)) | | `--dry-run` | With `--sync-upstream`: list files only; no merge / migrate | | `--yes` | Skip confirm when applying `--sync-upstream` | | `--force` | Allow production and/or dirty tree | | `--no-migrate` / `--no-permissions` / `--prune-permissions` | Tune the post-sync subset | ## Docker volumes With Compose (`compose.yaml` / `compose.prod.yaml` / `compose.quick.yaml`): - Keep named volumes (`externa_storage` / `externa_prod_storage` / `externa_quick_*`, DB volumes). - Rebuild or pull the app image after the tag change. - Prefer `externa:upgrade --force` (or migrate + `permissions:sync`) inside the app container when entrypoint migrate is off. ## Smoke checklist - [ ] `GET /health/ready` → 200 - [ ] Login as an operator - [ ] Open one collection + one item - [ ] Queue still processes (zip or a trivial job) if you rely on async features ## Non-goals - Silent auto-pull as the **default** of `externa:upgrade` (opt-in `--sync-upstream` only) - Remote zip overwrite without git - Composer create-project auto-update magic - Cross-engine DB moves (SQLite → Postgres) — dump/restore with engine tools separately --- # Backup & restore Source: https://docs.externa.qiick.io/docs/backup-restore Disaster-recovery runbook for self-hosted Externa: what to copy, restore order, and downtime expectations. Pair with [Upgrade](/docs/upgrade) before every release bump. > **Related** > [Upgrade](/docs/upgrade) · [Deployment](/docs/deployment) · [Files configuration](/docs/files-configuration) · [Supported databases](/docs/supported-databases) ## What to back up | Asset | Required? | Notes | | ---------------------- | ------------------ | ---------------------------------------------------------------------------------------- | | **Database** | Yes | All CMS data, users, jobs tables | | **`.env`** | Yes | Secrets + config — store separately from public backups | | **`storage/`** | Yes (local disks) | Uploads, zips, logs, framework files under `storage/app` | | **S3 / MinIO objects** | If `FILES_DISK=s3` | Objects live in the bucket — use bucket versioning / lifecycle; still backup DB + `.env` | | **Code tree** | Optional | Prefer Git tag / Packagist release over tarring `vendor/` | Downtime: DB dump is consistent if you freeze writes (`php artisan down` or brief maintenance). File copy can run warm; restore expects matching DB + files. ## Backup — databases ### PostgreSQL ```bash pg_dump -Fc -h "$DB_HOST" -U "$DB_USERNAME" -d "$DB_DATABASE" -f externa-$(date +%F).dump ``` ### MySQL / MariaDB ```bash mysqldump -h "$DB_HOST" -u "$DB_USERNAME" -p"$DB_PASSWORD" \ --single-transaction --routines --triggers "$DB_DATABASE" \ > externa-$(date +%F).sql ``` ### SQLite ```bash php artisan down cp database/database.sqlite "backups/database-$(date +%F).sqlite" # also copy WAL/SHM if present while down php artisan up ``` ## Backup — files and env ```bash # from project root tar -czf "backups/storage-$(date +%F).tar.gz" storage/app storage/logs cp .env "backups/env-$(date +%F).env" # keep offline / encrypted ``` Never commit backup tarballs or `.env` copies to git. ### Optional script Repo helper (core): `scripts/backup.sh` — dumps DB (when CLI tools exist) + tars `storage/app` into `./backups/` (gitignored). Review before cron. ```bash ./scripts/backup.sh ``` ## Docker Compose volumes Local: `externa_pgsql`, `externa_storage`, … — see `compose.yaml` `volumes:`. Prod: `externa_prod_pgsql`, `externa_prod_storage`, `externa_prod_redis`. ```bash # example: snapshot Postgres volume via running service docker compose -f compose.prod.yaml exec -T pgsql \ pg_dump -U externa -d externa -Fc > externa-prod-$(date +%F).dump # storage volume → tar from app container docker compose -f compose.prod.yaml exec -T app \ tar -czf - -C /var/www/html storage/app > storage-$(date +%F).tar.gz ``` ## Restore order 1. Provision empty host / containers; restore `.env` (fix secrets if the incident was a breach). 2. Restore **database** first. 3. Restore **`storage/app`** (and link `php artisan storage:link`). 4. `composer install` / image rebuild for the **same** app version as the dump. 5. `php artisan migrate --force` only if upgrading past the dump’s schema — otherwise skip. 6. `php artisan optimize:clear`; restart workers. 7. Smoke: `/health/ready`, login, one collection, one file download. ### Postgres restore ```bash pg_restore -h "$DB_HOST" -U "$DB_USERNAME" -d "$DB_DATABASE" --clean --if-exists externa-YYYY-MM-DD.dump ``` ### MySQL restore ```bash mysql -h "$DB_HOST" -u "$DB_USERNAME" -p"$DB_PASSWORD" "$DB_DATABASE" < externa-YYYY-MM-DD.sql ``` ### SQLite restore ```bash php artisan down cp backups/database-YYYY-MM-DD.sqlite database/database.sqlite php artisan up ``` ## S3 caveat With `FILES_DISK=s3`, DB rows point at object keys. Backup the **bucket** (or rely on provider versioning) separately from the DB dump. Restoring an old DB against a newer bucket (or vice versa) yields missing objects. ## Dry-run note Operators should practice restore once on a non-prod copy before relying on backups for GA. Record date/host in your runbook when you do. --- # Production without Redis Source: https://docs.externa.qiick.io/docs/production-without-redis Supported **degraded** production path when the host cannot run Redis: `QUEUE_CONNECTION=database`, broadcast `log`, no Horizon / Reverb. Prefer the full Redis stack when possible. > Without Redis you lose Horizon, Echo push notifications, presence, and Pulse redis ingest. Database queues work for light operator teams; they are **not** recommended for busy multi-user realtime or large async fan-out. > **Related** > [Minimal vs full stack](/docs/minimal-vs-full-stack) · [Redis prerequisite](/docs/redis-prerequisite) · [Deployment](/docs/deployment) · [Operations](/docs/operations) ## Capability matrix | Capability | Full (Redis) | Without Redis | | -------------------------------- | --------------------------- | ----------------------------------------------- | | Queues (zips, imports, webhooks) | Horizon / `redis` | `QUEUE_CONNECTION=database` + `queue:work` | | Admin notifications | Echo / Reverb push | **60s unread poll only** | | Presence / avatar connection dot | Online via presence channel | Offline / “Realtime off” | | App cache | `CACHE_STORE=redis` | `CACHE_STORE=database` (or `file`) | | Session | Often `redis` | `SESSION_DRIVER=database` | | Pulse Health cards | Redis ingest + `pulse:work` | Off or storage ingest — limited | | Horizon UI | `/horizon` | Unavailable — use Jobs monitor / failed_jobs | | AI imports / sync schedules | Same jobs, Redis worker | Same jobs, database worker (watch table growth) | ## `.env` (production-lean) ```env QUEUE_CONNECTION=database CACHE_STORE=database SESSION_DRIVER=database BROADCAST_CONNECTION=log PULSE_ENABLED=false # or PULSE_INGEST_DRIVER=storage ``` Ensure `jobs` / `failed_jobs` / `cache` / `sessions` migrations have run (`php artisan migrate --force`). ## Supervisor examples ### Queue worker ```ini [program:externa-queue] process_name=%(program_name)s command=php /var/www/externa/artisan queue:work database --sleep=1 --tries=3 --timeout=300 --max-time=3600 directory=/var/www/externa autostart=true autorestart=true user=www-data numprocs=1 redirect_stderr=true stdout_logfile=/var/log/externa-queue.log stopwaitsecs=3600 ``` ### Scheduler ```ini [program:externa-scheduler] command=php /var/www/externa/artisan schedule:work directory=/var/www/externa autostart=true autorestart=true user=www-data redirect_stderr=true stdout_logfile=/var/log/externa-scheduler.log ``` Or classic cron: ```cron * * * * * cd /var/www/externa && php artisan schedule:run >> /dev/null 2>&1 ``` Do **not** run `horizon` or `reverb:start` on this profile. Restart `queue:work` on every deploy. ## When to switch back to Redis - More than a handful of concurrent operators relying on live notifications - Large zip / import backlogs - You want Pulse/Horizon Health cards in production Then follow [Redis prerequisite](/docs/redis-prerequisite) and [Horizon](/docs/horizon) / [Reverb](/docs/reverb-and-echo). --- # Releasing & versions Source: https://docs.externa.qiick.io/docs/releasing How maintainers ship Externa Core versions: **semver**, **CHANGELOG**, bump checklist, and **GitHub Releases**. > **Related** > [Changelog (docs)](/docs/changelog) · [`CHANGELOG.md`](https://github.com/qiick-io/externa-core/blob/develop/CHANGELOG.md) · [`.github/RELEASE_TEMPLATE.md`](https://github.com/qiick-io/externa-core/blob/develop/.github/RELEASE_TEMPLATE.md) · [API 1.x compatibility](/docs/api-compatibility) · [Contributing](/docs/contributing) · [Packagist & create-project](/docs/packagist) ## Version source of truth | File | Role | | --------------------------- | ---------------------------------------- | | `composer.json` → `version` | Canonical package version | | `package.json` → `version` | Must match Composer | | README badge / version line | Update when bumping | | Docs site | No separate product version unless noted | Git tags: `v` + semver (examples: `v1.0.0`, `v1.0.0-beta.4`). ## Semver (Externa) Aligned with [API & GraphQL 1.x compatibility](/docs/api-compatibility): | Bump | When | | ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------- | | **Major** | Breaking Public API / GraphQL / documented operator contracts after deprecation (or security-driven breaks called out in notes) | | **Minor** | Additive features; deprecations may be announced | | **Patch** | Bug fixes, docs, non-contract hardening | | **Pre-release** (`-beta.N`, `-rc.N`) | Milestone cuts before GA; same CHANGELOG discipline | Admin UI / Inertia props are not a public API, but call out operator-visible breaks in Upgrade notes. ## CHANGELOG Keep [Keep a Changelog](https://keepachangelog.com/) sections under `## [Unreleased]` while working on `develop`. At release: 1. Move Unreleased items under `## [X.Y.Z] - YYYY-MM-DD` 2. Refresh compare links at the bottom of `CHANGELOG.md` 3. Leave a fresh empty Unreleased section ## Cut a release (checklist) > **Who ships** > **Maintainers** merge the release PR, create the git tag, and publish the GitHub Release. Agents may open the `develop` → `main` release PR (full body + assignee) and stop there — they must **not** merge to `main`, tag, or run `gh release create` unless explicitly asked. 1. Milestone issues **Done**; CI green on `develop` 2. Bump `composer.json` + `package.json` (+ README version line) 3. Finalize `CHANGELOG.md` for this version 4. Open PR `develop` → `main` (release PR); assignee + milestone labels — **do not auto-merge** 5. Maintainer merges; then tags `vX.Y.Z` on `main` 6. Maintainer publishes GitHub Release from tag — paste from `.github/RELEASE_TEMPLATE.md` (Added / Fixed / Breaking / Upgrade) 7. Board: set Status to the matching **Released …** option when applicable 8. Packagist auto-update should pick up the new tag — verify on [packagist.org](https://packagist.org/packages/qiick/externa-core) ([submit runbook](/docs/packagist#maintainer-submit-to-packagist-manual)) · docs deploy as usual **Shipped:** [externa-core `v1.0.0-beta.4`](https://github.com/qiick-io/externa-core/releases/tag/v1.0.0-beta.4) (2026-09-17) on Packagist. **GA:** [externa-core `v1.0.0`](https://github.com/qiick-io/externa-core/releases/tag/v1.0.0) — human merges the release PR, tags, and confirms Packagist. Docs site for this cut: **`1.1.1`**. See [Packagist status](/docs/packagist#packagist-status). ## GitHub Release notes Use the repo template sections: - Highlights - Added / Changed / Fixed / Deprecated / Removed · Breaking / Security - Upgrade notes - Links (CHANGELOG, docs, full diff) --- # Changelog Source: https://docs.externa.qiick.io/docs/changelog Product history for **Externa Core** (what operators install). Newest first. Sourced from [GitHub Releases](https://github.com/qiick-io/externa-core/releases), [`CHANGELOG.md`](https://github.com/qiick-io/externa-core/blob/main/CHANGELOG.md), and closed milestone issues. > **Related** > [Releasing & versions](/docs/releasing) · [Upgrade](/docs/upgrade) · [API 1.x compatibility](/docs/api-compatibility) · [GA readiness](/docs/ga-readiness) · Core project board: [qiick-io/projects/1](https://github.com/orgs/qiick-io/projects/1) > Prefer fresh installs or follow [Upgrade](/docs/upgrade). After `1.0.0`, Public API / GraphQL follow [API 1.x compatibility](/docs/api-compatibility). ## Docs site ### Security notice — PHP emergency releases (21 Oct 2026) - **PHP CVEs (not in Externa)** — minimum safe patch versions are **8.4.27** / **8.5.12**. From externa-core **1.2.2**, Composer requires `php: ^8.4.27`. Official GHCR `1.2.2` / `latest` ship PHP 8.4.27 — re-pull containers. Full announcement: [externa-core discussion #247](https://github.com/qiick-io/externa-core/discussions/247). Operator pages: [Installation](/docs/installation), [Deploy with Docker](/docs/deploy-with-docker), [Upgrade](/docs/upgrade), [Security checklist](/docs/security-checklist). ### 1.3.9 — Core 1.3.0 SSRF / MIME upgrade notes (2026-10-25) - **Upgrade / security** — documented externa-core **1.3.0** webhook SSRF hardening (`WEBHOOKS_ALLOW_PRIVATE_HOSTS`), MIME allowlist updates, and related breaking notes across Upgrade, Webhooks, Environment variables, File manager, Files configuration, Threat model, and Security checklist (docs #169, core #118 / PR #267) ### 1.3.8 — Why the name Externa? (2026-10-23) - **Introduction** — home page callout on the Latin etymology of _Externa_, the client origin of the name, and the outward-facing bow-tie mark (docs #165, #166) ### 1.3.7 — Externa mark v3 (2026-10-22) - **Brand** — the docs now use the Externa mark v3 (teal bow-tie) in the header logo, favicon, app and Apple touch icons, the Open Graph image and the README headers (docs #161) ### 1.3.6 — Locale fallback chain (2026-10-22) - **Locales** — new [Configuration — Locale fallback](/docs/configuration#locale-fallback) section: active locale resolution vs the value fallback chain (requested → enabled `fallback_content_locales` → `APP_FALLBACK_LOCALE` if enabled → `null`), empty strings treated as missing, and how editing Content languages rewrites the stored chain; cross-links from Collections data model, Public CMS API, Project settings, Field types and Live Preview. Live Preview active-locale step 5 corrected to `APP_LOCALE` (docs #157, closes #133) ### 1.3.5 — Installer anchor fix (2026-10-22) - **Links** — the `externa:install` links on the Upgrade, Changelog and Packagist pages now point to the correct Installation section (`#interactive-installer-externa-install`) (docs #153) ### 1.3.4 — externa-core 1.2.1 and 1.2.2 (2026-10-22) - **Upgrade** — new `1.2.0 → 1.2.1` and `1.2.1 → 1.2.2` sections; Docker examples pin `ghcr.io/qiick-io/externa-core:1.2.2` (docs #149) - **Schema changes** — field renames are safe from core 1.2.1; new "Repairing older drafts" section for `php artisan externa:repair-renamed-field-drafts` (dry run, `--apply`, `--map=collection:old=new`) (docs #149) - **PHP security** — externa-core 1.2.2 requires `php: ^8.4.27`; official GHCR `1.2.2` / `latest` ship PHP 8.4.27 (docs #149) - **AI webhook** — collection-import HMAC is over the raw body only and returns **401** on failure or when `AI_WEBHOOK_TOKEN` is unset outside `local` (core 1.2.2) across AI, AuthZ, routing, threat model and security checklist pages (docs #149) - **Installer** — `externa:install` keeps or generates `APP_KEY`, runs the minimal profile without Redis, and can be re-run with `--force` (core 1.2.2) (docs #149) - **Changelog** — Externa Core 1.2.1 and 1.2.2 entries (docs #149) ### 1.3.3 — Accessibility, qiick theme, and core 1.2.1 content (2026-10-22) - **Accessibility** — WCAG 2.2 AA pass with 0 axe violations in light and dark: `
` landmark, skip link, labelled logo and dialogs, contrast fixes, and dead localhost/private-repo links removed (docs #136) - **Theme and icons** — qiick semantic theme tokens (layout unchanged) and Lucide icons instead of inline SVGs (docs #142) - **PHP security** — PHP 8.4.27+ / 8.5.12+ required after the October 2026 PHP advisories, plus a notice on the operator pages that the official GHCR `1.2.1` / `latest` images were rebuilt on PHP 8.4.27 (docs #143, #145) - **Translation workspace** — matches the externa-core `1.2.1` locale UI (docs #144) - **Live preview** — draft vs published response shapes, preview auth with allowed origins, and locale resolution (docs #138) - **Field types** — collection pack block schema and nested block locale behaviour (docs #139) - **Schema changes** — new page on renaming fields and changing types with existing content (docs #140) - **Files** — thumbnail max size corrected from 256 to 512 px (docs #137) - **Repo** — qiick brand header in the README and agent UI rules in `AGENTS.md` / `CLAUDE.md` (docs #130, #135) ### 1.3.2 — Canonical Externa URLs - **Links** — marketing links now point to the canonical website `https://externa.qiick.io/en` and docs `https://docs.externa.qiick.io/` (GA readiness, Headless starter, Website ↔ docs messaging), and the JSON-LD `SoftwareApplication.url` uses the EN home page (docs #117) ### 1.3.1 — Chrome Ask the docs fix + AI coding tools page - **Ask the docs (Chrome built-in)** — separate Prompt API sessions for rewrite and answer, English output for Gemini Nano, `inputQuota` fitting, passage fallback for stub replies, and an English unverified banner outside answer markdown (docs #112) - **Using Externa with AI coding tools** — new Getting started page for Context7 library IDs (`/qiick-io/externa-core`, `/llmstxt/externa_qiick_io_llms_txt`), MCP setup, and `llms.txt` / `llms-full.txt` (docs #113); Context7 snippet counts omitted so they do not drift on reindex - **Core 1.2.0 docs gap** — AI translate, collection layouts, upgrade notes, and related pages aligned with externa-core `v1.2.0` (docs #111) - **CI** — cache the MiniLM Transformers.js model between Actions runs (docs #110) ### 1.3.0 — Ask the docs (in-browser AI assistant) - **Ask the docs** — optional on-device assistant: chat runs in the browser via WebLLM or the built-in Prompt API, with no server-side inference; shown only on capable browsers (docs #105) - **Semantic search** — MiniLM embeddings built at build time (`/search/embeddings.v1.json`) with query expansion and source ranking; answers link to the matching pages (docs #105) - **Safe rendering** — model output rendered through a hardened Markdoc pipeline (docs #105) ### 1.2.0 — SEO, AI Markdown, security, and performance - **Security** — `next` 16.4.2 + `sharp` ≥0.35.5; production `npm audit --omit=dev` clean of high/critical (docs #95) - **SEO metadata & sitemap** — restore `og:image` on all pages, branded homepage title, unique sitemap URLs with per-page `lastmod` (docs #96) - **AI discoverability** — `/llms.txt`, `/llms-full.txt`, and `/docs/.md` Markdown mirrors (docs #97) - **Index hygiene** — noindex maintainer pages, docs→website links, sidebar headings no longer precede page `h1` (docs #98) - **Structured data** — per-page `TechArticle` + `BreadcrumbList` JSON-LD linked to marketing-site entities (docs #99) - **Performance** — CSS hero glows, optimized screenshots, lazy search dialog, contrast fixes (docs #100) ### 1.1.16 — notifier filters + core 1.1.0/1.2.0 sync - **Notifier event filters** — per-project muting of event types (`notifier_event_types`) documented in [Project settings](/docs/project-settings) - **Core 1.1.0 / 1.2.0 docs sync** — changelog, environment variables, headless starter, project settings, upgrade and webhooks pages updated (docs #88) ### 1.1.15 — starters + ops guides - **Nuxt headless starter** — [Headless starter](/docs/headless-starter) + [qiick-io/externa-nuxt-starter](https://github.com/qiick-io/externa-nuxt-starter) ([core #152](https://github.com/qiick-io/externa-core/issues/152)) - **Starter conventions** — multi-repo checklist ([core #156](https://github.com/qiick-io/externa-core/issues/156)) - **Performance tuning** — Public API cache, `include`s, queues, Pulse (docs #49) — [Performance tuning](/docs/performance-tuning) - **Website ↔ docs messaging** — GA feature matrix (docs #50) — [Website ↔ docs messaging](/docs/website-docs-messaging) - **Docs sync epic** — nav/changelog confirmed for core 1.1.0 feature set (docs #48) ### 1.1.14 — core 1.2.0 companion Companion docs for **externa-core 1.2.0**: editorial approvals, translation workspace, kanban/calendar layouts, Slack/Telegram notifiers, collaborative presence, Meilisearch, git export, CMS import connectors, SSO/OIDC. | Topic | Docs | Core | | --------------------- | ---------------------------------------------------- | -------------------------------------------------------------------- | | Editorial approvals | [Approvals](/docs/approvals) | [#48](https://github.com/qiick-io/externa-core/issues/48) / docs #75 | | Translation workspace | [Translation workspace](/docs/translation-workspace) | [#49](https://github.com/qiick-io/externa-core/issues/49) / docs #76 | | Collection layouts | [Collection layouts](/docs/collection-layouts) | [#47](https://github.com/qiick-io/externa-core/issues/47) / docs #77 | | Slack / Telegram | [Integrations](/docs/integrations) | [#39](https://github.com/qiick-io/externa-core/issues/39) / docs #78 | | Collaborative editing | [Collaborative editing](/docs/collaborative-editing) | [#50](https://github.com/qiick-io/externa-core/issues/50) / docs #80 | | Meilisearch | [Meilisearch sync](/docs/meilisearch) | [#40](https://github.com/qiick-io/externa-core/issues/40) / docs #81 | | Git export | [Git export sync](/docs/git-export) | [#41](https://github.com/qiick-io/externa-core/issues/41) / docs #82 | | CMS import | [CMS import connectors](/docs/cms-import) | [#30](https://github.com/qiick-io/externa-core/issues/30) / docs #83 | | SSO / OIDC | [SSO / OIDC](/docs/sso-oidc) | [#29](https://github.com/qiick-io/externa-core/issues/29) / docs #84 | ### 1.1.7 — OpenAPI from Core - **OpenAPI serve** — document `GET /api/v1/openapi.json` + YAML sync policy ([core #25](https://github.com/qiick-io/externa-core/issues/25)) — [Public CMS API client types](/docs/public-cms-api-types) ### 1.1.6 — scheduled publish - **Scheduled publish / unpublish** — item datetime + `collections:process-schedules` ([core #44](https://github.com/qiick-io/externa-core/issues/44)) — [Content versioning](/docs/content-versioning) ### 1.1.5 — content versioning UX - **Content versioning & revisions** — draft/publish badges, promote/discard, list filter ([core #43](https://github.com/qiick-io/externa-core/issues/43)) — [Content versioning](/docs/content-versioning) ### 1.1.4 — live preview - **Live Preview** — URL templates, signed token, `GET /api/v1/preview` ([core #42](https://github.com/qiick-io/externa-core/issues/42)) — [Live Preview](/docs/live-preview) ### 1.1.3 — headless starter - **Headless starter** — official Next.js App Router starter against Public CMS API ([core #36](https://github.com/qiick-io/externa-core/issues/36)) — [Headless starter](/docs/headless-starter) · [qiick-io/externa-next-starter](https://github.com/qiick-io/externa-next-starter) ### 1.1.2 — webhooks recipes - **Outbound webhooks** — delivery status in admin, Make / n8n / Zapier recipes, Vercel/Netlify build-hook notes ([core #35](https://github.com/qiick-io/externa-core/issues/35)) — [Outbound webhooks](/docs/webhooks) ## Externa Core ### [1.2.2](https://github.com/qiick-io/externa-core/releases/tag/v1.2.2) — 2026-10-22 Security patch from `release/1.2.2` ([#250](https://github.com/qiick-io/externa-core/pull/250)). #### Fixes - `externa:install` keeps or generates `APP_KEY`, migrates the minimal profile without Redis, and can be re-run with `--force` ([#249](https://github.com/qiick-io/externa-core/pull/249), [#245](https://github.com/qiick-io/externa-core/issues/245)) — [Installation](/docs/installation#interactive-installer-externa-install) #### Security - Official Docker image on `php:8.4.27-fpm-bookworm` with hardened `php.ini`; Composer `php` constraint raised to `^8.4.27` ([#248](https://github.com/qiick-io/externa-core/pull/248), [#216](https://github.com/qiick-io/externa-core/issues/216)) — [discussion #247](https://github.com/qiick-io/externa-core/discussions/247) - AI collection-import webhook: timing-safe HMAC-SHA256 over the **raw body** (`hash_equals`); fail-closed when `AI_WEBHOOK_TOKEN` is unset outside `local`; **401** on mismatch ([#214](https://github.com/qiick-io/externa-core/pull/214), [#185](https://github.com/qiick-io/externa-core/issues/185)) — [AI API](/docs/ai-api#webhook-hmac-auth) #### Upgrade notes - Docker: pin `ghcr.io/qiick-io/externa-core:1.2.2` (or `latest`). Source installs: PHP **8.4.27+** (or **8.5.12+**) and disable `session.upload_progress` — see [Installation](/docs/installation) and [Deploy with Docker](/docs/deploy-with-docker). - Webhook signers must sign the raw request body only (`sha256=` + HMAC-SHA256). Set `AI_WEBHOOK_TOKEN` in every non-local environment. Invalid signatures return **401** (was 403). - See [Upgrade — 1.2.1 → 1.2.2](/docs/upgrade#1-2-1-1-2-2). ### [1.2.1](https://github.com/qiick-io/externa-core/releases/tag/v1.2.1) — 2026-10-21 Hotfix from `hotfix/1.2.1` ([#223](https://github.com/qiick-io/externa-core/pull/223)). #### Fixes - Renaming a field no longer drops its value from pending drafts and older revisions ([#218](https://github.com/qiick-io/externa-core/issues/218)) — [Schema changes](/docs/schema-changes) #### Security - Lockfile bumps: `follow-redirects` 1.16.1, `vite` 8.3.5 (via `launch-editor`; `package.json` range `^8.3.5`), `brace-expansion` 1.1.21 (dev); `npm audit --audit-level=high` reports 0 high/critical #### Upgrade notes - Run `php artisan externa:repair-renamed-field-drafts` (dry run, then `--apply`) right after deploying, before the scheduler runs or anyone publishes — [Upgrade](/docs/upgrade#1-2-0-1-2-1) · [Schema changes](/docs/schema-changes#repairing-older-drafts) - Docker: pin `ghcr.io/qiick-io/externa-core:1.2.1` (or jump to **1.2.2**). ### [1.2.0](https://github.com/qiick-io/externa-core/releases/tag/v1.2.0) — 2026-10-18 Editorial + integrations cut. Everything new is **opt-in**: off by default, no behavior change until enabled. #### Editorial - **Approvals workflow**: per-collection `approvals_required` (versioned collections only), submit / approve / reject with a note, publish gated on approval, webhook events `item.submitted` / `item.approved` / `item.rejected`, permissions `can-submit-collections` / `can-approve-collections` ([#48](https://github.com/qiick-io/externa-core/issues/48)) — [Editorial approvals](/docs/approvals) - **Translation workspace**: side-by-side source/target locales on the item form, copy helpers, completeness ([#49](https://github.com/qiick-io/externa-core/issues/49)) — note: the side-by-side Translate mode did not ship; 1.2.0 / 1.2.1 use the locale dropdown + per-field helpers described on [Translation workspace](/docs/translation-workspace) - **AI field translation** on the item form: **Translate with AI from {locale}** fills translatable target fields using the local AI provider (`POST /ai/translate-fields`, `ContentTranslator`) ([#193](https://github.com/qiick-io/externa-core/pull/193)) — [Translation workspace](/docs/translation-workspace) - **List layouts**: optional kanban (select/radio field) and calendar (date field); table stays the default ([#47](https://github.com/qiick-io/externa-core/issues/47)). Kanban columns lazy-load and card order persists via `board_sort`; the calendar adds Month / Week / Day / List views and drag-to-reschedule; the items-list toolbar gains filters ([#193](https://github.com/qiick-io/externa-core/pull/193)) — [Collection layouts](/docs/collection-layouts) - **Presence + soft lock**: avatars of co-editors, a 30 s soft lock, and stale-save protection via `expected_updated_at` ([#50](https://github.com/qiick-io/externa-core/issues/50)) — [Collaborative editing](/docs/collaborative-editing) #### Integrations - **Slack / Telegram notifiers** on the domain event bus, with an event-type filter and a test button ([#39](https://github.com/qiick-io/externa-core/issues/39)) — [Slack & Telegram notifiers](/docs/integrations) - **Meilisearch sync** of published items (queue jobs; no Scout/Algolia) ([#40](https://github.com/qiick-io/externa-core/issues/40)) — [Meilisearch sync](/docs/meilisearch) - **Git export** of published items to a remote (JSON/Markdown, deploy key, dry-run) ([#41](https://github.com/qiick-io/externa-core/issues/41)) — [Git export sync](/docs/git-export) - **CMS import profiles** `directus` / `wordpress` for the AI `ImportRemoteJson` tool ([#30](https://github.com/qiick-io/externa-core/issues/30)) — [CMS import connectors](/docs/cms-import) #### Auth - **OIDC / SSO login** (Socialite + issuer discovery): link by verified email, JIT provisioning off by default, login button alongside password/passkey ([#29](https://github.com/qiick-io/externa-core/issues/29)) — [SSO / OIDC](/docs/sso-oidc) #### Starters - Official **Nuxt** starter [qiick-io/externa-nuxt-starter](https://github.com/qiick-io/externa-nuxt-starter) + multi-repo starter conventions ([#152](https://github.com/qiick-io/externa-core/issues/152), [#156](https://github.com/qiick-io/externa-core/issues/156)) — [Headless starter](/docs/headless-starter) #### Fixes - Meilisearch: the index is created with `primaryKey=id`; before this fix documents were silently not indexed ([#179](https://github.com/qiick-io/externa-core/issues/179)) - Git export: clones use `--template=` (works on sandboxed filesystems) and empty bare remotes are bootstrapped ([#179](https://github.com/qiick-io/externa-core/issues/179), [#182](https://github.com/qiick-io/externa-core/issues/182)) - OIDC: callback validation errors redirect to login instead of looping back to the IdP; SSO rejection errors show on the login page - AI translate updates React-controlled TipTap / markdown fields ([#193](https://github.com/qiick-io/externa-core/pull/193)) - Telegram test in Project settings runs synchronously, shows Bot API errors, and validates the BotFather token format ([#183](https://github.com/qiick-io/externa-core/issues/183)) #### Security - OIDC client secret and Telegram bot token are stored encrypted - npm lockfile bumps so `npm audit --omit=dev --audit-level=high` passes ([#182](https://github.com/qiick-io/externa-core/issues/182)) #### Upgrade notes - Run `composer install` (adds `laravel/socialite` `^5.31`), then `npm ci && npm run build`, then `php artisan externa:upgrade` (or `migrate --force` + `permissions:sync`). New migrations: `collections.approvals_required`; `collections_items.approval_status` / `rejection_note` / `submitted_by` / `reviewed_by` / `reviewed_at`; `collections_items.board_sort` / `published_at`; `users.oidc_sub` (unique). **`users.password` becomes nullable.** Notifier mute list uses project setting `notifier_event_types` (null = all events). - `permissions:sync` adds `can-submit-collections` / `can-approve-collections`. Assign them to roles before turning on approvals. - Restart queue workers (Horizon) and Reverb — presence and the Meilisearch / git-export jobs depend on them. Without realtime, the item form works as before. - Docker: pin `ghcr.io/qiick-io/externa-core:1.2.0`. ### [1.1.0](https://github.com/qiick-io/externa-core/releases/tag/v1.1.0) — 2026-10-01 First post-GA cut. Milestone: [1.1.0](https://github.com/qiick-io/externa-core/milestone/5). - Live Preview, `GET /api/v1/preview` (#42) · Draft/publish UX, `has_draft` (#43) · Scheduled publish, `collections:process-schedules` (#44) - OpenAPI `GET /api/v1/openapi.json` (#25) · webhook catalog + delivery log (#35) · Next.js starter (#36) - Field conditions `contains`, `gt/gte/lt/lte`, `in/not_in`, OR (#45) · Activity export (#28) · a11y (#26) · i18n (#27) - **Breaking:** `laravel/ai` ^1.0 changes the AI chat history schema, and destructive AI tools now require Approve/Reject (#144). Run `php artisan migrate`. ### [1.0.0](https://github.com/qiick-io/externa-core/releases/tag/v1.0.0) — GA First stable GA. Builds on beta.4 security/install/Docker work plus AuthZ docs, outbound webhook secret enforcement, and Docker deploy DX. Milestone: [1.0.0](https://github.com/qiick-io/externa-core/milestone/4). #### Security - **Outbound webhooks** — refuse delivery when signing secret empty (no empty-key HMAC) ([#88](https://github.com/qiick-io/externa-core/issues/88)) - **AuthZ map** — surface → gate → Pest coverage for admin, Public CMS API, GraphQL, AI, webhooks, private files ([#90](https://github.com/qiick-io/externa-core/issues/90)) — [AuthZ map](/docs/authz-map) #### Ops / Docker - **Deploy with Docker** — GHCR multi-arch publish on `v*` tags, `compose.quick.yaml` pull path, gated `RUN_SEED` first-boot, healthchecks ([#56](https://github.com/qiick-io/externa-core/issues/56)) — [Deploy with Docker](/docs/deploy-with-docker) #### Files / DX - **FileChunkUploadService** extract from FileService ([#89](https://github.com/qiick-io/externa-core/issues/89)) Docs site for this cut: **`1.1.1`**. Related: [Packagist](/docs/packagist), [Releasing](/docs/releasing), [GA readiness](/docs/ga-readiness), [Security checklist](/docs/security-checklist). --- ### [1.0.0-beta.4](https://github.com/qiick-io/externa-core/releases/tag/v1.0.0-beta.4) — 2026-09-17 Last pre-GA beta: private files + upload denylist, AI webhook HMAC, minimal create-project handoff, Docker bake fixes, release ownership. Milestone: [1.0.0-beta.4](https://github.com/qiick-io/externa-core/milestone/7). #### Security - **Private files** — effective-private bytes on `private_assets` (not public symlink) ([#84](https://github.com/qiick-io/externa-core/issues/84)) - **Upload denylist** — block SVG / HTML / HTM ([#85](https://github.com/qiick-io/externa-core/issues/85)) - **AI collection-import webhook** — HMAC over body + `collection_id` ([#86](https://github.com/qiick-io/externa-core/issues/86)) #### Install & release - **create-project** — post-scripts stay minimal; handoff to `externa:install` ([#93](https://github.com/qiick-io/externa-core/issues/93)) - **npm prepare / Lefthook** — no-op without `.git` so Docker `image-build` stays green ([#94](https://github.com/qiick-io/externa-core/issues/94)) - **Who ships** — maintainers merge/tag/Release; agents open release PRs only ([#92](https://github.com/qiick-io/externa-core/issues/92)) — [Releasing](/docs/releasing) #### Docker - **phpredis pin** — `6.3.0` from GitHub tarball (bypass flaky `pecl install redis` on multi-arch bake) - Dependency refresh within constraints ([#95](https://github.com/qiick-io/externa-core/issues/95)); Larastan level 5 + baseline ([#87](https://github.com/qiick-io/externa-core/issues/87)) Packagist lists `1.0.0-beta.4`. Docs pages: [Files configuration](/docs/files-configuration), [File Manager — upload security](/docs/file-manager#upload-security), [AI API](/docs/ai-api), [Packagist](/docs/packagist), [Contributing — Lefthook](/docs/contributing#local-git-hooks-lefthook). --- ### [1.0.0-beta.3](https://github.com/qiick-io/externa-core/releases/tag/v1.0.0-beta.3) — 2026-09-16 Last beta before beta.4/GA: install/upgrade path, Docker ops depth, security CI, AI/DX, and release/API contracts. Release cut: [PR #77](https://github.com/qiick-io/externa-core/pull/77). Milestone: [1.0.0-beta.3](https://github.com/qiick-io/externa-core/milestone/3). #### Install & upgrade - **Packagist / create-project** — document `composer create-project` path for `qiick/externa-core` ([#7](https://github.com/qiick-io/externa-core/issues/7); Packagist org submit may still be pending) - **Upgrade guide** — backup → fetch tag → Composer → migrate → permission sync → frontend rebuild; optional `externa:upgrade` ([#8](https://github.com/qiick-io/externa-core/issues/8)) - **Interactive installer** — guided `.env` + first-run setup via `externa:install` ([#4](https://github.com/qiick-io/externa-core/issues/4)) - **Backup & restore** — self-hosted runbook for DB, `storage/`, `.env`, S3/Docker notes ([#19](https://github.com/qiick-io/externa-core/issues/19)) - **Production without Redis** — database queue / degraded realtime path ([#20](https://github.com/qiick-io/externa-core/issues/20)) #### Docker & ops - **Multi-arch images** — `linux/amd64` + `linux/arm64` bake ([#14](https://github.com/qiick-io/externa-core/issues/14)) - **Managed Compose profile** — app + workers only; external DB/Redis ([#15](https://github.com/qiick-io/externa-core/issues/15)) - **Reverse proxy cookbook** — Caddy / Traefik / nginx, TLS, Reverb WebSockets ([#17](https://github.com/qiick-io/externa-core/issues/17)) - **Dev Container** — one-click VS Code / Codespaces stack ([#18](https://github.com/qiick-io/externa-core/issues/18)) #### AI & DX - **Embeddings for SearchSimilar** — real embeddings when a provider is configured ([#9](https://github.com/qiick-io/externa-core/issues/9)) - **ManageRoles duplicate** — AI tool can clone roles like the admin UI ([#10](https://github.com/qiick-io/externa-core/issues/10)) #### Security & CI - **Composer / npm audit** on PRs and main branches ([#32](https://github.com/qiick-io/externa-core/issues/32)) - **Dependabot** for Composer and npm ([#33](https://github.com/qiick-io/externa-core/issues/33)) - **CodeQL + secret scanning** hygiene for the public repo ([#34](https://github.com/qiick-io/externa-core/issues/34)) - **Lefthook / commitlint** — optional local hooks; CI remains source of truth ([#31](https://github.com/qiick-io/externa-core/issues/31)) #### Docs & release process - **Release process** — CHANGELOG, semver, version bump, GitHub Releases ([#22](https://github.com/qiick-io/externa-core/issues/22)) - **API & GraphQL 1.x compatibility policy** ([#23](https://github.com/qiick-io/externa-core/issues/23)) - **GA groundwork** — security checklist pass, perf smoke, support window notes ([#11](https://github.com/qiick-io/externa-core/issues/11)) Docs pages for this cut: [Packagist](/docs/packagist), [Upgrade](/docs/upgrade), [Backup & restore](/docs/backup-restore), [Deployment](/docs/deployment), [Reverse proxy](/docs/reverse-proxy), [Production without Redis](/docs/production-without-redis), [Releasing](/docs/releasing), [API compatibility](/docs/api-compatibility), [GA readiness](/docs/ga-readiness). --- ### [1.0.0-beta.2](https://github.com/qiick-io/externa-core/releases/tag/v1.0.0-beta.2) — 2026-09-16 Ops-first pre-release: Docker stacks, health probes, S3/MinIO-safe files, multi-DB CI, and product polish. Full notes: [GitHub Release](https://github.com/qiick-io/externa-core/releases/tag/v1.0.0-beta.2). Compare: [v1.0.0-beta.1...v1.0.0-beta.2](https://github.com/qiick-io/externa-core/compare/v1.0.0-beta.1...v1.0.0-beta.2). Milestone: [1.0.0-beta.2](https://github.com/qiick-io/externa-core/milestone/2). #### Docker & ops - **Local + production Compose** — `compose.yaml`, `compose.prod.yaml`, multi-stage `Dockerfile`, Docker CI ([#12](https://github.com/qiick-io/externa-core/issues/12), [PR #55](https://github.com/qiick-io/externa-core/pull/55)) - **Health probes** — `GET /health/live` and `GET /health/ready` for LB / Compose ([#13](https://github.com/qiick-io/externa-core/issues/13)) - **S3 / MinIO file storage** — `FILES_DISK` + Flysystem AWS S3 adapter for multi-instance uploads ([#16](https://github.com/qiick-io/externa-core/issues/16)) #### Databases & CI - **MySQL 8+ / MariaDB 10.6+** — first-class CI matrix + docs ([#5](https://github.com/qiick-io/externa-core/issues/5)) - **PostgreSQL Pest job** — production-leaning driver in CI ([#6](https://github.com/qiick-io/externa-core/issues/6), [PR #53](https://github.com/qiick-io/externa-core/pull/53)) #### Features - **Duplicate non-system roles** — clone Spatie permissions + matrices ([#2](https://github.com/qiick-io/externa-core/issues/2), [PR #52](https://github.com/qiick-io/externa-core/pull/52)) - **Branded transactional email** — logo / `project_color` / name from Appearance ([#21](https://github.com/qiick-io/externa-core/issues/21), [PR #54](https://github.com/qiick-io/externa-core/pull/54)) #### Docs - **README product narrative + UI screenshots** ([#3](https://github.com/qiick-io/externa-core/issues/3), [PR #51](https://github.com/qiick-io/externa-core/pull/51)) #### Upgrade notes - Compose app port defaults to **`:8000`** — set `COMPOSE_APP_URL` accordingly - Prefer S3/MinIO via `FILES_DISK` for multi-container file storage - Wire LB/Compose checks to `/health/live` and `/health/ready` - Laravel Herd local workflow unchanged Release cut: [PR #57](https://github.com/qiick-io/externa-core/pull/57). --- ### [1.0.0-beta.1](https://github.com/qiick-io/externa-core/releases/tag/v1.0.0-beta.1) — 2026-09-13 First public beta. Milestone issues were thin (release cut only); history below is from the [GitHub Release](https://github.com/qiick-io/externa-core/releases/tag/v1.0.0-beta.1), [`CHANGELOG.md`](https://github.com/qiick-io/externa-core/blob/main/CHANGELOG.md), and [PR #1](https://github.com/qiick-io/externa-core/pull/1). #### Content & collections - Dynamic collections with a broad field-type set (text, markdown/WYSIWYG, files, relations, blocks/M2A, map, conditions, layout groups) - Item revisions, activity, advanced multi-rule filters, form layout (tabs / accordion / DnD) - Collection/field packs and SEO-oriented expansion helpers #### Files - Hierarchical file manager (tags, previews, async duplicate/zip jobs) - Upload denylist, text sanitization, private file access paths - Public files API with role-gated grants #### Auth, roles & security - Spatie RBAC + groups with effective role inheritance - Optional TOTP 2FA and Fortify passkeys (secure context required for WebAuthn) - Project-level `two_factor_required`, bounded inputs, HTML sanitization - Operator threat model + `SECURITY.md` (host owns TLS / backups / network) #### APIs - Public REST CMS API (`/api/v1`) with API keys and collection permission matrix - Opt-in `?include=`, Origin allowlist / CORS, response cache controls - GraphQL at `/api/graphql` - Outbound webhooks #### Collaboration - Global chat hub: collection item threads + private/direct chats - Unread, pins, attachments, realtime (Reverb/Echo when enabled) - Per-user archive for private chats; notification mark-unread (no auto-read on drawer open) #### Ops & admin UX - Dashboard (activity, storage, upload health) + Health tab (Pulse/Horizon when enabled) - Horizon / Reverb / Pulse wiring for the full stack; minimal stack runs without Redis - i18n (en/it/de where shipped), project branding & appearance settings - Optional AI assistant (OpenAI-compatible / LM Studio) scoped to the signed-in user’s permissions #### Requirements (at cut) | | | | ----- | ------------------------------------------------------------------------------- | | PHP | **8.4+** | | Node | **24** | | DB | SQLite (default), MySQL, or Postgres | | Redis | Optional — required for Horizon / Reverb-friendly realtime / Pulse redis ingest | #### Known beta notes (at cut) - Browser FileManager CI job is fail-soft (product Pest suite remains the hard gate) - Dependabot findings on the default branch triaged after this cut - Kitchen Sink / local QA seeders are **not** shipped in the OSS tree --- ## Docs site versions This documentation site (`externa-docs`) versions separately from Core. Product features above always refer to **Externa Core** tags. | Docs tag | Notes | | -------------------- | --------------------------------------------------------------------- | | **1.1.4** (this cut) | Live Preview URL templates + signed token docs (#42) | | **1.1.3** | Headless Next.js starter page (#36) | | **1.1.2** | Webhooks delivery UX + Make/n8n/Zapier / build-hook recipes (#35) | | **1.1.1** | SEO/canonical fix, docs footer (Privacy + credit), dependency refresh | | **1.1.0** | Docs parity for Core `v1.0.0` GA + beta.4 narrative | | v1.0.4 | Docs for Core `v1.0.0-beta.3` (+ post-cut security/ops on develop) | | v1.0.3 | Docs for Core beta.2 ops (Docker, health, S3, multi-DB, …) | | v1.0.2 | Docs aligned with Core `v1.0.0-beta.1` | | Earlier | `v1.0.1`, `v1.0.0` — site bootstrap | How maintainers cut Core releases: [Releasing & versions](/docs/releasing). --- # Threat model & hosting Source: https://docs.externa.qiick.io/docs/threat-model Externa is **self-hosted**. The app ships RBAC, hashed passwords, optional MFA, and an admin-gated activity log. **Transport encryption, database disk encryption, and backup hygiene are the operator’s job.** > **Related** > Repo file: [`externa-core/SECURITY.md`](https://github.com/qiick-io/externa-core/blob/develop/SECURITY.md) · App gates: [Pre-release security checklist](/docs/security-checklist) · Surface → gate → Pest: [AuthZ map](/docs/authz-map) · [Deployment](/docs/deployment) · [Activity & notifications](/docs/activity-and-notifications) · [Passkeys](/docs/passkeys) · [Roles & permissions](/docs/roles-and-permissions) ## Goals Protect against: - Unauthenticated access to admin UI and APIs - Authenticated users exceeding their Spatie / collection / file permissions - Accidental logging of passwords, 2FA secrets, and API key material - XSS from rich text (TipTap HTML sanitizer) and from File Manager plain-text fields (name / metadata / tags via `PlainTextSanitizer`) - Upload of common executable / script extensions **and** `svg` / `html` / `htm` into the Files pool (`ForbiddenUploadExtension` denylist — product policy: **block**) - Guessing `/storage/assets/...` paths for effective-private files (bytes live on `private_assets`, not the public symlink) Not designed to protect against: - An attacker with shell access to the app host **or** a copy of the database **and** `APP_KEY` - Network sniffing when the operator runs plain HTTP in production - End-to-end confidentiality of chat (no client-held keys) ## Trust boundaries ``` [Browser] --TLS (operator)-- [App / PHP] | +-- DB (chat bodies, activity JSON, users) +-- Object storage (files) +-- .env / APP_KEY ``` Anyone who can read the DB (or app storage + key) can read **chat message bodies** and **activity log properties**, including short chat `body_preview` fields. That matches a normal collaborative CMS, not a Signal-style messenger. Laravel `encrypted` casts (e.g. AI sync bearer tokens) use `APP_KEY` on the server. Useful against raw DB dumps **without** the key; useless if the app server is compromised. ## What the application covers | Area | Behavior | | ---------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Passwords | Hashed; excluded from Spatie attribute logs on `User` | | 2FA / passkeys | Optional; project can require MFA — [Passkeys](/docs/passkeys) | | API keys | Stored hashed; hash excluded from activity logs | | Activity Log UI / AI tool | Permission `can-show-activity-logs` | | Collections / files | Spatie permissions + field / item ACL rules | | Wysiwyg | `WysiwygHtmlSanitizer` strips dangerous markup | | File Manager uploads | Extension denylist (`ForbiddenUploadExtension`, includes SVG/HTML) + MIME allowlist / magic bytes (`MimeTypeValidator`; SVG/JSON/XML out) + plain-text strip on name/metadata/tags | | Outbound webhooks / Slack | `SafeRemoteUrlValidator` on save + delivery; no redirects; `WEBHOOKS_ALLOW_PRIVATE_HOSTS` default `false` (SSRF) — [Outbound webhooks](/docs/webhooks#ssrf-guard) | | Private file bytes | Local `private_assets` disk (not under `storage:link`); `publicUrl()` null; admin/API auth paths | | AI collection-import webhook | HMAC over the **raw body** with `AI_WEBHOOK_TOKEN` (`X-AI-Webhook-Signature`); CSRF-excepted machine path; **401** on fail / unset outside `local` (1.2.2) — [AI API](/docs/ai-api) | | Activity retention | Default 365 days; `activitylog:clean` on the scheduler | Team chat attachments are **not** covered by the Files extension denylist at upload time (any type by design); see [Chat](/docs/chat#attachments--limits). Saving into Files applies the denylist (including blocked SVG/HTML). ## What operators must provide | Control | Why | | ----------------------------------- | --------------------------------------------------- | | HTTPS (`APP_URL`) | Cookies, passkeys, basic confidentiality in transit | | Unique `APP_KEY`, `APP_DEBUG=false` | Session/crypt integrity; no debug leaks | | Encrypted DB volume + backups | Data at rest if the disk or backup is stolen | | Locked-down `.env` / SSH | Keys and secrets live on the host | | Who gets `can-show-activity-logs` | Audit UI shows PII and chat previews | | Queue + scheduler | Retention cleanup and async jobs actually run | Full env/deploy steps: [Deployment](/docs/deployment). ## Activity log & chat privacy - **Activity log** stores plaintext properties (IP, event metadata, settings keys, lean chat `body_preview` ≤ 120 characters). It does **not** encrypt columns. - **Chat** stores full `body` plaintext in `chat_messages` so hub search, replies, and moderation work. Product docs: [Chat (hub & items)](/docs/chat). - **Do not** expect application-level encryption of chat or activity as a substitute for host security. If you need stronger confidentiality (regulated data, E2EE messaging), that is a different product shape — not the Externa default. ## Deploy checklist (host) - [ ] `APP_ENV=production`, `APP_DEBUG=false`, unique `APP_KEY` - [ ] HTTPS on the public origin; `APP_URL` matches what browsers use - [ ] Production database with volume encryption and encrypted backups - [ ] Strong bootstrap admin credentials; rotate after first login if seeded from env - [ ] Restrict `can-show-activity-logs` to trusted operators - [ ] Scheduler running (`activitylog:clean` and other ops jobs) — [Operations](/docs/operations) - [ ] Public API CORS / keys / IP policy reviewed — [Public CMS API](/docs/public-cms-api) - [ ] Optional: `two_factor_required` for all users — [Passkeys](/docs/passkeys) Then run the **application** gates in [Pre-release security checklist](/docs/security-checklist). ## Reporting issues See [`SECURITY.md`](https://github.com/qiick-io/externa-core/blob/develop/SECURITY.md) in **externa-core**: prefer private disclosure, not a public issue. --- # AuthZ map Source: https://docs.externa.qiick.io/docs/authz-map One navigable map of how Externa authorizes each surface: **admin UI**, **Public CMS API**, **GraphQL**, **AI**, **webhooks**, and **private files**. Detail guides stay where they are — this page ties routes to gates and primary Pest coverage. > **Related** > [Effective permissions](/docs/permissions) · [Roles & permissions](/docs/roles-and-permissions) · [Pre-release security checklist](/docs/security-checklist) · [Threat model & hosting](/docs/threat-model) · [Public CMS API](/docs/public-cms-api) · [GraphQL](/docs/graphql) · [AI API](/docs/ai-api) · [Outbound webhooks](/docs/webhooks) · [File manager](/docs/file-manager) ## Layers (read this first) | Layer | What it gates | Where configured | | --------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------ | | **Session + Spatie / `PermissionEnum`** | Admin UI and authenticated `/ai/*` HTTP (who can open screens / call AI routes) | Roles UI + `permissions:sync` | | **`collection_permissions`** | Headless `/api/v1` and `/api/graphql` collection CRUD; optional `rules.fields` / `item_filter`; admin item form field ACL when a grant exists | Role **Collection access** matrix | | **`file_permissions`** | Headless `/api/v1/files*` visibility (`read` / **`read_private`** / create / update / delete) | Role **Files access** matrix | | **Machine HMAC / project secret** | Inbound AI collection-import webhook; outbound webhook signing (not Spatie) | `AI_WEBHOOK_TOKEN`; project webhook URL + secret | | **`Gate::before` (super-admin)** | Bypasses Spatie ability checks in admin; **not** available via API keys | Seeded `super-admin` role | Spatie alone does **not** unlock Public CMS API data. A user with `can-show-collections` still needs Collection access (and Files access for private bytes) on the headless surface. Full resolution: [Effective permissions](/docs/permissions). ## Master matrix | Surface | Entry / auth | Permission / ACL | Primary Pest | | ------------------------------------------------------------ | ----------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------- | | **Admin UI** (collections, files, roles, settings, …) | Fortify session; `permission:*` middleware + FormRequest | Spatie `PermissionEnum` via `EffectivePermissionResolver`; collection field ACL on item forms when grant present | `CollectionAuthorizationTest`, `RolePermissionManagementTest`, `FileManagerTest`, `EffectivePermissionResolverTest`, `SuperAdminGateTest` | | **Public CMS API** `/api/v1` | Anonymous → role `public`; or `Authorization: Bearer ek_…` → key’s role | `ResolveApiAccess` → `CollectionPermissionEnforcer` + `FilePermissionGuard` | `PublicCollectionApiTest`, `CollectionPermissionRulesTest`, `PublicFilesApiTest`, `PublicApiOriginGateTest` | | **GraphQL** `/api/graphql` | Same as REST (no session cookies) | Same enforcer + `throttle:api` | `GraphqlCollectionApiTest` | | **AI HTTP** `/ai/*` (except webhook) | Session + `can-use-ai` | Spatie `can-use-ai`; tools still call `ChecksAiPermissions` | `AiPermissionMatrixTest`, `AiAssistantTest` | | **AI tools** (in-process) | Authenticated AI user | Per-tool Spatie / collection enforcer (`ChecksAiPermissions`, `AiCollectionPermissionEnforcer`) | `AiPermissionMatrixTest`, `AiCollectionPermissionEnforcerTest`, `AiToolActionsDbTest` | | **AI inbound webhook** `POST /ai/webhooks/collection-import` | No session; `X-AI-Webhook-Signature` HMAC | `AI_WEBHOOK_TOKEN` over **raw body only** (401 on fail / unset outside `local`; [#214](https://github.com/qiick-io/externa-core/pull/214)) | `CollectionImportWebhookTest` | | **Outbound webhooks** (Externa → your URL) | N/A (server push) | Project webhook URL **and** signing secret required; empty secret refused | `OutboundWebhookTest` | | **Private files** (disk + Public API) | Admin Spatie for File Manager; API key / `public` for `/api/v1/files*` | Admin: `can-*-files` + private disk; API: `read` vs **`read_private`**; private bytes not on public `/storage` | `PrivateDiskFilesTest`, `PublicFilesApiTest` | Run a focused suite: ```bash herd php artisan test --filter='CollectionAuthorization|CollectionPermissionRules|Graphql|PublicFiles|PublicCollection|PrivateDisk|OutboundWebhook|AiPermissionMatrix|AiCollectionPermission|AiPhaseThree|EffectivePermission|SuperAdminGate|RolePermission' ``` ## Admin UI | Concern | Gate | | --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Route middleware | Spatie `permission:` alias on `routes/web.php`, `routes/collections.php`, `routes/settings.php`, files routes | | Form requests | `AuthorizesWithPermission` → `EffectivePermissionResolver::hasPermission()` | | Super-admin | `Gate::before` bypasses Spatie checks | | Collection item fields | When the actor has a collection grant with `rules.fields`, admin form respects read/create/update flags ([Collection items](/docs/collection-items#field-acl-in-the-item-form)) | | Jobs / project / appearance | `can-show-jobs` / `can-manage-jobs`; `can-manage-project-settings` | Product guide: [Roles & permissions](/docs/roles-and-permissions). Enum list: [Effective permissions](/docs/permissions#permissionenum). ## Public CMS API | Concern | Gate | | ------------------- | ----------------------------------------------------------------------------------------- | | Middleware | `ResolveApiAccess` (anonymous → `public` role; Bearer → API key role) | | Collections / items | Role **Collection access** create/read/update/delete + optional field ACL / `item_filter` | | CORS / Origin | Project `public_api_allowed_origins` (and related env) — not a substitute for keys | | Preview | Admin **Preview as role** uses the same enforcer payload shape | Guides: [Public CMS API](/docs/public-cms-api) · [Roles — Collection access](/docs/roles-and-permissions#collection-access-public-cms-api). ## GraphQL Same auth and collection/file guards as REST. Endpoint **`/api/graphql` only**; middleware `ResolveApiAccess` + `throttle:api`. No session cookies; no super-admin via API keys. Guide: [GraphQL](/docs/graphql). Pest: `GraphqlCollectionApiTest`. ## AI | Path | AuthZ | | ----------------------------------------------------------- | -------------------------------------------------------------------------- | | Authenticated `/ai/*` | Session + Spatie `can-use-ai` | | Tools (manage collections/files/users/…, query activity, …) | `ChecksAiPermissions::requirePermission()` — HTTP gate is not enough | | Collection-aware tools | Also respect collection enforcer where applicable | | Inbound collection-import webhook | HMAC only (`X-AI-Webhook-Signature` over raw body); not Bearer; not Spatie | Guides: [AI API](/docs/ai-api) · [AI assistant model](/docs/ai-assistant). Pest: `AiPermissionMatrixTest`, `AiCollectionPermissionEnforcerTest`, `AiPhaseThreeToFiveTest`. ## Webhooks Two different directions — do not conflate: | Kind | Direction | AuthZ | | -------------- | -------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **Outbound** | Externa → operator URL | Project settings URL + **signing secret**; dispatcher and job refuse empty secret ([#88](https://github.com/qiick-io/externa-core/issues/88)) | | **AI inbound** | Integrator → `POST /ai/webhooks/collection-import` | HMAC over raw body with `AI_WEBHOOK_TOKEN` ([#214](https://github.com/qiick-io/externa-core/pull/214); was body+`collection_id` in [#86](https://github.com/qiick-io/externa-core/issues/86)) | Guides: [Outbound webhooks](/docs/webhooks) · [AI API — Webhook](/docs/ai-api#webhook-hmac-auth). Configure outbound under [Project settings](/docs/project-settings) (`can-manage-project-settings`). ## Files (private path) | Surface | Behavior | | ------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Storage | Effective-private files live on the private disk; public `/storage/...` URLs must not serve private bytes ([#84](https://github.com/qiick-io/externa-core/issues/84)) | | Admin File Manager | Spatie `can-*-files` (show/create/edit/delete/download/…) | | Public CMS API `/api/v1/files*` | Role **Files access**: `read` for public files; **`read_private`** unlocks effective-private get/content/transform; without it → **403** / omit / `access: denied` on expands | | Uploads | Extension denylist (`ForbiddenUploadExtension`); SVG/HTML blocked ([#85](https://github.com/qiick-io/externa-core/issues/85)) | Guides: [Public CMS API — public vs private files](/docs/public-cms-api#public-vs-private-files) · [Roles — Files access](/docs/roles-and-permissions#files-access-public-cms-api) · [File manager](/docs/file-manager#upload-security). Pest: `PrivateDiskFilesTest`, `PublicFilesApiTest`. ## Source map (code) | Concern | Location | | ----------------------------------- | ----------------------------------------------------------- | | Spatie middleware / FormRequest | `permission:` alias; `AuthorizesWithPermission` | | Effective Spatie | `App\Support\Authorization\EffectivePermissionResolver` | | API actor resolution | `App\Http\Middleware\ResolveApiAccess` | | Collection ACL | `App\Services\Api\CollectionPermissionEnforcer` | | File ACL | `App\Services\Api\FilePermissionGuard` | | AI tool gates | `App\Ai\Concerns\ChecksAiPermissions` | | AI inbound HMAC | `App\Http\Controllers\Ai\CollectionImportWebhookController` | | Outbound sign / refuse empty secret | outbound webhook dispatcher + `DeliverOutboundWebhookJob` | ## Operators checklist - [ ] Walk this matrix against staging before production (with [Security checklist](/docs/security-checklist)) - [ ] Confirm `public` role Collection + Files matrices match intended anonymous exposure - [ ] API keys use least-privilege roles (no accidental `read_private`) - [ ] `AI_WEBHOOK_TOKEN` set only when the inbound webhook is used - [ ] Outbound webhook: both URL and secret, or leave URL empty - [ ] Private disk: smoke that public URLs cannot fetch private bytes --- # Pre-release security checklist Source: https://docs.externa.qiick.io/docs/security-checklist Use this checklist before production — **application** gates (permissions, ACL, GraphQL, MFA). For host TLS / disk encryption / activity & chat data-at-rest assumptions, see [Threat model & hosting](/docs/threat-model). > **Related docs** > Host / data-at-rest posture: [Threat model & hosting](/docs/threat-model) · surface → gate → Pest: [AuthZ map](/docs/authz-map) · repo [`SECURITY.md`](https://github.com/qiick-io/externa-core/blob/develop/SECURITY.md) · [Roles & permissions](/docs/roles-and-permissions) · [Passkeys](/docs/passkeys) · [SSO / OIDC](/docs/sso-oidc) · [Public CMS API](/docs/public-cms-api) · [GraphQL](/docs/graphql) · [Outbound webhooks](/docs/webhooks) · [Collections API](/docs/collections-api) · [Operations](/docs/operations) · [externa-bruno](https://github.com/qiick-io/externa-bruno) ## Must pass - [ ] PHP is on a supported, patched release (`php -v` ≥ **8.4.27** / **8.5.12**; externa-core **1.2.2+** requires `^8.4.27`) — [externa-core discussion #247](https://github.com/qiick-io/externa-core/discussions/247). Docker: re-pull `ghcr.io/qiick-io/externa-core:1.2.2` / `latest` and recreate containers; source builds use `--pull --no-cache` - [ ] AuthZ surfaces match [AuthZ map](/docs/authz-map) (admin Spatie, Public API / GraphQL collection+file ACL, AI tools + inbound HMAC, outbound secret, private files / `read_private`) - [ ] All `routes/collections.php` actions use Spatie `permission:` middleware (bulk via FormRequest) - [ ] Form requests for collections authorize with the same permissions - [ ] Pest: verified user **without** collection perms → **403** on schema/item writes (`CollectionAuthorizationTest`) - [ ] Pest: user **with** perms → OK; super-admin bypass still works - [ ] `collection_permissions.rules.fields` strip/deny unreadable/unwritable fields (REST + admin) - [ ] `item_filter` hides/blocks items that do not match - [ ] Roles UI can edit field ACL + item filter (**Fields & item filter** panel) - [ ] GraphQL `/api/graphql` uses `ResolveApiAccess` + same guards + `throttle:api` - [ ] Project settings / appearance gated by `can-manage-project-settings` - [ ] If `two_factor_required` is on in production, users without Fortify TOTP **and** without a passkey are forced to `/settings/security` with a clear required-MFA banner (TOTP or passkey satisfies; optional personal 2FA/passkeys still work when the setting is off) — [decision table](/docs/passkeys#relationship-with-2fa--two_factor_required) - [ ] Passkeys: `php artisan migrate` applied (`passkeys` table); `APP_URL` origin matches WebAuthn `relying_party_id` / `allowed_origins`; **HTTPS in production** (passkeys need a secure context; rest of CMS can be HTTP but prefer HTTPS); Fortify `confirmPassword` still on for security settings; **no** vendor forks/patches of Fortify or `@laravel/passkeys`; RP checks not widened — [Passkeys](/docs/passkeys) - [ ] If OIDC/SSO is enabled: redirect URI is `{APP_URL}/auth/oidc/callback`; client secret encrypted (`oidc_client_secret_configured`); link only on IdP-verified email; JIT off unless intended; OIDC does **not** satisfy `two_factor_required` — [SSO / OIDC](/docs/sso-oidc) - [ ] Smoke: register passkey on `/settings/security`, sign in with **Sign in with passkey**, **Confirm with passkey** on `/user/confirm-password` (HTTPS / secure context), and password confirm + TOTP paths still work (password confirm also on HTTP when the passkey CTA is hidden) — [Confirm password with a passkey](/docs/passkeys#confirm-password-with-a-passkey) - [ ] No collections admin endpoint without Gate/permission middleware ## Should verify - [ ] Migrations applied: `passkeys` + `collection_item_revisions` + M2M junction object migration + `user_created_id` / `user_updated_id` on `collections_items` - [ ] Revisions restore creates a new revision (History UI) - [ ] M2M junction object shape documented for API consumers; item form preserves meta on save - [ ] Jobs monitor gated by `can-show-jobs` / `can-manage-jobs` - [ ] GraphQL query cache mode is `opcache` (or Redis), not database-serialized AST objects - [ ] Rate limit / CORS for `/api/v1` and `/api/graphql` reviewed for the target environment - [ ] Production: set project `public_api_allowed_origins` (or explicit `CORS_ALLOWED_ORIGINS`); do not rely on Origin alone — use API key + IP for strong protection ([Origin allowlist](/docs/public-cms-api-types#origin-allowlist)) - [ ] `item_filter` SQL pushdown: list `meta.total` / pages match filtered set (not post-paginate PHP) - [ ] Items Export CSV/JSON respects filters + cap (`CollectionItemExportService`) - [ ] TipTap wysiwyg: toolbar works; script paste stripped by `WysiwygHtmlSanitizer` - [ ] File Manager: dangerous extensions rejected (`ForbiddenUploadExtension`, including `svg`/`html`/`htm`); MIME allowlist + magic bytes (`MimeTypeValidator`; SVG/JSON/XML out); name/metadata/tags HTML-stripped (`PlainTextSanitizer`) — [Upload security](/docs/file-manager#upload-security) - [ ] Effective-private files not reachable via unauthenticated `/storage/assets/...`; `files:migrate-private-disk` run after upgrade — [Files configuration](/docs/files-configuration) - [ ] File Manager UI strings resolve under `files.*` for en/it/de (shell locale) - [ ] Outbound webhook URL + secret both required for delivery; empty secret refuses queue/POST (no empty-key HMAC); secret not exposed in shared Inertia props; queue worker delivers `DeliverOutboundWebhookJob` — [Outbound webhooks](/docs/webhooks) - [ ] SSRF: leave `WEBHOOKS_ALLOW_PRIVATE_HOSTS=false` unless a trusted LAN receiver needs it; private/reserved webhook & Slack URLs rejected on save + delivery — [SSRF guard](/docs/webhooks#ssrf-guard) - [ ] AI collection-import webhook: `AI_WEBHOOK_TOKEN` set in every non-local env that uses it; clients send `X-AI-Webhook-Signature` (HMAC over the **raw body** only; **401** on failure from 1.2.2); CSRF exception only for that path — [AI API](/docs/ai-api) ## Dependency audits (CI) GitHub Actions workflow **`audit`** (PRs + `develop` / `main`): - `composer audit --locked` — fails on **high/critical** (low/medium ignored for now) - `npm audit --omit=dev --audit-level=high` — production/runtime deps only Policy is **blocking** from beta.3 onward (including GA). Never use `npm audit fix --force` in CI. Triage steps: [Contributing — Dependency audits](/docs/contributing#dependency-audits-ci). Companion: Dependabot (#33). Report app vulns via [`SECURITY.md`](https://github.com/qiick-io/externa-core/blob/develop/SECURITY.md). ## Code scanning & secrets Maintainer hygiene for the public `qiick-io/externa-core` repo (not a substitute for the app gates above): - [ ] **CodeQL** workflow present (`.github/workflows/codeql.yml`) — JS/TS on PRs / default branches + weekly schedule (PHP when CodeQL pack supports it) - [ ] First-wave CodeQL alerts triaged (fix or dismiss with reason) under **Security → Code scanning** - [ ] **Secret scanning** enabled; **push protection** on (repo **Settings → Code security**, or org default) - [ ] No committed secrets; if push protection fires, rotate and remove from history See [`SECURITY.md`](https://github.com/qiick-io/externa-core/blob/develop/SECURITY.md) · [Operations](/docs/operations#code-scanning--secret-scanning). ## Pest smoke (PHP 8.4) ```bash herd php artisan test --filter='CollectionAuthorization|CollectionPermissionRules|Graphql|AdvancedFilters|CollectionItemRevision|CollectionItemAudit|CollectionItemExport|WysiwygSanitize|JobMonitor|ForbiddenUploadExtension|PlainTextSanitizer|FilesLocaleParity|FileManager|PrivateDiskFiles|many to many accepts junction' ``` --- # Contributing Source: https://docs.externa.qiick.io/docs/contributing Contributions to **externa-core** should match the existing Laravel + Inertia React stack, keep enums as the source of truth for permissions/field types/roles, and leave tests green. Work in the `externa-core` repository. Operators on Packagist / Docker prod paths can skip contributor hooks. ## Local git hooks (Lefthook) After `npm ci` (or `npx lefthook install`), clones get: | Hook | Checks | | ------------ | ------------------------------------------------------------- | | `pre-commit` | Pint (`--dirty`), ESLint + Prettier on staged frontend files | | `commit-msg` | [commitlint](https://commitlint.js.org/) Conventional Commits | | `pre-push` | `pint --test` + `eslint` — **not** full Pest / browser | **GitHub Actions remains the source of truth.** Skip locally with `LEFTHOOK=0` or `git commit --no-verify` (emergency only). See repo [`CONTRIBUTING.md`](https://github.com/qiick-io/externa-core/blob/develop/CONTRIBUTING.md). npm `prepare` installs Lefthook only when `.git` exists — Docker image builds without a git checkout stay green (#94). ## Tooling ### PHP — Pint Laravel Pint formats PHP. Composer scripts: ```bash composer lint # pint --parallel composer lint:check # pint --parallel --test ``` `composer test` runs `lint:check` before Pest. ### Frontend — ESLint & Prettier From `externa-core`: ```bash npm run lint # eslint . --fix npm run lint:check # eslint . npm run format # prettier --write resources/ npm run format:check # prettier --check resources/ npm run types:check # tsc --noEmit ``` CI-style gate: ```bash composer ci:check ``` (runs npm lint/format/types checks + `composer test`). ### Wayfinder Laravel Wayfinder + `@laravel/vite-plugin-wayfinder` generates typed route/action helpers under `resources/js` during Vite build/dev. - Prefer Wayfinder helpers over hard-coded URL strings in React. - After adding/renaming named routes, run Vite (`npm run dev` or `build`) so generated clients refresh. - Do not hand-edit generated route trees as a long-term source of truth. ### Pest Feature and browser tests use **Pest 4**. See [Testing](/docs/testing) for layout and how to run suites. ```bash composer test # or focused: php artisan test --filter=SomeTestName ``` ## Coding conventions ### Enums are the source of truth | Concern | Enum | Sync / mirror | | ---------------------- | -------------------------- | ----------------------------------------------------------------------------- | | Permissions | `App\Enums\PermissionEnum` | `php artisan permissions:sync` → DB + `resources/js/enums/permission-enum.ts` | | Roles (built-in names) | `App\Enums\RoleEnum` | Seeded in `RoleSeeder` | | Field types | `App\Enums\FieldTypeEnum` | Validation, normalizer, UI catalog, AI instructions | | File kinds | `App\Enums\FileTypeEnum` | File manager / storage helpers | Do not invent permission or field-type strings in controllers or React without updating the enum first. Extension recipes: [Extending Externa](/docs/extending). ### Actions & Services - **`app/Services/`** — domain orchestration used by controllers, jobs, and AI tools (`FileService`, `Authorization\EffectivePermissionResolver`, Collections services, …). - **`app/Actions/`** — single-purpose actions (Fortify actions live under `app/Actions/Fortify`). Prefer a small Action class when a use-case is reused or would bloat a controller. - Controllers stay thin: Form Requests validate/authorize; Services/Actions mutate state; Jobs handle async work. ### HTTP & auth - Admin/files/AI routes: enforce permissions with middleware or Form Requests (`AuthorizesWithPermission` / `EffectivePermissionResolver`). - Collections HTTP currently relies on UI + AI checks — if you harden that, document it and add tests ([Collections API](/docs/collections-api)). - AI tools must use `ChecksAiPermissions` for mutating/domain operations. ### Frontend - Pages under `resources/js/pages/…` matching Inertia names. - Gate UI with `useCan()` + `PermissionEnum` from the generated TS enum. - Follow existing shadcn/Radix + Tailwind patterns; avoid one-off design systems. ### Jobs & schedule New async work should use the queue (not `dispatchSync` in request paths for heavy I/O). If you add a scheduled command, register it in `routes/console.php` and document it under [Operations](/docs/operations). ## Graphify knowledge graph This monorepo maintains a graphify index under `graphify-out/` at the Externa workspace root. After you change **code** (PHP/TS that affects architecture or APIs): ```bash cd /path/to/Externa graphify update . ``` Use `graphify query "…"` when exploring relationships before large refactors. Skip `graphify update` for docs-only or pure config-comment edits that do not change behaviour. ## Dependency audits (CI) PRs and pushes to `develop` / `main` run `.github/workflows/audit.yml`: | Check | Command | Policy (GA) | | ------------- | ------------------------------------------------------------------------ | ----------------------------- | | Composer | `composer audit --locked --ignore-severity=low --ignore-severity=medium` | **Blocking** on high/critical | | npm (runtime) | `npm audit --omit=dev --audit-level=high` | **Blocking** on high/critical | DevDependencies are not audited in the blocking job. Do **not** run `npm audit fix --force` in CI or as a blind local fix. ### Reading failures 1. Open the failed **audit** job log; note package + advisory ID / CVE. 2. Prefer upgrading within the current major (`composer update pkg` / `npm update pkg`) and re-run locally. 3. False positives / accepted risk: document in the PR with justification. Composer: temporary `--ignore` entries only with a tracked follow-up. npm: avoid `.npmrc` force; prefer upgrading or switching package. 4. Path to GA (#24): keep audits **blocking** (no `continue-on-error`). See also [Security checklist](/docs/security-checklist#dependency-audits--ci) · repo [`SECURITY.md`](https://github.com/qiick-io/externa-core/blob/develop/SECURITY.md). ## Dependabot `.github/dependabot.yml` opens **weekly** PRs (Monday) for Composer, npm, and GitHub Actions. Minor/patch updates are **grouped** per ecosystem; open-PR limits keep noise down. Majors arrive as separate PRs. ### Reviewing a Dependabot PR 1. Confirm CI green (lint, tests, **audit** jobs). 2. Skim release notes / changelog for breaking changes — do not merge major bumps blind. 3. Prefer one ecosystem group merge at a time; re-run audits locally if unsure. 4. After merge, note notable upgrades in `CHANGELOG.md` when cutting a release (see Release process / #22). ## PR expectations 1. Pint / ESLint / Prettier / `tsc` clean. 2. Pest coverage for new behaviour (see [Testing](/docs/testing)). 3. Enum + `permissions:sync` when permissions change; commit regenerated TS enum. 4. Note public route or config contract changes in the PR so product docs can be updated. 5. `graphify update .` when the code graph should reflect your changes. 6. Dependency audit CI green (or justified ignore with follow-up). ## Releases Maintainers cutting a version: [Releasing & versions](/docs/releasing) (CHANGELOG, semver, GitHub Release template). Prefer noting user-facing changes in `CHANGELOG.md` Unreleased while the PR is open. ## Maintainer checklists (not indexed) Internal sign-off pages stay out of the sidebar and sitemap (`noindex`). Reach them here when needed: - [GA readiness](/docs/ga-readiness) - [Website ↔ docs messaging](/docs/website-docs-messaging) - [Post-v1 polish smoke](/docs/post-v1-polish-smoke) --- # Extending Externa Source: https://docs.externa.qiick.io/docs/extending Three common extension paths touch the same sources of truth: **enums**, **sync/seed**, **HTTP middleware or Form Requests**, **React `useCan`**, and **AI tools**. Follow the checklists so UI, API, and assistant stay aligned. > **Related** > Permissions model: [Effective permissions](/docs/permissions). Field catalogue: [Field types](/docs/field-types). Automation: [Automation without Flows](/docs/automation-without-flows). Contrib norms: [Contributing](/docs/contributing). ## Add a permission ### 1. Enum Add a case to `app/Enums/PermissionEnum.php` with a kebab-case string value, e.g. `CanExportReports = 'can-export-reports'`. ### 2. Sync ```bash php artisan permissions:sync ``` This upserts the Spatie `permissions` row and regenerates `resources/js/enums/permission-enum.ts`. Use `--prune` only when you intentionally remove enum cases. ### 3. Roles - Update `RoleSeeder` (or assign in admin UI) so `admin` / custom roles receive the new permission. - `super-admin` already resolves to all `PermissionEnum::values()` at runtime via `EffectivePermissionResolver`. - `reader` only gets `can-show-*` names at seed time — new show permissions matching that prefix are included when you re-seed or assign manually. ### 4. Middleware / Form Requests | Surface | Pattern | | ------------------ | -------------------------------------------------------------------------- | | Admin-style routes | `->middleware('permission:'.PermissionEnum::CanExportReports->value)` | | Controllers / bulk | `$this->authorizePermission(PermissionEnum::…->value)` | | Files-like mapping | Extend `EnsureCanManageFiles` match arms if it belongs to the file manager | ### 5. Frontend `useCan` ```tsx import { PermissionEnum } from '@/enums/permission-enum' import { useCan } from '@/hooks/use-can' const { can } = useCan() if (!can(PermissionEnum.CanExportReports)) { return null } ``` Shared props already expose `auth.permissions` from `HandleInertiaRequests`. ### 6. AI tools If the assistant should perform the action: 1. Call `$this->requirePermission(PermissionEnum::CanExportReports)` inside the tool (`ChecksAiPermissions`). 2. Register/include the tool in `App\Ai\Agents\AppAssistant::tools()` when the user has the relevant permission(s). 3. Mention the capability in `instructions()` / `capabilityLines()` so the model knows the tool exists. ### 7. Tests - Feature: denied without permission, allowed with it (see `tests/Feature/Authorization/`, Admin suites). - AI: matrix/smoke tests under `tests/Feature/Ai/` if a tool is involved. --- ## Add a field type ### 1. Enum Add a case to `app/Enums/FieldTypeEnum.php` (string value). Update helpers if needed: - `isArrayStorage()` - `isRelationType()` / `isMultipleRelationType()` ### 2. Normalizer & validation rules Wire storage and validation through Collections services: | Piece | Typical location | | ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------ | | Normalize inbound values | `CollectionItemDataNormalizer` | | Build Laravel rules | `CollectionItemDataRuleBuilder` / `FieldValidationRuleEvaluator` | | Field settings schema | `ValidatesCollectionFieldSettings` + Store/Update field Form Requests (`Rule::enum(FieldTypeEnum::class)` already constrains `type`) | ### 3. UI | Piece | Location | | --------------------- | --------------------------------------------------------------------------------------- | | Type option catalogue | `resources/js/lib/collection-field-types.ts` | | Field settings panels | `resources/js/components/collections/field-settings/…` | | Item form input | `resources/js/components/collections/dynamic-item-fields.tsx` (+ rich inputs if needed) | Keep locale-aware settings (`display_name`, notes) consistent with project **content locales** (see [Configuration](/docs/configuration)). ### 4. AI instructions `AppAssistant::instructions()` embeds `FieldTypeEnum::values()` and tells the model to pass enum strings to `create_field`. Update tool descriptions in `ManageCollections` if the new type needs special settings JSON. `maxSteps()` already scales with `count(FieldTypeEnum::cases())`. ### 5. Tests - Normalizer/rule unit or feature coverage under `tests/Feature/Collections/`. - Optional AI tool test that creates a field of the new type. --- ## Add an AI tool ### 1. Tool class Create `app/Ai/Tools/YourTool.php` implementing the Laravel AI `Tool` contract (mirror an existing tool such as `QueryCollectionItems` or `ManageFiles`). ### 2. `ChecksAiPermissions` ```php use App\Ai\Concerns\ChecksAiPermissions; class YourTool /* … */ { use ChecksAiPermissions; public function handle(/* … */): mixed { if ($error = $this->requirePermission(PermissionEnum::CanShowSomething)) { return $error; // English machine-error string (model translates for the user) } // … } } ``` Return structured JSON with `"ok": true` on success so the agent instructions (“never invent success”) stay honest. ### 3. Register on `AppAssistant` In `tools()`: 1. Gate with `EffectivePermissionResolver` (`hasPermission` / `canAny`). 2. `$tools[] = new YourTool;` 3. Extend `capabilityLines()` / instructions so the model knows when to call it. ### 4. HTTP (optional) Only add routes if the UI needs a non-chat endpoint (status polling, uploads). Chat itself stays on `POST /ai/chat`. ### 5. Tests Add Pest coverage under `tests/Feature/Ai/`: - Permission denied vs allowed (`AiPermissionMatrixTest` patterns). - Happy-path tool action against the DB (`AiToolActionsDbTest` patterns). - Use helper `grantAiPermissions($user, […])` from `tests/Pest.php`. ### 6. Config flags If the tool is experimental, gate it behind `config/ai.php` (same pattern as `embeddings.enabled` / `mcp.enabled`) and document the env key. --- ## After extending 1. `composer lint` / `npm run lint:check` / `npm run types:check` 2. `composer test` or focused Pest filters 3. Update Markdoc if routes or config contracts changed 4. `graphify update .` from the Externa workspace root when architecture changed --- # Testing Source: https://docs.externa.qiick.io/docs/testing Externa uses **Pest 4** with the Laravel and browser plugins. Feature tests refresh the database; prefer focused suites that mirror the domain folders under `tests/Feature`. ## Layout ```text tests/ Pest.php # binds TestCase + RefreshDatabase; grantAiPermissions() TestCase.php Unit/ # no RefreshDatabase by default Feature/ Admin/ # users, groups, roles, files, activity Ai/ # assistant, tools, webhook, attachments Auth/ # Fortify / verification flows Authorization/ # permission sync, super-admin gate, resolver Collections/ # collections, fields, items, normalizer Settings/ # account / profile settings DashboardTest.php NotificationsTest.php CreateSuperAdminSeederTest.php … Browser/ # pest-plugin-browser (Playwright) FileManagerBrowserTest.php ``` Configured in `tests/Pest.php`: | Suite | Base | Traits | | --------- | ---------------- | ----------------- | | `Feature` | `Tests\TestCase` | `RefreshDatabase` | | `Browser` | `Tests\TestCase` | `RefreshDatabase` | | `Unit` | `Tests\TestCase` | _(none)_ | ### Domain folders (what lives where) | Folder | Covers | | ----------------------- | ---------------------------------------------------------------------------------------------------------- | | `Feature/Admin` | User/group/role/permission management, file manager JSON, zip/duplicate async, thumbnails, activity log UI | | `Feature/Api` | Public CMS API (`/api/v1`) — public role grants, API keys, IP allowlist, per-action deny/allow | | `Feature/Ai` | Chat, attachments, tool permission matrix, import remote JSON, sync/webhook phases, multi-role smoke | | `Feature/Collections` | Collection CRUD, item data normalizer, management flows | | `Feature/Auth` | Login, registration, verification, 2FA-related behaviour | | `Feature/Authorization` | `permissions:sync`, effective permissions, `Gate::before` super-admin | | `Feature/Settings` | Profile / password / appearance settings pages | | `Browser` | Real browser interactions (file manager drive-like UX) | ## Browser plugin notes Dependencies: `pestphp/pest-plugin-browser` and `playwright` (npm). - Browser tests live under `tests/Browser/`, not `Feature/`. - Default `phpunit.xml` / `composer test` run **Unit + Feature** only — Browser is **not** in the main Pest gate. - CI runs a dedicated **`browser`** job on `FileManagerBrowserTest.php` with `continue-on-error: true` (fail-soft) so flakes do not block merges — see `.github/workflows/tests.yml`. - Feature CI also covers **`ci-mysql`**, **`ci-mariadb`**, and **`ci-pgsql`** (PHP 8.4) alongside the SQLite `ci` matrix — see [Supported databases](/docs/supported-databases). - They use the same factories/seeders patterns as feature tests (e.g. seed `PermissionSeeder`, grant a disposable role with exact permission names). - Prefer `data-testid` / `data-test` selectors over visible copy (i18n). - Require a working Playwright install (`npx playwright install` if browsers are missing) and **Node 24**. - Slower than HTTP feature tests — keep them for interactions that need real DOM/Inertia behaviour (drag/select, panels), not for every CRUD path. Example helper pattern (see `FileManagerBrowserTest.php`): create a unique role per test, `syncPermissions`, assign to the user, then drive the UI. ```bash # Isolated File Manager browser suite (local) ./vendor/bin/pest tests/Browser/FileManagerBrowserTest.php ``` ## How to run tests From `externa-core`: ```bash # Full gate used in CI-style scripts (clears config, Pint --test, then Pest) composer test # Direct Pest / Artisan php artisan test php artisan test --filter=FileManagerTest php artisan test tests/Feature/Ai php artisan test tests/Browser # Frontend static checks (also part of composer ci:check) npm run lint:check npm run format:check npm run types:check ``` `composer test` runs: 1. `php artisan config:clear` 2. `pint --parallel --test` 3. `php artisan test` > **PHP version** > The app targets **PHP 8.4+** (`composer.json` `^8.4`). Run tests with the same CLI PHP your Herd/FPM stack uses so platform checks match Composer. ### Helpers `grantAiPermissions(User $user, array $permissions): User` in `tests/Pest.php` creates a unique role, syncs the given permission names, and assigns it — safe for parallel/isolation-sensitive AI tests. ## What to cover for new features | Change | Minimum tests | | ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- | | New permission | Sync includes it; route/FormRequest **403** without it and **200/allowed** with it; UI gate optional via browser or Inertia assert | | New admin/files route | Feature test for happy path + permission denial; async path asserts job dispatched / worker-friendly outcome | | New collections behaviour | Feature test for store/update/validation; normalizer cases for new field shapes | | New field type | Normalizer + validation rules; create field via HTTP or AI tool; UI smoke if non-trivial | | New AI tool | Permission denied string; success JSON with `"ok": true`; register presence in `AppAssistant` for a user who has the gate permissions | | Webhook / import | Token rejection (empty/wrong); successful import summary; throttle not required in unit tests | | Seeder / config defaults | Assert super-admin email/password from `config/super_admin.php` when touching seeders | | Browser-only UX | One Browser test for the interaction that HTTP tests cannot express | Prefer **one focused Pest file** next to the domain folder over a giant catch-all. Reuse factories and seed `PermissionSeeder` (or full `DatabaseSeeder`) instead of hand-inserting permission rows. ## Related - [Contributing](/docs/contributing) — lint/format before push - [Extending Externa](/docs/extending) — end-to-end checklists that include tests - [Operations](/docs/operations) — queue/scheduler needed when testing async jobs locally (`composer run dev` or a separate `queue:listen`) - [Post-v1 polish smoke](/docs/post-v1-polish-smoke) — optional signed-off Pest/Playwright checklist (internal) ---