# Qiick Docs (full) Site: https://docs.qiick.io --- # Externa documentation (full) Documented externa-core version: 1.4.0 Site: https://docs.qiick.io/externa --- # Introduction Source: https://docs.qiick.io/externa 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](https://docs.qiick.io/externa/quick-start): Herd .env, seed roles, and the minimal commands to run locally. - [Architecture](https://docs.qiick.io/externa/architecture): How Laravel, Inertia, routes, services, and the React shell fit together. - [Permissions](https://docs.qiick.io/externa/permissions): Spatie RBAC with group-inherited effective permissions and built-in roles. - [Public CMS API](https://docs.qiick.io/externa/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](https://docs.qiick.io/externa/public-cms-api) and [GraphQL](https://docs.qiick.io/externa/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](https://docs.qiick.io/externa/public-cms-api)); GraphQL at [`/api/graphql`](https://docs.qiick.io/externa/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](https://docs.qiick.io/externa/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](https://docs.qiick.io/externa/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](https://docs.qiick.io/externa/minimal-vs-full-stack). Production databases: [Supported databases](https://docs.qiick.io/externa/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](https://docs.qiick.io/externa/changelog)**. - **Install / Packagist** — [Packagist create-project](https://docs.qiick.io/externa/packagist) (`qiick/externa-core` published) → [`externa:install`](https://docs.qiick.io/externa/installation) (#93); [Upgrade](https://docs.qiick.io/externa/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](https://docs.qiick.io/externa/authz-map) (#90) - **Ops / Docker** — [Deploy with Docker](https://docs.qiick.io/externa/deploy-with-docker) GHCR quick Compose (#56), lefthook `prepare` no-op without `.git` (#94), phpredis pin, multi-arch [Deployment](https://docs.qiick.io/externa/deployment) - **Release** — maintainers ship ([Releasing](https://docs.qiick.io/externa/releasing) #92); [API 1.x compatibility](https://docs.qiick.io/externa/api-compatibility); [GA readiness](https://docs.qiick.io/externa/ga-readiness) - Earlier: [Backup](https://docs.qiick.io/externa/backup-restore), [Reverse proxy](https://docs.qiick.io/externa/reverse-proxy), [Horizon](https://docs.qiick.io/externa/horizon), [Minimal vs full](https://docs.qiick.io/externa/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](https://docs.qiick.io/externa/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](https://docs.qiick.io/externa/project-layout) page. Environment keys are listed under [Environment variables](https://docs.qiick.io/externa/environment-variables). ## Next steps 1. **Install locally** — preferably with [Laravel Herd](https://herd.laravel.com). Start with [Quick Start](https://docs.qiick.io/externa/quick-start); deeper detail in [Installation](https://docs.qiick.io/externa/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](https://docs.qiick.io/externa/architecture), [Effective permissions](https://docs.qiick.io/externa/permissions), and [AI assistant model](https://docs.qiick.io/externa/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.qiick.io/externa/quick-start Get **externa-core** running on Laravel Herd in a few minutes. For troubleshooting, AI gateway setup, and SSR, use the full [Installation](https://docs.qiick.io/externa/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](https://docs.qiick.io/externa/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](https://docs.qiick.io/externa/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](https://docs.qiick.io/externa/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](https://docs.qiick.io/externa/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 /externa/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](https://docs.qiick.io/externa/supported-databases). Full key reference: [Environment variables](https://docs.qiick.io/externa/environment-variables). Minimal vs Redis/Reverb/Pulse: [Minimal vs full stack](https://docs.qiick.io/externa/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](https://docs.qiick.io/externa/roles-and-permissions) · [Public CMS API](https://docs.qiick.io/externa/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](https://docs.qiick.io/externa/redis-prerequisite) | | Horizon | `php artisan horizon` | [Horizon](https://docs.qiick.io/externa/horizon) | | Reverb + Echo | Herd Pro on `:8080` + `REVERB_*` / `VITE_REVERB_*` | [Reverb & Echo](https://docs.qiick.io/externa/reverb-and-echo) | | Pulse | `php artisan pulse:work` (redis ingest) | [Pulse & Health](https://docs.qiick.io/externa/pulse-and-health) | | Queues / scheduler | Ops overview | [Operations](https://docs.qiick.io/externa/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](https://docs.qiick.io/externa/public-cms-api), [GraphQL](https://docs.qiick.io/externa/graphql)); Bruno executes them. ## Next steps - Deeper local setup & troubleshooting: [Installation](https://docs.qiick.io/externa/installation) - Tune every key: [Environment variables](https://docs.qiick.io/externa/environment-variables) - Minimal vs Redis stack: [Minimal vs full stack](https://docs.qiick.io/externa/minimal-vs-full-stack) - Code map: [Project layout](https://docs.qiick.io/externa/project-layout) - Access control: [Effective permissions](https://docs.qiick.io/externa/permissions) - Headless API: [Public CMS API](https://docs.qiick.io/externa/public-cms-api) · [GraphQL](https://docs.qiick.io/externa/graphql) - Try requests: [externa-bruno](https://github.com/qiick-io/externa-bruno) --- # Installation Source: https://docs.qiick.io/externa/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](https://docs.qiick.io/externa/packagist#ci-non-interactive-not-the-human-path). > **Faster path** > **Operators:** [Packagist `create-project`](https://docs.qiick.io/externa/packagist) then `php artisan externa:install`. Copy-paste Herd `.env` without the wizard: [Quick Start](https://docs.qiick.io/externa/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 ([GHSA-w8hw-w9fx-j79w](https://github.com/php/php-src/security/advisories/GHSA-w8hw-w9fx-j79w), [GHSA-6wmf-4rv5-p3rp](https://github.com/php/php-src/security/advisories/GHSA-6wmf-4rv5-p3rp)), the minimum safe patch versions are **8.4.27** or **8.5.12**. Upgrading is the only full mitigation. After switching Herd / system PHP, **restart** the PHP service so FPM loads the patched binary. Full notice: [externa-core discussion #247](https://github.com/qiick-io/externa-core/discussions/247). Hardening (keep after patch): [Deployment — PHP runtime hardening](https://docs.qiick.io/externa/deployment#php-runtime-hardening). | 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](https://docs.qiick.io/externa/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](https://docs.qiick.io/externa/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](https://docs.qiick.io/externa/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.27+** (or **8.5.12+**). 2. Place the project (create-project or clone) in a directory Herd serves (or use `herd link` / park). 3. After changing the Herd PHP version, restart Herd’s PHP / FPM service, then confirm `php -v` and a request that hits FPM (not only CLI). 4. Open a terminal in the project root for the steps below. Herd is optional — any local PHP 8.4.27+ + 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)). Production php.ini notes (upload progress Off, body limits, no `PHP_VALUE` via nginx): [Deployment — PHP runtime hardening](https://docs.qiick.io/externa/deployment#php-runtime-hardening). ## 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](https://docs.qiick.io/externa/passkeys#local-https-herd-valet-for-passkey-testing); confirm-password details: [Confirm password with a passkey](https://docs.qiick.io/externa/passkeys#confirm-password-with-a-passkey). Database matrix: [Supported databases](https://docs.qiick.io/externa/supported-databases). File and AI tunables: [Environment variables](https://docs.qiick.io/externa/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](https://docs.qiick.io/externa/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](https://docs.qiick.io/externa/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](https://docs.qiick.io/externa/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](https://docs.qiick.io/externa/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](https://docs.qiick.io/externa/deployment#health-probes) · [Operations](https://docs.qiick.io/externa/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](https://docs.qiick.io/externa/deploy-with-docker) · [Deployment — Docker](https://docs.qiick.io/externa/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](https://docs.qiick.io/externa/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](https://docs.qiick.io/externa/ai-assistant) and [AI configuration](https://docs.qiick.io/externa/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](https://docs.qiick.io/externa/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](https://docs.qiick.io/externa/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](https://docs.qiick.io/externa/quick-start) - Map the codebase: [Project layout](https://docs.qiick.io/externa/project-layout) - Tune `.env`: [Environment variables](https://docs.qiick.io/externa/environment-variables) - Understand access control: [Effective permissions](https://docs.qiick.io/externa/permissions) - Hit the Public API / GraphQL: [externa-bruno](https://github.com/qiick-io/externa-bruno) (open in Bruno, env **Local**) · [Public CMS API](https://docs.qiick.io/externa/public-cms-api) - Run background work in production: [Queues & scheduler](https://docs.qiick.io/externa/operations) --- # Headless starter Source: https://docs.qiick.io/externa/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](https://docs.qiick.io/externa/public-cms-api) · [Public CMS API types](https://docs.qiick.io/externa/public-cms-api-types) · [GraphQL](https://docs.qiick.io/externa/graphql) · [Quick Start](https://docs.qiick.io/externa/quick-start) · [Installation](https://docs.qiick.io/externa/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](https://docs.qiick.io/externa/public-cms-api-types) and core [#25](https://github.com/qiick-io/externa-core/issues/25)). ## 1. Run Externa Follow [Quick Start](https://docs.qiick.io/externa/quick-start) or [Installation](https://docs.qiick.io/externa/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](https://docs.qiick.io/externa/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](https://docs.qiick.io/externa/graphql). ## Rebuild when content changes Point Externa [outbound webhooks](https://docs.qiick.io/externa/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](https://docs.qiick.io/externa/meilisearch) and query the index from a **server** route with a search-only key (filter by `collection_slug`). --- # Packagist & create-project Source: https://docs.qiick.io/externa/packagist Operators install **externa-core** with Composer — no git clone required. Contributors still clone the GitHub repo. > **Related** > [Installation](https://docs.qiick.io/externa/installation) · [Quick Start](https://docs.qiick.io/externa/quick-start) · [Upgrade](https://docs.qiick.io/externa/upgrade) · [Releasing](https://docs.qiick.io/externa/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](https://docs.qiick.io/externa/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](https://docs.qiick.io/externa/installation#interactive-installer-externa-install). **Docker** remains a parallel path: [Installation — Docker](https://docs.qiick.io/externa/installation#docker-compose-local-full-stack) · [Deploy with Docker](https://docs.qiick.io/externa/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](https://docs.qiick.io/externa/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.qiick.io/externa/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** | **15+** | **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: PostgreSQL 14 and older, MySQL 5.7, MariaDB < 10.6, SQL Server, Oracle. PostgreSQL **15** is required from externa-core **1.4.0**: a migration rebuilds the `collections_items_values` unique index with `NULLS NOT DISTINCT` — [Upgrade — 1.3.x → 1.4.0](https://docs.qiick.io/externa/upgrade#1-3-x-1-4-0). ## 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](https://docs.qiick.io/externa/environment-variables). Local examples: [Quick Start](https://docs.qiick.io/externa/quick-start), [Installation](https://docs.qiick.io/externa/installation). Production swap: [Deployment](https://docs.qiick.io/externa/deployment#9-production-database-leave-sq-lite). ## Related - [Environment variables](https://docs.qiick.io/externa/environment-variables) — `DB_*` - [Quick Start](https://docs.qiick.io/externa/quick-start) — Herd Postgres / MySQL snippets - [Installation](https://docs.qiick.io/externa/installation) · [Deployment](https://docs.qiick.io/externa/deployment) - [Testing](https://docs.qiick.io/externa/testing) — Pest + RefreshDatabase --- # Project layout Source: https://docs.qiick.io/externa/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](https://docs.qiick.io/externa/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](https://docs.qiick.io/externa/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](https://docs.qiick.io/externa/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](https://docs.qiick.io/externa/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](https://docs.qiick.io/externa/installation) | ## Related docs - [Architecture](https://docs.qiick.io/externa/architecture) — request lifecycle and layering - [Environment variables](https://docs.qiick.io/externa/environment-variables) — `.env` reference - [Routing overview](https://docs.qiick.io/externa/routing) — HTTP surface summary - [Testing](https://docs.qiick.io/externa/testing) — conventions for Pest and browser tests --- # Environment variables Source: https://docs.qiick.io/externa/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](https://docs.qiick.io/externa/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` | `false` (1.4.0 `.env.example`; was `true`) | Detailed errors when true — **keep `false` in production**; turn on only while actively debugging. | | `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](https://docs.qiick.io/externa/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=false 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](https://docs.qiick.io/externa/supported-databases). | Variable | Default | Purpose | | ---------------------- | ----------------------------- | --------------------------------------------------------------------------------------------------------------- | | `DB_CONNECTION` | `sqlite` | Driver: `sqlite` \| `pgsql` \| `mysql` \| `mariadb`. See [Supported databases](https://docs.qiick.io/externa/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](https://docs.qiick.io/externa/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](https://docs.qiick.io/externa/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](https://docs.qiick.io/externa/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](https://docs.qiick.io/externa/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](https://docs.qiick.io/externa/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](https://docs.qiick.io/externa/project-settings#transactional-email-branding). Ensure logo files are publicly reachable (`FILE_PUBLIC_URL_BASE` / assets disk / CDN). Production provider `.env` examples: [Mail providers](https://docs.qiick.io/externa/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](https://docs.qiick.io/externa/installation#docker-compose-local-full-stack). ## Google Cloud Storage (optional, 1.4.0) For `FILES_DISK=gcs` (`config/filesystems.php` → `disks.gcs`). Details: [Files & storage — Google Cloud Storage](https://docs.qiick.io/externa/files-configuration#google-cloud-storage-core-1-4-0). | Variable | Default | Notes | | ----------------------------------- | --------- | ----------------------------------------------------------------------------------------------------- | | `GOOGLE_CLOUD_PROJECT_ID` | _(empty)_ | GCP project id | | `GOOGLE_CLOUD_KEY_FILE` | _(empty)_ | Service-account JSON path; empty = Application Default Credentials / `GOOGLE_APPLICATION_CREDENTIALS` | | `GOOGLE_CLOUD_STORAGE_BUCKET` | _(empty)_ | Bucket name | | `GOOGLE_CLOUD_STORAGE_PATH_PREFIX` | _(empty)_ | Optional key prefix | | `GOOGLE_CLOUD_STORAGE_API_URI` | _(empty)_ | Optional public URI base | | `GOOGLE_CLOUD_STORAGE_API_ENDPOINT` | _(empty)_ | Optional API endpoint (emulators) | | `GOOGLE_CLOUD_STORAGE_VISIBILITY` | `private` | Default object visibility | ```env FILES_DISK=gcs GOOGLE_CLOUD_PROJECT_ID= GOOGLE_CLOUD_KEY_FILE= GOOGLE_CLOUD_STORAGE_BUCKET= # GOOGLE_CLOUD_STORAGE_PATH_PREFIX= # GOOGLE_CLOUD_STORAGE_API_URI= # GOOGLE_CLOUD_STORAGE_API_ENDPOINT= # GOOGLE_CLOUD_STORAGE_VISIBILITY=private ``` ## Azure Blob Storage (optional, 1.4.0) For `FILES_DISK=azure` (`config/filesystems.php` → `disks.azure`). Details: [Files & storage — Azure Blob Storage](https://docs.qiick.io/externa/files-configuration#azure-blob-storage-core-1-4-0). | Variable | Default | Notes | | --------------------------------- | --------- | -------------------------------------------------------------------------------------- | | `AZURE_STORAGE_CONNECTION_STRING` | _(empty)_ | Shared-key connection string | | `AZURE_STORAGE_CONTAINER` | `externa` | Container name | | `AZURE_STORAGE_PREFIX` | _(empty)_ | Optional blob prefix | | `AZURE_STORAGE_URL` | _(empty)_ | Optional public base URL; its origin is added to the admin CSP `img-src` / `media-src` | ```env FILES_DISK=azure AZURE_STORAGE_CONNECTION_STRING= AZURE_STORAGE_CONTAINER=externa # AZURE_STORAGE_PREFIX= # AZURE_STORAGE_URL= ``` ## 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](https://docs.qiick.io/externa/deploy-with-docker) · [Installation — Docker](https://docs.qiick.io/externa/installation#docker-compose-local-full-stack) · [Deployment — Docker](https://docs.qiick.io/externa/deployment#docker-production). ## Files Mapped in `config/files.php`: | Variable | Default | Purpose | | -------------------------------- | -------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | | `FILES_DISK` | `assets` | File manager disk name: `assets`, `s3`, `gcs` (1.4.0), or `azure` (1.4.0). | | `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](https://docs.qiick.io/externa/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](https://docs.qiick.io/externa/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_ASSISTANT_PROVIDER` | `local` | Provider the in-app Assistant (`AppAssistant`) runs on, a key under `config('ai.providers')` (1.4.0). Separate from `AI_DEFAULT_PROVIDER`, which other AI features use. | | `OPENAI_REASONING_EFFORT` | `medium` | Reasoning effort the Assistant asks OpenAI (and Azure) for: `low`, `medium` or `high` (1.4.0). Any other value, or empty, sends no reasoning option. | | `ANTHROPIC_REASONING_EFFORT` | `medium` | Anthropic extended thinking: `low` / `medium` / `high` = 2048 / 8192 / 24576 budget tokens (1.4.0). Any other value, or empty, turns thinking off. | | `GEMINI_REASONING_EFFORT` | `medium` | Gemini thinking level: `low`, `medium` or `high`, with thought summaries (1.4.0). Any other value, or empty, sends no thinking option. | | `LOCAL_AI_REASONING_EFFORT` | _(unset = none)_ | Reasoning effort sent to the local OpenAI-compatible model (1.4.0). Unset keeps the model's own behaviour; set `low` / `medium` / `high` only when the model supports it. | | `OPENAI_CONTEXT_WINDOW`, `ANTHROPIC_CONTEXT_WINDOW`, `GEMINI_CONTEXT_WINDOW`, `LOCAL_AI_CONTEXT_WINDOW` | `400000`, `200000`, `1048576`, `131072` | Context size in tokens shown next to an answer's token usage in the Assistant (1.4.0). Display only: it does not limit the request. | | `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 whenever the webhook is used** — empty fails closed in every env (1.3.0+). | | `AI_WEBHOOK_TOKEN_PREVIOUS` | _(empty)_ | Previous HMAC key accepted only while `AI_WEBHOOK_TOKEN_PREVIOUS_EXPIRES_AT` is in the future (set by `ai:rotate-webhook-token`). | | `AI_WEBHOOK_TOKEN_PREVIOUS_EXPIRES_AT` | _(empty)_ | ISO 8601 expiry for the previous token grace window. | | `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` | Turns on the MCP server at `/mcp` (1.4.0; **404** while `false`) — [MCP server](https://docs.qiick.io/externa/mcp-server). | | `AI_MCP_WRITE_ENABLED` | `false` | Registers MCP write tools (1.4.0). Still needs the `mcp:write` key scope, the AI write gate, and an approval. | | `AI_MCP_RATE_LIMIT_PER_MINUTE` | `60` | `mcp` rate limiter per API key / user / IP (1.4.0); a key's own `rate_limit_per_minute` wins. | | `AI_TOOLS_ENABLED` | `true` | `false` disables every AI tool in chat and MCP (1.4.0). | | `AI_WRITE_TOOLS` | `false` | Kill switch for write/destructive AI tools (1.4.0). Off = chat keeps read actions only — [AI tool approvals & audit](https://docs.qiick.io/externa/ai-tool-approvals). | | `AI_TOOLS_WRITE_ENABLED` | `false` | Older name for `AI_WRITE_TOOLS`, read only when `AI_WRITE_TOOLS` is unset (1.4.0). | | `AI_TOOLS_WRITE_ALLOW` | _(empty)_ | CSV of mutating tool class names allowed to run after approval, e.g. `ManageFiles,ImportCollectionCsv`; merged with Settings → AI write tools (1.4.0). | | `AI_TOOLS_APPROVAL_TTL` | `15` | Minutes before an MCP pending approval expires (1.4.0). | | `AI_TOOLS_AUDIT_RETENTION_DAYS` | `90` | Retention for `ai_tool` audit rows and resolved approvals, pruned daily by `ai:prune-tool-audit` (1.4.0). | | `AI_TOOLS_REDACT_KEYS` | built-in list (`authorization`, `password`, `token`, `api_key`, `secret`, `email`, `phone`, …) | CSV of argument keys redacted in the audit log and approval inbox (1.4.0; not in `.env.example`). | | `AI_TOOLS_ALLOW` | _(empty)_ | When set, only these tool names are available (1.4.0). | | `AI_TOOLS_DENY` | _(empty)_ | Tool names that are never available (1.4.0). | ```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 # Assistant provider, reasoning and context window (1.4.0) # AI_ASSISTANT_PROVIDER=local # LOCAL_AI_REASONING_EFFORT= # OPENAI_REASONING_EFFORT=medium # ANTHROPIC_REASONING_EFFORT=medium # GEMINI_REASONING_EFFORT=medium # LOCAL_AI_CONTEXT_WINDOW=131072 AI_REMOTE_IMPORT_HOSTS= AI_WEBHOOK_TOKEN= # AI_WEBHOOK_TOKEN_PREVIOUS= # AI_WEBHOOK_TOKEN_PREVIOUS_EXPIRES_AT= AI_DAILY_PROMPT_LIMIT=0 AI_EMBEDDINGS_ENABLED=false AI_MCP_ENABLED=false AI_MCP_WRITE_ENABLED=false AI_MCP_RATE_LIMIT_PER_MINUTE=60 AI_TOOLS_ENABLED=true AI_WRITE_TOOLS=false AI_TOOLS_WRITE_ENABLED=false AI_TOOLS_WRITE_ALLOW= AI_TOOLS_APPROVAL_TTL=15 AI_TOOLS_AUDIT_RETENTION_DAYS=90 AI_TOOLS_ALLOW= AI_TOOLS_DENY= ``` Rotation runbook: [AI API — Rotate the webhook token](https://docs.qiick.io/externa/ai-api#rotate-the-webhook-token). ### Meilisearch (optional site search) See [Meilisearch sync](https://docs.qiick.io/externa/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](https://docs.qiick.io/externa/git-export). Prefer [webhooks](https://docs.qiick.io/externa/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 with `ai:rotate-webhook-token` if it leaks ([runbook](https://docs.qiick.io/externa/ai-api#rotate-the-webhook-token)); 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`). | | `API_COST_LIMIT` | `0` | Optional cost budget per 60 s window (`0` = off). Weights: `API_COST_BASE` / `API_COST_INCLUDE` / `API_COST_MUTATION` (defaults `1` / `2` / `5`). | | `API_MAX_BODY_BYTES` | `0` | Optional max `Content-Length` (`0` = off). Over → **413**. | | `API_COST_FILTER_RELATION` | `2` | Cost added per dotted relational filter key (`filter[author.name]`) when `API_COST_LIMIT` is on (1.4.0). | | `API_MAX_BATCH_SIZE` | `100` | Max entries per `POST\|PATCH\|DELETE /api/v1/collections/{slug}/items/batch` request, min 1 (1.4.0). | | `API_MAX_PER_PAGE` | `100` | Ceiling for REST/GraphQL list `per_page` / `perPage`. | | `PUBLIC_API_CACHE_TTL` | `120` | Public REST/GraphQL response-cache safety-net TTL in seconds (`config/api.php` `response_cache_ttl`, min 1) — [Performance tuning](https://docs.qiick.io/externa/performance-tuning). | | `GRAPHQL_MAX_DEPTH` | empty | Lighthouse max query depth; empty/unset = disabled. | | `GRAPHQL_MAX_COMPLEXITY` | empty | Lighthouse max query complexity; empty/unset = disabled. | | `LIGHTHOUSE_SECURITY_DISABLE_INTROSPECTION` | `false` | When `true`, disable GraphQL introspection. | | `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](https://docs.qiick.io/externa/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 PUBLIC_API_CACHE_TTL=120 CORS_ALLOWED_ORIGINS=* # API_COST_LIMIT=0 # API_MAX_BODY_BYTES=0 # API_MAX_PER_PAGE=100 # API_COST_FILTER_RELATION=2 # API_MAX_BATCH_SIZE=100 # GRAPHQL_MAX_DEPTH= # GRAPHQL_MAX_COMPLEXITY= ``` Setup and auth model: [Public CMS API](https://docs.qiick.io/externa/public-cms-api). Abuse controls & scopes: [API abuse controls](https://docs.qiick.io/externa/api-abuse-controls). Cache TTL and include prewarm: [Performance tuning](https://docs.qiick.io/externa/performance-tuning). ## Security headers (1.4.0) From `config/security.php` — applied to admin HTML responses only (not `/api/*` or GraphQL). Details: [Security headers](https://docs.qiick.io/externa/configuration#security-headers). | Variable | Default | Purpose | | ----------------------------- | ------------------------------------------------------------------ | ------------------------------------------------------------------------------------- | | `SECURITY_HEADERS_ENABLED` | `true` | Master switch for the headers below plus `X-Content-Type-Options: nosniff` | | `SECURITY_X_FRAME_OPTIONS` | `DENY` | `X-Frame-Options` | | `SECURITY_REFERRER_POLICY` | `strict-origin-when-cross-origin` | `Referrer-Policy` | | `SECURITY_PERMISSIONS_POLICY` | `camera=(), microphone=(self), geolocation=(), payment=(), usb=()` | `Permissions-Policy`; `microphone=(self)` keeps AI voice input working | | `SECURITY_CSP` | _(empty)_ | Full `Content-Security-Policy` override; empty = built-in Vite/Reverb-friendly policy | ```env SECURITY_HEADERS_ENABLED=true # SECURITY_X_FRAME_OPTIONS=DENY # SECURITY_REFERRER_POLICY=strict-origin-when-cross-origin # SECURITY_PERMISSIONS_POLICY="camera=(), microphone=(self), geolocation=(), payment=(), usb=()" # SECURITY_CSP= ``` None of these keys are in `.env.example`; the defaults apply when they are unset. ## 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 and SSRF opt-in 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 | | `OIDC_ALLOW_PRIVATE_HOSTS` | `false` | 1.4.0: allow discovery / endpoint URLs on private or CGNAT hosts (lab / Docker IdP). Off = SSRF guard rejects them | Redirect URI to register at the IdP: `{APP_URL}/auth/oidc/callback`. See [SSO / OIDC](https://docs.qiick.io/externa/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](https://docs.qiick.io/externa/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](https://docs.qiick.io/externa/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 (assets | s3 | gcs | azure) FILES_DISK=assets 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 # MCP server + AI tool write gate (1.4.0) AI_MCP_ENABLED=false AI_MCP_WRITE_ENABLED=false AI_MCP_RATE_LIMIT_PER_MINUTE=60 AI_TOOLS_ENABLED=true AI_WRITE_TOOLS=false AI_TOOLS_WRITE_ALLOW= AI_TOOLS_APPROVAL_TTL=15 AI_TOOLS_AUDIT_RETENTION_DAYS=90 AI_TOOLS_ALLOW= AI_TOOLS_DENY= # Security headers (1.4.0, defaults shown) SECURITY_HEADERS_ENABLED=true # 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=* # API_COST_FILTER_RELATION=2 # API_MAX_BATCH_SIZE=100 ``` ## Related docs - [Quick Start](https://docs.qiick.io/externa/quick-start) — Herd-oriented `.env` and minimal commands - [Installation](https://docs.qiick.io/externa/installation) — when `.env` is created and seeded; Docker Compose local - [Deployment](https://docs.qiick.io/externa/deployment) — Docker production, health probes, `TRUSTED_PROXIES` - [Public CMS API](https://docs.qiick.io/externa/public-cms-api) — `/api/v1`, keys, collection access - [Files & storage](https://docs.qiick.io/externa/files-configuration) — runtime file behavior - [AI configuration](https://docs.qiick.io/externa/ai-configuration) — providers and tool safety - [MCP server](https://docs.qiick.io/externa/mcp-server) and [AI tool approvals & audit](https://docs.qiick.io/externa/ai-tool-approvals) — 1.4.0 AI keys - [Application config](https://docs.qiick.io/externa/configuration) — non-env config surfaces --- # Using Externa with AI coding tools Source: https://docs.qiick.io/externa/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.qiick.io/externa/llms.txt) / [`llms-full.txt`](https://docs.qiick.io/externa/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.qiick.io/externa/llms.txt`](https://docs.qiick.io/externa/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.qiick.io/externa/llms.txt](https://docs.qiick.io/externa/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.qiick.io/externa/llms-full.txt](https://docs.qiick.io/externa/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.qiick.io/externa/.md` (home: [`/externa.md`](https://docs.qiick.io/externa.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.qiick.io/externa/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](https://docs.qiick.io/externa/project-layout). For HTTP surfaces, see [Routing overview](https://docs.qiick.io/externa/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](https://docs.qiick.io/externa/collections-data-model) and [Field types](https://docs.qiick.io/externa/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](https://docs.qiick.io/externa/permissions) · [Collections API](https://docs.qiick.io/externa/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](https://docs.qiick.io/externa/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](https://docs.qiick.io/externa/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)](https://docs.qiick.io/externa/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](https://docs.qiick.io/externa/account-settings) and [Project settings](https://docs.qiick.io/externa/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](https://docs.qiick.io/externa/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](https://docs.qiick.io/externa/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](https://docs.qiick.io/externa/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](https://docs.qiick.io/externa/project-layout) — where files live - [Effective permissions](https://docs.qiick.io/externa/permissions) — authz model - [Collections data model](https://docs.qiick.io/externa/collections-data-model) — EAV schema - [File tree model](https://docs.qiick.io/externa/file-tree-model) — files domain - [AI assistant model](https://docs.qiick.io/externa/ai-assistant) — agent and tools --- # Effective permissions Source: https://docs.qiick.io/externa/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](https://docs.qiick.io/externa/public-cms-api#public-vs-private-files) and [Roles & permissions](https://docs.qiick.io/externa/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](https://docs.qiick.io/externa/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](https://docs.qiick.io/externa/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)](https://docs.qiick.io/externa/chat). **Collections** (`routes/collections.php`): `auth` + `verified` plus Spatie `permission:` per action (same pattern as admin). See [Collections API](https://docs.qiick.io/externa/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](https://docs.qiick.io/externa/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](https://docs.qiick.io/externa/project-settings) | | Jobs monitor | `permission:can-show-jobs` / `can-manage-jobs` | [Operations](https://docs.qiick.io/externa/operations#jobs-monitor-admin-ui) | ## Related pages - [Public CMS API](https://docs.qiick.io/externa/public-cms-api) — headless `/api/v1`, public role, API keys - [Roles & permissions](https://docs.qiick.io/externa/roles-and-permissions) — product feature guide - [Architecture](https://docs.qiick.io/externa/architecture) — shared props and middleware stack - [AI assistant model](https://docs.qiick.io/externa/ai-assistant) — tool-level permission gates - [Admin & files API](https://docs.qiick.io/externa/admin-api) — route-level permission strings --- # Collections data model Source: https://docs.qiick.io/externa/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](https://docs.qiick.io/externa/field-types). HTTP endpoints are covered in [Collections API](https://docs.qiick.io/externa/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](https://docs.qiick.io/externa/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](https://docs.qiick.io/externa/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](https://docs.qiick.io/externa/field-types) - [Collections](https://docs.qiick.io/externa/collections) / [Collection items](https://docs.qiick.io/externa/collection-items) - [Collections API](https://docs.qiick.io/externa/collections-api) --- # Field types Source: https://docs.qiick.io/externa/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](https://docs.qiick.io/externa/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](https://docs.qiick.io/externa/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](https://docs.qiick.io/externa/configuration#locale-fallback), or [Live Preview — Locale resolution](https://docs.qiick.io/externa/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](https://docs.qiick.io/externa/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](https://docs.qiick.io/externa/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](https://docs.qiick.io/externa/collections-data-model) - [Collection items](https://docs.qiick.io/externa/collection-items) - [AI assistant model](https://docs.qiick.io/externa/ai-assistant) — tools that create/edit fields and items --- # Field conditions Source: https://docs.qiick.io/externa/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](https://docs.qiick.io/externa/extending) if you need that. > **Related** > [Field types](https://docs.qiick.io/externa/field-types) · [Collections](https://docs.qiick.io/externa/collections) · [Collection items](https://docs.qiick.io/externa/collection-items) --- # Schema changes Source: https://docs.qiick.io/externa/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 renames from externa-core **1.2.1+**, type / translatable locks from **1.3.0+**, the repair command for older drafts, and safe field conversion with dry run from **1.4.0** ([core#220](https://github.com/qiick-io/externa-core/issues/220)). > 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](https://docs.qiick.io/externa/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 (core 1.3.0+) Externa rejects `type` changes on a field that already has stored content (published values, `draft_data`, or values on soft-deleted items) — [core#219](https://github.com/qiick-io/externa-core/issues/219) / [PR #265](https://github.com/qiick-io/externa-core/pull/265). The admin form returns a **422** validation error and locks the type control (the field list exposes `has_stored_values`); the AI assistant’s `ManageCollections.updateField` returns an error. Re-submitting the same type, or changing an empty field, still works. Stored values are never converted by the field form. From **1.4.0**, use [field conversion](#field-conversion-core-1-4-0) for the supported pairs, or the manual path below for anything else. ### 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](https://docs.qiick.io/externa/collections-api) or [Admin API](https://docs.qiick.io/externa/admin-api) to update each item programmatically. 3. **Delete the old field** after verifying the migration. --- ## Toggling the translatable flag ### Current behaviour (core 1.3.0+) Externa rejects `translatable` changes on a field that already has stored content (published values, `draft_data`, or values on soft-deleted items) — same lock as type changes ([PR #265](https://github.com/qiick-io/externa-core/pull/265)). The admin form returns a **422** validation error and locks the **Translatable** checkbox (the field list exposes `has_stored_values`); the AI assistant’s `ManageCollections.updateField` returns an error. Re-submitting the same flag, or changing an empty field, still works. Stored values are never converted by the field form. From **1.4.0**, [field conversion](#field-conversion-core-1-4-0) flips the flag with a locale map or a picked locale; otherwise use the manual path below. ### 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. --- ## Field conversion (core 1.4.0) From externa-core **1.4.0** ([#220](https://github.com/qiick-io/externa-core/issues/220)), fields with stored content can change type or the translatable flag with a migration of every value, always previewed first. ### Supported conversions | From | To | Translatable | | ---------------------------------- | ---------------------------------- | ------------------------------------------------------------------------------------------------------ | | `string` / `textarea` / `markdown` | `string` / `textarea` / `markdown` | unchanged or flipped | | `string` | `select` | unchanged or flipped; values map onto options (missing options can be created) | | any supported type | same type | `false → true` (value moves to the default content locale) or `true → false` (pick the locale to keep) | Print the matrix from core with `php artisan externa:field-convert --matrix`. Other pairs are rejected — use the [manual safe path](#safe-path). ### Admin Fields page → field menu → **Change type…** opens the convert dialog. It lists allowed targets (`GET /collections/{collection}/fields/{field}/convert-targets`), runs a **Dry run** with ok / failed / drafts / skipped counts, then **Convert** (`POST …/fields/{field}/convert` with `type`, `translatable`, `locale`, `create_missing_options`, `option_mapping`, `dry_run`). Applying from the dialog queues `ConvertCollectionFieldJob` (a queue worker must run) unless `sync=true` is sent. Needs `can-edit-collections`. ### CLI: `externa:field-convert` ```bash # Preview (no writes) php artisan externa:field-convert 42 --to=textarea --dry-run # Flip to non-translatable, keep the Italian value php artisan externa:field-convert 42 --translatable=0 --locale=it --dry-run php artisan externa:field-convert 42 --translatable=0 --locale=it ``` | Option | Meaning | | ---------------------------- | -------------------------------------------------------------- | | `field` | Field id (required) | | `--to=` | Target type; omit to keep the current type | | `--translatable=` | `0` / `1`; omit to keep the current flag | | `--locale=` | Locale to keep when collapsing translatable → non-translatable | | `--create-missing-options=1` | Create `select` options from values (`string → select`) | | `--chunk=100` | Items per write batch | | `--dry-run` | Preview only | | `--matrix` | Print the supported matrix and exit | The summary prints `ok`, `unchanged`, `failed`, `skipped`, and `with_draft` counts. Without `--dry-run`, the command applies the conversion; there is no confirmation prompt, so run the dry run first. ### Guarantees - **All or nothing:** if any published or draft value cannot be converted, nothing is written (`Conversion aborted: N value(s) cannot be converted. Nothing was written.`). - Published values **and** pending drafts are converted, in chunked transactions. - Each changed item gets a revision with `source=convert`, and the change is written to the activity log. - **Historical revisions stay as they were.** Restoring an old revision adapts its value to the current type, or rejects the restore with a validation error when the value no longer fits. - The AI assistant can call `ManageCollections` `convert_field`; it is a write action, so it needs the [AI write gate and an approval](https://docs.qiick.io/externa/ai-tool-approvals). Renaming is still a breaking change for API consumers, and so is a type change: update clients that read the field. --- ## Related pages - [Collections](https://docs.qiick.io/externa/collections) — Collection and field CRUD - [Field types](https://docs.qiick.io/externa/field-types) — Field type reference and validation - [Content versioning](https://docs.qiick.io/externa/content-versioning) — Drafts, approvals, and revisions - [Collections data model](https://docs.qiick.io/externa/collections-data-model) — EAV storage and field IDs - [Collections API](https://docs.qiick.io/externa/collections-api) — Programmatic item updates for migration - [Upgrade](https://docs.qiick.io/externa/upgrade) — Version-specific upgrade notes --- # File tree model Source: https://docs.qiick.io/externa/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](https://docs.qiick.io/externa/file-manager). Config keys: [Files & storage](https://docs.qiick.io/externa/files-configuration). HTTP: [Admin & files API](https://docs.qiick.io/externa/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](https://docs.qiick.io/externa/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](https://docs.qiick.io/externa/architecture) — services and jobs - [Effective permissions](https://docs.qiick.io/externa/permissions) — `can.manage.files` - [Files & storage](https://docs.qiick.io/externa/files-configuration) - [AI assistant model](https://docs.qiick.io/externa/ai-assistant) — `ManageFiles` tool --- # AI assistant model Source: https://docs.qiick.io/externa/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](https://docs.qiick.io/externa/ai-chat), [AI imports & sync](https://docs.qiick.io/externa/ai-imports-and-sync). Config: [AI configuration](https://docs.qiick.io/externa/ai-configuration). HTTP: [AI API & webhooks](https://docs.qiick.io/externa/ai-api). ## AppAssistant | Item | Detail | | ------------------ | --------------------------------------------------- | | Class | `App\Ai\Agents\AppAssistant` | | Path | `app/Ai/Agents/AppAssistant.php` | | Contracts | `Agent`, `Conversational`, `HasSkills`, `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` | From externa-core **1.4.0**, `AppAssistant::tools()` returns `AiToolRegistryService::chatToolsFor($user)`: the shared registry that also backs the [MCP server](https://docs.qiick.io/externa/mcp-server) ([#202](https://github.com/qiick-io/externa-core/issues/202)). A tool is listed only when the user holds the required permission(s) via `EffectivePermissionResolverService` **and** it passes the config gates (`AI_TOOLS_*`, write gate). Each tool then re-checks action-level permissions through `ChecksAiPermissions::requirePermission()`. > With `AI_WRITE_TOOLS=false` (default), chat keeps the read actions of the `Manage*` tools (`list`, `get`, `search`, …) and refuses their write and destructive actions. Pure write tools (`ImportCollectionCsv`, `ImportRemoteJson`) and `RollbackLastAiTurn` are not offered. Enable them per tool class and approve each write in the chat — [AI tool approvals & audit](https://docs.qiick.io/externa/ai-tool-approvals). ## 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 | Piece | Detail | | ----------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Cookbook | `App\Ai\Support\FieldTypeCookbook` — curated purpose / required keys / `settings_json` examples / nested rules (aligned with [Field types](https://docs.qiick.io/externa/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 | `describe_collection` on the [MCP server](https://docs.qiick.io/externa/mcp-server) exposes the same schema to external agents; the cookbook stays chat-only | ## Permission-gated tools ### Registration (tool available in the agent) Permission needed for the tool to be listed (before the write gate): | 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](https://docs.qiick.io/externa/field-types#field-packs). **ManageCollectionItems** | Actions | Permission | | -------------------------------------- | ------------------------------ | | `list`, `get`, `list_relation_options` | `can-show-collections` | | `create` | `can-create-collections` | | `update`, `bulk_update`, `publish` | `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`, `tag` | `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](https://docs.qiick.io/externa/permissions) · [Collections API](https://docs.qiick.io/externa/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}`. Since 1.4.0 each assistant message keeps what the stream showed, so a reload loses nothing ([core #572](https://github.com/qiick-io/externa-core/pull/572)): | Column on `agent_conversation_messages` | Content | | --------------------------------------- | ----------------------------------------------------------------------------------------------------- | | `steps` | The steps in order: reasoning, text, tool calls (arguments, result, error, denial) and provider tools | | `usage` | Input, output, reasoning and cache tokens | | `meta` | Provider, model, the cited sources (`citations`) and the error, if any | | `status` | How the turn ended | `AiConversationMessagePresenterService` turns a stored message into the payload the Assistant page and the conversation API send, with each tool call's final state. What the user sees: [AI chat — What an answer shows](https://docs.qiick.io/externa/ai-chat#what-an-answer-shows). ## 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` | Items, files, and saved prompts can also be attached as conversation context (1.4.0) — [AI chat — Context attachments](https://docs.qiick.io/externa/ai-chat#context-attachments). | 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](https://docs.qiick.io/externa/ai-configuration#embeddings). ## Agent Skills From externa-core **1.4.0** ([#181](https://github.com/qiick-io/externa-core/issues/181)), `AppAssistant` implements laravel/ai `HasSkills`. Playbooks live under `resources/skills/*/SKILL.md` and the model loads one on demand (`LoadSkill`) instead of carrying every rule in the system prompt. | Skill | Used when | | ----------------------- | -------------------------------------------------------------------------------------------------------- | | `collection-operations` | Creating, updating, querying, packing, exporting, or deleting collections, fields, or items | | `collection-import` | CSV/XLSX attachments, remote JSON/API URLs, PDF extraction, queued import status, scheduled sync sources | | `approvals` | A write or destructive action needs confirmation, or an approval was rejected or abandoned | Skills only add instructions. They do not grant tools or permissions; the registry and write gate still decide what can run. The playbook texts are marked as drafts for maintainer review in core. ## MCP server `ai.mcp.enabled` (`AI_MCP_ENABLED`, default `false`) turns on a real MCP endpoint at `/mcp` (externa-core 1.4.0) that reuses this registry for external AI clients, with owned API keys and `mcp:read` / `mcp:write` scopes. See [MCP server](https://docs.qiick.io/externa/mcp-server). ## 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](https://docs.qiick.io/externa/permissions) - [Architecture](https://docs.qiick.io/externa/architecture) - [Collections data model](https://docs.qiick.io/externa/collections-data-model) - [AI configuration](https://docs.qiick.io/externa/ai-configuration) - [AI tool approvals & audit](https://docs.qiick.io/externa/ai-tool-approvals) - [MCP server](https://docs.qiick.io/externa/mcp-server) --- # Dashboard Source: https://docs.qiick.io/externa/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](https://docs.qiick.io/externa/permissions). Activity: [Activity & notifications](https://docs.qiick.io/externa/activity-and-notifications). Ops stack: [Pulse & Health](https://docs.qiick.io/externa/pulse-and-health) · [Horizon](https://docs.qiick.io/externa/horizon) · [Reverb & Echo](https://docs.qiick.io/externa/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](https://docs.qiick.io/externa/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](https://docs.qiick.io/externa/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](https://docs.qiick.io/externa/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.qiick.io/externa/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](https://docs.qiick.io/externa/permissions). Role assignment UI: [Roles & permissions](https://docs.qiick.io/externa/roles-and-permissions). Drawer URLs: [Routing — deep-links](https://docs.qiick.io/externa/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). ### Super-admin principals (core 1.3.0+) Only a signed-in **super-admin** may grant the super-admin role (or groups that carry it) or create / update / delete / restore users and groups that are already super-admin. The same rules apply to bulk actions and AI `ManageUsers` / `ManageGroups` tools ([core PR #307](https://github.com/qiick-io/externa-core/pull/307)). Non–super-admin managers with `can-*-users` / `can-*-groups` still manage ordinary principals. ## Admin lists (1.4.0) The users, groups, roles, API keys, permissions, activity log, jobs and pending tool calls lists are **server-driven data tables** ([core #523](https://github.com/qiick-io/externa-core/pull/523), [#526–#528](https://github.com/qiick-io/externa-core/pull/526), [#530](https://github.com/qiick-io/externa-core/pull/530), [#533](https://github.com/qiick-io/externa-core/pull/533), [#535](https://github.com/qiick-io/externa-core/pull/535), [#537](https://github.com/qiick-io/externa-core/pull/537), [#561](https://github.com/qiick-io/externa-core/pull/561)): - **Sorting**: sortable column headers open an ascending / descending menu. Sorting, filtering and paging run on the server and stay in the URL, so a list can be shared or reloaded as it is. - **Faceted filters**: filter buttons (for example role or status) next to the search. - **Row actions**: icon actions at the end of each row; clicking the row opens the record. Row checkboxes and select-all drive the bulk actions. - **Paging**: a footer with the range, the pages and rows per page (10 / 20 / 50 / 100, kept in `per_page`). - **Delete confirmations**: deleting from a list, a bulk action or a row menu asks for confirmation first. All admin tables share one 41px row height with small avatars ([core #585](https://github.com/qiick-io/externa-core/pull/585), [#586](https://github.com/qiick-io/externa-core/pull/586)). ## 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](https://docs.qiick.io/externa/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.qiick.io/externa/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](https://docs.qiick.io/externa/authz-map). Resolution rules: [Effective permissions](https://docs.qiick.io/externa/permissions). > **Scale with groups** > For large memberships, attach roles to a **[group](https://docs.qiick.io/externa/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](https://docs.qiick.io/externa/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](https://docs.qiick.io/externa/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](https://docs.qiick.io/externa/public-cms-api) to that role for partners/backends Full flow, endpoints, and auth resolution: [Public CMS API](https://docs.qiick.io/externa/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](https://docs.qiick.io/externa/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 | | New rows | From 1.4.0, permissions created by this run are also granted to the built-in `admin` role ([#439](https://github.com/qiick-io/externa-core/issues/439)); permissions you removed from `admin` stay removed | | 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`. ### Super-admin escalation guard (core 1.3.0+) Only an existing **super-admin** may grant the super-admin role/groups or create / update / delete / restore super-admin users and groups (admin web, bulk actions, and AI tools). Delegated user/group managers without super-admin cannot escalate or manage those principals ([core PR #307](https://github.com/qiick-io/externa-core/pull/307)). AI role sync also respects `is_assignable`. ### Collection grants fail closed (core 1.3.0+) When an admin already has any `collection_permissions` rows, item actions require a **matching per-collection grant** (fail closed). Spatie-only admins with no collection grants keep prior behaviour. The collection index hides collections without Read ([core PR #312](https://github.com/qiick-io/externa-core/pull/312)). `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 ai.prompts.manage deploy.trigger ``` `ai.prompts.manage` (create/update/delete saved AI prompts — using them only needs `can-use-ai`) and `deploy.trigger` (run [deploy hooks](https://docs.qiick.io/externa/project-settings#deploy-hooks)) are new in externa-core **1.4.0** and use dotted names. Source: `app/Enums/PermissionEnum.php`. Helper: `PermissionEnum::values()`. ## API Keys (admin) The roles, permissions and API keys lists are server-driven data tables (1.4.0) — [Users & groups — Admin lists](https://docs.qiick.io/externa/users-and-groups#admin-lists-1-4-0). | 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](https://docs.qiick.io/externa/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](https://docs.qiick.io/externa/public-cms-api#field-level-rules-and-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](https://docs.qiick.io/externa/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](https://docs.qiick.io/externa/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](https://docs.qiick.io/externa/operations#jobs-monitor-admin-ui). ## Project settings | Permission | Purpose | | ----------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | | `can-manage-project-settings` | Edit split project sections (`/settings/project…`), branding `/settings/appearance`, sidebar `/settings/appearance/sidebar`, webhook/notifier tests | Details: [Project settings](https://docs.qiick.io/externa/project-settings). --- # Collections Source: https://docs.qiick.io/externa/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](https://docs.qiick.io/externa/permissions), [Collections API](https://docs.qiick.io/externa/collections-api). Data shape: [Collections data model](https://docs.qiick.io/externa/collections-data-model). Field types: [Field types](https://docs.qiick.io/externa/field-types). Schema changes: [Schema changes](https://docs.qiick.io/externa/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](https://docs.qiick.io/externa/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](https://docs.qiick.io/externa/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](https://docs.qiick.io/externa/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.qiick.io/externa/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](https://docs.qiick.io/externa/collections). Field type catalog: [Field types](https://docs.qiick.io/externa/field-types). Model internals: [Collections data model](https://docs.qiick.io/externa/collections-data-model). HTTP table: [Collections API](https://docs.qiick.io/externa/collections-api). Item discussion threads: [Chat (hub & items)](https://docs.qiick.io/externa/chat). Collaborative presence / soft lock: [Collaborative editing](https://docs.qiick.io/externa/collaborative-editing). Locales & translation: [Translation workspace](https://docs.qiick.io/externa/translation-workspace). Kanban/calendar: [Collection layouts](https://docs.qiick.io/externa/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](https://docs.qiick.io/externa/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](https://docs.qiick.io/externa/ai-chat#ask-ai) | | Item chat | Drawer on item form | Shared item thread — [Chat (hub & items)](https://docs.qiick.io/externa/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](https://docs.qiick.io/externa/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](https://docs.qiick.io/externa/roles-and-permissions) (role form Discard), [Project settings](https://docs.qiick.io/externa/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](https://docs.qiick.io/externa/roles-and-permissions#preview-as-role) and [Public CMS API — `access: denied`](https://docs.qiick.io/externa/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.qiick.io/externa/collection-layouts Optional **kanban** and **calendar** layouts on the collection items index. **Table** stays the default. > **Related** > [Collection items](https://docs.qiick.io/externa/collection-items) · [Field types](https://docs.qiick.io/externa/field-types) (select / date) · [Roles & permissions](https://docs.qiick.io/externa/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.qiick.io/externa/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](https://docs.qiick.io/externa/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](https://docs.qiick.io/externa/collection-items). Team chat (separate channel): [Chat](https://docs.qiick.io/externa/chat). Global online users: [Reverb & Echo](https://docs.qiick.io/externa/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.qiick.io/externa/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](https://docs.qiick.io/externa/environment-variables) · [Deploy with Docker](https://docs.qiick.io/externa/deploy-with-docker) · [Headless starter](https://docs.qiick.io/externa/headless-starter) · [Outbound webhooks](https://docs.qiick.io/externa/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](https://docs.qiick.io/externa/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.qiick.io/externa/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](https://docs.qiick.io/externa/webhooks) → CI rebuild instead. > **Related** > [Webhook rebuild & CDN purge](https://docs.qiick.io/externa/webhook-rebuild-purge) · [Content versioning](https://docs.qiick.io/externa/content-versioning) · [Environment variables](https://docs.qiick.io/externa/environment-variables) · [Threat model](https://docs.qiick.io/externa/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.qiick.io/externa/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](https://docs.qiick.io/externa/collection-items) · [Editorial approvals](https://docs.qiick.io/externa/approvals) · [Live Preview](https://docs.qiick.io/externa/live-preview) · [Project settings](https://docs.qiick.io/externa/project-settings) > Renaming a field while items have pending drafts can lead to data loss when those drafts are published on older cores. From **1.2.1** renames preserve drafts; from **1.3.0** type / translatable flips with stored values are rejected. See [Schema changes](https://docs.qiick.io/externa/schema-changes). ## 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](https://docs.qiick.io/externa/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](https://docs.qiick.io/externa/operations). - Emit webhook `item.published` with `scheduled: true` on scheduled promote ([Outbound webhooks](https://docs.qiick.io/externa/webhooks)). - **Failures (core 1.3.0+):** if promote fails (e.g. approvals required but draft no longer `approved`, or another promote error), the cron persists `schedule_error` + `schedule_failed_at` on the item, keeps a paired `unpublish_at`, notifies the last editor/submitter and users with `can-approve-collections`, and emits `item.publish_failed` ([core PR #303](https://github.com/qiick-io/externa-core/pull/303)). The item form shows a failure alert; the list shows a badge. Successful promote, reschedule, schedule clear, or discard draft clears the failure state. ## Ops note Versioning is editorial only. Public CMS API returns published values unless you use [Live Preview](https://docs.qiick.io/externa/live-preview) with `version=draft`. Scheduled publish needs the scheduler process running. --- # Editorial approvals Source: https://docs.qiick.io/externa/approvals Optional **editorial approvals** for versioned collections: editor submits a draft → reviewer approves or rejects → publish (promote) only when approved. > **Related** > [Content versioning](https://docs.qiick.io/externa/content-versioning) · [Roles & permissions](https://docs.qiick.io/externa/roles-and-permissions) · [Outbound webhooks](https://docs.qiick.io/externa/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. - Editing an **approved** draft that still has `publish_at` set resets approval and flashes a warning. If the scheduler later cannot promote (approval missing or other promote error), the item stores `schedule_error` / `schedule_failed_at`, notifies editors/approvers, and emits webhook `item.publish_failed` ([core PR #303](https://github.com/qiick-io/externa-core/pull/303)) — [Content versioning](https://docs.qiick.io/externa/content-versioning#scheduled-publish-unpublish). ## 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](https://docs.qiick.io/externa/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) | | `item.publish_failed` | Scheduled promote failed after approval was reset or promote threw | 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.qiick.io/externa/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](https://docs.qiick.io/externa/project-settings) (content locales) · [Collection items](https://docs.qiick.io/externa/collection-items) · [Field types](https://docs.qiick.io/externa/field-types) · [AI configuration](https://docs.qiick.io/externa/ai-configuration) · [Public CMS API](https://docs.qiick.io/externa/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](https://docs.qiick.io/externa/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.qiick.io/externa/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](https://docs.qiick.io/externa/collection-items) (RBAC JSON). > **Related** > [Collection items](https://docs.qiick.io/externa/collection-items) · [Content versioning](https://docs.qiick.io/externa/content-versioning) · [Public CMS API](https://docs.qiick.io/externa/public-cms-api) · [Headless starter](https://docs.qiick.io/externa/headless-starter) · [Outbound webhooks](https://docs.qiick.io/externa/webhooks) ## Configure 1. **Collection** (edit drawer) → **Live Preview URL**, or 2. **Settings → Content** (`/settings/project/content`) → **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](https://docs.qiick.io/externa/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 → 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](https://docs.qiick.io/externa/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](https://docs.qiick.io/externa/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](https://docs.qiick.io/externa/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. ## Point-in-time preview (`as_of`) From externa-core **1.4.0** ([#222](https://github.com/qiick-io/externa-core/issues/222)), a preview token can carry an optional `as_of` claim to show the item **as it will look at a future moment**, taking scheduled `publish_at` / `unpublish_at` into account. - **Admin:** a **Preview at…** date-time picker next to Live Preview calls `GET /collections/{collection}/items/{item}/live-preview-url?version=…&as_of=`. - **Bound:** `as_of` may be at most **90 days** ahead; an unparseable or later value returns **422** on the URL endpoint. - **Token only:** `as_of` lives inside the signed token. The URL template is unchanged and needs no new placeholder. When `GET /api/v1/preview?token=…` sees an `as_of` claim, it resolves the target item at that moment: | Situation at `as_of` | Response | | --------------------------------------------------------- | ----------------------------------------------------------------------- | | `unpublish_at` is on or before `as_of` | **404** `Preview target not found.` | | Item has a draft and `publish_at` is on or before `as_of` | Draft values (`meta.source: "draft_overlay"`), `published_at` = `as_of` | | Item is already published | Current published values (`meta.source: "published"`) | | Never published and no due publish | **404** | `meta` then also carries `as_of` (UTC, `Z`) and `source`; without the claim, `meta.as_of` is `null`. ```json { "data": { "id": 1, "collection_id": 3, "data": { "title": "Launch post" }, "published_at": "2026-12-01T09:00:00+00:00" }, "meta": { "preview": true, "version": "published", "locale": "en", "expires_at": "2026-11-03T12:00:00Z", "as_of": "2026-12-01T09:00:00Z", "source": "draft_overlay" } } ``` Scope: only the token's target item is simulated. Related items and `?include=` hydration show their **current** state, and the draft overlay returns flat `draft_data` without relation hydration. ## 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](https://docs.qiick.io/externa/headless-starter). --- # File manager Source: https://docs.qiick.io/externa/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](https://docs.qiick.io/externa/file-tree-model). Disks & limits: [Files & storage](https://docs.qiick.io/externa/files-configuration). Named presets + focal point: [Image transforms](https://docs.qiick.io/externa/image-transforms). HTTP tables: [Admin & files API](https://docs.qiick.io/externa/admin-api). Async outcomes land in the [notifications bell](https://docs.qiick.io/externa/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) | ### Grid and details panel (1.4.0) - **File cards** ([core #592](https://github.com/qiick-io/externa-core/pull/592), [#596](https://github.com/qiick-io/externa-core/pull/596)): every card is square, files and folders alike. The preview fills the card edge to edge above a 40px name row (`object-cover` on the file's focal point); only the selection checkbox, the favourite star and the **Private** badge sit over it. Non-image files and folders show their type or folder icon centred in the same area. - **Name row**: the name on one line (full name in a tooltip); a **tag** button, only when the file has tags, that lists them in a hover card on hover or keyboard focus; and a **⋮** button with the same actions as the right-click menu (opening it selects the card). - **Columns**: 2 on phones, then as many as fit at a minimum of 15rem (4 at 1440 px, 6 at 1920 px). - **Focal point** ([core #583](https://github.com/qiick-io/externa-core/pull/583)): for images, the details panel shows the preview; click it to set the focal point (a crosshair marks it), or use the arrow keys (1%, Shift 10%) and Enter. **Reset to centre** clears it. The point drives the crop of every `cover` transform — [Image transforms — Focal point](https://docs.qiick.io/externa/image-transforms#focal-point). - **Transforms fields removed**: the Translate X / Translate Y / Scale fields are gone from the panel because nothing read them. The database columns stay until a later migration. - Folders and non-image files show no focal point. 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](https://docs.qiick.io/externa/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`**, and from **1.3.0** SSI / markup extensions **`shtml` / `shtm` / `xml` / `xhtml` / `xht` / `mht` / `xsl` / `svgz`** ([core PR #314](https://github.com/qiick-io/externa-core/pull/314)). Rejection is a validation error (`This file type is not allowed.`). **Breaking (1.3.0):** **SVG, JSON, and XML** are not on the default MIME allowlist (SVG/HTML/SSI/markup also stay extension-denied). From **1.4.0**, operators can widen the allowlist and opt into SVG under **Settings → Files & Media** — HTML/SSI/executables remain on a non-disableable hard floor ([core#327](https://github.com/qiick-io/externa-core/issues/327)). **Docker nginx:** official images sandbox public `/storage/` with `X-Content-Type-Options: nosniff` and a restrictive CSP (`sandbox; default-src 'none'; …`) so a public-disk asset cannot execute as a document — [Deploy with Docker](https://docs.qiick.io/externa/deploy-with-docker). 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](https://docs.qiick.io/externa/chat#attachments)). 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](https://docs.qiick.io/externa/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](https://docs.qiick.io/externa/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](https://docs.qiick.io/externa/public-cms-api#public-vs-private-files) and [Roles & permissions](https://docs.qiick.io/externa/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](https://docs.qiick.io/externa/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.*` | --- # Image transforms Source: https://docs.qiick.io/externa/image-transforms Externa generates image derivatives on demand with PHP GD (`FileTransformService`). Operators define **named presets** under Settings → Files & Media; editors set a **focal point** per file so `cover` crops keep the subject. Headless clients consume preset URLs from the Public CMS API. > **Related** > Configure presets: [Project settings — Files & Media](https://docs.qiick.io/externa/project-settings#files-and-media-settings-project-files). File HTTP: [Public CMS API — Files](https://docs.qiick.io/externa/public-cms-api#files). Admin browse: [File manager](https://docs.qiick.io/externa/file-manager). Disks: [Files & storage](https://docs.qiick.io/externa/files-configuration). ## Named presets ### Configure (admin) Open **Settings → Files & Media** (`/settings/project/files`). The form stores: | Key | Meaning | | ------------------------- | --------------------------------------------------------------------- | | `allowed_transformations` | Which transform operations are enabled (defaults include `thumbnail`) | | `preset_transformations` | Ordered list of named presets edited by `TransformPresetsField` | Each preset has: | Field | Notes | | --------------------- | ------------------------------------------------------------------------ | | `key` | URL segment and map key (`thumbnail`, `card`, …). Unique, non-empty. | | `fit` | One of project `transform_fits`: `contain`, `cover`, `inside`, `outside` | | `width` / `height` | Target box in pixels (nullable depending on fit) | | `quality` | Encode quality (default **82**) | | `without_enlargement` | When true, never upscale beyond the source | | `format` | `auto` (prefer source-friendly encode) or an explicit output format | Fresh installs ship these defaults (`config/settings.php`): | key | fit | size | | ----------- | --------- | ------- | | `thumbnail` | `contain` | 128×128 | | `small` | `contain` | 64×64 | | `medium` | `contain` | 256×256 | | `card` | `inside` | 512×512 | Saving the form updates project settings; new requests resolve presets through `ProjectSettingsService::transformPreset($key)`. ### Public API URLs For an image file, `GET /api/v1/files/{id}` (and list / item `?include=files` expanders) includes a `transforms` map built by `FileTransformUrlBuilder` — one entry per configured preset key: ```json { "url": "http://externa-core.test/api/v1/files/12/content", "transforms": { "thumbnail": "http://externa-core.test/api/v1/files/12/transforms/thumbnail", "small": "http://externa-core.test/api/v1/files/12/transforms/small", "medium": "http://externa-core.test/api/v1/files/12/transforms/medium", "card": "http://externa-core.test/api/v1/files/12/transforms/card" } } ``` Fetch a derivative: ```bash # Named preset curl -sS -OJ http://externa-core.test/api/v1/files/12/transforms/card \ -H "Authorization: Bearer ek_YOUR_SECRET" # Ad-hoc edge (optional): size-{n}, clamped to FileTransformService::MAX_SIZE (512) curl -sS -OJ http://externa-core.test/api/v1/files/12/transforms/size-256 \ -H "Authorization: Bearer ek_YOUR_SECRET" ``` Path shape: **`GET /api/v1/files/{id}/transforms/{key}`**. - `{key}` = a project preset key → `ensureTransform(..., key: $key)` - `{key}` = `size-{n}` (digits) → `ensureThumbnail($file, n)` (legacy / ad-hoc edge) - Unknown key or non-image → **404** Auth matches file content: role needs **Files → Read** (and **Read private** for effective-private files). Anonymous requests use the `public` role — grant Read there for marketing sites so `` / Next `` can hit transform URLs without a Bearer header. See [Public vs private files](https://docs.qiick.io/externa/public-cms-api#public-vs-private-files). > Width, height, fit, and quality are **not** taken from the request URL. Change them in Settings → Files & Media (or use `size-{n}` for a simple max edge). There is no Cloudinary-style `?w=&h=` query API. ### Caching behaviour - First request for a preset (or `size-{n}`) runs GD, writes a cache object on the file’s disk, then streams it. - Response header: `Cache-Control: private, max-age=86400`. - Cache path includes the normalized preset + focal point. Changing `focal_point_x` / `focal_point_y` (or replacing the binary) clears existing transforms so the next request regenerates. - Presets larger than the ad-hoc `MAX_SIZE` (512) ceiling are allowed for **named** keys; `size-{n}` remains capped. ## Focal point Normalized coordinates on the file row: **`focal_point_x`** and **`focal_point_y`** in **0–1** (default centre `0.5` / `0.5` when unset). ### Set in admin File Manager → open an image's details panel → **click the preview** where the subject is (1.4.0; arrow keys nudge the point by 1%, Shift by 10%, Enter sets it). A crosshair marks the point and **Reset to centre** clears it. The panel saves it through the metadata update (`PATCH /files/{file}`, `focal_point_x` / `focal_point_y` in `[0, 1]`). Only images have a focal point; folders and other files do not show it. Before 1.4.0 the panel had X / Y number fields and Translate / Scale fields, which are removed. ### Set via Public CMS API ```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 '{"focal_point_x":0.35,"focal_point_y":0.6}' ``` Requires Files **update**. Response FileResource echoes the new focal fields. Metadata update clears cached transforms for that file. ### Effect on `cover` crops `FileTransformService` injects the file’s focal into every preset before geometry. For **`fit: cover`**, the crop window is placed so the focal stays near the centre of the output (clamped to edges) — faces / subjects stay in frame when the aspect ratio changes. Other fits (`contain`, `inside`, `outside`) still record focal in the cache key; cover is the geometry that uses it for crop offset. ## Next.js `` example Use the preset URL from the FileResource as the `src`. Prefer anonymous **public** Read for browser-loaded transforms (no API key in the client). Configure `images.remotePatterns` (or a custom loader) for your Externa host. ```tsx // next.config.ts — allow the Externa origin import type { NextConfig } from 'next' const externaHost = new URL( process.env.EXTERNA_API_URL ?? 'http://externa-core.test', ).hostname const nextConfig: NextConfig = { images: { remotePatterns: [ { protocol: 'http', // use https in production hostname: externaHost, pathname: '/api/v1/files/**', }, ], }, } export default nextConfig ``` ```tsx // components/ExternaImage.tsx import Image, { type ImageLoaderProps } from 'next/image' /** Pass-through loader: Externa already encodes size via the preset key. */ function externaLoader({ src }: ImageLoaderProps): string { return src } type Props = { fileId: number /** Preset key from Settings → Files (e.g. card, medium, thumbnail) */ preset?: string alt: string width: number height: number className?: string /** Absolute Externa origin, no trailing slash */ apiOrigin?: string } export function ExternaImage({ fileId, preset = 'card', alt, width, height, className, apiOrigin = process.env.NEXT_PUBLIC_EXTERNA_API_URL ?? 'http://externa-core.test', }: Props) { const src = `${apiOrigin.replace(/\/$/, '')}/api/v1/files/${fileId}/transforms/${preset}` return ( {alt} ) } ``` Usage with data already fetched server-side (`?include=files`): ```tsx // Prefer the URL Externa already built on the FileResource: const hero = item.data.cover // expanded file object ; src} src={hero.transforms.card} alt={hero.title ?? hero.filename} width={hero.width ?? 512} height={hero.height ?? 512} /> ``` > **Starter scope** > Official [externa-next-starter](https://github.com/qiick-io/externa-next-starter) does not ship this component yet. Copy the snippet into your app, or open a follow-up if you want it upstream. ## See also - [Public CMS API — Files](https://docs.qiick.io/externa/public-cms-api#files) - [Project settings — Files & Media](https://docs.qiick.io/externa/project-settings#files-and-media-settings-project-files) - [File manager](https://docs.qiick.io/externa/file-manager) - [Webhook rebuild & CDN purge](https://docs.qiick.io/externa/webhook-rebuild-purge) (core [#37](https://github.com/qiick-io/externa-core/issues/37)) --- # AI chat Source: https://docs.qiick.io/externa/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](https://docs.qiick.io/externa/ai-assistant). Imports, sync, webhook: [AI imports & sync](https://docs.qiick.io/externa/ai-imports-and-sync). Env/provider keys: [AI configuration](https://docs.qiick.io/externa/ai-configuration). HTTP: [AI API & webhooks](https://docs.qiick.io/externa/ai-api). > Human-to-human threads (item + DM hub at `/chat`) are documented in [Chat (hub & items)](https://docs.qiick.io/externa/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 | | `context_item_ids` | Optional, max 10 item ids (1.4.0) | | `context_file_ids` | Optional, max 10 file ids (1.4.0) | | `context_prompt_ids` | Optional, max 5 saved prompt ids (1.4.0) | 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`. Since 1.4.0 the stream also carries the model's work, not only its text: `reasoning_start` / `reasoning_delta` / `reasoning_end`, `tool_call` and `tool_result`, `citation` (and provider tools such as `web_search_call`), plus the token usage at `stream_end`. ## What an answer shows From 1.4.0 ([core #565](https://github.com/qiick-io/externa-core/pull/565), [#566](https://github.com/qiick-io/externa-core/pull/566), [#570](https://github.com/qiick-io/externa-core/pull/570), [#572](https://github.com/qiick-io/externa-core/pull/572), [#615](https://github.com/qiick-io/externa-core/pull/615), [#619](https://github.com/qiick-io/externa-core/pull/619), [#621](https://github.com/qiick-io/externa-core/pull/621)) every Assistant answer shows how it was produced, in the order the model produced it: | Part | What you see | | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | Reasoning | **Thinking…** while the model reasons, open so you can follow it; when it finishes it closes to **Thought for N seconds** ("Thought for a few seconds" after a reload). Open or close it yourself and it stays that way. | | Tool calls | Each call as one line with a sentence ("List collections", "Searched the web") and the record it works on, never its JSON. The icon shows the state: running, done, error or denied; a failed call opens to its error. Several calls in a row are grouped under **Done in N steps**. | | Text | The answer itself, in Markdown, with tables and highlighted code blocks. | | Sources | **Used N sources**: the pages the model cited (for example from a provider web search), as links. | | Token usage | The tokens used by the turn against the provider's context window (`*_CONTEXT_WINDOW`), with input, output, reasoning and cache tokens on hover. | Reasoning appears only when the provider returns it: set the reasoning effort per provider — [AI configuration — Assistant provider, reasoning and context window](https://docs.qiick.io/externa/ai-configuration#assistant-provider-reasoning-and-context-window-1-4-0). A write tool that needs approval pauses the answer on an approval card instead of a line: the call's sentence, **Reject** and **Approve** (red only when the call deletes data). The answer offers no follow-up suggestions until you decide; once you do, the call becomes a line and the model continues — [AI tool approvals & audit](https://docs.qiick.io/externa/ai-tool-approvals). All of it is **persisted**: reload the page or open the conversation later and the reasoning, every tool call with its state or error, the sources and the usage are shown again, the same way they streamed. See [AI assistant model — Conversations](https://docs.qiick.io/externa/ai-assistant#conversations) for the stored fields. ## 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`). ## Context attachments From externa-core **1.4.0** ([#195](https://github.com/qiick-io/externa-core/issues/195)), the composer's **Attach** button opens an **Attach context** picker for collection items, files, and saved prompts. The selection is sent with the next message as `context_item_ids`, `context_file_ids`, and `context_prompt_ids`. | Piece | Detail | | ------------- | ----------------------------------------------------------------------------------------------------------------- | | Picker search | `GET /ai/context/collections`, `/ai/context/items`, `/ai/context/files` (`throttle:60,1`) | | Limits | 10 items, 10 files, 5 saved prompts per message (more → **422**) | | Items | Need `can-show-collections`, collection read and the role's item filter; field values are stripped by field rules | | Files | Need `can-show-files`; only metadata (id, name, MIME, size, type) is sent, never file contents | | Saved prompts | Their body is added as a saved-prompt block | Access is **re-checked when the message is sent**, not only when the picker lists results, so access revoked in between cannot leak into the model prompt. Any id that is missing or not readable fails the whole send with **422** (`One or more context items are not accessible.`). The resolved context is appended to the prompt as `[AI_CONTEXT_ITEMS]` / `[AI_CONTEXT_FILES]` / `[AI_SAVED_PROMPT]` blocks, and the model is told to treat them as operator-selected context. Deleting a conversation from the `/ai` row menu asks for confirmation (1.4.0, [core #522](https://github.com/qiick-io/externa-core/pull/522)). ## Saved prompts Project-level reusable prompts (table `ai_prompts`), shared by everyone with `can-use-ai`. | Method | Path | Permission | Notes | | -------- | ------------------ | ------------------- | --------------------------------------------------------------------- | | `GET` | `/ai/prompts` | `can-use-ai` | Optional `search` (name / description), max 100; returns `can_manage` | | `POST` | `/ai/prompts` | `ai.prompts.manage` | `name` (≤120), `description` (≤255), `body` (≤20000) | | `PUT` | `/ai/prompts/{id}` | `ai.prompts.manage` | Same fields | | `DELETE` | `/ai/prompts/{id}` | `ai.prompts.manage` | | `ai.prompts.manage` is new in 1.4.0. `externa:upgrade` (via `permissions:sync`) grants it to the built-in `admin` role — [Upgrade](https://docs.qiick.io/externa/upgrade#1-3-x-1-4-0). ## Write actions and approvals From 1.4.0, write and destructive tool actions are **denied by default** in chat, while read actions keep working. When an operator enables a tool for writes, each write or destructive action asks for your approval in the conversation before it runs. Details: [AI tool approvals & audit](https://docs.qiick.io/externa/ai-tool-approvals). ## 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` | | Context | `app/Services/Ai/AiConversationContextService.php`, `AiContextSearchController.php` | | Saved prompts | `app/Http/Controllers/Ai/AiPromptController.php`, `app/Models/AiPrompt.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` | --- # AI tool approvals & audit Source: https://docs.qiick.io/externa/ai-tool-approvals From externa-core **1.4.0**, the in-app assistant and the [MCP server](https://docs.qiick.io/externa/mcp-server) share one permission-aware tool registry ([#202](https://github.com/qiick-io/externa-core/issues/202)). Tools that write or delete data are **denied by default**, need a human approval when enabled, and every tool decision lands in a redacted audit log ([#203](https://github.com/qiick-io/externa-core/issues/203)). > **Related** > Agent and per-action permissions: [AI assistant model](https://docs.qiick.io/externa/ai-assistant). Chat UI: [AI chat](https://docs.qiick.io/externa/ai-chat). Env keys: [AI configuration](https://docs.qiick.io/externa/ai-configuration#tool-registry-and-write-gate). ## Tool kinds Each registered tool has a kind (`AiToolKindEnum`): | Kind | Tools | | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `read` | `DescribeFieldTypes`, `ExtractPdfText`, `GetImportJobStatus`, `QueryCollectionItems`, `ExportCollection`, `SearchSimilarCollectionItems`, `QueryActivityLogs` | | `write` | `ImportCollectionCsv`, `ImportRemoteJson` | | `destructive` | `ManageCollections`, `ManageCollectionItems`, `ManageAiSyncSources`, `ManageFiles`, `ManageUsers`, `ManageRoles`, `ManageGroups`, `RollbackLastAiTurn` | `write` and `destructive` tools are **mutating**. The `Manage*` tools mix read and write actions through their `action` argument (for example `list` / `get` vs `create` / `delete`). ## Write gate A mutating tool may run only when **both** are true: 1. **Kill switch on:** `AI_WRITE_TOOLS=true` (also accepted as `AI_TOOLS_WRITE_ENABLED=true`). Default `false`. 2. **Tool on the allowlist:** its class name is in `AI_TOOLS_WRITE_ALLOW` (CSV) **or** selected under **Settings → AI write tools** (`/settings/ai-tools`, project setting `ai.write_tools`, needs `can-manage-project-settings`). The two lists are merged. An empty allowlist denies every mutating tool. ```env AI_WRITE_TOOLS=true AI_TOOLS_WRITE_ALLOW=ManageCollectionItems,ImportCollectionCsv ``` Three more keys filter tools for every kind, before the write gate: | Env | Default | Effect | | ------------------ | ------- | --------------------------------------------------------- | | `AI_TOOLS_ENABLED` | `true` | `false` turns off every AI tool in chat and MCP. | | `AI_TOOLS_ALLOW` | empty | When set, only these tool names are available at all. | | `AI_TOOLS_DENY` | empty | These tool names are never available (wins over `ALLOW`). | The registry then checks the actor's permissions and each tool re-checks action-level permissions, as before. ## Chat: read actions keep working With the write gate closed (the default), chat still lists the `Manage*` tools as a **read-only copy**: `list`, `get`, `search` and other read actions run, while every write or destructive action is refused with an English machine message the model relays ([#439](https://github.com/qiick-io/externa-core/issues/439)). This keeps the 1.3.0 behaviour for reads. Pure `write` tools (`ImportCollectionCsv`, `ImportRemoteJson`) and `RollbackLastAiTurn` are not offered in chat until they are allowlisted. When a tool is allowlisted and the kill switch is on, its write and destructive actions **pause for the user's approval in the chat** (laravel/ai `Approvable`) before they run. Examples of write actions: `ManageCollectionItems` `create` / `update` / `publish` / `restore` / `bulk_update`; `ManageFiles` `create` / `rename` / `move` / `tag` / `save_attachment`. Destructive: `delete`, `force_delete`, `bulk_delete`. The assistant's `approvals` [Agent Skill](https://docs.qiick.io/externa/ai-assistant#agent-skills) explains pending, rejected, and abandoned approvals to the user. ## MCP: pending approvals On the MCP surface, an allowed mutating call does not run. The executor stores a `PendingToolCall` and returns `pending_approval` to the MCP client. | Item | Detail | | ------------ | ------------------------------------------------------------------------------------------------- | | Inbox | **Settings → AI tool approvals** (`GET /settings/pending-tool-calls`, permission `can-use-ai`) | | Actions | `POST /settings/pending-tool-calls/{id}/approve`, `…/reject` | | Approver | Must also pass the tool's permission check for those arguments, otherwise the approval is refused | | Expiry | `AI_TOOLS_APPROVAL_TTL` minutes (default **15**); overdue rows become `expired` and never run | | Shown fields | Tool, redacted arguments, requester, expiry | Approving runs the call once and records `executed` or `failed`. MCP write tools are listed only when `AI_MCP_WRITE_ENABLED` and the key's `mcp:write` scope allow it — see [MCP server — Write tools](https://docs.qiick.io/externa/mcp-server#write-tools). ## Audit log Every registry decision for a signed-in actor (allowed, denied, pending, executed) is written to the activity log with `log_name=ai` and `event=ai_tool`, including the tool, the decision, the status, and the surface (`chat` or `mcp`). Filter it in **Activity log** by the `ai_tool` event. Arguments are **redacted** before they are logged or shown in the approval inbox. `AI_TOOLS_REDACT_KEYS` (CSV) sets the redacted keys. Default: ```text auth_bearer,auth_header,authorization,bearer,password,token,api_key,apikey,secret,client_secret,email,phone,telephone,ssn,social_security_number,credit_card,card_number,cvv ``` ## Retention: `ai:prune-tool-audit` ```bash php artisan ai:prune-tool-audit ``` Scheduled **daily** in `routes/console.php`. It: 1. expires overdue pending approvals; 2. deletes `ai_tool` activity rows older than `AI_TOOLS_AUDIT_RETENTION_DAYS` (default **90**); 3. deletes resolved `pending_tool_calls` (rejected, expired, executed, failed) older than the same window. The scheduler must be running — see [Queues & scheduler](https://docs.qiick.io/externa/operations). ## Environment summary | Env | Default | Config | | ------------------------------- | --------- | ------------------------------- | | `AI_TOOLS_ENABLED` | `true` | `ai.tools.enabled` | | `AI_WRITE_TOOLS` | `false` | `ai.tools.write_enabled` | | `AI_TOOLS_WRITE_ENABLED` | `false` | Fallback for `AI_WRITE_TOOLS` | | `AI_TOOLS_WRITE_ALLOW` | empty | `ai.tools.write_allow` | | `AI_TOOLS_ALLOW` | empty | `ai.tools.allow` | | `AI_TOOLS_DENY` | empty | `ai.tools.deny` | | `AI_TOOLS_APPROVAL_TTL` | `15` | `ai.tools.approval_ttl_minutes` | | `AI_TOOLS_AUDIT_RETENTION_DAYS` | `90` | `ai.tools.audit_retention_days` | | `AI_TOOLS_REDACT_KEYS` | see above | `ai.tools.redact_keys` | ## Source map | Concern | Location (externa-core) | | ------------- | ------------------------------------------------------------------------------------ | | Registry | `app/Services/Ai/AiToolRegistryService.php` | | Executor | `app/Services/Ai/AiToolExecutorService.php` | | Write gate | `app/Services/Ai/AiToolWriteGateService.php` | | Approvals | `app/Services/Ai/AiToolApprovalService.php`, `app/Models/PendingToolCall.php` | | Chat approval | `app/Ai/Concerns/RequiresDestructiveApprovalTrait.php` | | Inbox UI | `PendingToolCallController`, `resources/js/pages/admin/pending-tool-calls/index.tsx` | | Settings UI | `AiToolSettingsController`, `resources/js/pages/settings/ai-tools.tsx` | | Prune command | `app/Console/Commands/Ai/PruneAiToolAuditCommand.php` | --- # Chat (hub & item threads) Source: https://docs.qiick.io/externa/chat Externa’s **team chat** is separate from [AI chat](https://docs.qiick.io/externa/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](https://docs.qiick.io/externa/reverb-and-echo). Unread vs bell: [Activity & notifications](https://docs.qiick.io/externa/activity-and-notifications). Privacy / plaintext bodies: [Threat model & hosting](https://docs.qiick.io/externa/threat-model). Upload cap: project setting `chat_max_upload_bytes` on **Settings → Files & Media** (`/settings/project/files`; null = unlimited aside from PHP). Permissions: [Effective permissions](https://docs.qiick.io/externa/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 | ### In the thread (1.4.0) The human chat is rebuilt on shadcn/ui ([core #558](https://github.com/qiick-io/externa-core/pull/558), [#575](https://github.com/qiick-io/externa-core/pull/575), [#576](https://github.com/qiick-io/externa-core/pull/576), [#578](https://github.com/qiick-io/externa-core/pull/578), [#582](https://github.com/qiick-io/externa-core/pull/582)): | Piece | Behaviour | | --------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Reactions | One button per emoji with its count, pressed when you reacted. Its tooltip lists **who reacted**. Toggle from the button, the ⋯ menu or the right-click menu. | | Message menu | A visible **⋯** button beside each bubble (on hover and keyboard focus) and the right-click menu share one list: reactions, reply, copy, pin, edit, delete. | | User hover card | Author names and `@` mentions open a card with the user's avatar, name, role and online status (on hover or keyboard focus). Mentions render as badges. | | Reply quotes | A quoted row with a left rule inside the bubble; click it to jump to the original message. Above the composer it shows what you are replying to, with Cancel. | | Pinned messages | A bar above the thread with one row per pinned message (author and text); click a row to jump to the message. | | Photos | Images fill their bubble with the caption wrapping inside it; albums use a collage. Click to open the lightbox carousel (swipe, arrow keys, Previous / Next). | | Files | Each file is an attachment card with icon, name and size; click to download, or use its menu to download, **Save to Files** or **Add to field** (item threads). | | Uploads | Pending files show a spinner and progress, an error, or done, with a remove action. | | Delete | Deleting a message or a direct chat asks for confirmation first ([core #522](https://github.com/qiick-io/externa-core/pull/522)). | Day separators and the typing indicator are inline markers; reactions under the last message stay visible. ### 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](https://docs.qiick.io/externa/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](https://docs.qiick.io/externa/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](https://docs.qiick.io/externa/activity-and-notifications) · [Threat model](https://docs.qiick.io/externa/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.qiick.io/externa/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](https://docs.qiick.io/externa/ai-chat). Tool registration gates: [AI assistant model](https://docs.qiick.io/externa/ai-assistant). Env keys: [AI configuration](https://docs.qiick.io/externa/ai-configuration). Routes: [AI API & webhooks](https://docs.qiick.io/externa/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](https://docs.qiick.io/externa/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); fail-closed every env in **1.3.0**, [#185](https://github.com/qiick-io/externa-core/issues/185) / [#207](https://github.com/qiick-io/externa-core/issues/207)): signature verified **before** validation. Header **`X-AI-Webhook-Signature`** = `sha256=` + HMAC-SHA256(**raw body only**, `AI_WEBHOOK_TOKEN`, plus grace previous token when set). Empty configured token → **401** in **every** environment (no local unsigned bypass); mismatch → **401**. Bearer alone rejected. Rotation: [AI API — Rotate](https://docs.qiick.io/externa/ai-api#rotate-the-webhook-token). 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](https://docs.qiick.io/externa/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.qiick.io/externa/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](https://docs.qiick.io/externa/ai-imports-and-sync). Host allowlist: [AI configuration](https://docs.qiick.io/externa/ai-configuration) / [Environment variables](https://docs.qiick.io/externa/environment-variables). Webhook alternative: [AI API](https://docs.qiick.io/externa/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](https://docs.qiick.io/externa/webhooks) + CI when the goal is rebuild/purge, not content migration. --- # Activity & notifications Source: https://docs.qiick.io/externa/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](https://docs.qiick.io/externa/threat-model). Team chat events: [Chat (hub & items)](https://docs.qiick.io/externa/chat). Dashboard widgets that read activity: [Dashboard](https://docs.qiick.io/externa/dashboard). File jobs that emit notifications: [File manager](https://docs.qiick.io/externa/file-manager). Realtime bell / presence: [Reverb & Echo](https://docs.qiick.io/externa/reverb-and-echo). AI logging helpers: [AI assistant model](https://docs.qiick.io/externa/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](https://docs.qiick.io/externa/collection-items#activity-strip) (last update + revisions/activity links). Field-level history remains in [Item History](https://docs.qiick.io/externa/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](https://docs.qiick.io/externa/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` | The list is a server-driven data table like the other admin lists — [Users & groups — Admin lists](https://docs.qiick.io/externa/users-and-groups#admin-lists-1-4-0). 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](https://docs.qiick.io/externa/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.qiick.io/externa/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](https://docs.qiick.io/externa/project-settings). > **Related** > Auth stack & Fortify features: [Effective permissions](https://docs.qiick.io/externa/permissions) and [Architecture](https://docs.qiick.io/externa/architecture). Passkeys (register, sign-in, MFA): [Passkeys](https://docs.qiick.io/externa/passkeys). Soft-delete of the account uses the same `User` model as [Users & groups](https://docs.qiick.io/externa/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`, `/settings/appearance/sidebar`) require `can-manage-project-settings` — documented under [Project settings](https://docs.qiick.io/externa/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](https://docs.qiick.io/externa/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`). From externa-core **1.4.0**, server-side flash and validation messages follow the same locale (`lang/it.json`, `lang/de.json`; [#282](https://github.com/qiick-io/externa-core/issues/282)), and dates, numbers, speech input, and `` follow it too. It does **not** change project **content locales**. File Manager copy lives under the `files.*` namespace — see [File manager](https://docs.qiick.io/externa/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](https://docs.qiick.io/externa/passkeys) (secure context / HTTP vs HTTPS, register, sign-in, [confirm password with passkey](https://docs.qiick.io/externa/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](https://docs.qiick.io/externa/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](https://docs.qiick.io/externa/project-settings#require-two-factor-authentication) and the [decision table](https://docs.qiick.io/externa/passkeys#relationship-with-2-fa-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](https://docs.qiick.io/externa/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](https://docs.qiick.io/externa/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](https://docs.qiick.io/externa/project-settings) | --- # Passkeys Source: https://docs.qiick.io/externa/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](https://docs.qiick.io/externa/account-settings#password-2-fa-and-passkeys-fortify). Re-auth before Security: [Confirm password with a passkey](#confirm-password-with-a-passkey). Project MFA gate: [Project settings — Require 2FA](https://docs.qiick.io/externa/project-settings#require-two-factor-authentication). Pre-ship gates: [Security checklist](https://docs.qiick.io/externa/security-checklist). Fortify config: [Application config](https://docs.qiick.io/externa/configuration#fortify-config-fortify-php). Env: [Environment variables — `APP_URL`](https://docs.qiick.io/externa/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](https://docs.qiick.io/externa/account-settings#password-2-fa-and-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](https://docs.qiick.io/externa/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](https://docs.qiick.io/externa/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.qiick.io/externa/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](https://docs.qiick.io/externa/project-settings). Local auth stack: [Passkeys](https://docs.qiick.io/externa/passkeys) · [Account settings](https://docs.qiick.io/externa/account-settings). Env / `APP_URL`: [Environment variables](https://docs.qiick.io/externa/environment-variables). Pre-ship gates: [Security checklist](https://docs.qiick.io/externa/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 → Auth & Security** (`/settings/project/security`) → **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. Keys and routes: [Project settings — Auth & Security](https://docs.qiick.io/externa/project-settings#auth-and-security-settings-project-security). | 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.qiick.io/externa/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** across the admin. From externa-core **1.4.0**, CI enforces it with an axe browser smoke suite, `eslint-plugin-jsx-a11y`, and a theme-token contrast check ([#252](https://github.com/qiick-io/externa-core/issues/252)–[#255](https://github.com/qiick-io/externa-core/issues/255)). > **Related** > Personal theme (light/dark): [Account settings](https://docs.qiick.io/externa/account-settings). Shell layout: [Architecture](https://docs.qiick.io/externa/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) The sidebar (desktop and mobile sheet) and the header variant render a real `