v0.0.40

2 July 2026

Permission system, Page::new() for programmatic page creation, Field::validate(), weighted and fuzzy FTS5 search, and fieldtype search metadata.

Added

  • Permission class : Permission::forRole(string $role, App $app) loads the role's index.json (site overrides phlat). Permission::check(string $key) resolves dot-notation keys with * wildcard support across the permissions tree.
  • User::can(string $key) : delegates to Permission::forRole($this->role, $this->app)->check($key).
  • Session::can(string $key) : checks the logged-in user's permissions ($this->user()?->can($key) ?? false).
  • Session::requirePermission(string $key) : redirects to login if not logged in, throws ForbiddenException if the user lacks the given permission.
  • Session::popIntended() : pops the _intended flash key written by requireLogin(), so login handlers can redirect back to the original destination. Proxied on NamespacedSession.
  • permission(string $key) global helper : shorthand for app()->session->can($key).
  • Page::new(string $name, array $data = []) : creates a child page: slugifies $name, mints a UUID, writes index.json, then calls save($data) via the normal structural/content split. Throws ValidationException for an empty slug, ConflictException if the slug is already taken.
  • Page::has(string $slug) : returns true if a child page at that slug already exists on disk.
  • Field::validate(mixed $rawValue) : runs the inner fieldtype's validate() then enforces required, min_length, and max_length from the field's own options, returning a flat error array.
  • App::fieldtypeMeta(string $type) : loads and memoises a fieldtype's index.json (site overrides phlat), exposing title, description, and search defaults for use by FieldIndex.
  • Registry schema 7: search_index FTS5 columns split into content_high, content_normal, content_low; BM25 weights are 3.0 / 1.0 / 0.5. A companion search_index_vocab FTS5 vocab table enables fuzzy expansion. fuzzy=true (or fuzzy=1) alongside search= widens each token to nearby indexed terms by edit distance (max 1 for tokens under 6 chars, 2 for longer; capped at 5 candidates per token). Each fieldtype's index.json may declare search.weight and search.enabled defaults; individual field options can override these.
  • Str::distance(string $a, string $b) : Levenshtein edit distance.

Changed

  • Page::save(), Field::save(), Record::save() : $data parameter defaults to []; callers that just want to trigger the save lifecycle (e.g. bust caches) no longer need to pass an empty array explicitly.
  • Record::identity() : throws \RuntimeException on a missing uuid instead of auto-generating one. Creation call sites (Page::new(), user creation) are now responsible for minting the uuid and writing it to index.json before the record is constructed.
  • App::page('') : throws \InvalidArgumentException immediately; use '/' for the root page.
  • FieldIndex::index() routes each field's text into the appropriate content_high/normal/low tier and skips fields with search.enabled = false from search_index entirely.
  • phlat/Caddyfile.classic and phlat/Caddyfile.worker merged into a single phlat/Caddyfile.